From 4f4f4ef9340a4d0edb5362bd92a2238193e03216 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 26 Aug 2026 11:05:47 +0000 Subject: [PATCH 1/3] test(spec,drivers): add the VALUE_ROUNDTRIP conformance case-set (#12393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver-conformance census was green at 9 of 9 dialect-scored cells and none of its nine case-sets was about value storage — every one asks which rows come back, none asks what is in them. VALUE_ROUNDTRIP closes that as a class: 41 cases over json / multiple / string / number / boolean, asserting type as well as value, plus the injectivity pairs a per-value check cannot see. Enrolled through the census's own machinery, so CLASSIFIED obliges the fixture to be classified, CONSUMED obliges every driver to run it, and MATRIXED obliges driver-sql's cell to be answered on every dialect it speaks. 50 covered cells, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt. Also fixes a test-double fidelity gap the case-set surfaced: driver-turso's libsql stub did not model @libsql/client's client-side boolean -> 1/0 conversion, so a declared boolean written through the remote transport could not be bound at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .../value-roundtrip-conformance-case-set.md | 47 +++ ...memory-value-roundtrip-conformance.test.ts | 102 ++++++ ...ongodb-value-roundtrip-translation.test.ts | 123 ++++++++ ...driver-value-roundtrip-conformance.test.ts | 130 ++++++++ ...e-wasm-value-roundtrip-conformance.test.ts | 105 +++++++ .../src/libsql-sqlite-stub.testkit.ts | 43 ++- .../turso-value-roundtrip-conformance.test.ts | 148 +++++++++ packages/spec/src/data/index.ts | 7 + .../src/data/value-roundtrip-conformance.ts | 291 ++++++++++++++++++ scripts/check-driver-conformance.mjs | 34 ++ 10 files changed, 1027 insertions(+), 3 deletions(-) create mode 100644 .changeset/value-roundtrip-conformance-case-set.md create mode 100644 packages/drivers/driver-memory/src/memory-value-roundtrip-conformance.test.ts create mode 100644 packages/drivers/driver-mongodb/src/mongodb-value-roundtrip-translation.test.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-value-roundtrip-conformance.test.ts create mode 100644 packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-value-roundtrip-conformance.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-value-roundtrip-conformance.test.ts create mode 100644 packages/spec/src/data/value-roundtrip-conformance.ts diff --git a/.changeset/value-roundtrip-conformance-case-set.md b/.changeset/value-roundtrip-conformance-case-set.md new file mode 100644 index 0000000000..2941f1569e --- /dev/null +++ b/.changeset/value-roundtrip-conformance-case-set.md @@ -0,0 +1,47 @@ +--- +"@objectstack/spec": patch +--- + +test(spec,drivers): add the `VALUE_ROUNDTRIP` conformance case-set — "what you wrote is what you read back", enforced per driver per dialect (#12393) + +The driver-conformance census was green at 9 of 9 dialect-scored cells after +#12136 promoted `MATRIXED`, and **none of its nine case-sets was about value +storage**. All nine ask *which rows come back*; none asks *what is in them*. So +that green was not weak evidence about a round-trip defect — it was no evidence +at all, and it would have stayed green forever with the defect in place. That is +why this family kept arriving one card at a time: #12380 (SQLite's `Field.json` +codec was not injective), #11535 (a multi-value field read back as the string +`'["x","y"]'`), #11782 (MySQL answering `1`/`0` for a declared boolean), #10995 +(PG json values bound without `JSON.stringify`). + +`VALUE_ROUNDTRIP_CASES` closes it as a class rather than as a tenth instance. It +is 41 cases over five declared value classes — `json`, `multiple: true`, +`string`, `number`, `boolean` — and every value in it is one some driver was +**measured** to change, or a control that stayed faithful in the same +measurement. Assertions pin **type as well as value**: the before-state of every +card above was a wrong type carrying a right-looking value, which survives +`toEqual`-style coercion and every truthiness check. `VALUE_ROUNDTRIP_COLLISION_PAIRS` +adds the injectivity half a per-value check cannot see — a string and the native +value whose encoding it resembles must stay distinguishable. + +Enrolled through the census's existing machinery rather than as a bespoke suite, +which is the whole argument for this route: `CLASSIFIED` obliges the new fixture +to be named in `CASE_SETS`, `CONSUMED` obliges every driver to run it, and +`MATRIXED` obliges `driver-sql`'s cell to be answered on **every dialect it +speaks** rather than on SQLite alone — the coverage shape that let #12380 survive +in the first place. The census now reads **50 covered cells across 5 drivers × +10 case-sets, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt**. + +**No shipped behaviour and no public surface changes.** This is `@objectstack/spec`'s +`data` export gaining one conformance fixture, six new test files, and one +`CASE_SETS` row in the census script. No Zod schema, no runtime, no driver +source, no API. Graded `patch` for that reason: the package's published surface +grows by a test fixture that only conformance suites consume, and nothing an +existing consumer resolves changes shape. + +The one non-test change is a **test-double fidelity fix** the new case-set +surfaced: `driver-turso`'s `makeLibsqlSqliteStub` did not model `@libsql/client`'s +client-side boolean → `1`/`0` conversion, so a declared `boolean` written through +the REMOTE transport could not be bound at all. Verified against the dependency's +own source rather than the transport's comment; the transport is correct and +unchanged. diff --git a/packages/drivers/driver-memory/src/memory-value-roundtrip-conformance.test.ts b/packages/drivers/driver-memory/src/memory-value-roundtrip-conformance.test.ts new file mode 100644 index 0000000000..7003c60711 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-value-roundtrip-conformance.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12393] `driver-memory` held to `VALUE_ROUNDTRIP_CASES` — the shared + * `@objectstack/spec/data` table: what you wrote is what you read back. + * + * This driver runs in process, so every case here is a REAL execution — no + * server-free half in the shape `driver-mongodb` needs (#5517), and no + * emitted-shape assertion standing in for a value. + * + * ## Why an in-process store is not exempt from this table + * + * "It keeps the object you handed it, so of course it round-trips" is the + * assumption the census exists to disprove, and this driver has broken it + * before on the neighbouring axis: `computeAggregate` had no `count_distinct` + * arm and answered `null` silently (#6814). A store that clones, normalises, + * indexes or re-serialises a written value on the way in or out has exactly the + * seam every other driver has — and if it ever grows one, this is the file that + * says so. Its being green today is the measurement, not a reason to skip it. + * + * The reverse-verification leg below is what keeps it from being a test that + * cannot fail. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + VALUE_ROUNDTRIP_CASES, + VALUE_ROUNDTRIP_COLLISION_PAIRS, + VALUE_ROUNDTRIP_FIELDS, + VALUE_ROUNDTRIP_ROWS, + valueRoundTripDivergence, +} from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { InMemoryDriver } from './memory-driver.js'; + +const TABLE = 'conformance_value_roundtrip'; + +describe('[#12393] driver-memory — value storage round-trip conformance', () => { + let driver: InMemoryDriver; + + beforeAll(async () => { + driver = new InMemoryDriver(); + // `syncSchema` rather than `initObjects`: this driver is SCHEMALESS and has + // no `initObjects` at all. Declaring the object anyway is the point — it is + // what makes the declaration reachable if this driver ever grows a + // declared-type write path, and it is the same declaration every sibling + // suite hands its own driver. + await driver.syncSchema(TABLE, { fields: { ...VALUE_ROUNDTRIP_FIELDS } }); + for (const row of VALUE_ROUNDTRIP_ROWS) { + await driver.create(TABLE, { ...row }); + } + }); + + // The fixture read back rather than trusted: a seed that dropped or folded a + // row would turn every assertion below into a test of the wrong table. + it('the fixture is one row per case', async () => { + const rows = (await driver.find(TABLE, {})) as Array<{ label: string }>; + expect(rows.map((r) => r.label).sort()).toEqual(VALUE_ROUNDTRIP_CASES.map((c) => c.name).sort()); + }); + + for (const c of VALUE_ROUNDTRIP_CASES) { + it(`round-trips ${c.name} (${c.note})`, async () => { + const rows = (await driver.find(TABLE, { + where: { label: c.name }, + } as DriverQuery)) as any[]; + expect(rows).toHaveLength(1); + const read = rows[0][c.column]; + // The type pin comes first: a wrong type carrying a right-looking value + // is the before-state of every card this table was written from, and it + // survives every value-only comparison. + expect(typeof read, `typeof for ${c.name}`).toBe(typeof c.wrote); + expect(read, `value for ${c.name}`).toStrictEqual(c.wrote); + }); + } + + it('every case in the table round-trips — the whole set at once', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + const divergences = VALUE_ROUNDTRIP_CASES.map((c) => + valueRoundTripDivergence(c, byLabel.get(c.name)?.[c.column]), + ).filter((d): d is string => d !== null); + expect( + divergences, + `${VALUE_ROUNDTRIP_CASES.length - divergences.length}/${VALUE_ROUNDTRIP_CASES.length} faithful`, + ).toEqual([]); + }); + + it('a string and the native value it looks like stay distinguishable', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + const caseOf = (name: string) => VALUE_ROUNDTRIP_CASES.find((c) => c.name === name)!; + for (const [strName, nativeName] of VALUE_ROUNDTRIP_COLLISION_PAIRS) { + const s = byLabel.get(strName)?.[caseOf(strName).column]; + const n = byLabel.get(nativeName)?.[caseOf(nativeName).column]; + expect(typeof s, `${strName} must read back as a string`).toBe('string'); + expect( + JSON.stringify(s) === JSON.stringify(n) && typeof s === typeof n, + `${strName} and ${nativeName} read identically`, + ).toBe(false); + } + }); +}); diff --git a/packages/drivers/driver-mongodb/src/mongodb-value-roundtrip-translation.test.ts b/packages/drivers/driver-mongodb/src/mongodb-value-roundtrip-translation.test.ts new file mode 100644 index 0000000000..e190c33bb4 --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-value-roundtrip-translation.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12393] `driver-mongodb` held to `VALUE_ROUNDTRIP_CASES` — the shared + * `@objectstack/spec/data` table, answered **without a server**. + * + * ## Why the assertions run in-process rather than against mongod + * + * The same reason as `mongodb-filter-text-conformance.test.ts` (#6682) and + * `mongodb-comparand-type-conformance.test.ts` (#7872): this package's + * real-mongod suites are opt-in (#5517), so a standard that needed a server + * would not run in CI. What this suite substitutes is not a weaker question — + * it is the same question asked at the seam where this driver's stored values + * are actually decided. + * + * ## Where that seam is, read from source rather than assumed + * + * A `create()` on this driver does exactly two things to a value before it is + * stored, and this suite drives both: + * + * 1. **`toStorageForms`** — the write-side transform. Read at + * `mongodb-driver.ts`: it iterates the object's declared **temporal** fields + * and returns `data` unchanged when there are none. The fixture declares no + * temporal field at all, which the first test asserts mechanically rather + * than leaving as a claim — so for this table the transform is provably an + * identity and there is nothing of it to model. + * 2. **BSON encoding**, which is where every remaining value decision is made. + * `find()` returns the driver documents straight off the cursor (no + * read-side coercion pass exists in this driver), so the round trip a caller + * sees IS `BSON.deserialize(BSON.serialize(doc))`. + * + * ⇒ The server-free judgement here is not "we modelled mongod". It is the + * driver's own two value steps, executed, with the storage engine — which + * stores what BSON hands it — left out. That is the accepted substitute this + * package's sibling conformance suites already use, stated here rather than + * implied. + * + * ⚠️ What it therefore does NOT cover, said plainly: anything mongod itself + * would do to a value after decoding. The real-mongod half of this cell is + * absent, as it is for this driver's aggregation and filter-text cells. + */ + +import { describe, it, expect } from 'vitest'; +import { BSON } from 'mongodb'; +import { + VALUE_ROUNDTRIP_CASES, + VALUE_ROUNDTRIP_COLLISION_PAIRS, + VALUE_ROUNDTRIP_FIELDS, + VALUE_ROUNDTRIP_ROWS, + valueRoundTripDivergence, +} from '@objectstack/spec/data'; + +/** The temporal types `toStorageForms` reaches — the set that must be empty here. */ +const TEMPORAL_TYPES = new Set(['date', 'datetime', 'time']); + +const caseOf = (name: string) => VALUE_ROUNDTRIP_CASES.find((c) => c.name === name)!; + +/** One row through the driver's storage seam: BSON out, BSON back. */ +function throughBson(row: Record): Record { + return BSON.deserialize(BSON.serialize(row)) as Record; +} + +describe('[#12393] driver-mongodb — value storage round-trip conformance (server-free)', () => { + /** + * The premise the whole file rests on, asserted rather than asserted-in-prose: + * if the fixture ever grows a temporal column, `toStorageForms` stops being an + * identity and this suite would be modelling one of the driver's two value + * steps instead of both. Then it must drive that transform too — and this + * test is what says so, at the moment it becomes true. + */ + it('the fixture declares no temporal field, so the write-side transform is an identity', () => { + const temporal = Object.entries(VALUE_ROUNDTRIP_FIELDS) + .filter(([, def]) => TEMPORAL_TYPES.has((def as { type: string }).type)) + .map(([name]) => name); + expect(temporal).toEqual([]); + }); + + // The fixture read back rather than trusted: a table that lost a row would + // turn every assertion below into a test of the wrong corpus. + it('the fixture is one row per case', () => { + expect(VALUE_ROUNDTRIP_ROWS.map((r) => r.label).sort()).toEqual( + VALUE_ROUNDTRIP_CASES.map((c) => c.name).sort(), + ); + }); + + for (const c of VALUE_ROUNDTRIP_CASES) { + it(`round-trips ${c.name} (${c.note})`, () => { + const read = throughBson({ label: c.name, [c.column]: c.wrote })[c.column]; + // The type pin comes first: a wrong type carrying a right-looking value + // is the before-state of every card this table was written from. + expect(typeof read, `typeof for ${c.name}`).toBe(typeof c.wrote); + expect(read, `value for ${c.name}`).toStrictEqual(c.wrote); + }); + } + + it('every case in the table round-trips — the whole set at once', () => { + const byLabel = new Map( + VALUE_ROUNDTRIP_ROWS.map((r) => [r.label as string, throughBson({ ...r })]), + ); + const divergences = VALUE_ROUNDTRIP_CASES.map((c) => + valueRoundTripDivergence(c, byLabel.get(c.name)?.[c.column]), + ).filter((d): d is string => d !== null); + expect( + divergences, + `${VALUE_ROUNDTRIP_CASES.length - divergences.length}/${VALUE_ROUNDTRIP_CASES.length} faithful`, + ).toEqual([]); + }); + + it('a string and the native value it looks like stay distinguishable', () => { + const byLabel = new Map( + VALUE_ROUNDTRIP_ROWS.map((r) => [r.label as string, throughBson({ ...r })]), + ); + for (const [strName, nativeName] of VALUE_ROUNDTRIP_COLLISION_PAIRS) { + const s = byLabel.get(strName)?.[caseOf(strName).column]; + const n = byLabel.get(nativeName)?.[caseOf(nativeName).column]; + expect(typeof s, `${strName} must read back as a string`).toBe('string'); + expect( + JSON.stringify(s) === JSON.stringify(n) && typeof s === typeof n, + `${strName} and ${nativeName} read identically`, + ).toBe(false); + } + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-value-roundtrip-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-value-roundtrip-conformance.test.ts new file mode 100644 index 0000000000..5e01b23c74 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-value-roundtrip-conformance.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12393] `driver-sql` held to `VALUE_ROUNDTRIP_CASES` — the shared + * `@objectstack/spec/data` table, run across **every dialect this driver + * speaks**. + * + * ## Why this file is matrix-routed and not SQLite-only + * + * The defect that produced the table (#12380) was a *dialect* defect: SQLite's + * `Field.json` codec was not injective while Postgres and MySQL were faithful, + * and the difference was invisible to a suite pinned to one client. So the + * dialect axis is the whole point here, not a formality — this cell is the one + * `MATRIXED` (#12136) exists to make real, and a SQLite-only version of this + * file would restate exactly the coverage that let #12380 survive. + * + * PG and MySQL are also the **regression control**: they were faithful before + * #12380's fix and must stay faithful after it. If a future change to the codec + * moves the defect onto them instead of closing it, it goes red here first. + * + * ## Its relationship to `sql-driver-12380-json-roundtrip.test.ts` + * + * That file is the *instance* pin: it owns the mechanism evidence — the + * NUMERIC-affinity interrogation, the on-disk storage classes read through a + * separate raw query, and the legacy-row migration. None of that is portable to + * another driver, so none of it belongs in a cross-driver table. + * + * This file is the *census* cell: the same value classes, asserted through the + * public driver boundary only, in the vocabulary four other drivers are held to + * as well. The overlap in values is deliberate and is the point — it is what + * makes "SQLite now agrees with Postgres" and "every driver agrees with the + * standard" the same measurement rather than two. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + VALUE_ROUNDTRIP_CASES, + VALUE_ROUNDTRIP_COLLISION_PAIRS, + VALUE_ROUNDTRIP_FIELDS, + VALUE_ROUNDTRIP_ROWS, + valueRoundTripDivergence, +} from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'conformance_value_roundtrip'; + +const caseOf = (name: string) => VALUE_ROUNDTRIP_CASES.find((c) => c.name === name)!; + +function declareRoundTrip(cell: DialectCell): void { + describe(`[#12393] driver-sql — value storage round-trip conformance (${cell.label})`, () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([{ name: TABLE, fields: { ...VALUE_ROUNDTRIP_FIELDS } }]); + for (const row of VALUE_ROUNDTRIP_ROWS) { + await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); + } + }, 60_000); + + afterAll(async () => { + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.disconnect(); + }); + + // The fixture read back rather than trusted: a seed that dropped or folded + // a row would turn every assertion below into a test of the wrong table. + it('the fixture is one row per case', async () => { + const rows = (await driver.find(TABLE, {})) as Array<{ label: string }>; + expect(rows.map((r) => r.label).sort()).toEqual( + VALUE_ROUNDTRIP_CASES.map((c) => c.name).sort(), + ); + }); + + for (const c of VALUE_ROUNDTRIP_CASES) { + it(`round-trips ${c.name} (${c.note})`, async () => { + const rows = (await driver.find(TABLE, { + where: { label: c.name }, + } as DriverQuery)) as any[]; + expect(rows).toHaveLength(1); + const read = rows[0][c.column]; + // The type pin comes first: `'123'` read back as `123` is #12380's + // exact before-state, and it survives every value-only comparison. + expect(typeof read, `typeof for ${c.name}`).toBe(typeof c.wrote); + expect(read, `value for ${c.name}`).toStrictEqual(c.wrote); + }); + } + + it('every case in the table round-trips — the whole set at once', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + const divergences = VALUE_ROUNDTRIP_CASES.map((c) => + valueRoundTripDivergence(c, byLabel.get(c.name)?.[c.column]), + ).filter((d): d is string => d !== null); + expect( + divergences, + `${VALUE_ROUNDTRIP_CASES.length - divergences.length}/${VALUE_ROUNDTRIP_CASES.length} faithful`, + ).toEqual([]); + }); + + it('a string and the native value it looks like stay distinguishable', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + for (const [strName, nativeName] of VALUE_ROUNDTRIP_COLLISION_PAIRS) { + const s = byLabel.get(strName)?.[caseOf(strName).column]; + const n = byLabel.get(nativeName)?.[caseOf(nativeName).column]; + expect(typeof s, `${strName} must read back as a string`).toBe('string'); + expect( + JSON.stringify(s) === JSON.stringify(n) && typeof s === typeof n, + `${strName} and ${nativeName} read identically`, + ).toBe(false); + } + }); + }); +} + +// A matrix that silently finds zero cells reports OK — assert the axis is real +// before iterating it. +describe('[#12393] the dialect axis this suite runs', () => { + it('runs every dialect this driver speaks', () => { + expect(DIALECT_CELLS.map((c) => c.id)).toEqual(['sqlite', 'pg', 'mysql']); + }); +}); + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'value storage round-trip', declareRoundTrip); +} diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-value-roundtrip-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-value-roundtrip-conformance.test.ts new file mode 100644 index 0000000000..8074f44ddf --- /dev/null +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-value-roundtrip-conformance.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12393] `driver-sqlite-wasm` held to `VALUE_ROUNDTRIP_CASES` — the shared + * `@objectstack/spec/data` table, run through this driver's own pipeline. + * + * `SqliteWasmDriver extends SqlDriver`, so the `Field.json` codec #12380 made + * injective is INHERITED and nothing here re-implements it. What this cell pins + * is the other half, the same half its filter-logic, temporal, pagination and + * aggregation suites pin: the values have to survive a different **engine**. + * This driver swaps knex's transport for a custom sql.js dialect + * (`Client_WasmSqlite`) that compiles the statement, binds its parameters and + * marshals the rows back through its own path. + * + * That marshalling step is exactly why this cell is not a formality for THIS + * table in particular. Every case here turns on the JS type a value arrives + * back as, and a dialect that binds or marshals through a different type path — + * sql.js hands back its own value objects — can change a type while every row + * count and every filter result stays correct. "It inherits the codec, + * therefore it is fine" is the assumption these suites exist to disprove: the + * judgement #4405 recorded for this driver's filter-logic cell, applied to the + * stored value. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + VALUE_ROUNDTRIP_CASES, + VALUE_ROUNDTRIP_COLLISION_PAIRS, + VALUE_ROUNDTRIP_FIELDS, + VALUE_ROUNDTRIP_ROWS, + valueRoundTripDivergence, +} from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { SqliteWasmDriver } from './index.js'; + +const TABLE = 'conformance_value_roundtrip'; + +const caseOf = (name: string) => VALUE_ROUNDTRIP_CASES.find((c) => c.name === name)!; + +describe('[#12393] driver-sqlite-wasm — value storage round-trip conformance', () => { + let driver: SqliteWasmDriver; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([{ name: TABLE, fields: { ...VALUE_ROUNDTRIP_FIELDS } }]); + for (const row of VALUE_ROUNDTRIP_ROWS) { + await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); + } + }, 60_000); + + afterAll(async () => { + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.disconnect(); + }); + + // The fixture read back rather than trusted: a seed that dropped or folded + // a row would turn every assertion below into a test of the wrong table. + it('the fixture is one row per case', async () => { + const rows = (await driver.find(TABLE, {})) as Array<{ label: string }>; + expect(rows.map((r) => r.label).sort()).toEqual( + VALUE_ROUNDTRIP_CASES.map((c) => c.name).sort(), + ); + }); + + for (const c of VALUE_ROUNDTRIP_CASES) { + it(`round-trips ${c.name} (${c.note})`, async () => { + const rows = (await driver.find(TABLE, { + where: { label: c.name }, + } as DriverQuery)) as any[]; + expect(rows).toHaveLength(1); + const read = rows[0][c.column]; + // The type pin comes first: `'123'` read back as `123` is #12380's + // exact before-state, and it survives every value-only comparison. + expect(typeof read, `typeof for ${c.name}`).toBe(typeof c.wrote); + expect(read, `value for ${c.name}`).toStrictEqual(c.wrote); + }); + } + + it('every case in the table round-trips — the whole set at once', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + const divergences = VALUE_ROUNDTRIP_CASES.map((c) => + valueRoundTripDivergence(c, byLabel.get(c.name)?.[c.column]), + ).filter((d): d is string => d !== null); + expect( + divergences, + `${VALUE_ROUNDTRIP_CASES.length - divergences.length}/${VALUE_ROUNDTRIP_CASES.length} faithful`, + ).toEqual([]); + }); + + it('a string and the native value it looks like stay distinguishable', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + for (const [strName, nativeName] of VALUE_ROUNDTRIP_COLLISION_PAIRS) { + const s = byLabel.get(strName)?.[caseOf(strName).column]; + const n = byLabel.get(nativeName)?.[caseOf(nativeName).column]; + expect(typeof s, `${strName} must read back as a string`).toBe('string'); + expect( + JSON.stringify(s) === JSON.stringify(n) && typeof s === typeof n, + `${strName} and ${nativeName} read identically`, + ).toBe(false); + } + }); +}); diff --git a/packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts b/packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts index ec35e00724..5351957b26 100644 --- a/packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts +++ b/packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts @@ -52,8 +52,9 @@ export interface LibsqlSqliteStub { /** * Build the stub. `:memory:` unless a file is given. * - * `undefined` args are normalised to `null` because better-sqlite3 rejects - * them, while libsql accepts and binds them as NULL. + * Arguments are normalised by {@link normalize} to the value classes + * better-sqlite3 can bind, using the conversions `@libsql/client` performs + * client-side. See that function for what is modelled and why. */ export function makeLibsqlSqliteStub(filename = ':memory:'): LibsqlSqliteStub { const db = new Database(filename); @@ -84,8 +85,44 @@ export function makeLibsqlSqliteStub(filename = ':memory:'): LibsqlSqliteStub { }; } +/** + * The client-side value conversion `@libsql/client` performs before a bind + * reaches SQLite — modelled here because the stub binds through better-sqlite3 + * directly and better-sqlite3 performs none of it. + * + * ## Why this is the stub's job and not the transport's (#12393) + * + * `RemoteTransport.serializeValue` passes a boolean through unchanged, and its + * comment says why: *"booleans are kept as-is (libsql handles them)"*. Read + * against the dependency rather than trusted — `@libsql/client`'s sqlite3 + * backend converts a bound boolean to `1`/`0` itself under the default + * `intMode` (`lib-esm/sqlite3.js`, the `typeof value === "boolean"` arm of its + * argument valuer). So the transport's comment is accurate and the stub was the + * half that diverged: it handed the raw boolean to better-sqlite3, which + * rejects it outright with *"SQLite3 can only bind numbers, strings, bigints, + * buffers, and null"*. + * + * The asymmetry that hid it: the LOCAL path never reaches this question, + * because `SqlDriver.formatInput` carries its own bind safety net for exactly + * these value classes. So a boolean written through remote mode was the one + * combination nothing in this package exercised, and the gap surfaced only when + * `VALUE_ROUNDTRIP_CASES` asked every driver to store a declared `boolean`. + * + * ⚠️ What this deliberately does NOT model: the client also valuates `Date` to + * `value.valueOf()` and `ArrayBuffer` to a Buffer. Neither is added on + * speculation — `serializeValue` converts a `Date` to an ISO string before the + * client ever sees it, so no reachable write binds one, and an unreachable + * conversion here would be a claim about the client nothing in this package + * checks. Add them when a caller needs them, with the same source citation. + */ const normalize = (args: unknown[] | undefined) => - (args ?? []).map((a) => (a === undefined ? null : a)); + (args ?? []).map((a) => { + // better-sqlite3 rejects `undefined`; libsql accepts it and binds NULL. + if (a === undefined) return null; + // The `intMode: 'number'` default, which is what the driver runs under. + if (typeof a === 'boolean') return a ? 1 : 0; + return a; + }); /** * The stub, typed as the `@libsql/client` `Client` that `TursoDriverConfig` diff --git a/packages/drivers/driver-turso/src/turso-value-roundtrip-conformance.test.ts b/packages/drivers/driver-turso/src/turso-value-roundtrip-conformance.test.ts new file mode 100644 index 0000000000..09b4c0b7d5 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-value-roundtrip-conformance.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12393] `driver-turso` held to `VALUE_ROUNDTRIP_CASES` — the shared + * `@objectstack/spec/data` table, on **both transports**. + * + * This driver is dual-transport and the two halves share no value codec: + * + * - **Local / replica** — `TursoDriver extends SqlDriver`, so the `Field.json` + * codec #12380 made injective is inherited whole. This half is the twin of + * `sql-driver-value-roundtrip-conformance.test.ts`'s SQLite cell. + * - **Remote** — does not go through knex at all. `RemoteTransport` carries its + * own `serializeValue` on the write path and its own `mapRows` on the read + * path. That is the independent Nth backend the shared tables exist for, and + * it is exactly the seam this table asks about: a driver can be perfectly + * correct about *which rows* come back while its own marshalling changes + * *what is in them*. + * + * Both are driven here for the reason the temporal, filter-logic, pagination + * and aggregation cells are driven twice in this package: one green transport + * says nothing about the other. + * + * ## Why a SQLite-backed client stub for the remote half + * + * Same reason as the remote aggregation and filter-logic suites next door: + * libsql IS SQLite, so `makeLibsqlSqliteStub` gives the transport real value + * semantics — TEXT/INTEGER storage classes and the binding rules that go with + * them — with no network and no credentials. The suites that mock `execute` and + * assert on the SQL string keep the network as their concern; a value that + * comes back as the wrong TYPE leaves the SQL perfectly valid, so a string + * assertion cannot see it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + VALUE_ROUNDTRIP_CASES, + VALUE_ROUNDTRIP_COLLISION_PAIRS, + VALUE_ROUNDTRIP_FIELDS, + VALUE_ROUNDTRIP_ROWS, + valueRoundTripDivergence, +} from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { TursoDriver } from './turso-driver.js'; +import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +const TABLE = 'conformance_value_roundtrip'; +const OBJECT = { name: TABLE, fields: { ...VALUE_ROUNDTRIP_FIELDS } }; + +const caseOf = (name: string) => VALUE_ROUNDTRIP_CASES.find((c) => c.name === name)!; + +/** The shared body, so the two transports are held to the SAME assertions. */ +function declareRoundTrip(label: string, make: () => Promise, teardown: () => void): void { + describe(`[#12393] TursoDriver — value storage round-trip conformance (${label})`, () => { + let driver: TursoDriver; + + beforeAll(async () => { + driver = await make(); + }, 60_000); + + afterAll(async () => { + await driver.disconnect(); + teardown(); + }); + + // The fixture read back rather than trusted: a seed that dropped or folded + // a row would turn every assertion below into a test of the wrong table. + it('the fixture is one row per case', async () => { + const rows = (await driver.find(TABLE, {})) as Array<{ label: string }>; + expect(rows.map((r) => r.label).sort()).toEqual( + VALUE_ROUNDTRIP_CASES.map((c) => c.name).sort(), + ); + }); + + for (const c of VALUE_ROUNDTRIP_CASES) { + it(`round-trips ${c.name} (${c.note})`, async () => { + const rows = (await driver.find(TABLE, { + where: { label: c.name }, + } as DriverQuery)) as any[]; + expect(rows).toHaveLength(1); + const read = rows[0][c.column]; + // The type pin comes first: a wrong type carrying a right-looking value + // survives every value-only comparison, which is how this class of + // defect reached production more than once. + expect(typeof read, `typeof for ${c.name}`).toBe(typeof c.wrote); + expect(read, `value for ${c.name}`).toStrictEqual(c.wrote); + }); + } + + it('every case in the table round-trips — the whole set at once', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + const divergences = VALUE_ROUNDTRIP_CASES.map((c) => + valueRoundTripDivergence(c, byLabel.get(c.name)?.[c.column]), + ).filter((d): d is string => d !== null); + expect( + divergences, + `${VALUE_ROUNDTRIP_CASES.length - divergences.length}/${VALUE_ROUNDTRIP_CASES.length} faithful`, + ).toEqual([]); + }); + + it('a string and the native value it looks like stay distinguishable', async () => { + const rows = (await driver.find(TABLE, {})) as any[]; + const byLabel = new Map(rows.map((r) => [r.label, r])); + for (const [strName, nativeName] of VALUE_ROUNDTRIP_COLLISION_PAIRS) { + const s = byLabel.get(strName)?.[caseOf(strName).column]; + const n = byLabel.get(nativeName)?.[caseOf(nativeName).column]; + expect(typeof s, `${strName} must read back as a string`).toBe('string'); + expect( + JSON.stringify(s) === JSON.stringify(n) && typeof s === typeof n, + `${strName} and ${nativeName} read identically`, + ).toBe(false); + } + }); + }); +} + +declareRoundTrip( + 'local mode — the inherited SqlDriver codec', + async () => { + const driver = new TursoDriver({ url: ':memory:' }); + expect(driver.transportMode).toBe('local'); + await driver.initObjects([OBJECT]); + for (const row of VALUE_ROUNDTRIP_ROWS) { + await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); + } + return driver; + }, + () => {}, +); + +let remoteStub: LibsqlSqliteStub | undefined; +declareRoundTrip( + 'remote mode — the transport\'s own serializeValue/mapRows', + async () => { + remoteStub = makeLibsqlSqliteStub(); + const driver = new TursoDriver({ + url: 'libsql://conformance.turso.io', + client: remoteStub as never, + }); + await driver.connect(); + // The mode this half is about — the one that inherits nothing. + expect(driver.transportMode).toBe('remote'); + await driver.syncSchema(TABLE, OBJECT); + for (const row of VALUE_ROUNDTRIP_ROWS) await driver.create(TABLE, { ...row }); + return driver; + }, + () => remoteStub?.close(), +); diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index cc7ee3fa4b..d1133c8ce0 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -70,6 +70,13 @@ export * from './pagination-conformance'; // (`count(distinct x)`) is the first in the vocabulary that two faces of one // driver can get wrong in ways only a row-result comparison sees. export * from './aggregation-conformance'; +// Canonical conformance cases for value storage ROUND-TRIP (#12393) — "what you +// wrote is what you read back", asserted on type as well as value, plus the +// injectivity pairs a per-value check cannot see. The census's nine other +// case-sets are all about WHICH ROWS come back; this is the one about what the +// values in them are, and its absence is why that family (#12380, #11535, +// #11782, #10995) kept arriving one card at a time. +export * from './value-roundtrip-conformance'; export * from './date-macros.zod'; // Dashboard date-range preset names (#4614 single source, re-homed by #8793) — // declared for the dashboard date-filter positions, REFUSED as bare ordering diff --git a/packages/spec/src/data/value-roundtrip-conformance.ts b/packages/spec/src/data/value-roundtrip-conformance.ts new file mode 100644 index 0000000000..2b55548e5d --- /dev/null +++ b/packages/spec/src/data/value-roundtrip-conformance.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical conformance cases for **value storage round-trip** (#12393) — the + * one standard *"what you wrote is what you read back"*, run on every driver. + * + * ## Why this table exists — a measurement, not a proposal + * + * The driver-conformance census (`scripts/check-driver-conformance.mjs`) was + * green at 9 of 9 dialect-scored cells after #12136 promoted `MATRIXED`, and + * **none of its nine case-sets was about value storage**. The nine cover + * filters, temporal form, pagination and aggregation — every one of them a + * question about *which rows come back*, none about *what the values in them + * are*. So that green was not weak evidence about a round-trip defect; it was + * **no evidence at all**, and it would have stayed green forever with the + * defect in place. + * + * That is why this family kept arriving one card at a time: + * + * | card | the same question, one instance at a time | + * |---|---| + * | #12380 | SQLite's `Field.json` codec was not injective — `'123'` read back as the number `123`; PG/MySQL disagreed | + * | #11535 | a multi-value field read back as the string `'["x","y"]'` | + * | #11782 | MySQL answered `1`/`0` for a declared boolean | + * | #10995 | PG json values bound without `JSON.stringify` — the write half | + * + * Every value below is a value some driver was **measured** to change, or a + * control that stayed faithful in the same measurement. Nothing here was + * invented to round out a table. + * + * ## The contract this asserts, and why it is a contract argument + * + * `json`'s stored contract is `z.unknown()` — deliberately open (see + * `valueSchemaFor` in `./field-value.zod`: *"openness is now an explicit + * decision, not an accident of nobody checking"*). An explicitly-open contract + * admits **both** `123` and `'123'` as legal values of one field, so no driver + * has license to collapse them onto one representation. The scalar columns are + * the same argument in its easy direction: a declared `boolean` that reads back + * as `1` has changed the value's type, and the contract says the value is a + * boolean. + * + * ⇒ The standard is **injectivity**: distinct written values must remain + * distinguishable on read. {@link VALUE_ROUNDTRIP_COLLISION_PAIRS} is that + * property stated directly, and it is the half a per-value assertion can miss — + * two rows can each look plausible and still be the same bytes. + * + * ## How a driver suite consumes this table + * + * Write {@link VALUE_ROUNDTRIP_ROWS} through the driver's own `create()`, read + * them back through its own `find()`, and for each case assert **the type and + * the value**: + * + * ```ts + * expect(typeof read).toBe(typeof c.wrote); // FIRST — see below + * expect(read).toStrictEqual(c.wrote); + * ``` + * + * ⚠️ The `typeof` pin is not decoration and it is not redundant with + * `toStrictEqual`. The before-state of every card in the table above was a + * **wrong type carrying a right-looking value**: `'123'` read back as `123` + * passes `toEqual`-style coercion, every truthiness pin and most snapshot + * comparisons, which is exactly how those defects survived long enough to be + * found by reading source. A round-trip pin that does not compare types is not + * a round-trip pin. (`toStrictEqual` does compare types; the separate pin + * exists so the FAILURE names the type, which is the diagnosis.) + * + * A driver with no server available in CI (`driver-mongodb`, whose real-mongod + * suites are opt-in since #5517) makes the same judgement server-free, at the + * seam where its values are actually encoded — for that driver, BSON + * serialize/deserialize. That is the accepted substitute its sibling + * conformance suites already use, and it is stated in the suite rather than + * implied here. + * + * ## What belongs here — one axis, like every sibling table + * + * **Value storage form only.** Explicitly the subject of other tables, and + * deliberately absent from this one: + * + * - **Temporal storage form** — `TEMPORAL_CASES` / `TEMPORAL_TIME_CASES` + * (ADR-0053, #3994). Date/datetime/time are the one value class with a + * *canonical* storage form the platform declares; that is a different + * question from "the driver did not change it", and it already has two + * tables. + * - **Aggregated values** — `AGGREGATION_CASES` (#6409). `avg`/`sum` over a + * boolean (#11065 / #11151) is a value a driver *computes*, not one it + * stored. + * - **Which rows come back** — the filter, pagination and comparand tables. + * + * ⛔ **Also deliberately absent: an own key holding `undefined`.** A declared + * field written as an explicit `undefined` comes back from the JS-backed + * drivers as an own key holding `undefined` — neither "absent" nor "holds a + * value" — and it is an **open, tracked** divergence (#9276, on hold with a + * named restart condition), not a defect this table discovered. Putting it here + * would ship a case-set that is red on `driver-memory` by construction, which + * is precisely the escape hatch #12136's ruling closed: *a gate that ships red + * is worse than one that ships honest*. When #9276 lands, its value class + * belongs in this table and the case that proves it belongs in this file. + * + * @see https://github.com/objectstack-ai/objectstack/issues/12393 (this table) + * @see https://github.com/objectstack-ai/objectstack/issues/12380 (the measured boundary set) + * @see https://github.com/objectstack-ai/objectstack/issues/9276 (the value class deliberately excluded) + */ + +/** The declared column a case writes into. */ +export type ValueRoundTripColumn = 'v_json' | 'v_multi' | 'v_string' | 'v_number' | 'v_boolean'; + +/** + * The object the cases are written through, as a driver's `initObjects()` takes + * it. + * + * One column per declared value class rather than one object per class: a + * single fixture is one `create()` per row on every driver, and it keeps the + * collision pairs — which span classes — expressible in one read. + * + * `v_multi` is `multiple: true` on an ordinary string field. That is the + * #11535 shape and it is not a synonym for `v_json`: on the SQL family + * `multiple` decides the column type **before** the type switch runs, so a + * multi-value string and a declared `json` reach the JSON storage path by two + * different routes and can diverge independently. + */ +export const VALUE_ROUNDTRIP_FIELDS = { + label: { type: 'string' }, + v_json: { type: 'json' }, + v_multi: { type: 'string', multiple: true }, + v_string: { type: 'string' }, + v_number: { type: 'number' }, + v_boolean: { type: 'boolean' }, +} as const; + +/** A row in the round-trip fixture: one written value, in one declared column. */ +export interface ValueRoundTripCase { + /** Stable identifier — the row's `label`, and usable as a test name. */ + readonly name: string; + /** Which declared column this value is written into. */ + readonly column: ValueRoundTripColumn; + /** The exact JS value handed to `create()`. The read must `toStrictEqual` it. */ + readonly wrote: unknown; + /** Why the case is here — surfaced in failure output. */ + readonly note: string; +} + +/** + * The cases. + * + * The `v_json` block is #12380's measured boundary set: every string in it has + * content that is valid JSON or is number-like (or both) — the two classes the + * pre-fix SQLite encoding destroyed — plus the ordinary strings that always + * worked, kept as controls so a suite that goes red says *which* class broke. + */ +export const VALUE_ROUNDTRIP_CASES: readonly ValueRoundTripCase[] = [ + // ── json: strings whose CONTENT is valid JSON (the read-side class) ──────── + { name: 's_true', column: 'v_json', wrote: 'true', note: 'string whose content parses as a boolean' }, + { name: 's_false', column: 'v_json', wrote: 'false', note: 'string whose content parses as a boolean' }, + { name: 's_null', column: 'v_json', wrote: 'null', note: 'string whose content parses as null' }, + { name: 's_arr', column: 'v_json', wrote: '[]', note: 'string whose content parses as an array' }, + { name: 's_obj', column: 'v_json', wrote: '{"a":1}', note: 'string whose content parses as an object' }, + { name: 's_quoted', column: 'v_json', wrote: '"quoted"', note: 'string whose content parses as a string' }, + + // ── json: number-like strings (the write-side class) ────────────────────── + // + // These are the ones a read-path change cannot repair: on SQLite a `json` + // column carries NUMERIC affinity, so a bare number-like string was converted + // to INTEGER/REAL before storage. They are here because a driver that gets + // them wrong is destroying data, not misreporting it. + { name: 's_123', column: 'v_json', wrote: '123', note: 'number-like string' }, + { name: 's_pad', column: 'v_json', wrote: ' 123 ', note: 'number-like string with padding' }, + { name: 's_0123', column: 'v_json', wrote: '0123', note: 'number-like string, leading zero' }, + { name: 's_1e5', column: 'v_json', wrote: '1e5', note: 'number-like string, exponent form' }, + { name: 's_1p0', column: 'v_json', wrote: '1.0', note: 'number-like string, trailing zero' }, + { name: 's_neg0', column: 'v_json', wrote: '-0', note: 'number-like string, negative zero' }, + + // ── json: ordinary strings — controls that were faithful throughout ─────── + { name: 's_empty', column: 'v_json', wrote: '', note: 'empty string' }, + { name: 's_tz', column: 'v_json', wrote: 'America/New_York', note: 'ordinary string' }, + { name: 's_bad', column: 'v_json', wrote: '{bad json', note: 'string that does not parse' }, + { name: 's_nan', column: 'v_json', wrote: 'NaN', note: 'string that is not valid JSON' }, + + // ── json: native (non-string) values ────────────────────────────────────── + { name: 'n_true', column: 'v_json', wrote: true, note: 'native boolean inside json' }, + { name: 'n_false', column: 'v_json', wrote: false, note: 'native boolean inside json' }, + { name: 'n_int', column: 'v_json', wrote: 123, note: 'native integer' }, + { name: 'n_real', column: 'v_json', wrote: 1.5, note: 'native real' }, + { name: 'n_null', column: 'v_json', wrote: null, note: 'native null' }, + { name: 'n_str', column: 'v_json', wrote: 'plain', note: 'native plain string' }, + { name: 'n_obj', column: 'v_json', wrote: { a: 1 }, note: 'native object' }, + { name: 'n_arr', column: 'v_json', wrote: [1, 2], note: 'native array' }, + { name: 'n_arr_empty', column: 'v_json', wrote: [], note: 'native empty array' }, + { name: 'n_quoted', column: 'v_json', wrote: 'quoted', note: 'native string — the collision twin of s_quoted' }, + { + name: 'n_nested', + column: 'v_json', + wrote: { a: [1, { b: 'x' }], c: null }, + note: 'nested object — a codec that re-encodes only the top level shows up here', + }, + + // ── multi-value (#11535): stored as JSON, reached by a different route ──── + { + name: 'm_two', + column: 'v_multi', + wrote: ['userA', 'userB'], + note: "#11535's exact shape: the array that came back as the string '[\"userA\",\"userB\"]'", + }, + { name: 'm_one', column: 'v_multi', wrote: ['solo'], note: 'single-element array must not degrade to its element' }, + { name: 'm_empty', column: 'v_multi', wrote: [], note: 'empty array must not degrade to null' }, + { + name: 'm_numlike', + column: 'v_multi', + wrote: ['123', '0123'], + note: 'members are number-like strings — the json class, inside the multi-value route', + }, + + // ── scalar columns: the value class stated in the declaration ───────────── + { name: 'b_true', column: 'v_boolean', wrote: true, note: 'declared boolean — #11782 read this back as 1 on MySQL' }, + { name: 'b_false', column: 'v_boolean', wrote: false, note: 'declared boolean — the 0 half of #11782' }, + { name: 'num_int', column: 'v_number', wrote: 42, note: 'declared number, integral' }, + { name: 'num_real', column: 'v_number', wrote: 1.5, note: 'declared number, fractional' }, + { name: 'num_zero', column: 'v_number', wrote: 0, note: 'zero must survive as a number, not become null' }, + { name: 'num_neg', column: 'v_number', wrote: -7.25, note: 'declared number, negative fractional' }, + { + name: 'str_numlike', + column: 'v_string', + wrote: '00123', + note: 'a declared STRING holding a number-like value must stay a string', + }, + { + name: 'str_jsonlike', + column: 'v_string', + wrote: '{"a":1}', + note: 'a declared STRING holding JSON text must not be parsed on the way out', + }, + { name: 'str_plain', column: 'v_string', wrote: 'plain', note: 'ordinary string — the control' }, + { name: 'str_empty', column: 'v_string', wrote: '', note: 'empty string must not become null' }, +] as const; + +/** + * The fixture as rows, one per case: the case's own column carries its value + * and no other declared column is written. + * + * Deliberately one column per row rather than a dense table. A row that wrote + * every column at once could not tell "this driver changed the value" from + * "this driver moved it into the wrong column", and the second failure is one + * the JSON-storage path can actually produce. + */ +export const VALUE_ROUNDTRIP_ROWS: readonly Record[] = VALUE_ROUNDTRIP_CASES.map( + (c) => ({ label: c.name, [c.column]: c.wrote }), +); + +/** + * Pairs that must remain **distinguishable on read** — the injectivity half. + * + * Each pair is a string and the native value whose JSON encoding it looks like. + * Three of these collided on SQLite before #12380 (`'123'`/`123`, `'[]'`/`[]`, + * `'{"a":1}'`/`{a:1}`) and a fourth collided on read (`'null'`/`null`); all + * were distinct on Postgres and MySQL, which is what made it a driver defect + * rather than a platform decision. + * + * ⚠️ This is not implied by the per-case assertions. A driver that answered + * every case with the *string* form would pass half of them and fail the other + * half — but a driver that is merely LOSSY in one direction can pass every + * individual case whose written value happens to be the surviving form. The + * pair check asks the question the per-value check cannot. + */ +export const VALUE_ROUNDTRIP_COLLISION_PAIRS: readonly (readonly [string, string])[] = [ + ['s_123', 'n_int'], + ['s_arr', 'n_arr_empty'], + ['s_obj', 'n_obj'], + ['s_true', 'n_true'], + ['s_null', 'n_null'], + ['s_quoted', 'n_quoted'], +] as const; + +/** + * The verdict for one case, computed identically by every consuming suite. + * + * A shared judge rather than a per-suite `expect` chain, for the reason the + * table itself is shared: five suites each spelling "faithful" their own way is + * five definitions, and the one that is subtly weaker is the one that stays + * green. Returns `null` when the round trip was faithful, or the human-readable + * divergence when it was not. + */ +export function valueRoundTripDivergence(c: ValueRoundTripCase, read: unknown): string | null { + const wroteType = typeof c.wrote; + const readType = typeof read; + const same = + readType === wroteType && JSON.stringify(read ?? null) === JSON.stringify(c.wrote ?? null); + if (same) return null; + return ( + `${c.name} (${c.column}): wrote ${JSON.stringify(c.wrote)} (${wroteType}), ` + + `read ${JSON.stringify(read)} (${readType})` + ); +} diff --git a/scripts/check-driver-conformance.mjs b/scripts/check-driver-conformance.mjs index e51329eab4..f89424974e 100644 --- a/scripts/check-driver-conformance.mjs +++ b/scripts/check-driver-conformance.mjs @@ -252,6 +252,11 @@ const CASE_SETS = [ marker: 'FILTER_COMPARAND_TYPE_CASES', what: 'the comparand-type door: the six accepted types compile everywhere, everything else is refused loudly — #7872', }, + { + file: 'value-roundtrip-conformance.ts', + marker: 'VALUE_ROUNDTRIP_CASES', + what: 'value storage round-trip: what you wrote is what you read back, type included — #12393', + }, ]; // ── The ledger ────────────────────────────────────────────────────────────── @@ -513,6 +518,35 @@ const CASE_SETS = [ // The ledger is now EMPTY, which is the state its header calls the intended // steady one. Read the open set from a run, not from this prose. +// ## VALUE_ROUNDTRIP: a tenth column that added ZERO rows (#12393) +// +// Worth recording precisely because it is the shape the two paragraphs above +// describe as the goal and neither of them achieved on arrival. FILTER_TEXT +// landed ahead of its implementations and opened five rows; AGGREGATION landed +// with two. VALUE_ROUNDTRIP landed with none — all five drivers answered it on +// the day it arrived. +// +// ⛔ Read that as a MEASUREMENT, not as evidence the column is weak. The +// measurement is only meaningful because the column was ablated: restoring the +// pre-#12380 SQLite json branch turns this column red on THREE drivers +// (driver-sql, driver-sqlite-wasm and driver-turso's local transport, all of +// which route through `SqlDriver.formatInput`) while leaving every one of the +// other nine columns green — which is the whole argument the card was filed +// on. See `packages/spec/src/data/value-roundtrip-conformance.ts` and the +// per-driver suites for the numbers. +// +// Why zero rows was reachable at all: the column was deliberately SEQUENCED +// second, behind #12380's route-A codec fix, precisely so it would not ship red +// on SQLite by construction. That ordering is the reusable part — it is the +// #12014 -> #12136 shape applied again, and it is why this column needed no +// ledger entry and no new escape hatch. +// +// One value class is deliberately OUTSIDE the column rather than ledgered +// inside it: a declared field written as an explicit `undefined`, which the +// JS-backed drivers return as an own key holding `undefined` (#9276, open and +// tracked). A ledger row would have been the wrong instrument — the case-set +// simply does not ask that question yet, and the case-set file says so by name. + // The intended steady state, reached on 2026-08-11: every (driver x case-set) // cell is covered by an imported case-set, and nothing is deferred. Keep it // that way by writing the suite, not by adding a row — a DEBT entry is a From 6d96e0a73f33b1b3f49ba052fb97216f9cdb4acd Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 26 Aug 2026 12:15:13 +0000 Subject: [PATCH 2/3] chore(spec): regenerate export-origins for the VALUE_ROUNDTRIP exports (#12393) The generated export-origins artifact records every runtime export of each entry point, and `./data` gained seven. Caught by the package's own compiler-free freshness guard (scripts/export-origins.test.ts), regenerated with the command that guard's failure text names. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- packages/spec/export-origins/data.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 66b846348b..ff470e75d0 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -630,9 +630,15 @@ "UnknownAuthoringKeyFinding": "src/data/authoring-key-lint.ts#UnknownAuthoringKeyFinding (interface)", "UnorderedPaginationConformanceCase": "src/data/pagination-conformance.ts#UnorderedPaginationConformanceCase (interface)", "VALID_AST_OPERATORS": "src/data/filter.zod.ts#VALID_AST_OPERATORS (const)", + "VALUE_ROUNDTRIP_CASES": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_CASES (const)", + "VALUE_ROUNDTRIP_COLLISION_PAIRS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_COLLISION_PAIRS (const)", + "VALUE_ROUNDTRIP_FIELDS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_FIELDS (const)", + "VALUE_ROUNDTRIP_ROWS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_ROWS (const)", "ValidationRule": "src/data/validation.zod.ts#ValidationRule (type)", "ValidationRuleSchema": "src/data/validation.zod.ts#ValidationRuleSchema (const)", "ValueForm": "src/data/field-value.zod.ts#ValueForm (type)", + "ValueRoundTripCase": "src/data/value-roundtrip-conformance.ts#ValueRoundTripCase (interface)", + "ValueRoundTripColumn": "src/data/value-roundtrip-conformance.ts#ValueRoundTripColumn (type)", "ValueShapeFieldDef": "src/data/field-value.zod.ts#ValueShapeFieldDef (interface)", "ZeroLimitConformanceCase": "src/data/pagination-conformance.ts#ZeroLimitConformanceCase (interface)", "apiExposureDenialReason": "src/data/api-derivation.ts#apiExposureDenialReason (function)", @@ -769,6 +775,7 @@ "urlUserinfoUsername": "src/data/driver/common.zod.ts#urlUserinfoUsername (function)", "utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)", "validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)", + "valueRoundTripDivergence": "src/data/value-roundtrip-conformance.ts#valueRoundTripDivergence (function)", "valueSchemaFor": "src/data/field-value.zod.ts#valueSchemaFor (function)" } } From c1d0ba950317028bfc94fd56b7ff911521eb46e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 15:50:27 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the api-surface snapshot for the VALUE_ROUNDTRIP exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `Type Check · consumer gates` was red on `check:api-surface`, which named the seven additions and the exact remedy. Regenerated with the repo's tooling; the diff is those seven lines and nothing else, and `check:api-surface` now reports the surface unchanged. The generator refuses without a built package and says so rather than emitting a wrong snapshot — the build was run first, and `OS_SKIP_DTS=1` was NOT used, since that flag skips exactly the declarations this reads (AGENTS.md 9). Why it reached CI: `check:api-surface` is one of the nine families in #12514 whose declared path population matches nothing in the tree, so the local dispatch-gates derivation could not name it for this change set. The sibling `export-origins` snapshot WAS regenerated, because that one was caught by the package's own suite. --- packages/spec/api-surface/data.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 68439fabc9..ad09997433 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -630,9 +630,15 @@ "UnknownAuthoringKeyFinding (interface)", "UnorderedPaginationConformanceCase (interface)", "VALID_AST_OPERATORS (const)", + "VALUE_ROUNDTRIP_CASES (const)", + "VALUE_ROUNDTRIP_COLLISION_PAIRS (const)", + "VALUE_ROUNDTRIP_FIELDS (const)", + "VALUE_ROUNDTRIP_ROWS (const)", "ValidationRule (type)", "ValidationRuleSchema (const)", "ValueForm (type)", + "ValueRoundTripCase (interface)", + "ValueRoundTripColumn (type)", "ValueShapeFieldDef (interface)", "ZeroLimitConformanceCase (interface)", "apiExposureDenialReason (function)", @@ -769,6 +775,7 @@ "urlUserinfoUsername (function)", "utcInstantMs (function)", "validateDriverConfig (function)", + "valueRoundTripDivergence (function)", "valueSchemaFor (function)" ] }