Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/view-definition-active-row-unique.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata-core': patch
---

fix(metadata): `sys_view_definition` 的「活跃行唯一」真正生效——归档视图不再占用 (name, organization_id, owner) 名额

`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 索引——用户归档(或软删、重置)一个视图后,**无法再新建同名视图**,被一条自己刚扔掉的记录挡住。

现在补上运行时迁移 `ensureViewDefinitionActiveIndex`(照 `metadata-protocol` 既有的 `ensureOverlayIndex` 范式),在 `kernel:ready` 用 raw SQL 发 `CREATE UNIQUE INDEX idx_sys_view_def_active … WHERE state = 'active'`:

- **名额可回收**——归档视图不再占用名额,同名视图可以重建;
- **唯一性不放宽**——两条 `state='active'` 的同名同域行仍然被拒;
- **复用声明的索引名**——`syncDeclaredIndexes` 按名跳过,后续每次启动都不会把全量 UNIQUE 索引重新加回来;
- **降级只会退回今天的行为,不会更低**——迁移先用一个临时探针索引验证当前方言与数据确实能建出部分索引,成功后才替换既有索引。因此 MySQL / MariaDB(无部分索引)上原有的全量 UNIQUE 索引原样保留(归档行在该方言上仍占名额,以 `info` 记录),不会出现「旧索引已删、新索引没建成」的无约束窗口。

`metadata-core` 侧只更新了 `sys-view-definition.object.ts` 的注释:该声明现在被明确记为**降级形态**(供无部分索引的方言与不跑该迁移的宿主使用),不应删除。

已知未涵盖:`owner` 为 NULL 的共享视图与 `organization_id` 为 NULL 的环境级视图,因 SQL UNIQUE 的 NULL-distinct 语义本来就不受该索引约束。这是早于本次修复的既有缺口,本迁移只改变**行范围**(`WHERE state = 'active'`)而不动键的拼写——这也正是它严格弱于被替换的索引、因而不可能在存量数据上建失败的原因。该缺口已另单记录。
32 changes: 21 additions & 11 deletions packages/metadata-core/src/objects/sys-view-definition.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,17 +122,27 @@ export const SysViewDefinitionObject = ObjectSchema.create({
// A given view name is unique per (organization, owner) — a shared view
// (owner NULL) and each user's personal views don't collide.
//
// ⚠️ This entry carried `partial: "state = 'active'"` until #5248 / #4943
// retired the key, intending "among ACTIVE rows". No driver ever emitted
// the predicate (`syncDeclaredIndexes` builds indexes through knex's
// `table.unique()`, which cannot express a `WHERE`), so the index that has
// always been created is the unrestricted one below — dropping the key is
// a zero-DDL change. Unlike `sys_metadata`, there is NO runtime migration
// issuing the partial form for this table, so the active-row scoping is
// simply not delivered anywhere today: an archived/reset view still
// occupies its (name, organization_id, owner) slot. Tracked separately —
// deciding whether this table wants an `ensureOverlayIndex`-style
// migration is a behaviour change, out of scope for the key retirement.
// ⚠️ This entry is the FALLBACK shape, not the delivered one. It carried
// `partial: "state = 'active'"` until #5248 / #4943 retired the key,
// intending "among ACTIVE rows"; no driver ever emitted the predicate
// (`syncDeclaredIndexes` builds indexes through knex's `table.unique()`,
// which cannot express a `WHERE`), so what this declaration produces is the
// unrestricted UNIQUE below — and an archived view kept occupying its
// (name, organization_id, owner) slot, so a user could not re-create a view
// they had just archived.
//
// #5839 delivers the promised scoping the same way `sys_metadata` always
// had it — a runtime migration, not a declaration:
// `metadata-protocol`'s `ensureViewDefinitionActiveIndex` issues
// `CREATE UNIQUE INDEX idx_sys_view_def_active … WHERE state = 'active'`
// in raw SQL at `kernel:ready`, reusing THIS index's name so
// `syncDeclaredIndexes` (which skips by name) never re-imposes the
// unrestricted form on a later boot.
//
// Keep this declaration exactly as it is. It is what dialects without
// partial indexes (MySQL) and hosts that never run the migration fall back
// to, and the migration deliberately leaves it untouched when it cannot
// build the partial form — degraded to this behaviour, never below it.
{
name: 'idx_sys_view_def_active',
fields: ['name', 'organization_id', 'owner'],
Expand Down
20 changes: 20 additions & 0 deletions packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeView
export { recordNotFoundError } from './protocol.js';
export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
export type { MetadataProtocolPluginOptions } from './plugin.js';

// [#5839] `sys_view_definition`'s active-row uniqueness, delivered as a runtime
// partial-UNIQUE migration (the `ensureOverlayIndex` paradigm, for the one other
// table that declared the same intent with nothing behind it).
export {
ensureViewDefinitionActiveIndex,
resolveIndexExec,
buildActiveIndexSql,
classifyIndexFailure,
VIEW_DEFINITION_TABLE,
VIEW_ACTIVE_INDEX_NAME,
VIEW_ACTIVE_PROBE_INDEX_NAME,
VIEW_ACTIVE_INDEX_COLUMNS,
} from './migrations/view-definition-active-index.js';
export type {
IndexExec,
EnsureViewIndexLogger,
EnsureViewIndexStatus,
EnsureViewIndexResult,
} from './migrations/view-definition-active-index.js';
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js';
Expand Down
Loading
Loading