castle-web-sdk 0.4.4 → 0.4.5
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/dist/castle.d.ts +2 -0
- package/dist/castle.js +1 -0
- package/dist/commands.d.ts +117 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +2 -19
- package/dist/context.js +0 -71
- package/dist/leaderboard.js +69 -114
- package/dist/passes.d.ts +7 -0
- package/dist/passes.js +84 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +67 -1
- package/dist/storage.d.ts +1 -2
- package/dist/storage.js +82 -198
- package/dist/time.js +5 -16
- package/dist/transport.d.ts +16 -0
- package/dist/transport.js +126 -0
- package/dist/user.js +6 -23
- package/package.json +8 -4
- package/src/castle.ts +6 -0
- package/src/commands.ts +136 -0
- package/src/context.ts +4 -98
- package/src/leaderboard.ts +78 -186
- package/src/passes.ts +95 -0
- package/src/runtime.ts +80 -1
- package/src/storage.ts +106 -315
- package/src/time.ts +5 -29
- package/src/transport.ts +169 -0
- package/src/user.ts +6 -39
- package/dist/auth.d.ts +0 -8
- package/dist/auth.js +0 -52
- package/dist/graphql.d.ts +0 -15
- package/dist/graphql.js +0 -120
- package/src/auth.ts +0 -64
- package/src/graphql.ts +0 -182
package/src/storage.ts
CHANGED
|
@@ -1,42 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type SharedScope,
|
|
3
|
+
type StorageBlob,
|
|
4
|
+
type StorageUpdate,
|
|
5
|
+
} from "./commands";
|
|
3
6
|
import { CastleError } from "./errors";
|
|
4
|
-
import {
|
|
7
|
+
import { hostRequest } from "./transport";
|
|
5
8
|
import type { Json } from "./types";
|
|
6
9
|
|
|
7
10
|
const FLUSH_INTERVAL_MS = 2000;
|
|
8
11
|
|
|
9
12
|
type EncodedValue = string | null;
|
|
10
|
-
type StorageBlob = Record<string, string>;
|
|
11
|
-
type SharedScope = "deck" | "user";
|
|
12
|
-
|
|
13
|
-
interface DeckStorageData {
|
|
14
|
-
deckStorage: StorageBlob;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface UpdateDeckStorageData {
|
|
18
|
-
updateDeckStorage: StorageBlob;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface SharedDeckStorageData {
|
|
22
|
-
sharedDeckStorage: StorageBlob;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface UpdateSharedDeckStorageData {
|
|
26
|
-
updateSharedDeckStorage: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface StorageUpdate {
|
|
30
|
-
[key: string]: Json;
|
|
31
|
-
key: string;
|
|
32
|
-
value: EncodedValue;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface Bucket {
|
|
36
|
-
scope: SharedScope;
|
|
37
|
-
userId?: string;
|
|
38
|
-
sessionId?: string;
|
|
39
|
-
}
|
|
40
13
|
|
|
41
14
|
interface PendingRead {
|
|
42
15
|
resolve: (value: Json | null) => void;
|
|
@@ -44,7 +17,9 @@ interface PendingRead {
|
|
|
44
17
|
}
|
|
45
18
|
|
|
46
19
|
interface ReadBatch {
|
|
47
|
-
|
|
20
|
+
bucketKey: string;
|
|
21
|
+
scope: SharedScope;
|
|
22
|
+
userId?: string;
|
|
48
23
|
reads: Map<string, PendingRead[]>;
|
|
49
24
|
scheduled: boolean;
|
|
50
25
|
}
|
|
@@ -67,22 +42,24 @@ export interface SharedStorageApi {
|
|
|
67
42
|
remove(scope: SharedScope, key: string): void;
|
|
68
43
|
}
|
|
69
44
|
|
|
45
|
+
// Per-player private storage. The host stamps deckId/sessionId, so one deck
|
|
46
|
+
// session = one stable blob: load once, then serve reads from cache and batch
|
|
47
|
+
// writes on a 2s flush. Dirty writes overlay the server blob so an in-flight
|
|
48
|
+
// write isn't clobbered by the flush response.
|
|
70
49
|
class PrivateStorageImpl implements StorageApi {
|
|
71
50
|
private cache = new Map<string, Json>();
|
|
72
51
|
private dirty = new Map<string, EncodedValue>();
|
|
73
52
|
private loadPromise: Promise<void> | null = null;
|
|
74
|
-
private loadedContextKey: string | null = null;
|
|
75
53
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
76
54
|
|
|
77
55
|
async get<T extends Json = Json>(key: string): Promise<T | null> {
|
|
78
|
-
await this.ensureLoaded(
|
|
56
|
+
await this.ensureLoaded();
|
|
79
57
|
return (this.cache.get(key) ?? null) as T | null;
|
|
80
58
|
}
|
|
81
59
|
|
|
82
60
|
set(key: string, value: Json): void {
|
|
83
|
-
const encoded = encodeStorageValue(value, "Storage.set");
|
|
84
61
|
this.cache.set(key, value);
|
|
85
|
-
this.dirty.set(key,
|
|
62
|
+
this.dirty.set(key, encodeStorageValue(value, "Storage.set"));
|
|
86
63
|
this.scheduleFlush();
|
|
87
64
|
}
|
|
88
65
|
|
|
@@ -92,30 +69,9 @@ class PrivateStorageImpl implements StorageApi {
|
|
|
92
69
|
this.scheduleFlush();
|
|
93
70
|
}
|
|
94
71
|
|
|
95
|
-
private
|
|
96
|
-
if (this.dirty.size === 0) return;
|
|
97
|
-
const context = await currentContext("Storage.flush");
|
|
98
|
-
const snapshot = new Map(this.dirty);
|
|
99
|
-
const data = await storageGraphql<UpdateDeckStorageData>(
|
|
100
|
-
UPDATE_DECK_STORAGE_MUTATION,
|
|
101
|
-
{
|
|
102
|
-
deckId: context.deckId,
|
|
103
|
-
sessionId: context.sessionId,
|
|
104
|
-
updates: updatesFromDirty(snapshot),
|
|
105
|
-
},
|
|
106
|
-
"updateDeckStorage",
|
|
107
|
-
);
|
|
108
|
-
this.applyServerBlob(
|
|
109
|
-
data.updateDeckStorage,
|
|
110
|
-
contextKey(context.deckId, context.sessionId),
|
|
111
|
-
);
|
|
112
|
-
clearAcknowledgedDirty(this.dirty, snapshot);
|
|
113
|
-
this.overlayDirty();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private ensureLoaded(operation: string): Promise<void> {
|
|
72
|
+
private ensureLoaded(): Promise<void> {
|
|
117
73
|
if (!this.loadPromise) {
|
|
118
|
-
this.loadPromise = this.load(
|
|
74
|
+
this.loadPromise = this.load().catch((error: unknown) => {
|
|
119
75
|
this.loadPromise = null;
|
|
120
76
|
throw error;
|
|
121
77
|
});
|
|
@@ -123,32 +79,27 @@ class PrivateStorageImpl implements StorageApi {
|
|
|
123
79
|
return this.loadPromise;
|
|
124
80
|
}
|
|
125
81
|
|
|
126
|
-
private async load(
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
if (this.loadedContextKey === key) return;
|
|
130
|
-
const data = await storageGraphql<DeckStorageData>(
|
|
131
|
-
DECK_STORAGE_QUERY,
|
|
132
|
-
{ deckId: context.deckId, sessionId: context.sessionId },
|
|
133
|
-
"deckStorage",
|
|
134
|
-
);
|
|
135
|
-
this.cache = decodeStorageBlob(data.deckStorage, operation);
|
|
136
|
-
this.loadedContextKey = key;
|
|
82
|
+
private async load(): Promise<void> {
|
|
83
|
+
const { blob } = await hostRequest("deckStorage.load", {});
|
|
84
|
+
this.cache = decodeStorageBlob(blob, "Storage.get");
|
|
137
85
|
this.overlayDirty();
|
|
138
86
|
}
|
|
139
87
|
|
|
140
|
-
private
|
|
88
|
+
private async flush(): Promise<void> {
|
|
89
|
+
if (this.dirty.size === 0) return;
|
|
90
|
+
const snapshot = new Map(this.dirty);
|
|
91
|
+
const { blob } = await hostRequest("deckStorage.update", {
|
|
92
|
+
updates: updatesFromDirty(snapshot),
|
|
93
|
+
});
|
|
141
94
|
this.cache = decodeStorageBlob(blob, "Storage.flush");
|
|
142
|
-
this.
|
|
95
|
+
clearAcknowledgedDirty(this.dirty, snapshot);
|
|
96
|
+
this.overlayDirty();
|
|
143
97
|
}
|
|
144
98
|
|
|
145
99
|
private overlayDirty(): void {
|
|
146
100
|
for (const [key, encoded] of this.dirty) {
|
|
147
|
-
if (encoded === null)
|
|
148
|
-
|
|
149
|
-
} else {
|
|
150
|
-
this.cache.set(key, decodeStorageValue(encoded, "Storage.dirty"));
|
|
151
|
-
}
|
|
101
|
+
if (encoded === null) this.cache.delete(key);
|
|
102
|
+
else this.cache.set(key, decodeStorageValue(encoded, "Storage.dirty"));
|
|
152
103
|
}
|
|
153
104
|
}
|
|
154
105
|
|
|
@@ -161,12 +112,14 @@ class PrivateStorageImpl implements StorageApi {
|
|
|
161
112
|
}
|
|
162
113
|
}
|
|
163
114
|
|
|
115
|
+
// Cross-player storage. Writes always target the current player (the host
|
|
116
|
+
// forces 'user'-scope writes to whoever is signed in), so dirty writes bucket
|
|
117
|
+
// under a fixed key; reads of another player's 'user' bucket take an explicit
|
|
118
|
+
// userId and never have pending writes. Reads coalesce per microtask into one
|
|
119
|
+
// command with a keys[] array.
|
|
164
120
|
class SharedStorageImpl implements SharedStorageApi {
|
|
165
121
|
private dirtyBuckets = new Map<string, Map<string, EncodedValue>>();
|
|
166
|
-
private buckets = new Map<string, Bucket>();
|
|
167
122
|
private readBatches = new Map<string, ReadBatch>();
|
|
168
|
-
private pendingWrites = new Set<Promise<void>>();
|
|
169
|
-
private pendingWriteErrors: unknown[] = [];
|
|
170
123
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
171
124
|
|
|
172
125
|
async get<T extends Json = Json>(
|
|
@@ -174,54 +127,30 @@ class SharedStorageImpl implements SharedStorageApi {
|
|
|
174
127
|
userOrKey: string,
|
|
175
128
|
maybeKey?: string,
|
|
176
129
|
): Promise<T | null> {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
userOrKey,
|
|
180
|
-
maybeKey,
|
|
181
|
-
"SharedStorage.get",
|
|
182
|
-
);
|
|
130
|
+
assertScope(scope, "SharedStorage.get");
|
|
131
|
+
const userId = scope === "user" && maybeKey ? userOrKey : undefined;
|
|
183
132
|
const key = maybeKey ?? userOrKey;
|
|
184
|
-
|
|
185
|
-
const dirty = this.peekDirty(bucket, key);
|
|
133
|
+
const dirty = this.peekDirty(scope, userId, key);
|
|
186
134
|
if (dirty !== undefined) {
|
|
187
135
|
return (
|
|
188
136
|
dirty === null ? null : decodeStorageValue(dirty, "SharedStorage.get")
|
|
189
137
|
) as T | null;
|
|
190
138
|
}
|
|
191
|
-
return this.queueRead<T>(
|
|
139
|
+
return this.queueRead<T>(scope, userId, key);
|
|
192
140
|
}
|
|
193
141
|
|
|
194
142
|
set(scope: SharedScope, key: string, value: Json): void {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
key,
|
|
198
|
-
encodeStorageValue(value, "SharedStorage.set"),
|
|
199
|
-
"SharedStorage.set",
|
|
200
|
-
);
|
|
143
|
+
assertScope(scope, "SharedStorage.set");
|
|
144
|
+
this.write(scope, key, encodeStorageValue(value, "SharedStorage.set"));
|
|
201
145
|
}
|
|
202
146
|
|
|
203
147
|
remove(scope: SharedScope, key: string): void {
|
|
204
|
-
|
|
148
|
+
assertScope(scope, "SharedStorage.remove");
|
|
149
|
+
this.write(scope, key, null);
|
|
205
150
|
}
|
|
206
151
|
|
|
207
|
-
private
|
|
208
|
-
|
|
209
|
-
if (this.dirtyBuckets.size === 0) return;
|
|
210
|
-
const tasks = Array.from(this.dirtyBuckets, ([bucketKey, dirty]) =>
|
|
211
|
-
this.flushBucket(bucketKey, new Map(dirty)),
|
|
212
|
-
);
|
|
213
|
-
await Promise.all(tasks);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private async write(
|
|
217
|
-
scope: SharedScope,
|
|
218
|
-
key: string,
|
|
219
|
-
encoded: EncodedValue,
|
|
220
|
-
operation: string,
|
|
221
|
-
): Promise<void> {
|
|
222
|
-
const bucket = await writeBucket(scope, operation);
|
|
223
|
-
const bucketKey = makeBucketKey(bucket);
|
|
224
|
-
this.buckets.set(bucketKey, bucket);
|
|
152
|
+
private write(scope: SharedScope, key: string, encoded: EncodedValue): void {
|
|
153
|
+
const bucketKey = writeBucketKey(scope);
|
|
225
154
|
const dirty =
|
|
226
155
|
this.dirtyBuckets.get(bucketKey) ?? new Map<string, EncodedValue>();
|
|
227
156
|
dirty.set(key, encoded);
|
|
@@ -229,45 +158,24 @@ class SharedStorageImpl implements SharedStorageApi {
|
|
|
229
158
|
this.scheduleFlush();
|
|
230
159
|
}
|
|
231
160
|
|
|
232
|
-
private
|
|
161
|
+
private peekDirty(
|
|
233
162
|
scope: SharedScope,
|
|
163
|
+
userId: string | undefined,
|
|
234
164
|
key: string,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const pending = this.write(scope, key, encoded, operation)
|
|
239
|
-
.catch((error: unknown) => {
|
|
240
|
-
this.pendingWriteErrors.push(error);
|
|
241
|
-
})
|
|
242
|
-
.finally(() => {
|
|
243
|
-
this.pendingWrites.delete(pending);
|
|
244
|
-
});
|
|
245
|
-
this.pendingWrites.add(pending);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private async awaitPendingWrites(operation: string): Promise<void> {
|
|
249
|
-
if (this.pendingWrites.size > 0) {
|
|
250
|
-
await Promise.all(this.pendingWrites);
|
|
251
|
-
}
|
|
252
|
-
const error = this.pendingWriteErrors.shift();
|
|
253
|
-
if (error instanceof Error) throw error;
|
|
254
|
-
if (error) {
|
|
255
|
-
throw storageError(
|
|
256
|
-
"CASTLE_STORAGE_WRITE_FAILED",
|
|
257
|
-
"Queued shared storage write failed.",
|
|
258
|
-
operation,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
165
|
+
): EncodedValue | undefined {
|
|
166
|
+
if (scope === "user" && userId) return undefined;
|
|
167
|
+
return this.dirtyBuckets.get(writeBucketKey(scope))?.get(key);
|
|
261
168
|
}
|
|
262
169
|
|
|
263
|
-
private
|
|
264
|
-
|
|
170
|
+
private queueRead<T extends Json>(
|
|
171
|
+
scope: SharedScope,
|
|
172
|
+
userId: string | undefined,
|
|
265
173
|
key: string,
|
|
266
174
|
): Promise<T | null> {
|
|
267
|
-
const bucketKey =
|
|
175
|
+
const bucketKey = readBucketKey(scope, userId);
|
|
268
176
|
let batch = this.readBatches.get(bucketKey);
|
|
269
177
|
if (!batch) {
|
|
270
|
-
batch = {
|
|
178
|
+
batch = { bucketKey, scope, userId, reads: new Map(), scheduled: false };
|
|
271
179
|
this.readBatches.set(bucketKey, batch);
|
|
272
180
|
}
|
|
273
181
|
const reads = batch.reads.get(key) ?? [];
|
|
@@ -277,11 +185,9 @@ class SharedStorageImpl implements SharedStorageApi {
|
|
|
277
185
|
});
|
|
278
186
|
if (!batch.scheduled) {
|
|
279
187
|
batch.scheduled = true;
|
|
280
|
-
queueMicrotask(() =>
|
|
281
|
-
void this.flushReadBatch(bucketKey);
|
|
282
|
-
});
|
|
188
|
+
queueMicrotask(() => void this.flushReadBatch(bucketKey));
|
|
283
189
|
}
|
|
284
|
-
return
|
|
190
|
+
return promise as Promise<T | null>;
|
|
285
191
|
}
|
|
286
192
|
|
|
287
193
|
private async flushReadBatch(bucketKey: string): Promise<void> {
|
|
@@ -290,13 +196,12 @@ class SharedStorageImpl implements SharedStorageApi {
|
|
|
290
196
|
this.readBatches.delete(bucketKey);
|
|
291
197
|
const keys = Array.from(batch.reads.keys());
|
|
292
198
|
try {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
);
|
|
299
|
-
this.resolveReadBatch(batch, data.sharedDeckStorage);
|
|
199
|
+
const { blob } = await hostRequest("sharedDeckStorage.load", {
|
|
200
|
+
scope: batch.scope,
|
|
201
|
+
userId: batch.userId ?? null,
|
|
202
|
+
keys,
|
|
203
|
+
});
|
|
204
|
+
this.resolveReadBatch(batch, blob);
|
|
300
205
|
} catch (error) {
|
|
301
206
|
rejectReadBatch(batch, error);
|
|
302
207
|
}
|
|
@@ -304,7 +209,8 @@ class SharedStorageImpl implements SharedStorageApi {
|
|
|
304
209
|
|
|
305
210
|
private resolveReadBatch(batch: ReadBatch, blob: StorageBlob): void {
|
|
306
211
|
for (const [key, reads] of batch.reads) {
|
|
307
|
-
const encoded =
|
|
212
|
+
const encoded =
|
|
213
|
+
this.peekDirty(batch.scope, batch.userId, key) ?? blob[key] ?? null;
|
|
308
214
|
const value =
|
|
309
215
|
encoded === null
|
|
310
216
|
? null
|
|
@@ -313,36 +219,34 @@ class SharedStorageImpl implements SharedStorageApi {
|
|
|
313
219
|
}
|
|
314
220
|
}
|
|
315
221
|
|
|
222
|
+
private async flush(): Promise<void> {
|
|
223
|
+
if (this.dirtyBuckets.size === 0) return;
|
|
224
|
+
const tasks = Array.from(this.dirtyBuckets, ([bucketKey, dirty]) =>
|
|
225
|
+
this.flushBucket(bucketKey, new Map(dirty)),
|
|
226
|
+
);
|
|
227
|
+
await Promise.all(tasks);
|
|
228
|
+
}
|
|
229
|
+
|
|
316
230
|
private async flushBucket(
|
|
317
231
|
bucketKey: string,
|
|
318
232
|
snapshot: Map<string, EncodedValue>,
|
|
319
233
|
): Promise<void> {
|
|
320
234
|
if (snapshot.size === 0) return;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
UPDATE_SHARED_DECK_STORAGE_MUTATION,
|
|
326
|
-
sharedVariables(context.deckId, bucket, {
|
|
327
|
-
updates: updatesFromDirty(snapshot),
|
|
328
|
-
}),
|
|
329
|
-
"updateSharedDeckStorage",
|
|
330
|
-
);
|
|
235
|
+
await hostRequest("sharedDeckStorage.update", {
|
|
236
|
+
scope: scopeFromWriteKey(bucketKey),
|
|
237
|
+
updates: updatesFromDirty(snapshot),
|
|
238
|
+
});
|
|
331
239
|
const dirty = this.dirtyBuckets.get(bucketKey);
|
|
332
240
|
if (!dirty) return;
|
|
333
241
|
clearAcknowledgedDirty(dirty, snapshot);
|
|
334
242
|
if (dirty.size === 0) this.dirtyBuckets.delete(bucketKey);
|
|
335
243
|
}
|
|
336
244
|
|
|
337
|
-
private peekDirty(bucket: Bucket, key: string): EncodedValue | undefined {
|
|
338
|
-
return this.dirtyBuckets.get(makeBucketKey(bucket))?.get(key);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
245
|
private scheduleFlush(): void {
|
|
342
246
|
if (this.flushTimer) return;
|
|
343
247
|
this.flushTimer = setTimeout(() => {
|
|
344
248
|
this.flushTimer = null;
|
|
345
|
-
void this.flush();
|
|
249
|
+
void this.flush().catch(reportSharedStorageError);
|
|
346
250
|
}, FLUSH_INTERVAL_MS);
|
|
347
251
|
}
|
|
348
252
|
}
|
|
@@ -350,130 +254,19 @@ class SharedStorageImpl implements SharedStorageApi {
|
|
|
350
254
|
export const Storage: StorageApi = new PrivateStorageImpl();
|
|
351
255
|
export const SharedStorage: SharedStorageApi = new SharedStorageImpl();
|
|
352
256
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
`;
|
|
358
|
-
|
|
359
|
-
const UPDATE_DECK_STORAGE_MUTATION = `
|
|
360
|
-
mutation CastleUpdateDeckStorage(
|
|
361
|
-
$deckId: ID!,
|
|
362
|
-
$updates: [DeckStorageUpdateInput!]!,
|
|
363
|
-
$sessionId: ID
|
|
364
|
-
) {
|
|
365
|
-
updateDeckStorage(deckId: $deckId, updates: $updates, sessionId: $sessionId)
|
|
366
|
-
}
|
|
367
|
-
`;
|
|
368
|
-
|
|
369
|
-
const SHARED_DECK_STORAGE_QUERY = `
|
|
370
|
-
query CastleSharedDeckStorage(
|
|
371
|
-
$deckId: ID!,
|
|
372
|
-
$keys: [String!]!,
|
|
373
|
-
$sessionId: ID,
|
|
374
|
-
$userId: ID
|
|
375
|
-
) {
|
|
376
|
-
sharedDeckStorage(deckId: $deckId, keys: $keys, sessionId: $sessionId, userId: $userId)
|
|
377
|
-
}
|
|
378
|
-
`;
|
|
379
|
-
|
|
380
|
-
const UPDATE_SHARED_DECK_STORAGE_MUTATION = `
|
|
381
|
-
mutation CastleUpdateSharedDeckStorage(
|
|
382
|
-
$deckId: ID!,
|
|
383
|
-
$updates: [SharedDeckStorageUpdateInput!]!,
|
|
384
|
-
$sessionId: ID,
|
|
385
|
-
$userId: ID
|
|
386
|
-
) {
|
|
387
|
-
updateSharedDeckStorage(
|
|
388
|
-
deckId: $deckId,
|
|
389
|
-
updates: $updates,
|
|
390
|
-
sessionId: $sessionId,
|
|
391
|
-
userId: $userId
|
|
392
|
-
)
|
|
393
|
-
}
|
|
394
|
-
`;
|
|
395
|
-
|
|
396
|
-
async function currentContext(
|
|
397
|
-
operation: string,
|
|
398
|
-
): Promise<{ deckId: string; sessionId?: string }> {
|
|
399
|
-
const deckId = await storageDeckId(operation);
|
|
400
|
-
const context = await getDeckContext();
|
|
401
|
-
return { deckId, sessionId: context.sessionId ?? undefined };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async function storageDeckId(operation: string): Promise<string> {
|
|
405
|
-
try {
|
|
406
|
-
return await requireDeckId(operation);
|
|
407
|
-
} catch (error) {
|
|
408
|
-
if (error instanceof CastleError && error.code === "MISSING_DECK_ID") {
|
|
409
|
-
throw storageError(
|
|
410
|
-
"CASTLE_STORAGE_MISSING_DECK_ID",
|
|
411
|
-
"Save this deck before using Castle storage.",
|
|
412
|
-
operation,
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
throw error;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
async function storageGraphql<TData>(
|
|
420
|
-
query: string,
|
|
421
|
-
variables: Record<string, Json | undefined>,
|
|
422
|
-
operation: string,
|
|
423
|
-
): Promise<TData> {
|
|
424
|
-
const token = await requireAuthToken(operation);
|
|
425
|
-
return graphqlRequest<TData>(query, variables, {
|
|
426
|
-
operation,
|
|
427
|
-
requireAuth: true,
|
|
428
|
-
token,
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
async function readBucket(
|
|
433
|
-
scope: SharedScope,
|
|
434
|
-
userOrKey: string,
|
|
435
|
-
maybeKey: string | undefined,
|
|
436
|
-
operation: string,
|
|
437
|
-
): Promise<Bucket> {
|
|
438
|
-
assertScope(scope, operation);
|
|
439
|
-
if (scope === "deck") return withSession({ scope });
|
|
440
|
-
if (maybeKey) return withSession({ scope, userId: userOrKey });
|
|
441
|
-
return writeBucket(scope, operation);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
async function writeBucket(
|
|
445
|
-
scope: SharedScope,
|
|
446
|
-
operation: string,
|
|
447
|
-
): Promise<Bucket> {
|
|
448
|
-
assertScope(scope, operation);
|
|
449
|
-
if (scope === "deck") return withSession({ scope });
|
|
450
|
-
const auth = await getAuth();
|
|
451
|
-
if (!auth.userId) {
|
|
452
|
-
throw storageError(
|
|
453
|
-
"CASTLE_STORAGE_MISSING_USER_ID",
|
|
454
|
-
"Castle user id is required for user-scoped shared storage.",
|
|
455
|
-
operation,
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
return withSession({ scope, userId: auth.userId });
|
|
257
|
+
// 'user'-scope writes/self-reads share one bucket (the host resolves the
|
|
258
|
+
// current player); a 'user' read of someone else keys by their id.
|
|
259
|
+
function writeBucketKey(scope: SharedScope): string {
|
|
260
|
+
return scope === "deck" ? "deck" : "user:self";
|
|
459
261
|
}
|
|
460
262
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
return
|
|
263
|
+
function readBucketKey(scope: SharedScope, userId: string | undefined): string {
|
|
264
|
+
if (scope === "deck") return "deck";
|
|
265
|
+
return userId ? `user:other:${userId}` : "user:self";
|
|
464
266
|
}
|
|
465
267
|
|
|
466
|
-
function
|
|
467
|
-
|
|
468
|
-
bucket: Bucket,
|
|
469
|
-
extra: Record<string, Json | undefined>,
|
|
470
|
-
): Record<string, Json | undefined> {
|
|
471
|
-
return {
|
|
472
|
-
deckId,
|
|
473
|
-
sessionId: bucket.sessionId,
|
|
474
|
-
userId: bucket.userId,
|
|
475
|
-
...extra,
|
|
476
|
-
};
|
|
268
|
+
function scopeFromWriteKey(bucketKey: string): SharedScope {
|
|
269
|
+
return bucketKey === "deck" ? "deck" : "user";
|
|
477
270
|
}
|
|
478
271
|
|
|
479
272
|
function updatesFromDirty(dirty: Map<string, EncodedValue>): StorageUpdate[] {
|
|
@@ -562,13 +355,7 @@ function assertJsonArray(
|
|
|
562
355
|
seen: Set<object>,
|
|
563
356
|
operation: string,
|
|
564
357
|
): void {
|
|
565
|
-
|
|
566
|
-
throw storageError(
|
|
567
|
-
"CASTLE_STORAGE_SERIALIZE_FAILED",
|
|
568
|
-
"Castle storage values cannot be cyclic.",
|
|
569
|
-
operation,
|
|
570
|
-
);
|
|
571
|
-
}
|
|
358
|
+
assertNotCyclic(values, seen, operation);
|
|
572
359
|
seen.add(values);
|
|
573
360
|
for (const value of values) assertJsonValue(value, seen, operation);
|
|
574
361
|
seen.delete(values);
|
|
@@ -579,14 +366,8 @@ function assertJsonObject(
|
|
|
579
366
|
seen: Set<object>,
|
|
580
367
|
operation: string,
|
|
581
368
|
): void {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"CASTLE_STORAGE_SERIALIZE_FAILED",
|
|
585
|
-
"Castle storage values cannot be cyclic.",
|
|
586
|
-
operation,
|
|
587
|
-
);
|
|
588
|
-
}
|
|
589
|
-
const prototype = Object.getPrototypeOf(value);
|
|
369
|
+
assertNotCyclic(value, seen, operation);
|
|
370
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
590
371
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
591
372
|
throw storageError(
|
|
592
373
|
"CASTLE_STORAGE_SERIALIZE_FAILED",
|
|
@@ -600,12 +381,18 @@ function assertJsonObject(
|
|
|
600
381
|
seen.delete(value);
|
|
601
382
|
}
|
|
602
383
|
|
|
603
|
-
function
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
384
|
+
function assertNotCyclic(
|
|
385
|
+
value: object,
|
|
386
|
+
seen: Set<object>,
|
|
387
|
+
operation: string,
|
|
388
|
+
): void {
|
|
389
|
+
if (seen.has(value)) {
|
|
390
|
+
throw storageError(
|
|
391
|
+
"CASTLE_STORAGE_SERIALIZE_FAILED",
|
|
392
|
+
"Castle storage values cannot be cyclic.",
|
|
393
|
+
operation,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
609
396
|
}
|
|
610
397
|
|
|
611
398
|
function assertScope(
|
|
@@ -627,6 +414,10 @@ function rejectReadBatch(batch: ReadBatch, error: unknown): void {
|
|
|
627
414
|
}
|
|
628
415
|
}
|
|
629
416
|
|
|
417
|
+
function reportSharedStorageError(error: unknown): void {
|
|
418
|
+
console.warn("Castle shared storage write failed", error);
|
|
419
|
+
}
|
|
420
|
+
|
|
630
421
|
function storageError(
|
|
631
422
|
code: string,
|
|
632
423
|
message: string,
|
package/src/time.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CastleError } from "./errors";
|
|
2
|
-
import {
|
|
2
|
+
import { hostRequest } from "./transport";
|
|
3
3
|
import type { Json } from "./types";
|
|
4
4
|
|
|
5
5
|
export type CastleClockZone = "player" | "Castle";
|
|
@@ -21,30 +21,12 @@ export interface CastleTimeApi {
|
|
|
21
21
|
getServerDate(timezone?: CastleClockZone): Promise<CastleDateParts>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
interface ServerTimeQueryData {
|
|
25
|
-
serverTime: {
|
|
26
|
-
timestamp: number;
|
|
27
|
-
timezoneOffset: number;
|
|
28
|
-
castleEpochData: Json;
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
24
|
interface ServerTimeSnapshot {
|
|
33
25
|
offsetSeconds: number;
|
|
34
26
|
castleTimezoneOffsetMinutes: number;
|
|
35
27
|
daysSinceCastleEpoch: Record<CastleClockZone, number>;
|
|
36
28
|
}
|
|
37
29
|
|
|
38
|
-
const SERVER_TIME_QUERY = `
|
|
39
|
-
query CastleServerTime {
|
|
40
|
-
serverTime {
|
|
41
|
-
timestamp
|
|
42
|
-
timezoneOffset
|
|
43
|
-
castleEpochData
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
`;
|
|
47
|
-
|
|
48
30
|
let serverTimeSnapshot: ServerTimeSnapshot | null = null;
|
|
49
31
|
let serverTimePromise: Promise<ServerTimeSnapshot> | null = null;
|
|
50
32
|
|
|
@@ -84,25 +66,19 @@ async function syncServerTime(): Promise<ServerTimeSnapshot> {
|
|
|
84
66
|
|
|
85
67
|
async function fetchServerTime(): Promise<ServerTimeSnapshot> {
|
|
86
68
|
const operation = "Time.getServerTime";
|
|
87
|
-
const data = await
|
|
88
|
-
SERVER_TIME_QUERY,
|
|
89
|
-
undefined,
|
|
90
|
-
{
|
|
91
|
-
operation,
|
|
92
|
-
},
|
|
93
|
-
);
|
|
69
|
+
const data = await hostRequest("time.getServerTime", {});
|
|
94
70
|
const timestamp = numberField(
|
|
95
|
-
data.
|
|
71
|
+
data.timestamp,
|
|
96
72
|
"serverTime.timestamp",
|
|
97
73
|
operation,
|
|
98
74
|
);
|
|
99
75
|
const timezoneOffset = numberField(
|
|
100
|
-
data.
|
|
76
|
+
data.timezoneOffset,
|
|
101
77
|
"serverTime.timezoneOffset",
|
|
102
78
|
operation,
|
|
103
79
|
);
|
|
104
80
|
const epochData = jsonObject(
|
|
105
|
-
data.
|
|
81
|
+
data.castleEpochData,
|
|
106
82
|
"serverTime.castleEpochData",
|
|
107
83
|
operation,
|
|
108
84
|
);
|