castle-web-sdk 0.4.0 → 0.4.2

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/src/storage.ts ADDED
@@ -0,0 +1,636 @@
1
+ import { getAuth, requireAuthToken } from "./auth";
2
+ import { getDeckContext, requireDeckId } from "./context";
3
+ import { CastleError } from "./errors";
4
+ import { graphqlRequest } from "./graphql";
5
+ import type { Json } from "./types";
6
+
7
+ const FLUSH_INTERVAL_MS = 2000;
8
+
9
+ 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
+
41
+ interface PendingRead {
42
+ resolve: (value: Json | null) => void;
43
+ reject: (error: unknown) => void;
44
+ }
45
+
46
+ interface ReadBatch {
47
+ bucket: Bucket;
48
+ reads: Map<string, PendingRead[]>;
49
+ scheduled: boolean;
50
+ }
51
+
52
+ export interface StorageApi {
53
+ get<T extends Json = Json>(key: string): Promise<T | null>;
54
+ set(key: string, value: Json): void;
55
+ remove(key: string): void;
56
+ }
57
+
58
+ export interface SharedStorageApi {
59
+ get<T extends Json = Json>(scope: "deck", key: string): Promise<T | null>;
60
+ get<T extends Json = Json>(scope: "user", key: string): Promise<T | null>;
61
+ get<T extends Json = Json>(
62
+ scope: "user",
63
+ userId: string,
64
+ key: string,
65
+ ): Promise<T | null>;
66
+ set(scope: SharedScope, key: string, value: Json): void;
67
+ remove(scope: SharedScope, key: string): void;
68
+ }
69
+
70
+ class PrivateStorageImpl implements StorageApi {
71
+ private cache = new Map<string, Json>();
72
+ private dirty = new Map<string, EncodedValue>();
73
+ private loadPromise: Promise<void> | null = null;
74
+ private loadedContextKey: string | null = null;
75
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
76
+
77
+ async get<T extends Json = Json>(key: string): Promise<T | null> {
78
+ await this.ensureLoaded("Storage.get");
79
+ return (this.cache.get(key) ?? null) as T | null;
80
+ }
81
+
82
+ set(key: string, value: Json): void {
83
+ const encoded = encodeStorageValue(value, "Storage.set");
84
+ this.cache.set(key, value);
85
+ this.dirty.set(key, encoded);
86
+ this.scheduleFlush();
87
+ }
88
+
89
+ remove(key: string): void {
90
+ this.cache.delete(key);
91
+ this.dirty.set(key, null);
92
+ this.scheduleFlush();
93
+ }
94
+
95
+ private async flush(): Promise<void> {
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> {
117
+ if (!this.loadPromise) {
118
+ this.loadPromise = this.load(operation).catch((error: unknown) => {
119
+ this.loadPromise = null;
120
+ throw error;
121
+ });
122
+ }
123
+ return this.loadPromise;
124
+ }
125
+
126
+ private async load(operation: string): Promise<void> {
127
+ const context = await currentContext(operation);
128
+ const key = contextKey(context.deckId, context.sessionId);
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;
137
+ this.overlayDirty();
138
+ }
139
+
140
+ private applyServerBlob(blob: StorageBlob, loadedContextKey: string): void {
141
+ this.cache = decodeStorageBlob(blob, "Storage.flush");
142
+ this.loadedContextKey = loadedContextKey;
143
+ }
144
+
145
+ private overlayDirty(): void {
146
+ for (const [key, encoded] of this.dirty) {
147
+ if (encoded === null) {
148
+ this.cache.delete(key);
149
+ } else {
150
+ this.cache.set(key, decodeStorageValue(encoded, "Storage.dirty"));
151
+ }
152
+ }
153
+ }
154
+
155
+ private scheduleFlush(): void {
156
+ if (this.flushTimer) return;
157
+ this.flushTimer = setTimeout(() => {
158
+ this.flushTimer = null;
159
+ void this.flush();
160
+ }, FLUSH_INTERVAL_MS);
161
+ }
162
+ }
163
+
164
+ class SharedStorageImpl implements SharedStorageApi {
165
+ private dirtyBuckets = new Map<string, Map<string, EncodedValue>>();
166
+ private buckets = new Map<string, Bucket>();
167
+ private readBatches = new Map<string, ReadBatch>();
168
+ private pendingWrites = new Set<Promise<void>>();
169
+ private pendingWriteErrors: unknown[] = [];
170
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
171
+
172
+ async get<T extends Json = Json>(
173
+ scope: SharedScope,
174
+ userOrKey: string,
175
+ maybeKey?: string,
176
+ ): Promise<T | null> {
177
+ const bucket = await readBucket(
178
+ scope,
179
+ userOrKey,
180
+ maybeKey,
181
+ "SharedStorage.get",
182
+ );
183
+ const key = maybeKey ?? userOrKey;
184
+ await this.awaitPendingWrites("SharedStorage.get");
185
+ const dirty = this.peekDirty(bucket, key);
186
+ if (dirty !== undefined) {
187
+ return (
188
+ dirty === null ? null : decodeStorageValue(dirty, "SharedStorage.get")
189
+ ) as T | null;
190
+ }
191
+ return this.queueRead<T>(bucket, key);
192
+ }
193
+
194
+ set(scope: SharedScope, key: string, value: Json): void {
195
+ this.enqueueWrite(
196
+ scope,
197
+ key,
198
+ encodeStorageValue(value, "SharedStorage.set"),
199
+ "SharedStorage.set",
200
+ );
201
+ }
202
+
203
+ remove(scope: SharedScope, key: string): void {
204
+ this.enqueueWrite(scope, key, null, "SharedStorage.remove");
205
+ }
206
+
207
+ private async flush(): Promise<void> {
208
+ await this.awaitPendingWrites("SharedStorage.flush");
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);
225
+ const dirty =
226
+ this.dirtyBuckets.get(bucketKey) ?? new Map<string, EncodedValue>();
227
+ dirty.set(key, encoded);
228
+ this.dirtyBuckets.set(bucketKey, dirty);
229
+ this.scheduleFlush();
230
+ }
231
+
232
+ private enqueueWrite(
233
+ scope: SharedScope,
234
+ key: string,
235
+ encoded: EncodedValue,
236
+ operation: string,
237
+ ): void {
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
+ }
261
+ }
262
+
263
+ private async queueRead<T extends Json>(
264
+ bucket: Bucket,
265
+ key: string,
266
+ ): Promise<T | null> {
267
+ const bucketKey = makeBucketKey(bucket);
268
+ let batch = this.readBatches.get(bucketKey);
269
+ if (!batch) {
270
+ batch = { bucket, reads: new Map(), scheduled: false };
271
+ this.readBatches.set(bucketKey, batch);
272
+ }
273
+ const reads = batch.reads.get(key) ?? [];
274
+ batch.reads.set(key, reads);
275
+ const promise = new Promise<Json | null>((resolve, reject) => {
276
+ reads.push({ resolve, reject });
277
+ });
278
+ if (!batch.scheduled) {
279
+ batch.scheduled = true;
280
+ queueMicrotask(() => {
281
+ void this.flushReadBatch(bucketKey);
282
+ });
283
+ }
284
+ return (await promise) as T | null;
285
+ }
286
+
287
+ private async flushReadBatch(bucketKey: string): Promise<void> {
288
+ const batch = this.readBatches.get(bucketKey);
289
+ if (!batch) return;
290
+ this.readBatches.delete(bucketKey);
291
+ const keys = Array.from(batch.reads.keys());
292
+ try {
293
+ const context = await currentContext("SharedStorage.get");
294
+ const data = await storageGraphql<SharedDeckStorageData>(
295
+ SHARED_DECK_STORAGE_QUERY,
296
+ sharedVariables(context.deckId, batch.bucket, { keys }),
297
+ "sharedDeckStorage",
298
+ );
299
+ this.resolveReadBatch(batch, data.sharedDeckStorage);
300
+ } catch (error) {
301
+ rejectReadBatch(batch, error);
302
+ }
303
+ }
304
+
305
+ private resolveReadBatch(batch: ReadBatch, blob: StorageBlob): void {
306
+ for (const [key, reads] of batch.reads) {
307
+ const encoded = this.peekDirty(batch.bucket, key) ?? blob[key] ?? null;
308
+ const value =
309
+ encoded === null
310
+ ? null
311
+ : decodeStorageValue(encoded, "SharedStorage.get");
312
+ for (const read of reads) read.resolve(value);
313
+ }
314
+ }
315
+
316
+ private async flushBucket(
317
+ bucketKey: string,
318
+ snapshot: Map<string, EncodedValue>,
319
+ ): Promise<void> {
320
+ if (snapshot.size === 0) return;
321
+ const bucket = this.buckets.get(bucketKey);
322
+ if (!bucket) return;
323
+ const context = await currentContext("SharedStorage.flush");
324
+ await storageGraphql<UpdateSharedDeckStorageData>(
325
+ UPDATE_SHARED_DECK_STORAGE_MUTATION,
326
+ sharedVariables(context.deckId, bucket, {
327
+ updates: updatesFromDirty(snapshot),
328
+ }),
329
+ "updateSharedDeckStorage",
330
+ );
331
+ const dirty = this.dirtyBuckets.get(bucketKey);
332
+ if (!dirty) return;
333
+ clearAcknowledgedDirty(dirty, snapshot);
334
+ if (dirty.size === 0) this.dirtyBuckets.delete(bucketKey);
335
+ }
336
+
337
+ private peekDirty(bucket: Bucket, key: string): EncodedValue | undefined {
338
+ return this.dirtyBuckets.get(makeBucketKey(bucket))?.get(key);
339
+ }
340
+
341
+ private scheduleFlush(): void {
342
+ if (this.flushTimer) return;
343
+ this.flushTimer = setTimeout(() => {
344
+ this.flushTimer = null;
345
+ void this.flush();
346
+ }, FLUSH_INTERVAL_MS);
347
+ }
348
+ }
349
+
350
+ export const Storage: StorageApi = new PrivateStorageImpl();
351
+ export const SharedStorage: SharedStorageApi = new SharedStorageImpl();
352
+
353
+ const DECK_STORAGE_QUERY = `
354
+ query CastleDeckStorage($deckId: ID!, $sessionId: ID) {
355
+ deckStorage(deckId: $deckId, sessionId: $sessionId)
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 });
459
+ }
460
+
461
+ async function withSession(bucket: Bucket): Promise<Bucket> {
462
+ const context = await getDeckContext();
463
+ return { ...bucket, sessionId: context.sessionId ?? undefined };
464
+ }
465
+
466
+ function sharedVariables(
467
+ deckId: string,
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
+ };
477
+ }
478
+
479
+ function updatesFromDirty(dirty: Map<string, EncodedValue>): StorageUpdate[] {
480
+ return Array.from(dirty, ([key, value]) => ({ key, value }));
481
+ }
482
+
483
+ function clearAcknowledgedDirty(
484
+ dirty: Map<string, EncodedValue>,
485
+ acknowledged: Map<string, EncodedValue>,
486
+ ): void {
487
+ for (const [key, value] of acknowledged) {
488
+ if (dirty.get(key) === value) dirty.delete(key);
489
+ }
490
+ }
491
+
492
+ function decodeStorageBlob(
493
+ blob: StorageBlob,
494
+ operation: string,
495
+ ): Map<string, Json> {
496
+ return new Map(
497
+ Object.entries(blob).map(([key, value]) => [
498
+ key,
499
+ decodeStorageValue(value, operation),
500
+ ]),
501
+ );
502
+ }
503
+
504
+ function decodeStorageValue(encoded: string, operation: string): Json {
505
+ try {
506
+ return JSON.parse(encoded) as Json;
507
+ } catch {
508
+ throw storageError(
509
+ "CASTLE_STORAGE_PARSE_FAILED",
510
+ "Stored Castle value is not valid JSON.",
511
+ operation,
512
+ );
513
+ }
514
+ }
515
+
516
+ function encodeStorageValue(value: Json, operation: string): string {
517
+ try {
518
+ assertJsonValue(value, new Set(), operation);
519
+ return JSON.stringify(value);
520
+ } catch (error) {
521
+ if (error instanceof CastleError) throw error;
522
+ throw storageError(
523
+ "CASTLE_STORAGE_SERIALIZE_FAILED",
524
+ "Castle storage values must be JSON.",
525
+ operation,
526
+ );
527
+ }
528
+ }
529
+
530
+ function assertJsonValue(
531
+ value: unknown,
532
+ seen: Set<object>,
533
+ operation: string,
534
+ ): void {
535
+ if (value === null || typeof value === "boolean" || typeof value === "string")
536
+ return;
537
+ if (typeof value === "number") {
538
+ if (Number.isFinite(value)) return;
539
+ throw storageError(
540
+ "CASTLE_STORAGE_SERIALIZE_FAILED",
541
+ "Castle storage numbers must be finite.",
542
+ operation,
543
+ );
544
+ }
545
+ if (Array.isArray(value)) {
546
+ assertJsonArray(value, seen, operation);
547
+ return;
548
+ }
549
+ if (typeof value === "object") {
550
+ assertJsonObject(value, seen, operation);
551
+ return;
552
+ }
553
+ throw storageError(
554
+ "CASTLE_STORAGE_SERIALIZE_FAILED",
555
+ "Castle storage values must be JSON.",
556
+ operation,
557
+ );
558
+ }
559
+
560
+ function assertJsonArray(
561
+ values: unknown[],
562
+ seen: Set<object>,
563
+ operation: string,
564
+ ): void {
565
+ if (seen.has(values)) {
566
+ throw storageError(
567
+ "CASTLE_STORAGE_SERIALIZE_FAILED",
568
+ "Castle storage values cannot be cyclic.",
569
+ operation,
570
+ );
571
+ }
572
+ seen.add(values);
573
+ for (const value of values) assertJsonValue(value, seen, operation);
574
+ seen.delete(values);
575
+ }
576
+
577
+ function assertJsonObject(
578
+ value: object,
579
+ seen: Set<object>,
580
+ operation: string,
581
+ ): void {
582
+ if (seen.has(value)) {
583
+ throw storageError(
584
+ "CASTLE_STORAGE_SERIALIZE_FAILED",
585
+ "Castle storage values cannot be cyclic.",
586
+ operation,
587
+ );
588
+ }
589
+ const prototype = Object.getPrototypeOf(value);
590
+ if (prototype !== Object.prototype && prototype !== null) {
591
+ throw storageError(
592
+ "CASTLE_STORAGE_SERIALIZE_FAILED",
593
+ "Castle storage objects must be plain JSON.",
594
+ operation,
595
+ );
596
+ }
597
+ seen.add(value);
598
+ for (const child of Object.values(value))
599
+ assertJsonValue(child, seen, operation);
600
+ seen.delete(value);
601
+ }
602
+
603
+ function makeBucketKey(bucket: Bucket): string {
604
+ return `${bucket.scope}:${bucket.userId ?? ""}:${bucket.sessionId ?? ""}`;
605
+ }
606
+
607
+ function contextKey(deckId: string, sessionId: string | undefined): string {
608
+ return `${deckId}:${sessionId ?? ""}`;
609
+ }
610
+
611
+ function assertScope(
612
+ scope: string,
613
+ operation: string,
614
+ ): asserts scope is SharedScope {
615
+ if (scope !== "deck" && scope !== "user") {
616
+ throw storageError(
617
+ "CASTLE_STORAGE_INVALID_SCOPE",
618
+ 'SharedStorage scope must be "deck" or "user".',
619
+ operation,
620
+ );
621
+ }
622
+ }
623
+
624
+ function rejectReadBatch(batch: ReadBatch, error: unknown): void {
625
+ for (const reads of batch.reads.values()) {
626
+ for (const read of reads) read.reject(error);
627
+ }
628
+ }
629
+
630
+ function storageError(
631
+ code: string,
632
+ message: string,
633
+ operation: string,
634
+ ): CastleError {
635
+ return new CastleError({ code, message, operation });
636
+ }