mppx 0.17.3 → 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 (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/Errors.d.ts +94 -0
  3. package/dist/Errors.js +83 -0
  4. package/dist/Store.d.ts +199 -11
  5. package/dist/Store.js +527 -48
  6. package/dist/cli/cli.js +141 -137
  7. package/dist/cli/internal.js +28 -6
  8. package/dist/cli/plugins/index.js +16 -12
  9. package/dist/cli/validate/challenge.d.ts +4 -0
  10. package/dist/cli/validate/challenge.js +9 -6
  11. package/dist/cloudflare/ChannelStore.d.ts +97 -0
  12. package/dist/cloudflare/ChannelStore.js +304 -0
  13. package/dist/cloudflare/Channels.d.ts +92 -0
  14. package/dist/cloudflare/Channels.js +325 -0
  15. package/dist/cloudflare/index.d.ts +5 -0
  16. package/dist/cloudflare/index.js +5 -0
  17. package/dist/internal/sse.js +32 -15
  18. package/dist/internal/version.d.ts +2 -2
  19. package/dist/internal/version.js +1 -1
  20. package/dist/stripe/server/Methods.d.ts +2 -0
  21. package/dist/stripe/server/Methods.js +2 -1
  22. package/dist/stripe/server/internal/record-payment.d.ts +49 -0
  23. package/dist/stripe/server/internal/record-payment.js +75 -18
  24. package/dist/tempo/Methods.d.ts +8 -0
  25. package/dist/tempo/Methods.js +18 -2
  26. package/dist/tempo/client/Charge.d.ts +32 -1
  27. package/dist/tempo/client/Charge.js +201 -3
  28. package/dist/tempo/client/Methods.d.ts +16 -0
  29. package/dist/tempo/internal/fee-payer.d.ts +0 -25
  30. package/dist/tempo/internal/fee-payer.js +50 -3
  31. package/dist/tempo/internal/zone.d.ts +79 -0
  32. package/dist/tempo/internal/zone.js +133 -0
  33. package/dist/tempo/server/Charge.d.ts +7 -3
  34. package/dist/tempo/server/Charge.js +98 -4
  35. package/dist/tempo/server/Methods.d.ts +40 -0
  36. package/dist/tempo/server/internal/html.gen.js +1 -1
  37. package/dist/tempo/session/server/ChannelStore.d.ts +5 -0
  38. package/dist/tempo/session/server/ChannelStore.js +2 -2
  39. package/dist/tempo/session/server/CredentialVerification.js +1 -1
  40. package/dist/validation/core.js +2 -0
  41. package/package.json +5 -1
package/dist/Store.js CHANGED
@@ -55,16 +55,19 @@ function isReplayMarker(value) {
55
55
  /**
56
56
  * Runs `fn` on the stored value and commits its change with `swap`, a
57
57
  * compare-and-swap. When another writer won, retries from the value `swap`
58
- * returned (or a fresh read) after a jittered backoff.
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.
59
60
  */
60
61
  async function compareAndSwap(parameters) {
61
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));
62
65
  let raw = await read();
63
66
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
64
- const change = fn(raw == null ? null : Json.parse(raw));
67
+ const change = fn(raw == null ? null : decode(raw));
65
68
  if (change.op === 'noop')
66
69
  return change.result;
67
- const swapped = await swap(raw, change.op === 'set' ? Json.stringify(change.value) : null);
70
+ const swapped = await swap(raw, change.op === 'set' ? encode(change.value) : null);
68
71
  if (swapped.applied)
69
72
  return change.result;
70
73
  if (attempt + 1 === maxAttempts)
@@ -74,6 +77,167 @@ async function compareAndSwap(parameters) {
74
77
  }
75
78
  throw new Error(`${label} update of "${key}" conflicted ${maxAttempts} times.`);
76
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
+ }
77
241
  const keyPrefixCache = new WeakMap();
78
242
  export function from(store, options) {
79
243
  return withKeyPrefix(store, options?.keyPrefix);
@@ -148,10 +312,18 @@ export function cloudflare(kv, options) {
148
312
  ...wrapJsonUpdate(kv.update),
149
313
  }, options);
150
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+$/;
151
321
  /**
152
322
  * Atomic store backed by Cloudflare Durable Objects.
153
323
  *
154
- * Each key lives in its own Durable Object instance (`idFromName(key)`), so
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
155
327
  * reads and writes of one key are linearizable across every Worker isolate and
156
328
  * location, while different keys (such as different payment channels) scale
157
329
  * out across instances. {@link AtomicStore.update} runs `fn` in the Worker and
@@ -161,7 +333,9 @@ export function cloudflare(kv, options) {
161
333
  * object call.
162
334
  *
163
335
  * Bind {@link DurableObjectStore} as a SQLite-backed Durable Object and pass
164
- * its namespace.
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.
165
339
  *
166
340
  * @example
167
341
  * ```ts
@@ -176,12 +350,36 @@ export function cloudflare(kv, options) {
176
350
  * },
177
351
  * }
178
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
+ * ```
179
364
  */
180
365
  export function durableObject(namespace, options) {
181
366
  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/', {
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/', {
185
383
  body: JSON.stringify(body),
186
384
  headers: { 'content-type': 'application/json' },
187
385
  method: 'POST',
@@ -190,33 +388,86 @@ export function durableObject(namespace, options) {
190
388
  throw new Error(`Durable Object store request failed (${response.status}): ${await response.text()}`);
191
389
  return (await response.json());
192
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
+ })();
193
431
  return from({
194
432
  async get(key) {
195
- const raw = await call(key, { op: 'get', key });
433
+ const raw = await remote.get(key);
196
434
  if (raw == null)
197
435
  return null;
198
- return Json.parse(raw);
436
+ return native ? raw : Json.parse(raw);
199
437
  },
200
438
  async put(key, value) {
201
- await call(key, { op: 'put', key, value: Json.stringify(value) });
439
+ if (native)
440
+ assertComparable(value);
441
+ await remote.put(key, native ? value : Json.stringify(value));
202
442
  },
203
443
  async delete(key) {
204
- await call(key, { op: '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)]);
205
448
  },
206
449
  update(key, fn) {
207
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),
208
459
  fn,
209
460
  key,
210
461
  label: 'Durable Object store',
211
462
  maxAttempts,
212
- read: () => call(key, { op: 'get', key }),
213
- swap: (expected, value) => call(key, { op: 'swap', key, expected, value }),
463
+ read: async () => (await remote.get(key)) ?? null,
464
+ swap: (expected, value) => remote.swap(key, expected, value),
214
465
  });
215
466
  },
216
467
  async tryClaim(key, expires) {
217
468
  if (!Number.isFinite(expires) || expires <= Date.now())
218
469
  return false;
219
- return call(key, { op: 'claim', key, expires });
470
+ return replay ? replay.claim(key, expires) : remote.claim(key, expires);
220
471
  },
221
472
  }, options);
222
473
  }
@@ -225,7 +476,8 @@ export function durableObject(namespace, options) {
225
476
  *
226
477
  * Export a subclass of it from your Worker and bind it in
227
478
  * `wrangler.jsonc` with a `new_sqlite_classes` migration. It needs no
228
- * `cloudflare:workers` import, so it bundles anywhere.
479
+ * `cloudflare:workers` import, so it bundles anywhere; for Workers RPC, extend
480
+ * {@link DurableObjectStore.rpc} instead.
229
481
  *
230
482
  * @example
231
483
  * ```jsonc
@@ -236,54 +488,281 @@ export function durableObject(namespace, options) {
236
488
  * ```
237
489
  */
238
490
  export class DurableObjectStore {
239
- #storage;
240
- constructor(state, _env) {
241
- this.#storage = state.storage;
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;
242
504
  }
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. */
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. */
244
701
  async fetch(request) {
245
702
  if (request.method !== 'POST')
246
703
  return new Response('Method not allowed', { status: 405 });
247
704
  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
705
  switch (body.op) {
257
706
  case 'get':
258
- return Response.json(await read());
707
+ return Response.json(await this.storeGet(body.key));
259
708
  case 'put':
260
- await write(body.value);
709
+ await this.storePut(body.key, body.value);
261
710
  return Response.json(true);
262
711
  case 'delete':
263
- await write(null);
712
+ await this.storeDelete(body.key);
264
713
  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' }));
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);
281
722
  return Response.json(true);
282
- }
283
723
  default:
284
724
  return new Response('Unknown operation', { status: 400 });
285
725
  }
286
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);
287
766
  }
288
767
  /**
289
768
  * Atomic store backed by a SQL database: Postgres, SQLite or Cloudflare D1.