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
72 changes: 72 additions & 0 deletions .changeset/view-definition-null-safe-active-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
"@objectstack/metadata-protocol": patch
"@objectstack/metadata-core": patch
---

fix(metadata): two same-name active SHARED views can no longer coexist — `sys_view_definition`'s active-row index gets a NULL-safe key (#6417)

#5839 / PR #6415 delivered "unique among ACTIVE rows" for `sys_view_definition`
as a runtime partial UNIQUE index, and deliberately changed only the index's
**row scope** — that is what made it strictly weaker than the index it replaced
and therefore incapable of failing on existing data. It also left the other
half of the same index broken, and pinned that gap honestly rather than closing
it.

SQL UNIQUE treats NULLs as mutually **distinct**. `owner` is NULL for SHARED
views and `organization_id` is NULL for environment-level ones, so
`(name, organization_id, owner)` constrained **personal views only**. Measured
on real SQLite over the driver's own DDL:

```text
two ACTIVE personal views, same (name, org, owner) : REJECTED
two ACTIVE shared views (owner NULL) : OK ← unconstrained
two ACTIVE env-level views (organization_id NULL) : OK ← unconstrained
```

Two same-name shared views inside one tenant were therefore reachable, while
`name` is declared as the globally unique qualified view id (`object.viewKey`)
— so the view switcher, which aggregates and de-duplicates by `name`, and every
read path that locates a view by name, had no defined answer about which row
they got.

**What changes.** Per the maintainer ruling of 2026-08-08 this is now forbidden.
The same runtime migration materializes the key NULL-safe, folding each nullable
part's NULLs into one bucket that is unique among itself:

```sql
CREATE UNIQUE INDEX idx_sys_view_def_active ON sys_view_definition
(name, COALESCE(organization_id, '__global__'), COALESCE(owner, ''))
WHERE state = 'active'
```

Both spellings are copied from an existing in-repo precedent rather than
invented: `'__global__'` is ADR-0120 D3's reserved sentinel for the tenant
column (the driver's `GLOBAL_TENANT`), and `COALESCE(owner, '')` is
`ensureOverlayIndex`'s `COALESCE(package_id, '')` form for a non-tenant nullable
discriminator. Neither can collide with real data — an organization id may never
equal `'__global__'`, and an owner is a user id, never the empty string.
**Storage is untouched**: rows keep their NULLs, only the index folds them, so
`WHERE owner = ''` still matches nothing.

Unchanged: archived rows stay exempt (#5839's active-only scoping survives, on
shared views too), a shared view and a personal view may still share a name, and
so may two tenants' or two environments' rows.

**This is a tightening, so it can fail to build.** Unlike #5839, rows that
violate the new key exist in the wild today, precisely because nothing rejected
them. The migration probes before it replaces anything, and on a conflict takes
ADR-0120 D4's disposition: the previous index is left in place (the table is
never left unconstrained), the report names the key that is not enforced, ships
the exact `GROUP BY … HAVING COUNT(*) > 1` query that lists the offending rows,
points at `os migrate plan` — and the boot continues. Resolve the duplicate
active shared views, restart, and the tightening applies itself.

Dialects with no partial indexes (MySQL/MariaDB) keep the declared bare
composite, which is ADR-0120 D3's own degradation. That report is **raised from
`info` to `error`**: under #5839 alone the dialect lost slot recycling, a
functional degradation the next user hits immediately, but it now loses an
integrity guarantee the platform states it enforces while continuing to look
healthy — AGENTS.md's durability arm. The line names both gaps that stay open
there and the duplicate-listing query. The unclassifiable-failure arm is raised
with it, so the failure nobody can name is never reported more quietly than the
one that has a name.
22 changes: 20 additions & 2 deletions packages/metadata-core/src/objects/sys-view-definition.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ export const SysViewDefinitionObject = ObjectSchema.create({

indexes: [
// A given view name is unique per (organization, owner) — a shared view
// (owner NULL) and each user's personal views don't collide.
// (owner NULL) and each user's personal views don't collide, AND two
// shared views may not share a name either (#6417).
//
// ⚠️ This entry is the FALLBACK shape, not the delivered one. It carried
// `partial: "state = 'active'"` until #5248 / #4943 retired the key,
Expand All @@ -139,10 +140,27 @@ export const SysViewDefinitionObject = ObjectSchema.create({
// `syncDeclaredIndexes` (which skips by name) never re-imposes the
// unrestricted form on a later boot.
//
// ⚠️ The KEY below is NULL-DISTINCT, which is a second gap the declaration
// cannot close on its own (#6417). `owner` is NULL for SHARED views and
// `organization_id` is NULL for environment-level ones, and SQL UNIQUE
// treats NULLs as mutually distinct — so what this entry constrains is
// PERSONAL views only, measured: two active shared views could carry one
// name. Per the maintainer ruling of 2026-08-08 that is forbidden, and the
// same runtime migration delivers it, again without touching this
// declaration: it materializes the key NULL-safe, as
// `(name, COALESCE(organization_id, '__global__'), COALESCE(owner, ''))`
// — ADR-0120 D3's sentinel for the tenant column, `ensureOverlayIndex`'s
// `COALESCE(package_id, '')` form for the non-tenant one. Storage keeps
// its NULLs; only the index folds them into a bucket.
//
// 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.
// build the partial NULL-safe form — degraded to this behaviour, never
// below it. Rewriting it to `unique: 'organization'` would NOT be the same
// thing: that is ADR-0120 D1's declared-scope vocabulary, staged for the
// protocol-18 train (D7), and it scopes the tenant column only — `owner`
// would stay NULL-distinct.
{
name: 'idx_sys_view_def_active',
fields: ['name', 'organization_id', 'owner'],
Expand Down
Loading
Loading