Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/read-coercion-conformance.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
62 changes: 62 additions & 0 deletions packages/dogfood/test/read-coercion-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
6 changes: 6 additions & 0 deletions packages/verify/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
99 changes: 99 additions & 0 deletions packages/verify/src/read-coercion.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
disconnect?(): Promise<void>;
syncSchema(object: string, schema: unknown, options?: unknown): Promise<void>;
create(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>;
find(object: string, query: unknown, options?: unknown): Promise<any[]>;
}

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<string[]> {
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<string, unknown>;

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;
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.