From 224efb1582985f439a33a07ff2e710ab84e15156 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 23:51:49 +0000 Subject: [PATCH 1/5] fix(driver-sql): declare the `indexes` key `initObjects` / `registerObjectMetadata` already read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both entry points took `Array<{ name; fields?; tenancy? }>` with no `indexes`, while `registerManagedObjectMetadata` read the key out of those very objects through an `(obj as any)` cast and filled `managedObjectIndexes` from it — the map `syncDeclaredIndexes` renders every declared UNIQUE from. The sibling `detectManagedDrift` on the same class already declared `indexes?: any[]`, so the two halves of one class disagreed about the shape of the same input. This is the shape #4311 fixed for `tenancy`, one key over. Nothing tripped over it because TypeScript's excess-property check fires on a fresh object literal and not on one bound to a variable first, and every caller in the package happened to bind first — a green that held for a reason unrelated to correctness. - Add `indexes?: any[]` to `registerObjectMetadata`, `initObjects` and the shared `registerManagedObjectMetadata` helper, spelled as `detectManagedDrift` spells it. - Delete the `as any` at the `managedObjectIndexes.set` read site: the cast was the evidence that the declaration and the read disagreed. - Pin the fresh-object-literal form, which is the only form that can go red on this defect; a variable-bound call measures nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../sql-driver-init-objects-indexes-param.md | 20 +++ ...r-16570-init-objects-indexes-param.test.ts | 132 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 25 +++- 3 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 .changeset/sql-driver-init-objects-indexes-param.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts diff --git a/.changeset/sql-driver-init-objects-indexes-param.md b/.changeset/sql-driver-init-objects-indexes-param.md new file mode 100644 index 0000000000..bce45ab009 --- /dev/null +++ b/.changeset/sql-driver-init-objects-indexes-param.md @@ -0,0 +1,20 @@ +--- +"@objectstack/driver-sql": patch +--- + +`SqlDriver.initObjects()` and `SqlDriver.registerObjectMetadata()` now declare the `indexes` key they have always read. + +Both entry points took `Array<{ name; fields?; tenancy? }>`, with no `indexes` in the type. The key was read out of those very objects one call deep anyway, through an `as any`, in `registerManagedObjectMetadata` — and the map it fills, `managedObjectIndexes`, is what `syncDeclaredIndexes` renders every declared UNIQUE from. So the driver's whole index-sync path was driven by a key its own signature said did not exist, while the sibling `detectManagedDrift` on the same class had always declared `indexes?: any[]`: the two halves of one class disagreed about the shape of the same input. + +That is the shape #4311 already fixed for `tenancy`, one key over, and the comment #4311 left above `initObjects` described `indexes` word for word. + +**Why nothing tripped over it.** TypeScript's excess-property check fires on a fresh object literal and not on one bound to a variable first, so the same object was accepted or rejected by nothing but where it was spelled — `await driver.initObjects([{ ...bare, indexes: [] }])` was rejected with TS2353, `const o = { ...bare, indexes: [] }; await driver.initObjects([o])` was accepted, and the index was synced either way. Every caller happened to bind first, so the package typechecked green for a reason unrelated to correctness. + +**Why this matters beyond a compile error.** The loud symptom was a rejected correct call. The quiet one is the reachable branch: an author — or an AI — reading the signature concludes `indexes` is not accepted and drops the key, and a declared UNIQUE is then never synced, with no error at authoring time and no error at boot. The schema says those rows cannot collide; they can. + +What changed, all inside `SqlDriver`: + +- `registerObjectMetadata(objects)`, `initObjects(objects)` and the shared `registerManagedObjectMetadata(obj)` helper each gained `indexes?: any[]`, spelled exactly as `detectManagedDrift` already spells it. +- The `(obj as any)` cast at the `managedObjectIndexes.set` read site is gone. The cast was the evidence that the declaration and the read disagreed; leaving it would have fixed the signature while keeping the "the type does not admit me but I read it anyway" path alive. + +This relaxes a driver-local narrowing back toward the contract it implements — `IDataDriver.registerObjectMetadata?(schemas: unknown[])` in `@objectstack/spec` accepts `unknown[]`, and `SqlDriver` narrowed it on its own — so it is not a widening of the protocol. No call that compiles today stops compiling: the parameter type only gained an optional key. diff --git a/packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts b/packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts new file mode 100644 index 0000000000..268b40ad30 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16570] `initObjects` and `registerObjectMetadata` accept `indexes` — the + * key they have always READ — spelled as a **fresh object literal**. + * + * ## The defect this pins + * + * Both entry points declared `Array<{ name; fields?; tenancy? }>`, with no + * `indexes`. The key was read one call deep anyway, through an `as any`, in + * `registerManagedObjectMetadata`: + * + * ```ts + * this.managedObjectIndexes.set(tableName, (obj as any).indexes); + * ``` + * + * and `managedObjectIndexes` is what `syncDeclaredIndexes` renders every + * declared UNIQUE from — so the driver's whole index-sync path was driven by a + * key its own signature said did not exist. `detectManagedDrift`, on the same + * class, had always declared `indexes?: any[]`: the two halves of one class + * disagreed about the shape of the same input. That is the shape #4311 already + * fixed for `tenancy`, and the comment it left above `initObjects` described + * `indexes` word for word. + * + * ## Why the FORM of this pin is the whole point + * + * TypeScript's excess-property check fires on a **fresh object literal** and + * not on one bound to a variable first, so the same object was accepted or + * rejected by where it was spelled: + * + * ```ts + * await driver.initObjects([{ ...bare, indexes: [] }]); // TS2353 + * const withoutIndex = { ...bare, indexes: [] }; + * await driver.initObjects([withoutIndex]); // accepted + * ``` + * + * Every existing caller in this package happened to bind first — one of them + * (`sql-driver-11794-richtext-text-family.test.ts`) even wrote the workaround + * down: *"Hoisted (not an inline literal) … `indexes` rides through + * `initObjects` beyond its narrow parameter type"*. So the package typechecked + * green for a reason unrelated to correctness, and a **variable-bound pin + * cannot go red on this defect** — it measures nothing. Every call below is + * therefore an inline literal in argument position, which is what makes + * `tsc --noEmit` (this package's `typecheck` script) the instrument that + * measures it: revert either signature and these lines stop compiling with + * + * TS2353: Object literal may only specify known properties, and 'indexes' + * does not exist in type '{ name: string; fields?: Record + * | undefined; tenancy?: any; }'. + * + * The runtime assertions are the other half: they prove the key is not merely + * *admitted* by the type but still *read* — recorded in `managedObjectIndexes` + * (§1), rendered into a physical UNIQUE (§2), and cleared when withdrawn (§3). + * A signature relaxation that quietly stopped reading the key would pass the + * compile leg alone. + * + * Runs on the always-available in-memory SQLite cell: the defect is in a + * parameter type and in the registry it feeds, neither of which is + * dialect-specific. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { SqlDriver } from './sql-driver.js'; +import { dialectCell } from './live-dialect-matrix.testkit.js'; + +const SQLITE = dialectCell('sqlite'); + +/** + * The un-indexed base object, deliberately WITHOUT `indexes` — every call site + * below spreads it and writes `indexes` inline, so the literal being checked is + * fresh in argument position. `tenancy: { enabled: false }` keeps the declared + * UNIQUE on the plain (non-tenant-scoped) path, and the bounded `maxLength` + * keeps `v` a keyable varchar rather than an unbounded TEXT. + */ +const bareObject = (name: string) => ({ + name, + tenancy: { enabled: false }, + fields: { v: { type: 'text', maxLength: 64 } }, +}); + +/** What the driver recorded for `table`, read off the protected registry. */ +const recordedIndexes = (driver: SqlDriver, table: string): unknown => + (driver as unknown as { managedObjectIndexes: Map }).managedObjectIndexes.get(table); + +describe('initObjects / registerObjectMetadata accept `indexes` as a fresh object literal (#16570)', () => { + let driver: SqlDriver | undefined; + afterEach(async () => { + await driver?.disconnect().catch(() => {}); + driver = undefined; + }); + + it('§1 registerObjectMetadata: the inline literal compiles AND the key is recorded', async () => { + const T = 'os16570_register'; + driver = new SqlDriver(SQLITE.config()); + const declared = [{ fields: ['v'], unique: true as const, name: `uniq_${T}_v` }]; + + // Fresh literal in argument position — not hoisted to a variable first. + driver.registerObjectMetadata([{ ...bareObject(T), indexes: declared }]); + + expect(recordedIndexes(driver, T)).toEqual(declared); + }); + + it('§2 initObjects: the inline literal compiles AND the declared UNIQUE is physically synced', async () => { + const T = 'os16570_init'; + driver = new SqlDriver(SQLITE.config()); + + // Fresh literal in argument position. + await driver.initObjects([ + { ...bareObject(T), indexes: [{ fields: ['v'], unique: true as const, name: `uniq_${T}_v` }] }, + ]); + + const knex = (driver as unknown as { knex: (t: string) => any }).knex; + await knex(T).insert({ id: 'a', v: 'same' }); + // If `indexes` had been dropped at authoring time — the silent failure mode + // this card is about — this second row would be accepted. + await expect(knex(T).insert({ id: 'b', v: 'same' })).rejects.toThrow(); + }); + + it('§3 initObjects: the exact `{ ...bare, indexes: [] }` spelling from the card compiles, and withdraws the entry', async () => { + const T = 'os16570_withdraw'; + driver = new SqlDriver(SQLITE.config()); + const bare = bareObject(T); + + await driver.initObjects([{ ...bare, indexes: [{ fields: ['v'], unique: true as const, name: `uniq_${T}_v` }] }]); + expect(recordedIndexes(driver, T)).toHaveLength(1); + + // The spelling named in the card, verbatim: an empty array must CLEAR the + // entry, not leave the previous one standing. + await driver.initObjects([{ ...bare, indexes: [] }]); + expect(recordedIndexes(driver, T)).toEqual([]); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 64b54958e1..0b6200d65d 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -9599,7 +9599,7 @@ export class SqlDriver implements IDataDriver { * which `initObjects` goes on to use for its DDL. */ protected registerManagedObjectMetadata( - obj: { name: string; fields?: Record; tenancy?: any }, + obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }, ): { tableName: string; tenantField: string | null } { const tableName = StorageNameMapping.resolveTableName(obj); // #2186: remember the authoritative metadata field set for this table so @@ -9608,8 +9608,8 @@ export class SqlDriver implements IDataDriver { // Always overwrite — a metadata change that REMOVES `indexes` must clear // the previous entry, or drift detection keeps expecting an index nobody // declares any more (and never reports it as orphaned). - if (Array.isArray((obj as any).indexes)) { - this.managedObjectIndexes.set(tableName, (obj as any).indexes); + if (Array.isArray(obj.indexes)) { + this.managedObjectIndexes.set(tableName, obj.indexes); } else { this.managedObjectIndexes.delete(tableName); } @@ -9729,7 +9729,7 @@ export class SqlDriver implements IDataDriver { * Idempotent: pure metadata assignment, safe to re-drive on every reload. */ registerObjectMetadata( - objects: Array<{ name: string; fields?: Record; tenancy?: any }>, + objects: Array<{ name: string; fields?: Record; tenancy?: any; indexes?: any[] }>, ): void { for (const obj of objects) this.registerManagedObjectMetadata(obj); } @@ -9740,7 +9740,22 @@ export class SqlDriver implements IDataDriver { // undeclared here until #4311 (`registerExternalObject` and // `computeAndRecordTenantField` both had it), so a caller spelling the key // correctly was rejected by the type while the driver read it regardless. - async initObjects(objects: Array<{ name: string; fields?: Record; tenancy?: any }>): Promise { + // + // `indexes` is the same story, one key over, and it went undeclared here for + // longer: `registerManagedObjectMetadata` fills `managedObjectIndexes` from + // it, and that map is what `syncDeclaredIndexes` renders every declared + // UNIQUE from — so the whole index-sync path was driven by a key this + // signature said did not exist, reached through an `as any`. The sibling + // `detectManagedDrift` on this class had always declared it, so the two + // halves disagreed about the shape of the same input. Nothing tripped over + // it because TypeScript's excess-property check fires on a FRESH object + // literal and not on one bound to a variable first, and every caller here + // happened to bind first — a green that held for a reason unrelated to + // correctness. `src/sql-driver-16570-init-objects-indexes-param.test.ts` + // pins the fresh-literal form so it cannot silently go back. + async initObjects( + objects: Array<{ name: string; fields?: Record; tenancy?: any; indexes?: any[] }>, + ): Promise { // In-memory registration FIRST, and deliberately ahead of the DDL gate // below: being refused permission to alter a schema is not a reason to stay // ignorant of the objects we were just told about. On a datasource we are a From 0186542b4d45d4ad89f0b7cf80eb6f91a7cc1c76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 00:58:46 +0000 Subject: [PATCH 2/5] fix(driver-sql): drop the two now-dead `as any` casts on the `indexes` parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review finding 4. Both sites read `indexes` off the same `initObjects` parameter this change types, so once the signature declares the key the casts state a disagreement that no longer exists — and the changeset's "path closed" sentence is only true with them gone. Both lines sit inside this card's declared region and no other in-flight claim holds them. The remaining casts are deliberate: `ensureShardTable` reads through its own narrow parameter type outside the region, and the `lifecycle` read is the third-key class carried by a separate card. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- packages/drivers/driver-sql/src/sql-driver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 0b6200d65d..a6357ab837 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -9853,7 +9853,7 @@ export class SqlDriver implements IDataDriver { table: tableName, fields: obj.fields ?? {}, tenantField, - declaredIndexes: (obj as any).indexes, + declaredIndexes: obj.indexes, }); if (!exists) { @@ -9928,7 +9928,7 @@ export class SqlDriver implements IDataDriver { // referenced column physically exists — which is also why field-level // `unique` can no longer be emitted inline by `createColumn`: a composite // needs the tenant column to already be there. - const declaredIndexes = (obj as any).indexes; + const declaredIndexes = obj.indexes; const uniqueFields = Object.values(obj.fields ?? {}).some((f) => isUniqueScopeDeclared(f?.unique), ); From 2e309ce1194865c7b6807caf6e4c2eea3c37274a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:12:09 +0000 Subject: [PATCH 3/5] chore(changeset): grade the driver-sql accept-set widening `minor`, and state the narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review findings 1, 2 and 7. Level: the governing text is the WHICH LEVEL maintainer ruling in .github/workflows/pr-automation.yml — "A purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value) takes at least `minor` ... a `fix(` that widens an index is therefore `minor`" — mechanized by check-changeset-no-major.mjs, which reads a declared clause-② plus a `patch` grade as a self-contradiction inside one PR. The AGENTS.md sentence previously cited is the floor against `none`, not a ceiling, and the historical `patch` precedents are named pre-rule by that same ruling. Text: the previous "no call that compiles today stops compiling" was measured false. The accept set moves both ways — widened for fresh object literals, narrowed for variable-bound `indexes` spelled as a record, a readonly tuple or null, all three of which compiled before and now fail TS2322. Measured in both directions on this package's own tsc. No migration is owed: no caller here is affected, and every newly rejected shape was already discarded at run time by the Array.isArray guard. Scope: names @objectstack/driver-sqlite-wasm, whose SqliteWasmDriver extends SqlDriver and overrides neither method, so both signatures land in its published .d.ts. Same fixed version group, so this is a CHANGELOG effect only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../sql-driver-init-objects-indexes-param.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.changeset/sql-driver-init-objects-indexes-param.md b/.changeset/sql-driver-init-objects-indexes-param.md index bce45ab009..d3bbe9b048 100644 --- a/.changeset/sql-driver-init-objects-indexes-param.md +++ b/.changeset/sql-driver-init-objects-indexes-param.md @@ -1,12 +1,13 @@ --- -"@objectstack/driver-sql": patch +"@objectstack/driver-sql": minor +"@objectstack/driver-sqlite-wasm": minor --- `SqlDriver.initObjects()` and `SqlDriver.registerObjectMetadata()` now declare the `indexes` key they have always read. Both entry points took `Array<{ name; fields?; tenancy? }>`, with no `indexes` in the type. The key was read out of those very objects one call deep anyway, through an `as any`, in `registerManagedObjectMetadata` — and the map it fills, `managedObjectIndexes`, is what `syncDeclaredIndexes` renders every declared UNIQUE from. So the driver's whole index-sync path was driven by a key its own signature said did not exist, while the sibling `detectManagedDrift` on the same class had always declared `indexes?: any[]`: the two halves of one class disagreed about the shape of the same input. -That is the shape #4311 already fixed for `tenancy`, one key over, and the comment #4311 left above `initObjects` described `indexes` word for word. +That is the shape #4311 already addressed for `tenancy`, one key over, and the comment it left above `initObjects` described `indexes` word for word. **Why nothing tripped over it.** TypeScript's excess-property check fires on a fresh object literal and not on one bound to a variable first, so the same object was accepted or rejected by nothing but where it was spelled — `await driver.initObjects([{ ...bare, indexes: [] }])` was rejected with TS2353, `const o = { ...bare, indexes: [] }; await driver.initObjects([o])` was accepted, and the index was synced either way. Every caller happened to bind first, so the package typechecked green for a reason unrelated to correctness. @@ -15,6 +16,12 @@ That is the shape #4311 already fixed for `tenancy`, one key over, and the comme What changed, all inside `SqlDriver`: - `registerObjectMetadata(objects)`, `initObjects(objects)` and the shared `registerManagedObjectMetadata(obj)` helper each gained `indexes?: any[]`, spelled exactly as `detectManagedDrift` already spells it. -- The `(obj as any)` cast at the `managedObjectIndexes.set` read site is gone. The cast was the evidence that the declaration and the read disagreed; leaving it would have fixed the signature while keeping the "the type does not admit me but I read it anyway" path alive. +- Every `(obj as any)` cast reading `indexes` off those parameters is gone — the one at the `managedObjectIndexes.set` site and the two inside `initObjects`' own create/alter path. The cast was the evidence that the declaration and the read disagreed; leaving any of them would have fixed the signature while keeping the "the type does not admit me but I read it anyway" path alive. That path is now closed on this parameter. -This relaxes a driver-local narrowing back toward the contract it implements — `IDataDriver.registerObjectMetadata?(schemas: unknown[])` in `@objectstack/spec` accepts `unknown[]`, and `SqlDriver` narrowed it on its own — so it is not a widening of the protocol. No call that compiles today stops compiling: the parameter type only gained an optional key. +**What the accept set does, precisely — it moves in both directions.** For a **fresh object literal**, which is what an author writes and what the excess-property check judges, this is purely a widening: `{ ...bare, indexes: [...] }` was rejected and is now accepted. For a **variable-bound** argument, which bypasses that check and is judged by ordinary assignability, it is a narrowing: `indexes` spelled as a record, as a `readonly` tuple (`as const`), or as `null` compiled under the previous signatures and is now rejected with TS2322. Measured in both directions, all three shapes, on this package's own `tsc`. + +That narrowing is deliberate and carries no migration. No caller in this repository is affected. Every shape newly rejected at compile time was already discarded at run time by the `Array.isArray(obj.indexes)` guard the driver has always applied — an author who wrote one of them got no index and no diagnostic, which is the exact failure this change exists to make impossible. And `any[]` is the spelling already published on `detectManagedDrift` for the same key on the same class, so an `as const` caller could not satisfy the sibling method either. + +`@objectstack/driver-sqlite-wasm` is named because `SqliteWasmDriver extends SqlDriver` and overrides neither method, so both widened signatures land in its own published `.d.ts` and its consumers see the identical change. The two packages are in the same fixed version group, so this is a CHANGELOG effect rather than a version one. + +The `IDataDriver` contract itself did not move: `registerObjectMetadata?(schemas: unknown[])` in `@objectstack/spec` already accepted `unknown[]`, and `SqlDriver` narrowed it on its own. What grew is `SqlDriver`'s own published accept set. From 09dec14e6e98f763a38a312914b42f93f68676b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:33:03 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs(changeset):=20correct=20the=20run-time?= =?UTF-8?q?=20claim=20=E2=80=94=20an=20`as=20const`=20tuple=20compiled=20A?= =?UTF-8?q?ND=20worked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review F1. The changeset said every shape newly rejected at compile time was already discarded at run time by the `Array.isArray` guard. That is false for one of the three shapes it names: `as const` is type-only, so at run time the value is a plain array, `Array.isArray` returns true, and the index WAS synced. Measured against the real driver rather than reasoned about: all three shapes passed through `registerObjectMetadata` with the argument cast to `any`, so the value reaching the driver is exactly what a pre-fix caller passed (the read path is unchanged by this PR — the cast removal is type-only). Result: the `as const` tuple is recorded in `managedObjectIndexes`; the record and `null` are deleted. So the three are now stated apart: a record and `null` never survived the guard and got no index and no diagnostic, while the `readonly` tuple compiled and worked and is now rejected on the type surface alone. No banner and no ADR-0087 disposition are owed, on the two grounds that survive the correction: zero affected callers, and `any[]` already published on `detectManagedDrift` for the same key on the same class. Also pins the narrowing axis, which nothing on the tree covered: two `@ts-expect-error` lines in the existing pin file. They are the assertion — if either stops erroring, tsc fails it as TS2578. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../sql-driver-init-objects-indexes-param.md | 7 ++++++- ...ver-16570-init-objects-indexes-param.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.changeset/sql-driver-init-objects-indexes-param.md b/.changeset/sql-driver-init-objects-indexes-param.md index d3bbe9b048..b2c2bc203f 100644 --- a/.changeset/sql-driver-init-objects-indexes-param.md +++ b/.changeset/sql-driver-init-objects-indexes-param.md @@ -20,7 +20,12 @@ What changed, all inside `SqlDriver`: **What the accept set does, precisely — it moves in both directions.** For a **fresh object literal**, which is what an author writes and what the excess-property check judges, this is purely a widening: `{ ...bare, indexes: [...] }` was rejected and is now accepted. For a **variable-bound** argument, which bypasses that check and is judged by ordinary assignability, it is a narrowing: `indexes` spelled as a record, as a `readonly` tuple (`as const`), or as `null` compiled under the previous signatures and is now rejected with TS2322. Measured in both directions, all three shapes, on this package's own `tsc`. -That narrowing is deliberate and carries no migration. No caller in this repository is affected. Every shape newly rejected at compile time was already discarded at run time by the `Array.isArray(obj.indexes)` guard the driver has always applied — an author who wrote one of them got no index and no diagnostic, which is the exact failure this change exists to make impossible. And `any[]` is the spelling already published on `detectManagedDrift` for the same key on the same class, so an `as const` caller could not satisfy the sibling method either. +That narrowing is deliberate, and the three shapes did **not** all behave the same way before it — the difference is worth stating exactly, because only one of them ever worked: + +- A **record** and **`null`** never survived the `Array.isArray(obj.indexes)` guard the driver has always applied. That author got no index and no diagnostic — silently, at run time. Rejecting those two at compile time is precisely the failure this change exists to make impossible. +- A **`readonly` tuple (`as const`)** is a different case, and the only one with anything to lose. `as const` is type-only: at run time the value is a plain array, `Array.isArray` returns `true`, and the index **was** synced. That caller compiled and worked, and is now rejected at compile time. Nothing about its run-time behaviour changed — the rejection is entirely on the type surface. + +No migration is owed even so. No caller in this repository is affected, and the shape could never have reached `detectManagedDrift` on the same class either, which publishes the very same `any[]` spelling for the very same key — so a `readonly` caller was already unable to use half of this driver's declared-index surface. A caller in that position spells the array without `as const`, or widens it at the call site. `@objectstack/driver-sqlite-wasm` is named because `SqliteWasmDriver extends SqlDriver` and overrides neither method, so both widened signatures land in its own published `.d.ts` and its consumers see the identical change. The two packages are in the same fixed version group, so this is a CHANGELOG effect rather than a version one. diff --git a/packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts b/packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts index 268b40ad30..e19b3c6dbb 100644 --- a/packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-16570-init-objects-indexes-param.test.ts @@ -130,3 +130,20 @@ describe('initObjects / registerObjectMetadata accept `indexes` as a fresh objec expect(recordedIndexes(driver, T)).toEqual([]); }); }); + +/** + * The other half of the accept set. A variable-bound argument bypasses the + * excess-property check and is judged by ordinary assignability, so `indexes` + * must be an array — the two `@ts-expect-error`s ARE the assertion here: if + * either line stops erroring, `tsc` fails it as TS2578. Compile-time only, + * deliberately never called. + */ +export async function pinsTheNarrowingAxis(driver: SqlDriver): Promise { + const asRecord = { ...bareObject('os16570_narrow'), indexes: { uniq_v: { fields: ['v'] } } }; + // @ts-expect-error TS2322 — a record is not `any[]`, and never synced an index at run time. + await driver.initObjects([asRecord]); + + const asNull = { ...bareObject('os16570_narrow'), indexes: null }; + // @ts-expect-error TS2322 — `null` is not `any[]`, and never synced an index at run time. + await driver.initObjects([asNull]); +} From 695026b24d0e07b4d352a884bc09d82a6f1d94c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 05:16:25 +0000 Subject: [PATCH 5/5] docs(changeset): state the readonly-tuple narrowing truthfully (#16570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review F1. The changeset's run-time sentence was false for one of the three shapes it names: `as const` is type-only, so the value is a plain array at run time, `Array.isArray` returns true, and the index WAS synced. The three shapes are stated apart — a record and `null` were discarded at run time by the guard and got no index and no diagnostic; the `readonly` tuple compiled and worked and is now rejected on the type surface alone (`TS2322`), with no in-repo caller and no way to have reached `detectManagedDrift` on the same class, which already publishes `any[]` for the same key. This commit records the seat's disposition on that corrected ground, which the restatement had left unstated: no `BREAKING` banner and no ADR-0087 disposition, resting on grounds (i) zero affected callers and (iii) the sibling's already published `any[]` spelling — never on the run-time ground, which is false for the `readonly` tuple. No code change. Gates at this head, verbatim: node scripts/check-changeset-no-major.mjs --base origin/main EXIT=0 node scripts/check-adr-0087-registration.mjs --base origin/main EXIT=0 node scripts/check-empty-changeset.mjs --base origin/main EXIT=0 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --- .changeset/sql-driver-init-objects-indexes-param.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/sql-driver-init-objects-indexes-param.md b/.changeset/sql-driver-init-objects-indexes-param.md index b2c2bc203f..79c3dd097f 100644 --- a/.changeset/sql-driver-init-objects-indexes-param.md +++ b/.changeset/sql-driver-init-objects-indexes-param.md @@ -27,6 +27,8 @@ That narrowing is deliberate, and the three shapes did **not** all behave the sa No migration is owed even so. No caller in this repository is affected, and the shape could never have reached `detectManagedDrift` on the same class either, which publishes the very same `any[]` spelling for the very same key — so a `readonly` caller was already unable to use half of this driver's declared-index surface. A caller in that position spells the array without `as const`, or widens it at the call site. +The disposition on that corrected ground, recorded here because the ground itself moved: **no `BREAKING` banner and no ADR-0087 disposition**, resting on grounds (i) and (iii) alone — zero affected callers, and the `any[]` spelling already published on `detectManagedDrift` for the same key on the same class. The ground that every newly rejected shape had already been discarded at run time is **not** among them: it is false for the `readonly` tuple, and nothing here leans on it. + `@objectstack/driver-sqlite-wasm` is named because `SqliteWasmDriver extends SqlDriver` and overrides neither method, so both widened signatures land in its own published `.d.ts` and its consumers see the identical change. The two packages are in the same fixed version group, so this is a CHANGELOG effect rather than a version one. The `IDataDriver` contract itself did not move: `registerObjectMetadata?(schemas: unknown[])` in `@objectstack/spec` already accepted `unknown[]`, and `SqlDriver` narrowed it on its own. What grew is `SqlDriver`'s own published accept set.