Skip to content

Commit 3fd3576

Browse files
os-zhuangclaude
andauthored
feat(verify): reusable checkReadCoercion driver conformance helper (#2671)
* test(service-datasource): pin cross-driver read-coercion conformance 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 drivers disagree, code green on one silently breaks on another. 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 memory/better-sqlite3 repro was green). The storage-specific fixes live in each driver; this suite pins the shared contract so no framework driver — present or future — can reintroduce the gap. Runs against driver-sql (better-sqlite3) and driver-memory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EsQntpH9mUZqG7LAWzPAK * chore: add empty changeset (test-only, no version bump) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EsQntpH9mUZqG7LAWzPAK * feat(verify): add reusable checkReadCoercion driver conformance helper Promote the read-coercion contract from a service-datasource-local test into a reusable, driver-agnostic export in @objectstack/verify (mirrors checkLedger: returns a problems list, empty = conformant, no test-runner dependency). Any driver — including out-of-tree ones like cloud's driver-turso in remote mode — can now run the identical contract against itself. - verify: new checkReadCoercion(driver) + CoercibleDriver/ReadCoercionOptions. - service-datasource: the cross-driver conformance test now consumes the shared helper (driver-sql better-sqlite3 + driver-memory), asserting `toEqual([])`. - changeset: minor for @objectstack/verify (new public API). Verified: service-datasource conformance 2 passed; helper flags a raw non-coercing driver with 3 problems (boolean 1 / json text / numeric string); verify build incl. DTS clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EsQntpH9mUZqG7LAWzPAK * fix: break verify↔service-datasource build cycle; run driver conformance in dogfood The prior commit added @objectstack/verify as a devDep of service-datasource, but verify already depends on service-datasource (via runtime) — turbo flagged the cycle and the build/typecheck/test jobs failed. - Move the cross-driver conformance test out of service-datasource into packages/dogfood (which already dev-tests through @objectstack/verify and runs as the Dogfood Regression Gate); add driver-sql + driver-memory as dogfood devDeps. No cycle: drivers don't depend on verify/dogfood. - Revert the service-datasource test + devDep. - checkReadCoercion now reads back its own row by id (robust when the probe object already holds rows); the memory-driver test uses `persistence: false` so the probe never leaks to a shared on-disk snapshot across suites. Verified: `turbo run build --filter=./packages/*` 60/60 (no cycle); full dogfood suite 191 passed / 39 files (incl. the new conformance test: both drivers → [], non-coercing stub → 3 problems); verify build incl. DTS clean; eslint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EsQntpH9mUZqG7LAWzPAK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ff2580f commit 3fd3576

6 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/verify": minor
3+
---
4+
5+
Add `checkReadCoercion` — a reusable, driver-agnostic read-coercion conformance
6+
helper (a stored value must read back as its declared type: boolean as boolean,
7+
json as object, integer as number). Mirrors `checkLedger`: returns a list of
8+
problems (empty = conformant) with no test-runner dependency, so any driver —
9+
including out-of-tree ones like cloud's driver-turso — can run the identical
10+
contract against itself. This is the invariant behind the case_escalation
11+
`1 != true` incident.

packages/dogfood/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"@objectstack/verify": "workspace:*"
1818
},
1919
"devDependencies": {
20+
"@objectstack/driver-memory": "workspace:*",
21+
"@objectstack/driver-sql": "workspace:*",
2022
"@types/node": "^26.1.0",
2123
"typescript": "^6.0.3",
2224
"vitest": "^4.1.10"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Driver read-coercion conformance — exercises the reusable `checkReadCoercion`
4+
// helper (from @objectstack/verify) against the framework's own SQL + memory
5+
// drivers. A stored value must read back as its DECLARED type on every driver:
6+
// a boolean as a boolean (not the integer 0/1 SQLite stores), a json field as
7+
// an object, an integer as a number.
8+
//
9+
// This is the invariant behind the 2026-07-06 case_escalation incident: a
10+
// boolean guard `field != true` read the field back as integer `1` on Turso, so
11+
// `1 != true` was always true and the flow self-triggered forever — while the
12+
// local repro (memory / better-sqlite3, both of which coerce) was green in 6s.
13+
// Cloud's driver-turso runs the identical contract against itself in remote mode.
14+
15+
import { describe, it, expect } from 'vitest';
16+
import { checkReadCoercion } from '@objectstack/verify';
17+
import { SqlDriver } from '@objectstack/driver-sql';
18+
import { InMemoryDriver } from '@objectstack/driver-memory';
19+
20+
const DRIVERS = [
21+
{
22+
name: 'driver-sql (better-sqlite3 :memory:)',
23+
make: () =>
24+
new SqlDriver({
25+
client: 'better-sqlite3',
26+
connection: { filename: ':memory:' },
27+
useNullAsDefault: true,
28+
}),
29+
},
30+
{
31+
name: 'driver-memory',
32+
// `persistence: false` → pure in-memory, so the probe object does not leak to
33+
// a shared on-disk snapshot and collide with other suites in the full run.
34+
make: () => new InMemoryDriver({ persistence: false }),
35+
},
36+
];
37+
38+
describe.each(DRIVERS)('read-coercion conformance: $name', ({ make }) => {
39+
it('reads a stored row back as its declared types (boolean/json/number)', async () => {
40+
const problems = await checkReadCoercion(make() as never);
41+
expect(problems).toEqual([]);
42+
});
43+
});
44+
45+
describe('checkReadCoercion detects a non-coercing driver', () => {
46+
it('flags boolean/json/number that come back raw (the pre-fix remote-Turso shape)', async () => {
47+
const raw = {
48+
async connect() {},
49+
async disconnect() {},
50+
async syncSchema() {},
51+
async create() {},
52+
async find() {
53+
return [{ id: '1', name: 'Widget', active: 1, meta: '{"k":1,"arr":[1,2]}', count: '5' }];
54+
},
55+
};
56+
const problems = await checkReadCoercion(raw as never);
57+
expect(problems).toHaveLength(3);
58+
expect(problems.join('\n')).toMatch(/boolean not coerced/);
59+
expect(problems.join('\n')).toMatch(/json not coerced/);
60+
expect(problems.join('\n')).toMatch(/number not coerced/);
61+
});
62+
});

packages/verify/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ export type { RlsReport, RlsResult } from './rls.js';
2323
// runtime harness): classify every declarable property, fail closed on drift.
2424
export { checkLedger } from './conformance.js';
2525
export type { ConformanceRow, ConformanceState, CheckLedgerOptions } from './conformance.js';
26+
27+
// Driver read-coercion conformance: a stored value must read back as its
28+
// declared type on every driver (the case_escalation `1 != true` invariant).
29+
// Driver-agnostic — any driver, including out-of-tree ones, runs the same check.
30+
export { checkReadCoercion } from './read-coercion.js';
31+
export type { CoercibleDriver, ReadCoercionOptions } from './read-coercion.js';
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
}

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)