castle-web-sdk 0.4.25 → 0.4.26

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.
@@ -0,0 +1,218 @@
1
+ import { chunked } from "../chunk";
2
+ import { decodeStorageValue, encodeStorageValue, storageError, } from "../storageJson";
3
+ // Deck storage for an author's session server.
4
+ //
5
+ // The same store the deck's clients use, reached over the platform channel instead of
6
+ // the host bridge. Two differences follow from what a session server IS, and both are
7
+ // enforced by the platform, not here:
8
+ //
9
+ // - It owns `deck` scope. Once a deck has a session server, clients read that data
10
+ // but no longer write it, which is what makes server-validated state possible.
11
+ // - It can never touch `private` scope, not even for its own players. Private
12
+ // storage belongs to the player, so this API does not offer it at all.
13
+ //
14
+ // Writes are not coalesced, unlike the client's. A server's writes are deliberate
15
+ // rather than one-per-frame, and every call resolving when the platform has the write
16
+ // is what makes saving during `onShutdown` simply work: the author awaits, and the
17
+ // host holds the session open until the request lands.
18
+ /** The platform takes at most this many keys per call. */
19
+ const MAX_KEYS_PER_CALL = 32;
20
+ /**
21
+ * A storage request crosses the platform channel as one frame, so it has a size limit
22
+ * the client path does not. Batches are split to stay under it. A single value too
23
+ * large to fit at all is refused here with a typed error, which is the difference
24
+ * between a save the author can handle and a frame that closes the session.
25
+ */
26
+ const MAX_REQUEST_BYTES = 320 * 1024;
27
+ // Room for the operation name, the scope and the subject, all of which are bounded
28
+ // below so this allowance is a real ceiling rather than an assumption.
29
+ const REQUEST_OVERHEAD_BYTES = 4 * 1024;
30
+ /**
31
+ * The platform's own limits, mirrored so an over-long key or user id fails here with a
32
+ * typed error. Sent instead, they would push the request past the channel's frame
33
+ * limit -- and past that the socket closes and the session ends, which is a far worse
34
+ * answer than "that key is too long".
35
+ */
36
+ const MAX_KEY_BYTES = 128;
37
+ const MAX_SUBJECT_BYTES = 256;
38
+ export function createServerStorage(platformHandle) {
39
+ const call = async (op, args) => {
40
+ if (typeof platformHandle.storage !== "function") {
41
+ // An older host-agent that predates storage. Say so rather than hanging on a
42
+ // reply that is never coming.
43
+ throw storageError("CAULDRON_STORAGE_UNAVAILABLE", "This Castle host does not support session-server storage.", `session.storage.${op}`);
44
+ }
45
+ if (platformHandle.storageEnabled === false) {
46
+ throw storageError("CAULDRON_STORAGE_UNAVAILABLE", "Storage is not available to this session.", `session.storage.${op}`);
47
+ }
48
+ const result = await platformHandle.storage(op, JSON.stringify(args));
49
+ return JSON.parse(result);
50
+ };
51
+ return {
52
+ deck: createScope(call, "DECK", null),
53
+ user(userId) {
54
+ // A bad id poisons the scope rather than throwing here, so the failure arrives
55
+ // the same way every other failure does -- as a rejection the author can await.
56
+ // A server addresses other people's data, and an absent subject would be
57
+ // resolved by the platform to the deck's creator, quietly writing the wrong
58
+ // player's bucket, so nothing is sent either.
59
+ if (typeof userId !== "string" || userId.length === 0) {
60
+ return createScope(rejectMissingSubject, "USER", null);
61
+ }
62
+ if (utf8Length(userId) > MAX_SUBJECT_BYTES) {
63
+ return createScope(rejectOversizeSubject, "USER", null);
64
+ }
65
+ return createScope(call, "USER", userId);
66
+ },
67
+ board(name) {
68
+ return createBoard(call, name);
69
+ },
70
+ };
71
+ }
72
+ function createScope(call, scope, subject) {
73
+ return {
74
+ async get(keys) {
75
+ // A bare array, not a wrapper: the platform hands back exactly what the
76
+ // storage verb returns, and `get` returns its entries directly.
77
+ const entries = await call("get", {
78
+ scope,
79
+ subject,
80
+ keys: assertKeys(keys, "session.storage.get"),
81
+ });
82
+ const result = {};
83
+ for (const entry of entries ?? []) {
84
+ // defineProperty, not assignment: a stored key named "__proto__" would
85
+ // otherwise reach the prototype setter rather than becoming an own property.
86
+ Object.defineProperty(result, entry.key, {
87
+ value: decodeStorageValue(entry.value, "session.storage.get"),
88
+ writable: true,
89
+ enumerable: true,
90
+ configurable: true,
91
+ });
92
+ }
93
+ return result;
94
+ },
95
+ async set(values) {
96
+ const entries = Object.entries(values).map(([key, value]) => ({
97
+ key,
98
+ value: encodeStorageValue(value, "session.storage.set"),
99
+ }));
100
+ for (const chunk of batched(entries)) {
101
+ await call("set", { scope, subject, entries: chunk });
102
+ }
103
+ },
104
+ async remove(keys) {
105
+ for (const chunk of chunked(assertKeys(keys, "session.storage.remove"), MAX_KEYS_PER_CALL)) {
106
+ await call("delete", { scope, subject, keys: chunk });
107
+ }
108
+ },
109
+ async increment(key, delta = 1) {
110
+ const value = await call("increment", { scope, subject, key, delta });
111
+ return Number(value);
112
+ },
113
+ async list(options = {}) {
114
+ const page = await call("list", {
115
+ scope,
116
+ subject,
117
+ prefix: options.prefix ?? null,
118
+ limit: options.limit ?? null,
119
+ cursor: options.cursor ?? null,
120
+ });
121
+ return {
122
+ entries: (page?.entries ?? []).map((entry) => ({
123
+ key: entry.key,
124
+ value: decodeStorageValue(entry.value, "session.storage.list"),
125
+ })),
126
+ cursor: page?.cursor ?? null,
127
+ };
128
+ },
129
+ };
130
+ }
131
+ function createBoard(call, board) {
132
+ return {
133
+ async submit(userId, score) {
134
+ return call("boardSubmit", {
135
+ board,
136
+ subject: assertUserId(userId, "session.storage.board.submit"),
137
+ score,
138
+ });
139
+ },
140
+ async top(limit) {
141
+ const entries = await call("boardTop", {
142
+ board,
143
+ limit: limit ?? null,
144
+ });
145
+ return entries ?? [];
146
+ },
147
+ async get(userId) {
148
+ const entry = await call("boardGet", {
149
+ board,
150
+ subject: assertUserId(userId, "session.storage.board.get"),
151
+ });
152
+ return entry ?? null;
153
+ },
154
+ };
155
+ }
156
+ function assertKeys(keys, operation) {
157
+ if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
158
+ throw storageError("CASTLE_STORE_INVALID_KEYS", "Storage keys must be an array of strings.", operation);
159
+ }
160
+ for (const key of keys) {
161
+ if (utf8Length(key) > MAX_KEY_BYTES) {
162
+ throw storageError("CAULDRON_STORAGE_INVALID_KEY", `Storage keys must be at most ${MAX_KEY_BYTES} bytes.`, operation);
163
+ }
164
+ }
165
+ return keys;
166
+ }
167
+ const rejectMissingSubject = (op) => Promise.reject(missingSubjectError(`session.storage.${op}`));
168
+ const rejectOversizeSubject = (op) => Promise.reject(storageError("CASTLE_STORE_INVALID_SUBJECT", `A user id must be at most ${MAX_SUBJECT_BYTES} bytes.`, `session.storage.${op}`));
169
+ function missingSubjectError(operation) {
170
+ return storageError("CASTLE_STORE_INVALID_SUBJECT", "A user id is required here; a session server has no storage of its own.", operation);
171
+ }
172
+ function assertUserId(userId, operation) {
173
+ if (typeof userId !== "string" || userId.length === 0) {
174
+ throw missingSubjectError(operation);
175
+ }
176
+ if (utf8Length(userId) > MAX_SUBJECT_BYTES) {
177
+ throw storageError("CASTLE_STORE_INVALID_SUBJECT", `A user id must be at most ${MAX_SUBJECT_BYTES} bytes.`, operation);
178
+ }
179
+ return userId;
180
+ }
181
+ /**
182
+ * Splits entries into requests that fit both limits: the platform's key count and the
183
+ * channel's frame size.
184
+ */
185
+ function batched(entries) {
186
+ const budget = MAX_REQUEST_BYTES - REQUEST_OVERHEAD_BYTES;
187
+ const batches = [];
188
+ let current = [];
189
+ let currentBytes = 0;
190
+ for (const entry of entries) {
191
+ const bytes = entryBytes(entry);
192
+ if (bytes > budget) {
193
+ throw storageError("CAULDRON_STORAGE_INVALID_VALUE", `A single stored value must be under ${budget} bytes when written from a session server.`, "session.storage.set");
194
+ }
195
+ if (current.length === MAX_KEYS_PER_CALL || currentBytes + bytes > budget) {
196
+ batches.push(current);
197
+ current = [];
198
+ currentBytes = 0;
199
+ }
200
+ current.push(entry);
201
+ currentBytes += bytes;
202
+ }
203
+ if (current.length > 0)
204
+ batches.push(current);
205
+ return batches;
206
+ }
207
+ function entryBytes(entry) {
208
+ // The ENCODED size, not the string length. `value` is already JSON, so putting it
209
+ // in the request escapes it a second time -- every quote and backslash doubles.
210
+ // Budgeting on the raw length would let an escape-heavy value slip a batch past the
211
+ // channel's limit, and past the limit the socket closes and the session with it.
212
+ return utf8Length(JSON.stringify(entry));
213
+ }
214
+ function utf8Length(value) {
215
+ if (typeof TextEncoder !== "undefined")
216
+ return new TextEncoder().encode(value).length;
217
+ return value.length;
218
+ }
@@ -1,5 +1,7 @@
1
1
  import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
2
+ import { type ServerStorage } from "./storage";
2
3
  export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlayerIdentity, } from "./platformHandle";
4
+ export type { ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerStorageScope, } from "./storage";
3
5
  export interface MultiplayerServerPlayer extends PlayerIdentity {
4
6
  playerId: string;
5
7
  }
@@ -11,13 +13,18 @@ export interface MultiplayerServerSession {
11
13
  readonly sessionId: string;
12
14
  readonly deckId: string;
13
15
  readonly mode: MultiplayerSessionMode;
16
+ /**
17
+ * Deck storage that outlives the session. Nothing else here does: a session ends
18
+ * when its last player leaves and its memory goes with it.
19
+ */
20
+ readonly storage: ServerStorage;
14
21
  /** Sends bytes as a binary message and all other payloads as JSON. */
15
22
  send(playerId: string, data: unknown): void;
16
23
  /** Broadcasts bytes as a binary message and all other payloads as JSON. */
17
24
  broadcast(data: unknown, options?: MultiplayerBroadcastOptions): void;
18
25
  disconnect(playerId: string, reason?: string): void;
19
26
  }
20
- type CallbackResult = unknown | Promise<unknown>;
27
+ type CallbackResult = unknown;
21
28
  export interface MultiplayerServerCallbacks {
22
29
  onStart?: (session: MultiplayerServerSession) => CallbackResult;
23
30
  onPlayerJoin?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
@@ -26,6 +33,12 @@ export interface MultiplayerServerCallbacks {
26
33
  onMessage?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer, data: unknown) => CallbackResult;
27
34
  onTick?: (session: MultiplayerServerSession) => CallbackResult;
28
35
  onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
36
+ /**
37
+ * The session is ending. Awaited, so a save made here lands before the runtime is
38
+ * stopped. Not a guarantee: an out-of-memory kill or a host dying sends no shutdown
39
+ * at all, so save as things change and treat this as the last chance, not the plan.
40
+ */
41
+ onShutdown?: (session: MultiplayerServerSession) => CallbackResult;
29
42
  }
30
43
  export type CastleSessionBoot = (platformHandle: PlatformHandle) => Promise<void>;
31
44
  export declare function createCastleSessionBoot(callbacks: MultiplayerServerCallbacks): CastleSessionBoot;
@@ -1,4 +1,5 @@
1
1
  import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
2
+ import { createServerStorage } from "./storage";
2
3
  // The publish bundler binds the author's default callbacks export once and
3
4
  // exports the returned one-argument function as `__castleBootSession`.
4
5
  export function createCastleSessionBoot(callbacks) {
@@ -44,10 +45,11 @@ async function bootSession(platformHandle, callbacks) {
44
45
  platformHandle.on("replaced", async () => {
45
46
  await callbacks.onReplaced?.(session);
46
47
  });
47
- platformHandle.on("shutdown", () => {
48
+ platformHandle.on("shutdown", async () => {
48
49
  if (tickTimer !== null)
49
50
  clearInterval(tickTimer);
50
51
  tickTimer = null;
52
+ await callbacks.onShutdown?.(session);
51
53
  });
52
54
  await callbacks.onStart?.(session);
53
55
  const onTick = callbacks.onTick;
@@ -65,6 +67,7 @@ function createAuthorSession(platformHandle, players) {
65
67
  sessionId: platformHandle.config.sessionId,
66
68
  deckId: platformHandle.config.deckId,
67
69
  mode: platformHandle.config.mode,
70
+ storage: createServerStorage(platformHandle),
68
71
  send(playerId, data) {
69
72
  if (isBinaryPayload(data)) {
70
73
  requireSendBinary(platformHandle);
package/dist/storage.d.ts CHANGED
@@ -12,5 +12,20 @@ export interface SharedStorageApi {
12
12
  set(scope: SharedScope, key: string, value: Json): void;
13
13
  remove(scope: SharedScope, key: string): void;
14
14
  }
15
+ /**
16
+ * Per-player private storage.
17
+ *
18
+ * @deprecated Use `Store.private` instead. This keeps one document per player
19
+ * and rewrites all of it on every change, so it is capped at 2 MB and loses
20
+ * concurrent writes. It stays supported for decks already using it.
21
+ */
15
22
  export declare const Storage: StorageApi;
23
+ /**
24
+ * Storage other players can read.
25
+ *
26
+ * @deprecated Use `Store.deck`, `Store.me` and `Store.user(id)` instead. This
27
+ * has no ownership rule on `'deck'` scope -- any player can overwrite any key --
28
+ * and two players writing at once lose one of the writes. It stays supported
29
+ * for decks already using it.
30
+ */
16
31
  export declare const SharedStorage: SharedStorageApi;
package/dist/storage.js CHANGED
@@ -1,5 +1,6 @@
1
- import { CastleError } from "./errors";
1
+ import { decodeStorageValue, encodeStorageValue, storageError, } from "./storageJson";
2
2
  import { hostRequest } from "./transport";
3
+ import { onUnloadFlush } from "./unloadFlush";
3
4
  // Writes coalesce by key as they are made, so setting the same key repeatedly
4
5
  // costs one entry; this is how often the result is handed to the host. The host, not the deck, talks to the
5
6
  // server and owns retry: it lives in the page, so it is still alive to finish a
@@ -194,27 +195,32 @@ class SharedStorageImpl {
194
195
  void this.flush(true).catch(reportSharedStorageError);
195
196
  }
196
197
  }
197
- // Writes batch on a timer, so leaving the page inside that window would lose
198
- // them -- a deck saving progress as someone quits is exactly when it matters.
199
- // `pagehide` is the reliable signal on iOS, where unload often never fires, and
200
- // visibilitychange covers backgrounding without a navigation. Hooked on the
201
- // first write so a deck that never stores anything registers nothing.
202
- let unloadFlushHooked = false;
198
+ // Hoisted, not built per call: handlers are deduplicated by identity, so a fresh
199
+ // closure on every write would register one handler per write and fire them all
200
+ // at unload.
201
+ const flushStorageOnUnload = () => {
202
+ Storage.flushNow();
203
+ SharedStorage.flushNow();
204
+ };
203
205
  function hookUnloadFlush() {
204
- if (unloadFlushHooked || typeof window === "undefined")
205
- return;
206
- unloadFlushHooked = true;
207
- const flush = () => {
208
- Storage.flushNow();
209
- SharedStorage.flushNow();
210
- };
211
- window.addEventListener("pagehide", flush);
212
- document.addEventListener("visibilitychange", () => {
213
- if (document.visibilityState === "hidden")
214
- flush();
215
- });
206
+ onUnloadFlush(flushStorageOnUnload);
216
207
  }
208
+ /**
209
+ * Per-player private storage.
210
+ *
211
+ * @deprecated Use `Store.private` instead. This keeps one document per player
212
+ * and rewrites all of it on every change, so it is capped at 2 MB and loses
213
+ * concurrent writes. It stays supported for decks already using it.
214
+ */
217
215
  export const Storage = new PrivateStorageImpl();
216
+ /**
217
+ * Storage other players can read.
218
+ *
219
+ * @deprecated Use `Store.deck`, `Store.me` and `Store.user(id)` instead. This
220
+ * has no ownership rule on `'deck'` scope -- any player can overwrite any key --
221
+ * and two players writing at once lose one of the writes. It stays supported
222
+ * for decks already using it.
223
+ */
218
224
  export const SharedStorage = new SharedStorageImpl();
219
225
  // 'user'-scope writes/self-reads share one bucket (the host resolves the
220
226
  // current player); a 'user' read of someone else keys by their id.
@@ -244,71 +250,6 @@ function decodeStorageBlob(blob, operation) {
244
250
  decodeStorageValue(value, operation),
245
251
  ]));
246
252
  }
247
- function decodeStorageValue(encoded, operation) {
248
- try {
249
- return JSON.parse(encoded);
250
- }
251
- catch {
252
- throw storageError("CASTLE_STORAGE_PARSE_FAILED", "Stored Castle value is not valid JSON.", operation);
253
- }
254
- }
255
- function encodeStorageValue(value, operation) {
256
- try {
257
- assertJsonValue(value, new Set(), operation);
258
- return JSON.stringify(value);
259
- }
260
- catch (error) {
261
- if (error instanceof CastleError)
262
- throw error;
263
- throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values must be JSON.", operation);
264
- }
265
- }
266
- function assertJsonValue(value, seen, operation) {
267
- if (value === null || typeof value === "boolean" || typeof value === "string")
268
- return;
269
- if (typeof value === "number") {
270
- if (Number.isFinite(value))
271
- return;
272
- throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage numbers must be finite.", operation);
273
- }
274
- if (Array.isArray(value)) {
275
- assertJsonArray(value, seen, operation);
276
- return;
277
- }
278
- if (typeof value === "object") {
279
- assertJsonObject(value, seen, operation);
280
- return;
281
- }
282
- throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values must be JSON.", operation);
283
- }
284
- function assertJsonArray(values, seen, operation) {
285
- assertNotCyclic(values, seen, operation);
286
- seen.add(values);
287
- for (const value of values)
288
- assertJsonValue(value, seen, operation);
289
- seen.delete(values);
290
- }
291
- function assertJsonObject(value, seen, operation) {
292
- assertNotCyclic(value, seen, operation);
293
- const prototype = Object.getPrototypeOf(value);
294
- if (prototype !== Object.prototype && prototype !== null) {
295
- throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage objects must be plain JSON.", operation);
296
- }
297
- seen.add(value);
298
- for (const child of Object.values(value))
299
- assertJsonValue(child, seen, operation);
300
- seen.delete(value);
301
- }
302
- function assertNotCyclic(value, seen, operation) {
303
- if (seen.has(value)) {
304
- throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values cannot be cyclic.", operation);
305
- }
306
- }
307
- function assertScope(scope, operation) {
308
- if (scope !== "deck" && scope !== "user") {
309
- throw storageError("CASTLE_STORAGE_INVALID_SCOPE", 'SharedStorage scope must be "deck" or "user".', operation);
310
- }
311
- }
312
253
  function rejectReadBatch(batch, error) {
313
254
  for (const reads of batch.reads.values()) {
314
255
  for (const read of reads)
@@ -318,6 +259,8 @@ function rejectReadBatch(batch, error) {
318
259
  function reportSharedStorageError(error) {
319
260
  console.warn("Castle shared storage write failed", error);
320
261
  }
321
- function storageError(code, message, operation) {
322
- return new CastleError({ code, message, operation });
262
+ function assertScope(scope, operation) {
263
+ if (scope !== "deck" && scope !== "user") {
264
+ throw storageError("CASTLE_STORAGE_INVALID_SCOPE", 'SharedStorage scope must be "deck" or "user".', operation);
265
+ }
323
266
  }
@@ -0,0 +1,5 @@
1
+ import { CastleError } from "./errors";
2
+ import type { Json } from "./types";
3
+ export declare function storageError(code: string, message: string, operation: string): CastleError;
4
+ export declare function decodeStorageValue(encoded: string, operation: string): Json;
5
+ export declare function encodeStorageValue(value: Json, operation: string): string;
@@ -0,0 +1,69 @@
1
+ // The JSON contract shared by both deck-storage APIs: the whole-document
2
+ // Storage/SharedStorage and the per-key Store. Values cross the wire as strings,
3
+ // so this is where a deck's value becomes one and where a stored string becomes
4
+ // a value again. Kept apart from either API so neither owns the rules and the
5
+ // two cannot drift.
6
+ import { CastleError } from "./errors";
7
+ export function storageError(code, message, operation) {
8
+ return new CastleError({ code, message, operation });
9
+ }
10
+ export function decodeStorageValue(encoded, operation) {
11
+ try {
12
+ return JSON.parse(encoded);
13
+ }
14
+ catch {
15
+ throw storageError("CASTLE_STORAGE_PARSE_FAILED", "Stored Castle value is not valid JSON.", operation);
16
+ }
17
+ }
18
+ export function encodeStorageValue(value, operation) {
19
+ try {
20
+ assertJsonValue(value, new Set(), operation);
21
+ return JSON.stringify(value);
22
+ }
23
+ catch (error) {
24
+ if (error instanceof CastleError)
25
+ throw error;
26
+ throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values must be JSON.", operation);
27
+ }
28
+ }
29
+ function assertJsonValue(value, seen, operation) {
30
+ if (value === null || typeof value === "boolean" || typeof value === "string")
31
+ return;
32
+ if (typeof value === "number") {
33
+ if (Number.isFinite(value))
34
+ return;
35
+ throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage numbers must be finite.", operation);
36
+ }
37
+ if (Array.isArray(value)) {
38
+ assertJsonArray(value, seen, operation);
39
+ return;
40
+ }
41
+ if (typeof value === "object") {
42
+ assertJsonObject(value, seen, operation);
43
+ return;
44
+ }
45
+ throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values must be JSON.", operation);
46
+ }
47
+ function assertJsonArray(values, seen, operation) {
48
+ assertNotCyclic(values, seen, operation);
49
+ seen.add(values);
50
+ for (const value of values)
51
+ assertJsonValue(value, seen, operation);
52
+ seen.delete(values);
53
+ }
54
+ function assertJsonObject(value, seen, operation) {
55
+ assertNotCyclic(value, seen, operation);
56
+ const prototype = Object.getPrototypeOf(value);
57
+ if (prototype !== Object.prototype && prototype !== null) {
58
+ throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage objects must be plain JSON.", operation);
59
+ }
60
+ seen.add(value);
61
+ for (const child of Object.values(value))
62
+ assertJsonValue(child, seen, operation);
63
+ seen.delete(value);
64
+ }
65
+ function assertNotCyclic(value, seen, operation) {
66
+ if (seen.has(value)) {
67
+ throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values cannot be cyclic.", operation);
68
+ }
69
+ }
@@ -0,0 +1,67 @@
1
+ import type { Json } from "./types";
2
+ export interface StoreListOptions {
3
+ /** Only keys beginning with this. Omit for every key in the scope. */
4
+ prefix?: string;
5
+ /** Rows per page. The platform caps both the row count and the page's size. */
6
+ limit?: number;
7
+ /** The `cursor` from the previous page. Omit for the first page. */
8
+ cursor?: string | null;
9
+ }
10
+ export interface StoreListEntry<T extends Json = Json> {
11
+ key: string;
12
+ value: T;
13
+ }
14
+ export interface StoreListPage<T extends Json = Json> {
15
+ entries: StoreListEntry<T>[];
16
+ /**
17
+ * Pass to the next `list` call to continue. Null means there is nothing left,
18
+ * and is the only reliable end-of-range signal: a page can come back short
19
+ * because it hit its size cap rather than because the keys ran out.
20
+ */
21
+ cursor: string | null;
22
+ }
23
+ export interface StoreScopeApi {
24
+ /** Reads keys. Keys with no value are absent from the result. */
25
+ get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
26
+ /**
27
+ * Writes keys. Resolves once the platform has the write, so a deck can tell
28
+ * whether saving worked; the write itself is coalesced with nearby ones.
29
+ */
30
+ set(values: Record<string, Json>): Promise<void>;
31
+ remove(keys: string[]): Promise<void>;
32
+ /**
33
+ * Adds to a numeric key and returns the new total, creating the key at `delta`
34
+ * if it is absent. Atomic: simultaneous callers each see their own increment
35
+ * applied, which a read-modify-write cannot promise.
36
+ */
37
+ increment(key: string, delta?: number): Promise<number>;
38
+ /** A page of keys under a prefix, ordered by key. */
39
+ list<T extends Json = Json>(options?: StoreListOptions): Promise<StoreListPage<T>>;
40
+ }
41
+ export interface StoreBoardScore {
42
+ /** The user id whose score this is. */
43
+ subject: string;
44
+ score: number;
45
+ updatedAt: string;
46
+ }
47
+ export interface StoreBoardApi {
48
+ /** Records the current player's score. Later submissions replace earlier ones. */
49
+ submit(score: number): Promise<StoreBoardScore>;
50
+ /** The highest scores, best first. */
51
+ top(limit?: number): Promise<StoreBoardScore[]>;
52
+ /** One player's score, defaulting to the current player. Null if unset. */
53
+ get(userId?: string): Promise<StoreBoardScore | null>;
54
+ }
55
+ export interface CastleStoreApi {
56
+ /** Shared by everyone playing the deck. */
57
+ readonly deck: StoreScopeApi;
58
+ /** The current player's data, which other players can read. */
59
+ readonly me: StoreScopeApi;
60
+ /** Another player's public data. Readable by anyone; writable only by them. */
61
+ user(userId: string): StoreScopeApi;
62
+ /** The current player's own data. Nobody else can read it, not even a session server. */
63
+ readonly private: StoreScopeApi;
64
+ /** A leaderboard. */
65
+ board(name: string): StoreBoardApi;
66
+ }
67
+ export declare const Store: CastleStoreApi;