diff --git a/.changeset/read-coercion-conformance.md b/.changeset/read-coercion-conformance.md new file mode 100644 index 0000000000..97e5bf475b --- /dev/null +++ b/.changeset/read-coercion-conformance.md @@ -0,0 +1,11 @@ +--- +"@objectstack/verify": minor +--- + +Add `checkReadCoercion` — a reusable, driver-agnostic read-coercion conformance +helper (a stored value must read back as its declared type: boolean as boolean, +json as object, integer as number). Mirrors `checkLedger`: returns a list of +problems (empty = conformant) with no test-runner dependency, so any driver — +including out-of-tree ones like cloud's driver-turso — can run the identical +contract against itself. This is the invariant behind the case_escalation +`1 != true` incident. diff --git a/packages/dogfood/package.json b/packages/dogfood/package.json index 9fb5778af6..70079980aa 100644 --- a/packages/dogfood/package.json +++ b/packages/dogfood/package.json @@ -17,6 +17,8 @@ "@objectstack/verify": "workspace:*" }, "devDependencies": { + "@objectstack/driver-memory": "workspace:*", + "@objectstack/driver-sql": "workspace:*", "@types/node": "^26.1.0", "typescript": "^6.0.3", "vitest": "^4.1.10" diff --git a/packages/dogfood/test/read-coercion-conformance.test.ts b/packages/dogfood/test/read-coercion-conformance.test.ts new file mode 100644 index 0000000000..2eb19989d6 --- /dev/null +++ b/packages/dogfood/test/read-coercion-conformance.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Driver read-coercion conformance — exercises the reusable `checkReadCoercion` +// helper (from @objectstack/verify) against the framework's own SQL + memory +// drivers. A stored value must read back as its DECLARED type on every driver: +// a boolean as a boolean (not the integer 0/1 SQLite stores), a json field as +// an object, an integer as a number. +// +// This is the invariant behind the 2026-07-06 case_escalation incident: a +// boolean guard `field != true` read the field back as integer `1` on Turso, so +// `1 != true` was always true and the flow self-triggered forever — while the +// local repro (memory / better-sqlite3, both of which coerce) was green in 6s. +// Cloud's driver-turso runs the identical contract against itself in remote mode. + +import { describe, it, expect } from 'vitest'; +import { checkReadCoercion } from '@objectstack/verify'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; + +const DRIVERS = [ + { + name: 'driver-sql (better-sqlite3 :memory:)', + make: () => + new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), + }, + { + name: 'driver-memory', + // `persistence: false` → pure in-memory, so the probe object does not leak to + // a shared on-disk snapshot and collide with other suites in the full run. + make: () => new InMemoryDriver({ persistence: false }), + }, +]; + +describe.each(DRIVERS)('read-coercion conformance: $name', ({ make }) => { + it('reads a stored row back as its declared types (boolean/json/number)', async () => { + const problems = await checkReadCoercion(make() as never); + expect(problems).toEqual([]); + }); +}); + +describe('checkReadCoercion detects a non-coercing driver', () => { + it('flags boolean/json/number that come back raw (the pre-fix remote-Turso shape)', async () => { + const raw = { + async connect() {}, + async disconnect() {}, + async syncSchema() {}, + async create() {}, + async find() { + return [{ id: '1', name: 'Widget', active: 1, meta: '{"k":1,"arr":[1,2]}', count: '5' }]; + }, + }; + const problems = await checkReadCoercion(raw as never); + expect(problems).toHaveLength(3); + expect(problems.join('\n')).toMatch(/boolean not coerced/); + expect(problems.join('\n')).toMatch(/json not coerced/); + expect(problems.join('\n')).toMatch(/number not coerced/); + }); +}); diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 96af85216e..775f82181d 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -23,3 +23,9 @@ export type { RlsReport, RlsResult } from './rls.js'; // runtime harness): classify every declarable property, fail closed on drift. export { checkLedger } from './conformance.js'; export type { ConformanceRow, ConformanceState, CheckLedgerOptions } from './conformance.js'; + +// Driver read-coercion conformance: a stored value must read back as its +// declared type on every driver (the case_escalation `1 != true` invariant). +// Driver-agnostic — any driver, including out-of-tree ones, runs the same check. +export { checkReadCoercion } from './read-coercion.js'; +export type { CoercibleDriver, ReadCoercionOptions } from './read-coercion.js'; diff --git a/packages/verify/src/read-coercion.ts b/packages/verify/src/read-coercion.ts new file mode 100644 index 0000000000..f262525d05 --- /dev/null +++ b/packages/verify/src/read-coercion.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Driver read-coercion conformance — a reusable, driver-agnostic check. + * + * A stored value must read back as its DECLARED type on every driver: a + * `boolean` as a JS boolean (not the integer 0/1 SQLite stores), a `json` field + * as an object/array (not serialized text), an `integer` as a number. When two + * drivers disagree, code that is green on one silently breaks on the other. + * + * This is the invariant behind the 2026-07-06 `case_escalation` incident: a + * boolean guard `field != true` read the field back as integer `1` on Turso, so + * `1 != true` was always true and the flow self-triggered forever — while the + * local repro (memory / better-sqlite3, both of which coerce) was green. + * + * Like {@link checkLedger}, this returns a list of human-readable problems + * (empty = conformant) and carries NO test-runner dependency — callers assert + * `expect(await checkReadCoercion(driver)).toEqual([])`. It takes any driver + * structurally (see {@link CoercibleDriver}) so out-of-tree drivers — e.g. + * cloud's `driver-turso` in remote mode — can run the identical contract against + * themselves without importing a concrete driver type. + */ + +/** The minimal driver surface this check drives. */ +export interface CoercibleDriver { + connect?(): Promise; + disconnect?(): Promise; + syncSchema(object: string, schema: unknown, options?: unknown): Promise; + create(object: string, data: Record, options?: unknown): Promise; + find(object: string, query: unknown, options?: unknown): Promise; +} + +export interface ReadCoercionOptions { + /** Object/table name to create for the probe. Default `read_coercion_probe`. */ + object?: string; +} + +const FIELDS = { + name: { type: 'string' }, + active: { type: 'boolean' }, + meta: { type: 'json' }, + count: { type: 'integer' }, +} as const; + +const INPUT = { id: '1', name: 'Widget', active: true, meta: { k: 1, arr: [1, 2] }, count: 5 }; + +function stableStringify(v: unknown): string { + // Order-insensitive for plain objects so a driver that reorders JSON keys on + // round-trip is not falsely flagged; arrays keep their order. + const norm = (x: any): any => { + if (Array.isArray(x)) return x.map(norm); + if (x && typeof x === 'object') { + return Object.keys(x).sort().reduce((o: any, k) => ((o[k] = norm(x[k])), o), {}); + } + return x; + }; + return JSON.stringify(norm(v)); +} + +/** + * Round-trip a typed row through `driver` and report every field whose read-back + * value does not match its declared type. Empty array = conformant. + */ +export async function checkReadCoercion( + driver: CoercibleDriver, + opts: ReadCoercionOptions = {}, +): Promise { + const object = opts.object ?? 'read_coercion_probe'; + const problems: string[] = []; + + await driver.connect?.(); + try { + await driver.syncSchema(object, { name: object, fields: FIELDS }); + await driver.create(object, { ...INPUT }); + + // Read back only the row we just wrote (by id), so the check is robust even + // when the probe object already holds unrelated rows on a shared backend. + const rows = await driver.find(object, { object, where: { id: INPUT.id } }); + if (!Array.isArray(rows) || rows.length !== 1) { + problems.push(`find returned ${Array.isArray(rows) ? `${rows.length} rows` : typeof rows}, expected exactly 1`); + return problems; + } + const row = rows[0] as Record; + + if (row.active !== true) { + problems.push(`boolean not coerced: 'active' expected true, got ${typeof row.active} ${JSON.stringify(row.active)}`); + } + if (stableStringify(row.meta) !== stableStringify(INPUT.meta)) { + problems.push(`json not coerced: 'meta' expected object ${JSON.stringify(INPUT.meta)}, got ${typeof row.meta} ${JSON.stringify(row.meta)}`); + } + if (row.count !== 5) { + problems.push(`number not coerced: 'count' expected 5, got ${typeof row.count} ${JSON.stringify(row.count)}`); + } + } finally { + await driver.disconnect?.(); + } + + return problems; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3da7154ddb..ba14e740ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -748,6 +748,12 @@ importers: specifier: workspace:* version: link:../verify devDependencies: + '@objectstack/driver-memory': + specifier: workspace:* + version: link:../plugins/driver-memory + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../plugins/driver-sql '@types/node': specifier: ^26.1.0 version: 26.1.0