Skip to content

Commit 022c6e7

Browse files
test(dogfood): pin the undeclared-key refusal on the suite's SQL driver arms (#15953 slice 7) (#23976)
Part of #15953 Clause-②: no ## What this is Slice 7 of the card: row 10 of the classification table (comment `6115219467`), the per-driver half, as the slice-7 claim (`6217542057`) scopes it. It is one new test file, `packages/qa/dogfood/test/undeclared-key-conformance.test.ts`, beside `read-coercion-conformance.test.ts` and in its style. On every SQL driver arm the dogfood conformance files run, it pins that a key the object does not declare is refused by the ENGINE as one envelope, whether the caller wrote it or a before-hook stamped it. The table, read straight past the engine through knex, holds nothing the refused write carried. This is the platform's own pin for what hotcrm's `test/undeclared-key-probe.test.ts` asserted, so objectstack-ai/hotcrm#1583 can retire that file. - No source change anywhere. - `@objectstack/dogfood` is `private: true`, so there is no changeset. The seat applies `skip-changeset`. - The card stays open for the date-window family's residue and the acceptance clause "runs from `os verify` on hotcrm's metadata". ## The arms - **`driver-sql` on better-sqlite3 `:memory:`.** This arm is the table's "memory", read as the claim rules: the driver-memory arm left the conformance files in #5704, and this is the arm `read-coercion-conformance.test.ts` runs. It is also the table's "sqlite". - **`driver-sqlite-wasm` `:memory:`.** Covered by measurement. The suite already runs this arm (`date-bucket-parity-conformance.test.ts`, `group-key-read-shape-parity.test.ts`), and here it boots under `ObjectQL` and syncs its schema. ## What each arm answers today, measured before anything was pinned The setup is `ObjectQL` over each real driver with a system context. `probe_quote` declares `tax_rate` and `probe_quote_line` does not (hotcrm's declared/undeclared pair). The reading was taken on the built engine and drivers at base `161aee104`, and the pinned file re-ran green at the final head. | write carrying `tax_rate` onto `probe_quote_line` | driver-sql | sqlite-wasm | |:--|:--|:--| | caller insert, caller update | refused: `Error`, `code` `INVALID_FIELD`, `status` 400, `field` `tax_rate`, `object` `probe_quote_line` | the same | | `beforeInsert` hook stamps it | the same envelope | the same | | `beforeUpdate` hook, by id | the same envelope | the same | | `beforeUpdate` hook, by predicate (`multi: true`) | the same envelope | the same | | the table after each refusal | unchanged | unchanged | | the same hook on `probe_quote`, which declares the key | stored `7` | stored `7` | No arm departs from one envelope, so there was no defect to stop at. The objectql pins (`engine-post-hook-undeclared-field.test.ts`, `engine-undeclared-key-caller-path.test.ts`) prove the door against driver doubles only, and say so. This file is the half on the shipped drivers. ## The cases (six per arm, twelve in all) 1. **Control:** a declared key round-trips through insert, update, `findOne` and the knex read. 2. **Control:** the same hook stores the same key on the object that declares it. So a refusal can never mean "hooks never ran on this arm". 3. **Caller insert:** `INVALID_FIELD` / 400, naming the field and the object. The table is empty. 4. **Caller update:** the same envelope, and the row is unchanged. 5. **Hook insert:** `code` and `status` as above, and the same envelope, field for field, as the caller path's answer on that arm. The table is empty. 6. **Hook update, by id and by predicate:** the same, and both rows are unchanged. The envelope is projected from the engine's own error (`code`, `status`, `field`, `object`) and never rebuilt in the test. The hook path is compared with the caller path's measured answer. No message text is pinned. ## Ablation (one, on the fixture) - **Mutation:** the probe hook on `probe_quote_line` writes a DECLARED key instead. `const LINE_HOOK_WRITES = { [KEY]: 7 };` becomes `{ unit_price: 7 }`, through `scripts/ablation-replace.mjs`: anchor x1 to x0, replacement x0 to x1, blob `878a1b97a7cc` to `8c606255f1ce`. A trap in the ablation script restores on exit. - **Reading:** predicted `4 failed | 8 passed`, read `4 failed | 8 passed`. Both hook-path cases failed on both arms, each with "expected the engine to refuse, and it answered". The controls and the caller path stayed green. - **Restore:** the blob is back to `878a1b97a7cc`, equal to HEAD's; `git diff HEAD` and `git status --porcelain` are both empty. - **No build leg:** the mutated file is the test itself, which vitest loads from source, so there was no build or dist leg to run. ## Tests and gates, at `04185a291` - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 --reporter=verbose test/undeclared-key-conformance.test.ts test/read-coercion-conformance.test.ts`: 2 files, 14 passed. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0, after a build of the whole `@objectstack/dogfood^...` closure. `tsc --listFiles` includes the new file. - `pnpm exec eslint --no-inline-config packages/qa/dogfood/test/undeclared-key-conformance.test.ts`: exit 0 with 0 messages, and the file was linted, not ignored. The repo-wide lint is CI's `Lint & Repo Gates`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths): 53 derived, 53 run, all exit 0. `--ran` reconciles 53 / 53 / 0 NOT-MEASURED / 0 UNRUN. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages outside the dogfood closure had no `dist/`). It exited 0 after those were built. - **Declared to CI, not run locally:** the rest of the dogfood suite (the Dogfood Regression Gate, three shards) and `test:repo` (Test Core). ## Acceptance notes - **Wall time.** The twelve cases take about 200 ms. The file's local wall (15 s on a shared box) is module import of the built objectql and drivers. Dogfood is excluded from Test Core, whose per-file captures feed the 10 s line, so this file is outside that reading. - **A driver-memory arm was not added.** `bootStack({ databaseDriver: 'memory' })` can still boot driver-memory in this suite; the analytics isolation pins use it as a two-driver equivalence arm. This slice follows the claim's reading of "memory". Noted, not filed; carrier: none. - **`origin/main` moved after the merge.** This branch merged `6d1660dc3`, and `origin/main` then moved to `68ffa5dc1`. That is one commit (PR #23959), touching other dogfood files (`authz-conformance.test.ts` and its census) and `packages/rest`. Neither reads this file or anything it imports, so the branch was not re-merged. - **Left alone, as the claim says:** - the objectql write-path half of row 10 (it landed with #23603); - the date-window family's residue; - `packages/verify`. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e0e451 commit 022c6e7

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Undeclared-key driver conformance — a key the object does not declare is
4+
// refused by the ENGINE, as one envelope, on every SQL driver arm this suite
5+
// runs, whether the caller wrote it or a `before*` hook did. And the table,
6+
// read straight past the engine, holds nothing the refused write carried.
7+
//
8+
// What a misspelled key costs, per writer and per driver, is the engine's and
9+
// the drivers' fact, not an app's (#15953's classification table, row 10).
10+
// hotcrm pinned it by hand in `test/undeclared-key-probe.test.ts` over its two
11+
// line-item objects; this file is the platform's own pin, so that one can go.
12+
//
13+
// The engine runs one door, `undeclaredWriteFieldErrors` (objectql
14+
// `engine.ts`), twice per write: over the caller's keys ahead of the hooks,
15+
// and again over the post-hook payload ahead of the statement. Before the
16+
// second run existed, ONE hook writing `tax_rate` onto an object that does not
17+
// declare it meant a different thing per driver (objectql's suite records the
18+
// 17.1.0 reading): the memory driver stored a shadow column outside
19+
// field-level security, driver-sql threw a raw `SQLITE_ERROR` with no
20+
// `status`, and sqlite-wasm a bare `Error` with neither. objectql pins the
21+
// door against driver DOUBLES (`engine-post-hook-undeclared-field.test.ts`,
22+
// and the caller path in `engine-undeclared-key-caller-path.test.ts`) and
23+
// says so: it depends on no real driver. This file is the other half, the
24+
// shipped drivers under the engine.
25+
//
26+
// The arms. The table's "memory" is the suite's in-memory SQL arm: the
27+
// `driver-memory` arm left the conformance files in #5704, and `driver-sql` on
28+
// better-sqlite3 `:memory:` is the arm `read-coercion-conformance.test.ts`
29+
// runs. It is also the table's "sqlite". `sqlite-wasm` is the arm
30+
// `date-bucket-parity-conformance.test.ts` runs beside it.
31+
//
32+
// The envelope is read off the engine, never rebuilt here. The caller path is
33+
// pinned to the code and status the table names, `INVALID_FIELD` / 400, which
34+
// is what both arms answer today. The hook path must answer the SAME envelope
35+
// the caller path answers on the same arm, compared field for field. Two
36+
// controls keep a red-free run from measuring nothing: a declared key
37+
// round-trips, and the same hook DOES store the same key on an object that
38+
// declares it, so a refusal can never be "hooks never ran on this arm".
39+
40+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
41+
import { ObjectQL } from '@objectstack/objectql';
42+
import { SqlDriver } from '@objectstack/driver-sql';
43+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
44+
45+
const DRIVERS = [
46+
{
47+
name: 'driver-sql (better-sqlite3 :memory:)',
48+
make: (): SqlDriver =>
49+
new SqlDriver({
50+
client: 'better-sqlite3',
51+
connection: { filename: ':memory:' },
52+
useNullAsDefault: true,
53+
}),
54+
},
55+
{
56+
name: 'driver-sqlite-wasm (:memory:)',
57+
make: (): SqlDriver => new SqliteWasmDriver({ filename: ':memory:' }),
58+
},
59+
];
60+
61+
const SYS = { context: { isSystem: true, userId: 'usr_system' } };
62+
63+
/** hotcrm's pair: `probe_quote` declares the key, `probe_quote_line` does not. */
64+
const KEY = 'tax_rate';
65+
const QUOTE = 'probe_quote';
66+
const LINE = 'probe_quote_line';
67+
68+
/** What the probe hook stamps onto a `probe_quote_line` payload: a key that object does not declare. */
69+
const LINE_HOOK_WRITES = { [KEY]: 7 };
70+
/** What the same hook stamps onto a `probe_quote` payload: the same key, declared there. */
71+
const QUOTE_HOOK_WRITES = { [KEY]: 7 };
72+
73+
/** The caller path's answer, as the table names it and as both arms answer it today. */
74+
const CALLER_PATH = { code: 'INVALID_FIELD', status: 400 };
75+
76+
const APP = {
77+
id: 'com.objectstack.dogfood.undeclared-key-conformance',
78+
name: 'Undeclared key conformance',
79+
version: '1.0.0',
80+
type: 'plugin',
81+
scope: 'system',
82+
objects: [
83+
{
84+
name: QUOTE,
85+
label: 'Probe quote',
86+
fields: {
87+
id: { name: 'id', type: 'text', primaryKey: true },
88+
name: { name: 'name', type: 'text' },
89+
[KEY]: { name: KEY, type: 'number' },
90+
},
91+
},
92+
{
93+
name: LINE,
94+
label: 'Probe quote line',
95+
fields: {
96+
id: { name: 'id', type: 'text', primaryKey: true },
97+
name: { name: 'name', type: 'text' },
98+
unit_price: { name: 'unit_price', type: 'number' },
99+
},
100+
},
101+
],
102+
};
103+
104+
/** The fields of a refusal a caller reads. Projected from the engine's error, never constructed. */
105+
interface Refusal {
106+
code?: unknown;
107+
status?: unknown;
108+
field?: unknown;
109+
object?: unknown;
110+
}
111+
const envelope = (e: Refusal): Refusal => ({ code: e.code, status: e.status, field: e.field, object: e.object });
112+
113+
async function refusalOf(run: () => Promise<unknown>): Promise<Refusal> {
114+
try {
115+
await run();
116+
} catch (e) {
117+
return e as Refusal;
118+
}
119+
throw new Error('expected the engine to refuse, and it answered');
120+
}
121+
122+
/** A `beforeInsert` + `beforeUpdate` hook on `object` that stamps `writes` onto the payload. */
123+
function armStampHook(engine: ObjectQL, object: string, writes: Record<string, unknown>): void {
124+
const stamp = (ctx: { input: Record<string, unknown> }) => {
125+
Object.assign(ctx.input.data as Record<string, unknown>, writes);
126+
};
127+
engine.registerHook('beforeInsert', stamp, { object });
128+
engine.registerHook('beforeUpdate', stamp, { object });
129+
}
130+
131+
describe.each(DRIVERS)('undeclared-key conformance: $name', ({ make }) => {
132+
let driver: SqlDriver;
133+
let engine: ObjectQL;
134+
135+
beforeEach(async () => {
136+
driver = make();
137+
engine = new ObjectQL();
138+
engine.registerDriver(driver, true);
139+
await engine.init();
140+
engine.registerApp(APP);
141+
await engine.syncSchemas();
142+
});
143+
144+
afterEach(async () => {
145+
await engine.destroy();
146+
});
147+
148+
/** Ground truth, read off the table through knex: past every engine layer. */
149+
async function stored(object: string, column: string): Promise<string[]> {
150+
const knex = (driver as unknown as {
151+
knex: (t: string) => { select: (...c: string[]) => Promise<Array<Record<string, unknown>>> };
152+
}).knex;
153+
const rows = await knex(object).select('id', 'name', column);
154+
return rows.map((r) => `${String(r.id)}:${String(r.name)}:${String(r[column])}`).sort();
155+
}
156+
157+
it('control: a declared key round-trips through insert, update and read', async () => {
158+
await engine.insert(QUOTE, { id: 'q1', name: 'Q', [KEY]: 10 }, SYS);
159+
expect((await engine.findOne(QUOTE, { where: { id: 'q1' } }, SYS))?.[KEY]).toBe(10);
160+
161+
await engine.update(QUOTE, { id: 'q1', [KEY]: 12 }, SYS);
162+
expect((await engine.findOne(QUOTE, { where: { id: 'q1' } }, SYS))?.[KEY]).toBe(12);
163+
expect(await stored(QUOTE, KEY)).toEqual(['q1:Q:12']);
164+
});
165+
166+
it('control: the same hook stamping the same key on an object that DECLARES it is stored', async () => {
167+
armStampHook(engine, QUOTE, QUOTE_HOOK_WRITES);
168+
169+
await engine.insert(QUOTE, { id: 'q1', name: 'Q' }, SYS);
170+
171+
expect(await stored(QUOTE, KEY)).toEqual(['q1:Q:7']);
172+
});
173+
174+
it(`caller path: an insert carrying an undeclared key is ${CALLER_PATH.code} / ${CALLER_PATH.status}, and nothing is stored`, async () => {
175+
const err = await refusalOf(() => engine.insert(LINE, { id: 'l1', name: 'L', unit_price: 1, [KEY]: 5 }, SYS));
176+
177+
expect(envelope(err)).toEqual({ ...CALLER_PATH, field: KEY, object: LINE });
178+
expect(await stored(LINE, 'unit_price')).toEqual([]);
179+
});
180+
181+
it(`caller path: an update carrying an undeclared key is ${CALLER_PATH.code} / ${CALLER_PATH.status}, and the row is unchanged`, async () => {
182+
await engine.insert(LINE, { id: 'l1', name: 'L', unit_price: 100 }, SYS);
183+
184+
const err = await refusalOf(() => engine.update(LINE, { id: 'l1', unit_price: 200, [KEY]: 5 }, SYS));
185+
186+
expect(envelope(err)).toEqual({ ...CALLER_PATH, field: KEY, object: LINE });
187+
expect(await stored(LINE, 'unit_price')).toEqual(['l1:L:100']);
188+
});
189+
190+
it('hook path: a beforeInsert-written undeclared key is refused as the caller-path envelope, and nothing is stored', async () => {
191+
const callerAnswer = envelope(
192+
await refusalOf(() => engine.insert(LINE, { id: 'c1', name: 'C', unit_price: 1, [KEY]: 5 }, SYS)),
193+
);
194+
armStampHook(engine, LINE, LINE_HOOK_WRITES);
195+
196+
const err = await refusalOf(() => engine.insert(LINE, { id: 'l1', name: 'L', unit_price: 1 }, SYS));
197+
198+
expect(err).toMatchObject(CALLER_PATH);
199+
expect(envelope(err)).toEqual(callerAnswer);
200+
expect(await stored(LINE, 'unit_price')).toEqual([]);
201+
});
202+
203+
it('hook path: a beforeUpdate-written undeclared key is refused the same way, by id and by predicate, and no row changes', async () => {
204+
await engine.insert(LINE, { id: 'l1', name: 'L', unit_price: 100 }, SYS);
205+
await engine.insert(LINE, { id: 'l2', name: 'L', unit_price: 100 }, SYS);
206+
const callerAnswer = envelope(await refusalOf(() => engine.update(LINE, { id: 'l1', [KEY]: 5 }, SYS)));
207+
armStampHook(engine, LINE, LINE_HOOK_WRITES);
208+
209+
const byId = await refusalOf(() => engine.update(LINE, { id: 'l1', unit_price: 200 }, SYS));
210+
const byPredicate = await refusalOf(() =>
211+
engine.update(LINE, { unit_price: 300 }, { ...SYS, where: { name: 'L' }, multi: true }),
212+
);
213+
214+
expect(byId).toMatchObject(CALLER_PATH);
215+
expect(envelope(byId)).toEqual(callerAnswer);
216+
expect(byPredicate).toMatchObject(CALLER_PATH);
217+
expect(envelope(byPredicate)).toEqual(callerAnswer);
218+
expect(await stored(LINE, 'unit_price')).toEqual(['l1:L:100', 'l2:L:100']);
219+
});
220+
});

0 commit comments

Comments
 (0)