feat(plugins): predicate-guarded atomic updateIf for plugin storage (no-oversell) - #2169
feat(plugins): predicate-guarded atomic updateIf for plugin storage (no-oversell)#2169vedanshujain wants to merge 11 commits into
Conversation
🦋 Changeset detectedLatest commit: 11fffb5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 732 lines across 9 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-moderation
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
|
This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity. If you're still working on this, please push an update or leave a comment. |
Overlapping PRsThis PR modifies files that are also changed by other open PRs: This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
Adds `ctx.storage.<collection>.updateIf(id, { where, set?, delta? })` — the
no-oversell primitive. The guard and the arithmetic run in a single
`UPDATE … SET data = <json_set/jsonb_set expr>, updated_at = ? WHERE <pk> AND
<guard> RETURNING data`, so there is no read-then-write and N concurrent guarded
decrements serialize correctly (exactly M of N apply, never oversell).
- `where` reuses the numeric-correct WhereClause translation from query(), so a
multi-digit guard like `stock >= 10` compares numerically on Postgres.
- `set` writes wholesale field values; `delta` applies integer inc/dec in-SQL
over `COALESCE(base, 0)` (a delta on a missing/null field starts from 0).
Integer-only deltas enforced at runtime; a field may not be in both set and
delta; at least one is required.
- Returns `{ applied: true, data }` or `{ applied: false }` (row absent OR guard
failed — intentionally indistinguishable). Never inserts.
Backed by `pluginDataWriteExpr` (json_set / jsonb_set, dialect-correct, values
bound). No new column, no migration. Tests: storage-updateif (guard pass/fail/
missing, inc/dec, float rejection, COALESCE-from-0, set/mixed/validation, guard
operator coverage, empty-`in`) and storage-no-oversell (M-of-N concurrent
decrements) — both dialects, Postgres via EMDASH_TEST_PG.
Scoped deliberately: `insert` (create-iff-absent) and the sandbox bridges are
separate follow-ups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TX9YciGFRZX9aF2UcQ6rUW
…rd bypass, doc isolation
Three follow-ups on the guarded PluginStorageRepository.updateIf primitive:
1. Trap Postgres serialization failures. Under an isolation level stricter
than READ COMMITTED the losing concurrent updateIf writers abort with
SQLSTATE 40001 (serialization_failure) / 40P01 (deadlock_detected) instead
of resolving to { applied: false }. A new exported StorageSerializationError
(in storage-query.ts, alongside StorageQueryError) carries the SQLSTATE and
cause and explains the READ COMMITTED assumption + retry guidance. A pure,
unit-testable mapSerializationFailure(err) helper wraps 40001/40P01 and
rethrows everything else unchanged; updateIf's catch is
`throw mapSerializationFailure(err)`. SQLSTATE is read from err.code (and
err.cause.code defensively) — confirmed empirically that Kysely propagates
node-pg's DatabaseError.code unwrapped.
2. Fix the all-undefined delta/set guard bypass (both reviewers). Presence is
now derived from DEFINED entries: undefined-valued set fields are filtered
and undefined delta specs skipped BEFORE the "at least one of set/delta"
check, so an all-undefined payload throws instead of doing a no-op write
that bumped updated_at and returned { applied: true }. undefined values in
set/delta are documented as ignored.
3. Doc the isolation contract on the updateIf doc-comment in both types.ts
(interface) and plugin-storage.ts (impl): { applied: false } assumes READ
COMMITTED; under REPEATABLE READ / SERIALIZABLE losing writers throw
StorageSerializationError — the no-oversell SAFETY invariant holds either way.
Tests: all-undefined delta/set throw (integration, both dialects); deterministic
mapSerializationFailure unit tests (40001, 40P01, .cause.code nesting, 23505
pass-through, plain Error pass-through). Full core plugin suites green on both
dialects (804 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TX9YciGFRZX9aF2UcQ6rUW
bd80510 to
16ae127
Compare
There was a problem hiding this comment.
The change is the right solution to the right problem: plugin storage had no in-process predicate-guarded atomic write, so "sell one iff in stock" required a racy read-modify-write. Using a single UPDATE … WHERE <guard> RETURNING and composing set/delta with dialect-safe json_set/jsonb_set is idiomatic for EmDash and fits the existing repository layer.
What I checked: the diff, the full changed source files, the validateJsonFieldName helper, existing plugin-storage query/where code, the canonical skills/creating-plugins/references/storage.md docs, and the unit/integration tests. I did not run the test suite, linter, or builds (no shell). Statically the implementation looks sound: field names are validated before interpolation, values are bound, the empty-in: [] short-circuit is correct, and the tests cover the core guard/arithmetic/validation paths for both dialects.
Headline issues:
-
Reviewer-addressed comment left in tests.
storage-updateif.test.tscontains// … (#reviewer flag). AGENTS.md explicitly forbids comments that reference review threads/PRs because they become stale the moment the change merges. -
Serialization-error messaging is misleading about
40P01. The code and docstrings claim40001/40P01aborts happen "under an isolation level stricter than READ COMMITTED". That is not true for40P01(deadlock): deadlocks can occur underREAD COMMITTEDtoo, not only underREPEATABLE READ/SERIALIZABLE. The resulting error message tells the caller to "run it at READ COMMITTED" even when they already are, which is bad guidance. The mapping itself is reasonable; the prose just needs to stop attributing deadlocks to isolation level. -
Canonical plugin-storage docs are now out of date.
skills/creating-plugins/references/storage.mdstill lists the oldStorageCollectionAPI and has noupdateIfsection. Since the PR adds a public plugin-facing method, the canonical reference should describe the guard/set/deltasemantics and show the updated interface.
Findings
-
[needs fixing]
packages/core/tests/integration/plugins/storage-updateif.test.ts:185This inline comment ends with
(#reviewer flag), which is addressed to the reviewer/review thread. AGENTS.md forbids comments that reference PRs, issues, or review threads in source — they become stale the moment the change merges; that context belongs in the commit message or PR description, not in a test.// `{ stock: undefined }` has a key but no DEFINED entry — presence is // derived from defined entries, so this hits the "at least one" guard // instead of doing a no-op write that bumps `updated_at`. -
[needs fixing]
packages/core/src/database/repositories/plugin-storage.ts:49-52This docstring asserts that
40001/40P01aborts happen "under an isolation level stricter than READ COMMITTED". That is incorrect for40P01(deadlock_detected): deadlocks can occur underREAD COMMITTEDas well. The same inaccurate narrative is repeated in theStorageSerializationErrordocstring inpackages/core/src/plugins/storage-query.ts.Update the comment to describe the SQLSTATEs without falsely tying
40P01to strict isolation./** * SQLSTATEs a losing concurrent `updateIf` writer may abort with: * `40001` (serialization_failure) and `40P01` (deadlock_detected). * The `{ applied: false }` contract assumes READ COMMITTED; these aborts * are surfaced as `StorageSerializationError` so callers can retry. */Also update the
mapSerializationFailuredocstring immediately below to remove the cross-reference to that false claim. -
[needs fixing]
packages/core/src/database/repositories/plugin-storage.ts:88-93This error message tells users that the abort happened "under REPEATABLE READ / SERIALIZABLE" and advises them to "run it at READ COMMITTED". Because the code maps
40P01(deadlock) to this same error, a deadlock underREAD COMMITTEDwould receive advice that does not apply. Drop the false isolation-level attribution and simply advise a retry.`updateIf lost a concurrent race (SQLSTATE ${sqlState}). Under ` + `READ COMMITTED (the default) a losing writer normally resolves to ` + `{ applied: false }, but a serialization failure or deadlock can still ` + `abort. Retry the call. The no-oversell safety invariant still holds ` + `— a losing writer never applies.`, -
[needs fixing]
skills/creating-plugins/references/storage.md:147-156The PR adds
ctx.storage.<collection>.updateIf(...)as a public plugin API, but the canonical storage reference still shows the oldStorageCollectioninterface withoutupdateIfand has no section explaining predicate-guarded updates. The changeset README says useful examples/explanations should not live only in the changeset or PR description.Update the interface block and add a
### Conditional Updatessection before### Full APIcovering thewhereguard,set, integerdelta, return shape, andStorageSerializationError.interface StorageCollection<T = unknown> { get(id: string): Promise<T | null>; put(id: string, data: T): Promise<void>; delete(id: string): Promise<boolean>; exists(id: string): Promise<boolean>; getMany(ids: string[]): Promise<Map<string, T>>; putMany(items: Array<{ id: string; data: T }>): Promise<void>; deleteMany(ids: string[]): Promise<number>; query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>; count(where?: WhereClause): Promise<number>; updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>; }
… review-thread comment Addresses the automated review on emdash-cms#2169. The 40001/40P01 docstrings and the StorageSerializationError message attributed both SQLSTATEs to an isolation level stricter than READ COMMITTED. That holds for 40001 but not for 40P01: a deadlock needs only two transactions taking row locks in opposite order, which is reachable at READ COMMITTED. The message told such a caller to "run it at READ COMMITTED" when they already were. The message is now SQLSTATE-aware and names the remedy that applies to each. Documents updateIf in the canonical plugin storage reference, which still described the StorageCollection interface without it, and syncs the template copies via scripts/sync-template-skills.sh. Removes a comment in storage-updateif.test.ts addressed to the review thread rather than to a future reader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NK2NYBPGxwwzgHf7KfQRGM
|
@vedanshujain, would you be open to me contributing to this PR? I've prepared and tested additional changes from my earlier combined implementation against your
These preserve your existing return shape and add no revision migration. The changes have been tested on SQLite, PostgreSQL and D1, including the sandbox paths. If this approach works for you, I'd like to contribute these fixes here and help get your PR ready to merge first. After that, I'd welcome your contribution to my revision based writes PR so we can finish that part together. Let me know how you'd prefer to receive the commits. |
buildCondition pushes only the bounds that are defined, so a filter whose
every bound is undefined returned an empty SQL string. buildWhereClause
joined that empty string into the clause, which dropped the predicate
entirely.
The reachable shape is a bound built from an optional value:
const since = options.since; // string | undefined
await storage.query({ where: { timestamp: { gte: since } } });
That type-checks, because each bound on RangeFilter is optional. query()
and count() then returned every row in the collection, and updateIf()
applied its write with no guard — a guarded decrement could drive a
counter past the bound the caller asked for, which is the oversell
updateIf exists to prevent. A second field alongside the empty one
emitted a dangling AND and failed to parse.
buildCondition now throws StorageQueryError naming the field, and
buildWhereClause skips any condition contributing no SQL.
Callers that mean "match unconditionally" omit the field instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK2NYBPGxwwzgHf7KfQRGM
|
@logelog sounds good to me, please do. I added the access to the fork so you can push commit. I was already working on vedanshujain#9 before I saw your message, it addresses the argument validation but rest three are still open. |
fix(plugins): reject a storage range filter with no defined bound
|
I am marking the PR as draft again so we can get it polished before asking maintainer's time for review. Once @logelog adds their commit, we will mark this ready for review and merge again |
|
Thanks @vedanshujain. I've pushed three commits directly to this branch and merged the latest main. Your fix for range filters with no defined bound is retained. The additions cover:
The shared filter precedence and return shape are preserved, with no migration added. Local checks passed on SQLite, PostgreSQL and D1, including the sandbox paths; build, typecheck and lint also passed. Please take a look at the additions. Once you're happy with them and CI passes, we can mark this ready for review and move on to the revision work together. |
|
Looks great, thanks so much! I am marking this for review. |
There was a problem hiding this comment.
This remains the right approach to the right problem: a single-statement UPDATE … WHERE <guard> RETURNING is the idiomatic way to add predicate-guarded atomic writes to plugin storage. The follow-up diff addresses all three findings from the previous review and adds thorough sandbox-transport handling plus the undefined-bound range-filter hardening.
What I checked: the full diff, the changed core/cloudflare/workerd source files, validate.ts, package.json exports, the new integration/workerd tests, and the updated canonical docs & changesets. I did not run the test suite, linter, or builds (no shell).
Headline: the prior review comments are resolved.
- The
#reviewer flagtest comment has been removed. - The
StorageSerializationErrordocstrings and message no longer falsely tie40P01(deadlock) to strict isolation levels; the prose now correctly notes that deadlocks can occur at READ COMMITTED too. - Canonical plugin-storage docs (
skills/creating-plugins/references/storage.mdand all template copies) now include theupdateIfinterface and a detailed “Conditional Updates” section coveringwhere,set, integerdelta, return shape, and retry semantics.
New areas covered in this revision also look solid:
- The range-filter hardening (
buildConditionthrows when every bound isundefined;parseStorageUpdate/copyStorageWherereject the same) prevents a guard built from an optional value from silently widening to “match everything.” - Both Cloudflare RPC and Workerd HTTP sandbox transports serialize
StorageSerializationErroras{__emdashStorageError: …}/{error: {…}}and reconstruct a retryable error on the plugin side, with private SQL/cause stripped. - Field names are validated before JSON-path interpolation; values are bound parameters;
json_set/jsonb_setare composed correctly for both dialects; integer-safety guards are applied in-SQL. - Tests cover no-oversell, guard operators, malformed inputs, sandbox serialization, and Workerd wrapper marshaling.
I did not find a new correctness issue, SQL-injection surface, or AGENTS.md convention violation. Empty findings.
What does this PR do?
Adds
ctx.storage.<collection>.updateIf(id, { where, set?, delta? })— a predicate-guarded atomic update for plugin storage, and the no-oversell primitive. Today plugin storage offers only unconditionalput(whole-doc upsert), so "sell one iff in stock" is impossible in-process without a racy read-modify-write.updateIfruns the guard and the arithmetic in one statement:No read-then-write, no interactive transaction — so N concurrent guarded decrements serialize correctly (exactly M of N apply, final stock 0, never oversell).
wherereuses the numeric-correctWhereClausetranslation fromquery()(landed upstream in fix(core): plugin storage where-filters fail on Postgres with boolean = integer (#920) #1898 + the numeric fix), so a multi-digit guard likestock >= 10compares numerically on Postgres.setwrites wholesale field values;deltaapplies integerinc/decin-SQL overCOALESCE(base, 0)(a delta on a missing/null field starts from 0). Integer-only deltas enforced at runtime; a field may not appear in bothsetanddelta; at least one is required.set/deltaare separate args so{inc:n}is never mistaken for a value.{ applied: true, data }or{ applied: false }(row absent or guard failed — intentionally indistinguishable). Never inserts.Backed by
pluginDataWriteExpr(dialect-correctjson_set/jsonb_set, values bound as params). No new column, no migration.Isolation: the
{ applied: false }contract assumes READ COMMITTED (Postgres' default), where the losing concurrent writers cleanly resolve to{ applied: false }via EvalPlanQual. Under REPEATABLE READ / SERIALIZABLE a loser aborts instead, soupdateIftraps SQLSTATE40001/40P01and throws a typedStorageSerializationError(withcause+sqlState) telling the caller to retry — rather than surfacing a bare driver error. The no-oversell safety invariant holds under every isolation level (a losing writer never applies); only the result shape degrades to a throw. SQLite/D1 serialize writes and never hit this path.Tests
storage-updateif.test.ts— guard pass/fail/missing, integer inc/dec + integer round-trip, float-delta rejection,COALESCE-from-0 on missing/null, wholesaleset,set+delta, both-fields / neither-provided rejection, guard operator coverage (equality, multi-digitgte,in,startsWith, non-indexed field), empty-inno-op.storage-no-oversell.test.ts— M-of-N concurrent guarded decrements (the acceptance bar; Postgres is the real race, SQLite proves SQL correctness).Both dialects; Postgres via
EMDASH_TEST_PG. Full core plugin suites green (795 tests), typecheck / lint / format clean.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change) — full core plugin suites, both dialectspnpm formathas been runAI-generated code disclosure