Skip to content

Commit f874bb5

Browse files
committed
test(rest): a plain readable column reaches the update preview's rules as its stored value
Also states the served-as-stored column rule in the changeset. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f4ca15 commit f874bb5

3 files changed

Lines changed: 100 additions & 2 deletions

File tree

‎.changeset/22445-update-preview-stored-row.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The preview's accept set widens: rows it refused, and the write admits, are now
1212

1313
- **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists.
1414
- **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol.
15-
- **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it), kept to the columns the read door returned: a column the caller may not read is judged as empty. A preview discloses nothing about a row or column the caller could not read.
15+
- **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value.
1616
- **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update.
1717
- **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence.
1818

‎packages/objectql/src/validate-update-stored-row.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ describe('#22445 — an update-mode preview judges the stored row merged with th
356356
for (const country of ['cn', 'us']) {
357357
rows.set('c1', { id: 'c1', name: 'one', country });
358358
// Precondition: the read door serves the key, masked.
359-
expect(await engine.findOne(OBJECT, { where: { id: 'c1' }, context: { userId: 'erin' } as any })).toMatchObject({ country: '**' });
359+
expect(await engine.findOne(OBJECT, { where: { id: 'c1' }, context: { userId: 'erin' } })).toMatchObject({ country: '**' });
360360
verdicts.push(await verdictOf(engine, { province: 'zj', id: 'c1' }, { userId: 'erin' }));
361361
}
362362
expect(verdicts[0]).toEqual(verdicts[1]);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #22445 (d4) — the `update`-mode preview keeps a stored column only where the
4+
// read door served the caller that very value, so a column the read door
5+
// served TRANSFORMED (a partial mask) is judged as empty. This file is the
6+
// representation control for that comparison, on a real driver (driver-sql over
7+
// SQLite): a plain readable column of every common type must reach the rules
8+
// as its stored value. If the read door and the prior-row read represented one
9+
// of them differently, the comparison would judge a readable column empty and
10+
// the preview would answer on less than the write knows.
11+
//
12+
// Each `n_<column>` is required while its column holds the stored value. The
13+
// stored row satisfies every rule; the patch clears every `n_<column>`, so a
14+
// rule that judges the STORED value refuses with `required` (a compliant row
15+
// made violating), and a rule that judged the column empty would answer
16+
// otherwise (a fault, or an admission). The by-id update is the reference.
17+
18+
import { describe, it, expect, afterEach } from 'vitest';
19+
import { ObjectQL } from '@objectstack/objectql';
20+
import { SqlDriver } from '@objectstack/driver-sql';
21+
22+
const OBJECT = 'pv_repr';
23+
24+
/** column → [field definition, the predicate its `n_<column>` is required under, stored value]. */
25+
const COLUMNS: Record<string, [Record<string, unknown>, string, unknown]> = {
26+
amount: [{ type: 'number' }, 'record.amount > 10', 50],
27+
active: [{ type: 'boolean' }, 'record.active == true', true],
28+
due_on: [{ type: 'date' }, 'record.due_on != null', '2026-01-15'],
29+
happened_at: [{ type: 'datetime' }, 'record.happened_at != null', '2026-01-15T08:30:00.000Z'],
30+
meta: [{ type: 'json' }, 'record.meta != null', { a: 1, b: [1, 2] }],
31+
tags: [{ type: 'multiselect', options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }] }, 'record.tags != null', ['a', 'b']],
32+
tier: [{ type: 'select', options: [{ label: 'Gold', value: 'gold' }, { label: 'Basic', value: 'basic' }] }, "record.tier == 'gold'", 'gold'],
33+
code: [{ type: 'text' }, "record.code == 'X1'", 'X1'],
34+
};
35+
36+
const fields: Record<string, Record<string, unknown>> = { title: { name: 'title', label: 'Title', type: 'text' } };
37+
for (const [column, [def, predicate]] of Object.entries(COLUMNS)) {
38+
fields[column] = { name: column, label: column, ...def };
39+
fields[`n_${column}`] = { name: `n_${column}`, label: `n_${column}`, type: 'text', requiredWhen: predicate };
40+
}
41+
42+
const liveEngines: ObjectQL[] = [];
43+
afterEach(async () => {
44+
while (liveEngines.length) {
45+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
46+
}
47+
});
48+
49+
async function boot() {
50+
const engine = new ObjectQL();
51+
liveEngines.push(engine);
52+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
53+
await engine.init();
54+
engine.registry.registerObject({ name: OBJECT, label: 'Preview Representation', fields } as any);
55+
await engine.syncSchemas();
56+
const stored: Record<string, unknown> = { title: 'stored' };
57+
for (const [column, [, , value]] of Object.entries(COLUMNS)) {
58+
stored[column] = value;
59+
stored[`n_${column}`] = 'kept';
60+
}
61+
const created = await engine.insert(OBJECT, stored) as Record<string, unknown>;
62+
return { engine, id: created.id as string };
63+
}
64+
65+
const clearAll = () => Object.fromEntries(Object.keys(COLUMNS).map((c) => [`n_${c}`, null]));
66+
const findings = (errors: Array<{ field: string; code: string }>) =>
67+
errors.map((e) => ({ field: e.field, code: e.code })).sort((a, b) => a.field.localeCompare(b.field));
68+
69+
describe('#22445 (d4) a plain readable column reaches the preview\'s rules as its stored value', () => {
70+
it('every column type judges the stored value, as the by-id update does', async () => {
71+
const { engine, id } = await boot();
72+
const expected = Object.keys(COLUMNS).map((c) => ({ field: `n_${c}`, code: 'required' }))
73+
.sort((a, b) => a.field.localeCompare(b.field));
74+
75+
const preview = await engine.validate(OBJECT, { ...clearAll(), id }, { mode: 'update', context: { userId: 'reader' } });
76+
expect(findings(preview.results![0]!.errors)).toEqual(expected);
77+
78+
let writeErrors: Array<{ field: string; code: string }> = [];
79+
try {
80+
await engine.update(OBJECT, { ...clearAll(), id }, { context: { userId: 'reader' } });
81+
} catch (e: any) {
82+
expect(e?.code).toBe('VALIDATION_FAILED');
83+
writeErrors = e.fields;
84+
}
85+
expect(findings(writeErrors)).toEqual(expected);
86+
});
87+
88+
it('control: each rule admits once its own column is cleared in the same patch', async () => {
89+
const { engine, id } = await boot();
90+
const patch: Record<string, unknown> = { ...clearAll(), id };
91+
for (const column of Object.keys(COLUMNS)) patch[column] = null;
92+
const preview = await engine.validate(OBJECT, patch, { mode: 'update', context: { userId: 'reader' } });
93+
expect(findings(preview.results![0]!.errors)).toEqual(
94+
// `null > 10` has no overload: the number rule cannot judge a cleared column.
95+
[{ field: 'n_amount', code: 'rule_violation' }],
96+
);
97+
});
98+
});

0 commit comments

Comments
 (0)