feat(plugins): atomic conditional writes for plugin storage (insert / updateIf) - #2
Open
vedanshujain wants to merge 1 commit into
Conversation
… updateIf)
Add two additive, single-statement primitives to the plugin
StorageCollection<T> API:
- insert(id, data): insert-once via INSERT … ON CONFLICT (pk) DO NOTHING.
A same-id collision returns { inserted:false, reason:"exists" }; a
declared unique-index collision on a non-id field is classified as
{ inserted:false, reason:"unique_violation", conflictField? } and any
other DB error is re-thrown.
- updateIf(id, { where, set?, delta? }): predicate-guarded atomic update
via one UPDATE … SET json_set/jsonb_set(…) WHERE <pk> AND <guard>
RETURNING data. The guard reuses the numeric-correct where translation
from the prior fix, and integer deltas are applied in-SQL with
COALESCE(base,0) ± n, so N concurrent guarded decrements cannot
oversell. set and delta are separate args; floats are rejected at
runtime; an empty in:[] guard short-circuits to applied:false.
Declared uniqueIndexes are now materialized as real unique indexes on
plugin install (fail-loud on error) and dropped on uninstall. The new
methods are delegated through the in-process context, the workerd bridge,
and the Cloudflare D1 bridge so all runners behave identically.
Proven no-oversell on real Postgres (and the SQLite/D1 dialect); both
dialects covered by the contract suite.
vedanshujain
force-pushed
the
fix/plugin-storage-postgres-jsonb
branch
from
July 19, 2026 13:09
d7d732d to
8b89c14
Compare
vedanshujain
force-pushed
the
feat/plugin-storage-conditional-writes
branch
from
July 19, 2026 13:09
dfd52d0 to
fbbb854
Compare
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. |
18 tasks
|
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 two additive, single-statement atomic primitives to the plugin
StorageCollection<T>API, and wires up the previously-unused declared-uniqueIndexescreation. This closes the "plugin storage has no atomic conditional write" gap (discussion emdash-cms#632): today plugin storage offers only an unconditionalputupsert, so an inventory decrement ("sell one iff in stock") is impossible in-process.Design — single-statement, D1-safe, no migration
insert=INSERT … ON CONFLICT (plugin_id, collection, id) DO NOTHING;insertedfrom the affected-row count. A same-id collision →{inserted:false, reason:"exists"}. A partial UNIQUE expression-index collision (not the PK conflict target) throws → caught and classified as{inserted:false, reason:"unique_violation", conflictField?}(PG23505+error.constraint; SQLiteSQLITE_CONSTRAINT_UNIQUE/message). Non-unique errors are re-thrown.updateIf= ONE guardedUPDATE … SET … WHERE <guard> RETURNING data. The guard lives entirely in theWHERE(reusing the existingquery()WhereClausetranslation) and the arithmetic is computed in SQL over the original row — no read-then-write, no interactive transaction, noSELECT … FOR UPDATE. That is what makes it correct on D1 (serialized writes, no interactive txn) and portable to Postgres (row-lock + predicate re-evaluation).appliedcomes fromRETURNING;set/deltaare separate args (no{inc:n}-as-value ambiguity); deltas useCOALESCE(base,0)±nso a delta on a missing field can never null thedatacolumn. Integer-only deltas enforced at runtime (Number.isInteger).createStorageIndexesis now called fromPluginManager.install()(fail-loud on error;CREATE UNIQUE INDEX IF NOT EXISTS, idempotent, partial-scoped per plugin/collection), andremoveAllPluginIndexesfromuninstall().storage/insert+storage/updateIfadded to thepackages/workerdbridge and thepackages/cloudflare(production D1) bridge + wrappers, each collection-validated, so native and sandboxed plugins both get the primitives.get/put/delete/query/countsemantics are unchanged.No-oversell test evidence (the acceptance bar)
storage-no-oversell.test.ts(dialect-parameterized): seed stock M=5, fire N=20 concurrentupdateIf({where:{stock:{gte:1}}, delta:{stock:{dec:1}}})viaPromise.all, assert exactly 5applied:true, 15applied:false, final stock 0.✓ [postgres] exactly M of N guarded decrements apply under real concurrent connections (the true no-oversell race)—applied=5, rejected=15, final=0.storage-conditional.test.tscovers insert new/replay/unique-collision; updateIf guard-pass/guard-fail/missing-row; integer inc/dec; float-delta rejection; wholesalesetmerge; combinedset+delta;set∩deltaand neither-provided rejection; guard over equality + multi-digit RangeFilter +in+startsWith+ non-indexed field + emptyin:[]; delta-on-missing COALESCE. Both dialects; typecheck / lint / format clean; full core + workerd suites green.Reviewed by two engineers independently — both approve.
Deferred follow-ups (out of scope, per the reporter's diagnosis in emdash-cms#632)
updateIf's predicate guard covers the inventory/idempotency cases without one.getWithMeta, multi-rowapplyBatch/ D1batch()— deferred.PluginManager.install()(the native/self-hosted path). The production D1 marketplace/registry install path runs throughEmDashRuntime(handleMarketplaceInstall→syncMarketplacePlugins) and does not go throughPluginManager, so declareduniqueIndexesare not yet materialized on that path — tracked as a follow-up (theupdateIf/no-oversell guarantee needs no index and is unaffected; onlyinsert's unique-field dedup depends on it).Credit to the emdash-cms#632 reporter for diagnosing that plugin storage's unconditional-upsert-only surface blocks atomic inventory decrements.