Skip to content

feat(plugins): predicate-guarded atomic updateIf for plugin storage (no-oversell)#2169

Draft
vedanshujain wants to merge 2 commits into
emdash-cms:mainfrom
vedanshujain:feat/plugin-storage-updateif
Draft

feat(plugins): predicate-guarded atomic updateIf for plugin storage (no-oversell)#2169
vedanshujain wants to merge 2 commits into
emdash-cms:mainfrom
vedanshujain:feat/plugin-storage-updateif

Conversation

@vedanshujain

Copy link
Copy Markdown
Contributor

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 unconditional put (whole-doc upsert), so "sell one iff in stock" is impossible in-process without a racy read-modify-write.

updateIf runs the guard and the arithmetic in one statement:

UPDATE _plugin_storage
SET data = <json_set/jsonb_set expr>, updated_at = ?
WHERE plugin_id = ? AND collection = ? AND id = ? AND <guard>
RETURNING data

No read-then-write, no interactive transaction — so N concurrent guarded decrements serialize correctly (exactly M of N apply, final stock 0, never oversell).

  • where reuses the numeric-correct WhereClause translation from query() (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 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 appear in both set and delta; at least one is required. set/delta are separate args so {inc:n} is never mistaken for a value.
  • Returns { applied: true, data } or { applied: false } (row absent or guard failed — intentionally indistinguishable). Never inserts.

Backed by pluginDataWriteExpr (dialect-correct json_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, so updateIf traps SQLSTATE 40001/40P01 and throws a typed StorageSerializationError (with cause + 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, wholesale set, set+delta, both-fields / neither-provided rejection, guard operator coverage (equality, multi-digit gte, in, startsWith, non-indexed field), empty-in no-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

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change) — full core plugin suites, both dialects
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — N/A
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — see Plugin storage has no atomic primitive - am I missing something? #632 (approval pending)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code)

vedanshujain and others added 2 commits July 21, 2026 10:12
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
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bd80510

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions

Copy link
Copy Markdown
Contributor

PR template validation failed

Please fix the following issues by editing your PR description:

See CONTRIBUTING.md for the full contribution policy.

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@pkg-pr-new

pkg-pr-new Bot commented Jul 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2169

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2169

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2169

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2169

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2169

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2169

emdash

npm i https://pkg.pr.new/emdash@2169

create-emdash

npm i https://pkg.pr.new/create-emdash@2169

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2169

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2169

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2169

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2169

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2169

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2169

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2169

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2169

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2169

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2169

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2169

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2169

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2169

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2169

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2169

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2169

commit: bd80510

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant