feat(db): 0001 来源域——connection/snapshot/job/record + 快照不可变触发器
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1792494963444,
|
||||
"tag": "0000_pale_sumo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1792495504658,
|
||||
"tag": "0001_broken_microchip",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)])
|
||||
|
||||
@@ -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<typeof getDb>
|
||||
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<Fixture> {
|
||||
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<void>) {
|
||||
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<string> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user