Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions .changeset/19911-readonlywhen-interdependent-locks.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/objectql": patch
---
Expand All @@ -20,11 +20,15 @@
**What happens now.** A value one `readonlyWhen` lock drops can no longer
unlock another: no field is written while its `readonlyWhen` is TRUE on the row
the update stores. The locks are judged together, again with each dropped
value put back to the row's stored one, until no further field locks; then a
field that was held only by a value that was later put back is released, if
the result agrees with the stored row. In the example above `amount` is
dropped as locked, exactly as `update(c1, { amount: 999 })` on its own always
was. Values a `beforeUpdate` hook wrote are still stored and read as before.
value put back to the row's stored one, until no further field locks. Then the
fields held only by a value that was later put back are released, and every
lock is judged again against what that release stores, round after round,
until the dropped fields are exactly the ones locked on the row the update
stores; after one round more than the caller sent fields carrying a
`readonlyWhen`, the first, larger set of drops stands instead. In the example
above `amount` is dropped as locked, exactly as `update(c1, { amount: 999 })`
on its own always was. Values a `beforeUpdate` hook wrote are still stored and
read as before.

**What else you may see move:**

Expand All @@ -39,16 +43,21 @@
the amount the row keeps: a requirement only the let-through amount raised
no longer refuses the write, and clearing a field the kept amount requires is
now refused (`VALIDATION_FAILED`).
- The release is one step, not a search. When it does not settle the drops,
every lock involved holds and the field is dropped, never written — so a
field whose own lock is FALSE on the stored row can still be dropped. That
happens when locks read each other in a cycle (no set of drops agrees with
the stored row), and in a cascade where releasing one field changes another's
verdict: with `c` locked by `previous.c == 'L'`, `x` by `record.c == 'open'`
and `y` by `record.x == 'xv'`, `update(r, { c: 'open', x: 'xv', y: 'yv' })`
on a row with `c: 'L'` drops all three, although `x` is unlocked on the
stored row. That was dropped before this change too; it is tracked as
#19927.
- A field whose own lock is FALSE on the stored row can still be dropped, but
only where locks read each other in a cycle (a `parent`-scoped lock counts
as reading the master-detail field, which picks the header). Without such a
cycle the update drops exactly the caller's fields whose lock is TRUE on the
row it stores (on a bulk update, on at least one matched row). In a cycle,
no set of drops may agree with the stored row: with `a` locked by `record.b
== 'x'` and `b` by `record.a == 'old_a'`, `update(r, { a: 'new_a', b: 'x'
})` on a row `{ a: 'old_a', b: 'y' }` drops both, although `a` is unlocked
on the row it stores. A cycle can also have more than one set that agrees:
with `a` locked by `record.b == 'new_b'` and `b` by `record.a == 'new_a'`,
`update(r, { a: 'new_a', b: 'new_b' })` on a row holding neither new value
would agree with the row by dropping either one, and it drops both. Some
other updates with two such sets store one of them. Where the drops do not
settle, the first, larger set stands: a lock the update cannot settle is
not waived.
- A master-detail repoint that the field's own `record`-scoped lock used to
hold can now land. Its lock is judged together with the other locks on the
header the update names, so when the value that lock reads is itself locked
Expand Down
40 changes: 40 additions & 0 deletions .changeset/19927-readonlywhen-exact-drop-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/objectql": patch
---

fix(objectql): a chain of `readonlyWhen` locks no longer ignores an edit whose own lock is FALSE on the row the update stores (#19927)

**What happened before.** When `readonlyWhen` locks read each other in a chain,
an update could ignore an edit whose own lock was FALSE on the row it stored.
With `c: { readonlyWhen: "previous.c == 'L'" }`, `x: { readonlyWhen: "record.c
== 'open'" }` and `y: { readonlyWhen: "record.x == 'xv'" }`, `update(r, { c:
'open', x: 'xv', y: 'yv' })` on a row with `c: 'L'` ignored all three fields.
The row keeps `c: 'L'`, so `x`'s lock is FALSE there, yet its edit was lost
with no refusal, and a `strictReadonlyWrites` refusal named `x` as read-only.

**What happens now.** That update stores `x: 'xv'` and ignores `c` and `y`:
`c` is locked, and `y`'s lock reads the `x` the row now holds. This holds by
id and on bulk (`multi: true`) updates, and for `isSystem` callers.

**What else you may see move:**

- `onFieldsDropped` reports only the fields the update ignored (`['c', 'y']`
above), and a `strictReadonlyWrites` refusal names only those. Whether a
write is refused under that option does not change.
- An edit that used to be stored can now be ignored. When a field that used
to be ignored now lands, a lock that reads it can be TRUE on the row the
update stores, and that lock's field is then ignored instead of written.
With `p` locked by `previous.p == 'L'`, `m` by `record.p == 'L'`, `j` by
`record.m == 'new'` and `k` by `record.p == 'L' && record.j == 'new'`,
`update(r, { p: 'new', m: 'new', j: 'new', k: 'new' })` on a row `{ p: 'L',
m: 'old', j: 'old', k: 'old' }` used to ignore `j` and store `k: 'new'`; it
now stores `j: 'new'` and ignores `k`, whose lock reads that `j`. A
`strictReadonlyWrites` refusal of that update now names `k` instead of `j`.
- A master-detail repoint that such a chain used to hold can now land. With
`c` locked by `previous.c == 'L'`, the master-detail `invoice` by `record.c ==
'open'`, `y` by `record.invoice == 'h_open'` and `amt` by `parent.status ==
'paid'`, an update setting all four on a line with `c: 'L'` under a paid
invoice used to keep the line there, store `y` and ignore `amt`. It now moves
the line to `h_open`, stores `amt` (unlocked under the open invoice) and
ignores `y`, by id and on bulk updates. A `strictReadonlyWrites` refusal of
that update now names `c` and `y` instead of `c`, `invoice` and `amt`.
Loading
Loading