Skip to content

Commit 55a4c8b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25079-federated-report-migrate-run
2 parents 5cf4a45 + 03c12a5 commit 55a4c8b

6 files changed

Lines changed: 285 additions & 96 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(objectql)!: under `strictReadonlyWrites`, a write whose value a stored field's `expression` replaces (`stored_expression`) is refused, and nothing is written
7+
8+
Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a stored expression replaces is refused instead of reported; it ships now under the amended 「甲」, #6015 `6303865025`)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored shape or exported declaration is added, removed, renamed or narrowed, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the engine gives an in-process caller that opts into `strictReadonlyWrites` and sends a value a stored expression then replaces: that write is refused where it completed and was reported. The remedy is the caller's own payload (leave the field out, or send the computed value), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (the strict mode's refusal set grows by one reason), shipped as `minor` under the amended ruling 「甲」: a narrowing that no conversion carries ships now, in 18.x. It applies only to a caller that opts into the in-process `strictReadonlyWrites` option. No REST or wire caller can set that option, and every write without it behaves exactly as before.
13+
14+
`strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed, one reported drop was left out: a caller's value for a stored (non-`formula`) field that declares an `expression`, which the engine replaces with the computed value. Under strict, such a write completed and was only reported. It is now refused like every other strip's drop.
15+
16+
- **FROM:** a write with `strictReadonlyWrites: true` that sent a value for a stored expression field, different from the value the engine computes, completed. The row held the computed value, and `onFieldsDropped` received a `stored_expression` event.
17+
- **TO:** the same write throws `ReadonlyFieldRejectedError` (`code: 'ERR_READONLY_FIELD_REJECTED'`), whose `drops` carries the `stored_expression` event. Nothing is written, and `onFieldsDropped` does not fire. This holds on insert (a single row, a batch, and `insertMany`, which refuses the whole call as it does for the other strict drops), on update by id, and on a predicate update.
18+
- **One refusal names everything.** When the same payload also carries another strip's drop (`readonly`, `readonly_when`, `primary_key`, `computed`), the one refusal names all of them together, `stored_expression` last. Before, such a write was refused for the other drops alone. The message's per-reason form gains a `stored_expression` remedy, spoken only when that reason is present, so every other refusal's message is unchanged.
19+
- **Ordering is kept.** The strips' refusal still comes first: if the stored-expression pass itself throws, for example a predicate update whose patch computes different values on different matched rows (`VALIDATION_FAILED`), a strict write that also carries a strip's drop is still refused with `ERR_READONLY_FIELD_REJECTED` naming those drops, as before.
20+
- **`@objectstack/spec` (patch):** the TSDoc on `WriteObservabilityOptions.strictReadonlyWrites` and `DroppedFieldsEvent.reason`, which ships in the published declarations, now says the `stored_expression` arm is refused under strict. No schema, `.describe()` text or export changes.
21+
- **Unchanged:** a strict write that sends the computed value, or no value for the field, is not refused, because nothing it sent was replaced. A stored expression field also declared `readonly` is still refused once, as `readonly`. Without `strictReadonlyWrites`, the write completes with the computed value and the replacement is reported, as before. The error code is unchanged.
22+
- **The fix for a strict caller:** leave the stored expression field out of the payload, or send the value its expression computes. To let the replacement happen and merely observe it, drop `strictReadonlyWrites` and pass `onFieldsDropped`.
23+
24+
This supersedes the `strictReadonlyWrites` bullet of the `stored_expression` reason's entry, which deferred this refusal to a later major.
25+
26+
Measured before the change: no non-test caller in this repository passes `strictReadonlyWrites` (the examples, dogfood suites and runtime doors pass none), so no in-repo producer moves.

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

Lines changed: 128 additions & 23 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';
@@ -128,6 +130,8 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s
128130
quantity: { type: 'number', readonlyWhen: "parent.status == 'paid'" },
129131
unit_price: { type: 'currency', readonlyWhen: "parent.status == 'paid'" },
130132
description: { type: 'text' },
133+
// A static lock, so a predicate update can carry a strip's drop.
134+
note: { type: 'text', readonly: true },
131135
amount: { type: 'currency', min: 0, expression: { dialect: 'cel', source: 'record.quantity * record.unit_price' } },
132136
},
133137
} as any);
@@ -254,49 +258,150 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s
254258
});
255259
});
256260

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 () => {
261+
describe('[#25000] strictReadonlyWrites — a replaced value is REFUSED, and nothing is written', () => {
262+
const STRICT = { strictReadonlyWrites: true } as const;
263+
async function refusal(write: Promise<unknown>): Promise<any> {
264+
const err: any = await write.then(() => undefined, (e: unknown) => e);
265+
expect(err).toBeInstanceOf(ReadonlyFieldRejectedError);
266+
expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED');
267+
return err;
268+
}
269+
const snapshot = () => [...storeFor(LINE).values()].map((r) => ({ ...r }));
270+
271+
it('update by id: refused, the row keeps its stored values, and no listener fires', async () => {
272+
const l = listener();
273+
const before = snapshot();
274+
const err = await refusal(engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any));
275+
expect(err.operation).toBe('update');
276+
expect(err.fields).toEqual(['amount']);
277+
expect(err.drops).toEqual([AMOUNT]);
278+
expect(snapshot()).toEqual(before);
279+
expect(l.events).toEqual([]);
280+
});
281+
282+
it('control, update by id: the same payload without strict completes with the computed value, and is reported', async () => {
259283
const l = listener();
260-
await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped } as any);
284+
await engine.update(LINE, { id: 'line_draft', quantity: 4, amount: 5 }, { onFieldsDropped: l.onFieldsDropped });
261285
expect(line('line_draft')).toMatchObject({ quantity: 4, amount: 40 });
262286
expect(l.events).toEqual([AMOUNT]);
263287
});
264288

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

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

281-
it('a strict write that ALSO carries a refusable drop is refused for THAT drop alone, writes nothing, and fires no listener', async () => {
368+
it('a strict write that ALSO carries another strip\'s drop is refused ONCE, naming both, and writes nothing', async () => {
282369
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' }]);
370+
const err = await refusal(engine.update(LINE, { id: 'line_paid', quantity: 60, amount: 2999.4 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped } as any));
371+
expect(err.drops).toEqual([
372+
{ object: LINE, fields: ['quantity'], reason: 'readonly_when' },
373+
AMOUNT,
374+
]);
375+
expect(err.fields).toEqual(['quantity', 'amount']);
289376
expect(line('line_paid')).toMatchObject({ quantity: 6, amount: 299.94 });
290377
expect(l.events).toEqual([]);
291378
});
292379

293-
it('insert: a strict write with a refusable drop is refused before the computation, carrying no stored_expression drop', async () => {
380+
it('predicate update: a strip\'s drop is refused BEFORE a divergent settle — ERR_READONLY_FIELD_REJECTED, not VALIDATION_FAILED (#5126 ordering)', async () => {
294381
const l = listener();
382+
const before = snapshot();
383+
// quantity: 10 computes amount 100 on line_draft and 70 on line_draft_2,
384+
// which one statement cannot store; `note` is taken by the static strip.
385+
const err = await refusal(engine.update(LINE, { note: 'x', quantity: 10 }, { where: { invoice: 'INV-1004' }, multi: true, ...STRICT, onFieldsDropped: l.onFieldsDropped } as any));
386+
expect(err.drops).toEqual([{ object: LINE, fields: ['note'], reason: 'readonly' }]);
387+
expect(snapshot()).toEqual(before);
388+
expect(l.events).toEqual([]);
389+
});
390+
391+
it('control, predicate update: the same divergent payload without strict is refused by the settle, VALIDATION_FAILED', async () => {
392+
const before = snapshot();
295393
const err: any = await engine
296-
.insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { strictReadonlyWrites: true, onFieldsDropped: l.onFieldsDropped })
394+
.update(LINE, { note: 'x', quantity: 10 }, { where: { invoice: 'INV-1004' }, multi: true } as any)
297395
.catch((e: unknown) => e);
298-
expect(err).toBeInstanceOf(ReadonlyFieldRejectedError);
299-
expect(err.drops.map((d: DroppedFieldsEvent) => d.reason)).not.toContain('stored_expression');
396+
expect(err?.code).toBe('VALIDATION_FAILED');
397+
expect(err?.fields).toEqual([expect.objectContaining({ field: 'amount', code: 'invalid_value' })]);
398+
expect(snapshot()).toEqual(before);
399+
});
400+
401+
it('insert: a value the static `readonly` strip took is refused as `readonly` alone, never also as `stored_expression`', async () => {
402+
const l = listener();
403+
const err = await refusal(engine.insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped }));
404+
expect(err.drops).toEqual([{ object: 'qa_locked_line', fields: ['amount'], reason: 'readonly' }]);
300405
expect([...storeFor('qa_locked_line').values()]).toHaveLength(1);
301406
expect(l.events).toEqual([]);
302407
});

0 commit comments

Comments
 (0)