Skip to content

Commit 9801da1

Browse files
fix(objectql)!: enforce a progress field's declared min / max at the write seam (#20386) (#20482)
Fixes #20386 Clause-②: no (narrowing) A `progress` field's declared `min` / `max` are now **enforced at the objectql write seam**, per triage `5865053231` (ENFORCE, no decision card). A `progress` write outside a declared bound is refused with `400 VALIDATION_FAILED` and the `number` field's own field codes, `max_value` / `min_value`. `scale` and `precision` stay unread on `progress`. Measured head: **`af9e5101a`**. ## What changes - **`packages/objectql/src/validation/record-validator.ts`, the number arm.** The `if (t === 'progress') return null;` early return moves from above the `min` / `max` checks to directly below them, and above `scale` / `precision`. The #20308 docblock that deferred this now says why the bounds bind and why the return stays above `scale` / `precision`: each of those keys' own `.describe()` names a type set `progress` is not in. - **The file header's `min` / `max` line** now lists `progress`. It named the five types that were enforced, so leaving it would have made it false. This is one line outside "the number arm and the #20308 docblock" (declared below as a deviation). It is line 31, far from the date line PR #20469 edits (line 55 on `main`). - **Tests.** `record-validator.blank-typed-value.test.ts` pinned the old boundary (`progress` `max: 100` accepting `150.5`). It now pins what stays true: `summary` reads no bound or `scale`, and `progress` reads no `scale`. One test name in `record-validator.precision.test.ts` said `progress`'s "bounds the numeric branch never reads", and it is reworded. Its assertion is unchanged. - **`.changeset/20386-progress-min-max-enforced.md`**: `@objectstack/objectql` `minor`, **BREAKING** banner, the `Clause-②` line, a before → after line and the ADR-0087 disposition (below). ## Measured premises (dispatch zone 2) - **H1: `progress` bounds are skipped on `origin/main`. Held.** Reproduced on `dc0ab6a2e` through the real `RestServer` `POST /api/v1/data/:object` handler, over a real `ObjectQL` engine on the SQLite `SqlDriver` and on the memory driver. This was a scratch harness, not committed, because `check:driver-memory-census` refuses a new driver-memory consumer. | field | value | SQLite before | memory before | both after (`5f5bc7580`) | |:--|:--|:--|:--|:--| | `progress`, `max: 100` | `150` | 201, stored `150` (real) | 201, stored `150` | 400 `VALIDATION_FAILED` / `max_value` `{ max: 100 }`, no row | | `progress`, `min: 0` | `-5` | 201, stored `-5` (real) | 201, stored `-5` | 400 `VALIDATION_FAILED` / `min_value` `{ min: 0 }`, no row | | `number`, `max: 100` (control) | `150` | 400 / `max_value`, no row | the same | unchanged | | `number`, `min: 0` (control) | `-5` | 400 / `min_value`, no row | the same | unchanged | | `progress` in bounds / on each bound | `50`, `0`, `100` | 201 | 201 | 201, stored unchanged | - **H2: `scale` / `precision` after the move. Held, with a measured boundary.** With the return placed below the bounds, `scale` and `precision` are still not enforced on `progress`: `33.5` under `scale: 0` gets 201, and `99.5` under `precision: 2` gets 201, on SQLite and memory. Deleting the return outright would start both. Measured by ablation (below): four pins turn red with `max_scale` / `max_precision`. So the placement is load-bearing, and it is pinned from both sides. - **H3: producers. Zero writes outside a declared bound.** - objectui `SliderField`, the `progress` editor (`FieldEditWidget.tsx:87` `progress: SliderField`), is byte-identical at the pinned `.objectui-sha` `dd3f7e1be3` and at objectui HEAD `b8e0941`. It passes `min = field.min ?? 0` and `max = field.max ?? 100` to `@radix-ui/react-slider` (`^1.4.7`). That component's `updateValues` does `clamp(snapToStep, [min, max])` before every `onValueChange` (read in the 1.4.7 tarball). So it cannot emit a value outside a declared bound. - Example apps, on `dc0ab6a2e`: 2 `progress` fields declare bounds, `showcase_task.progress` and the field zoo's `f_progress`, both `min: 0, max: 100`. Their writers are 12 seed rows, the `showcase_mark_done` action (`progress: 100`) and the dogfood field-zoo matrix (`60`). All of them are in bounds. ## Pins - `packages/objectql/src/validation/record-validator.progress-bounds.test.ts` (new, 14 tests): - the triage pins (`150` gets `max_value` `{ max: 100 }`, `-5` gets `min_value` `{ min: 0 }`, and in-bounds values plus both inclusive bounds are accepted); - envelope equality with the `number` refusal; - one bound declared alone, and no invented 0..100 bound when none is declared; - update mode, string-carried values, and an omitted field that is never re-read; - ⛔ `scale` / `precision` unread on `progress`, while the same declaration on `slider` refuses; - every engine write door through a stub driver: insert of one row and of an array, `insertMany` partial success, update by id and by predicate, the `validate` dry run, and a control showing that in-bounds values arrive as the same number. - `packages/rest/src/rest-data-progress-bounds.test.ts` (new, 6 tests), on the real `RestServer` routes over SQLite, reading the physical column past every read coercion: - POST, batch create, PATCH, batch update and updateMany refuse `150` / `-5` with the `number` field's envelope and write or change nothing; - controls: in-bounds values and both bounds are stored unchanged, and `33.5` writes under `scale: 0, precision: 2`. ## Verification Heavy runs went through `scripts/pm/os-verify-lock.sh`. All readings are at `af9e5101a` unless stated otherwise. - **Build.** `pnpm turbo run build --filter='@objectstack/rest...' --concurrency=2`: 25/25, VERDICT command-exit 0. It was re-run after each merge of `main` (last at `5c4148234`). The objectql source has not changed since. - **objectql.** - Validator and door suites (`progress-bounds`, `blank-typed-value`, `precision`, `number-value`, `record-validator`, `engine-number-value-door`, `engine-blank-typed-value-door`): 7 files, 333/333. - Full `--project local`: 328 files, 6081/6081 (at `6a029a923`, before the second merge, which brought only spec and driver-sql commits). - `typecheck` (tsc, scripts, and `check:test-typecheck`, whose `tsconfig.test.json` includes `src/**/*`): exit 0. - **rest.** `rest-data-progress-bounds`, `rest-data-number-value`, `rest-data-blank-typed-value` and `import-integration`: 4 files, 100/100. `typecheck` including `check:test-typecheck`: exit 0. - **Reverse verification.** - **REST pin, with a build.** Against the `dist/` built from base `dc0ab6a2e`, the REST pin read **5 failed / 1 passed**. Each failure was `expected 201 to be 400` or a batch row reporting success. The one green is the in-bounds control. The `dist/index.js` number arm was read directly before and after: the return sat above the bounds, then below `max`. After `pnpm --filter @objectstack/objectql build` at `5f5bc7580` the pin reads 6/6. - **Ablation 1, fix committed first (`3b7b55406`).** `scripts/ablation-replace.mjs` re-planted `if (t === 'progress') return null;` above the bounds: anchor 1 → 0, blob `9ede5b5b` → `d396c53a`. Result: the new objectql file reads **10 failed / 4 passed**. The 4 greens are the controls: in-bounds values, `scale` unread, `precision` unread, and the engine in-bounds control. Restored: blob `9ede5b5b` equals HEAD, and `git diff HEAD` is empty. The subject resolves through a relative import to `src/`, so no rebuild was involved. Direction: red. - **Ablation 2, the H2 reading.** The same tool deleted the return: blob `9ede5b5b` → `d683b83e`. **4 failed**: the two `progress-bounds` pins for `scale` / `precision`, the `blank-typed-value` `scale` pin, and the `precision` test that excludes `progress`. Restored the same way. Direction: red. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `af9e5101a` derived 64 commands. **62 exit 0**. 2 are **NOT MEASURED** (exit 3, PREREQUISITE NOT MET): `check:dual-build-cjs-loads` and `check:type-check-debt` both need a whole-repo build. `--ran` reconciliation: 64 derived, 62 run, 2 NOT-MEASURED (derived from exit 3), 0 UNRUN, exit 0. - First pass at `5c4148234`: `check:error-code-casing` exit 1 on four bare `{ code: 'max_value' }` style assertions. They are now field-addressed (`af9e5101a`), and the gate reads 0. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` on the 5 changed `.ts` files: 5 files, 0 errors, 0 warnings. - Population: `--print-config` applies the config's rules to each file (6 rules on the validator, 5 on the REST test). - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. - The repo-wide `pnpm lint` is declared to CI. - **Not run locally, declared to CI:** the rest of the rest suite, runtime and dogfood, and the 6 path-matched families that take a value from the workflow. ## ADR-0087 disposition: which precedent, and why `not-required (no-migration-prescription)`, following **PR #20423**, not #7501: - #20423 is the closer precedent: the same arm, the same kind of change (a declared numeric bound starting to bind at the write seam, a narrowing of the write accept set with no authored key moving), and a gate-era marker. - #7501's changeset (`number-scale-enforced-by-rejection.md`, `951476719`) declared no BREAKING banner, so `check:adr-0087-registration` never asked it for a marker. It carries none, and there is nothing to copy. One conflict with the dispatch order's wording is worth stating. The seat's dispatch order asks for "a FROM → TO line" (claim 5873443045 itself names only `.changeset/20386-*.md`; seat edit after review 5875022310). Measured with the gate's own exported `findMigrationPrescription`: a line opening with the literal `FROM → TO` label is read as a **migration prescription** (branch `from-to-label`), and that refuses `no-migration-prescription`. The only category left would then be `registered`, which would need a new ledger entry in `packages/spec`. That is out of scope for this card and wrong on the facts, since nothing authorable moves. So the changeset carries the mapping as **"What a caller sees, before → after"** (`201`, stored as sent → `400 VALIDATION_FAILED` + `max_value` / `min_value`, nothing stored), plus the one-line fix. The detector reads that as no prescription, and `check:adr-0087-registration` passes. ## Acceptance notes - **`scale` / `precision` declared on a `progress` field parse, and nothing reads them at the write seam.** - Their `.describe()` texts name the types they bind on, and `precision`'s says "Not read on any other field type". - The metadata designer offers neither on `progress`: `ObjectFieldInspector` `isNumeric` covers only `number`, `currency` and `percent`. - No example declares them. Noted, not filed. - **objectui `SliderField`'s undeclared-bound defaults** (`min ?? 0`, `max ?? 100`) are narrower than the server, which enforces nothing undeclared. - There is one degenerate shape, not measured in a browser: a `progress` field declaring `min` above 100 and no `max`. The slider then clamps into `[min, 100]`, so it would emit `100`, which is now refused. - No field declares that shape. Noted, not filed. Holder: none. - **`docs/qa/platform-checklist/areas/records-forms.json`**, item `records-forms.field-type-constraints`: its steps do not reach `progress` bounds (triage said so). This belongs to the next checklist-author sweep. Holder: none. - **PR #20469** (#20264) edits the header's date line and the date / datetime arm of the same file. The hunks are far apart and there is no textual overlap. The later lander merges `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e967cbd commit 9801da1

6 files changed

Lines changed: 455 additions & 16 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a `progress` field's declared `min` / `max` are enforced on writes — a value outside them is refused with `min_value` / `max_value`, exactly as on `number` (#20386)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — a narrowing of the write accept set on `@objectstack/objectql`, shipped as `minor` under the repo's launch-window convention (`check-changeset-no-major` refuses `major` until GA); the breaking-ness is carried by this banner and the ADR-0087 disposition, never by the level. Nothing an author writes changes spelling: `min` and `max` keep their keys, their type and their legality on every field type.
10+
11+
`FieldSchema.min` / `max` declare a check ("Checked on the WRITTEN value only") with no type exclusion, but the record validator returned for a `progress` field right after its finite-number check, above the bounds. So a `progress` field declaring `max: 100` stored `150`, and one declaring `min: 0` stored `-5`, with `201` on memory and SQLite, while a `number` field with the same bounds refused both. The bounds now bind on `progress` at the one place a write is judged.
12+
13+
**What a caller sees, before → after.** A `progress` write outside a declared bound: `201`, stored as sent → `400 VALIDATION_FAILED` with field code `max_value` (`constraint: { max }`) or `min_value` (`constraint: { min }`), nothing stored. That is the `number` field's answer, envelope for envelope, in all four locales. The REST create, batch, update and updateMany routes all answer it, and `validate` (the dry run) predicts it. A value inside the bounds, or on either bound (both are inclusive), writes exactly as before. Only a write that CARRIES the field is judged: a stored value outside a bound is never re-read and survives an update that does not send it.
14+
15+
The fix, when a write is refused: send a value inside the bounds, or widen or delete the field's `min` / `max` to match what it really holds.
16+
17+
⛔ Only the bounds. `scale` and `precision` stay unread on `progress`: each key's own contract names the types it binds on, and `progress` is in neither set, so `33.5` still writes into a `progress` field that declares `scale: 0`.
18+
19+
**Who is affected, measured** on `origin/main` `dc0ab6a2e`: the two example-app `progress` fields (`examples/app-showcase` `showcase_task.progress` and the field zoo's `f_progress`) both declare `min: 0, max: 100`, and every value their seeds and actions write (12 seed rows, one `progress: 100` action) is inside. The console's `progress` editor, objectui's `SliderField`, drives a Radix slider bounded by the field's declared `min` / `max`, so it cannot emit a value outside them.
20+
21+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: `min` and `max` keep their keys, their type (`z.number().optional()`) and their legality on every field type, `packages/spec` is untouched, and no stored metadata representation changes, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What narrows is the record validator's write accept set for values under bounds the field already declares, which is runtime behaviour, not an authored shape. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id is minted here and none covers this (not `registered` / `already-registered`); and runtime behaviour changes, not only a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎packages/objectql/src/validation/record-validator.blank-typed-value.test.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,14 @@ describe('the numeric type door is NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPE
152152
expect(refusal(type, 'abc')).toBeNull();
153153
});
154154

155-
it('progress takes the type check only, and summary none — no bound or scale is newly enforced', () => {
156-
// The boundary the branch states: a declared `max` / `scale` on these two
157-
// was never enforced, and this change does not start (a separate decision).
158-
for (const type of ['progress', 'summary']) {
159-
const fields = { [`f_${type}`]: { name: `f_${type}`, type, max: 100, scale: 0 } } as any;
160-
expect(() => validateRecord({ fields }, { [`f_${type}`]: 150.5 }, 'insert'), type).not.toThrow();
161-
}
155+
it('summary takes no check at all, and progress no `scale` — a declared `max` / `scale` on summary is not read', () => {
156+
// The boundary the branch states for `summary`: its shape is the producer's,
157+
// so a declared `max` / `scale` is never enforced on it.
158+
const summary = { f_summary: { name: 'f_summary', type: 'summary', max: 100, scale: 0 } } as any;
159+
expect(() => validateRecord({ fields: summary }, { f_summary: 150.5 }, 'insert')).not.toThrow();
160+
// `progress` took the type check here, and its declared `min` / `max` since
161+
// #20386 (record-validator.progress-bounds.test.ts) — but still no `scale`.
162+
const progress = { f_progress: { name: 'f_progress', type: 'progress', max: 100, scale: 0 } } as any;
163+
expect(() => validateRecord({ fields: progress }, { f_progress: 50.5 }, 'insert')).not.toThrow();
162164
});
163165
});

‎packages/objectql/src/validation/record-validator.precision.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ describe('validateRecord — `precision` on `currency` and `percent` (#19992)',
187187
});
188188

189189
describe('validateRecord — where `precision` binds, and where it does not (#19992)', () => {
190-
it('binds on number, currency, percent, rating and slider; ⛔ not on progress, whose bounds the numeric branch never reads', () => {
190+
it('binds on number, currency, percent, rating and slider; ⛔ not on progress, which takes only `min` / `max` (#20386)', () => {
191191
for (const type of ['number', 'currency', 'percent', 'rating', 'slider']) {
192192
const s = { fields: { v: { type, label: 'V', precision: 1, ...(type === 'percent' ? { max: 100 } : {}) } } };
193193
expect(fieldsOf(s, { v: 12 })?.[0], type).toMatchObject({ field: 'v', code: 'max_precision' });
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { validateRecord, ValidationError } from './record-validator.js';
5+
import { ObjectQL } from '../engine.js';
6+
7+
/**
8+
* #20386 — a `progress` field's declared `min` / `max` are ENFORCED at the
9+
* write seam, with the `number` field's codes (`min_value` / `max_value`).
10+
*
11+
* Before this, the number arm returned for `progress` right after the finite
12+
* check, above the bounds. Measured through the REST create route on
13+
* `origin/main` dc0ab6a2e: `150` into `max: 100` and `-5` into `min: 0` both
14+
* answered 201 and were stored, on memory and on SQLite, while a `number` field
15+
* with the same bounds refused both. `FieldSchema.min` / `max` declare the
16+
* check 「Checked on the WRITTEN value only」 with no type exclusion, and
17+
* triage 5865053231 ruled ENFORCE.
18+
*
19+
* ⛔ Only the bounds: `scale` and `precision` name their own type sets, and
20+
* `progress` is in neither, so both stay unread on it. The pins below hold
21+
* that line from both sides — the same declaration on `slider` refuses.
22+
*
23+
* The REST physical-column half (SQLite) is
24+
* `packages/rest/src/rest-data-progress-bounds.test.ts`. Memory and MongoDB
25+
* store exactly the payload the engine hands them, which the stub driver in the
26+
* last block records.
27+
*/
28+
29+
const fieldsOf = (
30+
schema: Parameters<typeof validateRecord>[0],
31+
data: Record<string, unknown>,
32+
mode: 'insert' | 'update' = 'insert',
33+
) => {
34+
try {
35+
validateRecord(schema, data, mode);
36+
} catch (e) {
37+
expect(e).toBeInstanceOf(ValidationError);
38+
expect((e as ValidationError).code).toBe('VALIDATION_FAILED');
39+
return (e as ValidationError).fields;
40+
}
41+
return null;
42+
};
43+
44+
const BOUNDED = (type: string, extra: Record<string, unknown> = {}) => ({
45+
fields: { v: { type, label: 'Done', min: 0, max: 100, ...extra } },
46+
});
47+
48+
describe('validateRecord — a `progress` field\'s `min` / `max` are enforced (#20386): the triage pins', () => {
49+
it('`progress` `max: 100` refuses 150 with `max_value`, the `number` field\'s code', () => {
50+
const errs = fieldsOf(BOUNDED('progress'), { v: 150 });
51+
expect(errs).toHaveLength(1);
52+
expect(errs![0]).toMatchObject({ field: 'v', code: 'max_value', constraint: { max: 100 } });
53+
// The template is interpolated: the bound reaches the sentence.
54+
expect(errs![0].message).toContain('100');
55+
expect(errs![0].message).not.toContain('{{');
56+
});
57+
58+
it('`progress` `min: 0` refuses -5 with `min_value`, the `number` field\'s code', () => {
59+
const errs = fieldsOf(BOUNDED('progress'), { v: -5 });
60+
expect(errs).toHaveLength(1);
61+
expect(errs![0]).toMatchObject({ field: 'v', code: 'min_value', constraint: { min: 0 } });
62+
});
63+
64+
it('a value inside the bounds is accepted, and both bounds are inclusive', () => {
65+
for (const v of [50, 0, 100, 33.5]) {
66+
expect(fieldsOf(BOUNDED('progress'), { v }), String(v)).toBeNull();
67+
}
68+
});
69+
70+
it('the refusal is the `number` field\'s refusal, envelope for envelope', () => {
71+
for (const v of [150, -5]) {
72+
const strip = (errs: ReturnType<typeof fieldsOf>) => errs?.map(({ field, code, constraint }) => ({ field, code, constraint }));
73+
expect(strip(fieldsOf(BOUNDED('progress'), { v })), String(v)).toEqual(strip(fieldsOf(BOUNDED('number'), { v })));
74+
}
75+
});
76+
77+
it('one bound declared alone binds alone', () => {
78+
const maxOnly = { fields: { v: { type: 'progress', label: 'Done', max: 100 } } };
79+
expect(fieldsOf(maxOnly, { v: 101 })?.[0]).toMatchObject({ field: 'v', code: 'max_value' });
80+
expect(fieldsOf(maxOnly, { v: -1000 })).toBeNull();
81+
const minOnly = { fields: { v: { type: 'progress', label: 'Done', min: 0 } } };
82+
expect(fieldsOf(minOnly, { v: -0.5 })?.[0]).toMatchObject({ field: 'v', code: 'min_value' });
83+
expect(fieldsOf(minOnly, { v: 1e6 })).toBeNull();
84+
// No bound declared: nothing is invented for the type (no implicit 0..100).
85+
const unbounded = { fields: { v: { type: 'progress', label: 'Done' } } };
86+
expect(fieldsOf(unbounded, { v: 150 })).toBeNull();
87+
expect(fieldsOf(unbounded, { v: -5 })).toBeNull();
88+
});
89+
90+
it('refuses on update too, judges a string-carried number after coercion, and never re-reads an omitted field', () => {
91+
expect(fieldsOf(BOUNDED('progress'), { v: 150 }, 'update')?.[0]).toMatchObject({ field: 'v', code: 'max_value' });
92+
expect(fieldsOf(BOUNDED('progress'), { v: '150' })?.[0]).toMatchObject({ field: 'v', code: 'max_value' });
93+
expect(fieldsOf(BOUNDED('progress'), { v: '-5' })?.[0]).toMatchObject({ field: 'v', code: 'min_value' });
94+
expect(fieldsOf(BOUNDED('progress'), { v: '50' })).toBeNull();
95+
// The WRITTEN value only: an update that does not carry the field is not judged.
96+
expect(fieldsOf(BOUNDED('progress'), { other: 1 }, 'update')).toBeNull();
97+
});
98+
});
99+
100+
describe('validateRecord — ⛔ `progress` takes the bounds only, never `scale` or `precision` (#20386)', () => {
101+
it('`scale: 0` is not read on `progress` — the same declaration on `slider` refuses', () => {
102+
expect(fieldsOf(BOUNDED('progress', { scale: 0 }), { v: 33.5 })).toBeNull();
103+
expect(fieldsOf(BOUNDED('slider', { scale: 0 }), { v: 33.5 })?.[0]).toMatchObject({ field: 'v', code: 'max_scale' });
104+
});
105+
106+
it('`precision: 2` is not read on `progress` — the same declaration on `slider` refuses', () => {
107+
expect(fieldsOf(BOUNDED('progress', { precision: 2 }), { v: 99.5 })).toBeNull();
108+
expect(fieldsOf(BOUNDED('slider', { precision: 2 }), { v: 99.5 })?.[0]).toMatchObject({ field: 'v', code: 'max_precision' });
109+
});
110+
111+
it('a bound still answers first when `scale` / `precision` are declared beside it', () => {
112+
expect(fieldsOf(BOUNDED('progress', { scale: 0, precision: 2 }), { v: 150.5 })?.[0]).toMatchObject({ field: 'v', code: 'max_value' });
113+
});
114+
});
115+
116+
// ---------------------------------------------------------------------------
117+
// Every engine write door reaches the refusal, the bulk doors included (AGENTS.md
118+
// Prime Directive #10: "check every call site, bulk paths included"). The stub
119+
// driver records what it is handed, so a refused write is shown to reach
120+
// nothing, and an accepted one to arrive as the same number.
121+
// ---------------------------------------------------------------------------
122+
123+
function makeStubDriver() {
124+
const calls: Array<{ fn: string; data: unknown }> = [];
125+
const rows = new Map<string, Record<string, unknown>>();
126+
let n = 0;
127+
const driver: any = {
128+
name: 'stub', version: '0.0.0', supports: {},
129+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
130+
async find() { return [...rows.values()]; },
131+
async findOne(_o: string, q: any) {
132+
const id = (q?.where ?? q?.filter ?? q)?.id;
133+
return (typeof id === 'string' ? rows.get(id) : rows.values().next().value) ?? null;
134+
},
135+
async count() { return rows.size; },
136+
async create(_o: string, data: Record<string, unknown>) {
137+
calls.push({ fn: 'create', data: { ...data } });
138+
const row = { ...data, id: (data.id as string) ?? `r${++n}` };
139+
rows.set(row.id as string, row);
140+
return row;
141+
},
142+
async bulkCreate(_o: string, list: Record<string, unknown>[]) {
143+
calls.push({ fn: 'bulkCreate', data: list.map((r) => ({ ...r })) });
144+
return list.map((r) => {
145+
const row = { ...r, id: (r.id as string) ?? `r${++n}` };
146+
rows.set(row.id as string, row);
147+
return row;
148+
});
149+
},
150+
async update(_o: string, id: string, data: Record<string, unknown>) {
151+
calls.push({ fn: 'update', data: { ...data } });
152+
const row = { ...(rows.get(id) ?? {}), ...data, id };
153+
rows.set(id, row);
154+
return row;
155+
},
156+
async updateMany(_o: string, _ast: unknown, data: Record<string, unknown>) {
157+
calls.push({ fn: 'updateMany', data: { ...data } });
158+
return rows.size;
159+
},
160+
async upsert(o: string, data: Record<string, unknown>) { return this.create(o, data); },
161+
async delete() { return true; },
162+
async bulkUpdate() { return []; }, async bulkDelete() {},
163+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
164+
async commit() {}, async rollback() {},
165+
};
166+
return { driver, calls };
167+
}
168+
169+
const TASK = {
170+
name: 'progress_task',
171+
label: 'Task',
172+
fields: {
173+
id: { name: 'id', type: 'text' as const, primaryKey: true },
174+
done: { name: 'done', type: 'progress' as const, label: 'Done', min: 0, max: 100 },
175+
},
176+
};
177+
178+
describe('engine write doors — a `progress` bound is refused on every door, bulk included (#20386)', () => {
179+
let engine: ObjectQL;
180+
let stub: ReturnType<typeof makeStubDriver>;
181+
182+
beforeEach(async () => {
183+
stub = makeStubDriver();
184+
engine = new ObjectQL();
185+
engine.registerDriver(stub.driver, true);
186+
await engine.init();
187+
engine.registry.registerObject(TASK as any);
188+
});
189+
190+
const refusal = async (fn: () => Promise<unknown>) => {
191+
try {
192+
await fn();
193+
} catch (e) {
194+
expect(e).toBeInstanceOf(ValidationError);
195+
return { code: (e as ValidationError).code, fields: (e as ValidationError).fields.map((f) => [f.field, f.code]) };
196+
}
197+
return null;
198+
};
199+
const OVER = { code: 'VALIDATION_FAILED', fields: [['done', 'max_value']] };
200+
const UNDER = { code: 'VALIDATION_FAILED', fields: [['done', 'min_value']] };
201+
const writes = () => stub.calls.filter((c) => c.fn !== 'find');
202+
/** Every value any driver write call carried for `done`. */
203+
const written = () =>
204+
writes()
205+
.flatMap((c) => (Array.isArray(c.data) ? c.data : [c.data]) as Record<string, unknown>[])
206+
.filter((r) => 'done' in r)
207+
.map((r) => r.done);
208+
209+
it('insert of one row, and of an array of rows — the whole batch is refused and nothing reaches the driver', async () => {
210+
expect(await refusal(() => engine.insert('progress_task', { id: 'a', done: 150 }))).toEqual(OVER);
211+
expect(await refusal(() => engine.insert('progress_task', { id: 'b', done: -5 }))).toEqual(UNDER);
212+
expect(
213+
await refusal(() => engine.insert('progress_task', [{ id: 'c1', done: 50 }, { id: 'c2', done: 150 }])),
214+
).toEqual(OVER);
215+
expect(writes()).toEqual([]);
216+
});
217+
218+
it('insertMany (partial success): the out-of-bound row fails alone, the fitting row is written', async () => {
219+
const outcomes = await engine.insertMany('progress_task', [{ id: 'm1', done: 50 }, { id: 'm2', done: 150 }]);
220+
expect(outcomes.map((o) => o.ok)).toEqual([true, false]);
221+
const failed = outcomes[1] as { ok: false; error: unknown };
222+
expect(failed.error).toBeInstanceOf(ValidationError);
223+
expect((failed.error as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['done', 'max_value']]);
224+
expect(written()).toEqual([50]);
225+
});
226+
227+
it('update by id and update by predicate (multi) — refused before the driver', async () => {
228+
await engine.insert('progress_task', { id: 'u1', done: 10 });
229+
stub.calls.length = 0;
230+
expect(await refusal(() => engine.update('progress_task', { id: 'u1', done: 150 }))).toEqual(OVER);
231+
expect(
232+
await refusal(() => engine.update('progress_task', { done: -5 }, { where: { id: { $in: ['u1'] } }, multi: true } as any)),
233+
).toEqual(UNDER);
234+
expect(writes().filter((c) => c.fn === 'update' || c.fn === 'updateMany')).toEqual([]);
235+
});
236+
237+
it('the dry run (`validate`) predicts the same refusal', async () => {
238+
const refused = await engine.validate('progress_task', { id: 'p1', done: 150 });
239+
expect(refused.valid).toBe(false);
240+
expect(refused.results?.[0]?.errors.map((e: any) => [e.field, e.code])).toEqual([['done', 'max_value']]);
241+
expect((await engine.validate('progress_task', { id: 'p2', done: 100 })).valid).toBe(true);
242+
});
243+
244+
it('CONTROL — a value inside the bounds reaches the driver as the same number on every door', async () => {
245+
await engine.insert('progress_task', { id: 'k1', done: 100 });
246+
await engine.insert('progress_task', [{ id: 'k2', done: 0 }]);
247+
await engine.update('progress_task', { id: 'k1', done: 45.5 });
248+
await engine.update('progress_task', { done: 60 }, { where: { id: { $in: ['k1'] } }, multi: true } as any);
249+
const got = written();
250+
expect(got).toEqual([100, 0, 45.5, 60]);
251+
});
252+
});

0 commit comments

Comments
 (0)