Skip to content

fix(metadata): sys_view_definition 的「活跃行唯一」补运行时 partial UNIQUE 迁移 (#5839) - #6415

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-5839-view-active-partial-unique
Aug 7, 2026
Merged

fix(metadata): sys_view_definition 的「活跃行唯一」补运行时 partial UNIQUE 迁移 (#5839)#6415
baozhoutao merged 3 commits into
mainfrom
claude/issue-5839-view-active-partial-unique

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #5839

按维护者 2026-08-06 裁决(选项 1:名额可回收)实施:补运行时 partial UNIQUE 迁移,归档视图不再占用 (name, organization_id, owner) 名额。

前提核验(三条全部成立)

前提 结论 证据
P1 活跃行唯一今天确实不生效 ✅ 成立 真实 SQLite 实测,见下
P2 ensureOverlayIndex 范式在 main 且形态如假设 ✅ 成立 packages/metadata-protocol/src/protocol.ts:2519,raw SQL CREATE UNIQUE INDEX … WHERE state = 'active' + 方言降级
P3 #5248 现状与本单不冲突 ✅ 成立 #5248 已于 2026-08-06 合并关闭partial 现为 retiredKey 墓碑,本单未恢复该键

P1 实测(用 declared-index-retired-keys.test.ts 已钉住的、驱动真实产出的 DDL):

CREATE UNIQUE INDEX `idx_sys_view_def_active` on `sys_view_definition` (`name`, `organization_id`, `owner`)

insert active personal view : OK
归档 v1 后同名新建           : REJECTED: UNIQUE constraint failed:
    sys_view_definition.name, sys_view_definition.organization_id, sys_view_definition.owner

用户归档「我的管道」之后,被一条自己刚扔掉的记录挡住,无法重建同名视图——正文口径复现成功。

落点

  • 迁移packages/metadata-protocol/src/migrations/view-definition-active-index.ts(新增)
  • 接线packages/metadata-protocol/src/plugin.tsassembleMetadataProtocol,在 kernel:ready 触发
  • 声明面packages/metadata-core/src/objects/sys-view-definition.object.ts 仅改注释

未触 engine.ts / protocol.ts 热区。接线选在 assembleMetadataProtocol 而非 protocol.ts,是因为它是两种挂载共用的唯一入口MetadataProtocolPlugin 委托模式 ObjectQLPluginregisterProtocol !== false 内建便利模式)——只在委托插件上挂 hook 会漏掉默认挂载。选 kernel:ready 是因为表必须先存在:ObjectQLPluginstart() 里经 syncRegisteredSchemas 建表,晚于所有插件的 init()

零新增依赖pnpm-lock.yaml 与 main 完全一致。测试用 Node 内置的 node:sqlite 跑真实数据库,而不是为跑测试把 better-sqlite3 这个原生模块写进 lockfile。

为什么复用声明的索引名

syncDeclaredIndexes 按名跳过(if (existing.has(name)) continue;)。用 idx_sys_view_def_active 这个声明里的同名建 partial 索引,才能让后续每次启动都看到名额已占、不再把全量 UNIQUE 加回来。换个名字会在下次启动被静默撤销;而删掉声明则会让不跑该迁移的宿主完全失去唯一性——所以声明保留为降级形态

与范式的两处有意偏离(均在模块头注释写明)

1. 先探针、后替换。 ensureOverlayIndex 是「先 DROP 旧索引、再建 partial」;建失败(方言不支持 / 数据冲突)时,表会静默地一个唯一索引都不剩。本迁移反转顺序:先用临时探针名建一次 partial 索引,确证当前方言与数据都能接受,才 DROP 旧索引并以声明名重建。任何建不成的情况下,原有全量 UNIQUE 索引原样保留——只会退回今天的行为,不会更低。代价是迁移那一次启动把小索引建了两遍。(范式自身的这个窗口已另单记录:#6418。)

2. resolveIndexExec 逐个 probe 加 try/catch,并优先 getDriverForObject ObjectQL.getDriver(objectName) 必须带对象名,不带会抛 No driver available for object 'undefined';范式因整体 try/catch 掩盖了这一点,而本迁移跑在 kernel:ready 钩子里,抛出会失败整个 bootstrap。这不是假设——消费半径扫描时它真的让 16 个 ObjectQL 启动测试翻红,已按此修复并加钉子测试。优先 getDriverForObject(sys_view_definition) 还更正确:多数据源内核上平台对象可能落在自己的数据源上。

测试(18 条,全部跑真实 SQLite)

packages/metadata-protocol/src/migrations/view-definition-active-index.test.ts

  • 名额回收(本单钉子):归档后同名新建成功;配一条 BEFORE the migration… 把缺陷本身钉住
  • 唯一性不放宽:两条 state='active' 同名同域行仍被拒;多条归档行可共存,但活跃行仍只允许一条;不同 owner / 不同 org 互不干扰
  • 幂等:重跑后索引定义逐字节相同、探针无残留;行为层面重跑后名额回收依然成立;从「从未建过声明索引」的表也能收敛
  • 降级:模拟无 partial 索引的方言 → 原全量 UNIQUE 索引逐字节保留且仍在生效,以 info 记录(非操作者错误);模拟冲突行 → 按 ADR-0120 D4 口径error 点名未生效的列并指向 os migrate plan,旧索引保留,不阻断启动
  • seamgetDriver 抛错时不抛出(上面那条回归的钉子);getDriverForObject 优先于引擎级默认

反向验证(肢 A:去掉迁移)

预测方向 翻红,实测一致:ensureViewDefinitionActiveIndex 改为直接 return { status: 'created' }7 红 9 绿,其中本单钉子 AFTER the migration, an archived view frees its slot 翻红。

如实记录两点:still rejects two ACTIVE rows…does NOT close the NULL-distinct hole 在肢 A 下保持绿——前者由被保留的全量索引提供,后者是既有事实的钉子,二者都不是迁移的证明,而是「不放宽 / 不越界」的守卫。另外 is idempotent — 逐字节相同 在肢 A 下空绿(什么都没做当然不变),真正覆盖它的是同组的行为版 is idempotent in BEHAVIOUR too,该条翻红。

已知未涵盖(已另单:#6417

owner 为 NULL 的共享视图organization_id 为 NULL 的环境级视图,因 SQL UNIQUE 的 NULL-distinct 语义本来就不受该索引约束——实测两条同名活跃共享视图今天可以并存。这是早于本次修复的既有缺口,且不是 #5839 裁决的内容;本迁移只改行范围WHERE state = 'active')而不动键的拼写,这也正是它严格弱于被替换的索引、因而不可能在存量数据上建失败的原因。已加测试如实钉住该缺口。

验证

pnpm --filter @objectstack/metadata-protocol --filter @objectstack/metadata-core test
  metadata-core      8 files / 103 tests passed
  metadata-protocol 53 files / 552 tests passed
pnpm --filter @objectstack/objectql test      140 files / 2308 tests passed
pnpm --filter @objectstack/driver-sql test     68 passed | 4 skipped, 953 passed
node scripts/check-engine-double-contract.mjs    OK — 80 pinned, 133 DEBT, 4 exempt
node scripts/check-startup-registry-verdict.mjs  OK — 40 seams, none contradictable
node scripts/check-nul-bytes.mjs                 OK — 6055 files, no raw control bytes

@objectstack/metadata-protocoltypecheck script(既有状况),其 tsup DTS 构建即类型门,已通过。

⚠️ 过程记录:objectqlprotocol-meta.test.ts 一度出现 3 条红,排查为 AGENTS.md §9 的陈旧产物陷阱——合并 origin/main 后只重建了本单两个包,spec/lint 的 dist 仍是旧的。用 origin/main 的对照工作树复核(同一文件 89/89 绿),再在本分支重建完整依赖链后同样 89/89 绿,与本 PR 无关。

claude added 2 commits August 7, 2026 17:04
)

`idx_sys_view_def_active` 的注释一直承诺「among active rows」,但该语义从未
在任何一层交付:声明面的 `partial` 键没有任何 driver 消费者(knex 的
`table.unique()` 无法表达 `WHERE`),已随 #5248 / #4943 退役;而与
`sys_metadata` 不同,这张表背后没有等价的运行时迁移。结果建出来的一直是无
谓词的全量 UNIQUE 索引——用户归档一个视图后无法再新建同名视图。

补 `ensureViewDefinitionActiveIndex`(照 `ensureOverlayIndex` 范式),在
`kernel:ready` 用 raw SQL 发 `CREATE UNIQUE INDEX … WHERE state = 'active'`,
复用声明的索引名以便 `syncDeclaredIndexes`(按名跳过)不会在后续启动把全量
索引加回来。

与范式的两处有意偏离,均在模块头注释里写明理由:
- 先用临时探针索引验证方言与数据确实能建出部分索引,成功后才替换既有索引,
  因此「旧索引已删、新索引没建成」的无约束窗口不存在(范式存在该窗口);
- `resolveIndexExec` 逐个 probe 加 try/catch,并优先 `getDriverForObject`:
  `ObjectQL.getDriver(objectName)` 必须带对象名、否则抛错,范式因整体
  try/catch 而掩盖了这一点。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 5:24pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/metadata-protocol.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-core, @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/plugins/packages.mdx (via @objectstack/metadata-core)
  • content/docs/releases/v12.mdx (via @objectstack/metadata-core)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

本包除该测试外不需要任何 SQL 依赖,为跑测试而把一个原生模块写进
lockfile 并不划算。Node 内置的 `node:sqlite` 提供同样真实的 SQLite ——
真实的 partial index、真实的 UNIQUE 约束 —— 且零依赖。

顺带的好处:pnpm-lock.yaml 回到与 main 完全一致,本 PR 不再触发
「Validate Package Dependencies」的 OSV 扫描。该扫描只在 lockfile 变动时
运行,而它当前会因 main 上既有的 dompurify@3.4.12 公告
(GHSA-55q2-fjhq-7xh7, dependabot #16) 判红 —— 与本 PR 无关。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 17:42
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 3d4c545 Aug 7, 2026
26 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5839-view-active-partial-unique branch August 7, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[metadata-core] sys_view_definition 的"活跃行唯一"从未生效——归档视图仍占用 (name, organization_id, owner) 名额

2 participants