feat(db): 0005 路由注册表——exact/collision 双键 + NULLS NOT DISTINCT 唯一
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 守住其存在与语义。
|
||||
})
|
||||
|
||||
@@ -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<typeof getDb>
|
||||
type Fixture = { siteId: string; rand: string }
|
||||
|
||||
async function setupRoutesFixture(db: Db): Promise<Fixture> {
|
||||
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin)
|
||||
values (gen_random_uuid(), '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<void>) {
|
||||
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<string> {
|
||||
try {
|
||||
await db.execute(query)
|
||||
} catch (e: any) {
|
||||
return String(e?.cause?.message ?? e?.message ?? e)
|
||||
}
|
||||
throw new Error(`expected query to be rejected: ${query}`)
|
||||
}
|
||||
|
||||
// 探测插入:除显式指定的列外全部换新值(随机 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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user