发现于 #5248(IndexSchema.type / partial 退役)的生产者清点。不在该 PR 范围内——退役本身是零 DDL 变化,这条是被退役暴露出来的既有行为缺口,修它是行为变更。
事实
packages/metadata-core/src/objects/sys-view-definition.object.ts 声明:
indexes: [
// A given view name is unique per (organization, owner) among active rows —
// a shared view (owner NULL) and each user's personal views don't collide.
{
name: 'idx_sys_view_def_active',
fields: ['name', 'organization_id', 'owner'],
unique: true,
partial: "state = 'active'", // ← #5248 退役前的原文
},
注释说的是"among active rows",但 partial 从来没有任何 driver 消费者:SqlDriver.syncDeclaredIndexes 走 knex 的 table.unique(fields, { indexName }),无法表达 WHERE。所以实际建出来的一直是无谓词的全量 UNIQUE 索引。
实测(#5248 PR 里的 packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts 同一手法,真实 SQLite 存储的 DDL):
WITH partial -> CREATE UNIQUE INDEX `idx_sys_view_def_active` on `t` (`name`, `organization_id`, `owner`)
WITHOUT partial -> CREATE UNIQUE INDEX `idx_sys_view_def_active` on `t` (`name`, `organization_id`, `owner`)
两者逐字节相同,且都不含 WHERE。
与 sys_metadata 的关键差别
sys_metadata 的同类声明(idx_sys_metadata_overlay_active)有一个运行时兜底:packages/metadata-protocol/src/protocol.ts 的 ensureOverlayIndex() 用 raw SQL 发 CREATE UNIQUE INDEX … WHERE state = 'active'(并且顺带修了 COALESCE(package_id,'') 的 NULL-distinct 问题),所以那张表的活跃行范围是真的交付了的。
sys_view_definition 没有任何等价的运行时迁移——全仓检索 idx_sys_view_def_active 只有声明处一个命中。也就是说这张表的"活跃行唯一"在任何一层都没有实现。
用户可达的后果
sys_view_definition 有 state 字段(defaultValue: 'active')。一旦某个视图被置为非 active(归档/重置/软删),它的 (name, organization_id, owner) 组合仍然被全量 UNIQUE 索引占用,于是:
- 用户删除/归档一个名为 "我的管道" 的个人视图后,无法再新建同名视图——唯一约束会拒绝插入;
- 反过来,如果当初的意图是"同名的历史版本可以共存",那么当前的全量唯一约束比设计更严。
两种读法下当前行为都不是声明所描述的那个。
可选修法(需要拍板,故不自行实施)
- 补运行时迁移——照
ensureOverlayIndex 的形状,为 sys_view_definition 发 partial UNIQUE,MySQL 侧降级为普通复合索引 + 应用层 upsert 语义。交付注释所承诺的语义。
- 接受全量唯一并改注释——如果产品上就是"视图名在 (org, owner) 内永久唯一,含已归档",那么把注释里的 "among active rows" 删掉即可,零代码。
- 把
state 纳入索引列——(name, organization_id, owner, state),允许同名的一个 active + 多个非 active;但这不等价于 partial(多个 active 之间仍互斥,而多个同 state 的归档行会互斥),语义要单独想清楚。
倾向 1 或 2:取决于产品是否要求视图名可回收。这是行为决策,不是机械修复。
相关
发现于 #5248(
IndexSchema.type/partial退役)的生产者清点。不在该 PR 范围内——退役本身是零 DDL 变化,这条是被退役暴露出来的既有行为缺口,修它是行为变更。事实
packages/metadata-core/src/objects/sys-view-definition.object.ts声明:注释说的是"among active rows",但
partial从来没有任何 driver 消费者:SqlDriver.syncDeclaredIndexes走 knex 的table.unique(fields, { indexName }),无法表达WHERE。所以实际建出来的一直是无谓词的全量 UNIQUE 索引。实测(#5248 PR 里的
packages/drivers/driver-sql/src/declared-index-retired-keys.test.ts同一手法,真实 SQLite 存储的 DDL):两者逐字节相同,且都不含
WHERE。与
sys_metadata的关键差别sys_metadata的同类声明(idx_sys_metadata_overlay_active)有一个运行时兜底:packages/metadata-protocol/src/protocol.ts的ensureOverlayIndex()用 raw SQL 发CREATE UNIQUE INDEX … WHERE state = 'active'(并且顺带修了COALESCE(package_id,'')的 NULL-distinct 问题),所以那张表的活跃行范围是真的交付了的。sys_view_definition没有任何等价的运行时迁移——全仓检索idx_sys_view_def_active只有声明处一个命中。也就是说这张表的"活跃行唯一"在任何一层都没有实现。用户可达的后果
sys_view_definition有state字段(defaultValue: 'active')。一旦某个视图被置为非 active(归档/重置/软删),它的(name, organization_id, owner)组合仍然被全量 UNIQUE 索引占用,于是:两种读法下当前行为都不是声明所描述的那个。
可选修法(需要拍板,故不自行实施)
ensureOverlayIndex的形状,为sys_view_definition发 partial UNIQUE,MySQL 侧降级为普通复合索引 + 应用层 upsert 语义。交付注释所承诺的语义。state纳入索引列——(name, organization_id, owner, state),允许同名的一个 active + 多个非 active;但这不等价于 partial(多个 active 之间仍互斥,而多个同 state 的归档行会互斥),语义要单独想清楚。倾向 1 或 2:取决于产品是否要求视图名可回收。这是行为决策,不是机械修复。
相关
IndexSchema.type与IndexSchema.partial没有任何 DDL 消费者 #5248 / [spec/driver-sql]IndexSchema.partialis authorable but no driver ever emits the WHERE clause (declared ≠ enforced) #4943 ——IndexSchema.partial退役(本条的发现路径);该 PR 只删声明面的死键,不改任何 DDL,因此没有顺手改这里。sys_metadata侧同类问题的既有解法参考。