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.
- package/CHANGELOG.md +37 -0
- package/dist/Errors.d.ts +94 -0
- package/dist/Errors.js +83 -0
- package/dist/Store.d.ts +199 -11
- package/dist/Store.js +527 -48
- package/dist/cli/cli.js +141 -137
- package/dist/cli/internal.js +28 -6
- package/dist/cli/plugins/index.js +16 -12
- package/dist/cli/validate/challenge.d.ts +4 -0
- package/dist/cli/validate/challenge.js +9 -6
- package/dist/cloudflare/ChannelStore.d.ts +97 -0
- package/dist/cloudflare/ChannelStore.js +304 -0
- package/dist/cloudflare/Channels.d.ts +92 -0
- package/dist/cloudflare/Channels.js +325 -0
- package/dist/cloudflare/index.d.ts +5 -0
- package/dist/cloudflare/index.js +5 -0
- package/dist/internal/sse.js +32 -15
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/stripe/server/Methods.d.ts +2 -0
- package/dist/stripe/server/Methods.js +2 -1
- package/dist/stripe/server/internal/record-payment.d.ts +49 -0
- package/dist/stripe/server/internal/record-payment.js +75 -18
- package/dist/tempo/Methods.d.ts +8 -0
- package/dist/tempo/Methods.js +18 -2
- package/dist/tempo/client/Charge.d.ts +32 -1
- package/dist/tempo/client/Charge.js +201 -3
- package/dist/tempo/client/Methods.d.ts +16 -0
- package/dist/tempo/internal/fee-payer.d.ts +0 -25
- package/dist/tempo/internal/fee-payer.js +50 -3
- package/dist/tempo/internal/zone.d.ts +79 -0
- package/dist/tempo/internal/zone.js +133 -0
- package/dist/tempo/server/Charge.d.ts +7 -3
- package/dist/tempo/server/Charge.js +98 -4
- package/dist/tempo/server/Methods.d.ts +40 -0
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/session/server/ChannelStore.d.ts +5 -0
- package/dist/tempo/session/server/ChannelStore.js +2 -2
- package/dist/tempo/session/server/CredentialVerification.js +1 -1
- package/dist/validation/core.js +2 -0
- 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 :
|
|
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' ?
|
|
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)
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
439
|
+
if (native)
|
|
440
|
+
assertComparable(value);
|
|
441
|
+
await remote.put(key, native ? value : Json.stringify(value));
|
|
202
442
|
},
|
|
203
443
|
async delete(key) {
|
|
204
|
-
|
|
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: () =>
|
|
213
|
-
swap: (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
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
/**
|
|
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
|
|
707
|
+
return Response.json(await this.storeGet(body.key));
|
|
259
708
|
case 'put':
|
|
260
|
-
await
|
|
709
|
+
await this.storePut(body.key, body.value);
|
|
261
710
|
return Response.json(true);
|
|
262
711
|
case 'delete':
|
|
263
|
-
await
|
|
712
|
+
await this.storeDelete(body.key);
|
|
264
713
|
return Response.json(true);
|
|
265
|
-
case 'swap':
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return Response.json(
|
|
271
|
-
|
|
272
|
-
|
|
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.
|