mppx 0.17.1 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/Store.d.ts +225 -0
- package/dist/Store.js +358 -0
- package/dist/cli/cli.js +95 -94
- package/dist/cli/plugins/index.js +12 -11
- package/dist/cli/sessions/Manager.js +12 -1
- package/dist/cli/utils.d.ts +2 -0
- package/dist/cli/utils.js +6 -0
- package/dist/client/Mppx.d.ts +1 -1
- package/dist/client/Mppx.js +7 -3
- package/dist/client/internal/Fetch.d.ts +1 -1
- package/dist/client/internal/Fetch.js +9 -0
- package/dist/client/internal/MethodResponse.d.ts +8 -0
- package/dist/client/internal/MethodResponse.js +9 -0
- package/dist/client/internal/ProofRealm.d.ts +3 -3
- package/dist/client/internal/ProofRealm.js +4 -4
- package/dist/client/internal/protocols/Mcp.d.ts +4 -0
- package/dist/client/internal/protocols/Mcp.js +27 -14
- package/dist/identity/Client.js +7 -1
- package/dist/internal/sse.d.ts +21 -0
- package/dist/internal/sse.js +65 -0
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/mcp/client/McpClient.d.ts +2 -2
- package/dist/mcp/client/McpClient.js +5 -2
- package/dist/proxy/Proxy.d.ts +2 -0
- package/dist/proxy/Proxy.js +31 -6
- package/dist/proxy/internal/Replay.js +1 -1
- package/dist/server/Mppx.js +5 -4
- package/dist/server/Transport.js +1 -1
- package/dist/server/internal/html/config.d.ts +7 -1
- package/dist/server/internal/html/config.js +4 -0
- package/dist/stripe/internal/types.d.ts +82 -6
- package/dist/stripe/server/Charge.js +1 -2
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +85 -10
- package/dist/tempo/internal/fee-payer.js +183 -19
- package/dist/tempo/legacy/session/Chain.js +2 -0
- package/dist/tempo/server/Charge.js +2 -2
- package/dist/tempo/server/Subscription.js +1 -0
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/transport.d.ts +6 -0
- package/dist/tempo/server/internal/transport.js +21 -21
- package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/client/CredentialState.js +2 -0
- package/dist/tempo/session/client/Session.d.ts +11 -1
- package/dist/tempo/session/client/Session.js +39 -6
- package/dist/tempo/session/client/SessionManager.d.ts +1 -1
- package/dist/tempo/session/client/SessionManager.js +8 -4
- package/dist/tempo/session/client/Transports.d.ts +2 -0
- package/dist/tempo/session/client/Transports.js +4 -1
- package/dist/tempo/session/precompile/Chain.js +1 -0
- package/dist/tempo/session/server/ChannelStore.d.ts +9 -0
- package/dist/tempo/session/server/ChannelStore.js +8 -1
- package/dist/tempo/session/server/MeteredStream.d.ts +4 -0
- package/dist/tempo/session/server/MeteredStream.js +6 -1
- package/dist/tempo/session/server/Sse.d.ts +59 -0
- package/dist/tempo/session/server/Sse.js +175 -10
- package/dist/zod.d.ts +2 -1
- package/dist/zod.js +1 -1
- package/package.json +1 -3
- package/dist/stripe/server/internal/html/types.d.ts +0 -2
- package/dist/stripe/server/internal/html/types.js +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# mppx
|
|
2
2
|
|
|
3
|
+
## 0.17.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f8852c3: Removed the `eventsource-parser` dependency. The MCP client protocol now reads the first event of a `text/event-stream` response with a small internal SSE parser.
|
|
8
|
+
|
|
9
|
+
## 0.17.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 247dc02: Rendered nested problem-details fields (such as `details`) as JSON in the CLI's Payment Rejected output instead of `[object Object]`.
|
|
14
|
+
- d87c8bb: Removed `@stripe/stripe-js` from `dependencies`. The Stripe HTML payment page bundles Stripe.js at build time, and the Stripe Elements option types in `stripe.charge`'s `html.elements` are now inlined, so the published types no longer import `@stripe/stripe-js`.
|
|
15
|
+
- a384733: Added `Store.durableObject()` and `Store.DurableObjectStore`, an atomic store backed by Cloudflare Durable Objects (one object per key, compare-and-swap with jittered retries), for running `tempo.session()` and replay protection on Cloudflare Workers.
|
|
16
|
+
- adaeef7: Fixed Tempo and Stripe server bundles always including their HTML payment page: the page now loads with a dynamic `import()` the first time it is rendered, and `html.content` also accepts an async loader.
|
|
17
|
+
- 2060c5f: Bound automatic session signatures and stored channel reuse to the authorized HTTP origin. Applied `trustedTempoProofRealms` to session challenges and forwarded manager pins. Added `session({ origin })` for direct credentials that will be reused over HTTP; originless stored channels must be explicitly migrated or closed rather than silently rebound. Use separate channel stores per HTTP origin because each payment scope retains a single channel.
|
|
18
|
+
|
|
19
|
+
Preserved request swap policies for automatic session top-ups and payment suppression through identity wrappers. Kept MCP adapter approval and payer selection authoritative when global payment fetch is installed. Limited proxy request bodies, including free routes, to 10 MiB by default with a configurable `maxRequestBodySize`.
|
|
20
|
+
|
|
21
|
+
Required explicit origin trust for remote snapshot recovery and rejected opaque origins. Preserved explicit CLI closing of legacy channels through validated origin migration. Returned payment challenges before buffering unpaid uploads and rejected oversized tee branches without waiting for sibling cancellation.
|
|
22
|
+
|
|
23
|
+
- 55a7b62: Credited prepaid SSE items that failed before delivery when their spend remained unsettled. Added an idempotent merchant reconciliation hook for declined or uncertain credits, preserving settled funds and delivered-item charges.
|
|
24
|
+
|
|
25
|
+
Automatic prepaid credits are opt-in with `sse: { autoCreditPrepaid: true, onRefundRequired }`; existing merchant refund handlers remain unchanged. Delivery obligations persist in channel state before a managed stream starts, with bounded records and idempotent credit retries. Refund jobs distinguish uncertain writes from definite failures and expose only finalized captured funds as externally refundable. HTTP close receipts accept spend reduced by credits while retaining authorization and scope validation.
|
|
26
|
+
|
|
27
|
+
- 7a18dac: Fixed sponsored preflight simulation billing the sender's fee token. Tempo nodes recover the fee payer of an `eth_call` only from `feePayerSignature` and ignore a bare `feePayer` address, so the final simulation charged the sender, and payments failed with "insufficient fee token balance" when the sponsor picked a fee token the sender did not hold. The simulation now carries the sponsor's signature: hosted sponsors return it from `eth_fillTransaction` (`fillHostedFeePayerTransaction` now returns `feePayerSignature`), and local fee-payer accounts sign a simulation-only copy with the new `signFeePayerSimulation`, only after the sender simulation passes. The sender simulation now runs at the transaction's exact gas limit with zero fee fields and the fields that set its intrinsic gas, so a transaction whose gas is too low is rejected before broadcast without needing a sender fee balance. The local simulation-only copy adds up to `sponsoredSimulationGasBuffer` (10,000) gas, because a sponsored `eth_call` charges more gas than the mined transaction uses, clamped to the fee payer policy's `maxGas` and `maxTotalFee` (`sponsoredSimulationGas`). When the sponsored simulation runs without the full buffer (hosted signatures, or clamped padding), a failure that is only out of gas no longer rejects the payment; fee balance errors and reverts still do.
|
|
28
|
+
- a4d7a8e: Added `Store.sql()`, an atomic store on SQL databases (single-statement compare-and-swap, no transactions needed, and replay claims in one statement), with `Store.postgres()` for `pg`, Neon, Vercel Postgres, Supabase and PGlite, `Store.sqlite()` for `node:sqlite`, `better-sqlite3` and `bun:sqlite`, and `Store.d1()` for Cloudflare D1.
|
|
29
|
+
- 8be5d55: Fixed esbuild bundles including all of `zod/mini` by importing it as a namespace in `mppx`'s zod helpers, so unused validators tree-shake.
|
|
30
|
+
|
|
3
31
|
## 0.17.1
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/dist/Store.d.ts
CHANGED
|
@@ -99,6 +99,231 @@ export declare namespace cloudflare {
|
|
|
99
99
|
update: NonNullable<Parameters['update']>;
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Atomic store backed by Cloudflare Durable Objects.
|
|
104
|
+
*
|
|
105
|
+
* Each key lives in its own Durable Object instance (`idFromName(key)`), so
|
|
106
|
+
* reads and writes of one key are linearizable across every Worker isolate and
|
|
107
|
+
* location, while different keys (such as different payment channels) scale
|
|
108
|
+
* out across instances. {@link AtomicStore.update} runs `fn` in the Worker and
|
|
109
|
+
* commits with a compare-and-swap in the object. When another writer changed
|
|
110
|
+
* the key in between, the swap returns the stored value and the update retries
|
|
111
|
+
* from it after a jittered backoff; {@link AtomicStore.tryClaim} runs in a single
|
|
112
|
+
* object call.
|
|
113
|
+
*
|
|
114
|
+
* Bind {@link DurableObjectStore} as a SQLite-backed Durable Object and pass
|
|
115
|
+
* its namespace.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* import { Store } from 'mppx/server'
|
|
120
|
+
*
|
|
121
|
+
* export class MppxStore extends Store.DurableObjectStore {}
|
|
122
|
+
*
|
|
123
|
+
* export default {
|
|
124
|
+
* fetch(request: Request, env: Env) {
|
|
125
|
+
* const store = Store.durableObject(env.MPPX_STORE)
|
|
126
|
+
* // ...
|
|
127
|
+
* },
|
|
128
|
+
* }
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export declare function durableObject(namespace: durableObject.Namespace, options?: durableObject.Options): AtomicStore;
|
|
132
|
+
export declare namespace durableObject {
|
|
133
|
+
type Options = {
|
|
134
|
+
/** Prefix prepended to every backing store key. */
|
|
135
|
+
keyPrefix?: string | undefined;
|
|
136
|
+
/** Compare-and-swap attempts before {@link AtomicStore.update} gives up. @default 32 */
|
|
137
|
+
maxAttempts?: number | undefined;
|
|
138
|
+
};
|
|
139
|
+
/** The Durable Object namespace binding of a {@link DurableObjectStore} class. */
|
|
140
|
+
type Namespace = {
|
|
141
|
+
idFromName: (name: string) => unknown;
|
|
142
|
+
get: (id: any) => {
|
|
143
|
+
fetch: (input: string, init: RequestInit) => Promise<Response>;
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Durable Object class that holds the values of {@link durableObject}.
|
|
149
|
+
*
|
|
150
|
+
* Export a subclass of it from your Worker and bind it in
|
|
151
|
+
* `wrangler.jsonc` with a `new_sqlite_classes` migration. It needs no
|
|
152
|
+
* `cloudflare:workers` import, so it bundles anywhere.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```jsonc
|
|
156
|
+
* {
|
|
157
|
+
* "durable_objects": { "bindings": [{ "name": "MPPX_STORE", "class_name": "MppxStore" }] },
|
|
158
|
+
* "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MppxStore"] }]
|
|
159
|
+
* }
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export declare class DurableObjectStore {
|
|
163
|
+
#private;
|
|
164
|
+
constructor(state: {
|
|
165
|
+
storage: DurableObjectStore.Storage;
|
|
166
|
+
}, _env?: unknown);
|
|
167
|
+
/** Handles one store operation. Each runs without awaiting other I/O between its read and write, so the object's input gate makes it atomic. */
|
|
168
|
+
fetch(request: Request): Promise<Response>;
|
|
169
|
+
}
|
|
170
|
+
export declare namespace DurableObjectStore {
|
|
171
|
+
/** The subset of `DurableObjectStorage` the store uses. */
|
|
172
|
+
type Storage = {
|
|
173
|
+
get: (key: string) => Promise<unknown>;
|
|
174
|
+
put: (key: string, value: string) => Promise<void>;
|
|
175
|
+
delete: (key: string) => Promise<unknown>;
|
|
176
|
+
};
|
|
177
|
+
/** Result of a compare-and-swap: on a conflict, the stored value, so a retry needs no read. */
|
|
178
|
+
type SwapResult = {
|
|
179
|
+
applied: true;
|
|
180
|
+
} | {
|
|
181
|
+
applied: false;
|
|
182
|
+
current: string | null;
|
|
183
|
+
};
|
|
184
|
+
/** An operation sent from {@link durableObject} to the object. */
|
|
185
|
+
type Request = {
|
|
186
|
+
op: 'get';
|
|
187
|
+
key: string;
|
|
188
|
+
} | {
|
|
189
|
+
op: 'put';
|
|
190
|
+
key: string;
|
|
191
|
+
value: string;
|
|
192
|
+
} | {
|
|
193
|
+
op: 'delete';
|
|
194
|
+
key: string;
|
|
195
|
+
} | {
|
|
196
|
+
op: 'swap';
|
|
197
|
+
key: string;
|
|
198
|
+
expected: string | null;
|
|
199
|
+
value: string | null;
|
|
200
|
+
} | {
|
|
201
|
+
op: 'claim';
|
|
202
|
+
key: string;
|
|
203
|
+
expires: number;
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Atomic store backed by a SQL database: Postgres, SQLite or Cloudflare D1.
|
|
208
|
+
*
|
|
209
|
+
* Values live as JSON text in one table (`mppx_store` by default, created on
|
|
210
|
+
* first use). Every write is a single statement, and {@link AtomicStore.update}
|
|
211
|
+
* commits with a compare-and-swap on the stored value
|
|
212
|
+
* (`UPDATE … WHERE key = ? AND value = ? RETURNING`, or
|
|
213
|
+
* `INSERT … ON CONFLICT DO NOTHING RETURNING` for a new key), retrying from the
|
|
214
|
+
* current value after a jittered backoff when another writer won. It needs no
|
|
215
|
+
* interactive transactions, so it works over HTTP drivers and D1, and only
|
|
216
|
+
* `ON CONFLICT` and `RETURNING` (Postgres, SQLite 3.35+, D1, libSQL).
|
|
217
|
+
*
|
|
218
|
+
* Use {@link postgres}, {@link sqlite} or {@link d1} for common drivers, or pass
|
|
219
|
+
* any driver as a `query` function.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* import postgres from 'postgres'
|
|
224
|
+
* import { Store } from 'mppx/server'
|
|
225
|
+
*
|
|
226
|
+
* const db = postgres(process.env.DATABASE_URL!)
|
|
227
|
+
* const store = Store.sql({ dialect: 'postgres', query: (text, params) => db.unsafe(text, params) })
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
export declare function sql(driver: sql.Driver, options?: sql.Options): AtomicStore;
|
|
231
|
+
export declare namespace sql {
|
|
232
|
+
type Options = {
|
|
233
|
+
/** Prefix prepended to every backing store key. */
|
|
234
|
+
keyPrefix?: string | undefined;
|
|
235
|
+
/** Compare-and-swap attempts before {@link AtomicStore.update} gives up. @default 32 */
|
|
236
|
+
maxAttempts?: number | undefined;
|
|
237
|
+
/** Table holding the values, optionally schema-qualified. @default 'mppx_store' */
|
|
238
|
+
table?: string | undefined;
|
|
239
|
+
/** Create the table on first use (`CREATE TABLE IF NOT EXISTS`). Set `false` when migrations create it. @default true */
|
|
240
|
+
createTable?: boolean | undefined;
|
|
241
|
+
};
|
|
242
|
+
/** A result row. */
|
|
243
|
+
type Row = Record<string, unknown>;
|
|
244
|
+
/** Runs one statement with positional parameters and returns its rows. */
|
|
245
|
+
type Driver = {
|
|
246
|
+
/** Placeholder style: Postgres numbers them (`$1`), SQLite and D1 use `?`. */
|
|
247
|
+
dialect: 'postgres' | 'sqlite';
|
|
248
|
+
query: (sql: string, params: unknown[]) => Promise<Row[]>;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Atomic SQL store on a Postgres client: `pg` (`Pool` or `Client`),
|
|
253
|
+
* `@neondatabase/serverless` (`Pool` or `neon()`), `@vercel/postgres`,
|
|
254
|
+
* Supabase's Postgres connection or PGlite. See {@link sql}.
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```ts
|
|
258
|
+
* import { Pool } from 'pg'
|
|
259
|
+
* import { Store } from 'mppx/server'
|
|
260
|
+
*
|
|
261
|
+
* const store = Store.postgres(new Pool({ connectionString: process.env.DATABASE_URL }))
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
export declare function postgres(client: postgres.Client, options?: sql.Options): AtomicStore;
|
|
265
|
+
export declare namespace postgres {
|
|
266
|
+
/** Any client with `query(text, values)` that resolves to `{ rows }` or to the rows. */
|
|
267
|
+
type Client = {
|
|
268
|
+
query: (text: string, values: unknown[]) => Promise<{
|
|
269
|
+
rows: sql.Row[];
|
|
270
|
+
} | sql.Row[]>;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Atomic SQL store on a SQLite database: `node:sqlite` (`DatabaseSync`),
|
|
275
|
+
* `better-sqlite3` or `bun:sqlite`. See {@link sql}. For libSQL or Turso, use
|
|
276
|
+
* {@link sql} with dialect `sqlite` and return the result set's rows:
|
|
277
|
+
* `Store.sql({ dialect: 'sqlite', query: async (sql, args) => (await client.execute({ sql, args: args as InArgs })).rows })`.
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```ts
|
|
281
|
+
* import { DatabaseSync } from 'node:sqlite'
|
|
282
|
+
* import { Store } from 'mppx/server'
|
|
283
|
+
*
|
|
284
|
+
* const store = Store.sqlite(new DatabaseSync('mppx.db'))
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
export declare function sqlite(database: sqlite.Database, options?: sql.Options): AtomicStore;
|
|
288
|
+
export declare namespace sqlite {
|
|
289
|
+
/** A database whose prepared statements run with `all(...params)`, or `run(...params)` when better-sqlite3 marks them as returning no rows. */
|
|
290
|
+
type Database = {
|
|
291
|
+
prepare: (sql: string) => {
|
|
292
|
+
all: (...params: any[]) => unknown;
|
|
293
|
+
run?: ((...params: any[]) => unknown) | undefined;
|
|
294
|
+
/** better-sqlite3: whether the statement returns rows. */
|
|
295
|
+
reader?: boolean | undefined;
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Atomic SQL store on a Cloudflare D1 database binding. See {@link sql}.
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```ts
|
|
304
|
+
* import { Store } from 'mppx/server'
|
|
305
|
+
*
|
|
306
|
+
* export default {
|
|
307
|
+
* fetch(request: Request, env: { DB: D1Database }) {
|
|
308
|
+
* const store = Store.d1(env.DB)
|
|
309
|
+
* // ...
|
|
310
|
+
* },
|
|
311
|
+
* }
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
export declare function d1(database: d1.Database, options?: sql.Options): AtomicStore;
|
|
315
|
+
export declare namespace d1 {
|
|
316
|
+
/** The subset of `D1Database` the store uses. */
|
|
317
|
+
type Database = {
|
|
318
|
+
prepare: (sql: string) => {
|
|
319
|
+
bind: (...values: any[]) => {
|
|
320
|
+
all: () => Promise<{
|
|
321
|
+
results: unknown[];
|
|
322
|
+
}>;
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
}
|
|
102
327
|
/** In-memory store backed by a `Map`. JSON-roundtrips values to match production behavior. */
|
|
103
328
|
export declare function memory(options?: memory.Options): AtomicStore;
|
|
104
329
|
export declare namespace memory {
|
package/dist/Store.js
CHANGED
|
@@ -52,6 +52,28 @@ function isReplayMarker(value) {
|
|
|
52
52
|
'type' in value &&
|
|
53
53
|
value.type === 'mppx:replay');
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Runs `fn` on the stored value and commits its change with `swap`, a
|
|
57
|
+
* compare-and-swap. When another writer won, retries from the value `swap`
|
|
58
|
+
* returned (or a fresh read) after a jittered backoff.
|
|
59
|
+
*/
|
|
60
|
+
async function compareAndSwap(parameters) {
|
|
61
|
+
const { fn, key, label, maxAttempts, read, swap } = parameters;
|
|
62
|
+
let raw = await read();
|
|
63
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
64
|
+
const change = fn(raw == null ? null : Json.parse(raw));
|
|
65
|
+
if (change.op === 'noop')
|
|
66
|
+
return change.result;
|
|
67
|
+
const swapped = await swap(raw, change.op === 'set' ? Json.stringify(change.value) : null);
|
|
68
|
+
if (swapped.applied)
|
|
69
|
+
return change.result;
|
|
70
|
+
if (attempt + 1 === maxAttempts)
|
|
71
|
+
break;
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * Math.min(2 ** (attempt + 1), 200)));
|
|
73
|
+
raw = swapped.current !== undefined ? swapped.current : await read();
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`${label} update of "${key}" conflicted ${maxAttempts} times.`);
|
|
76
|
+
}
|
|
55
77
|
const keyPrefixCache = new WeakMap();
|
|
56
78
|
export function from(store, options) {
|
|
57
79
|
return withKeyPrefix(store, options?.keyPrefix);
|
|
@@ -126,6 +148,342 @@ export function cloudflare(kv, options) {
|
|
|
126
148
|
...wrapJsonUpdate(kv.update),
|
|
127
149
|
}, options);
|
|
128
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Atomic store backed by Cloudflare Durable Objects.
|
|
153
|
+
*
|
|
154
|
+
* Each key lives in its own Durable Object instance (`idFromName(key)`), so
|
|
155
|
+
* reads and writes of one key are linearizable across every Worker isolate and
|
|
156
|
+
* location, while different keys (such as different payment channels) scale
|
|
157
|
+
* out across instances. {@link AtomicStore.update} runs `fn` in the Worker and
|
|
158
|
+
* commits with a compare-and-swap in the object. When another writer changed
|
|
159
|
+
* the key in between, the swap returns the stored value and the update retries
|
|
160
|
+
* from it after a jittered backoff; {@link AtomicStore.tryClaim} runs in a single
|
|
161
|
+
* object call.
|
|
162
|
+
*
|
|
163
|
+
* Bind {@link DurableObjectStore} as a SQLite-backed Durable Object and pass
|
|
164
|
+
* its namespace.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* import { Store } from 'mppx/server'
|
|
169
|
+
*
|
|
170
|
+
* export class MppxStore extends Store.DurableObjectStore {}
|
|
171
|
+
*
|
|
172
|
+
* export default {
|
|
173
|
+
* fetch(request: Request, env: Env) {
|
|
174
|
+
* const store = Store.durableObject(env.MPPX_STORE)
|
|
175
|
+
* // ...
|
|
176
|
+
* },
|
|
177
|
+
* }
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
export function durableObject(namespace, options) {
|
|
181
|
+
const maxAttempts = options?.maxAttempts ?? 32;
|
|
182
|
+
async function call(key, body) {
|
|
183
|
+
const stub = namespace.get(namespace.idFromName(key));
|
|
184
|
+
const response = await stub.fetch('https://mppx-store/', {
|
|
185
|
+
body: JSON.stringify(body),
|
|
186
|
+
headers: { 'content-type': 'application/json' },
|
|
187
|
+
method: 'POST',
|
|
188
|
+
});
|
|
189
|
+
if (!response.ok)
|
|
190
|
+
throw new Error(`Durable Object store request failed (${response.status}): ${await response.text()}`);
|
|
191
|
+
return (await response.json());
|
|
192
|
+
}
|
|
193
|
+
return from({
|
|
194
|
+
async get(key) {
|
|
195
|
+
const raw = await call(key, { op: 'get', key });
|
|
196
|
+
if (raw == null)
|
|
197
|
+
return null;
|
|
198
|
+
return Json.parse(raw);
|
|
199
|
+
},
|
|
200
|
+
async put(key, value) {
|
|
201
|
+
await call(key, { op: 'put', key, value: Json.stringify(value) });
|
|
202
|
+
},
|
|
203
|
+
async delete(key) {
|
|
204
|
+
await call(key, { op: 'delete', key });
|
|
205
|
+
},
|
|
206
|
+
update(key, fn) {
|
|
207
|
+
return compareAndSwap({
|
|
208
|
+
fn,
|
|
209
|
+
key,
|
|
210
|
+
label: 'Durable Object store',
|
|
211
|
+
maxAttempts,
|
|
212
|
+
read: () => call(key, { op: 'get', key }),
|
|
213
|
+
swap: (expected, value) => call(key, { op: 'swap', key, expected, value }),
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
async tryClaim(key, expires) {
|
|
217
|
+
if (!Number.isFinite(expires) || expires <= Date.now())
|
|
218
|
+
return false;
|
|
219
|
+
return call(key, { op: 'claim', key, expires });
|
|
220
|
+
},
|
|
221
|
+
}, options);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Durable Object class that holds the values of {@link durableObject}.
|
|
225
|
+
*
|
|
226
|
+
* Export a subclass of it from your Worker and bind it in
|
|
227
|
+
* `wrangler.jsonc` with a `new_sqlite_classes` migration. It needs no
|
|
228
|
+
* `cloudflare:workers` import, so it bundles anywhere.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```jsonc
|
|
232
|
+
* {
|
|
233
|
+
* "durable_objects": { "bindings": [{ "name": "MPPX_STORE", "class_name": "MppxStore" }] },
|
|
234
|
+
* "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MppxStore"] }]
|
|
235
|
+
* }
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
export class DurableObjectStore {
|
|
239
|
+
#storage;
|
|
240
|
+
constructor(state, _env) {
|
|
241
|
+
this.#storage = state.storage;
|
|
242
|
+
}
|
|
243
|
+
/** Handles one store operation. Each runs without awaiting other I/O between its read and write, so the object's input gate makes it atomic. */
|
|
244
|
+
async fetch(request) {
|
|
245
|
+
if (request.method !== 'POST')
|
|
246
|
+
return new Response('Method not allowed', { status: 405 });
|
|
247
|
+
const body = (await request.json());
|
|
248
|
+
const storage = this.#storage;
|
|
249
|
+
const read = async () => (await storage.get(body.key)) ?? null;
|
|
250
|
+
const write = async (value) => {
|
|
251
|
+
if (value === null)
|
|
252
|
+
await storage.delete(body.key);
|
|
253
|
+
else
|
|
254
|
+
await storage.put(body.key, value);
|
|
255
|
+
};
|
|
256
|
+
switch (body.op) {
|
|
257
|
+
case 'get':
|
|
258
|
+
return Response.json(await read());
|
|
259
|
+
case 'put':
|
|
260
|
+
await write(body.value);
|
|
261
|
+
return Response.json(true);
|
|
262
|
+
case 'delete':
|
|
263
|
+
await write(null);
|
|
264
|
+
return Response.json(true);
|
|
265
|
+
case 'swap': {
|
|
266
|
+
const current = await read();
|
|
267
|
+
if (current !== body.expected)
|
|
268
|
+
return Response.json({ applied: false, current });
|
|
269
|
+
await write(body.value);
|
|
270
|
+
return Response.json({ applied: true });
|
|
271
|
+
}
|
|
272
|
+
case 'claim': {
|
|
273
|
+
const { expires } = body;
|
|
274
|
+
if (!Number.isFinite(expires) || expires <= Date.now())
|
|
275
|
+
return Response.json(false);
|
|
276
|
+
const raw = await read();
|
|
277
|
+
const current = raw == null ? null : Json.parse(raw);
|
|
278
|
+
if (current !== null && (!isReplayMarker(current) || current.expires > Date.now()))
|
|
279
|
+
return Response.json(false);
|
|
280
|
+
await write(Json.stringify({ expires, type: 'mppx:replay' }));
|
|
281
|
+
return Response.json(true);
|
|
282
|
+
}
|
|
283
|
+
default:
|
|
284
|
+
return new Response('Unknown operation', { status: 400 });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Atomic store backed by a SQL database: Postgres, SQLite or Cloudflare D1.
|
|
290
|
+
*
|
|
291
|
+
* Values live as JSON text in one table (`mppx_store` by default, created on
|
|
292
|
+
* first use). Every write is a single statement, and {@link AtomicStore.update}
|
|
293
|
+
* commits with a compare-and-swap on the stored value
|
|
294
|
+
* (`UPDATE … WHERE key = ? AND value = ? RETURNING`, or
|
|
295
|
+
* `INSERT … ON CONFLICT DO NOTHING RETURNING` for a new key), retrying from the
|
|
296
|
+
* current value after a jittered backoff when another writer won. It needs no
|
|
297
|
+
* interactive transactions, so it works over HTTP drivers and D1, and only
|
|
298
|
+
* `ON CONFLICT` and `RETURNING` (Postgres, SQLite 3.35+, D1, libSQL).
|
|
299
|
+
*
|
|
300
|
+
* Use {@link postgres}, {@link sqlite} or {@link d1} for common drivers, or pass
|
|
301
|
+
* any driver as a `query` function.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```ts
|
|
305
|
+
* import postgres from 'postgres'
|
|
306
|
+
* import { Store } from 'mppx/server'
|
|
307
|
+
*
|
|
308
|
+
* const db = postgres(process.env.DATABASE_URL!)
|
|
309
|
+
* const store = Store.sql({ dialect: 'postgres', query: (text, params) => db.unsafe(text, params) })
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
export function sql(driver, options) {
|
|
313
|
+
const table = options?.table ?? 'mppx_store';
|
|
314
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(table))
|
|
315
|
+
throw new Error(`Invalid SQL store table name: "${table}".`);
|
|
316
|
+
const maxAttempts = options?.maxAttempts ?? 32;
|
|
317
|
+
// Statements are written with `?`; Postgres numbers its placeholders.
|
|
318
|
+
const prepare = (text) => {
|
|
319
|
+
if (driver.dialect !== 'postgres')
|
|
320
|
+
return text;
|
|
321
|
+
let index = 0;
|
|
322
|
+
return text.replace(/\?/g, () => `$${++index}`);
|
|
323
|
+
};
|
|
324
|
+
let ready;
|
|
325
|
+
const ensureTable = () => {
|
|
326
|
+
if (options?.createTable === false)
|
|
327
|
+
return;
|
|
328
|
+
ready ??= Promise.resolve(driver.query(
|
|
329
|
+
// `expires` mirrors a replay marker's expiry, so a claim needs no JSON in SQL.
|
|
330
|
+
`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires DOUBLE PRECISION)`, [])).then(() => { }, async (error) => {
|
|
331
|
+
// Concurrent `CREATE TABLE IF NOT EXISTS` from other processes can fail on
|
|
332
|
+
// Postgres (duplicate catalog row) although the table now exists: probe it.
|
|
333
|
+
const exists = await Promise.resolve(driver.query(`SELECT 1 FROM ${table} WHERE 1 = 0`, [])).then(() => true, () => false);
|
|
334
|
+
if (exists)
|
|
335
|
+
return;
|
|
336
|
+
ready = undefined;
|
|
337
|
+
throw error;
|
|
338
|
+
});
|
|
339
|
+
return ready;
|
|
340
|
+
};
|
|
341
|
+
async function query(text, params) {
|
|
342
|
+
await ensureTable();
|
|
343
|
+
return driver.query(prepare(text), params);
|
|
344
|
+
}
|
|
345
|
+
async function read(key) {
|
|
346
|
+
const [row] = await query(`SELECT value FROM ${table} WHERE key = ?`, [key]);
|
|
347
|
+
return row ? row.value : null;
|
|
348
|
+
}
|
|
349
|
+
/** The `expires` column for a serialized value: the expiry of a replay marker, else null. */
|
|
350
|
+
const expiresOf = (raw) => {
|
|
351
|
+
const value = Json.parse(raw);
|
|
352
|
+
return isReplayMarker(value) ? value.expires : null;
|
|
353
|
+
};
|
|
354
|
+
/** Writes `value` (or deletes, for `null`) only if the key still holds `expected`. */
|
|
355
|
+
async function swap(key, expected, value) {
|
|
356
|
+
let rows;
|
|
357
|
+
if (expected === null) {
|
|
358
|
+
if (value === null)
|
|
359
|
+
return { applied: true };
|
|
360
|
+
rows = await query(`INSERT INTO ${table} (key, value, expires) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING RETURNING key`, [key, value, expiresOf(value)]);
|
|
361
|
+
}
|
|
362
|
+
else if (value === null)
|
|
363
|
+
rows = await query(`DELETE FROM ${table} WHERE key = ? AND value = ? RETURNING key`, [
|
|
364
|
+
key,
|
|
365
|
+
expected,
|
|
366
|
+
]);
|
|
367
|
+
else
|
|
368
|
+
rows = await query(`UPDATE ${table} SET value = ?, expires = ? WHERE key = ? AND value = ? RETURNING key`, [value, expiresOf(value), key, expected]);
|
|
369
|
+
return { applied: rows.length > 0 };
|
|
370
|
+
}
|
|
371
|
+
return from({
|
|
372
|
+
async get(key) {
|
|
373
|
+
const raw = await read(key);
|
|
374
|
+
if (raw == null)
|
|
375
|
+
return null;
|
|
376
|
+
return Json.parse(raw);
|
|
377
|
+
},
|
|
378
|
+
async put(key, value) {
|
|
379
|
+
const raw = Json.stringify(value);
|
|
380
|
+
await query(`INSERT INTO ${table} (key, value, expires) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires = excluded.expires`, [key, raw, expiresOf(raw)]);
|
|
381
|
+
},
|
|
382
|
+
async delete(key) {
|
|
383
|
+
await query(`DELETE FROM ${table} WHERE key = ?`, [key]);
|
|
384
|
+
},
|
|
385
|
+
update(key, fn) {
|
|
386
|
+
return compareAndSwap({
|
|
387
|
+
fn,
|
|
388
|
+
key,
|
|
389
|
+
label: 'SQL store',
|
|
390
|
+
maxAttempts,
|
|
391
|
+
read: () => read(key),
|
|
392
|
+
swap: (expected, value) => swap(key, expected, value),
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
// One statement: insert the marker, or replace one whose expiry has passed.
|
|
396
|
+
async tryClaim(key, expires) {
|
|
397
|
+
if (!Number.isFinite(expires) || expires <= Date.now())
|
|
398
|
+
return false;
|
|
399
|
+
const rows = await query(`INSERT INTO ${table} AS existing (key, value, expires) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires = excluded.expires WHERE existing.expires IS NOT NULL AND existing.expires <= ? RETURNING key`, [
|
|
400
|
+
key,
|
|
401
|
+
Json.stringify({ expires, type: 'mppx:replay' }),
|
|
402
|
+
expires,
|
|
403
|
+
Date.now(),
|
|
404
|
+
]);
|
|
405
|
+
// The claim's own expiry may have passed while the statement ran.
|
|
406
|
+
return rows.length > 0 && expires > Date.now();
|
|
407
|
+
},
|
|
408
|
+
}, options);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Atomic SQL store on a Postgres client: `pg` (`Pool` or `Client`),
|
|
412
|
+
* `@neondatabase/serverless` (`Pool` or `neon()`), `@vercel/postgres`,
|
|
413
|
+
* Supabase's Postgres connection or PGlite. See {@link sql}.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* ```ts
|
|
417
|
+
* import { Pool } from 'pg'
|
|
418
|
+
* import { Store } from 'mppx/server'
|
|
419
|
+
*
|
|
420
|
+
* const store = Store.postgres(new Pool({ connectionString: process.env.DATABASE_URL }))
|
|
421
|
+
* ```
|
|
422
|
+
*/
|
|
423
|
+
export function postgres(client, options) {
|
|
424
|
+
return sql({
|
|
425
|
+
dialect: 'postgres',
|
|
426
|
+
async query(text, params) {
|
|
427
|
+
const result = await client.query(text, params);
|
|
428
|
+
return Array.isArray(result) ? result : result.rows;
|
|
429
|
+
},
|
|
430
|
+
}, options);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Atomic SQL store on a SQLite database: `node:sqlite` (`DatabaseSync`),
|
|
434
|
+
* `better-sqlite3` or `bun:sqlite`. See {@link sql}. For libSQL or Turso, use
|
|
435
|
+
* {@link sql} with dialect `sqlite` and return the result set's rows:
|
|
436
|
+
* `Store.sql({ dialect: 'sqlite', query: async (sql, args) => (await client.execute({ sql, args: args as InArgs })).rows })`.
|
|
437
|
+
*
|
|
438
|
+
* @example
|
|
439
|
+
* ```ts
|
|
440
|
+
* import { DatabaseSync } from 'node:sqlite'
|
|
441
|
+
* import { Store } from 'mppx/server'
|
|
442
|
+
*
|
|
443
|
+
* const store = Store.sqlite(new DatabaseSync('mppx.db'))
|
|
444
|
+
* ```
|
|
445
|
+
*/
|
|
446
|
+
export function sqlite(database, options) {
|
|
447
|
+
return sql({
|
|
448
|
+
dialect: 'sqlite',
|
|
449
|
+
async query(text, params) {
|
|
450
|
+
const statement = database.prepare(text);
|
|
451
|
+
// better-sqlite3 allows all() only on statements that return rows.
|
|
452
|
+
if (statement.reader === false && statement.run) {
|
|
453
|
+
await statement.run(...params);
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
return (await statement.all(...params));
|
|
457
|
+
},
|
|
458
|
+
}, options);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Atomic SQL store on a Cloudflare D1 database binding. See {@link sql}.
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* ```ts
|
|
465
|
+
* import { Store } from 'mppx/server'
|
|
466
|
+
*
|
|
467
|
+
* export default {
|
|
468
|
+
* fetch(request: Request, env: { DB: D1Database }) {
|
|
469
|
+
* const store = Store.d1(env.DB)
|
|
470
|
+
* // ...
|
|
471
|
+
* },
|
|
472
|
+
* }
|
|
473
|
+
* ```
|
|
474
|
+
*/
|
|
475
|
+
export function d1(database, options) {
|
|
476
|
+
return sql({
|
|
477
|
+
dialect: 'sqlite',
|
|
478
|
+
async query(text, params) {
|
|
479
|
+
const { results } = await database
|
|
480
|
+
.prepare(text)
|
|
481
|
+
.bind(...params)
|
|
482
|
+
.all();
|
|
483
|
+
return results;
|
|
484
|
+
},
|
|
485
|
+
}, options);
|
|
486
|
+
}
|
|
129
487
|
/** In-memory store backed by a `Map`. JSON-roundtrips values to match production behavior. */
|
|
130
488
|
export function memory(options) {
|
|
131
489
|
const store = new Map();
|