From e905cba729d41b80dc92312bfb0b6ceec09d7d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 20 Oct 2026 20:24:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(db):=200006=20=E6=9E=84=E5=BB=BA/=E9=AA=8C?= =?UTF-8?q?=E6=94=B6/=E5=AE=A1=E8=AE=A1=E5=9F=9F=20+=20=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E5=BC=8F=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- apps/api/drizzle/0006_confused_lord_hawal.sql | 107 + apps/api/drizzle/meta/0006_snapshot.json | 2592 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 105 +- tests/integration/db.jobs.test.ts | 185 ++ 5 files changed, 2995 insertions(+), 1 deletion(-) create mode 100644 apps/api/drizzle/0006_confused_lord_hawal.sql create mode 100644 apps/api/drizzle/meta/0006_snapshot.json create mode 100644 tests/integration/db.jobs.test.ts diff --git a/apps/api/drizzle/0006_confused_lord_hawal.sql b/apps/api/drizzle/0006_confused_lord_hawal.sql new file mode 100644 index 0000000..5608a11 --- /dev/null +++ b/apps/api/drizzle/0006_confused_lord_hawal.sql @@ -0,0 +1,107 @@ +CREATE TYPE "public"."audit_actor_type" AS ENUM('system', 'cli_user');--> statement-breakpoint +CREATE TYPE "public"."build_artifact_kind" AS ENUM('dist', 'manifest', 'report', 'log');--> statement-breakpoint +CREATE TYPE "public"."build_job_status" AS ENUM('queued', 'snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale');--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid, + "actor_type" "audit_actor_type" NOT NULL, + "actor_id" text, + "actor_label" text NOT NULL, + "command_id" text, + "action" text NOT NULL, + "object_type" text NOT NULL, + "object_id" text NOT NULL, + "before_value" jsonb, + "after_value" jsonb, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "build_artifacts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "build_job_id" uuid NOT NULL, + "kind" "build_artifact_kind" NOT NULL, + "storage_path" text NOT NULL, + "sha256" text NOT NULL, + "byte_size" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "build_input_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "build_job_id" uuid NOT NULL, + "site_id" uuid NOT NULL, + "content_snapshot_hash" text NOT NULL, + "route_snapshot_hash" text NOT NULL, + "asset_manifest_hash" text NOT NULL, + "site_config_revision_id" uuid, + "theme_key" text NOT NULL, + "theme_version" text NOT NULL, + "theme_source_hash" text NOT NULL, + "dependency_lock_hash" text NOT NULL, + "source_commit" text NOT NULL, + "node_version" text NOT NULL, + "pnpm_version" text NOT NULL, + "astro_version" text NOT NULL, + "locale" text NOT NULL, + "timezone" text NOT NULL, + "build_timestamp" timestamp with time zone NOT NULL, + "manifest_path" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "build_input_snapshots_build_job_id_unique" UNIQUE("build_job_id") +); +--> statement-breakpoint +CREATE TABLE "build_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "status" "build_job_status" NOT NULL, + "idempotency_key" text NOT NULL, + "attempt" integer DEFAULT 0 NOT NULL, + "worker_id" text, + "started_at" timestamp with time zone, + "heartbeat_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "error_code" text, + "error_summary" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "build_jobs_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +CREATE TABLE "validation_issues" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "validation_run_id" uuid NOT NULL, + "severity" text NOT NULL, + "rule" text NOT NULL, + "url" text, + "evidence" jsonb NOT NULL, + "resolution_status" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "validation_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "build_job_id" uuid, + "rule_version" text NOT NULL, + "status" text NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_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 "build_artifacts" ADD CONSTRAINT "build_artifacts_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_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 "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_site_config_revision_id_site_config_revisions_id_fk" FOREIGN KEY ("site_config_revision_id") REFERENCES "public"."site_config_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "build_jobs" ADD CONSTRAINT "build_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 "validation_issues" ADD CONSTRAINT "validation_issues_validation_run_id_validation_runs_id_fk" FOREIGN KEY ("validation_run_id") REFERENCES "public"."validation_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "validation_runs" ADD CONSTRAINT "validation_runs_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 "validation_runs" ADD CONSTRAINT "validation_runs_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "build_artifacts_uq" ON "build_artifacts" USING btree ("build_job_id","kind","storage_path");--> statement-breakpoint +-- ↓ 手写追加(drizzle-kit 不生成触发器):输入快照不可变 + 审计日志追加式(禁改禁删)。 +-- sf_forbid_update 为 0001 建立的共享函数,此处复用(其报错文案 "immutable table %: UPDATE forbidden" +-- 同样覆盖 DELETE——触发器挂在 BEFORE UPDATE OR DELETE 上,两条路径走同一函数)。 +CREATE TRIGGER build_input_snapshots_immutable BEFORE UPDATE OR DELETE ON build_input_snapshots + FOR EACH ROW EXECUTE FUNCTION sf_forbid_update();--> statement-breakpoint +CREATE TRIGGER audit_logs_immutable BEFORE UPDATE OR DELETE ON audit_logs + FOR EACH ROW EXECUTE FUNCTION sf_forbid_update(); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0006_snapshot.json b/apps/api/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..676487e --- /dev/null +++ b/apps/api/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2592 @@ +{ + "id": "101bfe21-14ee-4bfc-829c-ef4666561df1", + "prevId": "09105afe-7f2e-4940-bfe2-57a73134abb9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "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": false + }, + "actor_type": { + "name": "actor_type", + "type": "audit_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command_id": { + "name": "command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_type": { + "name": "object_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_site_id_sites_id_fk": { + "name": "audit_logs_site_id_sites_id_fk", + "tableFrom": "audit_logs", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_artifacts": { + "name": "build_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "build_job_id": { + "name": "build_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "build_artifact_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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "build_artifacts_uq": { + "name": "build_artifacts_uq", + "columns": [ + { + "expression": "build_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "storage_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "build_artifacts_build_job_id_build_jobs_id_fk": { + "name": "build_artifacts_build_job_id_build_jobs_id_fk", + "tableFrom": "build_artifacts", + "tableTo": "build_jobs", + "columnsFrom": [ + "build_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_input_snapshots": { + "name": "build_input_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "build_job_id": { + "name": "build_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_snapshot_hash": { + "name": "content_snapshot_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_snapshot_hash": { + "name": "route_snapshot_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_manifest_hash": { + "name": "asset_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_config_revision_id": { + "name": "site_config_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "theme_key": { + "name": "theme_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme_version": { + "name": "theme_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme_source_hash": { + "name": "theme_source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dependency_lock_hash": { + "name": "dependency_lock_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_commit": { + "name": "source_commit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_version": { + "name": "node_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pnpm_version": { + "name": "pnpm_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "astro_version": { + "name": "astro_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "build_timestamp": { + "name": "build_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "manifest_path": { + "name": "manifest_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "build_input_snapshots_build_job_id_build_jobs_id_fk": { + "name": "build_input_snapshots_build_job_id_build_jobs_id_fk", + "tableFrom": "build_input_snapshots", + "tableTo": "build_jobs", + "columnsFrom": [ + "build_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "build_input_snapshots_site_id_sites_id_fk": { + "name": "build_input_snapshots_site_id_sites_id_fk", + "tableFrom": "build_input_snapshots", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "build_input_snapshots_site_config_revision_id_site_config_revisions_id_fk": { + "name": "build_input_snapshots_site_config_revision_id_site_config_revisions_id_fk", + "tableFrom": "build_input_snapshots", + "tableTo": "site_config_revisions", + "columnsFrom": [ + "site_config_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "build_input_snapshots_build_job_id_unique": { + "name": "build_input_snapshots_build_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "build_job_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_jobs": { + "name": "build_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 + }, + "status": { + "name": "status", + "type": "build_job_status", + "typeSchema": "public", + "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 + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "build_jobs_site_id_sites_id_fk": { + "name": "build_jobs_site_id_sites_id_fk", + "tableFrom": "build_jobs", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "build_jobs_idempotency_key_unique": { + "name": "build_jobs_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_entries": { + "name": "content_entries", + "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 + }, + "type_key": { + "name": "type_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle_status": { + "name": "lifecycle_status", + "type": "content_lifecycle_status", + "typeSchema": "public", + "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": { + "content_entries_site_type_idx": { + "name": "content_entries_site_type_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_entries_site_id_sites_id_fk": { + "name": "content_entries_site_id_sites_id_fk", + "tableFrom": "content_entries", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_revisions": { + "name": "content_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 + }, + "content_entry_id": { + "name": "content_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_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 + }, + "source_kind": { + "name": "source_kind", + "type": "revision_source_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_record_id": { + "name": "source_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "import_job_id": { + "name": "import_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_revisions_site_id_sites_id_fk": { + "name": "content_revisions_site_id_sites_id_fk", + "tableFrom": "content_revisions", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_revisions_content_entry_id_content_entries_id_fk": { + "name": "content_revisions_content_entry_id_content_entries_id_fk", + "tableFrom": "content_revisions", + "tableTo": "content_entries", + "columnsFrom": [ + "content_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_revisions_import_job_id_import_jobs_id_fk": { + "name": "content_revisions_import_job_id_import_jobs_id_fk", + "tableFrom": "content_revisions", + "tableTo": "import_jobs", + "columnsFrom": [ + "import_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_terms": { + "name": "content_terms", + "schema": "", + "columns": { + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_entry_id": { + "name": "content_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "term_id": { + "name": "term_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "content_terms_site_id_sites_id_fk": { + "name": "content_terms_site_id_sites_id_fk", + "tableFrom": "content_terms", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_terms_content_entry_id_content_entries_id_fk": { + "name": "content_terms_content_entry_id_content_entries_id_fk", + "tableFrom": "content_terms", + "tableTo": "content_entries", + "columnsFrom": [ + "content_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_terms_term_id_terms_id_fk": { + "name": "content_terms_term_id_terms_id_fk", + "tableFrom": "content_terms", + "tableTo": "terms", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "content_terms_site_id_content_entry_id_term_id_pk": { + "name": "content_terms_site_id_content_entry_id_term_id_pk", + "columns": [ + "site_id", + "content_entry_id", + "term_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entry_locales": { + "name": "entry_locales", + "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 + }, + "content_entry_id": { + "name": "content_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_revision_id": { + "name": "current_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_revision_id": { + "name": "published_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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": { + "entry_locales_uq": { + "name": "entry_locales_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locale", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entry_locales_site_id_sites_id_fk": { + "name": "entry_locales_site_id_sites_id_fk", + "tableFrom": "entry_locales", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "entry_locales_content_entry_id_content_entries_id_fk": { + "name": "entry_locales_content_entry_id_content_entries_id_fk", + "tableFrom": "entry_locales", + "tableTo": "content_entries", + "columnsFrom": [ + "content_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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.media_assets": { + "name": "media_assets", + "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_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_path": { + "name": "public_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_mime": { + "name": "detected_mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_mime": { + "name": "declared_mime", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "media_asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_assets_source_url_uq": { + "name": "media_assets_source_url_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_assets_public_path_uq": { + "name": "media_assets_public_path_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "public_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_assets_site_id_sites_id_fk": { + "name": "media_assets_site_id_sites_id_fk", + "tableFrom": "media_assets", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "media_assets_source_connection_id_source_connections_id_fk": { + "name": "media_assets_source_connection_id_source_connections_id_fk", + "tableFrom": "media_assets", + "tableTo": "source_connections", + "columnsFrom": [ + "source_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_usages": { + "name": "media_usages", + "schema": "", + "columns": { + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_asset_id": { + "name": "media_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_revision_id": { + "name": "content_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "field_path": { + "name": "field_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "media_usages_site_id_sites_id_fk": { + "name": "media_usages_site_id_sites_id_fk", + "tableFrom": "media_usages", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "media_usages_media_asset_id_media_assets_id_fk": { + "name": "media_usages_media_asset_id_media_assets_id_fk", + "tableFrom": "media_usages", + "tableTo": "media_assets", + "columnsFrom": [ + "media_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "media_usages_content_revision_id_content_revisions_id_fk": { + "name": "media_usages_content_revision_id_content_revisions_id_fk", + "tableFrom": "media_usages", + "tableTo": "content_revisions", + "columnsFrom": [ + "content_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_usages_media_asset_id_content_revision_id_field_path_pk": { + "name": "media_usages_media_asset_id_content_revision_id_field_path_pk", + "columns": [ + "media_asset_id", + "content_revision_id", + "field_path" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.route_registry": { + "name": "route_registry", + "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 + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path_raw": { + "name": "source_path_raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_path": { + "name": "route_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exact_route_key": { + "name": "exact_route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collision_key": { + "name": "collision_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query_policy": { + "name": "query_policy", + "type": "route_query_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "query_key": { + "name": "query_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_status": { + "name": "source_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_status": { + "name": "target_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_kind": { + "name": "route_kind", + "type": "route_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_content_id": { + "name": "target_content_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_file": { + "name": "target_file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_status": { + "name": "redirect_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_location": { + "name": "redirect_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexable": { + "name": "indexable", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verification_status": { + "name": "verification_status", + "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": { + "route_registry_site_id_sites_id_fk": { + "name": "route_registry_site_id_sites_id_fk", + "tableFrom": "route_registry", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "route_registry_source_snapshot_id_source_snapshots_id_fk": { + "name": "route_registry_source_snapshot_id_source_snapshots_id_fk", + "tableFrom": "route_registry", + "tableTo": "source_snapshots", + "columnsFrom": [ + "source_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "public.taxonomies": { + "name": "taxonomies", + "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 + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_type": { + "name": "object_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_base_path": { + "name": "public_base_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "taxonomies_uq": { + "name": "taxonomies_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "taxonomies_site_id_sites_id_fk": { + "name": "taxonomies_site_id_sites_id_fk", + "tableFrom": "taxonomies", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.terms": { + "name": "terms", + "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 + }, + "taxonomy_id": { + "name": "taxonomy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_term_id": { + "name": "parent_term_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "terms_identity_uq": { + "name": "terms_identity_uq", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "taxonomy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "terms_site_id_sites_id_fk": { + "name": "terms_site_id_sites_id_fk", + "tableFrom": "terms", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "terms_taxonomy_id_taxonomies_id_fk": { + "name": "terms_taxonomy_id_taxonomies_id_fk", + "tableFrom": "terms", + "tableTo": "taxonomies", + "columnsFrom": [ + "taxonomy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "terms_parent_term_id_terms_id_fk": { + "name": "terms_parent_term_id_terms_id_fk", + "tableFrom": "terms", + "tableTo": "terms", + "columnsFrom": [ + "parent_term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.validation_issues": { + "name": "validation_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "validation_run_id": { + "name": "validation_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule": { + "name": "rule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "resolution_status": { + "name": "resolution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "validation_issues_validation_run_id_validation_runs_id_fk": { + "name": "validation_issues_validation_run_id_validation_runs_id_fk", + "tableFrom": "validation_issues", + "tableTo": "validation_runs", + "columnsFrom": [ + "validation_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.validation_runs": { + "name": "validation_runs", + "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 + }, + "build_job_id": { + "name": "build_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_version": { + "name": "rule_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "validation_runs_site_id_sites_id_fk": { + "name": "validation_runs_site_id_sites_id_fk", + "tableFrom": "validation_runs", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "validation_runs_build_job_id_build_jobs_id_fk": { + "name": "validation_runs_build_job_id_build_jobs_id_fk", + "tableFrom": "validation_runs", + "tableTo": "build_jobs", + "columnsFrom": [ + "build_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_actor_type": { + "name": "audit_actor_type", + "schema": "public", + "values": [ + "system", + "cli_user" + ] + }, + "public.build_artifact_kind": { + "name": "build_artifact_kind", + "schema": "public", + "values": [ + "dist", + "manifest", + "report", + "log" + ] + }, + "public.build_job_status": { + "name": "build_job_status", + "schema": "public", + "values": [ + "queued", + "snapshotting", + "building", + "validating", + "succeeded", + "failed", + "cancelled", + "stale" + ] + }, + "public.content_lifecycle_status": { + "name": "content_lifecycle_status", + "schema": "public", + "values": [ + "active", + "archived" + ] + }, + "public.import_job_status": { + "name": "import_job_status", + "schema": "public", + "values": [ + "queued", + "running", + "succeeded", + "succeeded_with_issues", + "failed", + "cancelled", + "stale" + ] + }, + "public.media_asset_status": { + "name": "media_asset_status", + "schema": "public", + "values": [ + "ready", + "missing", + "quarantined", + "unsupported" + ] + }, + "public.revision_source_kind": { + "name": "revision_source_kind", + "schema": "public", + "values": [ + "import", + "manual", + "restore" + ] + }, + "public.route_kind": { + "name": "route_kind", + "schema": "public", + "values": [ + "html", + "asset", + "redirect", + "gone", + "dynamic", + "unresolved" + ] + }, + "public.route_query_policy": { + "name": "route_query_policy", + "schema": "public", + "values": [ + "ignore", + "exact", + "preserve", + "dynamic" + ] + }, + "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 5cb593d..cf161e0 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1792498347954, "tag": "0005_fine_dark_phoenix", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1792498960250, + "tag": "0006_confused_lord_hawal", + "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 ae054f6..ec0b85d 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,4 +1,4 @@ -// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*;内容域 = 0002_*;分类域 = 0003_*;媒体域 = 0004_*;路由域 = 0005_*) +// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*;内容域 = 0002_*;分类域 = 0003_*;媒体域 = 0004_*;路由域 = 0005_*;构建/验收/审计域 = 0006_*) import { pgTable, pgEnum, uuid, text, integer, bigint, jsonb, timestamp, uniqueIndex, index, boolean, primaryKey, type AnyPgColumn } from 'drizzle-orm/pg-core' export const sites = pgTable('sites', { @@ -254,3 +254,106 @@ export const routeRegistry = pgTable('route_registry', { // 会产出语义错误(默认 NULLS DISTINCT,纯路径路由两行 NULL 不冲突)的迁移。该索引由 // 0005 迁移文件末尾手写 SQL 追加,集成测试 db.routes.test.ts 守住其存在与语义。 }) + +// ---- 构建/验收/审计域(spec §5.7/§5.8;迁移文件 0006_*,末尾手写追加两不可变触发器)---- + +export const buildJobStatus = pgEnum('build_job_status', [ + 'queued', 'snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale', +]) +export const buildArtifactKind = pgEnum('build_artifact_kind', ['dist', 'manifest', 'report', 'log']) +export const auditActorType = pgEnum('audit_actor_type', ['system', 'cli_user']) + +export const buildJobs = pgTable('build_jobs', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').notNull().references(() => sites.id), + status: buildJobStatus('status').notNull(), + // CLI 重试/断点续传靠同 key 幂等落库(同 import_jobs.idempotency_key 先例) + idempotencyKey: text('idempotency_key').notNull().unique(), + attempt: integer('attempt').notNull().default(0), + workerId: text('worker_id'), + startedAt: timestamp('started_at', { withTimezone: true }), + heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }), + finishedAt: timestamp('finished_at', { withTimezone: true }), + errorCode: text('error_code'), + errorSummary: text('error_summary'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +// 构建输入快照:可复现性的单一事实来源(内容/路由/资产三 hash + 主题/工具链全版本钉死)。 +// 创建后不可变(0006 迁移手写触发器复用 sf_forbid_update);重试读同一行,永不改写。 +// build_job_id UNIQUE = 一 job 恰好一份输入快照(一对一)。 +export const buildInputSnapshots = pgTable('build_input_snapshots', { + id: uuid('id').primaryKey().defaultRandom(), + buildJobId: uuid('build_job_id').notNull().references(() => buildJobs.id).unique(), + siteId: uuid('site_id').notNull().references(() => sites.id), + contentSnapshotHash: text('content_snapshot_hash').notNull(), + routeSnapshotHash: text('route_snapshot_hash').notNull(), + assetManifestHash: text('asset_manifest_hash').notNull(), + // 可空:极早期 job 可能无配置修订引用(FK 不强制非空) + siteConfigRevisionId: uuid('site_config_revision_id').references(() => siteConfigRevisions.id), + themeKey: text('theme_key').notNull(), + themeVersion: text('theme_version').notNull(), + themeSourceHash: text('theme_source_hash').notNull(), + dependencyLockHash: text('dependency_lock_hash').notNull(), + sourceCommit: text('source_commit').notNull(), + nodeVersion: text('node_version').notNull(), + pnpmVersion: text('pnpm_version').notNull(), + astroVersion: text('astro_version').notNull(), + locale: text('locale').notNull(), + timezone: text('timezone').notNull(), + buildTimestamp: timestamp('build_timestamp', { withTimezone: true }).notNull(), + manifestPath: text('manifest_path').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const buildArtifacts = pgTable('build_artifacts', { + id: uuid('id').primaryKey().defaultRandom(), + buildJobId: uuid('build_job_id').notNull().references(() => buildJobs.id), + kind: buildArtifactKind('kind').notNull(), + storagePath: text('storage_path').notNull(), + sha256: text('sha256').notNull(), + byteSize: bigint('byte_size', { mode: 'number' }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + // 同一 job 内同 kind 同路径只登记一次(重试上传同一路径幂等);跨 job / 换 kind / 换路径 = 新行 +}, (t) => [uniqueIndex('build_artifacts_uq').on(t.buildJobId, t.kind, t.storagePath)]) + +// 验收 Gate 运行记录;build_job_id 可空(可对既有产物重跑 Gate,不必是新构建) +export const validationRuns = pgTable('validation_runs', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').notNull().references(() => sites.id), + buildJobId: uuid('build_job_id').references(() => buildJobs.id), + ruleVersion: text('rule_version').notNull(), + status: text('status').notNull(), + startedAt: timestamp('started_at', { withTimezone: true }), + finishedAt: timestamp('finished_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +// severity 用 text 而非 enum:级别集合随 Gate 规则演进(P0-P3 为当前切片约定),不值得一次 ALTER +export const validationIssues = pgTable('validation_issues', { + id: uuid('id').primaryKey().defaultRandom(), + validationRunId: uuid('validation_run_id').notNull().references(() => validationRuns.id), + severity: text('severity').notNull(), + rule: text('rule').notNull(), + url: text('url'), + evidence: jsonb('evidence').notNull(), + resolutionStatus: text('resolution_status').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +// 审计日志追加式(spec §5.8):只 INSERT,UPDATE/DELETE 由 0006 手写触发器拒绝。 +// site_id 可空——组织级操作(无站点上下文)记 NULL;CLI 必须带明确 Actor,不用模糊 system 顶替。 +export const auditLogs = pgTable('audit_logs', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').references(() => sites.id), + actorType: auditActorType('actor_type').notNull(), + actorId: text('actor_id'), + actorLabel: text('actor_label').notNull(), + commandId: text('command_id'), + action: text('action').notNull(), + objectType: text('object_type').notNull(), + objectId: text('object_id').notNull(), + beforeValue: jsonb('before_value'), + afterValue: jsonb('after_value'), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(), +}) diff --git a/tests/integration/db.jobs.test.ts b/tests/integration/db.jobs.test.ts new file mode 100644 index 0000000..728b0f8 --- /dev/null +++ b/tests/integration/db.jobs.test.ts @@ -0,0 +1,185 @@ +// 构建/验收/审计域集成测试(0006 迁移):build_jobs 幂等键唯一 + 状态 enum 八值、 +// build_input_snapshots 不可变触发器 + build_job_id UNIQUE、build_artifacts 三元组唯一、 +// audit_logs 追加式(UPDATE/DELETE 拒绝)、validation_issues.severity 可写。 +// 生命周期纪律:随机后缀插入 + finally 反序清理;两张不可变表(build_input_snapshots / +// audit_logs)清场走点名 DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。 +// audit_logs 组织级行(site_id NULL,spec §5.8)无 site 可挂——清场按 fixture 唯一 command_id 圈定。 +import { describe, it, expect } from 'vitest' +import { getDb } from '../../apps/api/src/db/client.js' + +type Db = ReturnType +type Fixture = { + siteId: string + buildJobId: string + idempotencyKey: string + snapshotId: string + validationRunId: string + auditCommandId: string +} + +// 建链(FK 依赖顺序):site → build_job → build_input_snapshot;validation_run 挂 site+job; +// audit_logs 挂 site(站点级行;组织级 NULL 行在测试体内插,由 command_id 清场兜底)。 +async function setupJobsFixture(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(), 'jobs-${rand}', 'JobsFixture', 'en', 'https://jobs.example') returning id`)) as any + const siteId = site[0].id + const job = (await db.execute(`insert into build_jobs (id, site_id, status, idempotency_key, attempt) + values (gen_random_uuid(), '${siteId}', 'queued', 'build-idem-${rand}', 0) returning id, idempotency_key`)) as any + const buildJobId = job[0].id + const snap = (await db.execute(`insert into build_input_snapshots (id, build_job_id, site_id, + content_snapshot_hash, route_snapshot_hash, asset_manifest_hash, + theme_key, theme_version, theme_source_hash, dependency_lock_hash, source_commit, + node_version, pnpm_version, astro_version, locale, timezone, build_timestamp, manifest_path) + values (gen_random_uuid(), '${buildJobId}', '${siteId}', + 'content-hash-${rand}', 'route-hash-${rand}', 'asset-hash-${rand}', + 'sf-default', '1.0.0', 'theme-hash-${rand}', 'lock-hash-${rand}', 'commit-${rand}', + '22.11.0', '10.7.1', '5.0.0', 'en', 'UTC', now(), 'manifest://${rand}.json') returning id`)) as any + const run = (await db.execute(`insert into validation_runs (id, site_id, build_job_id, rule_version, status) + values (gen_random_uuid(), '${siteId}', '${buildJobId}', 'gate@1', 'running') returning id`)) as any + await db.execute(`insert into audit_logs (id, site_id, actor_type, actor_label, command_id, action, object_type, object_id) + values (gen_random_uuid(), '${siteId}', 'cli_user', 'tester@siteforge', 'audit-${rand}', 'build.start', 'build_job', '${buildJobId}')`) + return { + siteId, buildJobId, idempotencyKey: job[0].idempotency_key, snapshotId: snap[0].id, + validationRunId: run[0].id, auditCommandId: `audit-${rand}`, + } +} + +// 反序清理:子表常规删除;两张不可变表逐一 DISABLE → 删 → ENABLE(嵌套 finally); +// audit_logs 按 fixture 唯一 command_id 删(含组织级 NULL site 行);最后删 site。 +async function cleanupJobsFixture(db: Db, f: Fixture) { + await db.execute(`delete from validation_issues where validation_run_id = '${f.validationRunId}'`) + await db.execute(`delete from validation_runs where site_id = '${f.siteId}'`) + await db.execute(`delete from build_artifacts where build_job_id = '${f.buildJobId}'`) + await db.execute(`alter table build_input_snapshots disable trigger build_input_snapshots_immutable`) + try { + await db.execute(`delete from build_input_snapshots where site_id = '${f.siteId}'`) + } finally { + await db.execute(`alter table build_input_snapshots enable trigger build_input_snapshots_immutable`) + } + await db.execute(`alter table audit_logs disable trigger audit_logs_immutable`) + try { + await db.execute(`delete from audit_logs where command_id = '${f.auditCommandId}'`) + } finally { + await db.execute(`alter table audit_logs enable trigger audit_logs_immutable`) + } + await db.execute(`delete from build_jobs where site_id = '${f.siteId}'`) + await db.execute(`delete from sites where id = '${f.siteId}'`) +} + +async function withJobsFixture(fn: (db: Db, f: Fixture) => Promise) { + const db = getDb() + const f = await setupJobsFixture(db) + try { + await fn(db, f) + } finally { + await cleanupJobsFixture(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}`) +} + +// 不可变快照探测插入:换新 id 但复用 fixture 的 build_job_id(唯一键探测用)。 +function snapshotProbe(f: Fixture, buildJobId: string) { + const suffix = Math.random().toString(36).slice(2, 8) + return `insert into build_input_snapshots (id, build_job_id, site_id, + content_snapshot_hash, route_snapshot_hash, asset_manifest_hash, + theme_key, theme_version, theme_source_hash, dependency_lock_hash, source_commit, + node_version, pnpm_version, astro_version, locale, timezone, build_timestamp, manifest_path) + values (gen_random_uuid(), '${buildJobId}', '${f.siteId}', + 'content-hash-${suffix}', 'route-hash-${suffix}', 'asset-hash-${suffix}', + 'sf-default', '1.0.0', 'theme-hash-${suffix}', 'lock-hash-${suffix}', 'commit-${suffix}', + '22.11.0', '10.7.1', '5.0.0', 'en', 'UTC', now(), 'manifest://${suffix}.json')` +} + +describe('构建/验收/审计域约束', () => { + it('build_jobs.idempotency_key 唯一:同 key 第二条拒绝', async () => { + await withJobsFixture(async (db, f) => { + expect(await rejectionMessage(db, `insert into build_jobs (id, site_id, status, idempotency_key, attempt) + values (gen_random_uuid(), '${f.siteId}', 'queued', '${f.idempotencyKey}', 0)`)) + .toMatch(/build_jobs_idempotency_key_unique/) + }) + }) + + it('build_jobs.status enum 八值(queued/snapshotting/building/validating/succeeded/failed/cancelled/stale)可写往返,第九值拒绝', async () => { + await withJobsFixture(async (db, f) => { + for (const status of ['snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale', 'queued']) { + await db.execute(`update build_jobs set status = '${status}' where id = '${f.buildJobId}'`) + const row = (await db.execute(`select status from build_jobs where id = '${f.buildJobId}'`)) as any + expect(row[0].status).toBe(status) + } + // enum 在库:类型化列拒绝八值之外的任意字符串 + expect(await rejectionMessage(db, `update build_jobs set status = 'bogus' where id = '${f.buildJobId}'`)) + .toMatch(/build_job_status/) + }) + }) + + it('build_input_snapshots 不可变:UPDATE 与 DELETE 均被触发器拒绝', async () => { + await withJobsFixture(async (db, f) => { + expect(await rejectionMessage(db, `update build_input_snapshots set theme_version = '9.9.9' where id = '${f.snapshotId}'`)) + .toMatch(/immutable table build_input_snapshots/) + expect(await rejectionMessage(db, `delete from build_input_snapshots where id = '${f.snapshotId}'`)) + .toMatch(/immutable table build_input_snapshots/) + }) + }) + + it('build_input_snapshots.build_job_id UNIQUE:同 job 第二条快照拒绝', async () => { + await withJobsFixture(async (db, f) => { + expect(await rejectionMessage(db, snapshotProbe(f, f.buildJobId))) + .toMatch(/build_input_snapshots_build_job_id_unique/) + }) + }) + + it('build_artifacts UNIQUE(build_job_id, kind, storage_path):重复拒绝,换 kind 放行', async () => { + await withJobsFixture(async (db, f) => { + const artifact = (kind: string, path: string) => + `insert into build_artifacts (id, build_job_id, kind, storage_path, sha256, byte_size) + values (gen_random_uuid(), '${f.buildJobId}', '${kind}', '${path}', 'sha256-${Math.random().toString(36).slice(2, 8)}', 100)` + await db.execute(artifact('dist', 'build://out/dist.zip')) + // 同 job 同 kind 同路径(如重试上传同一路径)→ 唯一索引拒绝 + expect(await rejectionMessage(db, artifact('dist', 'build://out/dist.zip'))) + .toMatch(/build_artifacts_uq/) + // 对照组:同路径换 kind → 允许(log 与 dist 可以同路径前缀语义,约束打在三元组上),由 finally 清理 + await db.execute(artifact('log', 'build://out/dist.zip')) + }) + }) + + it('audit_logs 追加式:UPDATE 与 DELETE 均被触发器拒绝;组织级行(site_id NULL)可写入', async () => { + await withJobsFixture(async (db, f) => { + // spec §5.8:组织级操作无站点上下文,site_id 为 NULL 也必须可记 + const orgRow = (await db.execute(`insert into audit_logs (id, site_id, actor_type, actor_label, command_id, action, object_type, object_id) + values (gen_random_uuid(), null, 'system', 'siteforge-scheduler', '${f.auditCommandId}', 'org.gc', 'organization', 'global') returning id`)) as any + const siteRow = (await db.execute(`select id from audit_logs where site_id = '${f.siteId}' and command_id = '${f.auditCommandId}'`)) as any + for (const rowId of [orgRow[0].id, siteRow[0].id]) { + expect(await rejectionMessage(db, `update audit_logs set action = 'tampered' where id = '${rowId}'`)) + .toMatch(/immutable table audit_logs/) + expect(await rejectionMessage(db, `delete from audit_logs where id = '${rowId}'`)) + .toMatch(/immutable table audit_logs/) + } + }) + }) + + it('validation_issues.severity 列存在可写:P0/P1/P2/P3 text 往返', async () => { + await withJobsFixture(async (db, f) => { + const issue = (await db.execute(`insert into validation_issues (id, validation_run_id, severity, rule, url, evidence, resolution_status) + values (gen_random_uuid(), '${f.validationRunId}', 'P0', 'route.unresolved', '/probe', '[]', 'open') returning id`)) as any + const issueId = issue[0].id + // severity 是 text 而非 enum:四个级别逐一可写、原样读回(不折叠大小写、不做映射) + for (const severity of ['P1', 'P2', 'P3', 'P0']) { + await db.execute(`update validation_issues set severity = '${severity}' where id = '${issueId}'`) + const row = (await db.execute(`select severity from validation_issues where id = '${issueId}'`)) as any + expect(row[0].severity).toBe(severity) + } + }) + }) +})