mppx 0.17.1 → 0.17.4

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/Errors.d.ts +94 -0
  3. package/dist/Errors.js +83 -0
  4. package/dist/Store.d.ts +413 -0
  5. package/dist/Store.js +837 -0
  6. package/dist/cli/cli.js +141 -136
  7. package/dist/cli/internal.js +28 -6
  8. package/dist/cli/plugins/index.js +16 -11
  9. package/dist/cli/sessions/Manager.js +12 -1
  10. package/dist/cli/utils.d.ts +2 -0
  11. package/dist/cli/utils.js +6 -0
  12. package/dist/cli/validate/challenge.d.ts +4 -0
  13. package/dist/cli/validate/challenge.js +9 -6
  14. package/dist/client/Mppx.d.ts +1 -1
  15. package/dist/client/Mppx.js +7 -3
  16. package/dist/client/internal/Fetch.d.ts +1 -1
  17. package/dist/client/internal/Fetch.js +9 -0
  18. package/dist/client/internal/MethodResponse.d.ts +8 -0
  19. package/dist/client/internal/MethodResponse.js +9 -0
  20. package/dist/client/internal/ProofRealm.d.ts +3 -3
  21. package/dist/client/internal/ProofRealm.js +4 -4
  22. package/dist/client/internal/protocols/Mcp.d.ts +4 -0
  23. package/dist/client/internal/protocols/Mcp.js +27 -14
  24. package/dist/cloudflare/ChannelStore.d.ts +97 -0
  25. package/dist/cloudflare/ChannelStore.js +304 -0
  26. package/dist/cloudflare/Channels.d.ts +92 -0
  27. package/dist/cloudflare/Channels.js +325 -0
  28. package/dist/cloudflare/index.d.ts +5 -0
  29. package/dist/cloudflare/index.js +5 -0
  30. package/dist/identity/Client.js +7 -1
  31. package/dist/internal/sse.d.ts +21 -0
  32. package/dist/internal/sse.js +82 -0
  33. package/dist/internal/version.d.ts +2 -2
  34. package/dist/internal/version.js +1 -1
  35. package/dist/mcp/client/McpClient.d.ts +2 -2
  36. package/dist/mcp/client/McpClient.js +5 -2
  37. package/dist/proxy/Proxy.d.ts +2 -0
  38. package/dist/proxy/Proxy.js +31 -6
  39. package/dist/proxy/internal/Replay.js +1 -1
  40. package/dist/server/Mppx.js +5 -4
  41. package/dist/server/Transport.js +1 -1
  42. package/dist/server/internal/html/config.d.ts +7 -1
  43. package/dist/server/internal/html/config.js +4 -0
  44. package/dist/stripe/internal/types.d.ts +82 -6
  45. package/dist/stripe/server/Charge.js +1 -2
  46. package/dist/stripe/server/Methods.d.ts +2 -0
  47. package/dist/stripe/server/Methods.js +2 -1
  48. package/dist/stripe/server/internal/html.gen.js +1 -1
  49. package/dist/stripe/server/internal/record-payment.d.ts +49 -0
  50. package/dist/stripe/server/internal/record-payment.js +75 -18
  51. package/dist/tempo/Methods.d.ts +8 -0
  52. package/dist/tempo/Methods.js +18 -2
  53. package/dist/tempo/client/Charge.d.ts +32 -1
  54. package/dist/tempo/client/Charge.js +201 -3
  55. package/dist/tempo/client/Methods.d.ts +16 -0
  56. package/dist/tempo/internal/fee-payer.d.ts +64 -14
  57. package/dist/tempo/internal/fee-payer.js +231 -20
  58. package/dist/tempo/internal/zone.d.ts +79 -0
  59. package/dist/tempo/internal/zone.js +133 -0
  60. package/dist/tempo/legacy/session/Chain.js +2 -0
  61. package/dist/tempo/server/Charge.d.ts +7 -3
  62. package/dist/tempo/server/Charge.js +100 -6
  63. package/dist/tempo/server/Methods.d.ts +40 -0
  64. package/dist/tempo/server/Subscription.js +1 -0
  65. package/dist/tempo/server/internal/html.gen.js +1 -1
  66. package/dist/tempo/server/internal/transport.d.ts +6 -0
  67. package/dist/tempo/server/internal/transport.js +21 -21
  68. package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
  69. package/dist/tempo/session/client/CredentialState.js +2 -0
  70. package/dist/tempo/session/client/Session.d.ts +11 -1
  71. package/dist/tempo/session/client/Session.js +39 -6
  72. package/dist/tempo/session/client/SessionManager.d.ts +1 -1
  73. package/dist/tempo/session/client/SessionManager.js +8 -4
  74. package/dist/tempo/session/client/Transports.d.ts +2 -0
  75. package/dist/tempo/session/client/Transports.js +4 -1
  76. package/dist/tempo/session/precompile/Chain.js +1 -0
  77. package/dist/tempo/session/server/ChannelStore.d.ts +14 -0
  78. package/dist/tempo/session/server/ChannelStore.js +10 -3
  79. package/dist/tempo/session/server/CredentialVerification.js +1 -1
  80. package/dist/tempo/session/server/MeteredStream.d.ts +4 -0
  81. package/dist/tempo/session/server/MeteredStream.js +6 -1
  82. package/dist/tempo/session/server/Sse.d.ts +59 -0
  83. package/dist/tempo/session/server/Sse.js +175 -10
  84. package/dist/validation/core.js +2 -0
  85. package/dist/zod.d.ts +2 -1
  86. package/dist/zod.js +1 -1
  87. package/package.json +5 -3
  88. package/dist/stripe/server/internal/html/types.d.ts +0 -2
  89. package/dist/stripe/server/internal/html/types.js +0 -2
package/dist/Store.js CHANGED
@@ -52,6 +52,192 @@ 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. Values cross `read` and
59
+ * `swap` as JSON text unless `decode` and `encode` say otherwise.
60
+ */
61
+ async function compareAndSwap(parameters) {
62
+ const { fn, key, label, maxAttempts, read, swap } = parameters;
63
+ const decode = parameters.decode ?? ((raw) => Json.parse(raw));
64
+ const encode = parameters.encode ?? ((value) => Json.stringify(value));
65
+ let raw = await read();
66
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
67
+ const change = fn(raw == null ? null : decode(raw));
68
+ if (change.op === 'noop')
69
+ return change.result;
70
+ const swapped = await swap(raw, change.op === 'set' ? encode(change.value) : null);
71
+ if (swapped.applied)
72
+ return change.result;
73
+ if (attempt + 1 === maxAttempts)
74
+ break;
75
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * Math.min(2 ** (attempt + 1), 200)));
76
+ raw = swapped.current !== undefined ? swapped.current : await read();
77
+ }
78
+ throw new Error(`${label} update of "${key}" conflicted ${maxAttempts} times.`);
79
+ }
80
+ /** Object types native compare-and-swap compares by value, besides arrays, plain objects, maps, sets and typed arrays. */
81
+ const comparableTags = new Set([
82
+ '[object ArrayBuffer]',
83
+ '[object BigInt]',
84
+ '[object Boolean]',
85
+ '[object Date]',
86
+ '[object Number]',
87
+ '[object RegExp]',
88
+ '[object String]',
89
+ ]);
90
+ /** The error for a value native compare-and-swap cannot compare, which would otherwise conflict until `maxAttempts`. */
91
+ function incomparable(what) {
92
+ return new Error(`Store.durableObject: native encoding cannot compare ${what}.`);
93
+ }
94
+ /** Throws unless map keys and set members are primitives, compared like `Map#has` (SameValueZero). */
95
+ function assertPrimitiveKey(key, tag) {
96
+ if ((typeof key === 'object' && key !== null) || typeof key === 'function')
97
+ throw incomparable(`a ${tag.slice(8, -1)} with object keys`);
98
+ }
99
+ /**
100
+ * Throws for a value that native compare-and-swap cannot compare, so a write
101
+ * fails at once instead of making later updates conflict until `maxAttempts`.
102
+ */
103
+ function assertComparable(value, seen = new Set()) {
104
+ if (typeof value !== 'object' || value === null)
105
+ return;
106
+ if (ArrayBuffer.isView(value))
107
+ return assertFixedBuffer(value);
108
+ // Structured clone keeps cycles: check each object once.
109
+ if (seen.has(value))
110
+ return;
111
+ seen.add(value);
112
+ const tag = Object.prototype.toString.call(value);
113
+ if (Array.isArray(value) || tag === '[object Object]')
114
+ for (const key of Object.keys(value))
115
+ assertComparable(value[key], seen);
116
+ else if (value instanceof Map)
117
+ for (const [key, item] of value) {
118
+ assertPrimitiveKey(key, tag);
119
+ assertComparable(item, seen);
120
+ }
121
+ else if (value instanceof Set)
122
+ for (const item of value)
123
+ assertPrimitiveKey(item, tag);
124
+ else if (!comparableTags.has(tag))
125
+ throw incomparable(`${tag.slice(8, -1)} values`);
126
+ }
127
+ /**
128
+ * Throws for a view of a resizable `ArrayBuffer`: structured clone keeps whether it tracks the
129
+ * buffer's length, and telling that apart costs a resize of the whole buffer.
130
+ */
131
+ function assertFixedBuffer(view) {
132
+ if (view.buffer.resizable)
133
+ throw incomparable('views of resizable ArrayBuffers');
134
+ }
135
+ /** `SameValueZero`, how maps and sets match keys. */
136
+ function sameValueZero(a, b) {
137
+ return a === b || (a !== a && b !== b);
138
+ }
139
+ /** A view's bytes. */
140
+ function bytesOf(view) {
141
+ return ArrayBuffer.isView(view)
142
+ ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
143
+ : new Uint8Array(view);
144
+ }
145
+ /**
146
+ * Structural equality of stored values, as structured clone copies them:
147
+ * primitives (bigints by value), arrays (holes included) and plain objects, maps
148
+ * and sets, all in key or insertion order, typed arrays, `DataView`s and `ArrayBuffer`s (type, bytes and resizing; not views of resizable ones), dates,
149
+ * regular expressions and boxed primitives. A native compare-and-swap
150
+ * needs it, since a value read back from storage is a copy. Throws for values it
151
+ * cannot compare, rather than reporting a conflict that never resolves.
152
+ */
153
+ function deepEqual(a, b, pairs = [new Map(), new Map()]) {
154
+ if (a === b || (a !== a && b !== b))
155
+ return true;
156
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
157
+ return false;
158
+ // Pair objects one-to-one, both ways: a pair already being compared is assumed equal, so
159
+ // cyclic values terminate, and an object paired with another fails, so aliasing and cycle
160
+ // shapes, which structured clone keeps, must match.
161
+ const [left, right] = pairs;
162
+ if (left.has(a) || right.has(b))
163
+ return left.get(a) === b && right.get(b) === a;
164
+ left.set(a, b);
165
+ right.set(b, a);
166
+ const tag = Object.prototype.toString.call(a);
167
+ if (tag !== Object.prototype.toString.call(b))
168
+ return false;
169
+ // Arrays and plain objects: own enumerable keys in order (array indexes first, so holes,
170
+ // sparse lengths and named properties count, and inherited indexes don't), then values.
171
+ // Structured clone keeps key order, so values that differ only in order are different values.
172
+ if (Array.isArray(a) || tag === '[object Object]') {
173
+ if (Array.isArray(a) && a.length !== b.length)
174
+ return false;
175
+ const keys = Object.keys(a);
176
+ const other = Object.keys(b);
177
+ return (keys.length === other.length &&
178
+ keys.every((key, i) => key === other[i] &&
179
+ deepEqual(a[key], b[key], pairs)));
180
+ }
181
+ // Structured clone keeps a view's whole backing buffer and offset, not only its visible bytes.
182
+ if (ArrayBuffer.isView(a)) {
183
+ const other = b;
184
+ assertFixedBuffer(a);
185
+ assertFixedBuffer(other);
186
+ return (a.byteOffset === other.byteOffset &&
187
+ a.byteLength === other.byteLength &&
188
+ deepEqual(a.buffer, other.buffer, pairs));
189
+ }
190
+ if (tag === '[object ArrayBuffer]') {
191
+ const x = a;
192
+ const y = b;
193
+ // Structured clone keeps whether a buffer is resizable, and up to what length.
194
+ if (x.resizable !== y.resizable || x.maxByteLength !== y.maxByteLength)
195
+ return false;
196
+ const xs = bytesOf(x);
197
+ const ys = bytesOf(y);
198
+ return xs.length === ys.length && xs.every((byte, i) => byte === ys[i]);
199
+ }
200
+ if (a instanceof Map) {
201
+ const other = b;
202
+ if (a.size !== other.size)
203
+ return false;
204
+ // In insertion order, which structured clone keeps.
205
+ const entries = other.entries();
206
+ for (const [key, item] of a) {
207
+ assertPrimitiveKey(key, tag);
208
+ const [otherKey, otherItem] = entries.next().value;
209
+ if (!sameValueZero(key, otherKey) || !deepEqual(item, otherItem, pairs))
210
+ return false;
211
+ }
212
+ return true;
213
+ }
214
+ if (a instanceof Set) {
215
+ const other = b;
216
+ if (a.size !== other.size)
217
+ return false;
218
+ const items = other.values();
219
+ for (const item of a) {
220
+ assertPrimitiveKey(item, tag);
221
+ if (!sameValueZero(item, items.next().value))
222
+ return false;
223
+ }
224
+ return true;
225
+ }
226
+ if (a instanceof RegExp)
227
+ return a.source === b.source && a.flags === b.flags;
228
+ if (comparableTags.has(tag))
229
+ return Object.is(a.valueOf(), b.valueOf());
230
+ throw incomparable(`${tag.slice(8, -1)} values`);
231
+ }
232
+ /** 32-bit FNV-1a hash of a string's UTF-16 code units: cheap and stable, to pick a replay shard. */
233
+ function fnv1a(value) {
234
+ let hash = 0x811c9dc5;
235
+ for (let i = 0; i < value.length; i++) {
236
+ hash ^= value.charCodeAt(i);
237
+ hash = Math.imul(hash, 0x01000193);
238
+ }
239
+ return hash >>> 0;
240
+ }
55
241
  const keyPrefixCache = new WeakMap();
56
242
  export function from(store, options) {
57
243
  return withKeyPrefix(store, options?.keyPrefix);
@@ -126,6 +312,657 @@ export function cloudflare(kv, options) {
126
312
  ...wrapJsonUpdate(kv.update),
127
313
  }, options);
128
314
  }
315
+ /** Name prefix of {@link durableObject}'s replay shard objects. */
316
+ const replayShardPrefix = 'mppx:replay:';
317
+ /** Object name prefix of {@link durableObject}'s keys named like a replay shard or with this prefix. */
318
+ const keyObjectPrefix = 'mppx:key:';
319
+ /** A replay shard object's whole name: `mppx:replay:v<version>:<shard>`. */
320
+ const replayShardName = /^mppx:replay:v\d+:\d+$/;
321
+ /**
322
+ * Atomic store backed by Cloudflare Durable Objects.
323
+ *
324
+ * Each key lives in its own Durable Object instance (`idFromName(key)`; a key named
325
+ * like a replay shard, `mppx:replay:v<version>:<shard>`, or starting with `mppx:key:`
326
+ * uses `mppx:key:<key>`), so
327
+ * reads and writes of one key are linearizable across every Worker isolate and
328
+ * location, while different keys (such as different payment channels) scale
329
+ * out across instances. {@link AtomicStore.update} runs `fn` in the Worker and
330
+ * commits with a compare-and-swap in the object. When another writer changed
331
+ * the key in between, the swap returns the stored value and the update retries
332
+ * from it after a jittered backoff; {@link AtomicStore.tryClaim} runs in a single
333
+ * object call.
334
+ *
335
+ * Bind {@link DurableObjectStore} as a SQLite-backed Durable Object and pass
336
+ * its namespace. With `transport: 'rpc'`, the store calls the object's methods
337
+ * over Workers RPC instead of `fetch`, and `encoding: 'native'` then stores
338
+ * values as structured-clone values instead of JSON text.
339
+ *
340
+ * @example
341
+ * ```ts
342
+ * import { Store } from 'mppx/server'
343
+ *
344
+ * export class MppxStore extends Store.DurableObjectStore {}
345
+ *
346
+ * export default {
347
+ * fetch(request: Request, env: Env) {
348
+ * const store = Store.durableObject(env.MPPX_STORE)
349
+ * // ...
350
+ * },
351
+ * }
352
+ * ```
353
+ *
354
+ * @example
355
+ * ```ts
356
+ * import { DurableObject } from 'cloudflare:workers'
357
+ * import { Store } from 'mppx/server'
358
+ *
359
+ * // Workers RPC needs a class that extends `DurableObject`.
360
+ * export class MppxStore extends Store.DurableObjectStore.rpc(DurableObject) {}
361
+ *
362
+ * const store = Store.durableObject(env.MPPX_STORE, { transport: 'rpc', encoding: 'native' })
363
+ * ```
364
+ */
365
+ export function durableObject(namespace, options) {
366
+ const maxAttempts = options?.maxAttempts ?? 32;
367
+ const transport = options?.transport ?? 'fetch';
368
+ const encoding = options?.encoding ?? 'json';
369
+ if (encoding === 'native' && transport !== 'rpc')
370
+ throw new Error('Store.durableObject: `encoding: "native"` requires `transport: "rpc"`, since `fetch` carries values as JSON.');
371
+ const native = encoding === 'native';
372
+ const { replayShards, replayShardVersion = 1 } = options ?? {};
373
+ if (replayShards !== undefined && !(Number.isSafeInteger(replayShards) && replayShards > 0))
374
+ throw new Error(`Store.durableObject: \`replayShards\` must be a positive integer, got ${replayShards}.`);
375
+ if (!(Number.isSafeInteger(replayShardVersion) && replayShardVersion > 0))
376
+ throw new Error(`Store.durableObject: \`replayShardVersion\` must be a positive integer, got ${replayShardVersion}.`);
377
+ const object = (name) => namespace.get(namespace.idFromName(name));
378
+ // A key named like a replay shard gets its own object name, so its object never passes for a
379
+ // shard. Keys already in that escape namespace are escaped too, so no two keys share an object.
380
+ const keyObject = (key) => replayShardName.test(key) || key.startsWith(keyObjectPrefix) ? `${keyObjectPrefix}${key}` : key;
381
+ async function call(name, body) {
382
+ const response = await object(name).fetch('https://mppx-store/', {
383
+ body: JSON.stringify(body),
384
+ headers: { 'content-type': 'application/json' },
385
+ method: 'POST',
386
+ });
387
+ if (!response.ok)
388
+ throw new Error(`Durable Object store request failed (${response.status}): ${await response.text()}`);
389
+ return (await response.json());
390
+ }
391
+ // The object's operations on stored values: JSON text, or native values over RPC.
392
+ const remote = transport === 'rpc'
393
+ ? {
394
+ get: (key) => stub(key).storeGet(key, encoding),
395
+ put: (key, value) => stub(key).storePut(key, value, encoding),
396
+ delete: (key) => stub(key).storeDelete(key, encoding),
397
+ swap: (key, expected, value) => stub(key).storeSwap(key, expected, value, encoding),
398
+ claim: (key, expires) => stub(key).storeClaim(key, expires, encoding),
399
+ }
400
+ : {
401
+ get: (key) => call(keyObject(key), { op: 'get', key }),
402
+ put: (key, value) => call(keyObject(key), { op: 'put', key, value: value }),
403
+ delete: (key) => call(keyObject(key), { op: 'delete', key }),
404
+ swap: (key, expected, value) => call(keyObject(key), {
405
+ op: 'swap',
406
+ key,
407
+ expected: expected,
408
+ value: value,
409
+ }),
410
+ claim: (key, expires) => call(keyObject(key), { op: 'claim', key, expires }),
411
+ };
412
+ function stub(key) {
413
+ return object(keyObject(key));
414
+ }
415
+ // Replay claims in `replayShards` SQLite-backed shard objects instead of one object per key.
416
+ const replay = replayShards === undefined
417
+ ? undefined
418
+ : (() => {
419
+ const shardOf = (key) => `${replayShardPrefix}v${replayShardVersion}:${fnv1a(key) % replayShards}`;
420
+ const shard = (key) => object(shardOf(key));
421
+ return transport === 'rpc'
422
+ ? {
423
+ claim: (key, expires) => shard(key).claimReplay(key, expires),
424
+ release: (key) => shard(key).releaseReplay(key),
425
+ }
426
+ : {
427
+ claim: (key, expires) => call(shardOf(key), { op: 'claimReplay', key, expires }),
428
+ release: (key) => call(shardOf(key), { op: 'releaseReplay', key }),
429
+ };
430
+ })();
431
+ return from({
432
+ async get(key) {
433
+ const raw = await remote.get(key);
434
+ if (raw == null)
435
+ return null;
436
+ return native ? raw : Json.parse(raw);
437
+ },
438
+ async put(key, value) {
439
+ if (native)
440
+ assertComparable(value);
441
+ await remote.put(key, native ? value : Json.stringify(value));
442
+ },
443
+ async delete(key) {
444
+ // Callers release a claim by deleting its key (tempo charge does when a payment
445
+ // fails), and claims take any caller-chosen key and prefix, so every delete also
446
+ // releases the key's shard claim: one idempotent `DELETE`.
447
+ await Promise.all([remote.delete(key), replay?.release(key)]);
448
+ },
449
+ update(key, fn) {
450
+ return compareAndSwap({
451
+ // A native value is the compare-and-swap's expected value, so `fn` gets a copy.
452
+ decode: native ? (raw) => structuredClone(raw) : (raw) => Json.parse(raw),
453
+ encode: native
454
+ ? (value) => {
455
+ assertComparable(value);
456
+ return value;
457
+ }
458
+ : (value) => Json.stringify(value),
459
+ fn,
460
+ key,
461
+ label: 'Durable Object store',
462
+ maxAttempts,
463
+ read: async () => (await remote.get(key)) ?? null,
464
+ swap: (expected, value) => remote.swap(key, expected, value),
465
+ });
466
+ },
467
+ async tryClaim(key, expires) {
468
+ if (!Number.isFinite(expires) || expires <= Date.now())
469
+ return false;
470
+ return replay ? replay.claim(key, expires) : remote.claim(key, expires);
471
+ },
472
+ }, options);
473
+ }
474
+ /**
475
+ * Durable Object class that holds the values of {@link durableObject}.
476
+ *
477
+ * Export a subclass of it from your Worker and bind it in
478
+ * `wrangler.jsonc` with a `new_sqlite_classes` migration. It needs no
479
+ * `cloudflare:workers` import, so it bundles anywhere; for Workers RPC, extend
480
+ * {@link DurableObjectStore.rpc} instead.
481
+ *
482
+ * @example
483
+ * ```jsonc
484
+ * {
485
+ * "durable_objects": { "bindings": [{ "name": "MPPX_STORE", "class_name": "MppxStore" }] },
486
+ * "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MppxStore"] }]
487
+ * }
488
+ * ```
489
+ */
490
+ export class DurableObjectStore {
491
+ /** The object's state (`ctx`), for subclasses that use its storage or alarms. */
492
+ state;
493
+ /** The Worker's bindings, for subclasses. */
494
+ env;
495
+ /**
496
+ * The object's storage: a subclass schedules alarms with it from {@link DurableObjectStore.onWrite}.
497
+ * A plain field, like `state` and `env`, so subclasses that assign `this.storage = state.storage` keep working.
498
+ */
499
+ storage;
500
+ constructor(state, env) {
501
+ this.state = state;
502
+ this.env = env;
503
+ this.storage = state.storage;
504
+ }
505
+ /**
506
+ * Returns this class rebuilt on `base`, the `DurableObject` class from
507
+ * `cloudflare:workers`: Workers RPC calls only reach classes that extend it,
508
+ * and taking it as an argument keeps this module free of that import. Called
509
+ * on a subclass, it keeps the subclass's methods (such as `onWrite` and
510
+ * `alarm`) and static members, but not its constructor, field initializers or `#private` members,
511
+ * which the copied methods cannot reach: subclass the returned class, or set
512
+ * state up lazily in ordinary members.
513
+ *
514
+ * @example
515
+ * ```ts
516
+ * import { DurableObject } from 'cloudflare:workers'
517
+ *
518
+ * export class MppxStore extends Store.DurableObjectStore.rpc(DurableObject) {}
519
+ * ```
520
+ */
521
+ static rpc(base) {
522
+ class RpcStore extends base {
523
+ constructor(state, env) {
524
+ super(state, env);
525
+ Object.assign(this, { env, state, storage: state.storage });
526
+ }
527
+ }
528
+ // Copy the receiver's prototype chain down to DurableObjectStore, most-derived last so it wins.
529
+ const chain = [];
530
+ for (let proto = this.prototype;; proto = Object.getPrototypeOf(proto)) {
531
+ chain.unshift(proto);
532
+ if (proto === DurableObjectStore.prototype)
533
+ break;
534
+ }
535
+ // Collect each name's most-derived descriptor first, then define it once: redefining a
536
+ // name an ancestor made non-configurable would throw, though a subclass may shadow it.
537
+ const methods = new Map();
538
+ for (const proto of chain)
539
+ for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto)))
540
+ if (name !== 'constructor')
541
+ methods.set(name, descriptor);
542
+ for (const [name, descriptor] of methods)
543
+ Object.defineProperty(RpcStore.prototype, name, descriptor);
544
+ // And the subclasses' static members, without DurableObjectStore's own (such as `rpc`).
545
+ const statics = new Map();
546
+ for (const proto of chain.slice(1)) {
547
+ const ctor = proto.constructor;
548
+ for (const name of Reflect.ownKeys(ctor))
549
+ if (name !== 'length' && name !== 'name' && name !== 'prototype')
550
+ statics.set(name, Object.getOwnPropertyDescriptor(ctor, name));
551
+ }
552
+ for (const [name, descriptor] of statics)
553
+ Object.defineProperty(RpcStore, name, descriptor);
554
+ return RpcStore;
555
+ }
556
+ /**
557
+ * Called for every applied put, delete, swap and claim (except claims in replay shards,
558
+ * which live in the shard's own table), after the storage write and before the
559
+ * response, with the decoded values (`null` when the key is absent). When the
560
+ * storage has `transaction()`, the write and this hook run in one transaction,
561
+ * passed as `storage`: make the hook's writes and alarms
562
+ * through it so they commit with the store's write, and throw to roll the
563
+ * write back. Make only storage calls and alarms here: awaiting other I/O
564
+ * (`fetch`, a queue send) opens the object's input gate before the write
565
+ * commits, so a concurrent swap or claim could act on the old value. Do that
566
+ * work from {@link DurableObjectStore.alarm} instead. Does nothing by default.
567
+ */
568
+ onWrite(_key, _previous, _next, _storage) { }
569
+ /**
570
+ * Handles the object's alarm, which a subclass sets through
571
+ * {@link DurableObjectStore.storage}. In a replay shard it clears expired claims, so
572
+ * a subclass that overrides it must call `super.alarm()`, or expired claims stay stored.
573
+ * Elsewhere, including on key-value-backed objects, it does nothing. The subclass's own
574
+ * alarm work runs in shards too: skip it there with {@link DurableObjectStore.isReplayShard}.
575
+ *
576
+ * @example
577
+ * ```ts
578
+ * override async alarm() {
579
+ * await super.alarm()
580
+ * if (this.isReplayShard()) return
581
+ * // The subclass's own work.
582
+ * }
583
+ * ```
584
+ */
585
+ async alarm() {
586
+ // Only an object that holds claims has any to clear, whatever its name.
587
+ if (!this.hasReplayClaims())
588
+ return;
589
+ const { sql } = this.state.storage;
590
+ if (!sql)
591
+ return;
592
+ const now = Date.now();
593
+ sql.exec('DELETE FROM mppx_replay_claims WHERE expires <= ?', now);
594
+ const [{ expires } = {}] = sql
595
+ .exec('SELECT MIN(expires) AS expires FROM mppx_replay_claims')
596
+ .toArray();
597
+ if (typeof expires === 'number')
598
+ await this.state.storage.setAlarm?.(Math.max(expires, now + 1));
599
+ }
600
+ /**
601
+ * Whether this object is a replay shard of {@link durableObject}'s `replayShards`: an
602
+ * object of the same class that holds only replay claims, in its own SQL table. A
603
+ * subclass skips its own work there, and must not clear its storage. It tells by the
604
+ * object's whole name (`mppx:replay:v<version>:<shard>`) where the runtime gives it (`ctx.id.name`, so it
605
+ * works in the constructor of a fresh shard), and otherwise by the claims table, which a
606
+ * shard creates on its first claim.
607
+ */
608
+ isReplayShard() {
609
+ const name = this.state.id?.name;
610
+ return typeof name === 'string' ? replayShardName.test(name) : this.hasReplayClaims();
611
+ }
612
+ /** Whether this object holds the replay claims table, which a shard creates on its first claim. */
613
+ hasReplayClaims() {
614
+ const { sql } = this.state.storage;
615
+ if (!sql)
616
+ return false;
617
+ try {
618
+ return (sql
619
+ .exec("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'mppx_replay_claims'")
620
+ .toArray().length > 0);
621
+ }
622
+ catch {
623
+ // A key-value-backed object has `sql` but cannot run it, so it holds no replay shard.
624
+ return false;
625
+ }
626
+ }
627
+ /**
628
+ * Claims `key` until `expires` in this replay shard, unless it holds an
629
+ * unexpired claim: one `INSERT … ON CONFLICT DO UPDATE … WHERE` statement, so
630
+ * concurrent claims have exactly one winner. Schedules the alarm that clears it.
631
+ */
632
+ async claimReplay(key, expires) {
633
+ const sql = this.replayClaims();
634
+ const now = Date.now();
635
+ if (!Number.isFinite(expires) || expires <= now)
636
+ return false;
637
+ const claimed = sql
638
+ .exec('INSERT INTO mppx_replay_claims (key, expires) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET expires = excluded.expires WHERE mppx_replay_claims.expires <= ? RETURNING key', key, expires, now)
639
+ .toArray().length === 1;
640
+ if (claimed) {
641
+ const { storage } = this.state;
642
+ try {
643
+ const alarm = (await storage.getAlarm?.()) ?? null;
644
+ if (alarm === null || expires < alarm)
645
+ await storage.setAlarm?.(Math.max(expires, Date.now() + 1));
646
+ }
647
+ catch (error) {
648
+ // Without the alarm that clears it the claim must not stay: release it so a retry can claim.
649
+ sql.exec('DELETE FROM mppx_replay_claims WHERE key = ? AND expires = ?', key, expires);
650
+ throw error;
651
+ }
652
+ }
653
+ return claimed;
654
+ }
655
+ /** Releases `key`'s claim in this replay shard, if it has one. */
656
+ async releaseReplay(key) {
657
+ this.replayClaims().exec('DELETE FROM mppx_replay_claims WHERE key = ?', key);
658
+ }
659
+ // The store's operations are named `store*`, not `get`, `put` and `delete`: on a Worker
660
+ // with the `fetcher_has_get_put_delete` flag (or the `rpc` flag before 2024-03-26), a stub's
661
+ // own HTTP methods of those names shadow RPC methods, and the prefix keeps them apart
662
+ // from methods a subclass already has.
663
+ /** Reads a key: its JSON text, or its stored value with `native` encoding. */
664
+ async storeGet(key, _encoding = 'json') {
665
+ return (await this.state.storage.get(key)) ?? null;
666
+ }
667
+ /** Writes a key: JSON text, or any structured-clone value with `native` encoding. */
668
+ async storePut(key, value, encoding = 'json') {
669
+ await this.commit(key, value, encoding);
670
+ }
671
+ /** Deletes a key. */
672
+ async storeDelete(key, encoding = 'json') {
673
+ await this.commit(key, null, encoding);
674
+ }
675
+ /**
676
+ * Writes `value` (or deletes, for `null`) only if the key holds `expected`:
677
+ * the same JSON text, or with `native` encoding a structurally equal value.
678
+ * It reads and writes without awaiting other I/O in between, so the object's
679
+ * input gate makes it atomic.
680
+ */
681
+ async storeSwap(key, expected, value, encoding = 'json') {
682
+ const current = await this.storeGet(key);
683
+ if (encoding === 'native' ? !deepEqual(current, expected ?? null) : current !== expected)
684
+ return { applied: false, current };
685
+ await this.commit(key, value, encoding, current);
686
+ return { applied: true };
687
+ }
688
+ /** Records a replay marker until `expires` unless the key holds a value or an unexpired marker. */
689
+ async storeClaim(key, expires, encoding = 'json') {
690
+ if (!Number.isFinite(expires) || expires <= Date.now())
691
+ return false;
692
+ const raw = await this.storeGet(key);
693
+ const current = decode(raw, encoding);
694
+ if (current !== null && (!isReplayMarker(current) || current.expires > Date.now()))
695
+ return false;
696
+ const marker = { expires, type: 'mppx:replay' };
697
+ await this.commit(key, encoding === 'native' ? marker : Json.stringify(marker), encoding, raw);
698
+ return true;
699
+ }
700
+ /** Handles one store operation sent by `fetch`, as JSON text. */
701
+ async fetch(request) {
702
+ if (request.method !== 'POST')
703
+ return new Response('Method not allowed', { status: 405 });
704
+ const body = (await request.json());
705
+ switch (body.op) {
706
+ case 'get':
707
+ return Response.json(await this.storeGet(body.key));
708
+ case 'put':
709
+ await this.storePut(body.key, body.value);
710
+ return Response.json(true);
711
+ case 'delete':
712
+ await this.storeDelete(body.key);
713
+ return Response.json(true);
714
+ case 'swap':
715
+ return Response.json(await this.storeSwap(body.key, body.expected, body.value));
716
+ case 'claim':
717
+ return Response.json(await this.storeClaim(body.key, body.expires));
718
+ case 'claimReplay':
719
+ return Response.json(await this.claimReplay(body.key, body.expires));
720
+ case 'releaseReplay':
721
+ await this.releaseReplay(body.key);
722
+ return Response.json(true);
723
+ default:
724
+ return new Response('Unknown operation', { status: 400 });
725
+ }
726
+ }
727
+ /** The replay shard's claims table, created on first use. */
728
+ replayClaims() {
729
+ const { sql } = this.state.storage;
730
+ if (!sql)
731
+ throw new Error('Store.durableObject: `replayShards` needs a SQLite-backed Durable Object (a `new_sqlite_classes` migration).');
732
+ sql.exec('CREATE TABLE IF NOT EXISTS mppx_replay_claims (key TEXT PRIMARY KEY, expires INTEGER NOT NULL)');
733
+ sql.exec('CREATE INDEX IF NOT EXISTS mppx_replay_claims_by_expiry ON mppx_replay_claims (expires)');
734
+ return sql;
735
+ }
736
+ /** Writes or deletes (`null`) a key and runs {@link DurableObjectStore.onWrite}. `previous` is the value the caller already read, if any. */
737
+ async commit(key, value, encoding, previous) {
738
+ const storage = this.state.storage;
739
+ if (this.onWrite === DurableObjectStore.prototype.onWrite) {
740
+ if (value === null)
741
+ await storage.delete(key);
742
+ else
743
+ await storage.put(key, value);
744
+ return;
745
+ }
746
+ // One transaction: the hook's writes commit with this one, and its failure rolls it back.
747
+ const write = async (target) => {
748
+ const before = previous === undefined ? ((await target.get(key)) ?? null) : previous;
749
+ if (value === null)
750
+ await target.delete(key);
751
+ else
752
+ await target.put(key, value);
753
+ await this.onWrite(key, decode(before, encoding), decode(value, encoding), target);
754
+ };
755
+ if (storage.transaction)
756
+ await storage.transaction(write);
757
+ else
758
+ await write(storage);
759
+ }
760
+ }
761
+ /** A stored value as the store sees it: JSON text parsed, a native value as is. */
762
+ function decode(raw, encoding) {
763
+ if (raw == null)
764
+ return null;
765
+ return encoding === 'native' ? raw : Json.parse(raw);
766
+ }
767
+ /**
768
+ * Atomic store backed by a SQL database: Postgres, SQLite or Cloudflare D1.
769
+ *
770
+ * Values live as JSON text in one table (`mppx_store` by default, created on
771
+ * first use). Every write is a single statement, and {@link AtomicStore.update}
772
+ * commits with a compare-and-swap on the stored value
773
+ * (`UPDATE … WHERE key = ? AND value = ? RETURNING`, or
774
+ * `INSERT … ON CONFLICT DO NOTHING RETURNING` for a new key), retrying from the
775
+ * current value after a jittered backoff when another writer won. It needs no
776
+ * interactive transactions, so it works over HTTP drivers and D1, and only
777
+ * `ON CONFLICT` and `RETURNING` (Postgres, SQLite 3.35+, D1, libSQL).
778
+ *
779
+ * Use {@link postgres}, {@link sqlite} or {@link d1} for common drivers, or pass
780
+ * any driver as a `query` function.
781
+ *
782
+ * @example
783
+ * ```ts
784
+ * import postgres from 'postgres'
785
+ * import { Store } from 'mppx/server'
786
+ *
787
+ * const db = postgres(process.env.DATABASE_URL!)
788
+ * const store = Store.sql({ dialect: 'postgres', query: (text, params) => db.unsafe(text, params) })
789
+ * ```
790
+ */
791
+ export function sql(driver, options) {
792
+ const table = options?.table ?? 'mppx_store';
793
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(table))
794
+ throw new Error(`Invalid SQL store table name: "${table}".`);
795
+ const maxAttempts = options?.maxAttempts ?? 32;
796
+ // Statements are written with `?`; Postgres numbers its placeholders.
797
+ const prepare = (text) => {
798
+ if (driver.dialect !== 'postgres')
799
+ return text;
800
+ let index = 0;
801
+ return text.replace(/\?/g, () => `$${++index}`);
802
+ };
803
+ let ready;
804
+ const ensureTable = () => {
805
+ if (options?.createTable === false)
806
+ return;
807
+ ready ??= Promise.resolve(driver.query(
808
+ // `expires` mirrors a replay marker's expiry, so a claim needs no JSON in SQL.
809
+ `CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires DOUBLE PRECISION)`, [])).then(() => { }, async (error) => {
810
+ // Concurrent `CREATE TABLE IF NOT EXISTS` from other processes can fail on
811
+ // Postgres (duplicate catalog row) although the table now exists: probe it.
812
+ const exists = await Promise.resolve(driver.query(`SELECT 1 FROM ${table} WHERE 1 = 0`, [])).then(() => true, () => false);
813
+ if (exists)
814
+ return;
815
+ ready = undefined;
816
+ throw error;
817
+ });
818
+ return ready;
819
+ };
820
+ async function query(text, params) {
821
+ await ensureTable();
822
+ return driver.query(prepare(text), params);
823
+ }
824
+ async function read(key) {
825
+ const [row] = await query(`SELECT value FROM ${table} WHERE key = ?`, [key]);
826
+ return row ? row.value : null;
827
+ }
828
+ /** The `expires` column for a serialized value: the expiry of a replay marker, else null. */
829
+ const expiresOf = (raw) => {
830
+ const value = Json.parse(raw);
831
+ return isReplayMarker(value) ? value.expires : null;
832
+ };
833
+ /** Writes `value` (or deletes, for `null`) only if the key still holds `expected`. */
834
+ async function swap(key, expected, value) {
835
+ let rows;
836
+ if (expected === null) {
837
+ if (value === null)
838
+ return { applied: true };
839
+ rows = await query(`INSERT INTO ${table} (key, value, expires) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING RETURNING key`, [key, value, expiresOf(value)]);
840
+ }
841
+ else if (value === null)
842
+ rows = await query(`DELETE FROM ${table} WHERE key = ? AND value = ? RETURNING key`, [
843
+ key,
844
+ expected,
845
+ ]);
846
+ else
847
+ rows = await query(`UPDATE ${table} SET value = ?, expires = ? WHERE key = ? AND value = ? RETURNING key`, [value, expiresOf(value), key, expected]);
848
+ return { applied: rows.length > 0 };
849
+ }
850
+ return from({
851
+ async get(key) {
852
+ const raw = await read(key);
853
+ if (raw == null)
854
+ return null;
855
+ return Json.parse(raw);
856
+ },
857
+ async put(key, value) {
858
+ const raw = Json.stringify(value);
859
+ 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)]);
860
+ },
861
+ async delete(key) {
862
+ await query(`DELETE FROM ${table} WHERE key = ?`, [key]);
863
+ },
864
+ update(key, fn) {
865
+ return compareAndSwap({
866
+ fn,
867
+ key,
868
+ label: 'SQL store',
869
+ maxAttempts,
870
+ read: () => read(key),
871
+ swap: (expected, value) => swap(key, expected, value),
872
+ });
873
+ },
874
+ // One statement: insert the marker, or replace one whose expiry has passed.
875
+ async tryClaim(key, expires) {
876
+ if (!Number.isFinite(expires) || expires <= Date.now())
877
+ return false;
878
+ 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`, [
879
+ key,
880
+ Json.stringify({ expires, type: 'mppx:replay' }),
881
+ expires,
882
+ Date.now(),
883
+ ]);
884
+ // The claim's own expiry may have passed while the statement ran.
885
+ return rows.length > 0 && expires > Date.now();
886
+ },
887
+ }, options);
888
+ }
889
+ /**
890
+ * Atomic SQL store on a Postgres client: `pg` (`Pool` or `Client`),
891
+ * `@neondatabase/serverless` (`Pool` or `neon()`), `@vercel/postgres`,
892
+ * Supabase's Postgres connection or PGlite. See {@link sql}.
893
+ *
894
+ * @example
895
+ * ```ts
896
+ * import { Pool } from 'pg'
897
+ * import { Store } from 'mppx/server'
898
+ *
899
+ * const store = Store.postgres(new Pool({ connectionString: process.env.DATABASE_URL }))
900
+ * ```
901
+ */
902
+ export function postgres(client, options) {
903
+ return sql({
904
+ dialect: 'postgres',
905
+ async query(text, params) {
906
+ const result = await client.query(text, params);
907
+ return Array.isArray(result) ? result : result.rows;
908
+ },
909
+ }, options);
910
+ }
911
+ /**
912
+ * Atomic SQL store on a SQLite database: `node:sqlite` (`DatabaseSync`),
913
+ * `better-sqlite3` or `bun:sqlite`. See {@link sql}. For libSQL or Turso, use
914
+ * {@link sql} with dialect `sqlite` and return the result set's rows:
915
+ * `Store.sql({ dialect: 'sqlite', query: async (sql, args) => (await client.execute({ sql, args: args as InArgs })).rows })`.
916
+ *
917
+ * @example
918
+ * ```ts
919
+ * import { DatabaseSync } from 'node:sqlite'
920
+ * import { Store } from 'mppx/server'
921
+ *
922
+ * const store = Store.sqlite(new DatabaseSync('mppx.db'))
923
+ * ```
924
+ */
925
+ export function sqlite(database, options) {
926
+ return sql({
927
+ dialect: 'sqlite',
928
+ async query(text, params) {
929
+ const statement = database.prepare(text);
930
+ // better-sqlite3 allows all() only on statements that return rows.
931
+ if (statement.reader === false && statement.run) {
932
+ await statement.run(...params);
933
+ return [];
934
+ }
935
+ return (await statement.all(...params));
936
+ },
937
+ }, options);
938
+ }
939
+ /**
940
+ * Atomic SQL store on a Cloudflare D1 database binding. See {@link sql}.
941
+ *
942
+ * @example
943
+ * ```ts
944
+ * import { Store } from 'mppx/server'
945
+ *
946
+ * export default {
947
+ * fetch(request: Request, env: { DB: D1Database }) {
948
+ * const store = Store.d1(env.DB)
949
+ * // ...
950
+ * },
951
+ * }
952
+ * ```
953
+ */
954
+ export function d1(database, options) {
955
+ return sql({
956
+ dialect: 'sqlite',
957
+ async query(text, params) {
958
+ const { results } = await database
959
+ .prepare(text)
960
+ .bind(...params)
961
+ .all();
962
+ return results;
963
+ },
964
+ }, options);
965
+ }
129
966
  /** In-memory store backed by a `Map`. JSON-roundtrips values to match production behavior. */
130
967
  export function memory(options) {
131
968
  const store = new Map();