diff --git a/apps/api/drizzle/0005_fine_dark_phoenix.sql b/apps/api/drizzle/0005_fine_dark_phoenix.sql new file mode 100644 index 0000000..abd5021 --- /dev/null +++ b/apps/api/drizzle/0005_fine_dark_phoenix.sql @@ -0,0 +1,33 @@ +CREATE TYPE "public"."route_kind" AS ENUM('html', 'asset', 'redirect', 'gone', 'dynamic', 'unresolved');--> statement-breakpoint +CREATE TYPE "public"."route_query_policy" AS ENUM('ignore', 'exact', 'preserve', 'dynamic');--> statement-breakpoint +CREATE TABLE "route_registry" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "host" text NOT NULL, + "source_path_raw" text NOT NULL, + "route_path" text NOT NULL, + "exact_route_key" text NOT NULL, + "collision_key" text NOT NULL, + "query_policy" "route_query_policy" NOT NULL, + "query_key" text, + "source_status" text NOT NULL, + "target_status" text NOT NULL, + "route_kind" "route_kind" NOT NULL, + "target_content_id" uuid, + "target_file" text, + "redirect_status" integer, + "redirect_location" text, + "indexable" boolean NOT NULL, + "priority" integer NOT NULL, + "source_snapshot_id" uuid, + "verification_status" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "route_registry" ADD CONSTRAINT "route_registry_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 "route_registry" ADD CONSTRAINT "route_registry_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 +-- ↓ 手写追加(drizzle-kit 0.31 不生成 NULLS NOT DISTINCT 子句,schema 层故意的未声明该索引): +-- 纯路径唯一——query_key 为 NULL 的路由也参与唯一(spec §5.6 要求数据库级强制,不用哨兵值; +-- 默认 NULLS DISTINCT 下两行 NULL 不冲突,挡不住纯路径重复)。 +CREATE UNIQUE INDEX route_exact_uq ON route_registry (site_id, host, exact_route_key, query_key) NULLS NOT DISTINCT; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..067bdbf --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1897 @@ +{ + "id": "09105afe-7f2e-4940-bfe2-57a73134abb9", + "prevId": "fe7a789b-95ef-4f75-8d5c-00d1e2b1a518", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + } + }, + "enums": { + "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 40bb2a9..5cb593d 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1792497336007, "tag": "0004_last_victor_mancha", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1792498347954, + "tag": "0005_fine_dark_phoenix", + "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 9b28164..ae054f6 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_*) +// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*;内容域 = 0002_*;分类域 = 0003_*;媒体域 = 0004_*;路由域 = 0005_*) 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', { @@ -213,3 +213,44 @@ export const mediaUsages = pgTable('media_usages', { fieldPath: text('field_path').notNull(), sourceUrl: text('source_url').notNull(), }, (t) => [primaryKey({ columns: [t.mediaAssetId, t.contentRevisionId, t.fieldPath] })]) + +// ---- 路由域(spec §5.6;迁移文件 0005_*,末尾手写追加 NULLS NOT DISTINCT 唯一索引)---- + +export const routeKind = pgEnum('route_kind', ['html', 'asset', 'redirect', 'gone', 'dynamic', 'unresolved']) +export const routeQueryPolicy = pgEnum('route_query_policy', ['ignore', 'exact', 'preserve', 'dynamic']) + +// ADR-007 双键:exact_route_key 保留大小写/尾斜杠语义用于请求匹配(真实路由不折叠—— +// /About 与 /about 是两条路由);collision_key 只折叠给风险检测用(Windows 大小写不敏感、 +// 尾斜杠、Unicode NFC),永不参与唯一约束。查询路由单独建模(query_key),不与 path 静默合并。 +export const routeRegistry = pgTable('route_registry', { + id: uuid('id').primaryKey().defaultRandom(), + siteId: uuid('site_id').notNull().references(() => sites.id), + host: text('host').notNull(), + // source_path_raw 永远保存来源原文;route_path 是经安全校验的实际公开路径(spec §5.6 规则) + sourcePathRaw: text('source_path_raw').notNull(), + routePath: text('route_path').notNull(), + exactRouteKey: text('exact_route_key').notNull(), + collisionKey: text('collision_key').notNull(), + queryPolicy: routeQueryPolicy('query_policy').notNull(), + // 纯路径路由 query_key 为 NULL——其唯一性靠 0005 迁移手写索引的 NULLS NOT DISTINCT 兜底 + queryKey: text('query_key'), + sourceStatus: text('source_status').notNull(), + targetStatus: text('target_status').notNull(), + routeKind: routeKind('route_kind').notNull(), + // target_content_id 指向 content_entries,但照 spec §5.6 与 Task 11 清单暂不设 FK + // (同 source_records.content_entry_id 先例:需要时由后续迁移手写回填) + targetContentId: uuid('target_content_id'), + targetFile: text('target_file'), + redirectStatus: integer('redirect_status'), + redirectLocation: text('redirect_location'), + indexable: boolean('indexable').notNull(), + priority: integer('priority').notNull(), + sourceSnapshotId: uuid('source_snapshot_id').references(() => sourceSnapshots.id), + verificationStatus: text('verification_status').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + // 注意:唯一索引 route_exact_uq (site_id, host, exact_route_key, query_key) NULLS NOT DISTINCT + // 故意的在此不定义——drizzle-kit 0.31 无法生成 NULLS NOT DISTINCT 子句,声明普通唯一索引 + // 会产出语义错误(默认 NULLS DISTINCT,纯路径路由两行 NULL 不冲突)的迁移。该索引由 + // 0005 迁移文件末尾手写 SQL 追加,集成测试 db.routes.test.ts 守住其存在与语义。 +}) diff --git a/tests/integration/db.routes.test.ts b/tests/integration/db.routes.test.ts new file mode 100644 index 0000000..42ccbe2 --- /dev/null +++ b/tests/integration/db.routes.test.ts @@ -0,0 +1,132 @@ +// 路由域集成测试:route_registry 唯一索引 route_exact_uq (site_id, host, exact_route_key, +// query_key) NULLS NOT DISTINCT(0005 迁移手写 SQL——drizzle-kit 0.31 不生成该子句)。 +// 断言组(V2 设计 ADR-007 双键语义): +// 1. exact_route_key 区分大小写——/About 与 /about 是两条路由,均可插入(设计语义,不是 bug); +// 2. 纯路径唯一:同 (site, host, exact_route_key) 且 query_key 均 NULL 的第二条插入拒绝 +// ——这正是 NULLS NOT DISTINCT 的意义(默认 NULLS DISTINCT 下两行 NULL 不冲突,挡不住); +// 3. query_key 参与唯一:同 exact key 下 query_key='p' 与 NULL 共存;同 'p' 第二条拒绝; +// 4. route_kind enum 六值(html/asset/redirect/gone/dynamic/unresolved)往返 + 第七值拒绝; +// 5. query_policy enum 四值(ignore/exact/preserve/dynamic)往返 + 第五值拒绝。 +// 生命周期纪律:随机后缀建 site + finally 反序清理(route_registry → sites);本表无不可变触发器。 +import { describe, it, expect } from 'vitest' +import { getDb } from '../../apps/api/src/db/client.js' + +type Db = ReturnType +type Fixture = { siteId: string; rand: string } + +async function setupRoutesFixture(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(), 'routes-${rand}', 'RoutesFixture', 'en', 'https://routes.example') returning id`)) as any + return { siteId: site[0].id, rand } +} + +// 反序清理:route_registry(无不可变触发器,直接删)→ sites(级联挡在 site 前,先删子表)。 +async function cleanupRoutesFixture(db: Db, f: Fixture) { + await db.execute(`delete from route_registry where site_id = '${f.siteId}'`) + await db.execute(`delete from sites where id = '${f.siteId}'`) +} + +async function withRoutesFixture(fn: (db: Db, f: Fixture) => Promise) { + const db = getDb() + const f = await setupRoutesFixture(db) + try { + await fn(db, f) + } finally { + await cleanupRoutesFixture(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}`) +} + +// 探测插入:除显式指定的列外全部换新值(随机 suffix 的 id/path/collision_key),保证被测 +// 唯一索引 (site_id, host, exact_route_key, query_key) 是唯一可能的冲突源;默认 query_key=NULL +// (纯路径路由)。其余 NOT NULL 列填合法基线值。 +function routeProbe(f: Fixture, overrides: { + host?: string + exactRouteKey?: string + queryKey?: string | null + routeKind?: string + queryPolicy?: string +} = {}) { + const suffix = Math.random().toString(36).slice(2, 8) + const key = overrides.exactRouteKey ?? `/probe-${suffix}` + const host = overrides.host ?? 'example.com' + const queryKeySql = overrides.queryKey == null ? 'null' : `'${overrides.queryKey}'` + return `insert into route_registry (id, site_id, host, source_path_raw, route_path, exact_route_key, collision_key, query_policy, query_key, source_status, target_status, route_kind, indexable, priority, verification_status) + values (gen_random_uuid(), '${f.siteId}', '${host}', '/raw-${suffix}', '${key}', '${key}', 'collision-${suffix}', '${overrides.queryPolicy ?? 'ignore'}', ${queryKeySql}, '200', '200', '${overrides.routeKind ?? 'html'}', true, 100, 'pending') returning id` +} + +describe('路由域约束', () => { + it('exact_route_key 区分大小写(ADR-007 语义):同站同 host 下 /About 与 /about 两条均可插入', async () => { + await withRoutesFixture(async (db, f) => { + await db.execute(routeProbe(f, { exactRouteKey: '/About' })) + await db.execute(routeProbe(f, { exactRouteKey: '/about' })) + // 两条都真实入库,各自独立成行(exact key 不折叠大小写——这是请求匹配键的设计语义); + // 排序在 JS 侧做,不依赖库端 collation(默认排序 /about 在 /About 前) + const rows = (await db.execute(`select exact_route_key from route_registry + where site_id = '${f.siteId}' and exact_route_key in ('/About', '/about')`)) as any + expect(rows.map((r: any) => r.exact_route_key).sort()).toEqual(['/About', '/about']) + }) + }) + + it('纯路径唯一(NULLS NOT DISTINCT):同 (site, host, exact_route_key) 且 query_key 均 NULL 的第二条拒绝', async () => { + await withRoutesFixture(async (db, f) => { + await db.execute(routeProbe(f, { exactRouteKey: '/dup-null' })) + // 若索引是默认 NULLS DISTINCT,两行 query_key=NULL 不冲突、会静默放行——本断言专守这一点 + expect(await rejectionMessage(db, routeProbe(f, { exactRouteKey: '/dup-null' }))) + .toMatch(/route_exact_uq/) + }) + }) + + it('query_key 参与唯一:同 exact key 下 NULL 与 p 共存;同 query_key 第二条拒绝', async () => { + await withRoutesFixture(async (db, f) => { + // 纯路径路由(NULL)与查询路由('p')是不同路由,合法共存 + await db.execute(routeProbe(f, { exactRouteKey: '/query', queryKey: null })) + await db.execute(routeProbe(f, { exactRouteKey: '/query', queryKey: 'p' })) + // 同 exact key 同 query_key 的第二条 → 唯一索引拒绝(身份断言匹配索引名) + expect(await rejectionMessage(db, routeProbe(f, { exactRouteKey: '/query', queryKey: 'p' }))) + .toMatch(/route_exact_uq/) + }) + }) + + it('route_kind enum 六值(html/asset/redirect/gone/dynamic/unresolved)可写往返,第七值拒绝', async () => { + await withRoutesFixture(async (db, f) => { + const inserted = (await db.execute(routeProbe(f))) as any + const routeId = inserted[0].id + for (const kind of ['asset', 'redirect', 'gone', 'dynamic', 'unresolved', 'html']) { + await db.execute(`update route_registry set route_kind = '${kind}' where id = '${routeId}'`) + const row = (await db.execute(`select route_kind from route_registry where id = '${routeId}'`)) as any + expect(row[0].route_kind).toBe(kind) + } + // enum 在库:类型化列拒绝六值之外的任意字符串 + expect(await rejectionMessage(db, `update route_registry set route_kind = 'bogus' where id = '${routeId}'`)) + .toMatch(/route_kind/) + }) + }) + + it('query_policy enum 四值(ignore/exact/preserve/dynamic)可写往返,第五值拒绝', async () => { + await withRoutesFixture(async (db, f) => { + const inserted = (await db.execute(routeProbe(f))) as any + const routeId = inserted[0].id + for (const policy of ['exact', 'preserve', 'dynamic', 'ignore']) { + await db.execute(`update route_registry set query_policy = '${policy}' where id = '${routeId}'`) + const row = (await db.execute(`select query_policy from route_registry where id = '${routeId}'`)) as any + expect(row[0].query_policy).toBe(policy) + } + // enum 在库:类型化列拒绝四值之外的任意字符串 + expect(await rejectionMessage(db, `update route_registry set query_policy = 'bogus' where id = '${routeId}'`)) + .toMatch(/route_query_policy/) + }) + }) +})