Skip to content

Commit 3bb7ad6

Browse files
committed
fix(driver-sql, objectql): true declarations — the uncertified tenant-key finding names the ownership ceremony, the not-certified prose says what it reports, the changeset names the sys_setting re-key and the 18.0.0-born widening
Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 39d8b9d commit 3bb7ad6

6 files changed

Lines changed: 59 additions & 13 deletions

File tree

‎.changeset/24583-plain-organization-key-part.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ Clause-②: yes (widening)
1414
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).
1515

1616
- **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).
17-
- **Existing indexes stay as they are.** Nothing is renamed, dropped or rebuilt by this release. The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the tenant column holds no NULL: 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, as before.
18-
- **What widens.** Where a tenant column is still nullable (an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table), 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.
19-
- **Who sees it.** A certified database or a fresh install sees nothing: every row carries its organization there. Nothing to do.
17+
- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt. 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.
18+
- **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.
19+
- **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/drivers/driver-sql/src/schema-drift.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2557,6 +2557,22 @@ export function diffManagedIndexes(args: {
25572557
// own (`nullSafeColumns` is only ever recorded alongside an expression key
25582558
// part); a resolved shadow key can carry NULL-safe parts with no
25592559
// expressions at all, so the implication no longer holds.
2560+
// [ADR-0131 D1] An earlier release's COALESCE tenant key part against the
2561+
// plain one new DDL declares — the divergence is ONLY that form, on a
2562+
// table where the two are still different constraints (not
2563+
// certified, nullable tenant column). The remedy is the ownership ceremony,
2564+
// which certifies the database and so retires this entry — never a rebuild:
2565+
// `--allow-destructive` would drop and re-key the index over rows that may
2566+
// still lack an organization. The op is unchanged (destructive, reported,
2567+
// never applied unattended); only what it tells the operator differs.
2568+
const uncertifiedTenantKeyOnly =
2569+
tenantKeyCertified !== true &&
2570+
!!tenantField &&
2571+
p.unique === e.unique &&
2572+
(pk.nullSafeColumns ?? []).includes(tenantField) &&
2573+
!(e.nullSafeColumns ?? []).includes(tenantField) &&
2574+
canonicalIndexKey(pk.columns, (pk.nullSafeColumns ?? []).filter((c) => c !== tenantField)) ===
2575+
canonicalIndexKey(e.columns, (e.nullSafeColumns ?? []).filter((c) => c !== tenantField));
25602576
const tightenNullSafeOnly =
25612577
e.unique &&
25622578
p.unique &&
@@ -2584,7 +2600,15 @@ export function diffManagedIndexes(args: {
25842600
...(e.nullSafeColumns ? { nullSafeColumns: e.nullSafeColumns } : {}),
25852601
...(tightenNullSafeOnly ? { tightenNullSafeOnly: true } : {}),
25862602
},
2587-
message: tightenNullSafeOnly
2603+
message: uncertifiedTenantKeyOnly
2604+
? `${table}: index '${e.name}' is ${indexSignature(pk.columns, p.unique, pk.nullSafeColumns)} and metadata declares ` +
2605+
`${indexSignature(e.columns, e.unique, e.nullSafeColumns)}: the same columns, differing only in how the ` +
2606+
`'${tenantField}' key part treats a row with no organization. This database is not certified for the ` +
2607+
`organization-ownership shape and '${tenantField}' is nullable, so the two are still different constraints. ` +
2608+
`Run "os migrate organization-ownership": once it certifies the database the two read as one index and this ` +
2609+
`entry is gone. Do not rebuild it with "os migrate apply --allow-destructive", which would drop and re-key ` +
2610+
`the index over rows that may still have no organization.`
2611+
: tightenNullSafeOnly
25882612
? `${table}: index '${e.name}' is ${indexSignature(pk.columns, p.unique, pk.nullSafeColumns)} but metadata declares ` +
25892613
`${indexSignature(e.columns, e.unique, e.nullSafeColumns)} (ADR-0120 D3: the organization key part is NULL-safe, ` +
25902614
`so rows without an organization are constrained too). Pure tightening — eligibility is decided by the ` +

‎packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ describe('7b-1 — the differ: COALESCE(tenant) ≡ tenant where the tenant colu
7373
expect(entry).toMatchObject({ category: 'destructive', op: { type: 'recreate_index', indexName: NAME } });
7474
});
7575

76+
it('the uncertified entry names the ownership ceremony as its remedy, never --allow-destructive', () => {
77+
const [entry] = diff(expectedPlain, coalesced, false);
78+
expect(entry!.message).toContain('"os migrate organization-ownership"');
79+
expect(entry!.message).toContain('not certified');
80+
// The rebuild is named only to be ruled out, never prescribed as the fix.
81+
expect(entry!.message).toContain('Do not rebuild it with "os migrate apply --allow-destructive"');
82+
expect(entry!.message).not.toMatch(/existing duplicates: "os migrate apply --allow-destructive"/);
83+
// Control — a real redefinition keeps the rebuild remedy it always had.
84+
const other: ExpectedIndex = { name: NAME, columns: [ORG, 'code', 'region'], unique: true };
85+
const [rebuild] = diff(other, coalesced, false);
86+
expect(rebuild!.message).not.toContain('os migrate organization-ownership');
87+
expect(rebuild!.message).toContain('"os migrate apply --allow-destructive"');
88+
});
89+
7690
it('the other direction: a plain physical index against a NULL-safe expectation, no drift when certified', () => {
7791
expect(diff(expectedNullSafe, plain, true)).toEqual([]);
7892
expect(diff(expectedNullSafe, plain, false).map((d) => d.op.type)).toEqual(['recreate_index']);

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20413,9 +20413,12 @@ export class SqlDriver implements IDataDriver {
2041320413
* The answer decides one thing only: whether the differ reads an earlier
2041420414
* release's `COALESCE(<tenant>, …)` key part as the plain tenant column new
2041520415
* DDL keys ({@link detectTableIndexDrift}). Without a resolver, or when it
20416-
* throws, the answer is "not certified" — the two forms then stay distinct
20417-
* constraints, which is the reading that never proposes a rebuild on a
20418-
* guess.
20416+
* throws, the answer is "not certified": the two forms then stay distinct
20417+
* constraints, and the differ REPORTS the 18.0 index as a destructive
20418+
* `recreate_index`. That report is never applied unattended — the boot
20419+
* reconcile applies `safe` entries only, the artifact gate refuses it, and
20420+
* `os migrate apply` needs `--allow-destructive` — and its message names the
20421+
* ownership ceremony as the remedy.
2041920422
*/
2042020423
setTenantKeyCertifiedResolver(resolve: () => boolean | Promise<boolean>): void {
2042120424
this.tenantKeyCertifiedResolver = resolve;
@@ -20443,9 +20446,11 @@ export class SqlDriver implements IDataDriver {
2044320446
// {@link PRE_DDL_QUESTION_SCOPE}).
2044420447
if ((await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) !== true) return false;
2044520448
} catch (e: any) {
20446-
// Not knowing reads as "not certified" — the reading that never proposes
20447-
// a rebuild on a guess — and is said once, so a drift report that then
20448-
// names an 18.0 index is not taken for a verdict on a certified store.
20449+
// Not knowing reads as "not certified": the forms stay distinct and an
20450+
// 18.0 index is reported as a destructive recreate_index, which nothing
20451+
// applies unattended (safe-only boot reconcile, the artifact gate,
20452+
// `--allow-destructive`). Said once, so that report is not taken for a
20453+
// verdict on a certified store.
2044920454
if (!this.tenantKeyCertifiedReadFailureReported) {
2045020455
this.tenantKeyCertifiedReadFailureReported = true;
2045120456
this.logger.warn(

‎packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
* (`sql-driver-tenant-key-equivalence.test.ts`).
1616
*
1717
* Every way of not knowing answers `false`, the reading under which the differ
18-
* keeps the two forms distinct and never proposes a rebuild on a guess.
18+
* keeps the two forms distinct: the differ reports a destructive
19+
* `recreate_index`, which is never applied unattended.
1920
*/
2021

2122
import { describe, it, expect } from 'vitest';

‎packages/objectql/src/engine.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12209,8 +12209,10 @@ export class ObjectQL implements IObjectQLEngine {
1220912209
try {
1221012210
sink.setTenantKeyCertifiedResolver(() => this.isOrganizationOwnershipCertified());
1221112211
} catch (e: any) {
12212-
// A driver that did not take the resolver answers "not certified", the
12213-
// reading that never proposes a rebuild on a guess.
12212+
// A driver that did not take the resolver answers "not certified": it
12213+
// keeps the two forms distinct and REPORTS a destructive recreate_index,
12214+
// which is never applied unattended (safe-only boot reconcile, the
12215+
// artifact gate, `os migrate apply --allow-destructive`).
1221412216
this.logger.debug('Driver declined the ADR-0131 certification resolver', {
1221512217
driverName: driver.name,
1221612218
error: e?.message ?? String(e),

0 commit comments

Comments
 (0)