Skip to content

Commit a97a93e

Browse files
committed
fix(objectql)!: refuse a strict write whose value a stored expression replaces
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
1 parent 688052e commit a97a93e

5 files changed

Lines changed: 191 additions & 85 deletions

File tree

‎packages/objectql/src/engine-stored-expression-dropped-fields.test.ts‎

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
// grid's consistent save stays silent), nor is a key the caller never sent;
2121
// - a caller value another strip already took is reported once, under that
2222
// strip's reason, never twice;
23-
// - `strictReadonlyWrites` does NOT refuse a replaced value in 18.x (the
24-
// maintainer's ruling 「甲」: only the loud half ships; the refusal waits
25-
// for 19.0) — the write completes with the computed value and the report
26-
// reaches the listener under strict too, while a strict write that ALSO
27-
// carries a refusable drop is still refused, and fires no listener.
23+
// - #25000: under `strictReadonlyWrites` a replaced value is REFUSED like
24+
// every other strip's drop (the amended ruling 「甲」 ships it in 18.x) —
25+
// on insert (single, batch, `insertMany`), update by id and predicate
26+
// update: `ERR_READONLY_FIELD_REJECTED`, nothing written, no listener —
27+
// in one refusal with any other strip's drop; the computed value sent, or
28+
// no value, is never refused; and the same payload WITHOUT strict still
29+
// completes and is reported (the controls beside each refusal).
2830

2931
import { describe, it, expect, beforeEach } from 'vitest';
3032
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
@@ -254,49 +256,129 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s
254256
});
255257
});
256258

257-
describe('strictReadonlyWrites — a replaced value is REPORTED, not refused, in 18.x (ruling 「甲」)', () => {
258-
it('update by id: the write completes with the computed value, and the report reaches the listener', async () => {
259+
describe('[#25000] strictReadonlyWrites — a replaced value is REFUSED, and nothing is written', () => {
260+
const STRICT = { strictReadonlyWrites: true } as const;
261+
async function refusal(write: Promise<unknown>): Promise<any> {
262+
const err: any = await write.then(() => undefined, (e: unknown) => e);
263+
expect(err).toBeInstanceOf(ReadonlyFieldRejectedError);
264+
expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED');
265+
return err;
266+
}
267+
const snapshot = () => [...storeFor(LINE).values()].map((r) => ({ ...r }));
268+
269+
it('update by id: refused, the row keeps its stored values, and no listener fires', async () => {
270+
const l = listener();
271+
const before = snapshot();
272+
const err = await refusal(engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any));
273+
expect(err.operation).toBe('update');
274+
expect(err.fields).toEqual(['amount']);
275+
expect(err.drops).toEqual([AMOUNT]);
276+
expect(snapshot()).toEqual(before);
277+
expect(l.events).toEqual([]);
278+
});
279+
280+
it('control, update by id: the same payload without strict completes with the computed value, and is reported', async () => {
259281
const l = listener();
260-
await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any);
282+
await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { onFieldsDropped: l.onFieldsDropped });
261283
expect(line('line_draft')).toMatchObject({ quantity: 4, amount: 40 });
262284
expect(l.events).toEqual([AMOUNT]);
263285
});
264286

265-
it('insert: the row is written with the computed value, and reported', async () => {
287+
it('update by id under strict: the computed value sent, or no value, is not refused', async () => {
288+
await engine.update(LINE, { id: 'line_draft', quantity: 5, unit_price: 20, amount: 100 }, STRICT as any);
289+
expect(line('line_draft').amount).toBe(100);
290+
await engine.update(LINE, { id: 'line_draft', quantity: 7 }, STRICT as any);
291+
expect(line('line_draft').amount).toBe(140);
292+
});
293+
294+
it('insert, single row: refused, nothing written', async () => {
295+
const l = listener();
296+
const err = await refusal(engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped }));
297+
expect(err.operation).toBe('insert');
298+
expect(err.drops).toEqual([AMOUNT]);
299+
expect(storeFor(LINE).size).toBe(3);
300+
expect(l.events).toEqual([]);
301+
});
302+
303+
it('control, insert: the same row without strict is written with the computed value, and reported', async () => {
266304
const l = listener();
267-
await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped });
305+
await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }, { onFieldsDropped: l.onFieldsDropped });
268306
const rows = [...storeFor(LINE).values()];
269307
expect(rows).toHaveLength(4);
270308
expect(rows.find((r) => r.amount === 20 && r.quantity === 2)).toBeDefined();
271309
expect(l.events).toEqual([AMOUNT]);
272310
});
273311

274-
it('predicate update: every matched row is written, and the replacement is reported', async () => {
312+
it('insert under strict: the computed value sent, or no value, is written', async () => {
313+
await engine.insert(LINE, { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 20 }, STRICT);
314+
await engine.insert(LINE, { invoice: 'INV-1004', quantity: 3, unit_price: 10 }, STRICT);
315+
expect(storeFor(LINE).size).toBe(5);
316+
});
317+
318+
it('insert, batch: one replaced row refuses the whole batch, and no row is written', async () => {
319+
const l = listener();
320+
const err = await refusal(engine.insert(LINE, [
321+
{ invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 },
322+
{ invoice: 'INV-1004', quantity: 5, unit_price: 3, amount: 1 },
323+
], { ...STRICT, onFieldsDropped: l.onFieldsDropped }));
324+
expect(err.drops).toEqual([AMOUNT]);
325+
expect(storeFor(LINE).size).toBe(3);
326+
expect(l.events).toEqual([]);
327+
});
328+
329+
it('insertMany: refused like the other strict drops, and no row is written', async () => {
275330
const l = listener();
276-
await engine.update(LINE, { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true, strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any);
331+
const err = await refusal(engine.insertMany(LINE, [
332+
{ invoice: 'INV-1004', quantity: 1, unit_price: 3, amount: 0 },
333+
{ invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 },
334+
], { ...STRICT, onFieldsDropped: l.onFieldsDropped }));
335+
expect(err.drops).toEqual([AMOUNT]);
336+
expect(storeFor(LINE).size).toBe(3);
337+
expect(l.events).toEqual([]);
338+
});
339+
340+
it('control, insertMany: without strict both rows are written and the replaced one carries its per-row report', async () => {
341+
const outcomes: any[] = await engine.insertMany(LINE, [
342+
{ invoice: 'INV-1004', quantity: 1, unit_price: 3, amount: 0 },
343+
{ invoice: 'INV-1004', quantity: 4, unit_price: 3, amount: 12 },
344+
]);
345+
expect(outcomes.map((o) => o.ok)).toEqual([true, true]);
346+
expect(outcomes[0].droppedFields).toEqual([AMOUNT]);
347+
expect(storeFor(LINE).size).toBe(5);
348+
});
349+
350+
it('predicate update: refused, and no matched row is written', async () => {
351+
const l = listener();
352+
const before = snapshot();
353+
const err = await refusal(engine.update(LINE, { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true, ...STRICT, onFieldsDropped: l.onFieldsDropped } as any));
354+
expect(err.drops).toEqual([AMOUNT]);
355+
expect(snapshot()).toEqual(before);
356+
expect(l.events).toEqual([]);
357+
});
358+
359+
it('control, predicate update: without strict every matched row is written, and the replacement is reported', async () => {
360+
const l = listener();
361+
await engine.update(LINE, { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true, onFieldsDropped: l.onFieldsDropped } as any);
277362
expect([line('line_draft').amount, line('line_draft_2').amount]).toEqual([10, 10]);
278363
expect(l.events).toEqual([AMOUNT]);
279364
});
280365

281-
it('a strict write that ALSO carries a refusable drop is refused for THAT drop alone, writes nothing, and fires no listener', async () => {
366+
it('a strict write that ALSO carries another strip\'s drop is refused ONCE, naming both, and writes nothing', async () => {
282367
const l = listener();
283-
const err: any = await engine
284-
.update(LINE, { id: 'line_paid', quantity: 60, amount: 2999.4 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any)
285-
.catch((e: unknown) => e);
286-
expect(err).toBeInstanceOf(ReadonlyFieldRejectedError);
287-
expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED');
288-
expect(err.drops).toEqual([{ object: LINE, fields: ['quantity'], reason: 'readonly_when' }]);
368+
const err = await refusal(engine.update(LINE, { id: 'line_paid', quantity: 60, amount: 2999.4 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any));
369+
expect(err.drops).toEqual([
370+
{ object: LINE, fields: ['quantity'], reason: 'readonly_when' },
371+
AMOUNT,
372+
]);
373+
expect(err.fields).toEqual(['quantity', 'amount']);
289374
expect(line('line_paid')).toMatchObject({ quantity: 6, amount: 299.94 });
290375
expect(l.events).toEqual([]);
291376
});
292377

293-
it('insert: a strict write with a refusable drop is refused before the computation, carrying no stored_expression drop', async () => {
378+
it('insert: a value the static `readonly` strip took is refused as `readonly` alone, never also as `stored_expression`', async () => {
294379
const l = listener();
295-
const err: any = await engine
296-
.insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped })
297-
.catch((e: unknown) => e);
298-
expect(err).toBeInstanceOf(ReadonlyFieldRejectedError);
299-
expect(err.drops.map((d: DroppedFieldsEvent) => d.reason)).not.toContain('stored_expression');
380+
const err = await refusal(engine.insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped }));
381+
expect(err.drops).toEqual([{ object: 'qa_locked_line', fields: ['amount'], reason: 'readonly' }]);
300382
expect([...storeFor('qa_locked_line').values()]).toHaveLength(1);
301383
expect(l.events).toEqual([]);
302384
});

0 commit comments

Comments
 (0)