Skip to content

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

Open
vedanshujain wants to merge 454 commits into
mainfrom
feat/plugin-storage-updateif
Open

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

Conversation

@vedanshujain

@vedanshujain vedanshujain commented Jul 21, 2026

Copy link
Copy Markdown
Owner

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) emdash-cms/emdash#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.

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? emdash-cms/emdash#632 (approval pending)

AI-generated code disclosure

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

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


8 out of 13 committers have signed the CLA.
✅ (ascorbic)[https://github.com/ascorbic]
✅ (ArmandPhilippot)[https://github.com/ArmandPhilippot]
✅ (khoinguyenpham04)[https://github.com/khoinguyenpham04]
✅ (ahliweb)[https://github.com/ahliweb]
✅ (scottbuscemi)[https://github.com/scottbuscemi]
✅ (Glacier-Luo)[https://github.com/Glacier-Luo]
✅ (DavidPivert)[https://github.com/DavidPivert]
✅ (gruntlord5)[https://github.com/gruntlord5]
@helio-cf
@cherenkov
@danielmlr
@MatsudaTsunenori
@hossein-webdev
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@github-actions

Copy link
Copy Markdown

Scope check

This PR changes 542 lines across 7 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.

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet overlap labels Jul 21, 2026
@github-actions

Copy link
Copy Markdown

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot added stale and removed stale labels Aug 4, 2026
@github-actions github-actions Bot added stale and removed stale labels Aug 19, 2026
emdashbot Bot and others added 15 commits August 24, 2026 15:10
…-cms#2556)

* fix(client): retry requests with a body after a token refresh

refreshInterceptor derived its retry from the request it had already
sent. A request body can only be read once, so every retried POST/PUT
threw "Cannot construct a Request with a Request object that has already
been used" -- which is what CLI users see on the first content
create/update after the stored access token expires.

Keep a clone taken before the first attempt and rebuild the retry from
that. GET requests never hit this, which is why the existing test (a
bodyless retry whose mock backend never reads the body) passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(client): trim the retry-clone comment to the non-obvious fact

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dash-cms#2629)

EmDash-Run: 972b3c37-b688-440b-99f0-ad53c2a0e87b

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
…d rebuild (emdash-cms#2612)

Migration 040 rebuilds `_emdash_content_bylines` on SQLite by staging a copy,
dropping the old table and renaming the copy. When the run stops between the
drop and the rename, a retry skips the rebuild: its guard reads
`PRAGMA foreign_key_list` on a table that no longer exists and treats the empty
result as "already rebuilt". The migration is then recorded as applied with
only `_emdash_content_bylines_new` present, every byline subquery fails, and
the site reports no entries although the rows are intact.

Add migration 071, which renames the staged copy back when the live table is
missing and re-creates the two indexes the drop removed. It is restart-safe,
leaves a healthy table untouched, and is a no-op on Postgres, whose 040 path
alters the table in place.
…cms#2598)

* fix(auth): preserve CMS user on comment submissions

* test(comments): cover anonymous and session flows
* fix(admin): align disclosure section drag handles

* fix(admin): stabilize editor sidebar layout

* chore: consolidate sidebar alignment changeset
* fix(admin): make image actions reliably selectable

* chore: add changeset for reliable image actions
* i18n(ja): complete admin catalog translation

* i18n(ja): use plain-language CMS terms

* i18n(ja): clarify technical admin terminology

* chore: add Japanese translation changeset

* i18n(ja): refine admin terminology
…ms#2600)

* fix(core): keep playground welcome dismissed across reloads

* docs: remove issue 2595 technical spec
* docs: document media upload flows

* docs: clarify media upload reference

* Update docs/src/content/docs/reference/rest-api.mdx

Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>

---------

Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>
ascorbic and others added 30 commits September 11, 2026 07:35
* docs: keep contributor guidance maintainable

* docs: clarify architecture guide audience

* docs: restore contributor context

* docs: rebuild the EmDash front door
…3041)

isAvailable() answers only true or false, so a missing worker_loaders
binding, a missing PluginBridge export and a workerd binary that does not
run all produced the same startup warning and the same
SANDBOX_NOT_AVAILABLE refusal on install and update.

SandboxRunner gains an optional unavailableReason(). The Cloudflare and
workerd runners report their cause, and the startup warning and the four
refusals append it; runners without the method keep the previous text.
…sh-cms#2934)

* fix(portable-text): preserve table structure and identity

* fix(editor): make tables responsive and lossless

* feat(editor): add complete table controls and clipboard workflows

* test(editor): verify table workflows across browsers

* fix(editor): keep contextual table menus anchored

* test(admin): wait for loaded content in save conflict tests

* fix(core): preserve wide D1 entry loading

* test(admin): retain live social settings save locators

* test(admin): wait for autosave before clean unscheduling

* fix(core): allow localized inline table placeholders

* refactor(admin): remove spotlight toolbar control

* fix(editor): keep table resizing scroll-stable

* test(e2e): avoid range fragment layout flake

* test(e2e): scope table insertion announcement

* test(e2e): stabilize table deletion announcements

* fix(editor): soften table overflow cues

* ci: accept visual baselines (requested by @ascorbic)

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
… 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
…o content hooks (emdash-cms#2881) (emdash-cms#3057)

* fix(core): separate revision author from entry owner and pass actor to content hooks (emdash-cms#2881)

EmDash-Run: 806a2348-849c-4be2-9529-757229e5c2a7

* fix(core): preserve content actor attribution

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
…s#2351)

* fix(cloudflare): derive sandboxed plugins from Worker Loader

* fix(cloudflare): respect named Wrangler sandbox environments

* test: keep sandbox opt-in coverage accurate

* test(e2e): count rendered publication label lines

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
Co-authored-by: logelog <194732487+logelog@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
…onomies (emdash-cms#3062)

* feat: collapsible sidebar groups for collections and their taxonomies

Adds a nullable nav_group column on _emdash_collections, exposed as group
through the schema API, seed files, MCP tools and the admin manifest. The
admin sidebar folds collections sharing a group into one collapsible entry
at the first member's position; a taxonomy joins when every collection it
is assigned to shares the group. Folder state persists per browser and the
folder holding the active route opens on load. The content type editor
gains a Navigation block with the group field.

* fix: keep Content section rule and guard the nav_group rollback

* test: cover folder rendering, rail fallback and stored folder choices
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
* fix(admin): simplify theme toggle

* ci: accept visual baselines (requested by @ascorbic)

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
fix(plugins): reject a storage range filter with no defined bound
* fix marketplace update re-consent

* fix plugin update consent feedback
* fix: surface registry configuration errors

* fix: validate registry exclusion shape
…#3078)

* fix(registry): exempt proven first releases from holdback

* fix(admin): preserve registry policy evidence

* test(admin): cover release policy fallback
* feat(admin): show registry plugin public names

* fix(admin): normalize registry public-name handles

* docs(admin): clarify registry detail routing
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.