Skip to content

Commit 0e9eba5

Browse files
committed
fix(objectql): a strict strip refusal still pre-empts a throwing stored-expression pass
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
1 parent e57d780 commit 0e9eba5

3 files changed

Lines changed: 77 additions & 17 deletions

File tree

‎.changeset/25000-strict-stored-expression-refusal.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
'@objectstack/objectql': minor
3+
'@objectstack/spec': patch
34
---
45

56
fix(objectql)!: under `strictReadonlyWrites`, a write whose value a stored field's `expression` replaces (`stored_expression`) is refused, and nothing is written
@@ -15,6 +16,8 @@ Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a s
1516
- **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.
1617
- **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.
1718
- **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.
1821
- **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.
1922
- **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`.
2023

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s
130130
quantity: { type: 'number', readonlyWhen: "parent.status == 'paid'" },
131131
unit_price: { type: 'currency', readonlyWhen: "parent.status == 'paid'" },
132132
description: { type: 'text' },
133+
// A static lock, so a predicate update can carry a strip's drop.
134+
note: { type: 'text', readonly: true },
133135
amount: { type: 'currency', min: 0, expression: { dialect: 'cel', source: 'record.quantity * record.unit_price' } },
134136
},
135137
} as any);
@@ -375,6 +377,27 @@ describe('[#24690] a caller value a stored expression replaced is reported as `s
375377
expect(l.events).toEqual([]);
376378
});
377379

380+
it('predicate update: a strip\'s drop is refused BEFORE a divergent settle — ERR_READONLY_FIELD_REJECTED, not VALIDATION_FAILED (#5126 ordering)', async () => {
381+
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();
393+
const err: any = await engine
394+
.update(LINE, { note: 'x', quantity: 10 }, { where: { invoice: 'INV-1004' }, multi: true } as any)
395+
.catch((e: unknown) => e);
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+
378401
it('insert: a value the static `readonly` strip took is refused as `readonly` alone, never also as `stored_expression`', async () => {
379402
const l = listener();
380403
const err = await refusal(engine.insert('qa_locked_line', { quantity: 4, unit_price: 2, amount: 5 }, { ...STRICT, onFieldsDropped: l.onFieldsDropped }));

‎packages/objectql/src/engine.ts‎

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16734,9 +16734,23 @@ export class ObjectQL implements IObjectQLEngine {
1673416734
const liveIndexes = rows
1673516735
.map((row, i) => (rowErrors[i] === undefined && row != null && typeof row === 'object' ? i : -1))
1673616736
.filter((i) => i >= 0);
16737-
const computed = await computeStoredExpressions(
16738-
storedExpressionPlan, liveIndexes.map((i) => rows[i] as Record<string, unknown>), opCtx.context, storedExpressionServing,
16739-
);
16737+
let computed: Array<Record<string, unknown>>;
16738+
try {
16739+
computed = await computeStoredExpressions(
16740+
storedExpressionPlan, liveIndexes.map((i) => rows[i] as Record<string, unknown>), opCtx.context, storedExpressionServing,
16741+
);
16742+
} catch (err) {
16743+
// A pass that THROWS is pre-empted by the strict verdict on the
16744+
// strips' drops (#5126's ordering — the check used to sit ahead
16745+
// of this pass), then rethrown.
16746+
const stripDrops = droppedFieldEvents(object, computedDropped, insertDropped);
16747+
if (stripDrops.length > 0 && options?.strictReadonlyWrites === true) {
16748+
throw new ReadonlyFieldRejectedError(
16749+
object, [...new Set(stripDrops.flatMap((d) => d.fields))], stripDrops, 'insert',
16750+
);
16751+
}
16752+
throw err;
16753+
}
1674016754
liveIndexes.forEach((i, k) => {
1674116755
const row = rows[i] as Record<string, unknown>;
1674216756
const taken = insertDroppedPerRow[i] ?? [];
@@ -18403,9 +18417,18 @@ export class ObjectQL implements IObjectQLEngine {
1840318417
// every drop and replacement together. The computation writes
1840418418
// nothing (it evaluates over a copy), and an object declaring no
1840518419
// stored expression skips it, so its refusal is where it stood.
18420+
// A pass that THROWS (a permission or related-row read) is
18421+
// pre-empted by the strict verdict on the strips' drops already
18422+
// collected — #5126's ordering, as on the predicate branch.
1840618423
if (storedExpressionPlan.length > 0) {
1840718424
const patch = hookContext.input.data as Record<string, unknown>;
18408-
const [computed] = await computeStoredExpressions(storedExpressionPlan, [{ ...priorRecord, ...patch }], opCtx.context, storedExpressionServing);
18425+
let computed: Record<string, unknown> | undefined;
18426+
try {
18427+
[computed] = await computeStoredExpressions(storedExpressionPlan, [{ ...priorRecord, ...patch }], opCtx.context, storedExpressionServing);
18428+
} catch (err) {
18429+
assertNoStrictDrops();
18430+
throw err;
18431+
}
1840918432
emitDrop(replacedStoredExpressionFields(storedExpressionPlan, suppliedValues, patch, [computed!]), 'stored_expression');
1841018433
applyStoredExpressionsToPatch(patch, computed!, priorRecord);
1841118434
}
@@ -18693,20 +18716,31 @@ export class ObjectQL implements IObjectQLEngine {
1869318716
// the column left as it is) is reported under
1869418717
// `stored_expression`; [#25000] through `emitDrop`, so under
1869518718
// strict it is REFUSED before the statement and no row is written,
18696-
// in one refusal with every strip's drop. The pass writes nothing;
18697-
// a patch it cannot store as one statement is refused by
18698-
// `settleBulkStoredExpressions` on its own, before this judgment.
18719+
// in one refusal with every strip's drop. The pass writes nothing.
18720+
// ⛔ #5126's ordering survives it: a pass that THROWS — above
18721+
// all `settleBulkStoredExpressions` refusing a patch that computes
18722+
// different values on different rows (`VALIDATION_FAILED`) — is
18723+
// pre-empted by the strict verdict on the strips' drops already
18724+
// collected, so a strict caller whose payload a strip took is told
18725+
// `ERR_READONLY_FIELD_REJECTED` first, exactly as when the assert
18726+
// sat ahead of the whole pass. With no throw, one refusal names
18727+
// every strip drop plus the replacement.
1869918728
if (storedExpressionPlan.length > 0) {
18700-
const matched = ((await readPriorRows()) ?? []).filter((row) => row != null && typeof row === 'object');
18701-
const patch = hookContext.input.data as Record<string, unknown>;
18702-
const before = await computeStoredExpressions(storedExpressionPlan, matched, opCtx.context, storedExpressionServing);
18703-
const after = await computeStoredExpressions(storedExpressionPlan, matched.map((row) => ({ ...row, ...patch })), opCtx.context, storedExpressionServing);
18704-
const settled = settleBulkStoredExpressions(storedExpressionPlan, patch, before, after, object);
18705-
emitDrop(
18706-
replacedStoredExpressionFields(storedExpressionPlan, suppliedValues, patch, matched.map((row) => ({ ...row, ...settled }))),
18707-
'stored_expression',
18708-
);
18709-
hookContext.input.data = settled as any;
18729+
try {
18730+
const matched = ((await readPriorRows()) ?? []).filter((row) => row != null && typeof row === 'object');
18731+
const patch = hookContext.input.data as Record<string, unknown>;
18732+
const before = await computeStoredExpressions(storedExpressionPlan, matched, opCtx.context, storedExpressionServing);
18733+
const after = await computeStoredExpressions(storedExpressionPlan, matched.map((row) => ({ ...row, ...patch })), opCtx.context, storedExpressionServing);
18734+
const settled = settleBulkStoredExpressions(storedExpressionPlan, patch, before, after, object);
18735+
emitDrop(
18736+
replacedStoredExpressionFields(storedExpressionPlan, suppliedValues, patch, matched.map((row) => ({ ...row, ...settled }))),
18737+
'stored_expression',
18738+
);
18739+
hookContext.input.data = settled as any;
18740+
} catch (err) {
18741+
assertNoStrictDrops();
18742+
throw err;
18743+
}
1871018744
}
1871118745
assertNoStrictDrops();
1871218746
// [#21663] The predicate-path twin of the by-id second pass, at the

0 commit comments

Comments
 (0)