feat(plugins): atomic multi-document storage batch (ctx.storage.batch) - #3
Open
vedanshujain wants to merge 1 commit into
Open
Conversation
Adds ctx.storage.batch([...ops]) — an all-or-nothing batch of conditional
writes (insert / updateIf) across multiple documents and collections. Commits
iff every op's guard passes; any guard failure rolls back the whole batch and
reports { applied:false, failedIndex, reason, conflictField? }.
- Core: factor buildInsertQuery / buildUpdateIfQuery / normalizeDeltaEntries out
of insert/updateIf (behaviour-preserving); lift recoverConflictField to a free
function; add applyPluginStorageBatch (pg/sqlite real transaction) and
applyPluginStorageBatchD1 (raw env.DB.batch with interleaved zero-rows guard
assertions + failure-path diagnosis). Reserve "batch" as a collection name.
- StorageAccess intersection type + PluginContext.storage; ctx.storage.batch
wired in createStorageAccess / createPluginStorageAccessor.
- workerd + cloudflare bridges: storage/batch dispatch with per-op declared
collection validation (anti-smuggling); identical BatchResult shape.
- Tests: core contract suite (both dialects) incl. coupled no-oversell on
Postgres; workerd bridge round-trip; real-D1 suite via vitest-pool-workers.
vedanshujain
force-pushed
the
feat/plugin-storage-conditional-writes
branch
from
July 19, 2026 13:09
dfd52d0 to
fbbb854
Compare
vedanshujain
force-pushed
the
feat/plugin-storage-atomic-batch
branch
from
July 19, 2026 13:09
2a1f8de to
ff80dff
Compare
This was referenced Jul 21, 2026
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. |
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
ctx.storage.batch([...ops])— an atomic, all-or-nothing batch of conditional writes (insert/updateIf, reusing thewhere/set/deltashapes from #2) across multiple documents and multiple collections. This is the deferred "multi-row applyBatch / D1 batch()" follow-up named in #2, and the final primitive needed to give a sandboxed/in-process plugin the equivalent of a short one-connection transaction — so a commerce plugin can couple (guarded inventory decrement ∧ reservation state-flip) into one all-or-nothing unit.Commits iff every op's guard passes (each
updateIfmatches a row; eachinsertinserts, or is a satisfiedifNotExistsno-op). Any guard failure rolls back the whole batch and reports which op failed and why. Malformed ops (float delta, a field in bothsetanddelta, unknown op, empty array,updateIfwith neithersetnordelta,insertwithoutdata, over-limit batch) throw; guard/uniqueness outcomes are reported, never thrown.Atomicity per dialect
db.transaction(). Each op runs its single guarded statement (reusing fix(plugins): correct plugin-storage query/count/index/order on Postgres #1's numeric-correct total guard and feat(plugins): atomic conditional writes for plugin storage (insert / updateIf) #2'sinsert/updateIfstatement builders — factored out, behavior-preserving); if any op doesn't apply, a privateBatchAbortunwinds the transaction → nothing commits.conflictFieldis recovered on a fresh connection after rollback (never the poisoned tx).env.DB.batch()is atomic-on-error only, and a guardedUPDATEaffecting 0 rows does not error. So after each guarded write we interleave an assertionSELECT CASE WHEN changes()=0 THEN abs(-9223372036854775808) ELSE 1 END— a 0-row guard raisesSQLITE_ERROR(integer overflow; no JSON1 dependency), rolling back the whole batch. Statements are compiled against a SQLite-dialect Kysely (never leaks Postgresjsonbto D1). On the failure path a read-only, parameterized diagnosis pass reportsfailedIndex/reasonbest-effort (committed state is always correct; a concurrent-restock TOCTOU can only drift the report — callers re-resolve by durable state).The D1 mechanism was verified empirically on a real D1 binding (
@cloudflare/vitest-pool-workers) before implementation:changes()carries across statements within oneenv.DB.batch(), the assertion raises and rolls the batch back, andRETURNINGsurfaces through the batch results (one round-trip). (Verified on miniflare's local D1; a preview-edge re-run is recommended before an upstream ship.)Batch size capped at 50 ops (≈ ≤100 D1 statements, well within D1's per-batch and SQLite bound-variable limits; generous for the 2–3-op coupled-write use case).
Test evidence
storage-batch.test.ts(both dialects): coupled decrement∧flip both-commit; guard-fail rolls BOTH back (the atomicity proof); second-op-fail rolls back the first; claim∧decrement∧flip; idempotent replay (duplicate claim →failedIndex 0, reason:"exists", no double-decrement);unique_violation+conflictField;ifNotExistsno-op still commits siblings; cross-collection + cross-op-type; emptyin:[]→guard_failed; malformed-throws vs guard-fails-reports;results[]order; the 50-op cap.storage-batch-no-oversell.test.ts: N concurrent coupled reserve-style batches at stock M → exactly M commit, on_hand 0, none held-without-decrement, on real Postgres (SQLite variant annotated as SQL-correctness-only).storage/batchround-trip, guard-fail rollback, undeclared collection in ANY op rejected without committing op 0 (anti-smuggling), per-plugin scoping.@cloudflare/vitest-pool-workers): guard-fail rolls back both coupled writes on real D1,changes()carryover, RETURNING-per-op, graceful concurrent diagnosis.Full suites green on both dialects + the D1 config; typecheck / lint / format clean. Reviewed independently by two engineers — both approve.
Notes
get/put/delete/query/count/insert/updateIfare unchanged;batchis a new method on the storage access object (StorageAccess)."batch"is now a reserved collection name (rejected at install) so a declared collection can't shadow the method._plugin_storagealready keys all collections by(plugin_id, collection, id).emdash,@emdash-cms/cloudflare,@emdash-cms/sandbox-workerd— all minor.getWithMeta.