Skip to content

Commit 3bd221d

Browse files
fix(objectql): judge a parent-scoped readonlyWhen against the header the write stores (#19853) (#19877)
Fixes #19853 Clause-②: no ## What a caller could do before, and what happens now **Before.** On a master-detail child whose parent field is `readonly: true`, a caller with edit rights could change a field locked by a `parent`-scoped `readonlyWhen` by naming a different, unlocked parent in the same update. The engine resolved `parent` from the FK the payload named, judged the lock against that header, and only afterwards stripped the read-only FK — so the row stayed under the locked parent with the locked field rewritten. The same happened when the FK carried its own `readonlyWhen` lock that kept the row where it was, and on bulk (`multi: true`) updates for every matched row. **Now.** The engine settles whether the update really moves the row BEFORE it judges any `parent`-scoped lock, and judges every lock (and `requiredWhen`, which shares the binding) against the parent the row is stored under afterwards. A legitimate move — FK writable, or an `isSystem` / `preserveAudit` write the static strip exempts — is still judged against the parent it moves to. ## A1 — reproduction on `origin/main` 2bbb462, engine untouched Real `ObjectQL` engine, in-memory driver (the suite's own), the card's shape: `inv_line.amount` has `readonlyWhen: "parent.status == 'paid'"`, `inv_line.invoice` is `master_detail` with `readonly: true`, `l1` (amount 100) under paid `inv_a`, `inv_b` open. - `update(l1, { amount: 999, invoice: 'inv_b' })` committed and stored `{ invoice: 'inv_a', amount: 999 }` — reproduced. - Control `update(l1, { amount: 555 })` — locked, amount stayed 100. - Same run, the other arms: FK's own record-scoped lock → stored `amount: 999` under `inv_a`; FK's own parent-scoped lock → same; bulk stripped repoint → same per row; reverse direction (naming the PAID invoice for a line under an open one) → the amount was dropped (over-lock); `requiredWhen: "parent.status == 'paid'"` → a note-only edit of an open line naming the paid invoice was refused `po_ref is required`, and clearing `po_ref` on a paid line by naming the open one was accepted. ## A2 — every strip that can move the FK on this path, from the code Order on both update branches, after the before-phase hooks: primary-key strip (`id` only) → `readonlyWhen` strip → static `readonly` strip (skipped for `isSystem`) → `assertNoStrictDrops` → `evaluateValidationRules` → reference check → driver. - **`readonlyWhen` strip** — can take the FK when the FK carries its own lock. Ran AFTER `parent` was resolved from the named FK. - **Static `readonly` strip** — takes a non-system caller's FK when it is `readonly: true` (unless `preserveAudit` keeps it, or a hook wrote it). Ran after the `readonlyWhen` strip. - Not strips: field-level security refuses a forbidden FK outright (plugin-security step 2.5 throws `PermissionDeniedError`); the #16344 hide pass withholds static-readonly values from hooks and restores them before any engine-owned read (net zero); `beforeUpdate` hooks, `normalizeMultiValueFields` and `validateRecord` all run before `parent` is resolved, so their effect is already in the payload the resolution reads; the primary-key strip touches `id` only. ## The fix — `packages/objectql/src/engine.ts` `settleMasterDetailLanding` (one private method, used by the by-id and the bulk branch) runs before the `readonlyWhen` strip: 1. the FK's own `readonlyWhen` lock is judged ALONE (the strip's `supplied` subset holds only the FK) against the header the FK names — #4889's verdict for the FK itself, byte-identical to before; the strip that judges the other fields is then handed a `supplied` without the FK, so that verdict is never re-asked against a different header; 2. the static strip's verdict on the FK is asked of the same `stripReadonlyFields`, silently, with the same `supplied`, `hookWrittenKeys` and `preserveAudit`; its `isSystem` gate is read once per branch into a const that both the settlement and the real strip consume (keeps the system-context census at 110 sites, and the two cannot disagree); 3. `parent` is resolved from the view the write stores — the payload, or the payload without the FK — so `masterIdOf` falls through to the prior row's FK when the repoint does not land. The `requiredWhen` non-regression pre-check's "does this write repoint" is asked of the same stored view. The header read keeps `opCtx.context` (the #19837 tenant scope) exactly as it was. ## A3 — what moves - `parent` for every `parent`-scoped `readonlyWhen` and `requiredWhen` is the header the row is stored under (the fix). - `onFieldsDropped` for the card's write: `[{amount, readonly_when}, {invoice, readonly}]` (was `[{invoice, readonly}]`); a `strictReadonlyWrites` refusal names `['amount', 'invoice']` with both drops (was `['invoice']`). - Over-lock removed: naming a locked parent beside a stripped FK no longer drops a field of a row that stays under an open parent. - `requiredWhen` moves with the shared binding, both directions (see the declaration note below). - Header reads: a stripped repoint reads the header the row keeps instead of the named one (same count, pinned); the `requiredWhen` pre-check no longer buys a second read for a repoint that does not land; a `parent`-scoped lock on the FK ITSELF that refuses the landing costs two reads (named, then kept) where it cost one. - Warn-line order only: when the FK's own lock fires, its line now prints before the other fields' lines. Report order, strict `fields` order and message text are unchanged. - Unmoved: the FK's own verdict; strip order; `isSystem` / `preserveAudit` behaviour; every write whose payload does not name the FK. ## A4 — the legitimate repoint With `invoice` writable (`inv_line_free`): paid → open `update(f1, { invoice: 'inv_b', amount: 999 })` is judged against `inv_b` and commits both; open → paid `update(f2, { invoice: 'inv_a', amount: 999 })` is judged against `inv_a` and drops the amount while the move lands. In both the judged header is the one the row is stored under because the FK lands: no strip takes it. ## Tests New `describe` block in `packages/objectql/src/engine-readonly-when-parent.test.ts` (16 cases): the card, the control, the report, the strict envelope (`code` `ERR_READONLY_FIELD_REJECTED`, `name`, `fields`, `drops`; the engine error carries no HTTP `status` — that mapping is downstream and not measured here), the reverse over-lock, the one-header read, `isSystem`, `preserveAudit`, both legitimate repoint directions, the FK's own record- and parent-scoped locks, both `requiredWhen` directions (refusal asserted on `code` `VALIDATION_FAILED` + `fields`), and bulk in both directions. Measured on `0a538af9d7`: - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2` → 305 files / 5105 tests passed (exit 0). - `pnpm --filter @objectstack/objectql test:repo` → 1 file / 5 tests passed; `pnpm --filter @objectstack/objectql typecheck` → exit 0, `check:test-typecheck: OK`. - Ablation (committed fix; `engine.ts` restored to its `2bbb462335` blob `ffcda81540` and verified on disk, `settleMasterDetailLanding` count 0): the parent suite ran 11 failed / 27 passed — every new pin except the five whose behaviour is unchanged (control, `isSystem`, `preserveAudit`, both legitimate repoints). Restored with `git checkout HEAD --`, blob `624fc1e38a` equals `HEAD:packages/objectql/src/engine.ts`, `git diff HEAD` empty, rerun 38/38 passed. Script carried an EXIT/INT/TERM trap. ## Gates on `0a538af9d7` - `node scripts/pm/dispatch-gates.mjs --commands` → 64 commands; all run with exit codes captured before any pipe; `--ran` → `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`, exit 0. - `check:dual-build-cjs-loads` and `check:type-check-debt` first answered PREREQUISITE NOT MET (no workspace `dist`); after `turbo run build` over `./packages/*` and `./packages/*/*` (72/72 tasks), both exit 0 (`type-check-debt` needed an objectql rebuild after the ablation restore touched `engine.ts`'s mtime). - `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks on) → exit 0, 13 citations resolve. - `node scripts/check-system-context-census.mjs` → exit 0 (110 sites). It was red on the first commit (`d4496752a1`) for a new `isSystem` read inside the helper; the second commit hoists the one read per branch instead. - `pnpm check:query-options-erasure` → exit 0. - Lint, narrowed and proven: eslint's own config (`ESLint.calculateConfigForFile` / `isPathIgnored`) puts `engine.ts` and the test file in the linted population and ignores the changeset; `eslint --no-inline-config --format json` over the three paths → 3 results, 0 errors, 0 warnings on the two linted files; the config enables no type-aware linting and no cross-file rule (its only file reads are two baselines this diff does not touch), so this diff cannot move a verdict on any untouched file. `pnpm lint` itself is CI's. ## Neighbour PR #19728 Textually disjoint: this diff does not touch the `evaluateValidationRules` lines, the import line, the region after `resolveMasterDetailParents` or `system-context.mdx`. Driver-free bare probe (`git clone --bare --shared`, no merge driver registered): `merge-tree --write-tree` of `0a538af9d7` against `3b9c5f2fca` → exit 0; against `origin/main` `b940f32a56` → exit 0. The merged tree carries both changes. ## Acceptance notes - **Sibling on the `record` root, same defect class, not fixed here (listed for the seat to file).** A record-scoped `readonlyWhen` that reads a static-`readonly` field is judged against the caller's forged value of that field, which the static strip then removes. Measured on `0a538af9d7`: `status` `readonly: true`, `amount` `readonlyWhen: "record.status == 'closed'"`, row closed; `update(t1, { amount: 555 })` stays 100, `update(t1, { status: 'open', amount: 999 })` stores `{ status: 'closed', amount: 999 }`. Judging the conditional strip over the post-static view would close both roots at once, but it moves reason attribution for fields declaring both locks and the strict message text, so it is a separate decision. - #4889's landing rule still decides the FK's OWN `parent`-scoped lock: `invoice: { readonlyWhen: "parent.status == 'paid'" }` judges a move off a paid invoice against the invoice it moves to. Documented behaviour, unchanged here; noted, not filed. ## Declaration note for the contract review The claim declares no widening; copied above as given. Measured movement in `update()`'s refuse/accept answer, only when a payload names an FK that a strip then takes back out and the object has a `parent`-scoped `requiredWhen`: a write refused `VALIDATION_FAILED` against the named header now commits (the row stays under a header that does not require the field), and a write that cleared a required field under a header that does require it is now refused. Every other movement is a strip (the write still commits) or a strict refusal that was already a refusal. Whether that requirement movement counts as an accept-set change is the review's call. **Seat's disposition (after the contract review):** `Clause-②: no` stands, and `patch` is right. The accept-direction movement removes a refusal the published text already negated. `content/docs/data-modeling/fields.mdx` ("Locking a detail from its master: `parent`") says: "`parent` = the record on the other end of this object's master_detail field", and "The server binds `parent` on the update path by reading the master the row points at (a repointing write is judged against the master it lands on)". A row whose repoint never lands points at, and lands on, its stored master, so refusing it against the named header contradicted that text. The refuse-direction movement is a restored guarantee (a requirement bypass now refused). Neither moves an authorable key, an export or an error code. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9eb224 commit 3bd221d

3 files changed

Lines changed: 450 additions & 11 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a parent-scoped `readonlyWhen` lock is judged against the invoice a line STAYS under, not the one the update names (#19853)
6+
7+
**What a caller could do before.** On a master-detail child whose parent field
8+
is read-only, a caller with edit rights could change a field locked by a
9+
`parent`-scoped `readonlyWhen` by naming a different, unlocked parent in the
10+
same update. With `amount: { readonlyWhen: "parent.status == 'paid'" }` and a
11+
`readonly: true` `invoice` field, `update(line, { amount: 999, invoice:
12+
'open_invoice' })` on a line of a PAID invoice committed `amount = 999`: the
13+
lock was judged against the open invoice the payload named, then the read-only
14+
`invoice` was stripped, so the line stayed under the paid invoice with its
15+
frozen amount rewritten. The same happened when the parent field carried a
16+
`readonlyWhen` lock of its own that kept the line where it was, and on bulk
17+
(`multi: true`) updates for every matched row.
18+
19+
**What happens now.** The engine settles whether the update really moves the
20+
line BEFORE it judges any `parent`-scoped lock, and judges every lock against
21+
the parent the row is stored under afterwards. In the example above `amount` is
22+
dropped as locked, exactly as `update(line, { amount: 999 })` on its own always
23+
was. A legitimate move — the parent field writable, or an `isSystem` /
24+
`preserveAudit` write the read-only strip exempts — is still judged against the
25+
parent it moves to, unchanged.
26+
27+
**What else you may see move, all in the same direction (the parent the row is
28+
stored under decides):**
29+
30+
- Naming a LOCKED parent beside a read-only parent field no longer locks a line
31+
that stays under an open one — its field now commits.
32+
- `requiredWhen` reads the same parent binding, so a `parent`-scoped requirement
33+
is also judged against the parent the row stays under: clearing a required
34+
field on a paid line by naming an open parent is now refused
35+
(`VALIDATION_FAILED`), and naming a paid parent beside a read-only parent
36+
field no longer refuses an open line.
37+
- `onFieldsDropped` now reports the locked field (`readonly_when`) beside the
38+
parent field (`readonly`), and a `strictReadonlyWrites` refusal names both.
39+
- When the parent field carries its own `readonlyWhen`, that lock is still
40+
judged against the parent the update names, as before.

‎packages/objectql/src/engine-readonly-when-parent.test.ts‎

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,239 @@ describe('parent-scoped readonlyWhen is enforced server-side (#4889)', () => {
402402
expect(reads.filter((r) => r === 'showcase_invoice')).toHaveLength(1);
403403
});
404404
});
405+
406+
// ── #19853 — `parent` is the header the write STORES, not the one it NAMES ──
407+
//
408+
// Every block above resolves `parent` payload-FK-first (#4889: a repoint is
409+
// judged against the master it lands on). That rule is only as good as the
410+
// repoint's LANDING. Two strips can take the FK back out after `parent` was
411+
// resolved from it — the static `readonly` strip (a non-system caller's
412+
// forged FK) and the FK's own `readonlyWhen` lock — and then the row stays
413+
// under the header it had while every other parent-scoped lock was judged
414+
// against a header it never reached. Measured on `origin/main` 2bbb462335
415+
// before the fix, the card's own write: `update(l1, { amount: 999, invoice:
416+
// 'inv_b' })` against a line of the PAID `inv_a` committed `{ amount: 999,
417+
// invoice: 'inv_a' }` — the frozen line rewritten, still under the paid
418+
// invoice. The header these cases expect is always the one the stored row
419+
// hangs off afterwards.
420+
describe('parent-scoped readonlyWhen reads the header the write STORES (#19853)', () => {
421+
let engine: ObjectQL;
422+
let storeFor: ReturnType<typeof makeDriver>['storeFor'];
423+
424+
beforeEach(async () => {
425+
engine = new ObjectQL();
426+
const d = makeDriver();
427+
storeFor = d.storeFor;
428+
engine.registerDriver(d.driver, true);
429+
await engine.init();
430+
engine.registry.registerObject({ name: 'inv', fields: { status: { type: 'text' } } } as any);
431+
// The card's shape: a line can never be moved to another invoice.
432+
engine.registry.registerObject({
433+
name: 'inv_line',
434+
fields: {
435+
invoice: { type: 'master_detail', reference: 'inv', readonly: true },
436+
amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" },
437+
note: { type: 'text' },
438+
},
439+
} as any);
440+
// The legitimate repoint: the same lock, the FK writable.
441+
engine.registry.registerObject({
442+
name: 'inv_line_free',
443+
fields: {
444+
invoice: { type: 'master_detail', reference: 'inv' },
445+
amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" },
446+
},
447+
} as any);
448+
// The FK's OWN lock, record-scoped: a submitted line stays where it is.
449+
engine.registry.registerObject({
450+
name: 'inv_line_moored',
451+
fields: {
452+
stage: { type: 'text' },
453+
invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: "record.stage == 'submitted'" },
454+
amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" },
455+
},
456+
} as any);
457+
// The FK's OWN lock, parent-scoped: no line lands on a void invoice. Its
458+
// verdict is #4889's and stays so — judged against the header it NAMES.
459+
engine.registry.registerObject({
460+
name: 'inv_line_void_guard',
461+
fields: {
462+
invoice: { type: 'master_detail', reference: 'inv', readonlyWhen: "parent.status == 'void'" },
463+
amount: { type: 'number', readonlyWhen: "parent.status == 'paid'" },
464+
},
465+
} as any);
466+
// `requiredWhen` shares the binding (#4977), so it moves with it.
467+
engine.registry.registerObject({
468+
name: 'inv_line_po',
469+
fields: {
470+
invoice: { type: 'master_detail', reference: 'inv', readonly: true },
471+
po_ref: { type: 'text', requiredWhen: "parent.status == 'paid'" },
472+
note: { type: 'text' },
473+
},
474+
} as any);
475+
476+
storeFor('inv').set('inv_a', { id: 'inv_a', status: 'paid' });
477+
storeFor('inv').set('inv_b', { id: 'inv_b', status: 'open' });
478+
storeFor('inv').set('inv_v', { id: 'inv_v', status: 'void' });
479+
storeFor('inv_line').set('l1', { id: 'l1', invoice: 'inv_a', amount: 100, note: 'paid' });
480+
storeFor('inv_line').set('l2', { id: 'l2', invoice: 'inv_b', amount: 200, note: 'open' });
481+
storeFor('inv_line_free').set('f1', { id: 'f1', invoice: 'inv_a', amount: 100 });
482+
storeFor('inv_line_free').set('f2', { id: 'f2', invoice: 'inv_b', amount: 200 });
483+
storeFor('inv_line_moored').set('m1', { id: 'm1', stage: 'submitted', invoice: 'inv_a', amount: 100 });
484+
storeFor('inv_line_void_guard').set('v1', { id: 'v1', invoice: 'inv_a', amount: 100 });
485+
storeFor('inv_line_po').set('p1', { id: 'p1', invoice: 'inv_b', po_ref: null, note: 'n0' });
486+
});
487+
488+
const row = (object: string, id: string) => storeFor(object).get(id);
489+
490+
async function rejection(p: Promise<unknown>): Promise<any> {
491+
try {
492+
await p;
493+
} catch (err) {
494+
return err;
495+
}
496+
throw new Error('expected the write to be refused, but it resolved');
497+
}
498+
499+
it('THE CARD: naming another invoice beside a read-only FK no longer unlocks the paid line', async () => {
500+
await engine.update('inv_line', { id: 'l1', amount: 999, invoice: 'inv_b' });
501+
expect(row('inv_line', 'l1')).toMatchObject({ invoice: 'inv_a', amount: 100 });
502+
});
503+
504+
it('CONTROL: the same lock without the repoint, unchanged', async () => {
505+
await engine.update('inv_line', { id: 'l1', amount: 555 });
506+
expect(row('inv_line', 'l1')).toMatchObject({ invoice: 'inv_a', amount: 100 });
507+
});
508+
509+
it('reports both strips, each under its own reason', async () => {
510+
const events: any[] = [];
511+
await engine.update(
512+
'inv_line',
513+
{ id: 'l1', amount: 999, invoice: 'inv_b' },
514+
{ onFieldsDropped: (e: any) => events.push(e) } as any,
515+
);
516+
expect(events).toEqual([
517+
{ object: 'inv_line', fields: ['amount'], reason: 'readonly_when' },
518+
{ object: 'inv_line', fields: ['invoice'], reason: 'readonly' },
519+
]);
520+
});
521+
522+
it('under strictReadonlyWrites the stripped repoint is refused naming BOTH fields, and nothing lands', async () => {
523+
const err = await rejection(engine.update(
524+
'inv_line',
525+
{ id: 'l1', amount: 999, invoice: 'inv_b', note: 'edited' },
526+
{ strictReadonlyWrites: true } as any,
527+
));
528+
expect(err.code).toBe('ERR_READONLY_FIELD_REJECTED');
529+
expect(err.name).toBe('ReadonlyFieldRejectedError');
530+
expect(err.fields).toEqual(['amount', 'invoice']);
531+
expect(err.drops).toEqual([
532+
{ object: 'inv_line', fields: ['amount'], reason: 'readonly_when' },
533+
{ object: 'inv_line', fields: ['invoice'], reason: 'readonly' },
534+
]);
535+
expect(row('inv_line', 'l1')).toMatchObject({ invoice: 'inv_a', amount: 100, note: 'paid' });
536+
});
537+
538+
it('REVERSE: naming a PAID invoice beside a read-only FK no longer locks an open line', async () => {
539+
// The over-lock twin: the row stays under the OPEN `inv_b`, so its amount
540+
// is writable, whatever header the payload named.
541+
await engine.update('inv_line', { id: 'l2', amount: 999, invoice: 'inv_a' });
542+
expect(row('inv_line', 'l2')).toMatchObject({ invoice: 'inv_b', amount: 999 });
543+
});
544+
545+
it('reads ONE header for the stripped repoint — the one the row keeps', async () => {
546+
const reads: unknown[] = [];
547+
const original = (engine as any).findOne.bind(engine);
548+
(engine as any).findOne = async (name: string, q: any, o?: any) => {
549+
if (name === 'inv') reads.push(q?.where?.id);
550+
return original(name, q, o);
551+
};
552+
await engine.update('inv_line', { id: 'l1', amount: 999, invoice: 'inv_b' });
553+
expect(reads).toEqual(['inv_a']);
554+
});
555+
556+
it('isSystem: the static strip does not run, the FK lands, and the lock reads the header it lands on', async () => {
557+
await engine.update(
558+
'inv_line',
559+
{ id: 'l1', amount: 999, invoice: 'inv_b' },
560+
{ context: { isSystem: true } } as any,
561+
);
562+
expect(row('inv_line', 'l1')).toMatchObject({ invoice: 'inv_b', amount: 999 });
563+
});
564+
565+
it('preserveAudit: the static strip keeps the FK, so the lock reads the header it lands on', async () => {
566+
await engine.update(
567+
'inv_line',
568+
{ id: 'l1', amount: 999, invoice: 'inv_b' },
569+
{ context: { preserveAudit: true } } as any,
570+
);
571+
expect(row('inv_line', 'l1')).toMatchObject({ invoice: 'inv_b', amount: 999 });
572+
});
573+
574+
it('LEGITIMATE REPOINT paid → open: judged against the open header it lands on, both fields commit', async () => {
575+
await engine.update('inv_line_free', { id: 'f1', invoice: 'inv_b', amount: 999 });
576+
expect(row('inv_line_free', 'f1')).toMatchObject({ invoice: 'inv_b', amount: 999 });
577+
});
578+
579+
it('LEGITIMATE REPOINT open → paid: judged against the paid header it lands on, the amount is locked', async () => {
580+
await engine.update('inv_line_free', { id: 'f2', invoice: 'inv_a', amount: 999 });
581+
expect(row('inv_line_free', 'f2')).toMatchObject({ invoice: 'inv_a', amount: 200 });
582+
});
583+
584+
it('the FK\'s own record-scoped lock keeps the row home, and the amount is judged against that home', async () => {
585+
const events: any[] = [];
586+
await engine.update(
587+
'inv_line_moored',
588+
{ id: 'm1', invoice: 'inv_b', amount: 999 },
589+
{ onFieldsDropped: (e: any) => events.push(e) } as any,
590+
);
591+
expect(row('inv_line_moored', 'm1')).toMatchObject({ invoice: 'inv_a', amount: 100 });
592+
expect(events).toEqual([
593+
{ object: 'inv_line_moored', fields: ['invoice', 'amount'], reason: 'readonly_when' },
594+
]);
595+
});
596+
597+
it('the FK\'s own parent-scoped lock is judged against the header it names; the rest against the one it keeps', async () => {
598+
// `inv_v` is void, so the FK's own lock refuses the landing (#4889's rule,
599+
// unchanged for the FK itself). The row stays under the PAID `inv_a`, and
600+
// that is the header the amount's lock reads.
601+
await engine.update('inv_line_void_guard', { id: 'v1', invoice: 'inv_v', amount: 999 });
602+
expect(row('inv_line_void_guard', 'v1')).toMatchObject({ invoice: 'inv_a', amount: 100 });
603+
});
604+
605+
it('requiredWhen shares the binding (#4977): a requirement is judged against the header the row keeps', async () => {
606+
// `p1` stays under the OPEN `inv_b` (its FK is read-only), so the paid-only
607+
// requirement does not apply to it — the named `inv_a` never becomes its
608+
// header.
609+
await engine.update('inv_line_po', { id: 'p1', invoice: 'inv_a', note: 'n1' });
610+
expect(row('inv_line_po', 'p1')).toMatchObject({ invoice: 'inv_b', po_ref: null, note: 'n1' });
611+
});
612+
613+
it('requiredWhen: naming an open invoice beside a read-only FK no longer lets a paid line drop its requirement', async () => {
614+
// The requirement's half of the card: `p2` stays under the PAID `inv_a`, so
615+
// clearing `po_ref` takes it from compliant to violating (ADR-0113).
616+
storeFor('inv_line_po').set('p2', { id: 'p2', invoice: 'inv_a', po_ref: 'PO-7', note: 'n0' });
617+
const err = await rejection(engine.update('inv_line_po', { id: 'p2', invoice: 'inv_b', po_ref: null }));
618+
expect(err.code).toBe('VALIDATION_FAILED');
619+
expect(err.fields).toEqual([expect.objectContaining({ field: 'po_ref', code: 'required' })]);
620+
expect(row('inv_line_po', 'p2')).toMatchObject({ invoice: 'inv_a', po_ref: 'PO-7' });
621+
});
622+
623+
it('BULK: a stripped repoint does not unlock the matched rows under a paid header', async () => {
624+
await engine.update(
625+
'inv_line',
626+
{ amount: 999, invoice: 'inv_b' },
627+
{ where: { note: 'paid' }, multi: true } as any,
628+
);
629+
expect(row('inv_line', 'l1')).toMatchObject({ invoice: 'inv_a', amount: 100 });
630+
});
631+
632+
it('BULK REVERSE: naming a paid header beside a read-only FK does not lock rows under an open one', async () => {
633+
await engine.update(
634+
'inv_line',
635+
{ amount: 999, invoice: 'inv_a' },
636+
{ where: { note: 'open' }, multi: true } as any,
637+
);
638+
expect(row('inv_line', 'l2')).toMatchObject({ invoice: 'inv_b', amount: 999 });
639+
});
640+
});

0 commit comments

Comments
 (0)