diff --git a/apps/api/drizzle/0001_broken_microchip.sql b/apps/api/drizzle/0001_broken_microchip.sql new file mode 100644 index 0000000..e1bc4d0 --- /dev/null +++ b/apps/api/drizzle/0001_broken_microchip.sql @@ -0,0 +1,78 @@ +CREATE TYPE "public"."import_job_status" AS ENUM('queued', 'running', 'succeeded', 'succeeded_with_issues', 'failed', 'cancelled', 'stale');--> statement-breakpoint +CREATE TYPE "public"."source_connection_kind" AS ENUM('wxr');--> statement-breakpoint +CREATE TYPE "public"."source_snapshot_kind" AS ENUM('wxr', 'sitemap', 'media_bundle', 'manual_url_list');--> statement-breakpoint +CREATE TABLE "import_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "source_snapshot_id" uuid NOT NULL, + "status" "import_job_status" NOT NULL, + "mapping_version" text NOT NULL, + "idempotency_key" text NOT NULL, + "attempt" integer DEFAULT 0 NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "error_code" text, + "error_summary" text, + "report_path" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "import_jobs_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +CREATE TABLE "source_connections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "kind" "source_connection_kind" NOT NULL, + "name" text NOT NULL, + "configuration" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "source_records" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "source_connection_id" uuid NOT NULL, + "source_snapshot_id" uuid NOT NULL, + "import_job_id" uuid NOT NULL, + "source_type" text NOT NULL, + "source_id" text NOT NULL, + "locale" text NOT NULL, + "source_url" text, + "raw_location" text NOT NULL, + "source_checksum" text, + "normalized_checksum" text, + "parse_status" text NOT NULL, + "content_entry_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "source_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "source_connection_id" uuid NOT NULL, + "kind" "source_snapshot_kind" NOT NULL, + "storage_path" text NOT NULL, + "sha256" text NOT NULL, + "byte_size" bigint NOT NULL, + "adapter_version" text NOT NULL, + "captured_at" timestamp with time zone NOT NULL, + "metadata" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "import_jobs" ADD CONSTRAINT "import_jobs_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "import_jobs" ADD CONSTRAINT "import_jobs_source_snapshot_id_source_snapshots_id_fk" FOREIGN KEY ("source_snapshot_id") REFERENCES "public"."source_snapshots"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_connections" ADD CONSTRAINT "source_connections_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_records" ADD CONSTRAINT "source_records_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_records" ADD CONSTRAINT "source_records_source_connection_id_source_connections_id_fk" FOREIGN KEY ("source_connection_id") REFERENCES "public"."source_connections"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_records" ADD CONSTRAINT "source_records_source_snapshot_id_source_snapshots_id_fk" FOREIGN KEY ("source_snapshot_id") REFERENCES "public"."source_snapshots"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_records" ADD CONSTRAINT "source_records_import_job_id_import_jobs_id_fk" FOREIGN KEY ("import_job_id") REFERENCES "public"."import_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_snapshots" ADD CONSTRAINT "source_snapshots_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_snapshots" ADD CONSTRAINT "source_snapshots_source_connection_id_source_connections_id_fk" FOREIGN KEY ("source_connection_id") REFERENCES "public"."source_connections"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "source_records_identity_uq" ON "source_records" USING btree ("site_id","source_connection_id","source_type","source_id","locale","source_snapshot_id");--> statement-breakpoint +CREATE UNIQUE INDEX "source_snapshots_uq" ON "source_snapshots" USING btree ("site_id","kind","sha256");--> statement-breakpoint +-- ↓ 手写追加(drizzle-kit 不生成触发器):快照不可变。sf_forbid_update 为共享函数, +-- 后续 content_revisions / audit_logs / build_input_snapshots 三张不可变表复用(Task 8/12)。 +CREATE OR REPLACE FUNCTION sf_forbid_update() RETURNS trigger AS $$ +BEGIN RAISE EXCEPTION 'immutable table %: UPDATE forbidden', TG_TABLE_NAME; END $$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER source_snapshots_immutable BEFORE UPDATE OR DELETE ON source_snapshots + FOR EACH ROW EXECUTE FUNCTION sf_forbid_update(); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..90939a4 --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,740 @@ +{ + "id": "6b9e4c4b-1851-48c7-b5c7-df804ef5477a", + "prevId": "b744b7d5-e0a8-4940-ab52-2c0b46abbb50", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.import_jobs": { + "name": "import_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "import_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "mapping_version": { + "name": "mapping_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_path": { + "name": "report_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "import_jobs_site_id_sites_id_fk": { + "name": "import_jobs_site_id_sites_id_fk", + "tableFrom": "import_jobs", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "import_jobs_source_snapshot_id_source_snapshots_id_fk": { + "name": "import_jobs_source_snapshot_id_source_snapshots_id_fk", + "tableFrom": "import_jobs", + "tableTo": "source_snapshots", + "columnsFrom": [ + "source_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "import_jobs_idempotency_key_unique": { + "name": "import_jobs_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config_revisions": { + "name": "site_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "site_config_rev_uq": { + "name": "site_config_rev_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "site_config_revisions_site_id_sites_id_fk": { + "name": "site_config_revisions_site_id_sites_id_fk", + "tableFrom": "site_config_revisions", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "production_origin": { + "name": "production_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sites_key_unique": { + "name": "sites_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_connections": { + "name": "source_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "source_connection_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "source_connections_site_id_sites_id_fk": { + "name": "source_connections_site_id_sites_id_fk", + "tableFrom": "source_connections", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_records": { + "name": "source_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_connection_id": { + "name": "source_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "import_job_id": { + "name": "import_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_location": { + "name": "raw_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_checksum": { + "name": "source_checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_checksum": { + "name": "normalized_checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_entry_id": { + "name": "content_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_records_identity_uq": { + "name": "source_records_identity_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locale", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_records_site_id_sites_id_fk": { + "name": "source_records_site_id_sites_id_fk", + "tableFrom": "source_records", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_records_source_connection_id_source_connections_id_fk": { + "name": "source_records_source_connection_id_source_connections_id_fk", + "tableFrom": "source_records", + "tableTo": "source_connections", + "columnsFrom": [ + "source_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_records_source_snapshot_id_source_snapshots_id_fk": { + "name": "source_records_source_snapshot_id_source_snapshots_id_fk", + "tableFrom": "source_records", + "tableTo": "source_snapshots", + "columnsFrom": [ + "source_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_records_import_job_id_import_jobs_id_fk": { + "name": "source_records_import_job_id_import_jobs_id_fk", + "tableFrom": "source_records", + "tableTo": "import_jobs", + "columnsFrom": [ + "import_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_snapshots": { + "name": "source_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_connection_id": { + "name": "source_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "source_snapshot_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "adapter_version": { + "name": "adapter_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_snapshots_uq": { + "name": "source_snapshots_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_snapshots_site_id_sites_id_fk": { + "name": "source_snapshots_site_id_sites_id_fk", + "tableFrom": "source_snapshots", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_snapshots_source_connection_id_source_connections_id_fk": { + "name": "source_snapshots_source_connection_id_source_connections_id_fk", + "tableFrom": "source_snapshots", + "tableTo": "source_connections", + "columnsFrom": [ + "source_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.import_job_status": { + "name": "import_job_status", + "schema": "public", + "values": [ + "queued", + "running", + "succeeded", + "succeeded_with_issues", + "failed", + "cancelled", + "stale" + ] + }, + "public.source_connection_kind": { + "name": "source_connection_kind", + "schema": "public", + "values": [ + "wxr" + ] + }, + "public.source_snapshot_kind": { + "name": "source_snapshot_kind", + "schema": "public", + "values": [ + "wxr", + "sitemap", + "media_bundle", + "manual_url_list" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 607c215..202b71d 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1792494963444, "tag": "0000_pale_sumo", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1792495504658, + "tag": "0001_broken_microchip", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index e811cae..ffa308e 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,5 +1,5 @@ -// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo) -import { pgTable, uuid, text, integer, jsonb, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' +// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*) +import { pgTable, pgEnum, uuid, text, integer, bigint, jsonb, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' export const sites = pgTable('sites', { id: uuid('id').primaryKey().defaultRandom(), @@ -19,3 +19,72 @@ export const siteConfigRevisions = pgTable('site_config_revisions', { checksum: text('checksum').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [uniqueIndex('site_config_rev_uq').on(t.siteId, t.version)]) + +// ---- 来源域(spec §5.2;迁移文件 0001_*,末尾手写追加快照不可变触发器)---- + +export const sourceConnectionKind = pgEnum('source_connection_kind', ['wxr']) +export const sourceSnapshotKind = pgEnum('source_snapshot_kind', ['wxr', 'sitemap', 'media_bundle', 'manual_url_list']) +export const importJobStatus = pgEnum('import_job_status', [ + 'queued', 'running', 'succeeded', 'succeeded_with_issues', 'failed', 'cancelled', 'stale', +]) + +// Connection 只描述来源,不保存某次文件路径和 Hash +export const sourceConnections = pgTable('source_connections', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').notNull().references(() => sites.id), + kind: sourceConnectionKind('kind').notNull(), + name: text('name').notNull(), + configuration: jsonb('configuration').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +// Snapshot 创建后不可变(库内触发器强制,见 0001 迁移手写 SQL);替换文件必须创建新 Snapshot +export const sourceSnapshots = pgTable('source_snapshots', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').notNull().references(() => sites.id), + sourceConnectionId: uuid('source_connection_id').notNull().references(() => sourceConnections.id), + kind: sourceSnapshotKind('kind').notNull(), + storagePath: text('storage_path').notNull(), + sha256: text('sha256').notNull(), + byteSize: bigint('byte_size', { mode: 'number' }).notNull(), + adapterVersion: text('adapter_version').notNull(), + capturedAt: timestamp('captured_at', { withTimezone: true }).notNull(), + metadata: jsonb('metadata').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (t) => [uniqueIndex('source_snapshots_uq').on(t.siteId, t.kind, t.sha256)]) + +export const importJobs = pgTable('import_jobs', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').notNull().references(() => sites.id), + sourceSnapshotId: uuid('source_snapshot_id').notNull().references(() => sourceSnapshots.id), + status: importJobStatus('status').notNull(), + mappingVersion: text('mapping_version').notNull(), + idempotencyKey: text('idempotency_key').notNull().unique(), + attempt: integer('attempt').notNull().default(0), + startedAt: timestamp('started_at', { withTimezone: true }), + finishedAt: timestamp('finished_at', { withTimezone: true }), + errorCode: text('error_code'), + errorSummary: text('error_summary'), + reportPath: text('report_path'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const sourceRecords = pgTable('source_records', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').notNull().references(() => sites.id), + sourceConnectionId: uuid('source_connection_id').notNull().references(() => sourceConnections.id), + sourceSnapshotId: uuid('source_snapshot_id').notNull().references(() => sourceSnapshots.id), + importJobId: uuid('import_job_id').notNull().references(() => importJobs.id), + sourceType: text('source_type').notNull(), + sourceId: text('source_id').notNull(), + locale: text('locale').notNull(), + sourceUrl: text('source_url'), + rawLocation: text('raw_location').notNull(), + sourceChecksum: text('source_checksum'), + normalizedChecksum: text('normalized_checksum'), + parseStatus: text('parse_status').notNull(), + // 暂无 FK:content_entries 于 Task 8(内容域)落地后再补引用 + contentEntryId: uuid('content_entry_id'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + // 跨 Snapshot 稳定映射键 = site_id + source_connection_id + source_type + source_id + locale(snapshot_id 进键以允许跨快照再导入) +}, (t) => [uniqueIndex('source_records_identity_uq').on(t.siteId, t.sourceConnectionId, t.sourceType, t.sourceId, t.locale, t.sourceSnapshotId)]) diff --git a/tests/integration/db.source.test.ts b/tests/integration/db.source.test.ts new file mode 100644 index 0000000..324eb2f --- /dev/null +++ b/tests/integration/db.source.test.ts @@ -0,0 +1,106 @@ +// 来源域集成测试:快照不可变触发器(UPDATE/DELETE 拒绝)+ 三处唯一约束在真 PG 上生效。 +// 生命周期纪律:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据。 +// 注意:source_snapshots 不可变触发器连 DELETE 也拒——清理时先 DISABLE 该触发器再删行后 ENABLE +// (不可变表的生产语义是永不删;测试清场是唯一例外,走显式 DDL 开关且删除限定本站点行)。 +import { describe, it, expect } from 'vitest' +import { getDb } from '../../apps/api/src/db/client.js' + +type Db = ReturnType +type Fixture = { siteId: string; connectionId: string; snapshotId: string; sha256: string; idempotencyKey: string } + +// 建链(FK 依赖顺序):site → source_connection → source_snapshot → import_job +async function setupSourceFixture(db: Db): Promise { + const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) + values (gen_random_uuid(), 'src-${rand}', 'SrcFixture', 'en', 'https://src.example') returning id`)) as any + const siteId = site[0].id + const conn = (await db.execute(`insert into source_connections (id, site_id, kind, name, configuration) + values (gen_random_uuid(), '${siteId}', 'wxr', 'conn-${rand}', '{}') returning id`)) as any + const connectionId = conn[0].id + const snap = (await db.execute(`insert into source_snapshots (id, site_id, source_connection_id, kind, storage_path, sha256, byte_size, adapter_version, captured_at, metadata) + values (gen_random_uuid(), '${siteId}', '${connectionId}', 'wxr', 'wxr://${rand}.xml', 'sha256-${rand}', 123, 'wxr@1', now(), '{}') returning id, sha256`)) as any + const job = (await db.execute(`insert into import_jobs (id, site_id, source_snapshot_id, status, mapping_version, idempotency_key, attempt) + values (gen_random_uuid(), '${siteId}', '${snap[0].id}', 'queued', 'mapping@1', 'idem-${rand}', 0) returning idempotency_key`)) as any + return { siteId, connectionId, snapshotId: snap[0].id, sha256: snap[0].sha256, idempotencyKey: job[0].idempotency_key } +} + +// 反序清理:子表常规删除;快照表临时 DISABLE 不可变触发器;最后删 site。 +async function cleanupSourceFixture(db: Db, f: Fixture) { + await db.execute(`delete from source_records where site_id = '${f.siteId}'`) + await db.execute(`delete from import_jobs where site_id = '${f.siteId}'`) + await db.execute(`alter table source_snapshots disable trigger source_snapshots_immutable`) + try { + await db.execute(`delete from source_snapshots where site_id = '${f.siteId}'`) + } finally { + await db.execute(`alter table source_snapshots enable trigger source_snapshots_immutable`) + } + await db.execute(`delete from source_connections where site_id = '${f.siteId}'`) + await db.execute(`delete from sites where id = '${f.siteId}'`) +} + +async function withSourceFixture(fn: (db: Db, f: Fixture) => Promise) { + const db = getDb() + const f = await setupSourceFixture(db) + try { + await fn(db, f) + } finally { + await cleanupSourceFixture(db, f) + await (db as any).$client.end() + } +} + +// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."), +// 服务端原始错误文本在 cause 里——断言触发器/约束身份时必须看 cause。 +async function rejectionMessage(db: Db, query: string): Promise { + try { + await db.execute(query) + } catch (e: any) { + return String(e?.cause?.message ?? e?.message ?? e) + } + throw new Error(`expected query to be rejected: ${query}`) +} + +describe('来源域约束', () => { + it('source_snapshots 不可变:UPDATE 与 DELETE 均被触发器拒绝', async () => { + await withSourceFixture(async (db, f) => { + expect(await rejectionMessage(db, `update source_snapshots set sha256 = 'tampered' where id = '${f.snapshotId}'`)) + .toMatch(/immutable table source_snapshots/) + expect(await rejectionMessage(db, `delete from source_snapshots where id = '${f.snapshotId}'`)) + .toMatch(/immutable table source_snapshots/) + }) + }) + + it('source_snapshots UNIQUE(site_id, kind, sha256):同三元组重复插入拒绝,不同 sha256 放行', async () => { + await withSourceFixture(async (db, f) => { + await expect(db.execute(`insert into source_snapshots (id, site_id, source_connection_id, kind, storage_path, sha256, byte_size, adapter_version, captured_at, metadata) + values (gen_random_uuid(), '${f.siteId}', '${f.connectionId}', 'wxr', 'wxr://dup.xml', '${f.sha256}', 456, 'wxr@1', now(), '{}')`)) + .rejects.toThrow() + // 对照组:同 site+kind 但 sha256 不同 → 允许(证明约束打在三元组上,由 finally 按 site 清理) + await db.execute(`insert into source_snapshots (id, site_id, source_connection_id, kind, storage_path, sha256, byte_size, adapter_version, captured_at, metadata) + values (gen_random_uuid(), '${f.siteId}', '${f.connectionId}', 'wxr', 'wxr://other.xml', 'sha256-other-${f.sha256}', 789, 'wxr@1', now(), '{}')`) + }) + }) + + it('source_records 稳定身份键 UNIQUE(site_id, source_connection_id, source_type, source_id, locale, source_snapshot_id):同键重复拒绝,换 snapshot 放行', async () => { + await withSourceFixture(async (db, f) => { + const insertRecord = (snapshotId: string, rawLocation: string) => + db.execute(`insert into source_records (id, site_id, source_connection_id, source_snapshot_id, import_job_id, source_type, source_id, locale, raw_location, parse_status) + values (gen_random_uuid(), '${f.siteId}', '${f.connectionId}', '${snapshotId}', (select id from import_jobs where site_id = '${f.siteId}' limit 1), 'post', 'source-1', 'en', '${rawLocation}', 'parsed')`) + await insertRecord(f.snapshotId, 'raw://1') + // 同稳定身份键 + 同 snapshot:第二次插入拒绝 + await expect(insertRecord(f.snapshotId, 'raw://1-dup')).rejects.toThrow() + // 对照组:同稳定身份键、不同 snapshot(跨快照再导入同一来源项)→ 允许,由 finally 按 site 清理 + const snap2 = (await db.execute(`insert into source_snapshots (id, site_id, source_connection_id, kind, storage_path, sha256, byte_size, adapter_version, captured_at, metadata) + values (gen_random_uuid(), '${f.siteId}', '${f.connectionId}', 'wxr', 'wxr://v2.xml', 'sha256-v2-${f.sha256}', 999, 'wxr@1', now(), '{}') returning id`)) as any + await insertRecord(snap2[0].id, 'raw://1-v2') + }) + }) + + it('import_jobs.idempotency_key 唯一:重复拒绝', async () => { + await withSourceFixture(async (db, f) => { + await expect(db.execute(`insert into import_jobs (id, site_id, source_snapshot_id, status, mapping_version, idempotency_key, attempt) + values (gen_random_uuid(), '${f.siteId}', '${f.snapshotId}', 'queued', 'mapping@1', '${f.idempotencyKey}', 0)`)) + .rejects.toThrow() + }) + }) +})