|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Driver read-coercion conformance — a reusable, driver-agnostic check. |
| 5 | + * |
| 6 | + * A stored value must read back as its DECLARED type on every driver: a |
| 7 | + * `boolean` as a JS boolean (not the integer 0/1 SQLite stores), a `json` field |
| 8 | + * as an object/array (not serialized text), an `integer` as a number. When two |
| 9 | + * drivers disagree, code that is green on one silently breaks on the other. |
| 10 | + * |
| 11 | + * This is the invariant behind the 2026-07-06 `case_escalation` incident: a |
| 12 | + * boolean guard `field != true` read the field back as integer `1` on Turso, so |
| 13 | + * `1 != true` was always true and the flow self-triggered forever — while the |
| 14 | + * local repro (memory / better-sqlite3, both of which coerce) was green. |
| 15 | + * |
| 16 | + * Like {@link checkLedger}, this returns a list of human-readable problems |
| 17 | + * (empty = conformant) and carries NO test-runner dependency — callers assert |
| 18 | + * `expect(await checkReadCoercion(driver)).toEqual([])`. It takes any driver |
| 19 | + * structurally (see {@link CoercibleDriver}) so out-of-tree drivers — e.g. |
| 20 | + * cloud's `driver-turso` in remote mode — can run the identical contract against |
| 21 | + * themselves without importing a concrete driver type. |
| 22 | + */ |
| 23 | + |
| 24 | +/** The minimal driver surface this check drives. */ |
| 25 | +export interface CoercibleDriver { |
| 26 | + connect?(): Promise<void>; |
| 27 | + disconnect?(): Promise<void>; |
| 28 | + syncSchema(object: string, schema: unknown, options?: unknown): Promise<void>; |
| 29 | + create(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>; |
| 30 | + find(object: string, query: unknown, options?: unknown): Promise<any[]>; |
| 31 | +} |
| 32 | + |
| 33 | +export interface ReadCoercionOptions { |
| 34 | + /** Object/table name to create for the probe. Default `read_coercion_probe`. */ |
| 35 | + object?: string; |
| 36 | +} |
| 37 | + |
| 38 | +const FIELDS = { |
| 39 | + name: { type: 'string' }, |
| 40 | + active: { type: 'boolean' }, |
| 41 | + meta: { type: 'json' }, |
| 42 | + count: { type: 'integer' }, |
| 43 | +} as const; |
| 44 | + |
| 45 | +const INPUT = { id: '1', name: 'Widget', active: true, meta: { k: 1, arr: [1, 2] }, count: 5 }; |
| 46 | + |
| 47 | +function stableStringify(v: unknown): string { |
| 48 | + // Order-insensitive for plain objects so a driver that reorders JSON keys on |
| 49 | + // round-trip is not falsely flagged; arrays keep their order. |
| 50 | + const norm = (x: any): any => { |
| 51 | + if (Array.isArray(x)) return x.map(norm); |
| 52 | + if (x && typeof x === 'object') { |
| 53 | + return Object.keys(x).sort().reduce((o: any, k) => ((o[k] = norm(x[k])), o), {}); |
| 54 | + } |
| 55 | + return x; |
| 56 | + }; |
| 57 | + return JSON.stringify(norm(v)); |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Round-trip a typed row through `driver` and report every field whose read-back |
| 62 | + * value does not match its declared type. Empty array = conformant. |
| 63 | + */ |
| 64 | +export async function checkReadCoercion( |
| 65 | + driver: CoercibleDriver, |
| 66 | + opts: ReadCoercionOptions = {}, |
| 67 | +): Promise<string[]> { |
| 68 | + const object = opts.object ?? 'read_coercion_probe'; |
| 69 | + const problems: string[] = []; |
| 70 | + |
| 71 | + await driver.connect?.(); |
| 72 | + try { |
| 73 | + await driver.syncSchema(object, { name: object, fields: FIELDS }); |
| 74 | + await driver.create(object, { ...INPUT }); |
| 75 | + |
| 76 | + // Read back only the row we just wrote (by id), so the check is robust even |
| 77 | + // when the probe object already holds unrelated rows on a shared backend. |
| 78 | + const rows = await driver.find(object, { object, where: { id: INPUT.id } }); |
| 79 | + if (!Array.isArray(rows) || rows.length !== 1) { |
| 80 | + problems.push(`find returned ${Array.isArray(rows) ? `${rows.length} rows` : typeof rows}, expected exactly 1`); |
| 81 | + return problems; |
| 82 | + } |
| 83 | + const row = rows[0] as Record<string, unknown>; |
| 84 | + |
| 85 | + if (row.active !== true) { |
| 86 | + problems.push(`boolean not coerced: 'active' expected true, got ${typeof row.active} ${JSON.stringify(row.active)}`); |
| 87 | + } |
| 88 | + if (stableStringify(row.meta) !== stableStringify(INPUT.meta)) { |
| 89 | + problems.push(`json not coerced: 'meta' expected object ${JSON.stringify(INPUT.meta)}, got ${typeof row.meta} ${JSON.stringify(row.meta)}`); |
| 90 | + } |
| 91 | + if (row.count !== 5) { |
| 92 | + problems.push(`number not coerced: 'count' expected 5, got ${typeof row.count} ${JSON.stringify(row.count)}`); |
| 93 | + } |
| 94 | + } finally { |
| 95 | + await driver.disconnect?.(); |
| 96 | + } |
| 97 | + |
| 98 | + return problems; |
| 99 | +} |
0 commit comments