Skip to content

Commit 983177c

Browse files
committed
Document guarded storage updates and retry behavior
1 parent 9683b28 commit 983177c

12 files changed

Lines changed: 184 additions & 61 deletions

File tree

  • .changeset
  • docs/src/content/docs/plugins/creating-plugins
  • skills/creating-plugins/references
  • templates
    • blank/.agents/skills/creating-plugins/references
    • blog-cloudflare/.agents/skills/creating-plugins/references
    • blog/.agents/skills/creating-plugins/references
    • marketing-cloudflare/.agents/skills/creating-plugins/references
    • marketing/.agents/skills/creating-plugins/references
    • portfolio-cloudflare/.agents/skills/creating-plugins/references
    • portfolio/.agents/skills/creating-plugins/references
    • starter-cloudflare/.agents/skills/creating-plugins/references
    • starter/.agents/skills/creating-plugins/references
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
---
22
"emdash": minor
3+
"@emdash-cms/cloudflare": patch
4+
"@emdash-cms/sandbox-workerd": patch
35
---
46

5-
feat(plugins): add `ctx.storage.<collection>.updateIf(id, { where, set?, delta? })` — a predicate-guarded atomic update for plugin storage. The guard and the arithmetic run in a single `UPDATE … WHERE <guard> RETURNING` (no read-then-write), so N concurrent guarded decrements serialize correctly — the no-oversell primitive. `set` writes wholesale field values; `delta` applies integer `inc`/`dec` in-SQL over `COALESCE(base, 0)`. Returns `{ applied: true, data }` or `{ applied: false }` (row absent or guard failed — never inserts). Works on SQLite and Postgres via `json_set`/`jsonb_set` with the numeric-correct guard translation.
7+
Adds `ctx.storage.<collection>.updateIf(id, { where, set?, delta? })` for atomic conditional updates to existing plugin documents. Use `where` to check stored fields, `set` to replace field values, and `delta` to increment or decrement integer counters. The method returns `{ applied: true, data }` with the updated document, or `{ applied: false }` when the document is absent or the condition fails. It never inserts a document.
8+
9+
Malformed update arguments reject without writing. Deltas require safe integer operands and results; missing or `null` counters start at `0`. Invalid stored counters, overflow, and non-object documents return `{ applied: false }` without changing any fields.
10+
11+
Available to native plugins and sandboxed plugins on Cloudflare and Workerd, with SQLite, D1, and PostgreSQL support. PostgreSQL serialization failures and deadlocks expose `code: "STORAGE_SERIALIZATION_FAILURE"` and `retryable: true`, including across sandbox transports. Retry standalone calls with bounded backoff, or restart the entire explicit transaction.

docs/src/content/docs/plugins/creating-plugins/storage.mdx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ interface StorageCollection<T = unknown> {
8585
// Basic CRUD
8686
get(id: string): Promise<T | null>;
8787
put(id: string, data: T): Promise<void>;
88+
updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>;
8889
delete(id: string): Promise<boolean>;
8990
exists(id: string): Promise<boolean>;
9091

@@ -99,6 +100,52 @@ interface StorageCollection<T = unknown> {
99100
}
100101
```
101102

103+
## Conditional updates
104+
105+
Use `updateIf()` to change an existing document only when its stored fields match a condition. The database checks the condition and applies the changes in one atomic operation. This method is available to native plugins and sandboxed plugins on Cloudflare and Workerd.
106+
107+
Import its `NumericDelta`, `UpdateIfArgs`, and `UpdateIfResult` types with `import type` from `emdash` or `emdash/plugin`.
108+
109+
The following call approves a pending submission and increments its review count in the same operation:
110+
111+
```typescript
112+
const result = await ctx.storage.submissions.updateIf("sub_123", {
113+
where: { status: "pending" },
114+
set: { status: "approved" },
115+
delta: { reviewCount: { inc: 1 } },
116+
});
117+
118+
if (result.applied) {
119+
ctx.log.info("Submission approved", { submission: result.data });
120+
}
121+
```
122+
123+
A successful call returns `{ applied: true, data }` with the complete updated document. It returns `{ applied: false }` if the document is missing or the condition does not match. It never inserts a document.
124+
125+
The arguments have the following behavior:
126+
127+
- `where` is required and uses the same operators as [query filters](#where-clause-operators). An explicit `where: {}` adds no field conditions. Guard fields do not need declared query indexes because the update targets one document by ID.
128+
- A range filter needs at least one defined bound. Undefined bounds are ignored when another bound is defined. Numeric operands used by a guard must be finite.
129+
- `set` replaces each supplied top-level field value and leaves other fields unchanged. Values must be JSON-serializable.
130+
- `delta` applies exactly one `{ inc: number }` or `{ dec: number }` per field. Each operand must be a safe integer; negative operands are allowed.
131+
- A field cannot appear in both `set` and `delta`. Top-level `undefined` entries in either object are ignored. At least one defined field must remain.
132+
133+
Malformed update arguments reject the promise without changing the document. The arguments object, `set`, `delta`, and each delta operation must be plain objects.
134+
135+
### Integer counters
136+
137+
A delta starts a missing or `null` counter at `0`. Existing counters and their results must be integers between `Number.MIN_SAFE_INTEGER` and `Number.MAX_SAFE_INTEGER`. A string, boolean, object, array, fractional number, unsafe integer, or out-of-range result causes the entire update to return `{ applied: false }`. A stored document that is not a JSON object also returns `{ applied: false }`. No fields change in either case.
138+
139+
Deltas can produce negative values. To keep a counter nonnegative, pair a decrement of `n` with a `where` condition requiring that counter to be at least `n`.
140+
141+
### Retry serialization failures
142+
143+
PostgreSQL can reject concurrent writes with a serialization failure or deadlock. A deadlock can occur at any isolation level, including READ COMMITTED. In native plugins, these failures throw `StorageSerializationError` with `code: "STORAGE_SERIALIZATION_FAILURE"`, `retryable: true`, and an optional `sqlState` (`40001` or `40P01`). Import the error class from `emdash`.
144+
145+
Use bounded retries with backoff for a standalone call. If the call is inside an explicit transaction, restart the entire transaction, including its reads; retrying the write inside the aborted transaction cannot succeed. Handle `{ applied: false }` as an unapplied update rather than a serialization error.
146+
147+
Sandbox transports preserve the error name and retry metadata, but do not guarantee `instanceof StorageSerializationError`. Check `code` and `retryable` when handling errors across a sandbox boundary.
148+
102149
## Querying
103150

104151
`query()` returns paginated results filtered by indexed fields:

skills/creating-plugins/references/storage.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const pending = await ctx.storage.submissions!.count({ status: "pending" });
119119

120120
### Conditional Updates
121121

122-
`updateIf()` applies a write only when a guard matches. The guard and the arithmetic run in a single `UPDATE` statement, so concurrent guarded writes serialize correctly.
122+
`updateIf()` applies a write only when a guard matches. The guard and the arithmetic run in a single `UPDATE` statement, so concurrent guarded writes serialize correctly. It is available to native plugins and sandboxed plugins on Cloudflare and Workerd.
123123

124124
The following example claims a submission for processing, and succeeds for exactly one caller when several run at once:
125125

@@ -136,7 +136,7 @@ if (result.applied) {
136136
}
137137
```
138138

139-
`where` takes the same operators as `query()`. `set` writes whole field values. `delta` applies an integer `inc` or `dec` to a field, counting from `0` when that field is absent.
139+
`where` takes the same operators as `query()`. `set` writes JSON-serializable whole field values. `delta` applies a safe integer `inc` or `dec` to a field, counting from `0` when that field is absent or `null`.
140140

141141
The following example records an attempt while the submission is still open:
142142

@@ -152,11 +152,18 @@ Behavior to account for:
152152

153153
- The call returns `{ applied: true, data }` or `{ applied: false }`. An absent row and a failed guard both return `{ applied: false }`.
154154
- An absent row is never inserted.
155-
- `delta` accepts integers. A float throws `TypeError`.
156-
- Pass at least one of `set` or `delta`. A field cannot appear in both.
155+
- `where` is required. Explicit `where: {}` adds no field conditions. Guard fields do not require query indexes because the update targets a document ID.
156+
- Malformed update arguments reject without writing. The arguments object, `set`, `delta`, and each delta operation must be plain objects.
157+
- Each delta contains exactly one `inc` or `dec` with a safe integer operand. Negative operands are allowed. Fractions and unsafe integer operands reject the call.
158+
- Pass at least one defined field in `set` or `delta`. Top-level `undefined` entries are ignored. A field cannot appear in both.
159+
- Existing non-null counters and all results must be safe integers. Invalid types, fractions, unsafe integers, overflow, and non-object documents return `{ applied: false }` without changing any fields.
157160
- A `dec` drives a field negative when the guard does not cover it. Pair `dec: k` with a `gte: k` guard to keep the field at or above zero.
158-
- A range filter in the guard needs at least one defined bound. `{ stock: { gte: undefined } }` throws `StorageQueryError` rather than leaving the write unguarded.
159-
- A losing writer that aborts instead of returning `{ applied: false }` throws `StorageSerializationError`, carrying the Postgres SQLSTATE (`40001` or `40P01`). Retry the call.
161+
- A range filter in the guard needs at least one defined bound. `{ stock: { gte: undefined } }` rejects the call. Undefined bounds are ignored when another bound is defined. Numeric operands used by a guard must be finite.
162+
- In native plugins, PostgreSQL serialization failures and deadlocks throw `StorageSerializationError` with `code: "STORAGE_SERIALIZATION_FAILURE"`, `retryable: true`, and optional `sqlState` (`40001` or `40P01`). Deadlocks can occur at READ COMMITTED too. Use bounded retries with backoff; inside an explicit transaction, restart the entire transaction, including its reads.
163+
164+
Sandbox transports preserve the error name and retry metadata, but `instanceof StorageSerializationError` is not guaranteed there. Check `code` and `retryable` across sandbox boundaries.
165+
166+
Import `NumericDelta`, `UpdateIfArgs`, and `UpdateIfResult` as types from `emdash` or `emdash/plugin`. Native plugins can import the runtime `StorageSerializationError` class from `emdash`; `emdash/plugin` supports type imports only.
160167

161168
### Index Design
162169

templates/blank/.agents/skills/creating-plugins/references/storage.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const pending = await ctx.storage.submissions!.count({ status: "pending" });
119119

120120
### Conditional Updates
121121

122-
`updateIf()` applies a write only when a guard matches. The guard and the arithmetic run in a single `UPDATE` statement, so concurrent guarded writes serialize correctly.
122+
`updateIf()` applies a write only when a guard matches. The guard and the arithmetic run in a single `UPDATE` statement, so concurrent guarded writes serialize correctly. It is available to native plugins and sandboxed plugins on Cloudflare and Workerd.
123123

124124
The following example claims a submission for processing, and succeeds for exactly one caller when several run at once:
125125

@@ -136,7 +136,7 @@ if (result.applied) {
136136
}
137137
```
138138

139-
`where` takes the same operators as `query()`. `set` writes whole field values. `delta` applies an integer `inc` or `dec` to a field, counting from `0` when that field is absent.
139+
`where` takes the same operators as `query()`. `set` writes JSON-serializable whole field values. `delta` applies a safe integer `inc` or `dec` to a field, counting from `0` when that field is absent or `null`.
140140

141141
The following example records an attempt while the submission is still open:
142142

@@ -152,11 +152,18 @@ Behavior to account for:
152152

153153
- The call returns `{ applied: true, data }` or `{ applied: false }`. An absent row and a failed guard both return `{ applied: false }`.
154154
- An absent row is never inserted.
155-
- `delta` accepts integers. A float throws `TypeError`.
156-
- Pass at least one of `set` or `delta`. A field cannot appear in both.
155+
- `where` is required. Explicit `where: {}` adds no field conditions. Guard fields do not require query indexes because the update targets a document ID.
156+
- Malformed update arguments reject without writing. The arguments object, `set`, `delta`, and each delta operation must be plain objects.
157+
- Each delta contains exactly one `inc` or `dec` with a safe integer operand. Negative operands are allowed. Fractions and unsafe integer operands reject the call.
158+
- Pass at least one defined field in `set` or `delta`. Top-level `undefined` entries are ignored. A field cannot appear in both.
159+
- Existing non-null counters and all results must be safe integers. Invalid types, fractions, unsafe integers, overflow, and non-object documents return `{ applied: false }` without changing any fields.
157160
- A `dec` drives a field negative when the guard does not cover it. Pair `dec: k` with a `gte: k` guard to keep the field at or above zero.
158-
- A range filter in the guard needs at least one defined bound. `{ stock: { gte: undefined } }` throws `StorageQueryError` rather than leaving the write unguarded.
159-
- A losing writer that aborts instead of returning `{ applied: false }` throws `StorageSerializationError`, carrying the Postgres SQLSTATE (`40001` or `40P01`). Retry the call.
161+
- A range filter in the guard needs at least one defined bound. `{ stock: { gte: undefined } }` rejects the call. Undefined bounds are ignored when another bound is defined. Numeric operands used by a guard must be finite.
162+
- In native plugins, PostgreSQL serialization failures and deadlocks throw `StorageSerializationError` with `code: "STORAGE_SERIALIZATION_FAILURE"`, `retryable: true`, and optional `sqlState` (`40001` or `40P01`). Deadlocks can occur at READ COMMITTED too. Use bounded retries with backoff; inside an explicit transaction, restart the entire transaction, including its reads.
163+
164+
Sandbox transports preserve the error name and retry metadata, but `instanceof StorageSerializationError` is not guaranteed there. Check `code` and `retryable` across sandbox boundaries.
165+
166+
Import `NumericDelta`, `UpdateIfArgs`, and `UpdateIfResult` as types from `emdash` or `emdash/plugin`. Native plugins can import the runtime `StorageSerializationError` class from `emdash`; `emdash/plugin` supports type imports only.
160167

161168
### Index Design
162169

templates/blog-cloudflare/.agents/skills/creating-plugins/references/storage.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const pending = await ctx.storage.submissions!.count({ status: "pending" });
119119

120120
### Conditional Updates
121121

122-
`updateIf()` applies a write only when a guard matches. The guard and the arithmetic run in a single `UPDATE` statement, so concurrent guarded writes serialize correctly.
122+
`updateIf()` applies a write only when a guard matches. The guard and the arithmetic run in a single `UPDATE` statement, so concurrent guarded writes serialize correctly. It is available to native plugins and sandboxed plugins on Cloudflare and Workerd.
123123

124124
The following example claims a submission for processing, and succeeds for exactly one caller when several run at once:
125125

@@ -136,7 +136,7 @@ if (result.applied) {
136136
}
137137
```
138138

139-
`where` takes the same operators as `query()`. `set` writes whole field values. `delta` applies an integer `inc` or `dec` to a field, counting from `0` when that field is absent.
139+
`where` takes the same operators as `query()`. `set` writes JSON-serializable whole field values. `delta` applies a safe integer `inc` or `dec` to a field, counting from `0` when that field is absent or `null`.
140140

141141
The following example records an attempt while the submission is still open:
142142

@@ -152,11 +152,18 @@ Behavior to account for:
152152

153153
- The call returns `{ applied: true, data }` or `{ applied: false }`. An absent row and a failed guard both return `{ applied: false }`.
154154
- An absent row is never inserted.
155-
- `delta` accepts integers. A float throws `TypeError`.
156-
- Pass at least one of `set` or `delta`. A field cannot appear in both.
155+
- `where` is required. Explicit `where: {}` adds no field conditions. Guard fields do not require query indexes because the update targets a document ID.
156+
- Malformed update arguments reject without writing. The arguments object, `set`, `delta`, and each delta operation must be plain objects.
157+
- Each delta contains exactly one `inc` or `dec` with a safe integer operand. Negative operands are allowed. Fractions and unsafe integer operands reject the call.
158+
- Pass at least one defined field in `set` or `delta`. Top-level `undefined` entries are ignored. A field cannot appear in both.
159+
- Existing non-null counters and all results must be safe integers. Invalid types, fractions, unsafe integers, overflow, and non-object documents return `{ applied: false }` without changing any fields.
157160
- A `dec` drives a field negative when the guard does not cover it. Pair `dec: k` with a `gte: k` guard to keep the field at or above zero.
158-
- A range filter in the guard needs at least one defined bound. `{ stock: { gte: undefined } }` throws `StorageQueryError` rather than leaving the write unguarded.
159-
- A losing writer that aborts instead of returning `{ applied: false }` throws `StorageSerializationError`, carrying the Postgres SQLSTATE (`40001` or `40P01`). Retry the call.
161+
- A range filter in the guard needs at least one defined bound. `{ stock: { gte: undefined } }` rejects the call. Undefined bounds are ignored when another bound is defined. Numeric operands used by a guard must be finite.
162+
- In native plugins, PostgreSQL serialization failures and deadlocks throw `StorageSerializationError` with `code: "STORAGE_SERIALIZATION_FAILURE"`, `retryable: true`, and optional `sqlState` (`40001` or `40P01`). Deadlocks can occur at READ COMMITTED too. Use bounded retries with backoff; inside an explicit transaction, restart the entire transaction, including its reads.
163+
164+
Sandbox transports preserve the error name and retry metadata, but `instanceof StorageSerializationError` is not guaranteed there. Check `code` and `retryable` across sandbox boundaries.
165+
166+
Import `NumericDelta`, `UpdateIfArgs`, and `UpdateIfResult` as types from `emdash` or `emdash/plugin`. Native plugins can import the runtime `StorageSerializationError` class from `emdash`; `emdash/plugin` supports type imports only.
160167

161168
### Index Design
162169

0 commit comments

Comments
 (0)