Skip to content

Commit 3d4c545

Browse files
baozhoutaoclaude
andauthored
fix(metadata): sys_view_definition 的「活跃行唯一」补运行时 partial UNIQUE 迁移 (#5839) (#6415)
* fix(metadata): sys_view_definition 的「活跃行唯一」补运行时 partial UNIQUE 迁移 (#5839) `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 * test(metadata-protocol): 迁移测试改用内置 node:sqlite,不再新增 better-sqlite3 依赖 本包除该测试外不需要任何 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7bd4e2 commit 3d4c545

6 files changed

Lines changed: 764 additions & 11 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/metadata-core': patch
4+
---
5+
6+
fix(metadata): `sys_view_definition` 的「活跃行唯一」真正生效——归档视图不再占用 (name, organization_id, owner) 名额
7+
8+
`sys_view_definition``idx_sys_view_def_active` 索引注释一直承诺「among active rows」,但这个语义从未在任何一层交付:声明面的 `partial: "state = 'active'"` 没有任何 driver 消费者(`syncDeclaredIndexes` 走 knex 的 `table.unique()`,无法表达 `WHERE`),该键已随 #5248 / #4943 退役;而与 `sys_metadata` 不同,这张表背后**没有**任何等价的运行时迁移。结果是建出来的一直是无谓词的全量 UNIQUE 索引——用户归档(或软删、重置)一个视图后,**无法再新建同名视图**,被一条自己刚扔掉的记录挡住。
9+
10+
现在补上运行时迁移 `ensureViewDefinitionActiveIndex`(照 `metadata-protocol` 既有的 `ensureOverlayIndex` 范式),在 `kernel:ready` 用 raw SQL 发 `CREATE UNIQUE INDEX idx_sys_view_def_active … WHERE state = 'active'`
11+
12+
- **名额可回收**——归档视图不再占用名额,同名视图可以重建;
13+
- **唯一性不放宽**——两条 `state='active'` 的同名同域行仍然被拒;
14+
- **复用声明的索引名**——`syncDeclaredIndexes` 按名跳过,后续每次启动都不会把全量 UNIQUE 索引重新加回来;
15+
- **降级只会退回今天的行为,不会更低**——迁移先用一个临时探针索引验证当前方言与数据确实能建出部分索引,成功后才替换既有索引。因此 MySQL / MariaDB(无部分索引)上原有的全量 UNIQUE 索引原样保留(归档行在该方言上仍占名额,以 `info` 记录),不会出现「旧索引已删、新索引没建成」的无约束窗口。
16+
17+
`metadata-core` 侧只更新了 `sys-view-definition.object.ts` 的注释:该声明现在被明确记为**降级形态**(供无部分索引的方言与不跑该迁移的宿主使用),不应删除。
18+
19+
已知未涵盖:`owner` 为 NULL 的共享视图与 `organization_id` 为 NULL 的环境级视图,因 SQL UNIQUE 的 NULL-distinct 语义本来就不受该索引约束。这是早于本次修复的既有缺口,本迁移只改变**行范围**`WHERE state = 'active'`)而不动键的拼写——这也正是它严格弱于被替换的索引、因而不可能在存量数据上建失败的原因。该缺口已另单记录。

packages/metadata-core/src/objects/sys-view-definition.object.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,27 @@ export const SysViewDefinitionObject = ObjectSchema.create({
122122
// A given view name is unique per (organization, owner) — a shared view
123123
// (owner NULL) and each user's personal views don't collide.
124124
//
125-
// ⚠️ This entry carried `partial: "state = 'active'"` until #5248 / #4943
126-
// retired the key, intending "among ACTIVE rows". No driver ever emitted
127-
// the predicate (`syncDeclaredIndexes` builds indexes through knex's
128-
// `table.unique()`, which cannot express a `WHERE`), so the index that has
129-
// always been created is the unrestricted one below — dropping the key is
130-
// a zero-DDL change. Unlike `sys_metadata`, there is NO runtime migration
131-
// issuing the partial form for this table, so the active-row scoping is
132-
// simply not delivered anywhere today: an archived/reset view still
133-
// occupies its (name, organization_id, owner) slot. Tracked separately —
134-
// deciding whether this table wants an `ensureOverlayIndex`-style
135-
// migration is a behaviour change, out of scope for the key retirement.
125+
// ⚠️ This entry is the FALLBACK shape, not the delivered one. It carried
126+
// `partial: "state = 'active'"` until #5248 / #4943 retired the key,
127+
// intending "among ACTIVE rows"; no driver ever emitted the predicate
128+
// (`syncDeclaredIndexes` builds indexes through knex's `table.unique()`,
129+
// which cannot express a `WHERE`), so what this declaration produces is the
130+
// unrestricted UNIQUE below — and an archived view kept occupying its
131+
// (name, organization_id, owner) slot, so a user could not re-create a view
132+
// they had just archived.
133+
//
134+
// #5839 delivers the promised scoping the same way `sys_metadata` always
135+
// had it — a runtime migration, not a declaration:
136+
// `metadata-protocol`'s `ensureViewDefinitionActiveIndex` issues
137+
// `CREATE UNIQUE INDEX idx_sys_view_def_active … WHERE state = 'active'`
138+
// in raw SQL at `kernel:ready`, reusing THIS index's name so
139+
// `syncDeclaredIndexes` (which skips by name) never re-imposes the
140+
// unrestricted form on a later boot.
141+
//
142+
// Keep this declaration exactly as it is. It is what dialects without
143+
// partial indexes (MySQL) and hosts that never run the migration fall back
144+
// to, and the migration deliberately leaves it untouched when it cannot
145+
// build the partial form — degraded to this behaviour, never below it.
136146
{
137147
name: 'idx_sys_view_def_active',
138148
fields: ['name', 'organization_id', 'owner'],

packages/metadata-protocol/src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeView
77
export { recordNotFoundError } from './protocol.js';
88
export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
99
export type { MetadataProtocolPluginOptions } from './plugin.js';
10+
11+
// [#5839] `sys_view_definition`'s active-row uniqueness, delivered as a runtime
12+
// partial-UNIQUE migration (the `ensureOverlayIndex` paradigm, for the one other
13+
// table that declared the same intent with nothing behind it).
14+
export {
15+
ensureViewDefinitionActiveIndex,
16+
resolveIndexExec,
17+
buildActiveIndexSql,
18+
classifyIndexFailure,
19+
VIEW_DEFINITION_TABLE,
20+
VIEW_ACTIVE_INDEX_NAME,
21+
VIEW_ACTIVE_PROBE_INDEX_NAME,
22+
VIEW_ACTIVE_INDEX_COLUMNS,
23+
} from './migrations/view-definition-active-index.js';
24+
export type {
25+
IndexExec,
26+
EnsureViewIndexLogger,
27+
EnsureViewIndexStatus,
28+
EnsureViewIndexResult,
29+
} from './migrations/view-definition-active-index.js';
1030
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
1131
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
1232
export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js';

0 commit comments

Comments
 (0)