@joystyk/storage 0.0.0-stage → 0.2.0

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +36 -0
  4. package/README.md +168 -2
  5. package/dist/backend.d.ts +64 -0
  6. package/dist/backend.js +35 -0
  7. package/dist/backend.js.map +1 -0
  8. package/dist/browser/changes.d.ts +17 -0
  9. package/dist/browser/changes.js +96 -0
  10. package/dist/browser/changes.js.map +1 -0
  11. package/dist/browser/index.d.ts +18 -0
  12. package/dist/browser/index.js +44 -0
  13. package/dist/browser/index.js.map +1 -0
  14. package/dist/browser/indexeddb.d.ts +40 -0
  15. package/dist/browser/indexeddb.js +145 -0
  16. package/dist/browser/indexeddb.js.map +1 -0
  17. package/dist/browser/persist.d.ts +24 -0
  18. package/dist/browser/persist.js +42 -0
  19. package/dist/browser/persist.js.map +1 -0
  20. package/dist/changes.d.ts +26 -0
  21. package/dist/changes.js +50 -0
  22. package/dist/changes.js.map +1 -0
  23. package/dist/envelope.d.ts +4 -0
  24. package/dist/envelope.js +109 -0
  25. package/dist/envelope.js.map +1 -0
  26. package/dist/game-data.d.ts +17 -0
  27. package/dist/game-data.js +54 -0
  28. package/dist/game-data.js.map +1 -0
  29. package/dist/index.d.ts +13 -0
  30. package/dist/index.js +14 -0
  31. package/dist/keys.d.ts +14 -0
  32. package/dist/keys.js +29 -0
  33. package/dist/keys.js.map +1 -0
  34. package/dist/memory.d.ts +23 -0
  35. package/dist/memory.js +65 -0
  36. package/dist/memory.js.map +1 -0
  37. package/dist/preferences.d.ts +61 -0
  38. package/dist/preferences.js +93 -0
  39. package/dist/preferences.js.map +1 -0
  40. package/dist/record.d.ts +117 -0
  41. package/dist/record.js +177 -0
  42. package/dist/record.js.map +1 -0
  43. package/dist/saves.d.ts +46 -0
  44. package/dist/saves.js +100 -0
  45. package/dist/saves.js.map +1 -0
  46. package/dist/stats.d.ts +34 -0
  47. package/dist/stats.js +78 -0
  48. package/dist/stats.js.map +1 -0
  49. package/dist/storage.d.ts +45 -0
  50. package/dist/storage.js +43 -0
  51. package/dist/storage.js.map +1 -0
  52. package/dist/streaks.d.ts +23 -0
  53. package/dist/streaks.js +41 -0
  54. package/dist/streaks.js.map +1 -0
  55. package/dist/validate.d.ts +14 -0
  56. package/dist/validate.js +24 -0
  57. package/dist/validate.js.map +1 -0
  58. package/package.json +51 -3
@@ -0,0 +1,145 @@
1
+ import { MESSAGES, STORE_NAMES, storageError } from "../backend.js";
2
+ import { err, ok } from "@joystyk/contracts";
3
+ //#region src/browser/indexeddb.ts
4
+ /**
5
+ * The database schema, oldest first. Record shapes are versioned per record (see
6
+ * `RecordKind`); this list changes only when an object store is added or removed.
7
+ */
8
+ var DATABASE_UPGRADES = [{
9
+ version: 1,
10
+ upgrade: (db) => {
11
+ for (const name of STORE_NAMES) db.createObjectStore(name);
12
+ }
13
+ }];
14
+ var QUOTA_NAMES = /* @__PURE__ */ new Set(["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"]);
15
+ /** Maps a DOMException (or anything thrown) to a StorageError. */
16
+ var toStorageError = (cause) => {
17
+ const name = typeof cause === "object" && cause !== null ? String(cause.name) : "";
18
+ const detail = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
19
+ if (QUOTA_NAMES.has(name)) return storageError("QuotaExceeded", MESSAGES.QuotaExceeded, detail);
20
+ if (name === "InvalidStateError" || name === "SecurityError" || name === "UnknownError") return storageError("Unavailable", MESSAGES.Unavailable, detail);
21
+ if (name === "DataCloneError" || name === "DataError") return storageError("InvalidValue", MESSAGES.InvalidValue, detail);
22
+ return storageError("Failed", MESSAGES.Failed, detail);
23
+ };
24
+ var checkUpgrades = (upgrades) => {
25
+ upgrades.forEach((step, index) => {
26
+ if (step.version !== index + 1) throw new RangeError(`Database upgrades must be numbered 1, 2, 3…; step ${index} is version ${step.version}`);
27
+ });
28
+ if (upgrades.length === 0) throw new RangeError("At least one database upgrade is required");
29
+ return upgrades.length;
30
+ };
31
+ /**
32
+ * Opens (creating or upgrading) the database and returns a backend over it. Runs every
33
+ * upgrade step newer than the stored version, in order, inside one `versionchange`
34
+ * transaction. Resolves to `Unavailable` where IndexedDB is missing or refused.
35
+ */
36
+ var openIndexedDbBackend = (options = {}) => {
37
+ const upgrades = options.upgrades ?? DATABASE_UPGRADES;
38
+ const version = checkUpgrades(upgrades);
39
+ const factory = options.factory ?? globalThis.indexedDB;
40
+ if (factory === void 0) return Promise.resolve(err(storageError("Unavailable", MESSAGES.Unavailable)));
41
+ return new Promise((resolve) => {
42
+ let request;
43
+ try {
44
+ request = factory.open(options.name ?? "joystyk", version);
45
+ } catch (cause) {
46
+ resolve(err(toStorageError(cause)));
47
+ return;
48
+ }
49
+ request.onupgradeneeded = (event) => {
50
+ const transaction = request.transaction;
51
+ if (transaction === null) return;
52
+ for (const step of upgrades) if (step.version > event.oldVersion) step.upgrade(request.result, transaction);
53
+ };
54
+ request.onblocked = () => options.onBlocked?.();
55
+ request.onerror = (event) => {
56
+ event.preventDefault();
57
+ resolve(err(toStorageError(request.error)));
58
+ };
59
+ request.onsuccess = () => resolve(ok(createBackend(request.result, options)));
60
+ });
61
+ };
62
+ var createBackend = (db, options) => {
63
+ let closed = false;
64
+ const close = () => {
65
+ if (closed) return;
66
+ closed = true;
67
+ db.close();
68
+ };
69
+ db.onversionchange = () => {
70
+ close();
71
+ options.onVersionChange?.();
72
+ };
73
+ db.onclose = () => {
74
+ closed = true;
75
+ };
76
+ /**
77
+ * Runs `work` in a transaction on one store and resolves when the transaction commits, so
78
+ * a write counts as done only once it is durable. Quota errors often surface only as the
79
+ * transaction aborting, so the abort is mapped too.
80
+ */
81
+ const run = (store, mode, work) => new Promise((resolve) => {
82
+ if (closed) {
83
+ resolve(err(storageError("Unavailable", MESSAGES.Unavailable)));
84
+ return;
85
+ }
86
+ let value;
87
+ let transaction;
88
+ try {
89
+ transaction = db.transaction(store, mode);
90
+ work(transaction.objectStore(store), (result) => {
91
+ value = result;
92
+ });
93
+ } catch (cause) {
94
+ resolve(err(toStorageError(cause)));
95
+ return;
96
+ }
97
+ transaction.oncomplete = () => resolve(ok(value));
98
+ transaction.onabort = () => resolve(err(toStorageError(transaction.error)));
99
+ transaction.onerror = (event) => event.preventDefault();
100
+ });
101
+ return {
102
+ get: (store, key) => run(store, "readonly", (objectStore, done) => {
103
+ const request = objectStore.get(key);
104
+ request.onsuccess = () => done(request.result);
105
+ }),
106
+ put: (store, key, value) => run(store, "readwrite", (objectStore) => {
107
+ objectStore.put(value, key);
108
+ }),
109
+ putIfAbsent: (store, key, value) => run(store, "readwrite", (objectStore, done) => {
110
+ const request = objectStore.get(key);
111
+ request.onsuccess = () => {
112
+ if (request.result !== void 0) return done({
113
+ written: false,
114
+ value: request.result
115
+ });
116
+ objectStore.add(value, key);
117
+ done({ written: true });
118
+ };
119
+ }),
120
+ delete: (store, key) => run(store, "readwrite", (objectStore) => {
121
+ objectStore.delete(key);
122
+ }),
123
+ keys: (store, prefix) => run(store, "readonly", (objectStore, done) => {
124
+ const keys = [];
125
+ done(keys);
126
+ const request = objectStore.openKeyCursor(prefix === "" ? null : IDBKeyRange.lowerBound(prefix));
127
+ request.onsuccess = () => {
128
+ const cursor = request.result;
129
+ if (cursor === null) return;
130
+ const key = cursor.key;
131
+ if (typeof key !== "string" || !key.startsWith(prefix)) return;
132
+ keys.push(key);
133
+ cursor.continue();
134
+ };
135
+ }),
136
+ clear: (store) => run(store, "readwrite", (objectStore) => {
137
+ objectStore.clear();
138
+ }),
139
+ close
140
+ };
141
+ };
142
+ //#endregion
143
+ export { DATABASE_UPGRADES, openIndexedDbBackend, toStorageError };
144
+
145
+ //# sourceMappingURL=indexeddb.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"indexeddb.js","names":[],"sources":["../../src/browser/indexeddb.ts"],"sourcesContent":["// A thin IndexedDB backend: one database, one object store per StoreName, string keys, JSON\n// values. No IndexedDB library; just requests wrapped in promises and errors mapped to\n// StorageError results.\nimport { err, ok, type Json, type Result } from '@joystyk/contracts';\nimport {\n MESSAGES,\n STORE_NAMES,\n storageError,\n type PutIfAbsent,\n type StorageBackend,\n type StorageError,\n type StoreName,\n} from '../backend.ts';\n\n/** One step of the database's own schema: which object stores exist. */\nexport type DatabaseUpgrade = {\n /** The database version this step produces, from 1, with no gaps. */\n readonly version: number;\n /** Runs inside the `versionchange` transaction. Create or delete object stores here. */\n upgrade(db: IDBDatabase, transaction: IDBTransaction): void;\n};\n\n/**\n * The database schema, oldest first. Record shapes are versioned per record (see\n * `RecordKind`); this list changes only when an object store is added or removed.\n */\nexport const DATABASE_UPGRADES: readonly DatabaseUpgrade[] = [\n {\n version: 1,\n upgrade: (db) => {\n for (const name of STORE_NAMES) db.createObjectStore(name);\n },\n },\n];\n\nexport type IndexedDbOptions = {\n /** Defaults to \"joystyk\". */\n readonly name?: string;\n /** Defaults to the global `indexedDB`. */\n readonly factory?: IDBFactory;\n /** Defaults to `DATABASE_UPGRADES`. */\n readonly upgrades?: readonly DatabaseUpgrade[];\n /**\n * Another tab holds an older connection open, so the upgrade waits. The frame can ask the\n * player to close other tabs of the site.\n */\n readonly onBlocked?: () => void;\n /**\n * Another tab needs a newer database version. This connection closes so that tab can\n * upgrade, and later operations fail with `Unavailable`; the frame should offer a reload.\n */\n readonly onVersionChange?: () => void;\n};\n\nconst QUOTA_NAMES = new Set(['QuotaExceededError', 'NS_ERROR_DOM_QUOTA_REACHED']);\n\n/** Maps a DOMException (or anything thrown) to a StorageError. */\nexport const toStorageError = (cause: unknown): StorageError => {\n const name = typeof cause === 'object' && cause !== null ? String((cause as { name?: unknown }).name) : '';\n const detail = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);\n if (QUOTA_NAMES.has(name)) return storageError('QuotaExceeded', MESSAGES.QuotaExceeded, detail);\n if (name === 'InvalidStateError' || name === 'SecurityError' || name === 'UnknownError') {\n return storageError('Unavailable', MESSAGES.Unavailable, detail);\n }\n if (name === 'DataCloneError' || name === 'DataError')\n return storageError('InvalidValue', MESSAGES.InvalidValue, detail);\n return storageError('Failed', MESSAGES.Failed, detail);\n};\n\nconst checkUpgrades = (upgrades: readonly DatabaseUpgrade[]): number => {\n upgrades.forEach((step, index) => {\n if (step.version !== index + 1) {\n throw new RangeError(`Database upgrades must be numbered 1, 2, 3…; step ${index} is version ${step.version}`);\n }\n });\n if (upgrades.length === 0) throw new RangeError('At least one database upgrade is required');\n return upgrades.length;\n};\n\n/**\n * Opens (creating or upgrading) the database and returns a backend over it. Runs every\n * upgrade step newer than the stored version, in order, inside one `versionchange`\n * transaction. Resolves to `Unavailable` where IndexedDB is missing or refused.\n */\nexport const openIndexedDbBackend = (options: IndexedDbOptions = {}): Promise<Result<StorageBackend, StorageError>> => {\n const upgrades = options.upgrades ?? DATABASE_UPGRADES;\n const version = checkUpgrades(upgrades);\n const factory = options.factory ?? (globalThis as { indexedDB?: IDBFactory }).indexedDB;\n if (factory === undefined) return Promise.resolve(err(storageError('Unavailable', MESSAGES.Unavailable)));\n\n return new Promise((resolve) => {\n let request: IDBOpenDBRequest;\n try {\n request = factory.open(options.name ?? 'joystyk', version);\n } catch (cause) {\n resolve(err(toStorageError(cause)));\n return;\n }\n request.onupgradeneeded = (event) => {\n const transaction = request.transaction;\n if (transaction === null) return;\n for (const step of upgrades) {\n if (step.version > event.oldVersion) step.upgrade(request.result, transaction);\n }\n };\n request.onblocked = () => options.onBlocked?.();\n request.onerror = (event) => {\n event.preventDefault();\n resolve(err(toStorageError(request.error)));\n };\n request.onsuccess = () => resolve(ok(createBackend(request.result, options)));\n });\n};\n\nconst createBackend = (db: IDBDatabase, options: IndexedDbOptions): StorageBackend => {\n let closed = false;\n const close = (): void => {\n if (closed) return;\n closed = true;\n db.close();\n };\n db.onversionchange = () => {\n close();\n options.onVersionChange?.();\n };\n // The browser closed the connection itself, e.g. when site data was cleared.\n db.onclose = () => {\n closed = true;\n };\n\n /**\n * Runs `work` in a transaction on one store and resolves when the transaction commits, so\n * a write counts as done only once it is durable. Quota errors often surface only as the\n * transaction aborting, so the abort is mapped too.\n */\n const run = <T>(\n store: StoreName,\n mode: IDBTransactionMode,\n work: (objectStore: IDBObjectStore, done: (value: T) => void) => void,\n ): Promise<Result<T, StorageError>> =>\n new Promise((resolve) => {\n if (closed) {\n resolve(err(storageError('Unavailable', MESSAGES.Unavailable)));\n return;\n }\n let value: T | undefined;\n let transaction: IDBTransaction;\n try {\n transaction = db.transaction(store, mode);\n work(transaction.objectStore(store), (result) => {\n value = result;\n });\n } catch (cause) {\n resolve(err(toStorageError(cause)));\n return;\n }\n transaction.oncomplete = () => resolve(ok(value as T));\n transaction.onabort = () => resolve(err(toStorageError(transaction.error)));\n // An error event is followed by an abort; prevent it from reaching window.onerror.\n transaction.onerror = (event) => event.preventDefault();\n });\n\n return {\n get: (store, key) =>\n run<unknown>(store, 'readonly', (objectStore, done) => {\n const request = objectStore.get(key);\n request.onsuccess = () => done(request.result);\n }),\n put: (store, key, value: Json) =>\n run<void>(store, 'readwrite', (objectStore) => {\n objectStore.put(value, key);\n }),\n // One readwrite transaction reads and adds. IndexedDB runs readwrite transactions on the\n // same store one at a time, across every connection to the database, so two tabs can't\n // both find the key absent.\n putIfAbsent: (store, key, value: Json) =>\n run<PutIfAbsent>(store, 'readwrite', (objectStore, done) => {\n const request = objectStore.get(key);\n request.onsuccess = () => {\n if (request.result !== undefined) return done({ written: false, value: request.result });\n objectStore.add(value, key);\n done({ written: true });\n };\n }),\n delete: (store, key) =>\n run<void>(store, 'readwrite', (objectStore) => {\n objectStore.delete(key);\n }),\n keys: (store, prefix) =>\n run<readonly string[]>(store, 'readonly', (objectStore, done) => {\n const keys: string[] = [];\n done(keys);\n const request = objectStore.openKeyCursor(prefix === '' ? null : IDBKeyRange.lowerBound(prefix));\n request.onsuccess = () => {\n const cursor = request.result;\n if (cursor === null) return;\n const key = cursor.key;\n if (typeof key !== 'string' || !key.startsWith(prefix)) return;\n keys.push(key);\n cursor.continue();\n };\n }),\n clear: (store) =>\n run<void>(store, 'readwrite', (objectStore) => {\n objectStore.clear();\n }),\n close,\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ /** The parts of `navigator.storage` these helpers use. */
2
+ export type StorageManagerLike = {
3
+ persist?(): Promise<boolean>;
4
+ persisted?(): Promise<boolean>;
5
+ estimate?(): Promise<{
6
+ usage?: number;
7
+ quota?: number;
8
+ }>;
9
+ };
10
+ /** `persisted`: the browser won't evict this site's data. `denied`: it may. `unsupported`: it can't say. */
11
+ export type PersistResult = 'persisted' | 'denied' | 'unsupported';
12
+ /**
13
+ * Asks the browser to keep this site's data. Call it from the player's gesture of pinning a
14
+ * game; never on page load. Already-persisted storage resolves without asking again, and a
15
+ * browser that throws counts as `unsupported`.
16
+ */
17
+ export declare const requestPersistentStorage: (manager?: StorageManagerLike | undefined) => Promise<PersistResult>;
18
+ /** Whether the browser already keeps this site's data, without asking. */
19
+ export declare const isStoragePersisted: (manager?: StorageManagerLike | undefined) => Promise<PersistResult>;
20
+ /** Bytes used and available to this site, or null where the browser won't say. */
21
+ export declare const estimateStorage: (manager?: StorageManagerLike | undefined) => Promise<{
22
+ readonly usage: number;
23
+ readonly quota: number;
24
+ } | null>;
@@ -0,0 +1,42 @@
1
+ //#region src/browser/persist.ts
2
+ var defaultManager = () => globalThis.navigator?.storage;
3
+ /**
4
+ * Asks the browser to keep this site's data. Call it from the player's gesture of pinning a
5
+ * game; never on page load. Already-persisted storage resolves without asking again, and a
6
+ * browser that throws counts as `unsupported`.
7
+ */
8
+ var requestPersistentStorage = async (manager = defaultManager()) => {
9
+ if (manager?.persist === void 0) return "unsupported";
10
+ try {
11
+ if (await manager.persisted?.() === true) return "persisted";
12
+ return await manager.persist() ? "persisted" : "denied";
13
+ } catch {
14
+ return "unsupported";
15
+ }
16
+ };
17
+ /** Whether the browser already keeps this site's data, without asking. */
18
+ var isStoragePersisted = async (manager = defaultManager()) => {
19
+ if (manager?.persisted === void 0) return "unsupported";
20
+ try {
21
+ return await manager.persisted() ? "persisted" : "denied";
22
+ } catch {
23
+ return "unsupported";
24
+ }
25
+ };
26
+ /** Bytes used and available to this site, or null where the browser won't say. */
27
+ var estimateStorage = async (manager = defaultManager()) => {
28
+ if (manager?.estimate === void 0) return null;
29
+ try {
30
+ const { usage, quota } = await manager.estimate();
31
+ return usage === void 0 || quota === void 0 ? null : {
32
+ usage,
33
+ quota
34
+ };
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+ //#endregion
40
+ export { estimateStorage, isStoragePersisted, requestPersistentStorage };
41
+
42
+ //# sourceMappingURL=persist.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persist.js","names":[],"sources":["../../src/browser/persist.ts"],"sourcesContent":["// Persistent storage (offline-and-updates.md, \"Cache tiers\"): the PWA asks for it when a\n// player first pins a game, so the browser won't evict saves and pinned games under pressure.\n\n/** The parts of `navigator.storage` these helpers use. */\nexport type StorageManagerLike = {\n persist?(): Promise<boolean>;\n persisted?(): Promise<boolean>;\n estimate?(): Promise<{ usage?: number; quota?: number }>;\n};\n\n/** `persisted`: the browser won't evict this site's data. `denied`: it may. `unsupported`: it can't say. */\nexport type PersistResult = 'persisted' | 'denied' | 'unsupported';\n\nconst defaultManager = (): StorageManagerLike | undefined =>\n (globalThis as { navigator?: { storage?: StorageManagerLike } }).navigator?.storage;\n\n/**\n * Asks the browser to keep this site's data. Call it from the player's gesture of pinning a\n * game; never on page load. Already-persisted storage resolves without asking again, and a\n * browser that throws counts as `unsupported`.\n */\nexport const requestPersistentStorage = async (\n manager: StorageManagerLike | undefined = defaultManager(),\n): Promise<PersistResult> => {\n if (manager?.persist === undefined) return 'unsupported';\n try {\n if ((await manager.persisted?.()) === true) return 'persisted';\n return (await manager.persist()) ? 'persisted' : 'denied';\n } catch {\n return 'unsupported';\n }\n};\n\n/** Whether the browser already keeps this site's data, without asking. */\nexport const isStoragePersisted = async (\n manager: StorageManagerLike | undefined = defaultManager(),\n): Promise<PersistResult> => {\n if (manager?.persisted === undefined) return 'unsupported';\n try {\n return (await manager.persisted()) ? 'persisted' : 'denied';\n } catch {\n return 'unsupported';\n }\n};\n\n/** Bytes used and available to this site, or null where the browser won't say. */\nexport const estimateStorage = async (\n manager: StorageManagerLike | undefined = defaultManager(),\n): Promise<{ readonly usage: number; readonly quota: number } | null> => {\n if (manager?.estimate === undefined) return null;\n try {\n const { usage, quota } = await manager.estimate();\n return usage === undefined || quota === undefined ? null : { usage, quota };\n } catch {\n return null;\n }\n};\n"],"mappings":";AAaA,IAAM,uBACH,WAAgE,WAAW;;;;;;AAO9E,IAAa,2BAA2B,OACtC,UAA0C,eAAe,MAC9B;CAC3B,IAAI,SAAS,YAAY,KAAA,GAAW,OAAO;CAC3C,IAAI;EACF,IAAK,MAAM,QAAQ,YAAY,MAAO,MAAM,OAAO;EACnD,OAAQ,MAAM,QAAQ,QAAQ,IAAK,cAAc;CACnD,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,IAAa,qBAAqB,OAChC,UAA0C,eAAe,MAC9B;CAC3B,IAAI,SAAS,cAAc,KAAA,GAAW,OAAO;CAC7C,IAAI;EACF,OAAQ,MAAM,QAAQ,UAAU,IAAK,cAAc;CACrD,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,IAAa,kBAAkB,OAC7B,UAA0C,eAAe,MACc;CACvE,IAAI,SAAS,aAAa,KAAA,GAAW,OAAO;CAC5C,IAAI;EACF,MAAM,EAAE,OAAO,UAAU,MAAM,QAAQ,SAAS;EAChD,OAAO,UAAU,KAAA,KAAa,UAAU,KAAA,IAAY,OAAO;GAAE;GAAO;EAAM;CAC5E,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,26 @@
1
+ /** A change another tab should hear about. Preferences are the only kind so far. */
2
+ export type StorageChange = {
3
+ readonly kind: 'preferences';
4
+ readonly playerId: string;
5
+ };
6
+ /** Parses a message from another tab. It crossed a boundary, so anything else is null. */
7
+ export declare const parseStorageChange: (message: unknown) => Promise<StorageChange | null>;
8
+ /**
9
+ * Carries changes between the tabs of one browser. A feed never hands a change back to the
10
+ * tab that published it; that tab has already told its own listeners.
11
+ */
12
+ export interface ChangeFeed {
13
+ /** Tells the other tabs. Never throws: a tab that can't be told simply isn't. */
14
+ publish(change: StorageChange): void;
15
+ /** Called with each message from another tab, unparsed. Returns an idempotent unsubscribe. */
16
+ subscribe(listener: (message: unknown) => void): () => void;
17
+ /** Stops listening and publishing. Idempotent. */
18
+ close(): void;
19
+ }
20
+ /**
21
+ * Feeds that reach each other in memory, for tests: each `feed()` stands for one tab, and a
22
+ * change reaches every other feed asynchronously, as a browser delivers it.
23
+ */
24
+ export declare const createMemoryChangeHub: () => {
25
+ feed(): ChangeFeed;
26
+ };
@@ -0,0 +1,50 @@
1
+ import { isKeyPart } from "./keys.js";
2
+ import { validate } from "./validate.js";
3
+ import * as z from "zod/mini";
4
+ //#region src/changes.ts
5
+ var { structuredClone } = globalThis;
6
+ var STORAGE_CHANGE = z.object({
7
+ kind: z.literal("preferences"),
8
+ playerId: z.string().check(z.refine(isKeyPart))
9
+ });
10
+ /** Parses a message from another tab. It crossed a boundary, so anything else is null. */
11
+ var parseStorageChange = async (message) => {
12
+ const parsed = await validate(STORAGE_CHANGE, message);
13
+ return parsed.ok ? parsed.value : null;
14
+ };
15
+ /**
16
+ * Feeds that reach each other in memory, for tests: each `feed()` stands for one tab, and a
17
+ * change reaches every other feed asynchronously, as a browser delivers it.
18
+ */
19
+ var createMemoryChangeHub = () => {
20
+ const feeds = /* @__PURE__ */ new Set();
21
+ return { feed: () => {
22
+ const listeners = /* @__PURE__ */ new Set();
23
+ feeds.add(listeners);
24
+ return {
25
+ publish: (change) => {
26
+ for (const other of feeds) {
27
+ if (other === listeners) continue;
28
+ Promise.resolve().then(() => {
29
+ for (const listener of [...other]) listener(structuredClone(change));
30
+ });
31
+ }
32
+ },
33
+ subscribe: (listener) => {
34
+ const entry = (message) => listener(message);
35
+ listeners.add(entry);
36
+ return () => {
37
+ listeners.delete(entry);
38
+ };
39
+ },
40
+ close: () => {
41
+ listeners.clear();
42
+ feeds.delete(listeners);
43
+ }
44
+ };
45
+ } };
46
+ };
47
+ //#endregion
48
+ export { createMemoryChangeHub, parseStorageChange };
49
+
50
+ //# sourceMappingURL=changes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.js","names":[],"sources":["../src/changes.ts"],"sourcesContent":["// Telling a browser's other tabs that stored data changed (gp-d4v). The core only describes\n// the feed; '@joystyk/storage/browser' builds one over BroadcastChannel, or over the `storage`\n// event where BroadcastChannel is missing. A change carries no data, only what changed:\n// a tab that hears of one reads the record back from storage, through its schema.\nimport * as z from 'zod/mini';\nimport { isKeyPart } from './keys.ts';\nimport { validate } from './validate.ts';\n\n// Node, browsers and workers all have structuredClone; the core's ES-only lib doesn't name it.\nconst { structuredClone } = globalThis as unknown as { structuredClone: <T>(value: T) => T };\n\n/** A change another tab should hear about. Preferences are the only kind so far. */\nexport type StorageChange = { readonly kind: 'preferences'; readonly playerId: string };\n\nconst STORAGE_CHANGE = z.object({\n kind: z.literal('preferences'),\n playerId: z.string().check(z.refine(isKeyPart)),\n});\n\n/** Parses a message from another tab. It crossed a boundary, so anything else is null. */\nexport const parseStorageChange = async (message: unknown): Promise<StorageChange | null> => {\n const parsed = await validate(STORAGE_CHANGE, message);\n return parsed.ok ? parsed.value : null;\n};\n\n/**\n * Carries changes between the tabs of one browser. A feed never hands a change back to the\n * tab that published it; that tab has already told its own listeners.\n */\nexport interface ChangeFeed {\n /** Tells the other tabs. Never throws: a tab that can't be told simply isn't. */\n publish(change: StorageChange): void;\n /** Called with each message from another tab, unparsed. Returns an idempotent unsubscribe. */\n subscribe(listener: (message: unknown) => void): () => void;\n /** Stops listening and publishing. Idempotent. */\n close(): void;\n}\n\n/**\n * Feeds that reach each other in memory, for tests: each `feed()` stands for one tab, and a\n * change reaches every other feed asynchronously, as a browser delivers it.\n */\nexport const createMemoryChangeHub = (): { feed(): ChangeFeed } => {\n const feeds = new Set<Set<(message: unknown) => void>>();\n return {\n feed: () => {\n const listeners = new Set<(message: unknown) => void>();\n feeds.add(listeners);\n return {\n publish: (change) => {\n for (const other of feeds) {\n if (other === listeners) continue;\n void Promise.resolve().then(() => {\n for (const listener of [...other]) listener(structuredClone(change));\n });\n }\n },\n subscribe: (listener) => {\n const entry = (message: unknown): void => listener(message);\n listeners.add(entry);\n return () => {\n listeners.delete(entry);\n };\n },\n close: () => {\n listeners.clear();\n feeds.delete(listeners);\n },\n };\n },\n };\n};\n"],"mappings":";;;;AASA,IAAM,EAAE,oBAAoB;AAK5B,IAAM,iBAAiB,EAAE,OAAO;CAC9B,MAAM,EAAE,QAAQ,aAAa;CAC7B,UAAU,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,OAAO,SAAS,CAAC;AAChD,CAAC;;AAGD,IAAa,qBAAqB,OAAO,YAAoD;CAC3F,MAAM,SAAS,MAAM,SAAS,gBAAgB,OAAO;CACrD,OAAO,OAAO,KAAK,OAAO,QAAQ;AACpC;;;;;AAmBA,IAAa,8BAAsD;CACjE,MAAM,wBAAQ,IAAI,IAAqC;CACvD,OAAO,EACL,YAAY;EACV,MAAM,4BAAY,IAAI,IAAgC;EACtD,MAAM,IAAI,SAAS;EACnB,OAAO;GACL,UAAU,WAAW;IACnB,KAAK,MAAM,SAAS,OAAO;KACzB,IAAI,UAAU,WAAW;KACzB,QAAa,QAAQ,CAAC,CAAC,WAAW;MAChC,KAAK,MAAM,YAAY,CAAC,GAAG,KAAK,GAAG,SAAS,gBAAgB,MAAM,CAAC;KACrE,CAAC;IACH;GACF;GACA,YAAY,aAAa;IACvB,MAAM,SAAS,YAA2B,SAAS,OAAO;IAC1D,UAAU,IAAI,KAAK;IACnB,aAAa;KACX,UAAU,OAAO,KAAK;IACxB;GACF;GACA,aAAa;IACX,UAAU,MAAM;IAChB,MAAM,OAAO,SAAS;GACxB;EACF;CACF,EACF;AACF"}
@@ -0,0 +1,4 @@
1
+ import { type SaveEnvelope } from '@joystyk/contracts';
2
+ import * as z from 'zod/mini';
3
+ /** Decodes a `SaveEnvelope`. `state` is only checked to be JSON. */
4
+ export declare const saveEnvelope: z.ZodMiniType<SaveEnvelope>;
@@ -0,0 +1,109 @@
1
+ import { json } from "./validate.js";
2
+ import { ASSIST_KINDS } from "@joystyk/contracts";
3
+ import * as z from "zod/mini";
4
+ //#region src/envelope.ts
5
+ var count = z.int().check(z.nonnegative());
6
+ var version = z.int().check(z.positive());
7
+ var milliseconds = z.number().check(z.nonnegative());
8
+ var rngState = z.object({
9
+ algorithm: z.string(),
10
+ words: z.array(z.number())
11
+ });
12
+ var controller = z.enum(["human", "ai"]);
13
+ var seatInfo = z.object({
14
+ id: count,
15
+ name: z.string(),
16
+ controller,
17
+ aiLevel: z.optional(z.string())
18
+ });
19
+ var seatChange = z.discriminatedUnion("type", [z.object({
20
+ type: z.literal("controller"),
21
+ controller,
22
+ name: z.string(),
23
+ aiLevel: z.optional(z.string())
24
+ }), z.object({ type: z.literal("left") })]);
25
+ var jobOutcome = z.union([z.object({
26
+ ok: z.literal(true),
27
+ value: json,
28
+ presentation: z.optional(json)
29
+ }), z.object({
30
+ ok: z.literal(false),
31
+ reason: z.enum(["timeout", "failed"])
32
+ })]);
33
+ var engineInput = z.discriminatedUnion("kind", [
34
+ z.object({
35
+ kind: z.literal("command"),
36
+ seat: count,
37
+ command: json
38
+ }),
39
+ z.object({
40
+ kind: z.literal("seat"),
41
+ seat: count,
42
+ change: seatChange
43
+ }),
44
+ z.object({
45
+ kind: z.literal("timeout"),
46
+ seat: count
47
+ }),
48
+ z.object({
49
+ kind: z.literal("resign"),
50
+ seat: count
51
+ }),
52
+ z.object({
53
+ kind: z.literal("job"),
54
+ jobId: z.string(),
55
+ outcome: jobOutcome
56
+ })
57
+ ]);
58
+ var replay = z.discriminatedUnion("layer", [z.object({
59
+ layer: z.literal(2),
60
+ gameId: z.string(),
61
+ rulesVersion: version,
62
+ config: json,
63
+ seats: z.array(seatInfo),
64
+ seed: rngState,
65
+ inputs: z.array(engineInput)
66
+ }), z.object({
67
+ layer: z.literal(3),
68
+ gameId: z.string(),
69
+ rulesVersion: version,
70
+ config: json,
71
+ seed: rngState,
72
+ inputs: z.array(z.object({
73
+ tick: count,
74
+ input: json
75
+ })),
76
+ endTick: count
77
+ })]);
78
+ /** Decodes a `SaveEnvelope`. `state` is only checked to be JSON. */
79
+ var saveEnvelope = z.object({
80
+ gameId: z.string(),
81
+ build: z.string(),
82
+ rulesVersion: version,
83
+ saveVersion: version,
84
+ daily: z.optional(z.object({
85
+ puzzleId: z.string().check(z.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*:\d{4}-\d{2}-\d{2}$/)),
86
+ contentHash: z.string().check(z.minLength(1))
87
+ })),
88
+ rng: rngState,
89
+ seats: z.array(seatInfo),
90
+ state: json,
91
+ tick: z.optional(count),
92
+ elapsedMs: z.optional(milliseconds),
93
+ clockStarted: z.optional(z.boolean()),
94
+ replay: z.optional(replay),
95
+ aiRng: z.optional(rngState),
96
+ undosUsed: z.optional(count),
97
+ assists: z.optional(z.array(z.enum(ASSIST_KINDS))),
98
+ assistCounts: z.optional(z.strictObject({
99
+ undo: z.optional(count),
100
+ hint: z.optional(count),
101
+ check: z.optional(count),
102
+ reveal: z.optional(count),
103
+ shuffle: z.optional(count)
104
+ }))
105
+ });
106
+ //#endregion
107
+ export { saveEnvelope };
108
+
109
+ //# sourceMappingURL=envelope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envelope.js","names":[],"sources":["../src/envelope.ts"],"sourcesContent":["// Standard Schemas for the contracts' save envelope (game-contract.md, \"Saves\"). The game's\n// own state inside it stays `Json` here; the game's migrations and `schemas.state` decode it.\nimport {\n ASSIST_KINDS,\n type AssistKind,\n type EngineInput,\n type Json,\n type Replay,\n type SaveEnvelope,\n} from '@joystyk/contracts';\nimport * as z from 'zod/mini';\nimport { json } from './validate.ts';\n\nconst count = z.int().check(z.nonnegative());\nconst version = z.int().check(z.positive());\nconst milliseconds = z.number().check(z.nonnegative());\n\nconst rngState = z.object({ algorithm: z.string(), words: z.array(z.number()) });\n\nconst controller = z.enum(['human', 'ai']);\n\nconst seatInfo = z.object({\n id: count,\n name: z.string(),\n controller,\n aiLevel: z.optional(z.string()),\n});\n\nconst seatChange = z.discriminatedUnion('type', [\n z.object({ type: z.literal('controller'), controller, name: z.string(), aiLevel: z.optional(z.string()) }),\n z.object({ type: z.literal('left') }),\n]);\n\nconst jobOutcome = z.union([\n z.object({ ok: z.literal(true), value: json, presentation: z.optional(json) }),\n z.object({ ok: z.literal(false), reason: z.enum(['timeout', 'failed']) }),\n]);\n\nconst engineInput: z.ZodMiniType<EngineInput<Json>> = z.discriminatedUnion('kind', [\n z.object({ kind: z.literal('command'), seat: count, command: json }),\n z.object({ kind: z.literal('seat'), seat: count, change: seatChange }),\n z.object({ kind: z.literal('timeout'), seat: count }),\n z.object({ kind: z.literal('resign'), seat: count }),\n z.object({ kind: z.literal('job'), jobId: z.string(), outcome: jobOutcome }),\n]);\n\nconst replay: z.ZodMiniType<Replay> = z.discriminatedUnion('layer', [\n z.object({\n layer: z.literal(2),\n gameId: z.string(),\n rulesVersion: version,\n config: json,\n seats: z.array(seatInfo),\n seed: rngState,\n inputs: z.array(engineInput),\n }),\n z.object({\n layer: z.literal(3),\n gameId: z.string(),\n rulesVersion: version,\n config: json,\n seed: rngState,\n inputs: z.array(z.object({ tick: count, input: json })),\n endTick: count,\n }),\n]);\n\n/** Decodes a `SaveEnvelope`. `state` is only checked to be JSON. */\nexport const saveEnvelope: z.ZodMiniType<SaveEnvelope> = z.object({\n gameId: z.string(),\n build: z.string(),\n rulesVersion: version,\n saveVersion: version,\n daily: z.optional(\n z.object({\n puzzleId: z.string().check(z.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*:\\d{4}-\\d{2}-\\d{2}$/)),\n contentHash: z.string().check(z.minLength(1)),\n }),\n ),\n rng: rngState,\n seats: z.array(seatInfo),\n state: json,\n tick: z.optional(count),\n elapsedMs: z.optional(milliseconds),\n clockStarted: z.optional(z.boolean()),\n replay: z.optional(replay),\n // The runner's records. The schema keeps them: an object schema drops keys it doesn't name.\n aiRng: z.optional(rngState),\n undosUsed: z.optional(count),\n assists: z.optional(z.array(z.enum(ASSIST_KINDS as readonly [AssistKind, ...AssistKind[]]))),\n assistCounts: z.optional(\n z.strictObject({\n undo: z.optional(count),\n hint: z.optional(count),\n check: z.optional(count),\n reveal: z.optional(count),\n shuffle: z.optional(count),\n }),\n ),\n});\n"],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import type { GameStorage, Json } from '@joystyk/contracts';
2
+ import type { StorageError } from './backend.js';
3
+ import { type RecordContext } from './record.js';
4
+ /** Version 1 of a game's own key-value record: any JSON. */
5
+ export declare const GAME_DATA_RECORD: import("./record.js").RecordKind<Json>;
6
+ /** Thrown (as a rejection) by a `GameStorage` write that failed; `error` says why. */
7
+ export declare class StorageFailure extends Error {
8
+ readonly error: StorageError;
9
+ constructor(error: StorageError);
10
+ }
11
+ /**
12
+ * The contracts' `GameStorage` (`PlatformApi.storage`) for one player and game. The game's
13
+ * keys are free-form, 1–256 characters. A read that fails resolves to null (the player has
14
+ * already been told); a failed write rejects with a `StorageFailure`, because the contract's
15
+ * `Promise<void>` has no other way to report it.
16
+ */
17
+ export declare const createGameStorage: (context: RecordContext, playerId: string, gameId: string) => GameStorage;
@@ -0,0 +1,54 @@
1
+ import { gameDataKey } from "./keys.js";
2
+ import { json } from "./validate.js";
3
+ import { createRecordStore, defineRecordKind } from "./record.js";
4
+ //#region src/game-data.ts
5
+ /** Version 1 of a game's own key-value record: any JSON. */
6
+ var GAME_DATA_RECORD = defineRecordKind({
7
+ store: "game-data",
8
+ version: 1,
9
+ schema: json,
10
+ label: "piece of game data"
11
+ });
12
+ /** Thrown (as a rejection) by a `GameStorage` write that failed; `error` says why. */
13
+ var StorageFailure = class extends Error {
14
+ error;
15
+ constructor(error) {
16
+ super(error.message);
17
+ this.name = "StorageFailure";
18
+ this.error = error;
19
+ }
20
+ };
21
+ var orThrow = async (result) => {
22
+ const settled = await result;
23
+ if (!settled.ok) throw new StorageFailure(settled.error);
24
+ return settled.value;
25
+ };
26
+ /**
27
+ * The contracts' `GameStorage` (`PlatformApi.storage`) for one player and game. The game's
28
+ * keys are free-form, 1–256 characters. A read that fails resolves to null (the player has
29
+ * already been told); a failed write rejects with a `StorageFailure`, because the contract's
30
+ * `Promise<void>` has no other way to report it.
31
+ */
32
+ var createGameStorage = (context, playerId, gameId) => {
33
+ const records = createRecordStore(context, GAME_DATA_RECORD);
34
+ return {
35
+ get: async (key) => {
36
+ const full = gameDataKey(playerId, gameId, key);
37
+ if (!full.ok) return null;
38
+ const value = await records.get(full.value);
39
+ return value.ok ? value.value : null;
40
+ },
41
+ set: async (key, value) => {
42
+ const full = gameDataKey(playerId, gameId, key);
43
+ await orThrow(full.ok ? records.set(full.value, value) : Promise.resolve(full));
44
+ },
45
+ delete: async (key) => {
46
+ const full = gameDataKey(playerId, gameId, key);
47
+ await orThrow(full.ok ? records.delete(full.value) : Promise.resolve(full));
48
+ }
49
+ };
50
+ };
51
+ //#endregion
52
+ export { GAME_DATA_RECORD, StorageFailure, createGameStorage };
53
+
54
+ //# sourceMappingURL=game-data.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"game-data.js","names":[],"sources":["../src/game-data.ts"],"sourcesContent":["import type { GameStorage, Json, Result } from '@joystyk/contracts';\nimport type { StorageError } from './backend.ts';\nimport { gameDataKey } from './keys.ts';\nimport { createRecordStore, defineRecordKind, type RecordContext } from './record.ts';\nimport { json } from './validate.ts';\n\n/** Version 1 of a game's own key-value record: any JSON. */\nexport const GAME_DATA_RECORD = defineRecordKind<Json>({\n store: 'game-data',\n version: 1,\n schema: json,\n label: 'piece of game data',\n});\n\n/** Thrown (as a rejection) by a `GameStorage` write that failed; `error` says why. */\nexport class StorageFailure extends Error {\n readonly error: StorageError;\n constructor(error: StorageError) {\n super(error.message);\n this.name = 'StorageFailure';\n this.error = error;\n }\n}\n\nconst orThrow = async <T>(result: Promise<Result<T, StorageError>>): Promise<T> => {\n const settled = await result;\n if (!settled.ok) throw new StorageFailure(settled.error);\n return settled.value;\n};\n\n/**\n * The contracts' `GameStorage` (`PlatformApi.storage`) for one player and game. The game's\n * keys are free-form, 1–256 characters. A read that fails resolves to null (the player has\n * already been told); a failed write rejects with a `StorageFailure`, because the contract's\n * `Promise<void>` has no other way to report it.\n */\nexport const createGameStorage = (context: RecordContext, playerId: string, gameId: string): GameStorage => {\n const records = createRecordStore(context, GAME_DATA_RECORD);\n return {\n get: async (key) => {\n const full = gameDataKey(playerId, gameId, key);\n if (!full.ok) return null;\n const value = await records.get(full.value);\n return value.ok ? value.value : null;\n },\n set: async (key, value) => {\n const full = gameDataKey(playerId, gameId, key);\n await orThrow(full.ok ? records.set(full.value, value) : Promise.resolve(full));\n },\n delete: async (key) => {\n const full = gameDataKey(playerId, gameId, key);\n await orThrow(full.ok ? records.delete(full.value) : Promise.resolve(full));\n },\n };\n};\n"],"mappings":";;;;;AAOA,IAAa,mBAAmB,iBAAuB;CACrD,OAAO;CACP,SAAS;CACT,QAAQ;CACR,OAAO;AACT,CAAC;;AAGD,IAAa,iBAAb,cAAoC,MAAM;CACxC;CACA,YAAY,OAAqB;EAC/B,MAAM,MAAM,OAAO;EACnB,KAAK,OAAO;EACZ,KAAK,QAAQ;CACf;AACF;AAEA,IAAM,UAAU,OAAU,WAAyD;CACjF,MAAM,UAAU,MAAM;CACtB,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,eAAe,QAAQ,KAAK;CACvD,OAAO,QAAQ;AACjB;;;;;;;AAQA,IAAa,qBAAqB,SAAwB,UAAkB,WAAgC;CAC1G,MAAM,UAAU,kBAAkB,SAAS,gBAAgB;CAC3D,OAAO;EACL,KAAK,OAAO,QAAQ;GAClB,MAAM,OAAO,YAAY,UAAU,QAAQ,GAAG;GAC9C,IAAI,CAAC,KAAK,IAAI,OAAO;GACrB,MAAM,QAAQ,MAAM,QAAQ,IAAI,KAAK,KAAK;GAC1C,OAAO,MAAM,KAAK,MAAM,QAAQ;EAClC;EACA,KAAK,OAAO,KAAK,UAAU;GACzB,MAAM,OAAO,YAAY,UAAU,QAAQ,GAAG;GAC9C,MAAM,QAAQ,KAAK,KAAK,QAAQ,IAAI,KAAK,OAAO,KAAK,IAAI,QAAQ,QAAQ,IAAI,CAAC;EAChF;EACA,QAAQ,OAAO,QAAQ;GACrB,MAAM,OAAO,YAAY,UAAU,QAAQ,GAAG;GAC9C,MAAM,QAAQ,KAAK,KAAK,QAAQ,OAAO,KAAK,KAAK,IAAI,QAAQ,QAAQ,IAAI,CAAC;EAC5E;CACF;AACF"}
@@ -0,0 +1,13 @@
1
+ export { MESSAGES, STORE_NAMES, storageError, type PutIfAbsent, type StorageBackend, type StorageError, type StorageErrorCode, type StoreName, } from './backend.js';
2
+ export { createMemoryChangeHub, parseStorageChange, type ChangeFeed, type StorageChange } from './changes.js';
3
+ export { createMemoryBackend, type MemoryBackend, type MemoryBackendOptions } from './memory.js';
4
+ export { createRecordStore, decodeRecord, defineRecordKind, putWithQuotaRetry, type ArchivedRecord, type RecordContext, type RecordKind, type RecordStore, type StorageNotice, type StorageNoticeCode, } from './record.js';
5
+ export { isKeyPart, MAX_GAME_KEY_LENGTH } from './keys.js';
6
+ export { json, validate, type Issue } from './validate.js';
7
+ export { saveEnvelope } from './envelope.js';
8
+ export { SAVE_RECORD, type SavedGame, type SaveDecoder, type SaveStore } from './saves.js';
9
+ export { EMPTY_STATS, recordOutcome, STATS_RECORD, type GameStats, type StatsStore } from './stats.js';
10
+ export { EMPTY_STREAK, STREAK_RECORD, type Streak, type StreakStore } from './streaks.js';
11
+ export { DEFAULT_PREFERENCES, PREFERENCES_RECORD, resolvePreferences, type PreferencesSource, type PreferencesStore, type ResolvedPreferences, type StoredPreferences, type SystemEnvironment, } from './preferences.js';
12
+ export { GAME_DATA_RECORD, StorageFailure } from './game-data.js';
13
+ export { openStorage, type PlayerStorage, type Storage, type StorageOptions } from './storage.js';
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ import { MESSAGES, STORE_NAMES, storageError } from "./backend.js";
2
+ import { createMemoryBackend } from "./memory.js";
3
+ import { MAX_GAME_KEY_LENGTH, isKeyPart } from "./keys.js";
4
+ import { json, validate } from "./validate.js";
5
+ import { createRecordStore, decodeRecord, defineRecordKind, putWithQuotaRetry } from "./record.js";
6
+ import { GAME_DATA_RECORD, StorageFailure } from "./game-data.js";
7
+ import { createMemoryChangeHub, parseStorageChange } from "./changes.js";
8
+ import { DEFAULT_PREFERENCES, PREFERENCES_RECORD, resolvePreferences } from "./preferences.js";
9
+ import { saveEnvelope } from "./envelope.js";
10
+ import { SAVE_RECORD } from "./saves.js";
11
+ import { EMPTY_STATS, STATS_RECORD, recordOutcome } from "./stats.js";
12
+ import { EMPTY_STREAK, STREAK_RECORD } from "./streaks.js";
13
+ import { openStorage } from "./storage.js";
14
+ export { DEFAULT_PREFERENCES, EMPTY_STATS, EMPTY_STREAK, GAME_DATA_RECORD, MAX_GAME_KEY_LENGTH, MESSAGES, PREFERENCES_RECORD, SAVE_RECORD, STATS_RECORD, STORE_NAMES, STREAK_RECORD, StorageFailure, createMemoryBackend, createMemoryChangeHub, createRecordStore, decodeRecord, defineRecordKind, isKeyPart, json, openStorage, parseStorageChange, putWithQuotaRetry, recordOutcome, resolvePreferences, saveEnvelope, storageError, validate };
package/dist/keys.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type Result } from '@joystyk/contracts';
2
+ import { type StorageError } from './backend.js';
3
+ /** True for a player id or slot name that can be part of a key: 1–64 letters, digits, `_` or `-`. */
4
+ export declare const isKeyPart: (value: unknown) => value is string;
5
+ /** The longest free-form key a game may use through `PlatformApi.storage`. */
6
+ export declare const MAX_GAME_KEY_LENGTH = 256;
7
+ /** `<playerId>/<gameId>/`: the prefix of everything one player keeps for one game. */
8
+ export declare const gamePrefix: (playerId: string, gameId: string) => Result<string, StorageError>;
9
+ /** `<playerId>/<gameId>/<slot>`, for saves. */
10
+ export declare const slotKey: (playerId: string, gameId: string, slot: string) => Result<string, StorageError>;
11
+ /** `<playerId>/<gameId>/<key>`, for a game's own key-value data. The key is free-form. */
12
+ export declare const gameDataKey: (playerId: string, gameId: string, key: string) => Result<string, StorageError>;
13
+ /** `<playerId>/<gameId>`, for per-game records with one value each (stats, streaks). */
14
+ export declare const gameKey: (playerId: string, gameId: string) => Result<string, StorageError>;
package/dist/keys.js ADDED
@@ -0,0 +1,29 @@
1
+ import { MESSAGES, storageError } from "./backend.js";
2
+ import { err, isGameId, ok } from "@joystyk/contracts";
3
+ //#region src/keys.ts
4
+ var PART = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
5
+ /** True for a player id or slot name that can be part of a key: 1–64 letters, digits, `_` or `-`. */
6
+ var isKeyPart = (value) => typeof value === "string" && PART.test(value);
7
+ /** The longest free-form key a game may use through `PlatformApi.storage`. */
8
+ var MAX_GAME_KEY_LENGTH = 256;
9
+ var invalid = (what, value) => err(storageError("InvalidKey", MESSAGES.InvalidKey, { [what]: value }));
10
+ /** `<playerId>/<gameId>/`: the prefix of everything one player keeps for one game. */
11
+ var gamePrefix = (playerId, gameId) => isGameId(gameId) ? ok(`${playerId}/${gameId}/`) : invalid("gameId", gameId);
12
+ /** `<playerId>/<gameId>/<slot>`, for saves. */
13
+ var slotKey = (playerId, gameId, slot) => {
14
+ const prefix = gamePrefix(playerId, gameId);
15
+ if (!prefix.ok) return prefix;
16
+ return isKeyPart(slot) ? ok(prefix.value + slot) : invalid("slot", slot);
17
+ };
18
+ /** `<playerId>/<gameId>/<key>`, for a game's own key-value data. The key is free-form. */
19
+ var gameDataKey = (playerId, gameId, key) => {
20
+ const prefix = gamePrefix(playerId, gameId);
21
+ if (!prefix.ok) return prefix;
22
+ return key.length > 0 && key.length <= 256 ? ok(prefix.value + key) : invalid("key", key);
23
+ };
24
+ /** `<playerId>/<gameId>`, for per-game records with one value each (stats, streaks). */
25
+ var gameKey = (playerId, gameId) => isGameId(gameId) ? ok(`${playerId}/${gameId}`) : invalid("gameId", gameId);
26
+ //#endregion
27
+ export { MAX_GAME_KEY_LENGTH, gameDataKey, gameKey, gamePrefix, isKeyPart, slotKey };
28
+
29
+ //# sourceMappingURL=keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.js","names":[],"sources":["../src/keys.ts"],"sourcesContent":["import { err, isGameId, ok, type Result } from '@joystyk/contracts';\nimport { MESSAGES, storageError, type StorageError } from './backend.ts';\n\n// Keys are paths: `<playerId>/<gameId>/<slot>`. Every part but a game's own free-form key is\n// restricted, so a part can never contain the separator and a prefix lists exactly one\n// player's or one game's records.\n\nconst PART = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;\n\n/** True for a player id or slot name that can be part of a key: 1–64 letters, digits, `_` or `-`. */\nexport const isKeyPart = (value: unknown): value is string => typeof value === 'string' && PART.test(value);\n\n/** The longest free-form key a game may use through `PlatformApi.storage`. */\nexport const MAX_GAME_KEY_LENGTH = 256;\n\nconst invalid = (what: string, value: string): Result<never, StorageError> =>\n err(storageError('InvalidKey', MESSAGES.InvalidKey, { [what]: value }));\n\n/** `<playerId>/<gameId>/`: the prefix of everything one player keeps for one game. */\nexport const gamePrefix = (playerId: string, gameId: string): Result<string, StorageError> =>\n isGameId(gameId) ? ok(`${playerId}/${gameId}/`) : invalid('gameId', gameId);\n\n/** `<playerId>/<gameId>/<slot>`, for saves. */\nexport const slotKey = (playerId: string, gameId: string, slot: string): Result<string, StorageError> => {\n const prefix = gamePrefix(playerId, gameId);\n if (!prefix.ok) return prefix;\n return isKeyPart(slot) ? ok(prefix.value + slot) : invalid('slot', slot);\n};\n\n/** `<playerId>/<gameId>/<key>`, for a game's own key-value data. The key is free-form. */\nexport const gameDataKey = (playerId: string, gameId: string, key: string): Result<string, StorageError> => {\n const prefix = gamePrefix(playerId, gameId);\n if (!prefix.ok) return prefix;\n return key.length > 0 && key.length <= MAX_GAME_KEY_LENGTH ? ok(prefix.value + key) : invalid('key', key);\n};\n\n/** `<playerId>/<gameId>`, for per-game records with one value each (stats, streaks). */\nexport const gameKey = (playerId: string, gameId: string): Result<string, StorageError> =>\n isGameId(gameId) ? ok(`${playerId}/${gameId}`) : invalid('gameId', gameId);\n"],"mappings":";;;AAOA,IAAM,OAAO;;AAGb,IAAa,aAAa,UAAoC,OAAO,UAAU,YAAY,KAAK,KAAK,KAAK;;AAG1G,IAAa,sBAAsB;AAEnC,IAAM,WAAW,MAAc,UAC7B,IAAI,aAAa,cAAc,SAAS,YAAY,GAAG,OAAO,MAAM,CAAC,CAAC;;AAGxE,IAAa,cAAc,UAAkB,WAC3C,SAAS,MAAM,IAAI,GAAG,GAAG,SAAS,GAAG,OAAO,EAAE,IAAI,QAAQ,UAAU,MAAM;;AAG5E,IAAa,WAAW,UAAkB,QAAgB,SAA+C;CACvG,MAAM,SAAS,WAAW,UAAU,MAAM;CAC1C,IAAI,CAAC,OAAO,IAAI,OAAO;CACvB,OAAO,UAAU,IAAI,IAAI,GAAG,OAAO,QAAQ,IAAI,IAAI,QAAQ,QAAQ,IAAI;AACzE;;AAGA,IAAa,eAAe,UAAkB,QAAgB,QAA8C;CAC1G,MAAM,SAAS,WAAW,UAAU,MAAM;CAC1C,IAAI,CAAC,OAAO,IAAI,OAAO;CACvB,OAAO,IAAI,SAAS,KAAK,IAAI,UAAA,MAAgC,GAAG,OAAO,QAAQ,GAAG,IAAI,QAAQ,OAAO,GAAG;AAC1G;;AAGA,IAAa,WAAW,UAAkB,WACxC,SAAS,MAAM,IAAI,GAAG,GAAG,SAAS,GAAG,QAAQ,IAAI,QAAQ,UAAU,MAAM"}