Skip to content

Commit d5d8146

Browse files
feat(driver-sql,driver-turso,driver-memory,objectql,cli,metadata-protocol): plain organization key part in new unique indexes; the differ reads COALESCE(tenant) as tenant on a certified database (18.x step 7b-1 + 7b-2) (#24893)
Part of #24583 Clause-②: yes (widening). New DDL changes form. - Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`. On SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. - One index is re-keyed. The `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on. This is probe-first and a pure relaxation, so it cannot fail on existing rows. - Some tenant columns are still nullable: every table of a database 18.0.0 created, and a table the ceremony reported. On those tables a newly declared organization unique no longer constrains NULL-organization rows, and the in-memory driver exempts a record with no organization. Metadata-protocol: semantic A declared `unique: 'organization'` index, and a field-level organization unique on a tenant-scoped object, change what they enforce where the tenant column is nullable. Nothing an author writes is added or refused. The changeset grades all six packages `minor`. There is no BREAKING banner and no ADR-0087 marker, because the change is a widening. <sub>Clause-② text corrected by the epic seat on 2026-10-20 (about 16:15Z) after the contract review's B1 (record 6293077099). It mirrors `.changeset/24583-plain-organization-key-part.md`. The first bullet's precision clause (re-review 6295919095, R1) was folded in at 8769400.</sub> 18.x step 7, parts 7b-1 + 7b-2 (claim 6289404450, governing record 6285122667). 7b-0 (#24940) has merged as 5e9b100, and this branch has merged `main` at that commit, so the diff is this part alone. ## What changes **7b-2: new DDL keys the plain organization column** (ADR-0131 D1: the tenant column is NOT NULL). - `uniqueIndexesFromFields`, `normalizeDeclaredIndex` (`unique: 'organization'`) and `legacyUniqueReplacements` no longer mark the tenant key part NULL-safe. A field-level organization unique is `UNIQUE (organization_id, f)`. `buildIndexName` is unchanged, so no index is renamed. - The same applies on every emitting path: - the sql-driver index emitters and the MySQL hash shadow, whose new shadows hash the plain column; - Turso, local and remote (it shares the generators); - `memory-unique-constraint.ts`, where a NULL organization now exempts the row like any other NULL key part; - `os generate migration`, which now emits the organization composite it used to list as NOT EMITTED (`GLOBAL_TENANT_KEY` removed); - the `sys_setting` row-identity migration: `organization_id` is plain and `COALESCE(user_id, '')` stays. Its duplicate query lists only rows the index refuses. - The duplicate pre-flight (`probeNullSafeUniqueDuplicates`, local and Turso remote) leaves out rows with a NULL plain key part. The index never refuses those rows. - `__global__` as the autonumber counter key is untouched (7b-3's ground). **7b-1: the differ reads `COALESCE(tenant)` ≡ `tenant`, in both directions, where the tenant column holds no NULL.** - `diffManagedIndexes` takes `tenantKeyCertified`. When it is set, the tenant key part's form drops out of the comparison identity. The column set, uniqueness and other key parts still have to match. - `SqlDriver.detectTableIndexDrift` sets it from two witnesses: - **The tenant column is physically NOT NULL.** This is self-proving, and 7b-0 guarantees it on fresh tables. The ceremony's SQLite rebuild and its PostgreSQL/MySQL `ALTER` produce it too. - **The database is certified.** The engine hands every driver a closure, `ObjectQL.isOrganizationOwnershipCertified()`: a verified `sys_migration` ownership row at the current `shapeGeneration` or above. This is the same reading the boot guard boots on, and it covers a birth marker or the ceremony's marker. - The driver asks for certification lazily, and only for a table that physically carries the COALESCE tenant key part. A failed read is reported once and read as "not certified". This is the `fileColumnsMoved` supply-seam precedent, so the driver never learns the ledger's vocabulary and there is no layering break. - The COALESCE form is still read: `enforcedIndexKey`, `isSyncReproducibleIndex`, `isRuntimeManagedIndex` and the shadow-key reader are unchanged. An input carrying `nullSafeColumns` is still honoured verbatim. ## Measured - **The ceremonied 18.0 fixture** (`ceremoniedFixture`): - On SQLite, HEAD's own ceremony NOT NULL step rebuilds 62 tables through the drift apply door, and that rebuild re-keys their indexes plain. `sys_email_template` stays nullable (with `sys_sso_provider` and one rotation shard) and keeps its 18.0 COALESCE index, so on the fixture as-is the **certification** witness is what carries it. - PostgreSQL/MySQL get NOT NULL by `ALTER` and a Turso remote store not at all, so there every 18.0 index survives the ceremony. A SQLite store certified by an earlier 18.x ceremony keeps them too. The pin therefore also restores all **13** COALESCE indexes exactly as 18.0 wrote them. - On both shapes, HEAD's differ reports **0 `recreate_index` and 0 destructive drift**. The 12 declared composites survive the boot byte for byte. `sys_setting`'s index is rebuilt by its own runtime migration, which rebuilds it every boot anyway. - **A database born on the published 18.0.0:** - Measured live: the published 18.0.0 boots a fresh SQLite file, then HEAD boots it with 0 errors and 0 refusals, and `os migrate plan` reports 0 changes over 73 tables. - Pinned as the equivalence file's third leg. - **Fresh install:** every organization unique index is plain, with 0 COALESCE and no index drift. - `packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts` pins all of the above: 10/10. - **Ablation**, via `scripts/ablation-replace.mjs` on `schema-drift.ts`: - With the equivalence switched off, `ablation-dist-preflight` showed the marker in 2 built files. The fixture run went red: 2 failed / 5 passed, both drift legs, with 26 `recreate_index` lines in the output. - With only the certification witness switched off, 3 legs went red, the 18.0.0-born leg among them. - Restore: the blob equals HEAD, the dist marker is gone, and the tree is clean. - Measured before the post-7b-0 merge, which changed no production file of this part. - **MySQL hash shadow, live on MySQL 8.0.46** (a local container): - A fresh shadow's generation is `unhex(sha2(concat(organization_id,0x1f,v),256))`, which is plain. - An 18.0 COALESCE shadow is `recreate_index`/destructive when uncertified and gives no drift when certified. - A re-boot leaves the generated column's expression and the rows untouched. - **No data migration and no column rebuild.** A shadow is regenerated only when its index has already been dropped (`stale`); the column is derived, so no data moves. ## Proof Measured on head 8769400 (`main` at 5e9b100 merged in). - R1: `pnpm dev:<app> -- --fresh` on a random port for showcase, crm and todo. Each one: - booted on an empty database; - answered health 200 and seeded-admin sign-in 200; - logged 0 error lines and 0 schema-drift lines. - R2 + R3: `upgrade-18x-harness`, the equivalence file and `organization-ownership-boot-refusal-order` gave 26/26. - R4: built CLI `run-dev.js serve` under `OS_TENANCY_POSTURE=isolated`, on a throwaway app declaring the real `@objectstack/organizations` (invite-only membership). - `Tenancy: isolated`, and Organizations mounted. - Health 200 and discovery 200. - 0 error lines and 0 refusals. - In the store: 13 organization unique indexes, 0 COALESCE, and 0 NULL rows across 29 tables carrying `organization_id`. - Step 4's 7 walled e2e cases (`OS_TEST_TIERS=nightly`): 7/7. - Suites: | Package | Result | |---|---| | driver-sql | 3734 passed, 0 failed | | driver-turso | 2550 passed | | driver-memory | 1757 passed | | objectql | 8641 passed, 1 timeout (see below) | | plugin-security | 4234 passed, 2 timeouts (see below) | | metadata-protocol `sys-setting-identity-index` | 34 passed | | cli and every other package | CI Test Core 1/6 to 6/6, all green | - The three timeouts happened while five suites ran at once, in `hook-run-as` (objectql) and `shipped-sets-identity-default-row-scope` (plugin-security). This part does not touch either file. Run alone, they pass 25/25 and 32/32. - `typecheck` is clean on all seven packages touched. - `node scripts/pm/dispatch-gates.mjs --commands`: 85 commands plus the 4 roster gates whose roster sits under a path this diff touches. That is 89 in all, each exit captured before any pipe, and all 89 exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0, with all 14 artifacts up to date. - `check-governed-merges --branch origin/claude/issue-24583-7b12-index-plain-form`: exit 0. 0 of 32 paths are governed. ## Size 2,741 changed lines over 32 files, under the 3,000-line threshold. Now that 7b-0 is in `main`, this is no longer Tier H by size, and it touches no governed surface. - Production: 528 lines. - Tests: 2,193 lines. - Changeset: 20 lines. - Fixtures: 0 lines; no fixture was regenerated. ## 维护者速读(草稿) - **改了什么**:新建的组织唯一索引改用普通的 `organization_id` 列,不再用 `COALESCE(organization_id, '__global__')`。旧库里已有的 COALESCE 索引,只要库已认证(出生标记或仪式标记)或租户列已 NOT NULL,差异检测就视为同一个索引,不重建、不改名。唯一例外是 `sys_setting` 的行标识索引:元数据协议每次启动都会重建它,下次启动起改用普通列(先探测再替换,只放宽、不会因已有数据失败)。 - **为什么改**:ADR-0131 D1 规定租户列 NOT NULL,没有组织为空的行,`__global__` 桶已无意义。不加等价判断的话,每个升级上来的库每次启动都会被提示「删掉重建」13 个唯一索引。 - **风险与代价(含回滚)**:在租户列仍可空的表上(用 18.0.0 新建的库的所有表、仪式没收紧的表),新建的组织唯一索引不再约束组织为空的行(与其他可空键一致)。除 `sys_setting` 行标识索引外,已有索引一律不动。回滚即还原本 PR,不涉及数据迁移;MySQL 实测无需重建列。 - **席位意见**: - **你要做的**:7b-0 已合并,本分支已合入 main。净改动 2,741 行,低于 3,000 行,且不触及受治理面。等席位做完增量复核后,按其结论落地。 ## Acceptance notes - The tightening `recreate_index` of ADR-0120 D4 (bare composite → NULL-safe) is no longer produced by any generator. Its apply path and pre-flight remain for an op that already carries `nullSafeColumns`, and the Turso suites pin them that way. - On an **uncertified** database with a nullable tenant column, an 18.0 COALESCE index is still reported as a destructive `recreate_index` toward the plain key. - The finding's message names `os migrate organization-ownership` as the remedy, never `os migrate apply --allow-destructive` (B3, pinned in `sql-driver-tenant-key-equivalence.test.ts`). - No unattended path applies it: boot reconciles only safe changes, and the artifact gate refuses. - Such a database is refused at `os serve` until the ceremony certifies it. - `os generate migration` emits the tenant column nullable, while the driver's fresh table is NOT NULL (7b-0). The generated composite is now the platform's own constraint, which is stronger than the nothing it emitted before, but the column's nullability still differs from the driver's. That is 7b-0's ground and is not changed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd --- _Generated by [Claude Code](https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e2c42f commit d5d8146

32 files changed

Lines changed: 2057 additions & 684 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/driver-memory': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/metadata-protocol': minor
7+
'@objectstack/cli': minor
8+
---
9+
10+
feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: semantic
14+
15+
18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold).
16+
17+
- **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays).
18+
- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy.
19+
- **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them.
20+
- **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization.

‎packages/cli/src/commands/generate-declared-unique-index.pin.test.ts‎

Lines changed: 126 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -34,26 +34,31 @@
3434
* constraint. The COLUMN — the #16091 result this change must not spend — read
3535
* `character varying(100)` on all three both before and after.
3636
*
37-
* ## What this pin does NOT claim, stated so nobody reads it as closed
37+
* ## The organization composite — emitted since ADR-0131 D1
38+
*
39+
* Until ADR-0131 D1 the ORGANIZATION-SCOPED form was the ADR-0120 D3
40+
* expression `(COALESCE(<tenant>, '__global__'), <field>)`, which neither format
41+
* could spell, so it was deliberately unemitted and named in a NOT EMITTED
42+
* line. The driver now keys the PLAIN tenant column — a fresh table's tenant
43+
* column is NOT NULL, so the ADR-0120 D3 NULL-organization bucket the COALESCE
44+
* existed for is gone from new DDL — and the bare `(organization_id, field)` IS
45+
* the platform's constraint. So both formats emit it, and this file pins that
46+
* the generated table carries the SAME composite the driver creates and refuses
47+
* the same in-organization duplicate (block A), and that no NOT EMITTED line is
48+
* printed for it any more (block B).
3849
*
39-
* Two declaration shapes are deliberately unemitted, and this file asserts that
40-
* they are unemitted *and named*, never that they are handled:
50+
* ## What this pin does NOT claim, stated so nobody reads it as closed
4151
*
42-
* 1. The ADR-0120 D3 ORGANIZATION-SCOPED form — `(COALESCE(<tenant>,
43-
* '__global__'), <field>)`. Emitting the bare composite instead would be
44-
* worse than emitting nothing: under SQL's NULL-distinct UNIQUE a bare
45-
* `(organization_id, field)` constrains NOTHING on rows with no
46-
* organization, which on a single-tenant stack is every row (#5030) — a
47-
* constraint advertised and not delivered, which is the failure Prime
48-
* Directive #10 names.
49-
* 2. OBJECT-LEVEL `indexes[]`. `normalizeDeclaredIndex` is a second
50-
* normalizer that reads the same `unique: true` token DIFFERENTLY (verbatim
51-
* as global, a maintainer ruling), so it is a second transcription with a
52-
* second pin, not a loop added to this one.
52+
* One declaration shape is deliberately unemitted, and this file asserts that it
53+
* is unemitted *and named*, never that it is handled: OBJECT-LEVEL `indexes[]`.
54+
* `normalizeDeclaredIndex` is a second normalizer that reads the same
55+
* `unique: true` token DIFFERENTLY (verbatim as global, a maintainer ruling), so
56+
* it is a second transcription with a second pin, not a loop added to this one.
5357
*
54-
* ⭐ Measured for the record, because "can the TypeScript format express an
55-
* expression key part through knex at all" was the open question that kept the
56-
* scoped form off this change (knex 3.3.0, live PostgreSQL 16.13):
58+
* ⭐ Measured for the record — HISTORICAL since ADR-0131 D1 retired the
59+
* expression key part, kept because it is why the scoped form was once
60+
* unemitted, and stays true for any future expression key part (knex 3.3.0,
61+
* live PostgreSQL 16.13):
5762
*
5863
* ```
5964
* table.unique([knex.raw("COALESCE(...)"), 'f'], {indexName})
@@ -72,8 +77,9 @@
7277
* knex handle and `db.raw` is exactly the seam `SqlDriver.createNullSafeUniqueIndex`
7378
* already uses for this — but knex's SCHEMA BUILDER cannot express it in either
7479
* format, so the scoped form costs a raw statement rather than another
75-
* `table.unique(...)` line. That is a fact for whoever takes the scoped half,
76-
* recorded here rather than acted on.
80+
* `table.unique(...)` line. ADR-0131 D1 made that moot for the organization
81+
* composite (its key parts are plain columns now); it is recorded for whoever
82+
* next meets an expression key part.
7783
*
7884
* ## Why this pin reads the driver instead of asserting the names
7985
*
@@ -82,7 +88,7 @@
8288
* transcribed `uniq_probe_keyed_unique` would re-create the defect one layer up
8389
* and stay green the day the driver's naming moves. Every name and every key-part
8490
* ordering here is recomputed from `driver-sql`'s own exported
85-
* `uniqueIndexesFromFields` / `buildIndexName` / `GLOBAL_TENANT`.
91+
* `uniqueIndexesFromFields` / `buildIndexName`.
8692
*
8793
* ⭐ And the leaves are not the authority. Above the differential sits THE REAL
8894
* CHAIN: all three producers driven into ONE in-memory better-sqlite3 database,
@@ -109,7 +115,7 @@
109115
* measuring the parser.
110116
*/
111117

112-
import { SqlDriver, GLOBAL_TENANT, buildIndexName, uniqueIndexesFromFields } from '@objectstack/driver-sql';
118+
import { SqlDriver, buildIndexName, uniqueIndexesFromFields } from '@objectstack/driver-sql';
113119
import { afterAll, describe, expect, it } from 'vitest';
114120

115121
import { generateMigrationSql, generateMigrationTs } from './generate.js';
@@ -210,8 +216,12 @@ function loadEmittedTs(ts: string): { up: (db: any) => Promise<void>; down: (db:
210216
interface Probe {
211217
label: string;
212218
object: Record<string, any>;
213-
/** What the object's declarations mean for the emitters, asserted below. */
214-
expect: 'emitted' | 'scoped-not-emitted' | 'no-column' | 'none';
219+
/**
220+
* What the object's declarations mean for the emitters, asserted below.
221+
* `scoped` is the organization composite — emitted like `emitted`, with the
222+
* plain tenant column as its FIRST key part (ADR-0131 D1).
223+
*/
224+
expect: 'emitted' | 'scoped' | 'no-column' | 'none';
215225
}
216226

217227
const ORG = { type: 'text', maxLength: 64 } as const;
@@ -229,14 +239,14 @@ const CORPUS: Probe[] = [
229239
expect: 'emitted',
230240
},
231241
{
232-
label: '`unique: true` beside an organization column — the ADR-0120 D3 scoped composite',
242+
label: '`unique: true` beside an organization column — the plain organization composite (ADR-0131 D1)',
233243
object: { name: 'p_scoped_true', fields: { organization_id: ORG, f: { ...KEYED, unique: true } } },
234-
expect: 'scoped-not-emitted',
244+
expect: 'scoped',
235245
},
236246
{
237247
label: "explicit `unique: 'organization'` — the same scoped composite",
238248
object: { name: 'p_scoped_word', fields: { organization_id: ORG, f: { ...KEYED, unique: 'organization' } } },
239-
expect: 'scoped-not-emitted',
249+
expect: 'scoped',
240250
},
241251
{
242252
label: 'unique ON the organization column itself — "one row per tenant" stays single-column',
@@ -259,7 +269,7 @@ const CORPUS: Probe[] = [
259269
tenancy: { tenantField: 'org' },
260270
fields: { org: ORG, f: { ...KEYED, unique: true } },
261271
},
262-
expect: 'scoped-not-emitted',
272+
expect: 'scoped',
263273
},
264274
{
265275
label: 'a name past the 60-character identifier budget — hash-suffixed',
@@ -349,8 +359,9 @@ describe('#16317 — the index each generator emits is the index the driver CREA
349359

350360
// ⭐ THE AUTHORITY. Whatever the driver's own table carries, the ts
351361
// format's table carries too — by NAME, because both go through knex's
352-
// named-index spelling — except for the two declared exclusions.
353-
const emittable = probe.expect === 'emitted';
362+
// named-index spelling — except for the declared exclusions. Since
363+
// ADR-0131 D1 the organization composite is one of the emitted shapes.
364+
const emittable = probe.expect === 'emitted' || probe.expect === 'scoped';
354365
if (emittable) {
355366
expect(uniqueShapes(built.tsgen)).toEqual(uniqueShapes(built.driver));
356367
expect(uniqueShapes(built.sqlgen)).toEqual(uniqueShapes(built.driver));
@@ -362,22 +373,28 @@ describe('#16317 — the index each generator emits is the index the driver CREA
362373
for (const want of wanted) expect(built.sqlText).toContain(`CONSTRAINT "${want.name}" UNIQUE (`);
363374
}
364375

365-
if (probe.expect === 'scoped-not-emitted') {
366-
// The driver DOES build it — the expression key part is why we do not.
376+
if (probe.expect === 'scoped') {
377+
// ADR-0131 D1: the driver keys the PLAIN tenant column, so the
378+
// composite it creates is a two-column list with the tenant FIRST and
379+
// no expression key part — the shape both formats can spell, and do.
380+
const tenant = newOracle().tenantFieldFor(probe.object);
381+
expect(tenant).not.toBeNull();
367382
expect(wanted.length).toBeGreaterThan(0);
368383
for (const want of wanted) {
369-
expect(want.nullSafeColumns ?? []).not.toHaveLength(0);
370-
// ⛔ Absence must be loud: named in the generated file, both formats.
371-
expect(built.sqlText).toContain(`NOT EMITTED: UNIQUE index "${want.name}"`);
372-
expect(built.tsText).toContain(`NOT EMITTED: UNIQUE index '${want.name}'`);
373-
expect(built.sqlText).toContain(`COALESCE("${want.nullSafeColumns![0]}", '${GLOBAL_TENANT}')`);
374-
// ...and NOT emitted as the bare composite, which would advertise a
375-
// constraint the table does not carry (#5030).
376-
expect(built.sqlText).not.toContain(`CONSTRAINT "${want.name}" UNIQUE (`);
377-
expect(built.tsText).not.toContain(`indexName: '${want.name}'`);
384+
expect(want.nullSafeColumns ?? []).toHaveLength(0);
385+
expect(want.columns).toHaveLength(2);
386+
expect(want.columns[0]).toBe(tenant);
378387
}
379-
expect(uniqueShapes(built.tsgen)).toHaveLength(0);
380-
expect(uniqueShapes(built.sqlgen)).toHaveLength(0);
388+
// The catalog agrees: every producer's table carries the composite as
389+
// two PLAIN key parts (SQLite reports `null` for an expression part).
390+
for (const rows of [built.driver, built.sqlgen, built.tsgen]) {
391+
expect(uniqueShapes(rows)).toEqual(wanted.map((w) => w.columns.join(',')).sort());
392+
}
393+
// ...and nothing is announced as missing, because nothing is.
394+
expect(built.sqlText).not.toContain('NOT EMITTED');
395+
expect(built.tsText).not.toContain('NOT EMITTED');
396+
expect(built.sqlText).not.toContain('COALESCE');
397+
expect(built.tsText).not.toContain('COALESCE');
381398
}
382399

383400
if (probe.expect === 'no-column') {
@@ -425,6 +442,44 @@ describe('#16317 — the index each generator emits is the index the driver CREA
425442
expect(verdicts).toEqual({ driver: 'refused', sqlgen: 'refused', tsgen: 'refused' });
426443
});
427444

445+
/**
446+
* ⭐ The same consequence for the ORGANIZATION composite (ADR-0131 D1): a
447+
* duplicate inside ONE organization is refused by the driver's table and by
448+
* BOTH generated ones, and the same value in a SECOND organization is accepted
449+
* by all three — uniqueness is per organization, not platform-wide. Rows
450+
* carry an explicit organization: a fresh driver table's tenant column is
451+
* NOT NULL, so a NULL-organization row is not a case the driver's table has.
452+
*/
453+
it('a duplicate inside one organization is refused by BOTH generated tables and the driver; another organization is not', async () => {
454+
const object = { name: 'p_enforce_org', fields: { organization_id: ORG, f: { ...KEYED, unique: true } } };
455+
const built = await threeTables(object);
456+
const insert = (db: any, id: string, org: string) =>
457+
db.raw(`INSERT INTO "p_enforce_org" ("id", "organization_id", "f") VALUES (?, ?, 'dup')`, [id, org]);
458+
const verdicts: Record<string, { sameOrg: string; otherOrg: string }> = {};
459+
for (const [who, db] of Object.entries(built.dbs)) {
460+
await insert(db, 'a', 'org_1');
461+
let sameOrg: string;
462+
try {
463+
await insert(db, 'b', 'org_1');
464+
sameOrg = 'accepted';
465+
} catch (err) {
466+
// A UNIQUE refusal specifically — not any error the insert could raise.
467+
expect(String((err as Error).message)).toMatch(/UNIQUE constraint failed/);
468+
sameOrg = 'refused';
469+
}
470+
let otherOrg: string;
471+
try {
472+
await insert(db, 'c', 'org_2');
473+
otherOrg = 'accepted';
474+
} catch {
475+
otherOrg = 'refused';
476+
}
477+
verdicts[who] = { sameOrg, otherOrg };
478+
}
479+
const perOrg = { sameOrg: 'refused', otherOrg: 'accepted' };
480+
expect(verdicts).toEqual({ driver: perOrg, sqlgen: perOrg, tsgen: perOrg });
481+
});
482+
428483
/**
429484
* NON-VACUITY for the whole block. A corpus that built no unique index at all
430485
* would satisfy every `toEqual` above by agreeing on emptiness.
@@ -436,7 +491,7 @@ describe('#16317 — the index each generator emits is the index the driver CREA
436491
for (const probe of CORPUS) {
437492
const wanted = driverIndexes(oracle, probe.object);
438493
if (probe.expect === 'emitted') counts.emitted += wanted.length;
439-
if (probe.expect === 'scoped-not-emitted') counts.scoped += wanted.length;
494+
if (probe.expect === 'scoped') counts.scoped += wanted.length;
440495
if (probe.expect === 'no-column') counts.noColumn += wanted.length;
441496
if (probe.expect === 'none') counts.none += wanted.length;
442497
for (const w of wanted) if (/_[0-9a-f]{8}$/.test(w.name)) counts.hashed += 1;
@@ -477,11 +532,29 @@ describe('#16317 — the transcriptions in generate.ts are the driver\'s own', (
477532
expect(hashed).toBeGreaterThanOrEqual(3);
478533
});
479534

480-
/** The `__global__` sentinel the unemitted-index note names. */
481-
it('the sentinel the NOT EMITTED note prints is the driver\'s GLOBAL_TENANT', () => {
535+
/**
536+
* The `__global__` sentinel retired with the ADR-0120 D3 expression key part
537+
* (ADR-0131 D1), and with it the NOT EMITTED note that printed it. What is
538+
* left to pin is that the generators stopped announcing the organization
539+
* composite as missing — a stale note would tell the operator to hand-write
540+
* an index the file already creates — and that no COALESCE spelling of it
541+
* survives in either format, comment or code.
542+
*/
543+
it('no NOT EMITTED line is printed for an organization composite, in either format', () => {
482544
const object = { name: 'g_probe', fields: { organization_id: ORG, f: { ...KEYED, unique: true } } };
483-
expect(generateMigrationSql(configFor(object))).toContain(`'${GLOBAL_TENANT}'`);
484-
expect(generateMigrationTs(configFor(object))).toContain(`'${GLOBAL_TENANT}'`);
545+
const [want] = driverIndexes(oracle, object);
546+
expect(want.columns).toEqual(['organization_id', 'f']);
547+
const sql = generateMigrationSql(configFor(object));
548+
const ts = generateMigrationTs(configFor(object));
549+
expect(sql).not.toContain('NOT EMITTED');
550+
expect(ts).not.toContain('NOT EMITTED');
551+
expect(sql).not.toContain('COALESCE');
552+
expect(ts).not.toContain('COALESCE');
553+
expect(sql).not.toContain('__global__');
554+
expect(ts).not.toContain('__global__');
555+
// Non-vacuity: the index is in the file — as itself, not as a note.
556+
expect(sql).toContain(`CONSTRAINT "${want.name}" UNIQUE ("organization_id", "f")`);
557+
expect(ts).toContain(`table.unique(['organization_id', 'f'], { indexName: '${want.name}' });`);
485558
});
486559

487560
/**
@@ -495,16 +568,19 @@ describe('#16317 — the transcriptions in generate.ts are the driver\'s own', (
495568
const ts = generateMigrationTs(configFor(probe.object));
496569
for (const want of driverIndexes(oracle, probe.object)) {
497570
checked += 1;
498-
const scoped = (want.nullSafeColumns ?? []).length > 0;
499-
const emitted = probe.expect === 'emitted';
571+
// ADR-0131 D1: every key part is a plain column, the organization
572+
// composite's included, so every emitted index is a column list.
573+
expect(want.nullSafeColumns ?? []).toHaveLength(0);
574+
const emitted = probe.expect === 'emitted' || probe.expect === 'scoped';
500575
const cols = want.columns.map((c) => `"${c}"`).join(', ');
501576
if (emitted) {
502-
expect(scoped).toBe(false);
503577
expect(sql).toContain(`CONSTRAINT "${want.name}" UNIQUE (${cols})`);
504578
expect(ts).toContain(
505579
`table.unique([${want.columns.map((c) => `'${c}'`).join(', ')}], { indexName: '${want.name}' });`,
506580
);
507581
} else {
582+
// Only the no-column exclusion is left to announce.
583+
expect(probe.expect).toBe('no-column');
508584
expect(sql).toContain(`NOT EMITTED: UNIQUE index "${want.name}" on (${want.columns.join(', ')})`);
509585
expect(ts).toContain(`NOT EMITTED: UNIQUE index '${want.name}' on (${want.columns.join(', ')})`);
510586
}

0 commit comments

Comments
 (0)