From 0761750f96240d9d4a4d5aadabaab98130cc85c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:08:49 +0000 Subject: [PATCH 1/3] feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: retire sys_view_definition as inert (ADR-0131 D13) Drop the object, its two registrations, the kernel:ready active-row index migration and its pre-flight probe, the public exports that served only it, its PLATFORM_OBJECTS_BY_PACKAGE entry and the runbook. Absence pins on both registration sites and the platform-object registry; the duplicates e2e re-premises its runtime-migration control on the sys_metadata overlay index. Generated artefacts (i18n bundles, census, migration registry) follow in the next commit. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- content/docs/data-modeling/drivers.mdx | 17 +- content/docs/deployment/cli.mdx | 5 +- content/docs/plugins/packages.mdx | 2 +- docs/qa/platform-checklist/areas/cli.json | 14 +- ...mmands.absent-database.integration.test.ts | 2 +- .../migrate/duplicates.contract.test.ts | 9 +- .../migrate/duplicates.integration.test.ts | 81 ++- ...form-migrations-arming.integration.test.ts | 10 +- ...grate.host-composition.integration.test.ts | 12 +- packages/metadata-core/src/objects/index.ts | 7 +- .../sys-view-definition.object.test.ts | 48 -- .../src/objects/sys-view-definition.object.ts | 187 ----- packages/metadata-protocol/src/index.ts | 40 +- .../src/migrations/overlay-index.test.ts | 8 +- .../src/migrations/overlay-index.ts | 8 +- .../src/migrations/partial-index-probe.ts | 6 +- .../runtime-index-preflight.test.ts | 83 +-- .../src/migrations/runtime-index-preflight.ts | 44 +- .../migrations/sys-setting-identity-index.ts | 6 +- .../view-definition-active-index.test.ts | 638 ------------------ .../view-definition-active-index.ts | 422 ------------ .../plugin.metadata-objects-retired.test.ts | 78 +++ packages/metadata-protocol/src/plugin.ts | 78 +-- packages/metadata/src/metadata-manager.ts | 6 +- ...grate-project-id-to-environment-id.test.ts | 2 - packages/metadata/src/plugin.test.ts | 25 + packages/metadata/src/plugin.ts | 9 +- .../scripts/i18n-extract.config.ts | 2 - .../translations/bundle-ownership.test.ts | 4 +- .../objects-es-es-echo-decisions.test.ts | 1 - .../objects-ja-jp-echo-decisions.test.ts | 1 - .../objects-zh-cn-echo-decisions.test.ts | 1 - .../src/audit/sys-import-job.object.ts | 4 +- .../platform-objects/src/metadata/index.ts | 3 +- packages/runtime/src/standalone-stack.ts | 4 +- .../constants/platform-object-names.test.ts | 14 + .../system/constants/platform-object-names.ts | 1 - .../overlay-views-to-sys-view-definition.md | 112 --- 38 files changed, 345 insertions(+), 1649 deletions(-) delete mode 100644 packages/metadata-core/src/objects/sys-view-definition.object.test.ts delete mode 100644 packages/metadata-core/src/objects/sys-view-definition.object.ts delete mode 100644 packages/metadata-protocol/src/migrations/view-definition-active-index.test.ts delete mode 100644 packages/metadata-protocol/src/migrations/view-definition-active-index.ts create mode 100644 packages/metadata-protocol/src/plugin.metadata-objects-retired.test.ts delete mode 100644 scripts/migrate/overlay-views-to-sys-view-definition.md diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 2cc6444fe4b..5065db485e9 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -376,7 +376,7 @@ log because they are worth knowing *before* you choose MySQL, not after. | Caveat | What MySQL does not give you | Way out | | :--- | :--- | :--- | | [`upsert` cannot honour a conflict target](#upsert-conflict-targets-the-target-mysql-cannot-honour) | `ON DUPLICATE KEY UPDATE` carries no target, so a merge can land on a UNIQUE key you never named. The driver refuses the call rather than let it. | Keep one UNIQUE key per table, or run the object on SQLite/PostgreSQL. | -| [Three runtime uniqueness indexes cannot be built](#uniqueness-indexes-mysql-cannot-build) | Database enforcement of three platform integrity guarantees. The weaker index stays in force and every boot logs an `error`. | None in-dialect. Run the platform on SQLite/PostgreSQL for the guarantee. | +| [Two platform tables' runtime uniqueness indexes cannot be built](#uniqueness-indexes-mysql-cannot-build) | Database enforcement of two platform integrity guarantees. The weaker index stays in force and every boot logs an `error`. | None in-dialect. Run the platform on SQLite/PostgreSQL for the guarantee. | | [A unique violation does not name the column](#the-conflicting-column-is-not-named) | The conflicting **field** in import errors and form-field conflict messages. The `409 UNIQUE_VIOLATION` itself is unaffected. | None. The message falls back to generic copy. | ### `upsert` conflict targets: the target MySQL cannot honour @@ -471,24 +471,23 @@ object on SQLite/PostgreSQL. ### Uniqueness indexes MySQL cannot build -Three platform tables get their uniqueness from an index issued as raw SQL at +Two platform tables get their uniqueness from an index issued as raw SQL at boot by a `metadata-protocol` runtime migration, because the shape each one needs -cannot be expressed through the declaration surface. Two of the three are -**partial** indexes (`CREATE UNIQUE INDEX … WHERE state = '…'`), and all three +cannot be expressed through the declaration surface. `sys_metadata`'s are +**partial** indexes (`CREATE UNIQUE INDEX … WHERE state = '…'`), and both tables' use **functional key parts** (`COALESCE(…)`) to fold a nullable column's NULLs into one bucket that is unique among itself. MySQL/MariaDB has no partial indexes at any version, and rejects the functional -key parts as these statements spell them. So on MySQL none of the three is built, -and the guarantee each one backs is not enforced by the database: +key parts as these statements spell them. So on MySQL neither is built, and the +guarantee each one backs is not enforced by the database: | Table | The guarantee that is not enforced on MySQL | | :--- | :--- | | `sys_metadata` | ADR-0005 overlay uniqueness. Package-less rows (`package_id` NULL) stay NULL-distinct and can duplicate, and `getMetaItem` then has no defined answer for which row wins. | -| `sys_view_definition` | Active-row view-name uniqueness. An archived view keeps occupying its name slot, and two same-name **active** shared views (`owner` NULL) or environment-level views (`organization_id` NULL) can coexist even though the platform states they cannot. | | `sys_setting` | NULL-safe row identity. `user_id` is NULL on every row that is not `scope='user'`, so two tenant-scope rows for one `(namespace, key)` in one organization — or two platform defaults on the global layer — can coexist, and `SettingsService` has no defined answer for which one wins. | -What happens instead is the same in all three cases, and it is deliberate. Each +What happens instead is the same in both cases, and it is deliberate. Each migration proves the new index is possible under a throwaway probe name *before* dropping anything, so a dialect that cannot take it is left holding **exactly the index it already had** — never an unconstrained table, and never a failed boot. @@ -497,7 +496,7 @@ lists any rows already violating the guarantee. **There is no in-dialect fix**, and none is planned — the DDL these migrations -need does not exist in MySQL/MariaDB. If you need these three guarantees enforced +need does not exist in MySQL/MariaDB. If you need these guarantees enforced by the database, run the platform on SQLite or PostgreSQL. On MySQL, treat the boot's `[metadata-protocol] this database cannot build …` lines as expected rather than as a failure, and run the duplicate-listing query each one prints to diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 08d03fb61ce..4a424f51c45 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1532,14 +1532,13 @@ The report carries a second section, `runtimeIndexPreflight`, answering a different question: **will the next server start be able to finish tightening the platform's own unique indexes?** -Three migrations run at `kernel:ready` on a serving boot (`os dev`, `os serve`, +Two migrations run at `kernel:ready` on a serving boot (`os dev`, `os serve`, `os start`) and replace a declared UNIQUE index with the NULL-safe — and -sometimes active-rows-only — form it was always meant to have: +sometimes state-scoped — form it was always meant to have: | Table | Index | What the tightening adds | | :--- | :--- | :--- | | `sys_metadata` | overlay `active` and `draft` | package-less overlays stop being NULL-distinct | -| `sys_view_definition` | `idx_sys_view_def_active` | shared and environment-level views stop being NULL-distinct, and only active rows are constrained | | `sys_setting` | the declared row identity | tenant- and global-scope rows stop being NULL-distinct on `user_id` | Each is a **tightening**, so rows an installation already holds can block it. diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 49f3fb44872..98f4eac0e4c 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -156,7 +156,7 @@ import { InMemoryDriver } from '@objectstack/driver-memory'; - **Purpose**: Production-ready SQL database support with migrations - **Supports**: PostgreSQL, MySQL, SQLite, and all Knex-compatible databases -- **MySQL caveats**: MySQL is supported, with three dialect caveats — `upsert` cannot honour a conflict target, three runtime uniqueness indexes cannot be built, and a unique violation does not name the conflicting column. See [Drivers → MySQL dialect caveats](/docs/data-modeling/drivers#mysql-dialect-caveats) +- **MySQL caveats**: MySQL is supported, with three dialect caveats — `upsert` cannot honour a conflict target, two platform tables' runtime uniqueness indexes cannot be built, and a unique violation does not name the conflicting column. See [Drivers → MySQL dialect caveats](/docs/data-modeling/drivers#mysql-dialect-caveats) - **When to use**: Traditional relational database deployments - **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/drivers/driver-sql/README.md) diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 47ee5eaaf01..f0f0ef658aa 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -1102,7 +1102,7 @@ "title": "os migrate duplicates: a read-only JSON inventory of identifiers minted across partitions — within-partition repeats excluded, nothing written, the live two-counter condition reported, and runnable BEFORE the #8686 repair destroys the evidence", "since": "v17", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "cli", "personas": ["operator (local shell, pre-repair audit)"], @@ -1122,7 +1122,7 @@ "seed via direct SQL per the fixture recipe: the cross-partition duplicate, the within-partition repeat, an organizations row for '', and the paired sequence counters", "dump the DB's CONTENT before the run — every user table's rows (sqlite3 '.dump' with the CREATE lines aside, or one ordered SELECT * per table), `SELECT * FROM _objectstack_sequences ORDER BY 1`, and `SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY name` — and checksum each dump; run `os migrate duplicates > report.json; echo $?`; dump and checksum again and byte-compare the three pairs. ⛔ Do not use the whole-file md5 as the oracle: it is not what the read-only contract promises (rows, counters and schema)", "jq the report: .report/.reportVersion/.generatedAt/.database/.globalPartition/.filter/.counters/.scanned/.skipped/.duplicates/.liveConditions/.runtimeIndexPreflight/.summary", - "seed the kernel:ready blocker too (#8725): two ACTIVE sys_view_definition rows with the SAME name and organization_id/owner both NULL — then jq .runtimeIndexPreflight and .summary.runtimeIndexesBlocked", + "seed the kernel:ready blocker too (#8725): the first boot already TIGHTENED idx_sys_metadata_overlay_active, so first put back the declared NULL-distinct form an untightened install still has (DROP INDEX idx_sys_metadata_overlay_active, then CREATE UNIQUE INDEX idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id, package_id)); then insert two ACTIVE sys_metadata rows with the SAME type and name, the SAME non-NULL organization_id and package_id NULL, every other NOT NULL column filled — then jq .runtimeIndexPreflight and .summary.runtimeIndexesBlocked", "run `os migrate duplicates --object ` and `--object ` — capture .filter in both payloads", "run `os migrate duplicates --database-url file:/tmp//dup.db` and confirm it reaches the same DB (the flag also honors OS_DATABASE_URL)", "negative (no_sql_seam): run `pnpm --filter @objectstack/cli exec vitest run src/commands/migrate/duplicates.null-seam.test.ts` and cite its pass — the seam double that answers no result set, driving the real command; then run `os migrate duplicates --database-url memory://qa; echo $?` and capture the boot_failed payload (the retired engine is refused at boot, naming the replacement)", @@ -1148,9 +1148,9 @@ "evidence": "the three before/after dump-checksum pairs (rows, _objectstack_sequences, sqlite_master)" }, { - "clause": "the kernel:ready pre-flight (#8725) reports the RUNTIME-migration class os migrate plan cannot see: one entry per index the three kernel:ready migrations tighten (four — the overlay migration owns two), each blocked|clear|table-absent|unreadable, a blocked one naming every colliding key group and its row count", + "clause": "the kernel:ready pre-flight (#8725) reports the RUNTIME-migration class os migrate plan cannot see: one entry per index the two kernel:ready migrations tighten (three — the overlay migration owns two; the third migration, sys_view_definition's, retired with its table under ADR-0131 D13), each blocked|clear|table-absent|unreadable, a blocked one naming every colliding key group and its row count", "oracle": "log", - "verify": ".runtimeIndexPreflight names idx_sys_view_def_active as blocked with the seeded view name, organization_id_key '__global__' and owner_key '' — and the SAME database run through `os migrate plan` mentions neither the index nor the view name (the matched control: the declared-index duplicate above IS reported by plan, this one is not)", + "verify": ".runtimeIndexPreflight names idx_sys_metadata_overlay_active as blocked with the seeded type, name and organization_id and package_id_key '' — and the SAME database run through `os migrate plan` mentions neither the index nor the seeded name (the matched control: the declared-index duplicate above IS reported by plan, this one is not); no entry names sys_view_definition", "evidence": "the pre-flight section beside the plan output for one database" }, { @@ -1201,6 +1201,12 @@ "date": "2026-10-04", "change": "A3 and its step re-pointed: the read-only oracle is the DB's content — rows, _objectstack_sequences and sqlite_master byte-identical before and after — not the whole-file md5, which is not what the contract promises (duplicates.ts header: 'a full run leaves both the rows and `_objectstack_sequences` byte-identical'; duplicates.pre-repair.test.ts pins the same). Assertion defect. Source duplicates.ts contract header", "ref": "#21735" + }, + { + "revision": 6, + "date": "2026-10-08", + "change": "the kernel:ready blocker re-seeded on sys_metadata: sys_view_definition retired as inert with its active-row index migration (ADR-0131 D13), so the pre-flight now probes three indexes from two migrations and never names that table. The seeding step and A4 now use two ACTIVE package-less sys_metadata overlays for one type, name and NON-NULL organization — the organization key part stays bare in the tightened index (#6418), so a NULL-organization pair would not block its CREATE — and the step first puts back the declared NULL-distinct index the first boot replaced, without which the insert is refused. Source runtime-index-preflight.ts runtimeIndexProbes", + "ref": "#15206" } ] }, diff --git a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts index 5ce8b25946a..b04a50256c5 100644 --- a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts +++ b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts @@ -207,7 +207,7 @@ const OWN_TABLES: Record = { resume: ['sys_migration_journal'], 'recorded-by': ['sys_metadata_history'], // The objects with a covered field: the scan walks each. - 'value-shapes': ['os21529_contact', 'sys_metadata', 'sys_view_definition'], + 'value-shapes': ['os21529_contact', 'sys_metadata'], }; const absentJson = {} as Record; diff --git a/packages/cli/src/commands/migrate/duplicates.contract.test.ts b/packages/cli/src/commands/migrate/duplicates.contract.test.ts index 5851e7efbae..7c427584524 100644 --- a/packages/cli/src/commands/migrate/duplicates.contract.test.ts +++ b/packages/cli/src/commands/migrate/duplicates.contract.test.ts @@ -134,7 +134,7 @@ const collect = async (objectFilter?: string) => client: 'better-sqlite3', now: () => new Date('2026-08-17T12:00:00.000Z'), // The real pre-flight against the real fixture — never a stand-in. This - // database has none of the four platform tables, so every entry is + // database has none of the platform tables the probes read, so every entry is // `table-absent`, and the `blocked` shape is pinned in its own test below // over a database that really carries the damage. runtimeIndexPreflight: await collectRuntimeIndexPreflight(exec, { client: 'better-sqlite3' }), @@ -142,7 +142,7 @@ const collect = async (objectFilter?: string) => }); /** - * The four probes, as `@objectstack/metadata-protocol` declares them. + * The three probes, as `@objectstack/metadata-protocol` declares them. * * Read from the producer rather than restated here: the descriptor (table, * index name, key parts, row scope, the two statements) is the migration's own @@ -231,7 +231,7 @@ describe('#8928 os migrate duplicates — the report document', () => { organizationCounters: [{ organization: 'org_x', lastValue: 4 }], }, ], - // One entry per index the three `kernel:ready` migrations tighten — FOUR, + // One entry per index the two `kernel:ready` migrations tighten — THREE, // because the overlay migration builds one per state and either can be // blocked on its own. Present whatever the outcome: an index left out // would make "nothing blocks it" and "it was never probed" the same @@ -253,14 +253,13 @@ describe('#8928 os migrate duplicates — the report document', () => { }); }); - it('names the four kernel:ready indexes, and the migration behind each', async () => { + it('names the three kernel:ready indexes, and the migration behind each — none for the retired sys_view_definition (ADR-0131 D13)', async () => { const report = await collect(); expect( report.runtimeIndexPreflight.map((p) => `${p.migration}:${p.table}:${p.index}`), ).toEqual([ 'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_active', 'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_draft', - 'ensureViewDefinitionActiveIndex:sys_view_definition:idx_sys_view_def_active', 'ensureSysSettingIdentityIndex:sys_setting:uniq_sys_setting_organization_id_namespace_key_scope_user_id', ]); }); diff --git a/packages/cli/src/commands/migrate/duplicates.integration.test.ts b/packages/cli/src/commands/migrate/duplicates.integration.test.ts index 5613c89fe60..d8854ceb40e 100644 --- a/packages/cli/src/commands/migrate/duplicates.integration.test.ts +++ b/packages/cli/src/commands/migrate/duplicates.integration.test.ts @@ -25,12 +25,18 @@ * - `crm_case.case_number` — a DECLARED identifier, one value held on both * sides of the organization partition. Reported by the `duplicates` scan, * and reported before this card existed. - * - `sys_view_definition` — two ACTIVE shared views under one name, which is - * exactly what blocks `ensureViewDefinitionActiveIndex`'s NULL-safe - * tightening. Invisible to the drift differ by construction, and therefore - * to `os migrate plan`: `isRuntimeManagedIndex` excludes the index once the - * partial form exists, and before that the migration reuses the DECLARED - * index's name so the name-matched slot reads as filled either way. + * - `sys_metadata` — two ACTIVE package-less overlays for one + * `(type, name, organization_id)`, which is exactly what blocks + * `ensureMetadataOverlayIndexes`' NULL-safe active-row tightening. Invisible + * to the drift differ by construction, and therefore to `os migrate plan`: + * `isRuntimeManagedIndex` excludes the index once the partial form exists, + * and before that the migration reuses the DECLARED index's name so the + * name-matched slot reads as filled either way. + * + * Until ADR-0131 D13 the runtime half was `sys_view_definition`'s active-row + * index. That table retired with its migration, so the fixture now also + * carries it, damaged, as the retirement's own control: a pre-flight that + * still probed it would report rows nothing will ever refuse. * * The control is what makes the second assertion mean something. A pre-flight * that quietly reported only the declared class — or a fixture that failed to @@ -145,11 +151,33 @@ beforeAll(async () => { { id: 'a1', created_at: '2026-02-01T00:00:00.000Z', organization_id: 'org_x', subject: 'api', case_number: 'CASE-00001' }, ]); // ── The runtime-migration half of the matched control (#8725) ────────── - // Two ACTIVE shared views under one name: `owner` NULL and `organization_id` - // NULL both fold into their sentinel buckets, so these two rows collide under - // `idx_sys_view_def_active`'s NULL-safe key while the declared, NULL-distinct - // index admits them. This is what blocks the tightening on the next serving - // boot — and what the drift differ cannot report. + // Two ACTIVE package-less overlays for one key in one organization: + // `package_id` NULL folds into its sentinel bucket, so these two rows collide + // under `idx_sys_metadata_overlay_active`'s NULL-safe key while the declared, + // NULL-distinct index admits them. This is what blocks the tightening on the + // next serving boot — and what the drift differ cannot report. The + // organization is NON-NULL on purpose: that key part stays bare in the index + // (#6418), so two rows with a NULL organization would not block the CREATE. + await k.schema.createTable('sys_metadata', (t: any) => { + t.string('id').primary(); + t.string('type'); + t.string('name'); + t.string('organization_id'); + t.string('package_id'); + t.string('state'); + }); + await k('sys_metadata').insert([ + { id: 'm1', type: 'view', name: 'crm_case.board', organization_id: 'org_x', package_id: null, state: 'active' }, + { id: 'm2', type: 'view', name: 'crm_case.board', organization_id: 'org_x', package_id: null, state: 'active' }, + // Outside the partial index's row scope: the same collision among archived + // rows is legal and must not be reported. + { id: 'm3', type: 'view', name: 'crm_case.retired', organization_id: 'org_x', package_id: null, state: 'archived' }, + { id: 'm4', type: 'view', name: 'crm_case.retired', organization_id: 'org_x', package_id: null, state: 'archived' }, + ]); + // ── The retired table, still physically present and still damaged ────── + // ADR-0131 D13: schema sync never drops a table, so an upgraded database + // keeps `sys_view_definition` and whatever its rows were. Nothing tightens + // an index on it any more, so nothing about it may reach the report. await k.schema.createTable('sys_view_definition', (t: any) => { t.string('id').primary(); t.string('name'); @@ -160,10 +188,6 @@ beforeAll(async () => { await k('sys_view_definition').insert([ { id: 'v1', name: 'crm_case.all_open', organization_id: null, owner: null, state: 'active' }, { id: 'v2', name: 'crm_case.all_open', organization_id: null, owner: null, state: 'active' }, - // Outside the partial index's row scope: the same collision among archived - // rows is legal and must not be reported. - { id: 'v3', name: 'crm_case.retired', organization_id: null, owner: null, state: 'archived' }, - { id: 'v4', name: 'crm_case.retired', organization_id: null, owner: null, state: 'archived' }, ]); await k.schema.createTable(ORGANIZATION_TABLE, (t: any) => { t.string('id').primary(); @@ -260,31 +284,34 @@ describe('#8928 os migrate duplicates — against a really booted stack', () => // duplicate above was already reported before #8725; a probe that surfaced // only that class would satisfy "the report names some duplicate" and still // leave the operator with nothing at the moment they are blocked. - const viewIndex = produced.runtimeIndexPreflight.find( - (entry) => entry.index === 'idx_sys_view_def_active', + const overlayIndex = produced.runtimeIndexPreflight.find( + (entry) => entry.index === 'idx_sys_metadata_overlay_active', ); - expect(viewIndex, 'the pre-flight must cover sys_view_definition').toBeDefined(); - expect(viewIndex).toMatchObject({ - migration: 'ensureViewDefinitionActiveIndex', - table: 'sys_view_definition', + expect(overlayIndex, 'the pre-flight must cover the sys_metadata overlay index').toBeDefined(); + expect(overlayIndex).toMatchObject({ + migration: 'ensureMetadataOverlayIndexes', + table: 'sys_metadata', rowScope: "state = 'active'", status: 'blocked', groups: [ { - key: { name: 'crm_case.all_open', organization_id_key: '__global__', owner_key: '' }, + key: { type: 'view', name: 'crm_case.board', organization_id: 'org_x', package_id_key: '' }, rowCount: 2, }, ], }); - // The archived pair is outside the partial index and is NOT reported. - expect(JSON.stringify(viewIndex!.groups)).not.toContain('crm_case.retired'); + // The archived pair is outside both partial indexes and is NOT reported. + expect(JSON.stringify(produced.runtimeIndexPreflight)).not.toContain('crm_case.retired'); + // The retired table is NOT probed, damaged as it is (ADR-0131 D13). + expect(produced.runtimeIndexPreflight.map((entry) => entry.table)).not.toContain('sys_view_definition'); + expect(JSON.stringify(produced.runtimeIndexPreflight)).not.toContain('crm_case.all_open'); // The summary counts it, so an operator scanning the head of the document // sees that something is blocked without reading every entry. expect(produced.summary.runtimeIndexesBlocked).toBe(1); expect(produced.summary.runtimeIndexBlockingRows).toBe(2); - // `sys_metadata` exists on this fixture only if the boot made it; whatever - // its status, the four indexes are all accounted for. - expect(produced.runtimeIndexPreflight).toHaveLength(4); + // `sys_setting` is absent on this fixture; whatever each status, the three + // indexes are all accounted for. + expect(produced.runtimeIndexPreflight).toHaveLength(3); expect(produced.reportVersion).toBe(2); // The whole run — boot included — wrote nothing. If a future change arms a diff --git a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts index f161042b49a..55fc8398bea 100644 --- a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts +++ b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts @@ -4,9 +4,9 @@ * #9380 — the three `kernel:ready` platform-table migrations never armed on a * self-hosted boot, and arming them must not make a read-only command write. * - * `assembleMetadataProtocol` arms #5839 (the `sys_view_definition` active-row - * index), #8629 (`sys_setting`'s row-identity index) and #8686 (the seed/API - * tenancy backfill) behind one gate whose own comment says standalone belongs + * `assembleMetadataProtocol` armed #5839 (the `sys_view_definition` active-row + * index, since retired with its table under ADR-0131 D13), #8629 (`sys_setting`'s + * row-identity index) and #8686 (the seed/API tenancy backfill) behind one gate whose own comment says standalone belongs * on the INSIDE of it: "platform / standalone kernels own their local * sys_metadata; per-project (cloud) kernels source metadata from the control * plane and must NOT provision these tables locally." @@ -91,7 +91,7 @@ async function readState(): Promise<{ data: unknown; schema: unknown }> { try { const k = (probe as any).knex; return { - // Everything the three migrations could move. Column-projected, not + // Everything the migrations could move. Column-projected, not // `select('*')`: a boot that is ALLOWED to run schema-sync DDL adds // audit/ownership columns to `crm_case`, and that is not what any of // these cases is about (see the non-deferred case below). @@ -104,7 +104,7 @@ async function readState(): Promise<{ data: unknown; schema: unknown }> { .orderBy(['object', 'tenant_id']), }, // The physical schema, so a case asserting "this boot changed nothing" - // also covers the two INDEX migrations (#5839, #8629). Without this the + // also covers the INDEX migration (#8629). Without this the // only migration a green run could speak for would be #8686's, and a // read-only boot that quietly created an index would pass. schema: await k('sqlite_master').select('type', 'name', 'tbl_name', 'sql').orderBy(['type', 'name']), diff --git a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts index 10cefa49f57..95b33123a45 100644 --- a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts @@ -52,13 +52,15 @@ function securityPackageRoot(): string { return resolve(dirname(require_.resolve('@objectstack/plugin-security')), '..'); } -/** The five tables the data stack alone registers — the pre-fix baseline. */ +/** + * The tables the data stack alone registers — the pre-fix baseline. Five until + * `sys_view_definition` retired as inert (ADR-0131 D13); four since. + */ const ARTIFACTLESS_BASELINE_TABLES = [ 'sys_metadata', 'sys_metadata_audit', 'sys_metadata_commit', 'sys_metadata_history', - 'sys_view_definition', ]; describe('os migrate plan/apply compose the deployment\'s own object set (#12938)', () => { @@ -178,7 +180,7 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 } }; - it('registers the host config\'s objects — well above the five-table baseline', async () => { + it('registers the host config\'s objects — well above the data-stack baseline', async () => { // A database of its own: this case is about what a FRESH target reports as // pending, which is only observable before anything created the tables. const stack = await bootSchemaStack({ @@ -314,8 +316,8 @@ describe('an artifact-less, config-less project is unchanged (#12938 baseline pi try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); - it('still examines exactly the five data-stack tables and composes nothing', async () => { - // The five-table baseline is LEGITIMATE here — there is no deployment to + it('still examines exactly the data-stack tables and composes nothing', async () => { + // The data-stack baseline is LEGITIMATE here — there is no deployment to // mirror — and the fix must not move it. It is also the number the // consumer-side coverage gate is calibrated against, so it is pinned by // value and by membership rather than by "greater than". diff --git a/packages/metadata-core/src/objects/index.ts b/packages/metadata-core/src/objects/index.ts index dc9a1fe42dc..1de0c4024e5 100644 --- a/packages/metadata-core/src/objects/index.ts +++ b/packages/metadata-core/src/objects/index.ts @@ -5,8 +5,10 @@ * * `sys_metadata` + `sys_metadata_history` + `sys_metadata_audit` are the * canonical single-source-of-truth storage substrate for ALL metadata - * customisations (ADR-0005). `sys_view_definition` backs runtime-authored - * shared/personal views (ADR-0017). + * customisations (ADR-0005). Runtime-authored views are `view` items on that + * same substrate: `sys_view_definition`, the table ADR-0017 once declared for + * them, was retired as inert under ADR-0131 D13 (no framework writer or reader + * of its rows ever existed). * * These definitions live HERE (the metadata core package) — not in * `@objectstack/platform-objects` — because the packages that actually read @@ -20,4 +22,3 @@ export { SysMetadataObject, SysMetadataObject as SysMetadata } from './sys-metad export { SysMetadataHistoryObject } from './sys-metadata-history.object.js'; export { SysMetadataCommitObject } from './sys-metadata-commit.object.js'; export { SysMetadataAuditObject } from './sys-metadata-audit.object.js'; -export { SysViewDefinitionObject } from './sys-view-definition.object.js'; diff --git a/packages/metadata-core/src/objects/sys-view-definition.object.test.ts b/packages/metadata-core/src/objects/sys-view-definition.object.test.ts deleted file mode 100644 index 02e5d3874be..00000000000 --- a/packages/metadata-core/src/objects/sys-view-definition.object.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #3026 follow-up — `sys_view_definition` is default-open, and the derivation - * grants it every operation including the batch routes. - * - * The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`, which turned - * this object's boilerplate CRUD-five whitelist into a silent denial of - * `/batch`, `createMany`, `updateMany` and `deleteMany` while the same verbs - * stayed open one record at a time. #3745 completed the whitelist to all six - * primitives; #3543's audit rule then applies — a whitelist naming all six is - * equivalent to no whitelist while NOT tracking future primitives — so the - * declaration is gone. - * - * Deleting it is safe HERE specifically because the object has no `managedBy`: - * `reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array - * `apiMethods`, so for a managed object an absent whitelist would take the - * managed-write backstop with it. That is why the RBAC objects reclaimed by - * #3745 keep their explicit arrays and this one does not. - */ - -import { describe, expect, it } from 'vitest'; -import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; -import { SysViewDefinitionObject } from './sys-view-definition.object.js'; - -describe('sys_view_definition — API exposure (#3026 / #3543 audit)', () => { - it('carries no whitelist, so the resolver reports unrestricted', () => { - // Regression guard both ways: re-adding a whitelist naming all six - // primitives is a no-op that stops tracking future ones, and any narrower - // whitelist silently closes routes that are open today. - expect(SysViewDefinitionObject.enable?.apiMethods).toBeUndefined(); - expect(resolveEffectiveApiMethods(SysViewDefinitionObject.enable).mode).toBe('unrestricted'); - }); - - it('admits createMany / updateMany / deleteMany and /batch', () => { - const eff = resolveEffectiveApiMethods(SysViewDefinitionObject.enable); - for (const child of ['create', 'update', 'delete'] as const) { - expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), `batch ${child}`).toBe(true); - } - }); - - it('derives the data-portability verbs from the primitives', () => { - const eff = resolveEffectiveApiMethods(SysViewDefinitionObject.enable); - for (const op of ['import', 'export', 'upsert', 'aggregate'] as const) { - expect(isApiOperationAllowed(eff, op), op).toBe(true); - } - }); -}); diff --git a/packages/metadata-core/src/objects/sys-view-definition.object.ts b/packages/metadata-core/src/objects/sys-view-definition.object.ts deleted file mode 100644 index 820ffffe353..00000000000 --- a/packages/metadata-core/src/objects/sys-view-definition.object.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -/** - * sys_view_definition — Runtime View Storage ("Object has-many View") - * - * Persists view definitions authored at RUNTIME by end users — the `shared` - * and `personal` layers of the view model (see spec `ViewItemSchema`). The - * `package` layer ships from `*.view.ts` source and lives in the metadata - * registry; it is NOT stored here. - * - * Why a dedicated object (not the `sys_metadata` overlay): runtime views are - * data, not admin metadata customisation. They carry an `owner`, a visibility - * `scope`, and are queried per-user — so they belong in a typed, permissioned - * ObjectQL object rather than the global metadata registry (which a personal - * "My hot leads" view should never pollute). - * - * CRUD flows through ObjectQL's generic data API (`/api/v1/data/ - * sys_view_definition`) — no bespoke per-view REST endpoints. The runtime - * switcher reads the `package` layer via `GET /meta/view?object=` and - * merges these rows client-side, filtered to `scope='shared'` OR - * `owner=`. - * - * This is a system object (isSystem: true) — protected from deletion and - * auto-provisioned on first use. - */ -export const SysViewDefinitionObject = ObjectSchema.create({ - name: 'sys_view_definition', - label: 'View Definition', - pluralLabel: 'View Definitions', - icon: 'layout-grid', - isSystem: true, - description: - 'Runtime-authored view definitions (shared / personal layers). The package layer ships from source.', - - fields: { - /** Primary Key (UUID) */ - id: Field.text({ label: 'ID', required: true, readonly: true }), - - /** - * Globally-unique qualified view id, `.`, matching the - * spec `ViewItemSchema.name`. For personal views the runtime may suffix - * to keep it unique per owner. - */ - name: Field.text({ - label: 'Name', - required: true, - searchable: true, - maxLength: 255, - }), - - /** Bound object — the foreign key used to aggregate views for the switcher. */ - object: Field.text({ - label: 'Object', - required: true, - searchable: true, - maxLength: 255, - }), - - /** Whether `config` is a ListView (list family) or a FormView. */ - view_kind: Field.select(['list', 'form'], { - label: 'View Kind', - required: true, - defaultValue: 'list', - }), - - /** Display label (plain string; i18n keys also accepted). */ - label: Field.text({ label: 'Label', required: false, maxLength: 255 }), - - /** Whether this is the object's default view in the switcher. */ - is_default: Field.boolean({ label: 'Is Default', required: false, defaultValue: false }), - - /** Sort order within the object's switcher / left rail. */ - view_order: Field.number({ label: 'Order', required: false, defaultValue: 0 }), - - /** - * Identity layer. Only `shared` and `personal` are stored at runtime; - * `package` views come from source. - */ - scope: Field.select(['shared', 'personal'], { - label: 'Scope', - required: true, - defaultValue: 'personal', - }), - - /** Owner user id — set when scope = personal; null for shared. */ - owner: Field.text({ label: 'Owner', required: false, maxLength: 255 }), - - /** Hidden from the switcher (per-user / per-org declutter). */ - hidden: Field.boolean({ label: 'Hidden', required: false, defaultValue: false }), - - /** The ListView / FormView configuration payload. */ - config: Field.json({ - label: 'Config', - required: true, - description: 'ListView or FormView configuration (matches spec ViewItem.config).', - }), - - /** Organization for multi-tenant isolation. */ - organization_id: Field.lookup('sys_organization', { - label: 'Organization', - required: false, - description: 'Organization for multi-tenant isolation.', - }), - - /** Lifecycle state. */ - state: Field.select(['draft', 'active', 'archived'], { - label: 'State', - required: false, - defaultValue: 'active', - }), - - /** Audit fields. */ - created_by: Field.lookup('sys_user', { label: 'Created By', required: false, readonly: true }), - created_at: Field.datetime({ label: 'Created At', required: false, readonly: true }), - updated_by: Field.lookup('sys_user', { label: 'Updated By', required: false }), - updated_at: Field.datetime({ label: 'Updated At', required: false }), - }, - - indexes: [ - // A given view name is unique per (organization, owner) — a shared view - // (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, - // 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. - // - // ⚠️ 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 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'], - unique: 'global', - }, - // The switcher query: views for one object within a tenant. - { name: 'idx_sys_view_def_object', fields: ['organization_id', 'object'] }, - { fields: ['scope'] }, - { fields: ['owner'] }, - { fields: ['state'] }, - ], - - enable: { - trackHistory: true, - searchable: false, - apiEnabled: true, - // No `apiMethods` — default-open (#3543 audit). #3745 completed this - // object's whitelist to all six primitives, which is equivalent to no - // whitelist while NOT tracking future primitives. Unlike the RBAC objects - // reclaimed alongside it, this one has no `managedBy`, so there is no - // ADR-0103 D3 managed-write backstop that an explicit array keeps alive — - // nothing argues for keeping the declaration, so it goes. - }, -}); diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index b938c1a5d61..e2a289d4a31 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -32,30 +32,18 @@ export { SDUI_MANIFEST_SERVICE } from './runtime-authoring-gate.js'; // undefined` proxy the #4463 gate used to key its activation off. export type { MetadataAuthoringChannel } from './protocol.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'; +// The probe-first replacement's shared vocabulary: the raw-SQL seam type the +// exported migrations below take, and the dialect classifier their reports +// rest on. Both used to ride out on the `sys_view_definition` migration's +// exports (#5839); that migration retired with its table (ADR-0131 D13), and +// these two are not specific to it, so they are exported from their own module. +export { classifyIndexFailure } from './migrations/partial-index-probe.js'; +export type { IndexExec } from './migrations/partial-index-probe.js'; // [#8629] `sys_setting`'s declared ROW IDENTITY, delivered as a runtime -// NULL-safe UNIQUE migration — the same paradigm again, for the object whose -// `user_id` key part is NULL on every tenant- and global-scope row and was -// therefore constraining nothing there (maintainer ruling 2026-08-14: route 1 +// NULL-safe UNIQUE migration — the `ensureOverlayIndex` paradigm again, for the +// object whose `user_id` key part is NULL on every tenant- and global-scope row +// and was therefore constraining nothing there (maintainer ruling 2026-08-14: route 1 // now, refuse-to-migrate on duplicates, never keep-newest). export { ensureSysSettingIdentityIndex, @@ -75,7 +63,7 @@ export type { EnsureSysSettingIndexStatus, EnsureSysSettingIndexResult, } from './migrations/sys-setting-identity-index.js'; -// #8686 — the seed/API tenancy split. Unlike its two siblings above this +// #8686 — the seed/API tenancy split. Unlike its index sibling above this // migration moves stored ROWS rather than tightening an index, so it is // single-tenant-guarded and reports (never renumbers) identifiers already minted // twice (maintainer ruling 2026-08-15: contract option 1, stored data shape 2). @@ -106,11 +94,11 @@ export { // asserted, which is also what an operator copies out of the docs page. } from './migrations/seed-tenancy-backfill.js'; export type { SeedTenancySeam } from './migrations/seed-tenancy-backfill.js'; -// [#8725] The read-only duplicate PRE-FLIGHT for the three `kernel:ready` index -// tightenings above. Exported because it has a real consumer outside this +// [#8725] The read-only duplicate PRE-FLIGHT for the `kernel:ready` index +// tightenings. Exported because it has a real consumer outside this // package — `os migrate duplicates`, the reporting path the maintainer ruled // (2026-08-22) for a class the drift differ cannot see by construction, and -// which the three migrations' conflict reports now name. Nothing about when a +// which the migrations' conflict reports now name. Nothing about when a // migration runs or what it does changes here; only its evidence becomes // readable one command before the restart. export { diff --git a/packages/metadata-protocol/src/migrations/overlay-index.test.ts b/packages/metadata-protocol/src/migrations/overlay-index.test.ts index 00f136b4287..033b45a4a44 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.test.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.test.ts @@ -28,9 +28,8 @@ import type { IndexExec } from './partial-index-probe.js'; * all — silently, since both `catch` blocks were empty. * * Uses Node's built-in `node:sqlite` rather than `better-sqlite3` (which the - * driver packages use), for the reason the sibling - * `view-definition-active-index.test.ts` gives: this package needs no SQL - * dependency of its own, and the built-in gives the same real SQLite — real + * driver packages use), for the reason the sibling migration suites share: + * this package needs no SQL dependency of its own, and the built-in gives the same real SQLite — real * partial indexes, real UNIQUE enforcement, real NULL-distinctness — for free. */ describe('sys_metadata overlay uniqueness (#6418)', () => { @@ -537,8 +536,7 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { /** * The scope caution, as a test. A full UNIQUE here would reject the ACTIVE - * + DRAFT coexistence this table is built on, which is the key difference - * from `sys_view_definition`. + * + DRAFT coexistence this table is built on. */ it('the dialect fallback is NOT unique, and never drops anything first', () => { const sql = buildOverlayFallbackIndexSql(OVERLAY_INDEX_NAMES.active); diff --git a/packages/metadata-protocol/src/migrations/overlay-index.ts b/packages/metadata-protocol/src/migrations/overlay-index.ts index 1d7a5ccea21..c7617d2f1ed 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.ts @@ -60,8 +60,9 @@ * previous index, name the key that is NOT enforced, ship the exact query that * lists the offending rows, point at `os migrate duplicates`, never block the * boot. `SqlDriver.createNullSafeUniqueIndex` is the in-repo precedent for that - * disposition; the sibling `view-definition-active-index.ts` is the precedent - * for the order. + * disposition; the order's precedent was #5839's `sys_view_definition` + * migration, retired with its table (ADR-0131 D13), and the order itself now + * lives in `partial-index-probe.ts`. * * ⚠️ The referral named `os migrate plan` until #8725, and it was FALSE for * this class: the differ never sees these indexes — `isRuntimeManagedIndex` @@ -82,8 +83,7 @@ * `(type, name, organization_id, package_id)` at the same time — that * coexistence is the entire reason * {@link OVERLAY_DRAFT_INDEX_NAME} exists as a separate index — and an - * unrestricted UNIQUE would reject it. This is the key difference from - * `sys_view_definition`, whose declared index IS a full UNIQUE. + * unrestricted UNIQUE would reject it. * * What #6418 changes about the fallback is not its shape but its honesty: it is * created with `IF NOT EXISTS` and **without** a preceding drop, so it can only diff --git a/packages/metadata-protocol/src/migrations/partial-index-probe.ts b/packages/metadata-protocol/src/migrations/partial-index-probe.ts index 3f917454647..67bc768b2ac 100644 --- a/packages/metadata-protocol/src/migrations/partial-index-probe.ts +++ b/packages/metadata-protocol/src/migrations/partial-index-probe.ts @@ -15,8 +15,8 @@ * and on the dialects that DO support the form the loss is silent. * * That was `ensureOverlayIndex`'s shape until #6418, and it is the exact defect - * `view-definition-active-index.ts` was written to avoid (see its "Why it - * PROBES before dropping anything"). The order this module implements: + * #5839's `sys_view_definition` migration was written to avoid (retired with + * its table, ADR-0131 D13). The order this module implements: * * 1. build the tighter index under a THROWAWAY probe name — nothing that is * currently enforcing is touched, so a failure here costs nothing; @@ -301,7 +301,7 @@ function indexFailureText(error: unknown): string { * down — was graded `failed` rather than `unsupported`. * * That gap is **not** a wording difference, which is why it was worth closing - * rather than documenting. `view-definition-active-index.ts` disposes of the + * rather than documenting. `sys-setting-identity-index.ts` disposes of the * two verdicts identically (keep the previous index, report at `error`), but * `overlay-index.ts` builds the composite **fallback lookup index** on * `unsupported` and only there — offered precisely because a dialect that diff --git a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts index 62880292e89..e9fda8dafe9 100644 --- a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts +++ b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts @@ -15,7 +15,6 @@ import { buildSysSettingDuplicateProbeSqlMysql, buildSysSettingPresenceSql, } from './sys-setting-identity-index.js'; -import { buildDuplicateProbeSql as buildViewActiveDuplicateProbeSql } from './view-definition-active-index.js'; import type { IndexExec } from './partial-index-probe.js'; /** @@ -63,11 +62,13 @@ describe('kernel:ready index pre-flight (#8725)', () => { db = new DatabaseSync(':memory:'); exec = async (sql: string) => db.prepare(sql).all(); - // ── sys_view_definition: two ACTIVE shared views under one name ──── - // The #5839/#6417 tightening's live conflict — `owner` NULL and - // `organization_id` NULL both fold into their sentinel buckets, so the - // two rows collide under the NULL-safe key while the declared, - // NULL-distinct index admits them. + // ── A RETIRED table, still physically present and still damaged ──── + // `sys_view_definition` retired under ADR-0131 D13 together with its + // `kernel:ready` tightening. Schema sync never drops a table, so an + // existing database keeps it — rows, colliding ones included. Nothing + // will ever tighten an index on it again, so a pre-flight that probed + // it would send an operator to resolve rows nothing is refusing. The + // absence is pinned below against exactly this fixture. db.exec(`CREATE TABLE sys_view_definition ( id TEXT PRIMARY KEY, name TEXT, organization_id TEXT, owner TEXT, state TEXT );`); @@ -76,12 +77,6 @@ describe('kernel:ready index pre-flight (#8725)', () => { ); view.run('v1', 'crm_case.all_open', null, null, 'active'); view.run('v2', 'crm_case.all_open', null, null, 'active'); - // The control for the ROW SCOPE: the same collision among ARCHIVED rows - // is legal — the index is partial — and must not be reported. - view.run('v3', 'crm_case.retired', null, null, 'archived'); - view.run('v4', 'crm_case.retired', null, null, 'archived'); - // A personal view that collides with nothing. - view.run('v5', 'crm_case.mine', 'org_x', 'usr_1', 'active'); // ── sys_metadata: two ACTIVE package-less overlays for one key ───── db.exec(`CREATE TABLE sys_metadata ( @@ -97,6 +92,11 @@ describe('kernel:ready index pre-flight (#8725)', () => { // back clear — the two states are independent indexes and a run that // conflated them would report this as blocked too. meta.run('m3', 'view', 'crm_case.board', null, null, 'draft'); + // The control for the ROW SCOPE: the same collision among ARCHIVED rows + // is legal — both overlay indexes are partial — and must not be + // reported by either of them. + meta.run('m4', 'view', 'crm_case.retired', null, null, 'archived'); + meta.run('m5', 'view', 'crm_case.retired', null, null, 'archived'); // ⛔ `sys_setting` is deliberately NOT created: it is registered by the // OPTIONAL `service-settings`, so an ordinary kernel reaches @@ -120,21 +120,9 @@ describe('kernel:ready index pre-flight (#8725)', () => { expect(results.map((entry) => `${entry.index}:${entry.status}`)).toEqual([ 'idx_sys_metadata_overlay_active:blocked', 'idx_sys_metadata_overlay_draft:clear', - 'idx_sys_view_def_active:blocked', 'uniq_sys_setting_organization_id_namespace_key_scope_user_id:table-absent', ]); - // The view-definition conflict, named row-for-row. Both NULL columns are - // reported through their own sentinel bucket, which is what the index - // actually keys on: `organization_id_key = '__global__'` reads as - // "organization_id IS NULL", `owner_key = ''` as "owner IS NULL". - expect(by('idx_sys_view_def_active', results).groups).toEqual([ - { - key: { name: 'crm_case.all_open', organization_id_key: '__global__', owner_key: '' }, - rowCount: 2, - }, - ]); - // The overlay conflict — and here `organization_id` is BARE, because // #6418 deliberately did not fold it. A NULL stays a NULL in the key. expect(by('idx_sys_metadata_overlay_active', results).groups).toEqual([ @@ -149,11 +137,17 @@ describe('kernel:ready index pre-flight (#8725)', () => { }, ]); - // ⭐ The archived pair is NOT reported. The index is partial, those rows - // are outside it, and a pre-flight that flagged them would send an - // operator to delete data nothing is refusing. - const viewKeys = JSON.stringify(by('idx_sys_view_def_active', results).groups); - expect(viewKeys).not.toContain('crm_case.retired'); + // ⭐ The archived pair is NOT reported. Both indexes are partial, those + // rows are outside them, and a pre-flight that flagged them would send + // an operator to delete data nothing is refusing. + const overlayKeys = JSON.stringify(results.map((entry) => entry.groups)); + expect(overlayKeys).not.toContain('crm_case.retired'); + + // ⭐ The retired table is NOT probed, though it is present and damaged + // (ADR-0131 D13): no entry names it, and its colliding view name appears + // nowhere in the report. + expect(results.map((entry) => entry.table)).not.toContain('sys_view_definition'); + expect(JSON.stringify(results)).not.toContain('crm_case.all_open'); expect(by('idx_sys_metadata_overlay_draft', results).groups).toEqual([]); expect(by('uniq_sys_setting_organization_id_namespace_key_scope_user_id', results).groups).toEqual([]); @@ -162,11 +156,11 @@ describe('kernel:ready index pre-flight (#8725)', () => { it('carries the row scope and key parts each migration actually builds', async () => { const results = await collectRuntimeIndexPreflight(exec); - expect(by('idx_sys_view_def_active', results)).toMatchObject({ - migration: 'ensureViewDefinitionActiveIndex', - table: 'sys_view_definition', + expect(by('idx_sys_metadata_overlay_active', results)).toMatchObject({ + migration: 'ensureMetadataOverlayIndexes', + table: 'sys_metadata', rowScope: "state = 'active'", - keyParts: ['name', "COALESCE(organization_id, '__global__')", "COALESCE(owner, '')"], + keyParts: ['type', 'name', 'organization_id', "COALESCE(package_id, '')"], }); expect(by('idx_sys_metadata_overlay_draft', results)).toMatchObject({ migration: 'ensureMetadataOverlayIndexes', @@ -178,13 +172,26 @@ describe('kernel:ready index pre-flight (#8725)', () => { expect(by('uniq_sys_setting_organization_id_namespace_key_scope_user_id', results).rowScope).toBeNull(); }); + it('probes exactly the indexes a kernel:ready migration still tightens — the retired view-definition one is gone (ADR-0131 D13)', () => { + // Every dialect, because the one arm that takes a dialect must not be + // the place a retired probe survives. + for (const client of [undefined, 'better-sqlite3', 'pg', 'mysql2']) { + expect( + runtimeIndexProbes({ client }).map((probe) => `${probe.migration}:${probe.table}:${probe.index}`), + ).toEqual([ + 'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_active', + 'ensureMetadataOverlayIndexes:sys_metadata:idx_sys_metadata_overlay_draft', + 'ensureSysSettingIdentityIndex:sys_setting:uniq_sys_setting_organization_id_namespace_key_scope_user_id', + ]); + } + }); + it('issues the OWNING migration\'s own statements, never a second spelling of the key', () => { const probes = runtimeIndexProbes(); const sql = Object.fromEntries(probes.map((probe) => [probe.index, probe.duplicateSql])); expect(sql['idx_sys_metadata_overlay_active']).toBe(buildOverlayDuplicateProbeSql('active')); expect(sql['idx_sys_metadata_overlay_draft']).toBe(buildOverlayDuplicateProbeSql('draft')); - expect(sql['idx_sys_view_def_active']).toBe(buildViewActiveDuplicateProbeSql()); expect(sql['uniq_sys_setting_organization_id_namespace_key_scope_user_id']).toBe( buildSysSettingDuplicateProbeSql(), ); @@ -223,8 +230,8 @@ describe('kernel:ready index pre-flight (#8725)', () => { it('a seam that accepts every statement and answers none is unreadable, never absent', async () => { // `InMemoryDriver.execute()` shape (#10677): it neither throws nor is // missing, it just returns `null`. Read through the per-index presence - // question alone that would be four `table-absent` entries — a clean - // bill of health from a probe that never ran. + // question alone that would be a `table-absent` entry per index — a + // clean bill of health from a probe that never ran. const noop: IndexExec = async () => null; const results = await collectRuntimeIndexPreflight(noop); @@ -234,14 +241,14 @@ describe('kernel:ready index pre-flight (#8725)', () => { it('reports a probe that throws as unreadable, with the driver\'s own message', async () => { const failing: IndexExec = async (sql: string) => { - if (sql.includes('sys_view_definition') && sql.includes('GROUP BY')) { + if (sql.includes('sys_metadata') && sql.includes("state = 'draft'") && sql.includes('GROUP BY')) { throw new Error('database is locked'); } return db.prepare(sql).all(); }; const results = await collectRuntimeIndexPreflight(failing); - expect(by('idx_sys_view_def_active', results)).toMatchObject({ + expect(by('idx_sys_metadata_overlay_draft', results)).toMatchObject({ status: 'unreadable', detail: 'database is locked', groups: [], diff --git a/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts b/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts index bad2a5a406d..ec89dcc64c1 100644 --- a/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts +++ b/packages/metadata-protocol/src/migrations/runtime-index-preflight.ts @@ -5,15 +5,18 @@ * * ## The gap this closes * - * Three migrations in this directory tighten an existing UNIQUE index into a + * Two migrations in this directory tighten an existing UNIQUE index into a * NULL-safe (and sometimes row-scoped) form at `kernel:ready`: * * | migration | table | index(es) | * |---|---|---| * | `ensureMetadataOverlayIndexes` | `sys_metadata` | active + draft | - * | `ensureViewDefinitionActiveIndex` | `sys_view_definition` | active | * | `ensureSysSettingIdentityIndex` | `sys_setting` | row identity | * + * A third, `ensureViewDefinitionActiveIndex` (`sys_view_definition`), retired + * with its table under ADR-0131 D13: no writer or reader of that table's rows + * existed, so there was no uniqueness of its to protect. + * * Each is a **tightening**, so rows the previous index admitted can block the * build. When that happens the migration refuses (ADR-0120 D4: the previous * index stays, no row is touched) and reports at `error` on the boot channel. @@ -34,9 +37,9 @@ * * Measured end to end before this module existed: a database carrying the same * duplicate damage twice — once under a DECLARED organization-unique index and - * once under `sys_view_definition`'s runtime one — produced an `os migrate - * plan` that named the declared one in full and said nothing whatsoever about - * the runtime one. The control is what makes that evidence rather than a + * once under a runtime one (then `sys_view_definition`'s, since retired) — + * produced an `os migrate plan` that named the declared one in full and said + * nothing whatsoever about the runtime one. The control is what makes that evidence rather than a * reading: a fixture that simply failed to carry damage would have been silent * on both. * @@ -87,14 +90,8 @@ import { buildSysSettingPresenceSql, sysSettingIdentityKeyParts, } from './sys-setting-identity-index.js'; -import { - VIEW_ACTIVE_INDEX_NAME, - VIEW_DEFINITION_TABLE, - buildDuplicateProbeSql as buildViewActiveDuplicateProbeSql, - viewActiveIndexKeyParts, -} from './view-definition-active-index.js'; -/** The column every one of the three duplicate-listing queries counts into. */ +/** The column every one of the duplicate-listing queries counts into. */ const DUPLICATE_ROWS_COLUMN = 'duplicate_rows'; /** @@ -169,16 +166,16 @@ function isMysqlClient(client?: string): boolean { } /** - * Every index the three `kernel:ready` migrations tighten — FOUR, from three + * Every index the `kernel:ready` migrations tighten — THREE, from two * migrations, because `ensureMetadataOverlayIndexes` builds one index per * overlay state and either can be blocked independently. * - * ## Why one arm takes a dialect and three do not + * ## Why one arm takes a dialect and the others do not * * `sys_setting`'s listing query is the only one whose bare spelling is not * merely unidiomatic on MySQL but a parse error: `key` is a RESERVED word * there, measured as `ERROR 1064` on MySQL 8.0.46 (#9434), which is why the - * migration already ships a MySQL-spelled variant. The other three queries name + * migration already ships a MySQL-spelled variant. The other two queries name * no MySQL-reserved identifier, so the platform's own spelling — the one the * migration prints in its boot report — runs on all three dialects, and * compiling a second variant of them would buy nothing and add a second @@ -197,15 +194,6 @@ export function runtimeIndexProbes(opts: { client?: string } = {}): RuntimeIndex return [ overlay('active'), overlay('draft'), - { - migration: 'ensureViewDefinitionActiveIndex', - table: VIEW_DEFINITION_TABLE, - index: VIEW_ACTIVE_INDEX_NAME, - keyParts: viewActiveIndexKeyParts(), - rowScope: "state = 'active'", - presenceSql: buildPresenceSql(VIEW_DEFINITION_TABLE), - duplicateSql: buildViewActiveDuplicateProbeSql(), - }, { migration: 'ensureSysSettingIdentityIndex', table: SYS_SETTING_TABLE, @@ -244,8 +232,8 @@ function groupSortKey(group: RuntimeIndexDuplicateGroup): string { * It separates the two failures the per-probe presence question below cannot * tell apart. Reading "the presence SELECT did not answer" as "the table is not * here" is right when the seam works, and catastrophic when it does not: a seam - * that accepts every statement and answers none of them would report all four - * tightenings as `table-absent` — a clean bill of health from a probe that never + * that accepts every statement and answers none of them would report every + * tightening as `table-absent` — a clean bill of health from a probe that never * ran, which is the #10677 defect `os migrate duplicates` already closed on its * own scan. So liveness is established once, first, against a statement whose * failure cannot mean "absent". @@ -317,8 +305,8 @@ async function runProbe(exec: IndexExec, probe: RuntimeIndexProbe): Promise { - let db: DatabaseSync; - let exec: IndexExec; - - /** Exactly the DDL `syncDeclaredIndexes` produces for the declaration. */ - const DECLARED_INDEX_DDL = - 'CREATE UNIQUE INDEX `idx_sys_view_def_active` on `sys_view_definition` ' + - '(`name`, `organization_id`, `owner`)'; - - const indexDdl = (name: string): string | undefined => - (db.prepare("SELECT sql FROM sqlite_master WHERE type='index' AND name=?").get(name) as - | { sql?: string } - | undefined)?.sql ?? undefined; - - const insert = ( - id: string, - name: string, - org: string | null, - owner: string | null, - state: string, - ): { ok: boolean; error?: string } => { - try { - db.prepare( - 'INSERT INTO sys_view_definition (id, name, organization_id, owner, state) VALUES (?,?,?,?,?)', - ).run(id, name, org, owner, state); - return { ok: true }; - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) }; - } - }; - - const archive = (id: string): void => { - db.prepare("UPDATE sys_view_definition SET state='archived' WHERE id=?").run(id); - }; - - beforeEach(() => { - db = new DatabaseSync(':memory:'); - db.exec(`CREATE TABLE sys_view_definition ( - id TEXT PRIMARY KEY, name TEXT, organization_id TEXT, owner TEXT, state TEXT - );`); - db.exec(DECLARED_INDEX_DDL); - exec = async (sql: string) => db.exec(sql); - }); - - afterEach(() => { - db.close(); - }); - - // ── The nail: an archived view frees its name slot ──────────────────── - - it('BEFORE the migration, an archived view still occupies its slot (the defect)', () => { - expect(insert('v1', 'lead.my_pipeline', 'org1', 'user1', 'active').ok).toBe(true); - archive('v1'); - - const retry = insert('v2', 'lead.my_pipeline', 'org1', 'user1', 'active'); - expect(retry.ok).toBe(false); - expect(retry.error).toContain('UNIQUE constraint failed'); - }); - - it('AFTER the migration, an archived view frees its slot', async () => { - expect(insert('v1', 'lead.my_pipeline', 'org1', 'user1', 'active').ok).toBe(true); - archive('v1'); - - const result = await ensureViewDefinitionActiveIndex(exec); - expect(result.status).toBe('created'); - - // The whole point of the issue: the user can re-create the view they - // archived, under the same name. - expect(insert('v2', 'lead.my_pipeline', 'org1', 'user1', 'active').ok).toBe(true); - }); - - it('the index it leaves behind is the PARTIAL, NULL-SAFE one, under the DECLARED name', async () => { - await ensureViewDefinitionActiveIndex(exec); - - const ddl = indexDdl(VIEW_ACTIVE_INDEX_NAME); - expect(ddl).toBeDefined(); - // The predicate the declaration always promised and never delivered. - expect(ddl!.toLowerCase()).toContain("where state = 'active'"); - expect(ddl!.toLowerCase()).toContain('unique'); - // …over the NULL-safe key parts (#6417), each copied from its own - // in-repo precedent: ADR-0120 D3's sentinel for the tenant column, - // `ensureOverlayIndex`'s `COALESCE(package_id, '')` form for `owner`. - expect(ddl).toContain("COALESCE(organization_id, '__global__')"); - expect(ddl).toContain("COALESCE(owner, '')"); - // Reusing the declared name is what stops `syncDeclaredIndexes` — which - // skips by name — from re-imposing the unrestricted form next boot. - expect(ddl).not.toEqual(DECLARED_INDEX_DDL); - // And the throwaway probe never survives. - expect(indexDdl(VIEW_ACTIVE_PROBE_INDEX_NAME)).toBeUndefined(); - }); - - // ── Uniqueness is scoped, NOT relaxed ───────────────────────────────── - - /** CASE 2 of #6417 — the one bucket that WAS already constrained. */ - it('still rejects two ACTIVE PERSONAL rows with the same (name, organization_id, owner)', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('v3', 'lead.hot', 'org1', 'user1', 'active').ok).toBe(true); - const dup = insert('v4', 'lead.hot', 'org1', 'user1', 'active'); - expect(dup.ok).toBe(false); - // The constraint's IDENTITY, not merely "something threw": SQLite names - // the index for an expression key, so this pins WHICH constraint fired. - expect(dup.error).toContain('UNIQUE constraint failed'); - expect(dup.error).toContain(VIEW_ACTIVE_INDEX_NAME); - }); - - it('admits MANY archived rows under one name — the slot is scoped, not shared', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('a1', 'lead.rev', 'org1', 'user1', 'archived').ok).toBe(true); - expect(insert('a2', 'lead.rev', 'org1', 'user1', 'archived').ok).toBe(true); - // …and an active one alongside them. - expect(insert('a3', 'lead.rev', 'org1', 'user1', 'active').ok).toBe(true); - // …but only ONE active one. - expect(insert('a4', 'lead.rev', 'org1', 'user1', 'active').ok).toBe(false); - }); - - it('keeps distinct owners and orgs independent', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('o1', 'lead.mine', 'org1', 'user1', 'active').ok).toBe(true); - // Same name, different user → a personal view of their own. - expect(insert('o2', 'lead.mine', 'org1', 'user2', 'active').ok).toBe(true); - // Same name, different tenant. - expect(insert('o3', 'lead.mine', 'org2', 'user1', 'active').ok).toBe(true); - }); - - // ── The NULL-distinct hole, now CLOSED (#6417) ──────────────────────── - - /** - * The pin PR #6415 left here read `does NOT close the pre-existing - * NULL-distinct hole for shared views (recorded, not fixed)` and asserted - * that the second insert succeeded. The maintainer ruling of 2026-08-08 - * forbids that outcome, so the pin flips: same fixture, opposite verdict. - * - * `owner` is NULL for SHARED views, and SQL UNIQUE treats NULLs as mutually - * DISTINCT, so the raw column constrained nothing for them — - * `COALESCE(owner, '')` folds every shared row into ONE bucket that is - * unique among itself. CASE 3 of the issue, measured. - */ - it('rejects a second ACTIVE SHARED view (owner NULL) under the same name', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('s1', 'lead.team', 'org1', null, 'active').ok).toBe(true); - const dup = insert('s2', 'lead.team', 'org1', null, 'active'); - expect(dup.ok).toBe(false); - expect(dup.error).toContain('UNIQUE constraint failed'); - expect(dup.error).toContain(VIEW_ACTIVE_INDEX_NAME); - }); - - /** CASE 4 — `organization_id` is NULL for environment-level views. */ - it('rejects a second ACTIVE ENVIRONMENT-LEVEL view (organization_id NULL) under the same name', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('e1', 'lead.env', null, 'user9', 'active').ok).toBe(true); - const dup = insert('e2', 'lead.env', null, 'user9', 'active'); - expect(dup.ok).toBe(false); - expect(dup.error).toContain('UNIQUE constraint failed'); - expect(dup.error).toContain(VIEW_ACTIVE_INDEX_NAME); - }); - - /** Both nullable parts NULL at once — an environment-level SHARED view. */ - it('rejects a second ACTIVE view with BOTH organization_id and owner NULL', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('b1', 'lead.both', null, null, 'active').ok).toBe(true); - const dup = insert('b2', 'lead.both', null, null, 'active'); - expect(dup.ok).toBe(false); - expect(dup.error).toContain('UNIQUE constraint failed'); - expect(dup.error).toContain(VIEW_ACTIVE_INDEX_NAME); - }); - - /** - * The tightening must not have swallowed #5839's row scoping. Shared views - * are the bucket #6417 newly constrains, so the archived exemption is - * re-proved THERE and not only on personal rows. - */ - it('archived SHARED rows stay exempt — the #5839 active-only scoping survives', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('sa1', 'lead.shared_arc', 'org1', null, 'archived').ok).toBe(true); - expect(insert('sa2', 'lead.shared_arc', 'org1', null, 'archived').ok).toBe(true); - // …plus exactly one active shared view alongside them. - expect(insert('sa3', 'lead.shared_arc', 'org1', null, 'active').ok).toBe(true); - expect(insert('sa4', 'lead.shared_arc', 'org1', null, 'active').ok).toBe(false); - - // And the recycling the whole of #5839 was about, on a shared view. - archive('sa3'); - expect(insert('sa5', 'lead.shared_arc', 'org1', null, 'active').ok).toBe(true); - }); - - /** - * The half of the declaration's comment that WAS true stays true: a shared - * view and a personal view may carry one name, and so may two environments' - * / two tenants' rows. The sentinels are chosen so they cannot collide with - * real data — an organization id may never be `'__global__'` (ADR-0120 D3 - * reserves the token) and an owner is a user id, never `''`. - */ - it('a SHARED view and a PERSONAL view still share a name, across tenants too', async () => { - await ensureViewDefinitionActiveIndex(exec); - - expect(insert('x1', 'lead.mix', 'org1', null, 'active').ok).toBe(true); - expect(insert('x2', 'lead.mix', 'org1', 'user1', 'active').ok).toBe(true); - // A shared view in another tenant, and the environment-level one. - expect(insert('x3', 'lead.mix', 'org2', null, 'active').ok).toBe(true); - expect(insert('x4', 'lead.mix', null, null, 'active').ok).toBe(true); - }); - - // ── Idempotence ─────────────────────────────────────────────────────── - - it('is idempotent — a second run leaves the schema byte-identical', async () => { - const first = await ensureViewDefinitionActiveIndex(exec); - const afterFirst = indexDdl(VIEW_ACTIVE_INDEX_NAME); - - const second = await ensureViewDefinitionActiveIndex(exec); - const afterSecond = indexDdl(VIEW_ACTIVE_INDEX_NAME); - - expect(first.status).toBe('created'); - expect(second.status).toBe('created'); - expect(afterSecond).toEqual(afterFirst); - // No probe residue accumulates across runs. - expect(indexDdl(VIEW_ACTIVE_PROBE_INDEX_NAME)).toBeUndefined(); - }); - - it('is idempotent in BEHAVIOUR too — slot recycling survives a re-run', async () => { - await ensureViewDefinitionActiveIndex(exec); - expect(insert('v1', 'lead.p', 'org1', 'user1', 'active').ok).toBe(true); - archive('v1'); - await ensureViewDefinitionActiveIndex(exec); - expect(insert('v2', 'lead.p', 'org1', 'user1', 'active').ok).toBe(true); - }); - - it('converges from a table that never had the declared index at all', async () => { - db.exec(`DROP INDEX ${VIEW_ACTIVE_INDEX_NAME}`); - const result = await ensureViewDefinitionActiveIndex(exec); - expect(result.status).toBe('created'); - expect(indexDdl(VIEW_ACTIVE_INDEX_NAME)!.toLowerCase()).toContain("where state = 'active'"); - }); - - /** - * The upgrade every already-migrated deployment takes: the table arrives - * carrying #5839's partial index with the OLD, NULL-distinct key, and this - * run has to replace it in place — same name, tighter key. - */ - it('upgrades a table already carrying #5839\'s NULL-distinct partial index', async () => { - db.exec(`DROP INDEX ${VIEW_ACTIVE_INDEX_NAME}`); - db.exec( - `CREATE UNIQUE INDEX ${VIEW_ACTIVE_INDEX_NAME} ON sys_view_definition ` + - `(name, organization_id, owner) WHERE state = 'active'`, - ); - // The #5839 shape admits two shared views under one name… - expect(insert('pre1', 'lead.pre', 'org1', null, 'active').ok).toBe(true); - db.prepare("UPDATE sys_view_definition SET state='archived' WHERE id='pre1'").run(); - - const result = await ensureViewDefinitionActiveIndex(exec); - - expect(result.status).toBe('created'); - expect(indexDdl(VIEW_ACTIVE_INDEX_NAME)).toContain("COALESCE(owner, '')"); - // …and after the upgrade it does not. - expect(insert('post1', 'lead.pre', 'org1', null, 'active').ok).toBe(true); - expect(insert('post2', 'lead.pre', 'org1', null, 'active').ok).toBe(false); - }); - - // ── Degradation: the constraint is never destroyed ──────────────────── - - /** - * MySQL has no partial indexes (and, before 8.0.13, no functional key parts - * for the `COALESCE` parts either). The paradigm this module follows - * (`ensureOverlayIndex`) drops the legacy index BEFORE attempting the - * partial one, so a rejected `WHERE` leaves the table with no unique index - * at all. This module probes first for exactly that reason, and this test - * is the proof: after a dialect refusal the ORIGINAL index is still there, - * still enforcing, byte-for-byte unchanged — which IS ADR-0120 D3's - * bare-composite degradation, reached by keeping rather than rebuilding. - */ - it('a dialect that cannot build the form keeps the original UNIQUE index intact', async () => { - const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - const mysqlish: IndexExec = async (sql: string) => { - if (/where/i.test(sql)) { - throw new Error( - "You have an error in your SQL syntax; check the manual … near 'WHERE state = 'active''", - ); - } - return db.exec(sql); - }; - - const result = await ensureViewDefinitionActiveIndex(mysqlish, logger); - - expect(result.status).toBe('unsupported'); - // The pre-existing constraint is untouched — degraded to yesterday's - // behaviour, never below it. - expect(indexDdl(VIEW_ACTIVE_INDEX_NAME)).toEqual(DECLARED_INDEX_DDL); - expect(insert('m1', 'lead.x', 'org1', 'user1', 'active').ok).toBe(true); - expect(insert('m2', 'lead.x', 'org1', 'user1', 'active').ok).toBe(false); - // Reported at `error`, RAISED from #6415's `info` by #6417: the same - // missing DDL now costs an integrity guarantee the platform states it - // enforces (not merely slot recycling), so it lands in the durability - // arm of AGENTS.md's rule — the system keeps looking healthy while - // duplicates accumulate. An `error` owes the consequence and the fix, - // and both gaps that stay open are named. - expect(logger.error).toHaveBeenCalledTimes(1); - const note = String(logger.error.mock.calls[0]![0]); - expect(note).toContain('UNRESTRICTED and NULL-distinct'); - expect(note).toContain('keeps looking healthy'); - expect(note).toContain('an archived view keeps occupying its name slot'); - expect(note).toContain('two same-name ACTIVE shared views (owner NULL)'); - // The fix, and the query that surfaces the duplicates meanwhile. - expect(note).toContain('SQLite/PostgreSQL'); - expect(note).toContain(buildDuplicateProbeSql()); - expect(logger.info).not.toHaveBeenCalled(); - }); - - /** - * The tightening-failure path, on a REAL database rather than a mocked - * throw: seed the exact duplicate pair the old index admitted (#6417 CASE - * 3), then run the migration and assert ADR-0120 D4's whole disposition. - * - * This is the case #5839 could not have — its partial index was strictly - * WEAKER than the one it replaced, so it could not fail on existing data. - * A NULL-safe key can, and does. - */ - it('a pre-existing duplicate pair blocks the tightening — old index kept, rows named, boot survives', async () => { - const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - // Two ACTIVE shared views under one name — legal until today. - expect(insert('d1', 'lead.team', 'org1', null, 'active').ok).toBe(true); - expect(insert('d2', 'lead.team', 'org1', null, 'active').ok).toBe(true); - - const result = await ensureViewDefinitionActiveIndex(exec, logger); - - // Reported, never thrown: a boot must not fail over an index. - expect(result.status).toBe('conflict'); - expect(result.detail).toContain('UNIQUE constraint failed'); - // The PREVIOUS index survives byte-for-byte, and still enforces what it - // always did — at no point is the table left unconstrained. - expect(indexDdl(VIEW_ACTIVE_INDEX_NAME)).toEqual(DECLARED_INDEX_DDL); - expect(insert('d3', 'lead.hot', 'org1', 'user1', 'active').ok).toBe(true); - expect(insert('d4', 'lead.hot', 'org1', 'user1', 'active').ok).toBe(false); - // No probe residue, and no half-built index under either name. - expect(indexDdl(VIEW_ACTIVE_PROBE_INDEX_NAME)).toBeUndefined(); - - // D4's wording contract: what is not enforced, the rows, the command. - expect(logger.error).toHaveBeenCalledTimes(1); - const msg = String(logger.error.mock.calls[0]![0]); - expect(msg).toContain("COALESCE(owner, '')"); - expect(msg).toContain('os migrate duplicates'); - // The repointing (#8725) is only done if the FALSE referral is gone. - expect(msg).not.toContain('os migrate plan'); - expect(msg).toContain(buildDuplicateProbeSql()); - - // …and that shipped query really does name the offending rows, on this - // very database. It is not a decorative string. - const offenders = db.prepare(buildDuplicateProbeSql()).all() as Array>; - expect(offenders).toHaveLength(1); - expect(offenders[0]!.name).toBe('lead.team'); - expect(offenders[0]!.duplicate_rows).toBe(2); - // The folded columns come back under their bucket-key aliases (#6772), - // and the operator loses nothing by reading them: the offending pair is - // `owner IS NULL`, and `''` is the only way that can be spelled here - // because an owner is a user id and never the empty string. - expect(offenders[0]!.organization_id_key).toBe('org1'); - expect(offenders[0]!.owner_key).toBe(''); - // ⚠️ What this test can and cannot see: the pre-#6772 bare projection - // EXECUTED here without error and returned the same one offender row - // with `duplicate_rows: 2` — SQLite grouped it happily, so the three - // assertions above the alias pair were green on the broken query too. - // Only the alias names (and the dialect pin below) move. Running the - // query on a real database therefore proves it lists the rows; it can - // never prove the query is legal on PostgreSQL, because the engine - // this test has is precisely the lenient one. - }); - - /** - * The same disposition when the driver reports the conflict in MySQL's - * wording rather than SQLite's — the classification, not the dialect, is - * what selects the branch. - */ - it('conflicting rows are named at error level and the old index survives (MySQL wording)', async () => { - const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - const conflicting: IndexExec = async (sql: string) => { - if (/CREATE UNIQUE INDEX/i.test(sql)) { - throw new Error("Duplicate entry 'lead.team-__global__-' for key 'idx_sys_view_def_active'"); - } - return db.exec(sql); - }; - - const result = await ensureViewDefinitionActiveIndex(conflicting, logger); - - expect(result.status).toBe('conflict'); - expect(indexDdl(VIEW_ACTIVE_INDEX_NAME)).toEqual(DECLARED_INDEX_DDL); - expect(logger.error).toHaveBeenCalledTimes(1); - const msg = String(logger.error.mock.calls[0]![0]); - expect(msg).toContain("COALESCE(organization_id, '__global__')"); - expect(msg).toContain('os migrate duplicates'); - // The repointing (#8725) is only done if the FALSE referral is gone. - expect(msg).not.toContain('os migrate plan'); - }); - - /** - * The catch-all arm. Same class as the dialect arm — the DDL did not run - * and nothing else looks wrong — so it is `error` too, and it must not be - * QUIETER than the failure we can name. - */ - it('an unclassifiable failure is reported at error and leaves the index alone', async () => { - const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - const broken: IndexExec = async (sql: string) => { - if (/CREATE UNIQUE INDEX/i.test(sql)) throw new Error('disk I/O error'); - return db.exec(sql); - }; - - const result = await ensureViewDefinitionActiveIndex(broken, logger); - - expect(result.status).toBe('failed'); - expect(indexDdl(VIEW_ACTIVE_INDEX_NAME)).toEqual(DECLARED_INDEX_DDL); - expect(logger.error).toHaveBeenCalledTimes(1); - expect(String(logger.error.mock.calls[0]![0])).toContain('can still coexist'); - }); - - it('a host with no raw-SQL driver is a silent no-op, not a failure', async () => { - const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - const result = await ensureViewDefinitionActiveIndex(undefined, logger); - expect(result.status).toBe('no-driver'); - expect(logger.error).not.toHaveBeenCalled(); - expect(logger.warn).not.toHaveBeenCalled(); - }); - - // ── Seams ───────────────────────────────────────────────────────────── - - it('classifies duplicate-row wording as a conflict even when it also says "key"', () => { - // MySQL's duplicate message mentions the key name; the data verdict has - // to win over the dialect verdict or a real conflict reads as "no - // partial index support here". - expect(classifyIndexFailure("Duplicate entry 'a-b-c' for key 'idx_sys_view_def_active'")).toBe( - 'conflict', - ); - expect(classifyIndexFailure('near "WHERE": syntax error')).toBe('unsupported'); - // MariaDB's refusal of a functional key part, which #6417 introduces. - expect(classifyIndexFailure('Functional index on a column is not supported')).toBe('unsupported'); - expect(classifyIndexFailure('disk I/O error')).toBe('failed'); - // #6699: the same verdict off the `code` channel, with prose that - // carries no signal at all. Asserted through THIS module's re-export - // (the public `@objectstack/metadata-protocol` surface), because that is - // the export the classifier's own home is reached by — the full - // channel matrix lives in `partial-index-probe.test.ts`. - expect( - classifyIndexFailure(Object.assign(new Error('insert failed'), { code: 'ER_DUP_ENTRY' })), - ).toBe('conflict'); - }); - - it('buildActiveIndexSql scopes rows AND spells the key NULL-safe', () => { - const sql = buildActiveIndexSql(VIEW_ACTIVE_INDEX_NAME); - expect(sql).toContain("(name, COALESCE(organization_id, '__global__'), COALESCE(owner, ''))"); - expect(sql).toContain("WHERE state = 'active'"); - expect(sql).toContain('IF NOT EXISTS'); - }); - - /** - * The sentinels are the point of #6417, so they are asserted as literals - * rather than only through the builder that produces them: `'__global__'` - * is ADR-0120 D3's reserved token (the driver's `GLOBAL_TENANT`), `''` is - * `ensureOverlayIndex`'s `COALESCE(package_id, '')` form. A silent change - * to either would re-partition every existing index without a migration. - */ - it('pins the two sentinels and the key order', () => { - expect(VIEW_ACTIVE_NULL_SENTINELS).toEqual({ organization_id: '__global__', owner: '' }); - expect(viewActiveIndexKeyParts()).toEqual([ - 'name', - "COALESCE(organization_id, '__global__')", - "COALESCE(owner, '')", - ]); - }); - - it('buildDuplicateProbeSql groups by exactly the index key the CREATE uses', () => { - const probe = buildDuplicateProbeSql(); - // Same key parts as the index, so what it reports and what the index - // rejects cannot diverge. - expect(probe).toContain(`GROUP BY ${viewActiveIndexKeyParts().join(', ')}`); - // The projection is those same key parts — each folded column under - // its bucket-key alias, never bare (#6772; see the dialect pin below). - expect(probe).toContain( - "SELECT name, COALESCE(organization_id, '__global__') AS organization_id_key, " - + "COALESCE(owner, '') AS owner_key, COUNT(*) AS duplicate_rows", - ); - expect(probe).toContain("WHERE state = 'active'"); - expect(probe).toContain('HAVING COUNT(*) > 1'); - }); - - /** - * The query is shipped to an operator inside an `error`-level degradation - * report, on both dialects that can build the index it explains. It has to - * RUN on both. PostgreSQL requires every non-aggregated projection to - * appear verbatim in `GROUP BY`; a bare `organization_id` projected against - * `GROUP BY COALESCE(organization_id, '__global__')` is rejected with - * `must appear in the GROUP BY clause` — which is exactly what shipped - * until #6772, invisible because the only engine the sibling test above can - * run is the lenient one. - * - * Mirrors `overlay-index.test.ts`'s - * `the duplicate-listing query is groupable on PostgreSQL, not only SQLite`. - */ - it('the duplicate-listing query is groupable on PostgreSQL, not only SQLite', () => { - const sql = buildDuplicateProbeSql(); - expect(sql).toEqual( - "SELECT name, COALESCE(organization_id, '__global__') AS organization_id_key, " - + "COALESCE(owner, '') AS owner_key, COUNT(*) AS duplicate_rows " - + "FROM sys_view_definition WHERE state = 'active' " - + "GROUP BY name, COALESCE(organization_id, '__global__'), COALESCE(owner, '') " - + 'HAVING COUNT(*) > 1', - ); - - // PG's rule, applied term by term rather than only to the whole string: - // every BARE projection must be a bare GROUP BY term, and every folded - // column must reach the select list only through its own expression. - const selectList = sql.slice('SELECT '.length, sql.indexOf(' FROM ')); - const groupBy = sql.slice(sql.indexOf('GROUP BY ') + 'GROUP BY '.length, sql.indexOf(' HAVING')); - for (const column of VIEW_ACTIVE_INDEX_COLUMNS) { - const bare = new RegExp(`(^|, )${column}(,|$)`); - const sentinel = VIEW_ACTIVE_NULL_SENTINELS[column]; - if (sentinel === undefined) { - expect(selectList).toMatch(bare); - expect(groupBy).toMatch(bare); - } else { - expect(selectList).not.toMatch(bare); - expect(selectList).toContain(`COALESCE(${column}, '${sentinel}') AS ${column}_key`); - expect(groupBy).toContain(`COALESCE(${column}, '${sentinel}')`); - } - } - }); - - /** - * The order flipped: this used to assert `raw()` first. `IDataDriver` - * declares `execute` non-optionally and has never declared `raw`, so the - * declared surface is the one tried first; `raw` stays as the fallback for a - * host or third-party driver that defines it. See `./driver-exec.ts`, and - * `driver-exec.test.ts` for the four-shape pin. The assertion below is the - * half that actually changed — a driver offering BOTH surfaces. - */ - it('resolveIndexExec prefers execute(), falls back to raw(), else undefined', async () => { - const raw = vi.fn(async () => undefined); - const execute = vi.fn(async () => undefined); - - await resolveIndexExec({ driver: { raw, execute } })!('SELECT 1'); - expect(execute).toHaveBeenCalledWith('SELECT 1', []); - expect(raw).not.toHaveBeenCalled(); - - execute.mockClear(); - await resolveIndexExec({ driver: { execute } })!('SELECT 2'); - expect(execute).toHaveBeenCalledWith('SELECT 2', []); - - // The fallback limb still resolves for a driver that has only `raw`. - await resolveIndexExec({ driver: { raw } })!('SELECT 3'); - expect(raw).toHaveBeenCalledWith('SELECT 3', []); - - // getDriver() and the drivers Map, the two other shapes the paradigm walks. - expect(resolveIndexExec({ getDriver: () => ({ raw }) })).toBeTypeOf('function'); - expect(resolveIndexExec({ drivers: new Map([['a', { execute }]]) })).toBeTypeOf('function'); - expect(resolveIndexExec({})).toBeUndefined(); - expect(resolveIndexExec({ driver: {} })).toBeUndefined(); - }); - - it('asks which driver OWNS sys_view_definition before taking any default', () => { - const owner = { raw: vi.fn(async () => undefined) }; - const fallback = { raw: vi.fn(async () => undefined) }; - const getDriverForObject = vi.fn(() => owner); - - const resolved = resolveIndexExec({ getDriverForObject, driver: fallback }); - - // The table-scoped answer wins over the engine-wide default: on a - // multi-datasource kernel the platform objects can live elsewhere. - expect(getDriverForObject).toHaveBeenCalledWith('sys_view_definition'); - void resolved!('SELECT 1'); - expect(owner.raw).toHaveBeenCalled(); - expect(fallback.raw).not.toHaveBeenCalled(); - }); - - /** - * Regression pin. `ObjectQL.getDriver(objectName)` REQUIRES an object name - * and throws `No driver available for object 'undefined'` without one, so - * the paradigm's bare `getDriver?.()` probe throws on a memory-driver - * kernel. `ensureOverlayIndex` never notices because its entire body sits - * in a swallow-everything try/catch; this resolver runs from a - * `kernel:ready` hook, where a throw failed 16 ObjectQL boot tests before - * each probe was guarded individually. - */ - it('never throws when the engine\'s driver accessors do', () => { - const thrower = () => { - throw new Error("[ObjectQL] No driver available for object 'undefined'"); - }; - - expect(resolveIndexExec({ getDriver: thrower, getDriverForObject: thrower })).toBeUndefined(); - expect(() => resolveIndexExec({ getDriver: thrower })).not.toThrow(); - expect( - resolveIndexExec({ - getDriverForObject: thrower, - get driver() { - throw new Error('boom'); - }, - drivers: new Map([['memory', { execute: vi.fn(async () => undefined) }]]), - }), - ).toBeTypeOf('function'); - }); -}); diff --git a/packages/metadata-protocol/src/migrations/view-definition-active-index.ts b/packages/metadata-protocol/src/migrations/view-definition-active-index.ts deleted file mode 100644 index 7de61ee3a88..00000000000 --- a/packages/metadata-protocol/src/migrations/view-definition-active-index.ts +++ /dev/null @@ -1,422 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * `sys_view_definition` — active-row uniqueness, delivered at runtime (#5839). - * - * ## What was broken - * - * `metadata-core`'s `sys-view-definition.object.ts` declares - * - * ```ts - * { name: 'idx_sys_view_def_active', fields: ['name', 'organization_id', 'owner'], unique: 'global' } - * ``` - * - * and its comment has always promised uniqueness **among ACTIVE rows**. It - * never delivered that. The declaration carried `partial: "state = 'active'"` - * until #5248 / #4943 retired the key, but no driver ever emitted the - * predicate — `SqlDriver.syncDeclaredIndexes` builds indexes through knex's - * `table.unique(fields, { indexName })`, which cannot express a `WHERE`. So - * the index that has always been created is the UNRESTRICTED one, and an - * archived view keeps occupying its `(name, organization_id, owner)` slot: - * archive "my pipeline", try to create "my pipeline" again, and the insert is - * rejected by a constraint about a row the user already threw away. - * - * Measured on real SQLite before this module existed: - * - * ```text - * insert active personal view : OK - * archive it; re-create same : REJECTED: UNIQUE constraint failed: - * sys_view_definition.name, sys_view_definition.organization_id, sys_view_definition.owner - * ``` - * - * `sys_metadata` never had this problem because `metadata-protocol`'s - * `ensureOverlayIndex` issues the partial form in raw SQL at runtime. This - * module is the same paradigm for the one other table that declared the same - * intent and had no runtime migration behind it (maintainer ruling - * 2026-08-06: view-name slots ARE recyclable). - * - * ## Why the index REUSES the declared name - * - * `syncDeclaredIndexes` skips by name (`if (existing.has(name)) continue`). - * Creating the partial index under `idx_sys_view_def_active` — the same name - * the object declares — is therefore what makes the fix durable: every later - * boot sees the name occupied and never re-imposes the unrestricted UNIQUE. - * A differently-named index would be silently undone on the next boot, and - * dropping the declaration instead would leave drivers that never run this - * migration with no uniqueness at all (the declaration is the fallback shape). - * - * ## Why it PROBES before dropping anything - * - * `ensureOverlayIndex` drops the legacy index and then creates the partial - * one. If that create fails — no partial-index support (MySQL), or rows that - * violate the new key — the table is left with NO unique constraint at all, - * silently. This module inverts the order: it first builds the partial index - * under a throwaway probe name, and only once that has demonstrably succeeded - * does it drop the legacy index and rebuild it under the declared name. On any - * dialect or dataset that cannot take the partial form, the existing - * unrestricted UNIQUE is left exactly as it was — degraded to today's - * behaviour, never below it. The cost is building a small index twice on the - * boot that migrates; the benefit is that the failure mode cannot destroy a - * live constraint. - * - * ## The KEY is NULL-safe too (#6417, maintainer ruling 2026-08-08) - * - * #5839 changed only the ROW SCOPE and deliberately left the key spelling - * alone. That left the other half of the same index broken, and #6415 pinned - * the gap honestly rather than closing it: SQL UNIQUE treats NULLs as - * DISTINCT, `owner` is NULL for SHARED views and `organization_id` is NULL for - * environment-level ones, so the index constrained PERSONAL views only. - * Measured on real SQLite over the driver's own DDL, before this half landed: - * - * ```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 - * ``` - * - * The maintainer ruling forbids that: two same-name active shared (or - * environment-level) views may not coexist, because `name` is declared as the - * globally unique qualified view id (`.`) and every read path - * that locates a view by name otherwise has no defined answer. - * - * The mechanism copies the two in-repo precedents rather than inventing a - * third — `COALESCE` the nullable key columns so their NULLs fold into ONE - * bucket that is unique among itself: - * - * - `organization_id` is the tenant column, so it takes **ADR-0120 D3**'s - * exact form, `COALESCE(organization_id, '__global__')` — the same sentinel - * `SqlDriver`'s `GLOBAL_TENANT` / `organizationKeyPartSql` materialize, so a - * violation message reads "the platform bucket collided" rather than as - * corrupt data. - * - `owner` is not a tenant column; it plays exactly `package_id`'s role in - * `ensureOverlayIndex` (a nullable discriminator whose NULL means "the one - * shared bucket"), so it takes THAT precedent's form, - * `COALESCE(owner, '')` — whose comment states this same NULL-distinct - * reason. - * - * Neither sentinel can collide with real data: an organization id may never - * equal `'__global__'` (reserved at the organization-creation seam, ADR-0120 - * D3) and an owner is a user id, never the empty string. Storage is untouched - * — only the INDEX folds NULL into a bucket, exactly as D3 specifies. - * - * ## Why a conflict IS expected now (and how it is reported) - * - * Under #5839 alone a conflict was near-unreachable: the partial index was - * strictly WEAKER than the unrestricted one it replaced — its active rows are - * a subset of all rows — so any database that satisfied the old constraint - * necessarily satisfied the new one. - * - * The NULL-safe key inverts that. It is a **tightening**: rows the old index - * admitted (two active shared views under one name) violate the new one, and - * such rows exist in the wild today precisely because nothing rejected them. - * So the probe-first order above stops being belt-and-braces and becomes the - * load-bearing part — and the conflict branch is a live path, not a corner. - * It is handled the way ADR-0120 D4 requires: the PREVIOUS index stays in - * place (never a table with no unique index at all), the report names the key - * that is not enforced, ships the exact query that lists the offending rows, - * points at `os migrate duplicates`, and the boot continues. - * - * ⚠️ The referral used to name `os migrate plan`, and it was FALSE (#8725): - * `plan` reports drift, and this index is invisible to the differ by - * construction — excluded by `isRuntimeManagedIndex` once the partial form is - * built, and indistinguishable from its own declaration before that, because - * the migration reuses the declared NAME on purpose (see above). Measured with - * a matched control: one database carrying the same duplicate damage under a - * declared index and under this one, `plan` named the declared one in full and - * said nothing about this one. Maintainer ruling 2026-08-22 routes this class - * to `os migrate duplicates`, which boots read-only and owns the "inventory, - * never repair" contract, and leaves `plan`'s drift contract untouched. - */ - -import { - logProblem, - probeThenReplaceIndex, - resolveIndexExecForTable, - type IndexExec, - type IndexMigrationLogger, - type PartialIndexStatus, -} from './partial-index-probe.js'; - -/** The one table this migration touches. */ -export const VIEW_DEFINITION_TABLE = 'sys_view_definition'; - -/** - * The index name — deliberately the SAME one `sys-view-definition.object.ts` - * declares, so `syncDeclaredIndexes` treats the slot as filled forever after. - */ -export const VIEW_ACTIVE_INDEX_NAME = 'idx_sys_view_def_active'; - -/** Throwaway name used to prove the partial form is possible before dropping. */ -export const VIEW_ACTIVE_PROBE_INDEX_NAME = 'idx_sys_view_def_active_probe'; - -/** - * The key COLUMNS the declaration names, unchanged. What #6417 changes is how - * two of them are SPELLED in the index — see {@link VIEW_ACTIVE_NULL_SENTINELS}. - */ -export const VIEW_ACTIVE_INDEX_COLUMNS = ['name', 'organization_id', 'owner'] as const; - -/** - * The nullable key columns and the sentinel each one's NULL folds to (#6417). - * - * A column listed here is materialized as `COALESCE(, '')` - * so its NULL rows form ONE bucket that is unique among itself, instead of - * being mutually DISTINCT and therefore unconstrained. Both spellings are - * copied from an existing in-repo precedent — neither is invented here: - * - * - `organization_id` → `'__global__'`, ADR-0120 D3's exact form for a tenant - * column (`SqlDriver`'s `GLOBAL_TENANT` / `organizationKeyPartSql`). NOT - * imported from `@objectstack/driver-sql`: this package must not depend on a - * driver. Both literals are therefore pinned as literals by the sibling - * test, so a silent edit here cannot re-partition every existing index. - * - `owner` → `''`, the `ensureOverlayIndex` precedent for a NON-tenant - * nullable discriminator (`COALESCE(package_id, '')`), whose comment states - * this same NULL-distinct reason. - * - * `name` is `required: true` and takes no sentinel. - * - * ⚠️ Storage is NOT touched: the row keeps its NULL, only the index folds it. - * `WHERE owner = ''` matches nothing, by design (ADR-0120 D3's invariant). - */ -export const VIEW_ACTIVE_NULL_SENTINELS: Readonly> = { - organization_id: '__global__', - owner: '', -}; - -/** - * The index's key parts, in key order: a bare column, or its NULL-safe - * `COALESCE` form when {@link VIEW_ACTIVE_NULL_SENTINELS} names one. - * - * One builder so the CREATE, the duplicate-listing query the conflict report - * ships, and the degradation messages can never describe different keys. - */ -export function viewActiveIndexKeyParts(): string[] { - return VIEW_ACTIVE_INDEX_COLUMNS.map((column) => { - const sentinel = VIEW_ACTIVE_NULL_SENTINELS[column]; - return sentinel === undefined ? column : `COALESCE(${column}, '${sentinel}')`; - }); -} - -/** - * The raw-SQL seam, the logger surface, the status vocabulary and the failure - * classifier all live in `partial-index-probe.ts` since #6418, when - * `ensureOverlayIndex` adopted this module's probe-first order and the two - * migrations stopped being able to afford separate copies of them. Re-exported - * under this module's original names so its callers and `index.ts` see no - * change. - */ -export type { IndexExec } from './partial-index-probe.js'; -export { classifyIndexFailure } from './partial-index-probe.js'; - -/** @see IndexMigrationLogger */ -export type EnsureViewIndexLogger = IndexMigrationLogger; - -/** @see PartialIndexStatus */ -export type EnsureViewIndexStatus = PartialIndexStatus; - -export interface EnsureViewIndexResult { - status: EnsureViewIndexStatus; - /** Driver error text, when there was one. */ - detail?: string; -} - -/** - * `CREATE UNIQUE INDEX … WHERE state = 'active'` under the given name, over the - * NULL-safe key parts (#6417). - */ -export function buildActiveIndexSql(indexName: string): string { - return ( - `CREATE UNIQUE INDEX IF NOT EXISTS ${indexName} ` + - `ON ${VIEW_DEFINITION_TABLE} (${viewActiveIndexKeyParts().join(', ')}) ` + - `WHERE state = 'active'` - ); -} - -/** - * The query that lists the rows blocking the tightening — ADR-0120 D4's - * "name the offending rows", shipped inside the conflict report so an operator - * has it without waiting for `os migrate duplicates`. - * - * The same statement is what `os migrate duplicates` issues for this index - * (#8725): the command reads this builder rather than restating the key, so the - * pre-flight and the boot report can never describe different duplicates. - * - * It GROUPs by exactly the index's own key parts, so what it reports and what - * the index rejects cannot diverge — the projection and the `GROUP BY` are - * built from the SAME array below, so they cannot drift apart either. - * - * ⚠️ Each folded column is projected through its OWN `COALESCE` (aliased - * `_key`), never bare. The three constructs are ANSI, but a query that - * projects a bare column while grouping by that column only INSIDE an - * expression is not: PostgreSQL requires every non-aggregated projection to - * appear verbatim in `GROUP BY` and rejects the bare form with - * `column "sys_view_definition.organization_id" must appear in the GROUP BY - * clause`. SQLite accepts it, which is why this shipped broken (#6772) — and - * PostgreSQL is one of exactly TWO dialects that can build the index this - * query explains, so it must be legal on both, not on the lenient one. - * - * Folding costs the operator nothing here: neither sentinel can occur in real - * data, so `organization_id_key = '__global__'` reads as "organization_id IS - * NULL" and `owner_key = ''` as "owner IS NULL" (see - * {@link VIEW_ACTIVE_NULL_SENTINELS}). - * - * Same shape as `overlay-index.ts`'s `buildOverlayDuplicateProbeSql()`, the - * sibling migration's query for the same report (#6770). - */ -export function buildDuplicateProbeSql(): string { - const keyParts = viewActiveIndexKeyParts(); - const projected = VIEW_ACTIVE_INDEX_COLUMNS.map((column, i) => { - const keyPart = keyParts[i]!; - return keyPart === column ? column : `${keyPart} AS ${column}_key`; - }); - return ( - `SELECT ${projected.join(', ')}, COUNT(*) AS duplicate_rows ` + - `FROM ${VIEW_DEFINITION_TABLE} WHERE state = 'active' ` + - `GROUP BY ${keyParts.join(', ')} HAVING COUNT(*) > 1` - ); -} - -/** - * Resolve a raw-SQL seam for `sys_view_definition`. - * - * The body moved to `partial-index-probe.ts` as - * {@link resolveIndexExecForTable} when #8629 made `sys_setting` the third - * caller of the same eight-line probe. This name stays because it is exported - * from the package index and armed by `plugin.ts`; what it resolves — the driver - * that OWNS this table, never the engine-wide default — is unchanged, and the - * shared function's header states why each fallback is in the order it is. - */ -export function resolveIndexExec(engine: unknown): IndexExec | undefined { - return resolveIndexExecForTable(engine, VIEW_DEFINITION_TABLE); -} - -/** - * Replace `sys_view_definition`'s unrestricted, NULL-distinct UNIQUE index with - * the active-row-scoped (#5839) NULL-safe (#6417) partial UNIQUE the - * declaration has always described. - * - * Idempotent: re-running rebuilds the same definition, so the resulting schema - * is byte-identical. Best-effort by design — a boot must never fail because an - * index could not be tightened, which is why every branch returns a status - * instead of throwing. - */ -export async function ensureViewDefinitionActiveIndex( - exec: IndexExec | undefined, - logger?: EnsureViewIndexLogger, -): Promise { - if (!exec) return { status: 'no-driver' }; - - // The probe-first order — prove the partial form is possible under a - // throwaway name, and only THEN drop the declared name and rebuild it — - // lives in `partial-index-probe.ts` since #6418, when `ensureOverlayIndex` - // adopted it. Claiming the DECLARED name is what stops `syncDeclaredIndexes` - // (which skips by name) from re-imposing the unrestricted form next boot. - const outcome = await probeThenReplaceIndex(exec, { - indexName: VIEW_ACTIVE_INDEX_NAME, - probeIndexName: VIEW_ACTIVE_PROBE_INDEX_NAME, - buildSql: buildActiveIndexSql, - }); - - if (outcome.status === 'created') return { status: 'created' }; - - const detail = outcome.detail ?? ''; - if (outcome.failedAt === 'replace') { - // Only reachable on a race with another process between the drop and - // the create — the probe already cleared dialect and data. Say so - // rather than leaving a table that now has no unique index at all. - logProblem( - logger, - `[metadata-protocol] could not create '${VIEW_ACTIVE_INDEX_NAME}' on ` + - `"${VIEW_DEFINITION_TABLE}" after the probe succeeded — the table may currently have NO ` + - `unique index on (${viewActiveIndexKeyParts().join(', ')}). Restart to retry.`, - detail, - ); - return { status: 'failed', detail }; - } - - reportDegradation(outcome.status, detail, logger); - return { status: outcome.status, detail }; -} - -/** - * Say what is NOT enforced and what fixes it — ADR-0120 D4's wording contract, - * which `SqlDriver.createNullSafeUniqueIndex` already follows for the same - * class of event. Never fails the boot: from the outside everything else looks - * normal, so silence here is what makes the gap expensive. - */ -function reportDegradation( - status: EnsureViewIndexStatus, - detail: string, - logger?: EnsureViewIndexLogger, -): void { - const columns = VIEW_ACTIVE_INDEX_COLUMNS.join(', '); - const keyParts = viewActiveIndexKeyParts().join(', '); - if (status === 'unsupported') { - // Expected on MySQL/MariaDB, which has no partial indexes at all (and, - // before 8.0.13, no functional key parts either). The outcome is - // exactly ADR-0120 D3's degradation — the BARE composite stays in - // force — reached by keeping the declared index rather than by - // rebuilding it, which is also `createNullSafeUniqueIndex`'s handling. - // - // ⚠️ Level RAISED from `info` to `error` by #6417, and the reason is - // that the CONSEQUENCE of the same missing DDL changed in kind, not - // that #6415's judgment was wrong. Under #5839 alone what MySQL lost - // was slot RECYCLING: a functional degradation, visibly smaller, the - // next user to re-create an archived view finds out immediately — the - // `warn`/`info` arm of AGENTS.md's rule, exactly as #6415 argued. - // What it loses now is an INTEGRITY guarantee this platform states it - // enforces: two same-name active shared views can coexist, nothing - // looks broken, and the duplicate surfaces releases later to someone - // who cannot connect it to a boot line. That is the `error` arm's own - // description ("DDL that was supposed to run did not"), the #4420 - // class the rule exists for, and the level - // `SqlDriver.createNullSafeUniqueIndex` uses for the same event. - // - // As an `error` owes: the consequence concretely, and the fix. - logProblem( - logger, - `[metadata-protocol] this database cannot build the active-row NULL-safe index — ` + - `'${VIEW_ACTIVE_INDEX_NAME}' on "${VIEW_DEFINITION_TABLE}" stays UNRESTRICTED and NULL-distinct ` + - `over (${columns}), the bare-composite degradation of ADR-0120 D3. The system keeps looking ` + - `healthy while two consequences hold on this dialect: an archived view keeps occupying its ` + - `name slot, and two same-name ACTIVE shared views (owner NULL) or environment-level ` + - `views (organization_id NULL) can still coexist even though the platform states they cannot. ` + - `MySQL/MariaDB has no partial indexes, so there is no in-dialect fix: run this ` + - `platform on SQLite/PostgreSQL for the guarantee, and meanwhile watch for duplicates with: ` + - `${buildDuplicateProbeSql()}`, - detail, - ); - return; - } - if (status === 'conflict') { - // A LIVE path since #6417: the NULL-safe key is a tightening, so rows - // the previous index admitted — two active shared views under one name - // — now block the build. ADR-0120 D4's disposition, in full: keep the - // previous index (never an unconstrained table), name the key that is - // not enforced, hand over the exact query that lists the offending - // rows, point at `os migrate duplicates`, and let the boot continue. - // The referral is repointed rather than dropped (#8725): the rows ARE - // reportable before a restart, just not by the drift differ. - logProblem( - logger, - `[metadata-protocol] cannot tighten '${VIEW_ACTIVE_INDEX_NAME}' on "${VIEW_DEFINITION_TABLE}" — ` + - `existing rows violate (${keyParts}) among state='active'. The previous index is left in ` + - `place, so (${columns}) is enforced only as far as it was before; the NULL-safe key is NOT ` + - `enforced until the duplicates are resolved. List them with: ${buildDuplicateProbeSql()} — or ` + - `run "os migrate duplicates" — then restart (ADR-0120 D4).`, - detail, - ); - return; - } - // The catch-all ('failed'), raised alongside the dialect arm above and for - // the same reason. Leaving it at `warn` would report the case we UNDERSTAND - // (a named dialect limitation) more loudly than the one we do not, while - // the consequence is identical: the DDL did not run, the NULL-safe key is - // not in force, and nothing else looks wrong. - logProblem( - logger, - `[metadata-protocol] could not rebuild '${VIEW_ACTIVE_INDEX_NAME}' on "${VIEW_DEFINITION_TABLE}" as ` + - `the active-row NULL-safe index; the existing index is unchanged, so two same-name ACTIVE shared ` + - `views can still coexist while everything else looks healthy. Fix the cause below and restart.`, - detail, - ); -} diff --git a/packages/metadata-protocol/src/plugin.metadata-objects-retired.test.ts b/packages/metadata-protocol/src/plugin.metadata-objects-retired.test.ts new file mode 100644 index 00000000000..34e2373a6eb --- /dev/null +++ b/packages/metadata-protocol/src/plugin.metadata-objects-retired.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; + +import { assembleMetadataProtocol } from './plugin.js'; + +/** + * ADR-0131 D13 — `sys_view_definition` retired as inert: no framework writer or + * reader of its rows ever existed (runtime-authored views are `view` items in + * `sys_metadata`). These pins hold the protocol assembly — the one seam both + * mounts share — to that, from the two places it used to carry the table: + * + * 1. the `com.objectstack.metadata-objects` registration provisions exactly + * the four metadata-storage objects; + * 2. the `kernel:ready` repair hook issues no statement against the retired + * table (#5839's active-row index migration rode that hook). + * + * The SQL seam is a recording double on purpose: the claim is about which + * statements the hook ISSUES, not about what a database answers. Each + * migration that does run here answers its own refusal into a `warn`, which + * the hook swallows by design; nothing below depends on that path. + */ +function assemble(options: { runPlatformMigrations?: boolean } = {}) { + const registeredApps: Array<{ id: string; objects: Array<{ name: string }> }> = []; + const hooks = new Map unknown>>(); + const statements: string[] = []; + const execute = async (sql: string) => { + statements.push(String(sql)); + return []; + }; + const ql = { + registerApp: (app: any) => registeredApps.push(app), + driver: { execute, raw: execute, config: { client: 'better-sqlite3' } }, + getDriver: () => ({ execute, raw: execute, config: { client: 'better-sqlite3' } }), + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + getServices: () => new Map(), + hook: (name: string, handler: () => unknown) => { + hooks.set(name, [...(hooks.get(name) ?? []), handler]); + }, + } as any; + + assembleMetadataProtocol(ctx, ql, undefined, options); + return { registeredApps, hooks, statements }; +} + +describe('the protocol assembly carries no sys_view_definition (ADR-0131 D13)', () => { + it('registers exactly the four metadata-storage objects', () => { + const { registeredApps } = assemble(); + const app = registeredApps.find((a) => a.id === 'com.objectstack.metadata-objects'); + expect(app, 'the metadata-objects registration must still happen on an unscoped kernel').toBeDefined(); + const names = app!.objects.map((o) => o.name); + expect(names).not.toContain('sys_view_definition'); + expect([...names].sort()).toEqual([ + 'sys_metadata', + 'sys_metadata_audit', + 'sys_metadata_commit', + 'sys_metadata_history', + ]); + }); + + it('arms a kernel:ready repair hook that issues no statement against the retired table', async () => { + const { hooks, statements } = assemble({ runPlatformMigrations: true }); + const ready = hooks.get('kernel:ready') ?? []; + // Two handlers: the gated platform-table repairs, and the ungated + // migration-plan registration. Anti-vacuity: the gated one must be here + // and must reach the seam, or "no statement names the table" is true of + // a hook that never ran. + expect(ready.length).toBe(2); + for (const handler of ready) await handler(); + expect(statements.length).toBeGreaterThan(0); + expect(statements.filter((sql) => sql.includes('sys_view_definition'))).toEqual([]); + expect(statements.filter((sql) => sql.includes('idx_sys_view_def'))).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/plugin.ts b/packages/metadata-protocol/src/plugin.ts index 122da992e94..1d27a1a0669 100644 --- a/packages/metadata-protocol/src/plugin.ts +++ b/packages/metadata-protocol/src/plugin.ts @@ -29,12 +29,7 @@ import { SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, - SysViewDefinitionObject, } from '@objectstack/metadata-core'; -import { - ensureViewDefinitionActiveIndex, - resolveIndexExec, -} from './migrations/view-definition-active-index.js'; import { ensureSysSettingIdentityIndex, resolveSysSettingIndexSeam, @@ -200,8 +195,8 @@ export function assembleMetadataProtocol( environmentId?: string, options: AssembleMetadataProtocolOptions = {}, ): ObjectStackProtocolImplementation { - // Metadata-storage platform objects (sys_metadata + history/audit - // siblings + sys_view_definition). Same `environmentId === undefined` + // Metadata-storage platform objects (sys_metadata + its history, + // commit and audit siblings). Same `environmentId === undefined` // gate as the historical assembly: platform / standalone kernels own // their local sys_metadata; per-project (cloud) kernels source // metadata from the control plane and must NOT provision these @@ -212,13 +207,13 @@ export function assembleMetadataProtocol( // `runPlatformMigrations` gate below, even though the two blocks // share a comment and a history. Measured: `MetadataPlugin` already // registers `com.objectstack.metadata-objects` with EXACTLY these - // five objects (`queryableMetadataObjects` in + // four objects (`queryableMetadataObjects` in // `packages/metadata/src/plugin.ts`, gated on // `registerSystemObjects !== false`, whose own note says it is // registered there "not only in the ObjectQLPlugin // `environmentId === undefined` standalone path"). So on a // standalone boot this block is redundant, not missing — flipping - // it on would put five more registrations into a registry that + // it on would put four more registrations into a registry that // already has them, and would show up as new pending schema work in // `os migrate plan`'s output for no gain. Arming the migrations is // this card's surface; provisioning is not. @@ -234,7 +229,6 @@ export function assembleMetadataProtocol( SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, - SysViewDefinitionObject, ], }); } @@ -257,67 +251,55 @@ export function assembleMetadataProtocol( ctx.registerService('protocol', protocolShim); ctx.logger.info('Protocol service registered (MetadataProtocolPlugin)'); - // #5839 — `sys_view_definition`'s "unique among ACTIVE rows" was - // never delivered by anything: the declaration's `partial` key was - // DDL-inert (and is now retired, #5248 / #4943), and unlike - // `sys_metadata` this table had no runtime migration behind it, so - // an archived view kept occupying its (name, organization_id, - // owner) slot and the user could not re-create a view they had - // just thrown away. Same paradigm as the protocol's own - // `ensureOverlayIndex`, armed from THIS assembly because this is - // the one seam both mounts share (MetadataProtocolPlugin's - // delegated mode AND ObjectQLPlugin's built-in - // `registerProtocol !== false` convenience mode) — a hook on the - // delegated plugin alone would miss the default mount entirely. + // The `kernel:ready` platform-table repairs this assembly arms: #8629 + // (`sys_setting`'s row identity) and #8686 (the seed/API tenancy + // backfill). They ride THIS assembly because it is the one seam both + // mounts share (MetadataProtocolPlugin's delegated mode AND + // ObjectQLPlugin's built-in `registerProtocol !== false` convenience + // mode) — a hook on the delegated plugin alone would miss the default + // mount entirely. (#5839's `sys_view_definition` active-row index + // rode here first; it retired with its table, ADR-0131 D13.) // // Gated for exactly the reason the registerApp block above is: // per-project (cloud) kernels do not provision these tables - // locally, so there is no index of ours to tighten there. + // locally, so there is nothing of ours to repair there. // - // [#9380] The gate is now DECLARED (`runPlatformMigrations`) rather + // [#9380] The gate is DECLARED (`runPlatformMigrations`) rather // than deduced from `environmentId === undefined`. The deduction was // wrong in the direction that mattered: the standalone stack stamps // `'env_local'`, so this whole block never armed on a self-hosted - // boot and the three migrations below reached no self-hosted - // install. The registerApp block above keeps the old predicate on - // purpose — see the note there. + // boot and the migrations below reached no self-hosted install. The + // registerApp block above keeps the old predicate on purpose — see + // the note there. // - // Deferred to `kernel:ready` because the table has to EXIST first — - // ObjectQLPlugin creates it in `start()` via `syncRegisteredSchemas`, + // Deferred to `kernel:ready` because the tables have to EXIST first — + // ObjectQLPlugin creates them in `start()` via `syncRegisteredSchemas`, // which runs after every plugin's `init()`. // // Wrapped so it can NEVER fail a bootstrap: `kernel:ready` handlers // propagate, and an index we could not tighten is not a reason to // refuse to boot. (`ensureOverlayIndex` gets this by wrapping its // whole body in a swallow-everything try/catch; the same guarantee, - // stated once here, keeps the migration itself readable.) - // #8629 rides the SAME seam and the same gate, for the same reasons - // — `sys_setting`'s declared row identity + // stated once here, keeps each migration itself readable.) + // + // #8629: `sys_setting`'s declared row identity // (`namespace, key, scope, user_id`) is NULL-distinct on `user_id`, // which is NULL on every row that is not `scope='user'`, so the // constraint is void on exactly the tenant and global layers. // - // Two differences from its sibling, both handled inside the - // migration rather than here: `sys_setting` is registered by the - // OPTIONAL `service-settings`, so the table may legitimately not - // exist on this kernel (probed for, and its absence is a silent - // no-op); and the tightening can be REFUSED by existing duplicate - // rows, which per the 2026-08-14 ruling leaves the previous index in - // place and hands the operator the list — never a keep-one rule. + // Two properties of it, both handled inside the migration rather + // than here: `sys_setting` is registered by the OPTIONAL + // `service-settings`, so the table may legitimately not exist on + // this kernel (probed for, and its absence is a silent no-op); and + // the tightening can be REFUSED by existing duplicate rows, which + // per the 2026-08-14 ruling leaves the previous index in place and + // hands the operator the list — never a keep-one rule. // // Separate try/catch per migration, deliberately: they protect // different tables and one that could not be armed must not skip the // other. if (shouldRunPlatformMigrations(environmentId, options.runPlatformMigrations)) { (ctx as any)?.hook?.('kernel:ready', async () => { - try { - await ensureViewDefinitionActiveIndex(resolveIndexExec(ql), ctx.logger); - } catch (e: unknown) { - ctx.logger.warn( - '[metadata-protocol] sys_view_definition active-row index migration skipped — the index that keeps a view name unique among ACTIVE rows only (an archived view frees its name) was not ensured this boot', - { error: e instanceof Error ? e.message : String(e) }, - ); - } try { // [#17175] The SEAM, not the bare exec: the presence // probe compiles a catalog statement for the connected @@ -336,7 +318,7 @@ export function assembleMetadataProtocol( ); } // #8686 rides the same seam and the same gate, with one - // difference worth stating: its two siblings above tighten an + // difference worth stating: its sibling above tightens an // INDEX, while this one moves stored ROWS. That is why it is // guarded on the install being single-tenant and on there // being exactly one organization to adopt — where the owner is diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index ac8a91196ab..cce8343b6b0 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -1691,9 +1691,9 @@ export class MetadataManager implements IMetadataService { * legacy aggregated container kept under the bare `` key — so callers * get exactly one entry per named view. Sorted by `order`, then `name`. * - * Runtime-authored `shared` / `personal` views (`sys_view_definition`) are - * merged in by the REST layer; this method returns the `package` layer that - * was registered from source. + * This method returns the `package` layer that was registered from source; + * runtime-authored views are `view` overlays in `sys_metadata`, served by the + * protocol's `getMetaItems`, not by this method. * * ## [#13913] Aggregated containers are expanded inline, per read * diff --git a/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts b/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts index a1f66f2b2c5..8a0a7d23b79 100644 --- a/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts +++ b/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts @@ -32,7 +32,6 @@ import { SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject, - SysViewDefinitionObject, } from '@objectstack/metadata-core'; import { @@ -57,7 +56,6 @@ const DECLARED_OBJECTS: readonly DeclaredObject[] = [ SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject, - SysViewDefinitionObject, ] as unknown as readonly DeclaredObject[]; /** diff --git a/packages/metadata/src/plugin.test.ts b/packages/metadata/src/plugin.test.ts index 8b795b4f833..db1204b9814 100644 --- a/packages/metadata/src/plugin.test.ts +++ b/packages/metadata/src/plugin.test.ts @@ -341,6 +341,31 @@ describe('MetadataPlugin — system object provisioning (ADR-0067 commit log)', expect(names).toContain('sys_metadata_commit'); }); + it('registers exactly the four metadata-storage objects — sys_view_definition is retired (ADR-0131 D13)', async () => { + const plugin = new MetadataPlugin({ + watch: false, + config: { bootstrap: 'lazy' }, + environmentId: 'proj_test', + }); + const { ctx, registered } = fakeCtxWithManifest(); + + await plugin.init(ctx); + + const names = registered.flatMap((m) => m.objects ?? []).map((o: any) => o.name); + // The absence, by name: no framework writer or reader of its rows ever + // existed, so a registration here would only provision an empty table + // the generic data door then serves. + expect(names).not.toContain('sys_view_definition'); + // …and the whole set, so a rename or a second retirement is a decision + // this pin is told about rather than one it waves through. + expect([...names].sort()).toEqual([ + 'sys_metadata', + 'sys_metadata_audit', + 'sys_metadata_commit', + 'sys_metadata_history', + ]); + }); + it('registers NOTHING when registerSystemObjects=false (control-plane kernel)', async () => { const plugin = new MetadataPlugin({ watch: false, diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 6924156b5fd..4076dea3320 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -22,7 +22,6 @@ import { SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, - SysViewDefinitionObject, applyArtifactForwardConversions, detectUnboundFormViewPredicateRoots, BOUND_FORM_VIEW_PREDICATE_ROOTS, @@ -100,10 +99,10 @@ const queryableMetadataObjects = [ // ADR-0067 commit log — sibling of sys_metadata_history (see note above). SysMetadataCommitObject, SysMetadataAuditObject, - // Runtime view storage (shared / personal). Must always be provisioned so - // end-user view creation via the generic data API has a place to write — - // mirroring why sys_metadata is always provisioned for PUT /meta. - SysViewDefinitionObject, + // ⛔ No `sys_view_definition`: it retired as inert under ADR-0131 D13 — no + // framework writer or reader of its rows ever existed, and runtime-authored + // views are `view` items written through `PUT /api/v1/meta/view/...` into + // `sys_metadata` like every other overlay. ]; // Subdirectory under `rootDir` reserved for the ADR-0008 repository's diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 1e2e2fd3800..543d7336df7 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -183,7 +183,6 @@ import { import { SysMetadataObject, SysMetadataHistoryObject, - SysViewDefinitionObject, SysMetadataAuditObject, } from '../src/metadata/index.js'; @@ -311,7 +310,6 @@ const config: ObjectStackDefinition = defineStack({ // Metadata SysMetadataObject, SysMetadataHistoryObject, - SysViewDefinitionObject, SysMetadataAuditObject, // System diff --git a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts index d234d9ce3c0..bc87738e140 100644 --- a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts +++ b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts @@ -35,8 +35,8 @@ const OWNED_OBJECTS = new Set([ 'sys_notification', 'sys_attachment', 'sys_email', 'sys_email_template', // (sys_saved_report / sys_report_schedule retired with the saved-report stack, #20102) 'sys_job', 'sys_job_run', 'sys_job_queue', 'sys_import_job', - // metadata - 'sys_metadata', 'sys_metadata_history', 'sys_view_definition', 'sys_metadata_audit', + // metadata (sys_view_definition retired as inert, ADR-0131 D13) + 'sys_metadata', 'sys_metadata_history', 'sys_metadata_audit', // system 'sys_setting', 'sys_secret', 'sys_setting_audit', 'sys_migration', ]); diff --git a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts index fe7b24bc5a3..a1c6fc810f6 100644 --- a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts @@ -209,7 +209,6 @@ const DECISIONS: readonly Decision[] = [ 'sys_email_template', 'sys_metadata', 'sys_metadata_history', - 'sys_view_definition', 'sys_metadata_audit', 'sys_secret', 'sys_setting_audit', diff --git a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts index 303d7773d66..40a3ef28850 100644 --- a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts @@ -187,7 +187,6 @@ const DECISIONS: readonly Decision[] = [ 'sys_email_template', 'sys_metadata', 'sys_metadata_history', - 'sys_view_definition', 'sys_metadata_audit', 'sys_secret', 'sys_setting_audit', diff --git a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts index 205c82c2ec4..a78530536bb 100644 --- a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts @@ -183,7 +183,6 @@ const DECISIONS: readonly Decision[] = [ 'sys_email_template', 'sys_metadata', 'sys_metadata_history', - 'sys_view_definition', 'sys_metadata_audit', 'sys_secret', 'sys_setting_audit', diff --git a/packages/platform-objects/src/audit/sys-import-job.object.ts b/packages/platform-objects/src/audit/sys-import-job.object.ts index 2c4c31af170..3b85165f1a6 100644 --- a/packages/platform-objects/src/audit/sys-import-job.object.ts +++ b/packages/platform-objects/src/audit/sys-import-job.object.ts @@ -104,8 +104,8 @@ export const SysImportJob = ObjectSchema.create({ // other actor column on a platform object is `Field.lookup('sys_user')`, // which driver-sql emits at `DEFAULT_STRING_VARCHAR_CHARS` = 255; // - the landed text declarations for the same value class: - // `sys_metadata_audit.actor`, `sys_metadata_commit.actor` and - // `sys_view_definition.owner` all declare `maxLength: 255`. + // `sys_metadata_audit.actor` and `sys_metadata_commit.actor` both + // declare `maxLength: 255`. // [#16410] There is no fixed "floor" to clear, because an id has no fixed // width. `driver-sql` mints `nanoid(DEFAULT_ID_LENGTH)` — a per-driver // constant — and `driver-memory` mints no fixed width at all, while a diff --git a/packages/platform-objects/src/metadata/index.ts b/packages/platform-objects/src/metadata/index.ts index be43ea37b8d..fcda44729e1 100644 --- a/packages/platform-objects/src/metadata/index.ts +++ b/packages/platform-objects/src/metadata/index.ts @@ -4,7 +4,7 @@ * platform-objects/metadata — BACK-COMPAT RE-EXPORT. * * The metadata-storage object definitions (`sys_metadata`, - * `sys_metadata_history`, `sys_metadata_audit`, `sys_view_definition`) have + * `sys_metadata_history`, `sys_metadata_audit`) have * MOVED to `@objectstack/metadata-core` — the lowest package shared by their * actual consumers (the ObjectQL protocol that reads/writes them, and the * metadata layer's `DatabaseLoader`). They no longer live in platform-objects. @@ -19,5 +19,4 @@ export { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject, - SysViewDefinitionObject, } from '@objectstack/metadata-core'; diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 69295ae4dcb..48d47530d88 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -255,8 +255,8 @@ export const StandaloneStackConfigSchema = z.object({ sqliteAbsentFile: z.enum(['create', 'empty-in-memory']).optional(), /** * [#9380] Does this boot arm the `kernel:ready` platform-table repair - * migrations (#5839's `sys_view_definition` active-row index, #8629's - * `sys_setting` row-identity index, #8686's seed/API tenancy backfill)? + * migrations (#8629's `sys_setting` row-identity index, #8686's seed/API + * tenancy backfill)? * * Defaults to `true`, and that default is the fix: a standalone kernel * OWNS its local platform tables, which is what the gate in diff --git a/packages/spec/src/system/constants/platform-object-names.test.ts b/packages/spec/src/system/constants/platform-object-names.test.ts index 7e787d938b6..f087699f58d 100644 --- a/packages/spec/src/system/constants/platform-object-names.test.ts +++ b/packages/spec/src/system/constants/platform-object-names.test.ts @@ -149,6 +149,20 @@ describe('platform-object predicates', () => { expect(hasPlatformObjectPrefix('sys_approval_process')).toBe(true); }); + it('no longer resolves the retired sys_view_definition (ADR-0131 D13)', () => { + // Retired as inert: no framework writer or reader of its rows existed. A + // stack still naming it is now flagged as a probable typo rather than + // resolved — the registry stays a list of objects something registers. + expect(PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']).toEqual([ + 'sys_metadata', + 'sys_metadata_audit', + 'sys_metadata_commit', + 'sys_metadata_history', + ]); + expect(isPlatformProvidedObjectName('sys_view_definition')).toBe(false); + expect(hasPlatformObjectPrefix('sys_view_definition')).toBe(true); + }); + it('treats an unprefixed name as neither', () => { expect(hasPlatformObjectPrefix('crm_lead')).toBe(false); expect(isPlatformProvidedObjectName('user')).toBe(false); diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 33d6474caf2..ef98b30db7d 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -41,7 +41,6 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly Package views (shipped from `*.view.ts`) are **not** affected: they live -> in the compiled artifact and reach the switcher via `objectDef.listViews` -> (dual-read), never via this overlay. - -## Pre-flight - -1. Confirm the `sys_view_definition` object is provisioned (ADR-0017 §3.4): - ``` - GET /api/v1/meta/sys_view_definition → 200 with object schema - ``` -2. Enumerate legacy overlay views for the tenant: - ``` - GET /api/v1/meta/view → { items: [ … ] } - ``` - A **runtime** overlay row is one **not** present in the compiled - artifact — i.e. it has no matching package ViewItem. Package rows must - be skipped (they are re-shipped from source). - -## Field mapping - -| overlay `view` field | `sys_view_definition` column | notes | -|:--------------------------------|:-----------------------------|:------| -| `name` | `name` | qualified `.` if possible | -| `data.object` / `object` | `object` | required FK | -| (n/a — list-family) | `view_kind = 'list'` | `'form'` only for form views | -| `label` | `label` | | -| `isDefault` | `is_default` | | -| `sortOrder` | `view_order` | | -| (overlay is org-wide) | `scope = 'shared'` | org-wide overlays migrate as **shared** | -| (none) | `owner = NULL` | shared rows have no owner | -| the whole view spec | `config` | ListView/FormView payload (JSON) | -| `organization_id` | `organization_id` | preserve tenant isolation | -| — | `state = 'active'` | | - -Rationale for `scope='shared'`: the legacy overlay had no per-user owner, -so every overlay view was effectively org-wide. They migrate to the -`shared` layer; a user can later **duplicate** one into a `personal` copy. - -## Migration (per tenant, idempotent) - -Run server-side, inside the tenant's `organization_id` context, via the -generic data API so ObjectQL applies validation + org isolation. Pseudocode: - -```ts -const overlay = await meta.getItems('view'); // legacy rows -const pkgNames = new Set( - (await meta.getItems('view')) // package ViewItems - .filter(v => v.viewKind && v.object) // expanded items only - .map(v => v.name), -); - -for (const v of overlay) { - const spec = v.list ?? v; // unwrap artifact wrapper - const object = spec?.data?.object ?? spec?.object; - const name = spec?.name; - if (!object || !name) continue; // unaddressable — skip - if (pkgNames.has(name)) continue; // package view — skip - - // Idempotency: skip if already migrated (unique on name+org+owner). - const existing = await data.find('sys_view_definition', { - filters: ['and', ['name', '=', name], ['object', '=', object], ['state', '=', 'active']], - top: 1, - }); - if (existing?.records?.length) continue; - - await data.create('sys_view_definition', { - name, - object, - view_kind: spec?.viewKind === 'form' ? 'form' : 'list', - label: spec?.label ?? name, - is_default: !!spec?.isDefault, - view_order: typeof spec?.sortOrder === 'number' ? spec.sortOrder : 0, - scope: 'shared', - owner: null, - hidden: !!spec?.hidden, - config: spec, // full ListView/FormView payload - state: 'active', - }); -} -``` - -## Post-flight - -1. Load each affected object's page; confirm the migrated views appear in - the switcher under the shared layer. -2. Once verified, the legacy `type='view'` overlay rows may be deleted - (`DELETE /api/v1/meta/view/:name`). **Leave them in place** during a - grace period — the runtime tolerates their absence, and keeping them - lets you re-run the migration if needed. - -## Rollback - -The migration only **creates** `sys_view_definition` rows; it never -deletes overlay rows. To roll back, delete the created rows -(`state='active'`, `scope='shared'`, matching names) — the legacy overlay -is untouched and the old adapter path can be temporarily restored. From 41dbe7962fed6dba9fae993a74a50f9f26b65b26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:26:14 +0000 Subject: [PATCH 2/3] chore(spec,platform-objects): ADR-0087 entry, regenerated registry, bundles and census for the sys_view_definition retirement The semantic entry sys-view-definition-retired and its step-18 rationale fragment; registry.ts regenerated by gen:migration-registry; the platform translation bundles regenerated by pnpm i18n:extract; the tenancy census regenerated by its own --write (registered 84 -> 83, in reach 49 -> 48); the changeset. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../15206-sys-view-definition-retired.md | 27 +++++++ .../apps/translations/en.objects.generated.ts | 73 ------------------- .../translations/es-ES.objects.generated.ts | 73 ------------------- .../es-ES.source-hashes.generated.ts | 1 - .../translations/ja-JP.objects.generated.ts | 73 ------------------- .../ja-JP.source-hashes.generated.ts | 1 - .../translations/zh-CN.objects.generated.ts | 73 ------------------- .../zh-CN.source-hashes.generated.ts | 1 - .../18.sys-view-definition-retired.ts | 61 ++++++++++++++++ packages/spec/src/migrations/registry.ts | 68 +++++++++++++++++ scripts/platform-object-tenancy-census.json | 11 +-- 11 files changed, 158 insertions(+), 304 deletions(-) create mode 100644 .changeset/15206-sys-view-definition-retired.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.sys-view-definition-retired.ts diff --git a/.changeset/15206-sys-view-definition-retired.md b/.changeset/15206-sys-view-definition-retired.md new file mode 100644 index 00000000000..1b2c137555a --- /dev/null +++ b/.changeset/15206-sys-view-definition-retired.md @@ -0,0 +1,27 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/metadata-protocol': minor +'@objectstack/metadata': minor +'@objectstack/platform-objects': minor +'@objectstack/spec': minor +--- + +feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + +Clause-②: no (narrowing) + + + +**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + +`sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + +**What moves for consumers.** + +- **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. +- **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. +- **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. +- **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. +- **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + +**Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 230b8ac11a7..0167dc48fb9 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3093,79 +3093,6 @@ export const enObjects: NonNullable = { } } }, - sys_view_definition: { - label: "View Definition", - pluralLabel: "View Definitions", - description: "Runtime-authored view definitions (shared / personal layers). The package layer ships from source.", - fields: { - id: { - label: "ID" - }, - name: { - label: "Name" - }, - object: { - label: "Object" - }, - view_kind: { - label: "View Kind", - options: { - list: "list", - form: "form" - } - }, - label: { - label: "Label" - }, - is_default: { - label: "Is Default" - }, - view_order: { - label: "Order" - }, - scope: { - label: "Scope", - options: { - shared: "shared", - personal: "personal" - } - }, - owner: { - label: "Owner" - }, - hidden: { - label: "Hidden" - }, - config: { - label: "Config", - help: "ListView or FormView configuration (matches spec ViewItem.config)." - }, - organization_id: { - label: "Organization", - help: "Organization for multi-tenant isolation." - }, - state: { - label: "State", - options: { - draft: "draft", - active: "active", - archived: "archived" - } - }, - created_by: { - label: "Created By" - }, - created_at: { - label: "Created At" - }, - updated_by: { - label: "Updated By" - }, - updated_at: { - label: "Updated At" - } - } - }, sys_metadata_audit: { label: "Metadata Audit", pluralLabel: "Metadata Audit", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 07a48503a2c..a9014be0e7e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3093,79 +3093,6 @@ export const esESObjects: NonNullable = { } } }, - sys_view_definition: { - label: "Definición de vista", - pluralLabel: "Definiciones de vista", - description: "Definiciones de vista creadas en tiempo de ejecución (capas compartida / personal). La capa de paquete se distribuye desde el código fuente.", - fields: { - id: { - label: "ID" - }, - name: { - label: "Nombre" - }, - object: { - label: "Objeto" - }, - view_kind: { - label: "Tipo de vista", - options: { - list: "Lista", - form: "Formulario" - } - }, - label: { - label: "Etiqueta" - }, - is_default: { - label: "Es predeterminada" - }, - view_order: { - label: "Orden" - }, - scope: { - label: "Ámbito", - options: { - shared: "Compartida", - personal: "Personal" - } - }, - owner: { - label: "Propietario" - }, - hidden: { - label: "Oculta" - }, - config: { - label: "Configuración", - help: "Configuración de ListView o FormView (coincide con ViewItem.config de la especificación)." - }, - organization_id: { - label: "Organización", - help: "Organización para el aislamiento multiinquilino." - }, - state: { - label: "Estado", - options: { - draft: "Borrador", - active: "Activa", - archived: "Archivada" - } - }, - created_by: { - label: "Creado por" - }, - created_at: { - label: "Creado el" - }, - updated_by: { - label: "Actualizado por" - }, - updated_at: { - label: "Actualizado el" - } - } - }, sys_metadata_audit: { label: "Auditoría de metadatos", pluralLabel: "Auditoría de metadatos", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index b04db6e5d77..73f2c7be6b3 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -63,5 +63,4 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_sso_provider._actions.register_sso_provider.params.mapName.placeholder": "a484c34aaf624bd6", "objects.sys_sso_provider._actions.register_sso_provider.params.scopes.placeholder": "58ac90cde28c764d", "objects.sys_sso_provider.fields.id.label": "00b0385c9c152888", - "objects.sys_view_definition.fields.id.label": "00b0385c9c152888", }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index d2f5a4907b7..d350e6e6ea9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3093,79 +3093,6 @@ export const jaJPObjects: NonNullable = { } } }, - sys_view_definition: { - label: "ビュー定義", - pluralLabel: "ビュー定義", - description: "実行時に作成されたビュー定義(共有/個人レイヤー)。パッケージレイヤーはソースから提供されます。", - fields: { - id: { - label: "ID" - }, - name: { - label: "名前" - }, - object: { - label: "オブジェクト" - }, - view_kind: { - label: "ビュー種別", - options: { - list: "リスト", - form: "フォーム" - } - }, - label: { - label: "ラベル" - }, - is_default: { - label: "デフォルト" - }, - view_order: { - label: "並び順" - }, - scope: { - label: "スコープ", - options: { - shared: "共有", - personal: "個人" - } - }, - owner: { - label: "所有者" - }, - hidden: { - label: "非表示" - }, - config: { - label: "構成", - help: "ListView または FormView の構成(仕様の ViewItem.config に対応)。" - }, - organization_id: { - label: "組織", - help: "マルチテナント分離のための組織。" - }, - state: { - label: "状態", - options: { - draft: "下書き", - active: "有効", - archived: "アーカイブ済み" - } - }, - created_by: { - label: "作成者" - }, - created_at: { - label: "作成日時" - }, - updated_by: { - label: "更新者" - }, - updated_at: { - label: "更新日時" - } - } - }, sys_metadata_audit: { label: "メタデータ監査", pluralLabel: "メタデータ監査", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 1ecb3cd524f..e1553f4ae95 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -53,5 +53,4 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_sso_provider._actions.register_sso_provider.params.mapName.placeholder": "a484c34aaf624bd6", "objects.sys_sso_provider._actions.register_sso_provider.params.scopes.placeholder": "58ac90cde28c764d", "objects.sys_sso_provider.fields.id.label": "00b0385c9c152888", - "objects.sys_view_definition.fields.id.label": "00b0385c9c152888", }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 5cf8f2e7e81..93c6e878a42 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3093,79 +3093,6 @@ export const zhCNObjects: NonNullable = { } } }, - sys_view_definition: { - label: "视图定义", - pluralLabel: "视图定义", - description: "运行时创建的视图定义(共享/个人层)。软件包层由源代码提供。", - fields: { - id: { - label: "ID" - }, - name: { - label: "名称" - }, - object: { - label: "对象" - }, - view_kind: { - label: "视图类型", - options: { - list: "列表", - form: "表单" - } - }, - label: { - label: "标签" - }, - is_default: { - label: "默认视图" - }, - view_order: { - label: "排序" - }, - scope: { - label: "范围", - options: { - shared: "共享", - personal: "个人" - } - }, - owner: { - label: "所有者" - }, - hidden: { - label: "隐藏" - }, - config: { - label: "配置", - help: "ListView 或 FormView 配置(与规范 ViewItem.config 一致)。" - }, - organization_id: { - label: "组织", - help: "用于多租户隔离的组织。" - }, - state: { - label: "状态", - options: { - draft: "草稿", - active: "活动", - archived: "已归档" - } - }, - created_by: { - label: "创建人" - }, - created_at: { - label: "创建时间" - }, - updated_by: { - label: "更新人" - }, - updated_at: { - label: "更新时间" - } - } - }, sys_metadata_audit: { label: "元数据审计", pluralLabel: "元数据审计", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 73ffa865234..01317fb3fb6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -52,5 +52,4 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_sso_provider._actions.register_sso_provider.params.mapName.placeholder": "a484c34aaf624bd6", "objects.sys_sso_provider._actions.register_sso_provider.params.scopes.placeholder": "58ac90cde28c764d", "objects.sys_sso_provider.fields.id.label": "00b0385c9c152888", - "objects.sys_view_definition.fields.id.label": "00b0385c9c152888", }; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-view-definition-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-view-definition-retired.ts new file mode 100644 index 00000000000..9ec597a9e4e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-view-definition-retired.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D13 (C5, stage S1) — a platform-object RETIREMENT, not a spec-key +// retirement: no authorable spec key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the +// scim-provider-object-retired shape). The writer/reader census behind the +// verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-view-definition-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the sys_view_definition platform object (SysViewDefinitionObject, exported by ' + + '@objectstack/metadata-core and re-exported by @objectstack/platform-objects and its ' + + 'metadata subpath), its registration by MetadataPlugin and by the metadata protocol ' + + 'assembly, its name in PLATFORM_OBJECTS_BY_PACKAGE (@objectstack/spec system constants), ' + + 'its kernel:ready active-row index migration and that migration\'s exports from ' + + '@objectstack/metadata-protocol (ensureViewDefinitionActiveIndex, resolveIndexExec, ' + + 'buildActiveIndexSql, VIEW_DEFINITION_TABLE, VIEW_ACTIVE_INDEX_NAME, ' + + 'VIEW_ACTIVE_PROBE_INDEX_NAME, VIEW_ACTIVE_INDEX_COLUMNS and the EnsureViewIndex types), ' + + 'and its idx_sys_view_def_active entry in the os migrate duplicates runtime-index pre-flight', + replacement: + 'nothing replaces the table — a runtime-authored view is a `view` metadata item in ' + + '`sys_metadata`, written through `PUT /api/v1/meta/view/` (the client\'s ' + + '`meta.saveItem` for type `view`), which is what every framework and Studio view door ' + + 'already does. Delete any import of the removed symbols; `classifyIndexFailure` and the ' + + '`IndexExec` type are still exported by `@objectstack/metadata-protocol`, from the ' + + 'shared index-migration module. A stack that names `sys_view_definition` (a lookup ' + + 'target, a flow trigger, a permission entry, a platform-global declaration) removes the ' + + 'reference: the name no longer resolves to a platform object', + reason: + 'ADR-0131 D13: an object no framework code writes or reads is inert and retires. Census at ' + + 'commit 41d0d4038c of this repository\'s main branch, run with the glob pathspec over ' + + 'packages/**/src (41 files; control word sys_metadata 769) and repo-wide (65 files): no ' + + 'framework writer of the table\'s rows and no reader of them — the only statements that ' + + 'touched its rows were the active-row index migration\'s own presence and duplicate ' + + 'probes and the os migrate duplicates pre-flight\'s copy of the latter. The sibling Studio ' + + 'repository never referenced it (0 hits against 93 for sys_metadata, at its main branch ' + + 'and at the pinned console commit): its view create, update and list doors write the ' + + 'ADR-0005 view overlay through the metadata API. The only way a row could ever have ' + + 'reached the table was a caller using the generic data door on the object by name. ' + + 'Keeping it registered kept an API-enabled table, a boot-time index migration and a ' + + 'pre-flight probe alive for no consumer, and kept the name resolving as a real platform ' + + 'object for authored metadata that named it.', + acceptanceCriteria: + 'No code imports SysViewDefinitionObject or the removed metadata-protocol exports (TS2305 ' + + 'after upgrade). isPlatformProvidedObjectName answers false for sys_view_definition, so a ' + + 'stack referencing the name is flagged as a probable typo rather than resolved. Neither ' + + 'MetadataPlugin nor the metadata protocol assembly registers the object, a serving boot ' + + 'issues no statement naming it, and os migrate duplicates reports three runtime-index ' + + 'pre-flight entries, none naming it. Existing databases: schema sync is additive and never ' + + 'drops a table, so a database an earlier release provisioned keeps sys_view_definition and ' + + 'any rows a caller wrote through the generic data door, and nothing reads them. os migrate ' + + 'apply --allow-destructive does not drop it either — it reconciles declared objects only ' + + '(measured: on one database it dropped an orphaned column of a declared table and left ' + + 'this table and its row in place). os migrate plan lists it among the platform-prefixed ' + + 'tables nothing declares when the project has a host config. Export any row worth keeping; ' + + 'dropping the table is the operator\'s call, by hand.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1ea24b09811..a90af560922 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6320,6 +6320,17 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is ' + 'the `sys-setting-global-rung-moved` semantic entry.', }, + { + id: 'sys-view-definition-retired', + order: 90, + text: + 'It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework ' + + 'code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. ' + + 'The object, its two registrations, its `kernel:ready` active-row index migration and that ' + + 'migration\'s exports leave, and its name leaves the platform-object registry. Nothing in stack ' + + 'metadata is rewritten; an existing database keeps the table, which no platform path drops. ' + + 'The D3 record is the `sys-view-definition-retired` semantic entry.', + }, { id: 'time-default-zone-refused', order: 50, @@ -19712,6 +19723,63 @@ const step18: MigrationStep = { + '`sys_platform_setting`, while the settings door keeps answering it for a holder of the ' + 'manifest capability. After the v18 ceremony no `sys_setting` row is at `scope = global`.', }, + // ADR-0131 D13 (C5, stage S1) — a platform-object RETIREMENT, not a spec-key + // retirement: no authorable spec key moves, so nothing lands in + // RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the + // scim-provider-object-retired shape). The writer/reader census behind the + // verdict is cited in the reason. + { + id: 'sys-view-definition-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the sys_view_definition platform object (SysViewDefinitionObject, exported by ' + + '@objectstack/metadata-core and re-exported by @objectstack/platform-objects and its ' + + 'metadata subpath), its registration by MetadataPlugin and by the metadata protocol ' + + 'assembly, its name in PLATFORM_OBJECTS_BY_PACKAGE (@objectstack/spec system constants), ' + + 'its kernel:ready active-row index migration and that migration\'s exports from ' + + '@objectstack/metadata-protocol (ensureViewDefinitionActiveIndex, resolveIndexExec, ' + + 'buildActiveIndexSql, VIEW_DEFINITION_TABLE, VIEW_ACTIVE_INDEX_NAME, ' + + 'VIEW_ACTIVE_PROBE_INDEX_NAME, VIEW_ACTIVE_INDEX_COLUMNS and the EnsureViewIndex types), ' + + 'and its idx_sys_view_def_active entry in the os migrate duplicates runtime-index pre-flight', + replacement: + 'nothing replaces the table — a runtime-authored view is a `view` metadata item in ' + + '`sys_metadata`, written through `PUT /api/v1/meta/view/` (the client\'s ' + + '`meta.saveItem` for type `view`), which is what every framework and Studio view door ' + + 'already does. Delete any import of the removed symbols; `classifyIndexFailure` and the ' + + '`IndexExec` type are still exported by `@objectstack/metadata-protocol`, from the ' + + 'shared index-migration module. A stack that names `sys_view_definition` (a lookup ' + + 'target, a flow trigger, a permission entry, a platform-global declaration) removes the ' + + 'reference: the name no longer resolves to a platform object', + reason: + 'ADR-0131 D13: an object no framework code writes or reads is inert and retires. Census at ' + + 'commit 41d0d4038c of this repository\'s main branch, run with the glob pathspec over ' + + 'packages/**/src (41 files; control word sys_metadata 769) and repo-wide (65 files): no ' + + 'framework writer of the table\'s rows and no reader of them — the only statements that ' + + 'touched its rows were the active-row index migration\'s own presence and duplicate ' + + 'probes and the os migrate duplicates pre-flight\'s copy of the latter. The sibling Studio ' + + 'repository never referenced it (0 hits against 93 for sys_metadata, at its main branch ' + + 'and at the pinned console commit): its view create, update and list doors write the ' + + 'ADR-0005 view overlay through the metadata API. The only way a row could ever have ' + + 'reached the table was a caller using the generic data door on the object by name. ' + + 'Keeping it registered kept an API-enabled table, a boot-time index migration and a ' + + 'pre-flight probe alive for no consumer, and kept the name resolving as a real platform ' + + 'object for authored metadata that named it.', + acceptanceCriteria: + 'No code imports SysViewDefinitionObject or the removed metadata-protocol exports (TS2305 ' + + 'after upgrade). isPlatformProvidedObjectName answers false for sys_view_definition, so a ' + + 'stack referencing the name is flagged as a probable typo rather than resolved. Neither ' + + 'MetadataPlugin nor the metadata protocol assembly registers the object, a serving boot ' + + 'issues no statement naming it, and os migrate duplicates reports three runtime-index ' + + 'pre-flight entries, none naming it. Existing databases: schema sync is additive and never ' + + 'drops a table, so a database an earlier release provisioned keeps sys_view_definition and ' + + 'any rows a caller wrote through the generic data door, and nothing reads them. os migrate ' + + 'apply --allow-destructive does not drop it either — it reconciles declared objects only ' + + '(measured: on one database it dropped an orphaned column of a declared table and left ' + + 'this table and its row in place). os migrate plan lists it among the platform-prefixed ' + + 'tables nothing declares when the project has a host config. Export any row worth keeping; ' + + 'dropping the table is the operator\'s call, by hand.', + }, { id: 'system-cache-durations-unit-in-key', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 715150efb1c..d829ab1b999 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,8 +24,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 84, - "inReach": 49, + "registered": 83, + "inReach": 48, "outOfReach": 35 }, "reasonTotals": { @@ -687,13 +687,6 @@ "managedBy: 'better-auth'" ] }, - { - "name": "sys_view_definition", - "file": "packages/metadata-core/src/objects/sys-view-definition.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_webhook", "file": "packages/plugins/plugin-webhooks/src/sys-webhook.object.ts", From 26a4702abfb1ed4b09a41c065784920d868e52ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:50:48 +0000 Subject: [PATCH 3/3] test(platform-objects): the three objects-locale echo ledgers lose the retired sys_view_definition ID leaf es-ES 46 -> 45, ja-JP 36 -> 35, zh-CN 35 -> 34 declared echoes: the regenerated bundles no longer carry the object, so its bare ID leaf left each ledger. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../translations/objects-es-es-echo-decisions.test.ts | 8 +++++--- .../translations/objects-ja-jp-echo-decisions.test.ts | 9 +++++---- .../translations/objects-zh-cn-echo-decisions.test.ts | 9 +++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts index a1c6fc810f6..f8586562ae3 100644 --- a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts @@ -240,10 +240,12 @@ describe('#20493 es-ES — the ledger itself (controls before verdicts)', () => // [ADR-0131 D7] 47 → 46 echoes: `sys_setting.scope` no longer declares the // `global` option (the rung moved to `sys_platform_setting`), so its leaf left // the catalog with it. `sys_setting_audit.scope`'s `global` option stays. - it('is the size it claims: 3 pinned translations and 46 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(49); + // [ADR-0131 D13] 46 → 45: `sys_view_definition` retired as inert, and its + // bare `ID` leaf left the catalog with the object. + it('is the size it claims: 3 pinned translations and 45 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(48); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(46); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(45); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts index 40a3ef28850..b3359d023f1 100644 --- a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts @@ -48,7 +48,8 @@ // Since that walk, the seven `sys_account._actions.link_social` provider-brand // rows (Google through Discord) left this ledger together with the action, // retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer -// carries those leaves, so the ledger below holds 36 echoes. +// carries those leaves, so the ledger held 36 echoes. Then `sys_view_definition` +// retired as inert (ADR-0131 D13) and took its bare `ID` leaf with it: 35. // // ## What this file deliberately does NOT assert // @@ -215,10 +216,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20493 ja-JP — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 36 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(39); + it('is the size it claims: 3 pinned translations and 35 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(38); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(36); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(35); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts index a78530536bb..9399a1b6d76 100644 --- a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts @@ -39,7 +39,8 @@ // Since that walk, the seven `sys_account._actions.link_social` provider-brand // rows (Google through Discord) left this ledger together with the action, // retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer -// carries those leaves, so the ledger below holds 35 echoes. +// carries those leaves, so the ledger held 35 echoes. Then `sys_view_definition` +// retired as inert (ADR-0131 D13) and took its bare `ID` leaf with it: 34. // // ## What this file deliberately does NOT assert // @@ -211,10 +212,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20462 — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 35 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(38); + it('is the size it claims: 3 pinned translations and 34 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(37); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(35); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(34); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); });