Skip to content

Commit 03c12a5

Browse files
fix(objectql)!: under strictReadonlyWrites, a write whose value a stored expression replaces is refused, and nothing is written (#25086)
Closes #25000 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`) ## What changes `strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed (#24690, PR #24778 `7532ea3c08`), one reported drop was left out of the refusal: a caller 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. The amended ruling 「甲」 (#6015 `6303865025`) ships the refusal in 18.x as a BREAKING minor, and triage re-timed this card to 18.x (`6305854988`). - **Insert (single, batch, `insertMany`).** The strict check in `engine.insert` now judges `insertDrops`, the one event list that includes the stored-expression replacement, after the compute pass. Before, it judged only the strips' drops, ahead of that pass. A replaced value throws `ReadonlyFieldRejectedError` (`operation: 'insert'`) before validation and before any driver dispatch, and no listener fires. `insertMany` delegates to `insert`, so it refuses the whole call, as it does for the other strict drops. - **Update by id and predicate update.** The `stored_expression` report now goes through `emitDrop`, the channel every strip reports through, and no longer calls `notifyDrop` directly. On both branches the one `assertNoStrictDrops()` call now comes after the stored-expression pass. So under strict the write is refused before the statement and nothing is written. An object that declares no stored expression skips that pass, so its refusal happens exactly where it did before. - **One refusal names everything.** A payload that carries another strip's drop as well as a replaced value is now refused once, naming both, with `stored_expression` last. Before, it was refused for the other drop alone. - **Wording.** `REASON_PHRASE.stored_expression` (written by #24690 ahead of this refusal) is reused. The per-reason message gains a `stored_expression` remedy (leave the field out, or send the computed value). That remedy is spoken only when the reason is present, so every other refusal's message is unchanged byte for byte, by the same rule the `computed` remedy follows. - **Docs.** The TSDoc in `packages/spec/src/contracts/data-engine.ts` (`strictReadonlyWrites`) and `packages/spec/src/data/data-engine.zod.ts` (`DroppedFieldsEvent.reason`) no longer names `stored_expression` as the arm strict does not refuse. The objectql comments in `engine.ts` and `readonly-strict-errors.ts` follow. No `.describe()` text moved: `check:generated` reports all 14 artifacts current, so the generated reference docs have nothing to regenerate. **Unchanged:** a strict write that sends the computed value, or no value, is not refused. A stored expression field that is also `readonly` is still refused once, as `readonly`. Without strict, the write completes with the computed value and is reported. The error code is unchanged. ## Tests `packages/objectql/src/engine-stored-expression-dropped-fields.test.ts`: the strict block changes from "reported, not refused" to "refused, nothing written". It covers update by id, insert single, batch, `insertMany` and predicate update. Each refusal asserts the `code`, `drops` (and `operation`/`fields` where they matter), that the store is unchanged, and that the listener stayed silent. A non-strict control sits beside each refusal (the write succeeds and is reported), together with the strict controls for "computed value sent / no value". The union case is also covered (`readonly_when` + `stored_expression` in one refusal), and so is the `readonly`-only insert refusal. All runs were taken on the final code commit `a8e9d3611c`: - `pnpm --filter @objectstack/objectql test`: 455 files / 8569 tests passed. `pnpm --filter @objectstack/objectql typecheck`: `tsc --noEmit` clean, and `check:test-typecheck` OK (ledger held). - `pnpm --filter @objectstack/spec build && check:generated`: all 14 generated artifacts up to date. `spec test`: 705 files / 21424 passed (1 todo). `spec typecheck`: OK. `spec test:repo`: 72 files / 1237 passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 94 commands from merge base `688052e14`, and all 94 exited 0. On the first pass, `check:dual-build-cjs-loads` and `check:type-check-debt` printed PREREQUISITE NOT MET (exit 3, nothing measured). I re-ran `check:type-check-debt` under the verify lock: its closure build ran and it re-measured OK, `none above its recorded number`. That full build also produced the missing `dist/` trees, and `check:dual-build-cjs-loads` then passed (112 entry points across 69 packages load). `dispatch-gates --ran` reconciles 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. The changeset gates are among the 94: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`. **Ablation.** The fix was committed first. Each leg went through `scripts/ablation-replace.mjs`, with an anchor hit of 1 to 0 and the blob proven back to the HEAD blob `3e8c0ec2bd1a` with `git diff HEAD` empty. The suite imports `./engine.js` from source, so no build is involved. - A, update paths: `emitDrop` lets `stored_expression` past strict. Three tests go red (update by id, predicate update, the union case), and the other 22 stay green. - B, insert: the strict check ignores a `stored_expression`-only list. Three tests go red (insert single, batch, `insertMany`), and the other 22 stay green. ## Producers measured No non-test caller in this repository passes `strictReadonlyWrites`: the examples, `packages/qa` dogfood and the runtime and REST doors pass none. The option is also not on the serializable options bag, so no wire caller can set it. No in-repo producer moves. In the objectql suite, the in-package strict tests on stored-expression objects (`engine-stored-expression-write.test.ts`, `engine-write-formula-hydration.test.ts`, `engine-formula-write-strip.test.ts`) stay green unchanged. ## Changeset `.changeset/25000-strict-stored-expression-refusal.md`: `@objectstack/objectql` `minor`, with a BREAKING section that maps FROM (completed, reported) TO (refused, nothing written), gives the remedy, and carries the claim's `Clause-②` line and an ADR-0087 `not-required (no-migration-prescription)` marker (the precedent shape of `bb4f5cc005`). `@objectstack/spec` is not listed: only TSDoc comments moved there, and no `.describe()` text or export did. ## Acceptance notes - The release-owned 18.x upgrade checklist (`content/docs/releases/`) is not edited here. The changeset carries the text that release compilation reads. - The pending `.changeset/24690-stored-expression-dropped-fields.md` (in the open Version Packages PR) says strict "is unchanged" and that the refusal is deferred to 19.0. That file is outside this card's claimed file surface, so this PR leaves it alone. The new changeset says it supersedes that bullet. The report names the one-line amendment for the seat to decide. - `content/docs/data-modeling/validation-rules.mdx` (the `expression` on a stored field paragraph) still says a sent value is "not reported as dropped", which has been false since #24690. That is a hand-written doc outside this card's surface, and it is noted in the report. Open PRs touching the same files were checked: #25072 and #24893 edit `engine.ts` in disjoint hunks (lines 4628–6300 and 9043–12250). No open PR touches `data-engine.zod.ts`, `contracts/data-engine.ts` or `readonly-strict-errors.ts`. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba244f1 commit 03c12a5

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)