Skip to content

feat(plugins): atomic multi-document storage batch (ctx.storage.batch) - #3

Open
vedanshujain wants to merge 1 commit into
feat/plugin-storage-conditional-writesfrom
feat/plugin-storage-atomic-batch
Open

feat(plugins): atomic multi-document storage batch (ctx.storage.batch)#3
vedanshujain wants to merge 1 commit into
feat/plugin-storage-conditional-writesfrom
feat/plugin-storage-atomic-batch

Conversation

@vedanshujain

Copy link
Copy Markdown
Owner

Summary

Adds ctx.storage.batch([...ops]) — an atomic, all-or-nothing batch of conditional writes (insert / updateIf, reusing the where/set/delta shapes 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.

Stacked on #2 (insert/updateIf) → #1 (Postgres fix). Review those first; this PR's diff is the batch feature only.

type BatchOp =
  | { op: "insert";  collection: string; id: string; data: T; ifNotExists?: boolean }
  | { op: "updateIf"; collection: string; id: string; where: WhereClause; set?: Partial<T>; delta?: {...} };

ctx.storage.batch(ops: BatchOp[]): Promise<
  | { applied: true;  results: BatchOpResult[] }                                  // every guard passed
  | { applied: false; failedIndex: number; reason: "guard_failed" | "exists" | "unique_violation"; conflictField?: string }>;

Commits iff every op's guard passes (each updateIf matches a row; each insert inserts, or is a satisfied ifNotExists no-op). Any guard failure rolls back the whole batch and reports which op failed and why. Malformed ops (float delta, a field in both set and delta, unknown op, empty array, updateIf with neither set nor delta, insert without data, over-limit batch) throw; guard/uniqueness outcomes are reported, never thrown.

Atomicity per dialect

  • Postgres & better-sqlite3 (SQLite): one real 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's insert/updateIf statement builders — factored out, behavior-preserving); if any op doesn't apply, a private BatchAbort unwinds the transaction → nothing commits. conflictField is recovered on a fresh connection after rollback (never the poisoned tx).
  • Cloudflare D1 (no interactive transactions): the raw env.DB.batch() is atomic-on-error only, and a guarded UPDATE affecting 0 rows does not error. So after each guarded write we interleave an assertion SELECT CASE WHEN changes()=0 THEN abs(-9223372036854775808) ELSE 1 END — a 0-row guard raises SQLITE_ERROR (integer overflow; no JSON1 dependency), rolling back the whole batch. Statements are compiled against a SQLite-dialect Kysely (never leaks Postgres jsonb to D1). On the failure path a read-only, parameterized diagnosis pass reports failedIndex/reason best-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 one env.DB.batch(), the assertion raises and rolls the batch back, and RETURNING surfaces 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; ifNotExists no-op still commits siblings; cross-collection + cross-op-type; empty in:[]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).
  • workerd bridge: storage/batch round-trip, guard-fail rollback, undeclared collection in ANY op rejected without committing op 0 (anti-smuggling), per-plugin scoping.
  • Cloudflare real-D1 (@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

  • Additive & backwards-compatible. get/put/delete/query/count/insert/updateIf are unchanged; batch is 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.
  • No migration — _plugin_storage already keys all collections by (plugin_id, collection, id).
  • Changesets: emdash, @emdash-cms/cloudflare, @emdash-cms/sandbox-workerd — all minor.
  • Inherits the numeric-guard sequential-scan caveat on Postgres from fix(plugins): correct plugin-storage query/count/index/order on Postgres #1.
  • Deferred follow-ups remain: version/optimistic-concurrency column; getWithMeta.

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.
@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 added the review/needs-review No maintainer or bot review yet label Jul 21, 2026
@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
@github-actions github-actions Bot added stale and removed stale labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant