castle-web-sdk 0.4.44 → 0.4.46
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/README.md +487 -53
- package/dist/agent.js +4 -4
- package/dist/avatar.d.ts +12 -0
- package/dist/avatar.js +23 -0
- package/dist/castle.d.ts +10 -1
- package/dist/castle.js +5 -0
- package/dist/commands.d.ts +229 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +6 -0
- package/dist/experiments.d.ts +15 -0
- package/dist/experiments.js +83 -0
- package/dist/hub.d.ts +85 -0
- package/dist/hub.js +107 -0
- package/dist/multiplayer.d.ts +88 -6
- package/dist/multiplayer.js +219 -8
- package/dist/save.d.ts +197 -0
- package/dist/save.js +525 -0
- package/dist/saveCoordinator.d.ts +39 -0
- package/dist/saveCoordinator.js +252 -0
- package/dist/server/hub.d.ts +169 -0
- package/dist/server/hub.js +125 -0
- package/dist/server/platformHandle.d.ts +51 -2
- package/dist/server/storage.d.ts +58 -4
- package/dist/server/storage.js +25 -5
- package/dist/server/wrapper.d.ts +147 -6
- package/dist/server/wrapper.js +741 -34
- package/dist/store.d.ts +3 -2
- package/dist/store.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +13 -1
- package/package.json +1 -1
package/dist/save.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { CastleError } from "./errors";
|
|
2
|
+
import { encodeStorageValue } from "./storageJson";
|
|
3
|
+
export class SaveError extends CastleError {
|
|
4
|
+
operationId;
|
|
5
|
+
retryable;
|
|
6
|
+
committed;
|
|
7
|
+
receipt;
|
|
8
|
+
retryAfterMs;
|
|
9
|
+
target;
|
|
10
|
+
constructor(envelope) {
|
|
11
|
+
super({
|
|
12
|
+
code: envelope.code,
|
|
13
|
+
message: envelope.message,
|
|
14
|
+
extensions: envelope,
|
|
15
|
+
});
|
|
16
|
+
this.name = "SaveError";
|
|
17
|
+
this.operationId = envelope.operationId;
|
|
18
|
+
this.retryable = envelope.retryable;
|
|
19
|
+
this.committed = envelope.committed;
|
|
20
|
+
this.receipt = envelope.receipt;
|
|
21
|
+
this.retryAfterMs = envelope.retryAfterMs;
|
|
22
|
+
this.target = envelope.target;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads the envelope out of whatever a host or platform threw. A failure that
|
|
27
|
+
* does not say whether it committed is treated as one that might have.
|
|
28
|
+
*/
|
|
29
|
+
export function asSaveError(error, operationId) {
|
|
30
|
+
if (error instanceof SaveError)
|
|
31
|
+
return error;
|
|
32
|
+
const { extensions, ...value } = error && typeof error === "object"
|
|
33
|
+
? error
|
|
34
|
+
: {};
|
|
35
|
+
const details = {
|
|
36
|
+
...value,
|
|
37
|
+
...extensions,
|
|
38
|
+
};
|
|
39
|
+
const committed = details.committed;
|
|
40
|
+
return new SaveError({
|
|
41
|
+
...details,
|
|
42
|
+
code: typeof details.code === "string"
|
|
43
|
+
? details.code
|
|
44
|
+
: "CAULDRON_STORAGE_UNAVAILABLE",
|
|
45
|
+
message: error instanceof Error
|
|
46
|
+
? error.message
|
|
47
|
+
: typeof details.message === "string"
|
|
48
|
+
? details.message
|
|
49
|
+
: "Storage request failed.",
|
|
50
|
+
operationId: typeof details.operationId === "string"
|
|
51
|
+
? details.operationId
|
|
52
|
+
: operationId,
|
|
53
|
+
retryable: details.retryable === true,
|
|
54
|
+
committed: committed === true || committed === false ? committed : "unknown",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Copies of the platform's limits.
|
|
58
|
+
const MAX_KEY_BYTES = 128;
|
|
59
|
+
const MAX_SUBJECT_BYTES = 256;
|
|
60
|
+
const MAX_VALUE_BYTES = 256 * 1024;
|
|
61
|
+
const encoder = new TextEncoder();
|
|
62
|
+
const byteLength = (text) => encoder.encode(text).length;
|
|
63
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
64
|
+
function invalid(message) {
|
|
65
|
+
throw new SaveError({
|
|
66
|
+
code: "CAULDRON_STORAGE_INVALID_ARGUMENT",
|
|
67
|
+
message,
|
|
68
|
+
retryable: false,
|
|
69
|
+
committed: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Nonempty text the platform can store: no NUL, no lone surrogate. */
|
|
73
|
+
function checkText(value, field, maxBytes) {
|
|
74
|
+
if (typeof value !== "string" ||
|
|
75
|
+
value.length === 0 ||
|
|
76
|
+
value.includes("\0") ||
|
|
77
|
+
LONE_SURROGATE.test(value) ||
|
|
78
|
+
(maxBytes !== undefined && byteLength(value) > maxBytes)) {
|
|
79
|
+
invalid(`Invalid ${field}.`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function checkKey(key, target) {
|
|
84
|
+
if (!checkText(key, "key", MAX_KEY_BYTES).startsWith(target.prefix)) {
|
|
85
|
+
invalid(`Key ${String(key)} is outside its target ${target.prefix}.`);
|
|
86
|
+
}
|
|
87
|
+
return key;
|
|
88
|
+
}
|
|
89
|
+
function checkList(value, field) {
|
|
90
|
+
if (!Array.isArray(value))
|
|
91
|
+
invalid(`${field} must be an array.`);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A session server has no current player, so it names a user target's player
|
|
96
|
+
* and cannot reach private storage.
|
|
97
|
+
*/
|
|
98
|
+
export function validateSaveTarget(target, server = false) {
|
|
99
|
+
if (!target || !["deck", "user", "private"].includes(target.scope)) {
|
|
100
|
+
invalid("Invalid storage scope.");
|
|
101
|
+
}
|
|
102
|
+
checkText(target.prefix, "target prefix", MAX_KEY_BYTES);
|
|
103
|
+
if (target.subject !== undefined) {
|
|
104
|
+
if (target.scope !== "user")
|
|
105
|
+
invalid("Only user targets take a subject.");
|
|
106
|
+
checkText(target.subject, "subject", MAX_SUBJECT_BYTES);
|
|
107
|
+
}
|
|
108
|
+
if (server && target.scope === "private") {
|
|
109
|
+
throw new SaveError({
|
|
110
|
+
code: "CAULDRON_STORAGE_FORBIDDEN",
|
|
111
|
+
message: "Session servers cannot access private storage.",
|
|
112
|
+
retryable: false,
|
|
113
|
+
committed: false,
|
|
114
|
+
target,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (server && target.scope === "user" && target.subject === undefined) {
|
|
118
|
+
invalid("A session server's user target needs a subject.");
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
scope: target.scope,
|
|
122
|
+
...(target.subject === undefined ? {} : { subject: target.subject }),
|
|
123
|
+
prefix: target.prefix,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function saveTargetKey(target) {
|
|
127
|
+
return JSON.stringify([target.scope, target.subject ?? null, target.prefix]);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Finds the caller's own target for one the platform names. The platform
|
|
131
|
+
* fills in the current player as a user target's subject, so a target the
|
|
132
|
+
* caller left without a subject matches any subject.
|
|
133
|
+
*/
|
|
134
|
+
export function matchSaveTarget(target, candidates) {
|
|
135
|
+
const scope = String(target.scope).toLowerCase();
|
|
136
|
+
const sameTarget = (candidate) => candidate.scope === scope && candidate.prefix === target.prefix;
|
|
137
|
+
const found = candidates.find((candidate) => sameTarget(candidate) && candidate.subject === target.subject) ??
|
|
138
|
+
candidates.find((candidate) => sameTarget(candidate) && candidate.subject === undefined);
|
|
139
|
+
if (found)
|
|
140
|
+
return found;
|
|
141
|
+
return {
|
|
142
|
+
scope,
|
|
143
|
+
...(scope === "user" && target.subject ? { subject: target.subject } : {}),
|
|
144
|
+
prefix: target.prefix,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Encoding a value also copies it, so later changes to the caller's object
|
|
148
|
+
// cannot reach a request that is waiting to be sent.
|
|
149
|
+
function encodeRows(rows, target) {
|
|
150
|
+
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
|
|
151
|
+
invalid("Rows must be an object of keys to JSON values.");
|
|
152
|
+
}
|
|
153
|
+
const encoded = {};
|
|
154
|
+
for (const [key, value] of Object.entries(rows)) {
|
|
155
|
+
checkKey(key, target);
|
|
156
|
+
let text;
|
|
157
|
+
try {
|
|
158
|
+
text = encodeStorageValue(value, "Store.save");
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
throw new SaveError({
|
|
162
|
+
code: error instanceof CastleError
|
|
163
|
+
? error.code
|
|
164
|
+
: "CAULDRON_STORAGE_INVALID_VALUE",
|
|
165
|
+
message: error instanceof Error ? error.message : "Invalid JSON value.",
|
|
166
|
+
retryable: false,
|
|
167
|
+
committed: false,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (byteLength(text) > MAX_VALUE_BYTES) {
|
|
171
|
+
invalid(`The value of ${key} is over 256 KiB.`);
|
|
172
|
+
}
|
|
173
|
+
encoded[key] = JSON.parse(text);
|
|
174
|
+
}
|
|
175
|
+
return encoded;
|
|
176
|
+
}
|
|
177
|
+
function encodeReads(reads, server) {
|
|
178
|
+
return checkList(reads, "load").map((value) => {
|
|
179
|
+
const read = value;
|
|
180
|
+
const target = validateSaveTarget(read.target, server);
|
|
181
|
+
const select = read.select;
|
|
182
|
+
if (select === undefined)
|
|
183
|
+
return { target };
|
|
184
|
+
if ("prefix" in select === "keys" in select) {
|
|
185
|
+
invalid("A selection has either keys or a prefix.");
|
|
186
|
+
}
|
|
187
|
+
if ("prefix" in select) {
|
|
188
|
+
checkKey(select.prefix, target);
|
|
189
|
+
return { target, select: { prefix: select.prefix } };
|
|
190
|
+
}
|
|
191
|
+
const keys = checkList(select.keys, "selection keys");
|
|
192
|
+
return {
|
|
193
|
+
target,
|
|
194
|
+
select: { keys: keys.map((key) => checkKey(key, target)) },
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function encodeInitial(initial, server) {
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
return checkList(initial, "initial").map((value) => {
|
|
201
|
+
const entry = value;
|
|
202
|
+
const target = validateSaveTarget(entry.target, server);
|
|
203
|
+
if (seen.has(saveTargetKey(target))) {
|
|
204
|
+
invalid("Each initial target appears once.");
|
|
205
|
+
}
|
|
206
|
+
seen.add(saveTargetKey(target));
|
|
207
|
+
return { target, rows: encodeRows(entry.rows, target) };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function encodeChange(change, server) {
|
|
211
|
+
const target = validateSaveTarget(change.target, server);
|
|
212
|
+
if (change.expectedRevision !== null) {
|
|
213
|
+
checkText(change.expectedRevision, "expected revision");
|
|
214
|
+
}
|
|
215
|
+
if (change.writer !== undefined)
|
|
216
|
+
checkText(change.writer, "writer");
|
|
217
|
+
if (change.replace !== undefined) {
|
|
218
|
+
if (change.put !== undefined || change.remove !== undefined) {
|
|
219
|
+
invalid("replace cannot be combined with put or remove.");
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
target,
|
|
223
|
+
expectedRevision: change.expectedRevision,
|
|
224
|
+
...(change.writer === undefined ? {} : { writer: change.writer }),
|
|
225
|
+
replace: encodeRows(change.replace, target),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const put = change.put === undefined ? undefined : encodeRows(change.put, target);
|
|
229
|
+
const remove = change.remove === undefined
|
|
230
|
+
? undefined
|
|
231
|
+
: checkList(change.remove, "remove").map((key) => checkKey(key, target));
|
|
232
|
+
if (remove?.some((key) => put && Object.hasOwn(put, key))) {
|
|
233
|
+
invalid("A key cannot be both put and removed.");
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
target,
|
|
237
|
+
expectedRevision: change.expectedRevision,
|
|
238
|
+
...(change.writer === undefined ? {} : { writer: change.writer }),
|
|
239
|
+
...(put === undefined ? {} : { put }),
|
|
240
|
+
...(remove === undefined ? {} : { remove }),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function encodeCheck(check, server) {
|
|
244
|
+
const target = validateSaveTarget(check.target, server);
|
|
245
|
+
if (check.expectedRevision === undefined && check.writer === undefined) {
|
|
246
|
+
invalid("A check needs an expected revision or a writer.");
|
|
247
|
+
}
|
|
248
|
+
if (check.expectedRevision !== undefined) {
|
|
249
|
+
checkText(check.expectedRevision, "expected revision");
|
|
250
|
+
}
|
|
251
|
+
if (check.writer !== undefined)
|
|
252
|
+
checkText(check.writer, "writer");
|
|
253
|
+
return {
|
|
254
|
+
target,
|
|
255
|
+
...(check.expectedRevision === undefined
|
|
256
|
+
? {}
|
|
257
|
+
: { expectedRevision: check.expectedRevision }),
|
|
258
|
+
...(check.writer === undefined ? {} : { writer: check.writer }),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function encodeTransaction(input, server) {
|
|
262
|
+
const changes = checkList(input.changes, "changes").map((change) => encodeChange(change, server));
|
|
263
|
+
const keys = changes.map((change) => saveTargetKey(change.target));
|
|
264
|
+
if (new Set(keys).size !== keys.length) {
|
|
265
|
+
invalid("Each changed target appears once.");
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
operationId: input.operationId,
|
|
269
|
+
expectedAccess: checkText(input.expectedAccess, "expected access"),
|
|
270
|
+
...(input.checks === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: {
|
|
273
|
+
checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server)),
|
|
274
|
+
}),
|
|
275
|
+
changes,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Validates and copies a request; the result is what goes on the wire. */
|
|
279
|
+
export function encodeSaveInput(verb, input, server = false) {
|
|
280
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
281
|
+
invalid("Save input must be an object.");
|
|
282
|
+
}
|
|
283
|
+
if (verb === "snapshot")
|
|
284
|
+
return { targets: encodeReads(input.targets, server) };
|
|
285
|
+
checkText(input.operationId, "operation id", MAX_KEY_BYTES);
|
|
286
|
+
if (verb === "operation")
|
|
287
|
+
return { operationId: input.operationId };
|
|
288
|
+
if (verb === "transaction") {
|
|
289
|
+
return {
|
|
290
|
+
...encodeTransaction(input, server),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (input.expectedAccess !== undefined) {
|
|
294
|
+
checkText(input.expectedAccess, "expected access");
|
|
295
|
+
}
|
|
296
|
+
if (verb === "initialize" && input.initial === undefined) {
|
|
297
|
+
invalid("initialize needs initial targets.");
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
operationId: input.operationId,
|
|
301
|
+
...(input.expectedAccess === undefined
|
|
302
|
+
? {}
|
|
303
|
+
: { expectedAccess: input.expectedAccess }),
|
|
304
|
+
...(verb === "claim"
|
|
305
|
+
? { target: validateSaveTarget(input.target, server) }
|
|
306
|
+
: {}),
|
|
307
|
+
...(input.initial === undefined
|
|
308
|
+
? {}
|
|
309
|
+
: { initial: encodeInitial(input.initial, server) }),
|
|
310
|
+
...(input.load === undefined
|
|
311
|
+
? {}
|
|
312
|
+
: { load: encodeReads(input.load, server) }),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function invalidResponse() {
|
|
316
|
+
throw new SaveError({
|
|
317
|
+
code: "CAULDRON_STORAGE_INVALID_RESPONSE",
|
|
318
|
+
message: "Storage returned an invalid save response.",
|
|
319
|
+
retryable: false,
|
|
320
|
+
committed: "unknown",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
function isPlatformTarget(target) {
|
|
324
|
+
const value = target;
|
|
325
|
+
return (!!value &&
|
|
326
|
+
typeof value.scope === "string" &&
|
|
327
|
+
["deck", "user", "private"].includes(value.scope.toLowerCase()) &&
|
|
328
|
+
typeof value.prefix === "string" &&
|
|
329
|
+
value.prefix.length > 0);
|
|
330
|
+
}
|
|
331
|
+
// Snapshot targets come back in the order they were read, which is how each
|
|
332
|
+
// is matched to the caller's own target.
|
|
333
|
+
function decodeSnapshot(value, reads) {
|
|
334
|
+
const snapshot = value;
|
|
335
|
+
if (!snapshot ||
|
|
336
|
+
typeof snapshot.access !== "string" ||
|
|
337
|
+
!Array.isArray(snapshot.targets) ||
|
|
338
|
+
snapshot.targets.length !== reads.length) {
|
|
339
|
+
invalidResponse();
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
access: snapshot.access,
|
|
343
|
+
targets: snapshot.targets.map((entry, index) => {
|
|
344
|
+
const read = reads[index].target;
|
|
345
|
+
if (!entry ||
|
|
346
|
+
!isPlatformTarget(entry.target) ||
|
|
347
|
+
entry.target.prefix !== read.prefix ||
|
|
348
|
+
entry.target.scope.toLowerCase() !== read.scope ||
|
|
349
|
+
(entry.revision !== null && typeof entry.revision !== "string") ||
|
|
350
|
+
typeof entry.complete !== "boolean" ||
|
|
351
|
+
!entry.rows ||
|
|
352
|
+
typeof entry.rows !== "object" ||
|
|
353
|
+
Array.isArray(entry.rows)) {
|
|
354
|
+
invalidResponse();
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
target: read,
|
|
358
|
+
revision: entry.revision,
|
|
359
|
+
rows: entry.rows,
|
|
360
|
+
complete: entry.complete,
|
|
361
|
+
};
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function decodeReceipt(value, operationId, targets) {
|
|
366
|
+
const receipt = value;
|
|
367
|
+
if (!receipt ||
|
|
368
|
+
receipt.operationId !== operationId ||
|
|
369
|
+
typeof receipt.access !== "string" ||
|
|
370
|
+
!Array.isArray(receipt.revisions) ||
|
|
371
|
+
receipt.revisions.some((entry) => !isPlatformTarget(entry?.target) || typeof entry.revision !== "string")) {
|
|
372
|
+
invalidResponse();
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
operationId: receipt.operationId,
|
|
376
|
+
access: receipt.access,
|
|
377
|
+
revisions: receipt.revisions.map((entry) => ({
|
|
378
|
+
target: matchSaveTarget(entry.target, targets),
|
|
379
|
+
revision: entry.revision,
|
|
380
|
+
})),
|
|
381
|
+
...(typeof receipt.writer === "string" ? { writer: receipt.writer } : {}),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function decodeOpened(value, operationId, targets, reads) {
|
|
385
|
+
const opened = value;
|
|
386
|
+
if (!opened)
|
|
387
|
+
invalidResponse();
|
|
388
|
+
return {
|
|
389
|
+
receipt: decodeReceipt(opened.receipt, operationId, targets),
|
|
390
|
+
snapshot: decodeSnapshot(opened.snapshot, reads),
|
|
391
|
+
...(typeof opened.writer === "string" ? { writer: opened.writer } : {}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function defaultLoad(targets) {
|
|
395
|
+
const unique = new Map(targets.map((target) => [saveTargetKey(target), target]));
|
|
396
|
+
return [...unique.values()].map((target) => ({ target }));
|
|
397
|
+
}
|
|
398
|
+
export function createSaveApi(call, server = false) {
|
|
399
|
+
async function send(verb, input) {
|
|
400
|
+
const operationId = typeof input?.operationId === "string" ? input.operationId : undefined;
|
|
401
|
+
let encoded;
|
|
402
|
+
try {
|
|
403
|
+
encoded = encodeSaveInput(verb, input, server);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
// Nothing was sent.
|
|
407
|
+
const failure = asSaveError(error);
|
|
408
|
+
throw new SaveError({
|
|
409
|
+
...failure.extensions,
|
|
410
|
+
code: failure.code,
|
|
411
|
+
message: failure.message,
|
|
412
|
+
operationId,
|
|
413
|
+
retryable: false,
|
|
414
|
+
committed: false,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
return await call(verb, encoded);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
throw asSaveError(error, operationId);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
async snapshot(input) {
|
|
426
|
+
return decodeSnapshot(await send("snapshot", { ...input }), input.targets);
|
|
427
|
+
},
|
|
428
|
+
async transaction(input) {
|
|
429
|
+
const result = await send("transaction", { ...input });
|
|
430
|
+
return decodeReceipt(result, input.operationId, input.changes.map((change) => change.target));
|
|
431
|
+
},
|
|
432
|
+
async claim(input) {
|
|
433
|
+
const initial = (input.initial ?? []).map((entry) => entry.target);
|
|
434
|
+
const load = input.load ?? defaultLoad([input.target, ...initial]);
|
|
435
|
+
const result = await send("claim", { ...input, load });
|
|
436
|
+
const opened = decodeOpened(result, input.operationId, [input.target, ...initial], load);
|
|
437
|
+
if (opened.writer === undefined)
|
|
438
|
+
invalidResponse();
|
|
439
|
+
return { ...opened, writer: opened.writer };
|
|
440
|
+
},
|
|
441
|
+
async initialize(input) {
|
|
442
|
+
const initial = input.initial.map((entry) => entry.target);
|
|
443
|
+
const load = input.load ?? defaultLoad(initial);
|
|
444
|
+
const result = await send("initialize", { ...input, load });
|
|
445
|
+
const { receipt, snapshot } = decodeOpened(result, input.operationId, initial, load);
|
|
446
|
+
return { receipt, snapshot };
|
|
447
|
+
},
|
|
448
|
+
async operation(operationId) {
|
|
449
|
+
const result = (await send("operation", {
|
|
450
|
+
operationId,
|
|
451
|
+
}));
|
|
452
|
+
if (result?.state === "unknown")
|
|
453
|
+
return { state: "unknown" };
|
|
454
|
+
if (result?.state !== "committed")
|
|
455
|
+
invalidResponse();
|
|
456
|
+
return {
|
|
457
|
+
state: "committed",
|
|
458
|
+
receipt: decodeReceipt(result.receipt, operationId, []),
|
|
459
|
+
};
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
export function createSaveBucket(api, scope, subject) {
|
|
464
|
+
const target = (prefix) => ({
|
|
465
|
+
scope,
|
|
466
|
+
...(subject === undefined ? {} : { subject }),
|
|
467
|
+
prefix,
|
|
468
|
+
});
|
|
469
|
+
const read = ({ prefix, select }) => ({
|
|
470
|
+
target: target(prefix),
|
|
471
|
+
...(select ? { select } : {}),
|
|
472
|
+
});
|
|
473
|
+
const initial = (entries) => entries.map(({ prefix, rows }) => ({ target: target(prefix), rows }));
|
|
474
|
+
return {
|
|
475
|
+
target,
|
|
476
|
+
snapshot: (input) => api.snapshot({ targets: [read(input)] }),
|
|
477
|
+
transaction: ({ checks, changes, ...input }) => api.transaction({
|
|
478
|
+
...input,
|
|
479
|
+
...(checks
|
|
480
|
+
? {
|
|
481
|
+
checks: checks.map(({ prefix, ...check }) => ({
|
|
482
|
+
...check,
|
|
483
|
+
target: target(prefix),
|
|
484
|
+
})),
|
|
485
|
+
}
|
|
486
|
+
: {}),
|
|
487
|
+
changes: changes.map(({ prefix, ...change }) => ({
|
|
488
|
+
...change,
|
|
489
|
+
target: target(prefix),
|
|
490
|
+
})),
|
|
491
|
+
}),
|
|
492
|
+
claim: ({ prefix, initial: entries, load, ...input }) => api.claim({
|
|
493
|
+
...input,
|
|
494
|
+
target: target(prefix),
|
|
495
|
+
...(entries ? { initial: initial(entries) } : {}),
|
|
496
|
+
...(load ? { load: load.map(read) } : {}),
|
|
497
|
+
}),
|
|
498
|
+
initialize: ({ initial: entries, load, ...input }) => api.initialize({
|
|
499
|
+
...input,
|
|
500
|
+
initial: initial(entries),
|
|
501
|
+
...(load ? { load: load.map(read) } : {}),
|
|
502
|
+
}),
|
|
503
|
+
operation: (operationId) => api.operation(operationId),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Refuses a request bigger than a transport carries. An atomic save is never
|
|
508
|
+
* split, so the whole encoded request has to fit.
|
|
509
|
+
*/
|
|
510
|
+
export function preflightSaveRequest(verb, request, limitBytes) {
|
|
511
|
+
const encodedBytes = byteLength(JSON.stringify(request));
|
|
512
|
+
if (encodedBytes <= limitBytes)
|
|
513
|
+
return;
|
|
514
|
+
const wire = request;
|
|
515
|
+
const operationId = wire.input?.operationId ?? wire.operationId;
|
|
516
|
+
throw new SaveError({
|
|
517
|
+
code: "CAULDRON_STORAGE_TOO_LARGE",
|
|
518
|
+
message: `The ${verb} request is ${encodedBytes} bytes, over this transport's ${limitBytes}.`,
|
|
519
|
+
operationId: typeof operationId === "string" ? operationId : undefined,
|
|
520
|
+
retryable: false,
|
|
521
|
+
committed: false,
|
|
522
|
+
encodedBytes,
|
|
523
|
+
limitBytes,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SaveError } from "./save";
|
|
2
|
+
import type { SaveApi, SaveChange, SaveCheck, SaveReceipt, SaveRevision, SaveSnapshot, SaveTarget, SaveWriter } from "./save";
|
|
3
|
+
export type SaveStatus = "pending" | "saved" | "blocked";
|
|
4
|
+
export type CoordinatedChange = Omit<SaveChange, "expectedRevision"> & {
|
|
5
|
+
/** Omitted takes the target's revision from the latest receipt. */
|
|
6
|
+
expectedRevision?: SaveRevision | null;
|
|
7
|
+
};
|
|
8
|
+
export interface CoordinatedSave {
|
|
9
|
+
operationId: string;
|
|
10
|
+
changes: CoordinatedChange[];
|
|
11
|
+
checks?: SaveCheck[];
|
|
12
|
+
}
|
|
13
|
+
export interface SaveCoordinator {
|
|
14
|
+
readonly status: SaveStatus;
|
|
15
|
+
/** The failure that blocks the queue. */
|
|
16
|
+
readonly error: SaveError | undefined;
|
|
17
|
+
readonly pendingOperationIds: readonly string[];
|
|
18
|
+
/** The latest revision this coordinator knows for a target. */
|
|
19
|
+
revision(target: SaveTarget): SaveRevision | null | undefined;
|
|
20
|
+
/** Resolves with the receipt once the save commits. */
|
|
21
|
+
submit(input: CoordinatedSave): Promise<SaveReceipt>;
|
|
22
|
+
/**
|
|
23
|
+
* Resolves once every save submitted before this call has committed, and
|
|
24
|
+
* rejects if the queue blocks first. Later saves do not hold it up.
|
|
25
|
+
*/
|
|
26
|
+
flush(): Promise<void>;
|
|
27
|
+
/** Sends the blocking save again, with its original id and input. */
|
|
28
|
+
retry(): Promise<void>;
|
|
29
|
+
/** Drops queued saves that have not been sent. A sent save still settles. */
|
|
30
|
+
discardUnsent(): void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Starts from a snapshot of the targets to be saved and the writers of any
|
|
34
|
+
* claimed ones. Keep the coordinator where it outlives a replaceable worker.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createSaveCoordinator(api: SaveApi, snapshot: SaveSnapshot, writers?: {
|
|
37
|
+
target: SaveTarget;
|
|
38
|
+
writer: SaveWriter;
|
|
39
|
+
}[]): SaveCoordinator;
|