@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/NOTICE +36 -0
- package/README.md +168 -2
- package/dist/backend.d.ts +64 -0
- package/dist/backend.js +35 -0
- package/dist/backend.js.map +1 -0
- package/dist/browser/changes.d.ts +17 -0
- package/dist/browser/changes.js +96 -0
- package/dist/browser/changes.js.map +1 -0
- package/dist/browser/index.d.ts +18 -0
- package/dist/browser/index.js +44 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/indexeddb.d.ts +40 -0
- package/dist/browser/indexeddb.js +145 -0
- package/dist/browser/indexeddb.js.map +1 -0
- package/dist/browser/persist.d.ts +24 -0
- package/dist/browser/persist.js +42 -0
- package/dist/browser/persist.js.map +1 -0
- package/dist/changes.d.ts +26 -0
- package/dist/changes.js +50 -0
- package/dist/changes.js.map +1 -0
- package/dist/envelope.d.ts +4 -0
- package/dist/envelope.js +109 -0
- package/dist/envelope.js.map +1 -0
- package/dist/game-data.d.ts +17 -0
- package/dist/game-data.js +54 -0
- package/dist/game-data.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/keys.d.ts +14 -0
- package/dist/keys.js +29 -0
- package/dist/keys.js.map +1 -0
- package/dist/memory.d.ts +23 -0
- package/dist/memory.js +65 -0
- package/dist/memory.js.map +1 -0
- package/dist/preferences.d.ts +61 -0
- package/dist/preferences.js +93 -0
- package/dist/preferences.js.map +1 -0
- package/dist/record.d.ts +117 -0
- package/dist/record.js +177 -0
- package/dist/record.js.map +1 -0
- package/dist/saves.d.ts +46 -0
- package/dist/saves.js +100 -0
- package/dist/saves.js.map +1 -0
- package/dist/stats.d.ts +34 -0
- package/dist/stats.js +78 -0
- package/dist/stats.js.map +1 -0
- package/dist/storage.d.ts +45 -0
- package/dist/storage.js +43 -0
- package/dist/storage.js.map +1 -0
- package/dist/streaks.d.ts +23 -0
- package/dist/streaks.js +41 -0
- package/dist/streaks.js.map +1 -0
- package/dist/validate.d.ts +14 -0
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -0
- package/package.json +51 -3
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type StorageBackend, type StoreName } from './backend.js';
|
|
2
|
+
export type MemoryBackendOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* Simulates a storage quota: a write fails with `QuotaExceeded` when the stored keys and
|
|
5
|
+
* JSON text would exceed this many UTF-16 code units. Unlimited when absent.
|
|
6
|
+
*/
|
|
7
|
+
readonly quota?: number;
|
|
8
|
+
};
|
|
9
|
+
/** A `StorageBackend` with read access to its raw contents, for tests. */
|
|
10
|
+
export interface MemoryBackend extends StorageBackend {
|
|
11
|
+
/** Writes a raw value with no checks, as a corrupted or older database would hold it. */
|
|
12
|
+
poke(store: StoreName, key: string, value: unknown): void;
|
|
13
|
+
/** The raw value, or `undefined`. */
|
|
14
|
+
peek(store: StoreName, key: string): unknown;
|
|
15
|
+
/** The space in use, in the units `quota` counts. */
|
|
16
|
+
used(): number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* An in-memory backend for unit tests and server rendering, where there is no IndexedDB.
|
|
20
|
+
* It copies values on the way in and out, as IndexedDB's structured clone does, so a caller
|
|
21
|
+
* that mutates a value it read can't change what is stored.
|
|
22
|
+
*/
|
|
23
|
+
export declare const createMemoryBackend: (options?: MemoryBackendOptions) => MemoryBackend;
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { MESSAGES, STORE_NAMES, storageError } from "./backend.js";
|
|
2
|
+
import { err, ok } from "@joystyk/contracts";
|
|
3
|
+
//#region src/memory.ts
|
|
4
|
+
var { structuredClone } = globalThis;
|
|
5
|
+
var copy = (value) => structuredClone(value);
|
|
6
|
+
var sizeOf = (key, value) => key.length + (JSON.stringify(value)?.length ?? 0);
|
|
7
|
+
/**
|
|
8
|
+
* An in-memory backend for unit tests and server rendering, where there is no IndexedDB.
|
|
9
|
+
* It copies values on the way in and out, as IndexedDB's structured clone does, so a caller
|
|
10
|
+
* that mutates a value it read can't change what is stored.
|
|
11
|
+
*/
|
|
12
|
+
var createMemoryBackend = (options = {}) => {
|
|
13
|
+
const stores = new Map(STORE_NAMES.map((name) => [name, /* @__PURE__ */ new Map()]));
|
|
14
|
+
const store = (name) => {
|
|
15
|
+
const found = stores.get(name);
|
|
16
|
+
if (found === void 0) throw new Error(`Unknown store "${name}"`);
|
|
17
|
+
return found;
|
|
18
|
+
};
|
|
19
|
+
const used = () => {
|
|
20
|
+
let total = 0;
|
|
21
|
+
for (const entries of stores.values()) for (const [key, value] of entries) total += sizeOf(key, value);
|
|
22
|
+
return total;
|
|
23
|
+
};
|
|
24
|
+
const put = (name, key, value) => {
|
|
25
|
+
const entries = store(name);
|
|
26
|
+
if (options.quota !== void 0) {
|
|
27
|
+
const previous = entries.has(key) ? sizeOf(key, entries.get(key)) : 0;
|
|
28
|
+
if (used() - previous + sizeOf(key, value) > options.quota) return err(storageError("QuotaExceeded", MESSAGES.QuotaExceeded));
|
|
29
|
+
}
|
|
30
|
+
entries.set(key, copy(value));
|
|
31
|
+
return ok(void 0);
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
get: async (name, key) => ok(copy(store(name).get(key))),
|
|
35
|
+
put: async (name, key, value) => put(name, key, value),
|
|
36
|
+
putIfAbsent: async (name, key, value) => {
|
|
37
|
+
const existing = store(name).get(key);
|
|
38
|
+
if (existing !== void 0) return ok({
|
|
39
|
+
written: false,
|
|
40
|
+
value: copy(existing)
|
|
41
|
+
});
|
|
42
|
+
const written = put(name, key, value);
|
|
43
|
+
return written.ok ? ok({ written: true }) : written;
|
|
44
|
+
},
|
|
45
|
+
delete: async (name, key) => {
|
|
46
|
+
store(name).delete(key);
|
|
47
|
+
return ok(void 0);
|
|
48
|
+
},
|
|
49
|
+
keys: async (name, prefix) => ok([...store(name).keys()].filter((key) => key.startsWith(prefix)).sort((a, b) => a < b ? -1 : a > b ? 1 : 0)),
|
|
50
|
+
clear: async (name) => {
|
|
51
|
+
store(name).clear();
|
|
52
|
+
return ok(void 0);
|
|
53
|
+
},
|
|
54
|
+
close: () => {},
|
|
55
|
+
poke: (name, key, value) => {
|
|
56
|
+
store(name).set(key, value);
|
|
57
|
+
},
|
|
58
|
+
peek: (name, key) => store(name).get(key),
|
|
59
|
+
used
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { createMemoryBackend };
|
|
64
|
+
|
|
65
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.js","names":[],"sources":["../src/memory.ts"],"sourcesContent":["import { err, ok, type Json, type Result } from '@joystyk/contracts';\nimport {\n MESSAGES,\n STORE_NAMES,\n storageError,\n type StorageBackend,\n type StorageError,\n type StoreName,\n} from './backend.ts';\n\nexport type MemoryBackendOptions = {\n /**\n * Simulates a storage quota: a write fails with `QuotaExceeded` when the stored keys and\n * JSON text would exceed this many UTF-16 code units. Unlimited when absent.\n */\n readonly quota?: number;\n};\n\n/** A `StorageBackend` with read access to its raw contents, for tests. */\nexport interface MemoryBackend extends StorageBackend {\n /** Writes a raw value with no checks, as a corrupted or older database would hold it. */\n poke(store: StoreName, key: string, value: unknown): void;\n /** The raw value, or `undefined`. */\n peek(store: StoreName, key: string): unknown;\n /** The space in use, in the units `quota` counts. */\n used(): number;\n}\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 };\nconst copy = <T>(value: T): T => structuredClone(value);\n\nconst sizeOf = (key: string, value: unknown): number => key.length + (JSON.stringify(value)?.length ?? 0);\n\n/**\n * An in-memory backend for unit tests and server rendering, where there is no IndexedDB.\n * It copies values on the way in and out, as IndexedDB's structured clone does, so a caller\n * that mutates a value it read can't change what is stored.\n */\nexport const createMemoryBackend = (options: MemoryBackendOptions = {}): MemoryBackend => {\n const stores = new Map<StoreName, Map<string, unknown>>(STORE_NAMES.map((name) => [name, new Map()]));\n const store = (name: StoreName): Map<string, unknown> => {\n const found = stores.get(name);\n if (found === undefined) throw new Error(`Unknown store \"${name}\"`);\n return found;\n };\n const used = (): number => {\n let total = 0;\n for (const entries of stores.values()) for (const [key, value] of entries) total += sizeOf(key, value);\n return total;\n };\n\n const put = (name: StoreName, key: string, value: Json): Result<void, StorageError> => {\n const entries = store(name);\n if (options.quota !== undefined) {\n const previous = entries.has(key) ? sizeOf(key, entries.get(key)) : 0;\n if (used() - previous + sizeOf(key, value) > options.quota) {\n return err(storageError('QuotaExceeded', MESSAGES.QuotaExceeded));\n }\n }\n entries.set(key, copy(value));\n return ok(undefined);\n };\n\n return {\n get: async (name, key) => ok(copy(store(name).get(key))),\n put: async (name, key, value: Json) => put(name, key, value),\n // Check and write in one synchronous step, so no other call can come between them.\n putIfAbsent: async (name, key, value: Json) => {\n const existing = store(name).get(key);\n if (existing !== undefined) return ok({ written: false, value: copy(existing) });\n const written = put(name, key, value);\n return written.ok ? ok({ written: true }) : written;\n },\n delete: async (name, key) => {\n store(name).delete(key);\n return ok(undefined);\n },\n keys: async (name, prefix) =>\n ok([...store(name).keys()].filter((key) => key.startsWith(prefix)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))),\n clear: async (name) => {\n store(name).clear();\n return ok(undefined);\n },\n close: () => {},\n poke: (name, key, value) => {\n store(name).set(key, value);\n },\n peek: (name, key) => store(name).get(key),\n used,\n };\n};\n"],"mappings":";;;AA6BA,IAAM,EAAE,oBAAoB;AAC5B,IAAM,QAAW,UAAgB,gBAAgB,KAAK;AAEtD,IAAM,UAAU,KAAa,UAA2B,IAAI,UAAU,KAAK,UAAU,KAAK,CAAC,EAAE,UAAU;;;;;;AAOvG,IAAa,uBAAuB,UAAgC,CAAC,MAAqB;CACxF,MAAM,SAAS,IAAI,IAAqC,YAAY,KAAK,SAAS,CAAC,sBAAM,IAAI,IAAI,CAAC,CAAC,CAAC;CACpG,MAAM,SAAS,SAA0C;EACvD,MAAM,QAAQ,OAAO,IAAI,IAAI;EAC7B,IAAI,UAAU,KAAA,GAAW,MAAM,IAAI,MAAM,kBAAkB,KAAK,EAAE;EAClE,OAAO;CACT;CACA,MAAM,aAAqB;EACzB,IAAI,QAAQ;EACZ,KAAK,MAAM,WAAW,OAAO,OAAO,GAAG,KAAK,MAAM,CAAC,KAAK,UAAU,SAAS,SAAS,OAAO,KAAK,KAAK;EACrG,OAAO;CACT;CAEA,MAAM,OAAO,MAAiB,KAAa,UAA4C;EACrF,MAAM,UAAU,MAAM,IAAI;EAC1B,IAAI,QAAQ,UAAU,KAAA,GAAW;GAC/B,MAAM,WAAW,QAAQ,IAAI,GAAG,IAAI,OAAO,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI;GACpE,IAAI,KAAK,IAAI,WAAW,OAAO,KAAK,KAAK,IAAI,QAAQ,OACnD,OAAO,IAAI,aAAa,iBAAiB,SAAS,aAAa,CAAC;EAEpE;EACA,QAAQ,IAAI,KAAK,KAAK,KAAK,CAAC;EAC5B,OAAO,GAAG,KAAA,CAAS;CACrB;CAEA,OAAO;EACL,KAAK,OAAO,MAAM,QAAQ,GAAG,KAAK,MAAM,IAAI,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC;EACvD,KAAK,OAAO,MAAM,KAAK,UAAgB,IAAI,MAAM,KAAK,KAAK;EAE3D,aAAa,OAAO,MAAM,KAAK,UAAgB;GAC7C,MAAM,WAAW,MAAM,IAAI,CAAC,CAAC,IAAI,GAAG;GACpC,IAAI,aAAa,KAAA,GAAW,OAAO,GAAG;IAAE,SAAS;IAAO,OAAO,KAAK,QAAQ;GAAE,CAAC;GAC/E,MAAM,UAAU,IAAI,MAAM,KAAK,KAAK;GACpC,OAAO,QAAQ,KAAK,GAAG,EAAE,SAAS,KAAK,CAAC,IAAI;EAC9C;EACA,QAAQ,OAAO,MAAM,QAAQ;GAC3B,MAAM,IAAI,CAAC,CAAC,OAAO,GAAG;GACtB,OAAO,GAAG,KAAA,CAAS;EACrB;EACA,MAAM,OAAO,MAAM,WACjB,GAAG,CAAC,GAAG,MAAM,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,QAAQ,IAAI,WAAW,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,MAAO,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAE,CAAC;EACjH,OAAO,OAAO,SAAS;GACrB,MAAM,IAAI,CAAC,CAAC,MAAM;GAClB,OAAO,GAAG,KAAA,CAAS;EACrB;EACA,aAAa,CAAC;EACd,OAAO,MAAM,KAAK,UAAU;GAC1B,MAAM,IAAI,CAAC,CAAC,IAAI,KAAK,KAAK;EAC5B;EACA,OAAO,MAAM,QAAQ,MAAM,IAAI,CAAC,CAAC,IAAI,GAAG;EACxC;CACF;AACF"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type Preferences, type Result } from '@joystyk/contracts';
|
|
2
|
+
import type { StorageError } from './backend.js';
|
|
3
|
+
import { type ChangeFeed } from './changes.js';
|
|
4
|
+
import { type RecordContext } from './record.js';
|
|
5
|
+
/**
|
|
6
|
+
* What a player chose. `system` follows the operating system's setting; the frame resolves
|
|
7
|
+
* the choices into the contracts' `Preferences` with `resolvePreferences`.
|
|
8
|
+
*/
|
|
9
|
+
export type StoredPreferences = {
|
|
10
|
+
readonly reducedMotion: 'system' | 'reduce' | 'no-preference';
|
|
11
|
+
readonly sound: boolean;
|
|
12
|
+
/** 0 … 1. */
|
|
13
|
+
readonly volume: number;
|
|
14
|
+
/**
|
|
15
|
+
* Whether shortcuts made of a single printable key are on. WCAG 2.1.4 requires a way to
|
|
16
|
+
* turn them off, for speech-input and switch users.
|
|
17
|
+
*/
|
|
18
|
+
readonly singleKeyShortcuts: boolean;
|
|
19
|
+
readonly theme: 'system' | 'light' | 'dark';
|
|
20
|
+
/** A BCP 47 tag, or null to follow the browser. */
|
|
21
|
+
readonly locale: string | null;
|
|
22
|
+
/**
|
|
23
|
+
* True once the player accepted the AI-content notice (D-44); games with AI-made content
|
|
24
|
+
* stay locked until then. Missing means not yet, so older records need no migration.
|
|
25
|
+
*/
|
|
26
|
+
readonly aiContentAccepted?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export declare const DEFAULT_PREFERENCES: StoredPreferences;
|
|
29
|
+
/** Version 1 of the preferences record. */
|
|
30
|
+
export declare const PREFERENCES_RECORD: import("./record.js").RecordKind<StoredPreferences>;
|
|
31
|
+
/** What the browser reports, read by the frame (`systemEnvironment` in the browser entry). */
|
|
32
|
+
export type SystemEnvironment = {
|
|
33
|
+
readonly prefersReducedMotion: boolean;
|
|
34
|
+
readonly prefersDark: boolean;
|
|
35
|
+
readonly locale: string;
|
|
36
|
+
};
|
|
37
|
+
/** The contracts' `Preferences` plus the shortcut setting, which games with keyboard controls honor too. */
|
|
38
|
+
export type ResolvedPreferences = Preferences & {
|
|
39
|
+
readonly singleKeyShortcuts: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** Resolves a player's choices against the system's settings. Pure. */
|
|
42
|
+
export declare const resolvePreferences: (stored: StoredPreferences, system: SystemEnvironment) => ResolvedPreferences;
|
|
43
|
+
/** Where a change came from: an `update` in this tab, or one in another tab of the site. */
|
|
44
|
+
export type PreferencesSource = 'this-tab' | 'other-tab';
|
|
45
|
+
/** One player's preferences. */
|
|
46
|
+
export interface PreferencesStore {
|
|
47
|
+
/** The stored choices, or `DEFAULT_PREFERENCES`. */
|
|
48
|
+
get(): Promise<Result<StoredPreferences, StorageError>>;
|
|
49
|
+
/**
|
|
50
|
+
* Merges `changes` into the stored choices, writes them, notifies this tab's subscribers,
|
|
51
|
+
* tells the other tabs, and returns them.
|
|
52
|
+
*/
|
|
53
|
+
update(changes: Partial<StoredPreferences>): Promise<Result<StoredPreferences, StorageError>>;
|
|
54
|
+
/**
|
|
55
|
+
* Called after each successful `update` in this tab, and, when storage has a change feed,
|
|
56
|
+
* after each one in another tab, with the choices read back from storage (gp-d4v).
|
|
57
|
+
* Returns an idempotent unsubscribe.
|
|
58
|
+
*/
|
|
59
|
+
subscribe(listener: (preferences: StoredPreferences, source: PreferencesSource) => void): () => void;
|
|
60
|
+
}
|
|
61
|
+
export declare const createPreferencesStore: (context: RecordContext, playerId: string, changes?: ChangeFeed) => PreferencesStore;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createRecordStore, defineRecordKind } from "./record.js";
|
|
2
|
+
import { parseStorageChange } from "./changes.js";
|
|
3
|
+
import { ok } from "@joystyk/contracts";
|
|
4
|
+
import * as z from "zod/mini";
|
|
5
|
+
//#region src/preferences.ts
|
|
6
|
+
var DEFAULT_PREFERENCES = {
|
|
7
|
+
reducedMotion: "system",
|
|
8
|
+
sound: true,
|
|
9
|
+
volume: .8,
|
|
10
|
+
singleKeyShortcuts: true,
|
|
11
|
+
theme: "system",
|
|
12
|
+
locale: null
|
|
13
|
+
};
|
|
14
|
+
/** Version 1 of the preferences record. */
|
|
15
|
+
var PREFERENCES_RECORD = defineRecordKind({
|
|
16
|
+
store: "preferences",
|
|
17
|
+
version: 1,
|
|
18
|
+
schema: z.object({
|
|
19
|
+
reducedMotion: z.enum([
|
|
20
|
+
"system",
|
|
21
|
+
"reduce",
|
|
22
|
+
"no-preference"
|
|
23
|
+
]),
|
|
24
|
+
sound: z.boolean(),
|
|
25
|
+
volume: z.number().check(z.gte(0), z.lte(1)),
|
|
26
|
+
singleKeyShortcuts: z.boolean(),
|
|
27
|
+
theme: z.enum([
|
|
28
|
+
"system",
|
|
29
|
+
"light",
|
|
30
|
+
"dark"
|
|
31
|
+
]),
|
|
32
|
+
locale: z.nullable(z.string().check(z.regex(/^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/))),
|
|
33
|
+
aiContentAccepted: z.optional(z.boolean())
|
|
34
|
+
}),
|
|
35
|
+
label: "set of preferences"
|
|
36
|
+
});
|
|
37
|
+
/** Resolves a player's choices against the system's settings. Pure. */
|
|
38
|
+
var resolvePreferences = (stored, system) => ({
|
|
39
|
+
reducedMotion: stored.reducedMotion === "system" ? system.prefersReducedMotion : stored.reducedMotion === "reduce",
|
|
40
|
+
sound: stored.sound,
|
|
41
|
+
volume: stored.volume,
|
|
42
|
+
colorScheme: stored.theme === "system" ? system.prefersDark ? "dark" : "light" : stored.theme,
|
|
43
|
+
locale: stored.locale ?? system.locale,
|
|
44
|
+
singleKeyShortcuts: stored.singleKeyShortcuts
|
|
45
|
+
});
|
|
46
|
+
var createPreferencesStore = (context, playerId, changes) => {
|
|
47
|
+
const records = createRecordStore(context, PREFERENCES_RECORD);
|
|
48
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
49
|
+
const get = async () => {
|
|
50
|
+
const stored = await records.get(playerId);
|
|
51
|
+
return stored.ok ? ok(stored.value ?? DEFAULT_PREFERENCES) : stored;
|
|
52
|
+
};
|
|
53
|
+
const tell = (preferences, source) => {
|
|
54
|
+
for (const listener of [...listeners]) listener(preferences, source);
|
|
55
|
+
};
|
|
56
|
+
changes?.subscribe((message) => {
|
|
57
|
+
parseStorageChange(message).then(async (change) => {
|
|
58
|
+
if (change?.kind !== "preferences" || change.playerId !== playerId || listeners.size === 0) return;
|
|
59
|
+
const current = await get();
|
|
60
|
+
if (current.ok) tell(current.value, "other-tab");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
get,
|
|
65
|
+
update: async (partial) => {
|
|
66
|
+
const current = await get();
|
|
67
|
+
if (!current.ok) return current;
|
|
68
|
+
const next = {
|
|
69
|
+
...current.value,
|
|
70
|
+
...partial
|
|
71
|
+
};
|
|
72
|
+
const written = await records.set(playerId, next);
|
|
73
|
+
if (!written.ok) return written;
|
|
74
|
+
tell(next, "this-tab");
|
|
75
|
+
changes?.publish({
|
|
76
|
+
kind: "preferences",
|
|
77
|
+
playerId
|
|
78
|
+
});
|
|
79
|
+
return ok(next);
|
|
80
|
+
},
|
|
81
|
+
subscribe: (listener) => {
|
|
82
|
+
const entry = (preferences, source) => listener(preferences, source);
|
|
83
|
+
listeners.add(entry);
|
|
84
|
+
return () => {
|
|
85
|
+
listeners.delete(entry);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { DEFAULT_PREFERENCES, PREFERENCES_RECORD, createPreferencesStore, resolvePreferences };
|
|
92
|
+
|
|
93
|
+
//# sourceMappingURL=preferences.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preferences.js","names":[],"sources":["../src/preferences.ts"],"sourcesContent":["import { ok, type Preferences, type Result } from '@joystyk/contracts';\nimport * as z from 'zod/mini';\nimport type { StorageError } from './backend.ts';\nimport { parseStorageChange, type ChangeFeed } from './changes.ts';\nimport { createRecordStore, defineRecordKind, type RecordContext } from './record.ts';\n\n/**\n * What a player chose. `system` follows the operating system's setting; the frame resolves\n * the choices into the contracts' `Preferences` with `resolvePreferences`.\n */\nexport type StoredPreferences = {\n readonly reducedMotion: 'system' | 'reduce' | 'no-preference';\n readonly sound: boolean;\n /** 0 … 1. */\n readonly volume: number;\n /**\n * Whether shortcuts made of a single printable key are on. WCAG 2.1.4 requires a way to\n * turn them off, for speech-input and switch users.\n */\n readonly singleKeyShortcuts: boolean;\n readonly theme: 'system' | 'light' | 'dark';\n /** A BCP 47 tag, or null to follow the browser. */\n readonly locale: string | null;\n /**\n * True once the player accepted the AI-content notice (D-44); games with AI-made content\n * stay locked until then. Missing means not yet, so older records need no migration.\n */\n readonly aiContentAccepted?: boolean;\n};\n\nexport const DEFAULT_PREFERENCES: StoredPreferences = {\n reducedMotion: 'system',\n sound: true,\n volume: 0.8,\n singleKeyShortcuts: true,\n theme: 'system',\n locale: null,\n};\n\n/** Version 1 of the preferences record. */\nexport const PREFERENCES_RECORD = defineRecordKind<StoredPreferences>({\n store: 'preferences',\n version: 1,\n schema: z.object({\n reducedMotion: z.enum(['system', 'reduce', 'no-preference']),\n sound: z.boolean(),\n volume: z.number().check(z.gte(0), z.lte(1)),\n singleKeyShortcuts: z.boolean(),\n theme: z.enum(['system', 'light', 'dark']),\n locale: z.nullable(z.string().check(z.regex(/^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/))),\n aiContentAccepted: z.optional(z.boolean()),\n }),\n label: 'set of preferences',\n});\n\n/** What the browser reports, read by the frame (`systemEnvironment` in the browser entry). */\nexport type SystemEnvironment = {\n readonly prefersReducedMotion: boolean;\n readonly prefersDark: boolean;\n readonly locale: string;\n};\n\n/** The contracts' `Preferences` plus the shortcut setting, which games with keyboard controls honor too. */\nexport type ResolvedPreferences = Preferences & { readonly singleKeyShortcuts: boolean };\n\n/** Resolves a player's choices against the system's settings. Pure. */\nexport const resolvePreferences = (stored: StoredPreferences, system: SystemEnvironment): ResolvedPreferences => ({\n reducedMotion: stored.reducedMotion === 'system' ? system.prefersReducedMotion : stored.reducedMotion === 'reduce',\n sound: stored.sound,\n volume: stored.volume,\n colorScheme: stored.theme === 'system' ? (system.prefersDark ? 'dark' : 'light') : stored.theme,\n locale: stored.locale ?? system.locale,\n singleKeyShortcuts: stored.singleKeyShortcuts,\n});\n\n/** Where a change came from: an `update` in this tab, or one in another tab of the site. */\nexport type PreferencesSource = 'this-tab' | 'other-tab';\n\n/** One player's preferences. */\nexport interface PreferencesStore {\n /** The stored choices, or `DEFAULT_PREFERENCES`. */\n get(): Promise<Result<StoredPreferences, StorageError>>;\n /**\n * Merges `changes` into the stored choices, writes them, notifies this tab's subscribers,\n * tells the other tabs, and returns them.\n */\n update(changes: Partial<StoredPreferences>): Promise<Result<StoredPreferences, StorageError>>;\n /**\n * Called after each successful `update` in this tab, and, when storage has a change feed,\n * after each one in another tab, with the choices read back from storage (gp-d4v).\n * Returns an idempotent unsubscribe.\n */\n subscribe(listener: (preferences: StoredPreferences, source: PreferencesSource) => void): () => void;\n}\n\nexport const createPreferencesStore = (\n context: RecordContext,\n playerId: string,\n changes?: ChangeFeed,\n): PreferencesStore => {\n const records = createRecordStore(context, PREFERENCES_RECORD);\n const listeners = new Set<(preferences: StoredPreferences, source: PreferencesSource) => void>();\n const get = async (): Promise<Result<StoredPreferences, StorageError>> => {\n const stored = await records.get(playerId);\n return stored.ok ? ok(stored.value ?? DEFAULT_PREFERENCES) : stored;\n };\n const tell = (preferences: StoredPreferences, source: PreferencesSource): void => {\n for (const listener of [...listeners]) listener(preferences, source);\n };\n // Another tab changed this player's choices: read them back, through the schema.\n changes?.subscribe((message) => {\n void parseStorageChange(message).then(async (change) => {\n if (change?.kind !== 'preferences' || change.playerId !== playerId || listeners.size === 0) return;\n const current = await get();\n if (current.ok) tell(current.value, 'other-tab');\n });\n });\n return {\n get,\n update: async (partial) => {\n const current = await get();\n if (!current.ok) return current;\n const next = { ...current.value, ...partial };\n const written = await records.set(playerId, next);\n if (!written.ok) return written;\n tell(next, 'this-tab');\n changes?.publish({ kind: 'preferences', playerId });\n return ok(next);\n },\n subscribe: (listener) => {\n // A wrapper, so subscribing the same function twice gives two independent subscriptions.\n const entry = (preferences: StoredPreferences, source: PreferencesSource): void => listener(preferences, source);\n listeners.add(entry);\n return () => {\n listeners.delete(entry);\n };\n },\n };\n};\n"],"mappings":";;;;;AA8BA,IAAa,sBAAyC;CACpD,eAAe;CACf,OAAO;CACP,QAAQ;CACR,oBAAoB;CACpB,OAAO;CACP,QAAQ;AACV;;AAGA,IAAa,qBAAqB,iBAAoC;CACpE,OAAO;CACP,SAAS;CACT,QAAQ,EAAE,OAAO;EACf,eAAe,EAAE,KAAK;GAAC;GAAU;GAAU;EAAe,CAAC;EAC3D,OAAO,EAAE,QAAQ;EACjB,QAAQ,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;EAC3C,oBAAoB,EAAE,QAAQ;EAC9B,OAAO,EAAE,KAAK;GAAC;GAAU;GAAS;EAAM,CAAC;EACzC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,qCAAqC,CAAC,CAAC;EACnF,mBAAmB,EAAE,SAAS,EAAE,QAAQ,CAAC;CAC3C,CAAC;CACD,OAAO;AACT,CAAC;;AAaD,IAAa,sBAAsB,QAA2B,YAAoD;CAChH,eAAe,OAAO,kBAAkB,WAAW,OAAO,uBAAuB,OAAO,kBAAkB;CAC1G,OAAO,OAAO;CACd,QAAQ,OAAO;CACf,aAAa,OAAO,UAAU,WAAY,OAAO,cAAc,SAAS,UAAW,OAAO;CAC1F,QAAQ,OAAO,UAAU,OAAO;CAChC,oBAAoB,OAAO;AAC7B;AAsBA,IAAa,0BACX,SACA,UACA,YACqB;CACrB,MAAM,UAAU,kBAAkB,SAAS,kBAAkB;CAC7D,MAAM,4BAAY,IAAI,IAAyE;CAC/F,MAAM,MAAM,YAA8D;EACxE,MAAM,SAAS,MAAM,QAAQ,IAAI,QAAQ;EACzC,OAAO,OAAO,KAAK,GAAG,OAAO,SAAS,mBAAmB,IAAI;CAC/D;CACA,MAAM,QAAQ,aAAgC,WAAoC;EAChF,KAAK,MAAM,YAAY,CAAC,GAAG,SAAS,GAAG,SAAS,aAAa,MAAM;CACrE;CAEA,SAAS,WAAW,YAAY;EAC9B,mBAAwB,OAAO,CAAC,CAAC,KAAK,OAAO,WAAW;GACtD,IAAI,QAAQ,SAAS,iBAAiB,OAAO,aAAa,YAAY,UAAU,SAAS,GAAG;GAC5F,MAAM,UAAU,MAAM,IAAI;GAC1B,IAAI,QAAQ,IAAI,KAAK,QAAQ,OAAO,WAAW;EACjD,CAAC;CACH,CAAC;CACD,OAAO;EACL;EACA,QAAQ,OAAO,YAAY;GACzB,MAAM,UAAU,MAAM,IAAI;GAC1B,IAAI,CAAC,QAAQ,IAAI,OAAO;GACxB,MAAM,OAAO;IAAE,GAAG,QAAQ;IAAO,GAAG;GAAQ;GAC5C,MAAM,UAAU,MAAM,QAAQ,IAAI,UAAU,IAAI;GAChD,IAAI,CAAC,QAAQ,IAAI,OAAO;GACxB,KAAK,MAAM,UAAU;GACrB,SAAS,QAAQ;IAAE,MAAM;IAAe;GAAS,CAAC;GAClD,OAAO,GAAG,IAAI;EAChB;EACA,YAAY,aAAa;GAEvB,MAAM,SAAS,aAAgC,WAAoC,SAAS,aAAa,MAAM;GAC/G,UAAU,IAAI,KAAK;GACnB,aAAa;IACX,UAAU,OAAO,KAAK;GACxB;EACF;CACF;AACF"}
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type Json, type Migration, type Result, type Validator } from '@joystyk/contracts';
|
|
2
|
+
import { type StorageBackend, type StorageError, type StoreName } from './backend.js';
|
|
3
|
+
import { type Issue } from './validate.js';
|
|
4
|
+
/**
|
|
5
|
+
* One kind of record: where it lives, the current version of its shape, the schema that
|
|
6
|
+
* decodes it and the migrations that bring older versions up to date. Every record a
|
|
7
|
+
* `RecordStore` reads passes through all three before anyone sees it.
|
|
8
|
+
*/
|
|
9
|
+
export type RecordKind<T extends Json> = {
|
|
10
|
+
readonly store: StoreName;
|
|
11
|
+
/** The current shape's version, from 1. Bump it, and add a migration, when the shape changes. */
|
|
12
|
+
readonly version: number;
|
|
13
|
+
readonly schema: Validator<T>;
|
|
14
|
+
/** Pure steps from each older version to the next (the contracts' `Migration`). */
|
|
15
|
+
readonly migrations: readonly Migration[];
|
|
16
|
+
/**
|
|
17
|
+
* What happens to a record that fails to decode: `discard` deletes it; `archive` moves its
|
|
18
|
+
* raw value to the `archive` store first, where nothing reads it as trusted data, so a bug
|
|
19
|
+
* report or a later build can still recover it. Saves are archived (offline-and-updates.md,
|
|
20
|
+
* Rule 3).
|
|
21
|
+
*/
|
|
22
|
+
readonly onInvalid: 'discard' | 'archive';
|
|
23
|
+
/** How notices name this record to a player, e.g. "saved game". */
|
|
24
|
+
readonly label: string;
|
|
25
|
+
};
|
|
26
|
+
/** Fills in a record kind's defaults: no migrations, and invalid records discarded. */
|
|
27
|
+
export declare const defineRecordKind: <T extends Json>(kind: Omit<RecordKind<T>, "migrations" | "onInvalid"> & Partial<Pick<RecordKind<T>, "migrations" | "onInvalid">>) => RecordKind<T>;
|
|
28
|
+
/** Why the store is telling the player something. */
|
|
29
|
+
export type StorageNoticeCode =
|
|
30
|
+
/** A record failed to decode and was deleted. */
|
|
31
|
+
'RecordDiscarded'
|
|
32
|
+
/** A record failed to decode and was moved to the archive. */
|
|
33
|
+
| 'RecordArchived'
|
|
34
|
+
/** A record was written by a newer build. It is kept, but this build can't read it. */
|
|
35
|
+
| 'RecordFromFuture'
|
|
36
|
+
/** A write failed because storage is full, even after clearing the archive. */
|
|
37
|
+
| 'QuotaExceeded'
|
|
38
|
+
/** Storage can't be opened at all. */
|
|
39
|
+
| 'Unavailable';
|
|
40
|
+
/**
|
|
41
|
+
* Something the frame should show the player, shaped like the contracts' `Notice` plus where
|
|
42
|
+
* it happened. `issues` is developer detail for logs; never show it to players.
|
|
43
|
+
*/
|
|
44
|
+
export type StorageNotice = {
|
|
45
|
+
readonly code: StorageNoticeCode;
|
|
46
|
+
readonly message: string;
|
|
47
|
+
/** Where it happened; null for a notice about storage as a whole. */
|
|
48
|
+
readonly store: StoreName | null;
|
|
49
|
+
readonly key: string | null;
|
|
50
|
+
readonly issues: readonly Issue[];
|
|
51
|
+
};
|
|
52
|
+
/** What every record store shares: the backend, the notice sink and the clock. */
|
|
53
|
+
export type RecordContext = {
|
|
54
|
+
readonly backend: StorageBackend;
|
|
55
|
+
readonly notify: (notice: StorageNotice) => void;
|
|
56
|
+
/** Epoch milliseconds, recorded with each write for v2 sync ("newest wins"). */
|
|
57
|
+
readonly now: () => number;
|
|
58
|
+
};
|
|
59
|
+
/** Typed, validated access to one kind of record. */
|
|
60
|
+
export interface RecordStore<T extends Json> {
|
|
61
|
+
/** The decoded value, or null when it is absent or failed to decode (with a notice). */
|
|
62
|
+
get(key: string): Promise<Result<T | null, StorageError>>;
|
|
63
|
+
/** Validates `value` against the kind's schema, then writes it at the current version. */
|
|
64
|
+
set(key: string, value: T): Promise<Result<void, StorageError>>;
|
|
65
|
+
/**
|
|
66
|
+
* Validates `value`, then writes it only when the key holds no readable record, atomically
|
|
67
|
+
* (see `StorageBackend.putIfAbsent`). Resolves to the record the key holds afterwards: this
|
|
68
|
+
* value, or the one another caller, perhaps in another tab, wrote first. A record that fails
|
|
69
|
+
* to decode is discarded or archived first, as `get` does. When the key holds a record from
|
|
70
|
+
* a newer build, the value is returned unwritten.
|
|
71
|
+
*/
|
|
72
|
+
setIfAbsent(key: string, value: T): Promise<Result<T, StorageError>>;
|
|
73
|
+
delete(key: string): Promise<Result<void, StorageError>>;
|
|
74
|
+
/** Keys that start with `prefix`, ascending. */
|
|
75
|
+
keys(prefix?: string): Promise<Result<readonly string[], StorageError>>;
|
|
76
|
+
/**
|
|
77
|
+
* Discards or archives a record (by the kind's `onInvalid`) that decoded but failed a later
|
|
78
|
+
* check, such as a game's own save schema, and tells the player.
|
|
79
|
+
*/
|
|
80
|
+
reject(key: string, issues: readonly Issue[]): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
/** What the archive store holds for a record that failed to decode. */
|
|
83
|
+
export type ArchivedRecord = {
|
|
84
|
+
readonly store: StoreName;
|
|
85
|
+
readonly key: string;
|
|
86
|
+
readonly archivedAt: number;
|
|
87
|
+
readonly issues: readonly string[];
|
|
88
|
+
/** The raw record, or null when it wasn't even JSON. */
|
|
89
|
+
readonly raw: Json | null;
|
|
90
|
+
};
|
|
91
|
+
type Decoded<T> = {
|
|
92
|
+
readonly kind: 'absent';
|
|
93
|
+
} | {
|
|
94
|
+
readonly kind: 'valid';
|
|
95
|
+
readonly value: T;
|
|
96
|
+
readonly migrated: boolean;
|
|
97
|
+
} | {
|
|
98
|
+
readonly kind: 'future';
|
|
99
|
+
readonly issues: readonly Issue[];
|
|
100
|
+
} | {
|
|
101
|
+
readonly kind: 'invalid';
|
|
102
|
+
readonly issues: readonly Issue[];
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Decodes a raw backend value as a record of `kind`: checks the record's own shape, runs the
|
|
106
|
+
* migrations from its version to the current one, then validates the value with the kind's
|
|
107
|
+
* schema. Pure apart from the schema, which may be async.
|
|
108
|
+
*/
|
|
109
|
+
export declare const decodeRecord: <T extends Json>(kind: RecordKind<T>, raw: unknown) => Promise<Decoded<T>>;
|
|
110
|
+
/**
|
|
111
|
+
* Writes a value, and when storage is full, clears the archive (the least valuable data the
|
|
112
|
+
* site keeps) and tries once more.
|
|
113
|
+
*/
|
|
114
|
+
export declare const putWithQuotaRetry: (context: RecordContext, store: StoreName, key: string, value: Json) => Promise<Result<void, StorageError>>;
|
|
115
|
+
/** Creates the typed store for one kind of record. */
|
|
116
|
+
export declare const createRecordStore: <T extends Json>(context: RecordContext, kind: RecordKind<T>) => RecordStore<T>;
|
|
117
|
+
export {};
|
package/dist/record.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { MESSAGES, storageError } from "./backend.js";
|
|
2
|
+
import { json, validate } from "./validate.js";
|
|
3
|
+
import { err, isJson, ok, runMigrations } from "@joystyk/contracts";
|
|
4
|
+
import * as z from "zod/mini";
|
|
5
|
+
//#region src/record.ts
|
|
6
|
+
/** Fills in a record kind's defaults: no migrations, and invalid records discarded. */
|
|
7
|
+
var defineRecordKind = (kind) => ({
|
|
8
|
+
migrations: [],
|
|
9
|
+
onInvalid: "discard",
|
|
10
|
+
...kind
|
|
11
|
+
});
|
|
12
|
+
/** How every record sits in a backend: its shape's version, when it was written, and the value. */
|
|
13
|
+
var storedRecord = z.object({
|
|
14
|
+
version: z.int().check(z.positive()),
|
|
15
|
+
writtenAt: z.number().check(z.nonnegative()),
|
|
16
|
+
value: json
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* Decodes a raw backend value as a record of `kind`: checks the record's own shape, runs the
|
|
20
|
+
* migrations from its version to the current one, then validates the value with the kind's
|
|
21
|
+
* schema. Pure apart from the schema, which may be async.
|
|
22
|
+
*/
|
|
23
|
+
var decodeRecord = async (kind, raw) => {
|
|
24
|
+
if (raw === void 0) return { kind: "absent" };
|
|
25
|
+
const record = await validate(storedRecord, raw);
|
|
26
|
+
if (!record.ok) return {
|
|
27
|
+
kind: "invalid",
|
|
28
|
+
issues: record.error
|
|
29
|
+
};
|
|
30
|
+
const { version, value } = record.value;
|
|
31
|
+
const migrated = runMigrations(kind.migrations, value, version, kind.version);
|
|
32
|
+
if (!migrated.ok) {
|
|
33
|
+
const issues = [`${migrated.error.code}: ${migrated.error.message}`];
|
|
34
|
+
return migrated.error.code === "SaveFromFuture" ? {
|
|
35
|
+
kind: "future",
|
|
36
|
+
issues
|
|
37
|
+
} : {
|
|
38
|
+
kind: "invalid",
|
|
39
|
+
issues
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const decoded = await validate(kind.schema, migrated.value);
|
|
43
|
+
if (!decoded.ok) return {
|
|
44
|
+
kind: "invalid",
|
|
45
|
+
issues: decoded.error
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
kind: "valid",
|
|
49
|
+
value: decoded.value,
|
|
50
|
+
migrated: version !== kind.version
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Writes a value, and when storage is full, clears the archive (the least valuable data the
|
|
55
|
+
* site keeps) and tries once more.
|
|
56
|
+
*/
|
|
57
|
+
var putWithQuotaRetry = async (context, store, key, value) => {
|
|
58
|
+
const first = await context.backend.put(store, key, value);
|
|
59
|
+
if (first.ok || first.error.code !== "QuotaExceeded" || store === "archive") return first;
|
|
60
|
+
if (!(await context.backend.clear("archive")).ok) return first;
|
|
61
|
+
return context.backend.put(store, key, value);
|
|
62
|
+
};
|
|
63
|
+
var failureNotice = (store, key, error) => error.code === "QuotaExceeded" || error.code === "Unavailable" ? {
|
|
64
|
+
code: error.code,
|
|
65
|
+
message: error.message,
|
|
66
|
+
store,
|
|
67
|
+
key,
|
|
68
|
+
issues: []
|
|
69
|
+
} : null;
|
|
70
|
+
/** Creates the typed store for one kind of record. */
|
|
71
|
+
var createRecordStore = (context, kind) => {
|
|
72
|
+
const { backend, notify } = context;
|
|
73
|
+
const reportFailure = (key, result) => {
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
const notice = failureNotice(kind.store, key, result.error);
|
|
76
|
+
if (notice !== null) notify(notice);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
const write = async (key, value) => reportFailure(key, await putWithQuotaRetry(context, kind.store, key, {
|
|
81
|
+
version: kind.version,
|
|
82
|
+
writtenAt: context.now(),
|
|
83
|
+
value
|
|
84
|
+
}));
|
|
85
|
+
const reject = async (key, raw, issues) => {
|
|
86
|
+
if (kind.onInvalid === "archive") {
|
|
87
|
+
const archivedAt = context.now();
|
|
88
|
+
const entry = {
|
|
89
|
+
store: kind.store,
|
|
90
|
+
key,
|
|
91
|
+
archivedAt,
|
|
92
|
+
issues,
|
|
93
|
+
raw: isJson(raw) ? raw : null
|
|
94
|
+
};
|
|
95
|
+
await backend.put("archive", `${kind.store}/${key}@${archivedAt}`, entry);
|
|
96
|
+
}
|
|
97
|
+
await backend.delete(kind.store, key);
|
|
98
|
+
const message = kind.onInvalid === "archive" ? `A ${kind.label} couldn't be opened, so it was set aside.` : `A ${kind.label} couldn't be read, so it was removed.`;
|
|
99
|
+
notify({
|
|
100
|
+
code: kind.onInvalid === "archive" ? "RecordArchived" : "RecordDiscarded",
|
|
101
|
+
message,
|
|
102
|
+
store: kind.store,
|
|
103
|
+
key,
|
|
104
|
+
issues
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const get = async (key) => {
|
|
108
|
+
const raw = await backend.get(kind.store, key);
|
|
109
|
+
if (!raw.ok) return reportFailure(key, raw);
|
|
110
|
+
const decoded = await decodeRecord(kind, raw.value);
|
|
111
|
+
switch (decoded.kind) {
|
|
112
|
+
case "absent": return ok(null);
|
|
113
|
+
case "future":
|
|
114
|
+
notify({
|
|
115
|
+
code: "RecordFromFuture",
|
|
116
|
+
message: `This ${kind.label} was saved by a newer version of the site. Reload the page to open it.`,
|
|
117
|
+
store: kind.store,
|
|
118
|
+
key,
|
|
119
|
+
issues: decoded.issues
|
|
120
|
+
});
|
|
121
|
+
return ok(null);
|
|
122
|
+
case "invalid":
|
|
123
|
+
await reject(key, raw.value, decoded.issues);
|
|
124
|
+
return ok(null);
|
|
125
|
+
case "valid":
|
|
126
|
+
if (decoded.migrated) await write(key, decoded.value);
|
|
127
|
+
return ok(decoded.value);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const check = async (value) => {
|
|
131
|
+
if (!isJson(value)) return err(storageError("InvalidValue", MESSAGES.InvalidValue));
|
|
132
|
+
const checked = await validate(kind.schema, value);
|
|
133
|
+
return checked.ok ? ok(checked.value) : err(storageError("InvalidValue", MESSAGES.InvalidValue, [...checked.error]));
|
|
134
|
+
};
|
|
135
|
+
/** One atomic attempt, with the same quota retry as `write`. */
|
|
136
|
+
const putIfAbsent = async (key, value) => {
|
|
137
|
+
const record = {
|
|
138
|
+
version: kind.version,
|
|
139
|
+
writtenAt: context.now(),
|
|
140
|
+
value
|
|
141
|
+
};
|
|
142
|
+
const first = await backend.putIfAbsent(kind.store, key, record);
|
|
143
|
+
if (first.ok || first.error.code !== "QuotaExceeded") return reportFailure(key, first);
|
|
144
|
+
const cleared = await backend.clear("archive");
|
|
145
|
+
return reportFailure(key, cleared.ok ? await backend.putIfAbsent(kind.store, key, record) : first);
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
get,
|
|
149
|
+
set: async (key, value) => {
|
|
150
|
+
const checked = await check(value);
|
|
151
|
+
return checked.ok ? write(key, checked.value) : checked;
|
|
152
|
+
},
|
|
153
|
+
setIfAbsent: async (key, value) => {
|
|
154
|
+
const checked = await check(value);
|
|
155
|
+
if (!checked.ok) return checked;
|
|
156
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
157
|
+
const put = await putIfAbsent(key, checked.value);
|
|
158
|
+
if (!put.ok) return put;
|
|
159
|
+
if (put.value.written) return ok(checked.value);
|
|
160
|
+
const existing = await get(key);
|
|
161
|
+
if (!existing.ok) return existing;
|
|
162
|
+
if (existing.value !== null) return ok(existing.value);
|
|
163
|
+
}
|
|
164
|
+
return ok(checked.value);
|
|
165
|
+
},
|
|
166
|
+
delete: async (key) => reportFailure(key, await backend.delete(kind.store, key)),
|
|
167
|
+
keys: async (prefix = "") => reportFailure(prefix, await backend.keys(kind.store, prefix)),
|
|
168
|
+
reject: async (key, issues) => {
|
|
169
|
+
const raw = await backend.get(kind.store, key);
|
|
170
|
+
await reject(key, raw.ok ? raw.value : void 0, issues);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
//#endregion
|
|
175
|
+
export { createRecordStore, decodeRecord, defineRecordKind, putWithQuotaRetry };
|
|
176
|
+
|
|
177
|
+
//# sourceMappingURL=record.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record.js","names":[],"sources":["../src/record.ts"],"sourcesContent":["import {\n err,\n isJson,\n ok,\n runMigrations,\n type Json,\n type Migration,\n type Result,\n type Validator,\n} from '@joystyk/contracts';\nimport * as z from 'zod/mini';\nimport { MESSAGES, storageError, type StorageBackend, type StorageError, type StoreName } from './backend.ts';\nimport { json, validate, type Issue } from './validate.ts';\n\n/**\n * One kind of record: where it lives, the current version of its shape, the schema that\n * decodes it and the migrations that bring older versions up to date. Every record a\n * `RecordStore` reads passes through all three before anyone sees it.\n */\nexport type RecordKind<T extends Json> = {\n readonly store: StoreName;\n /** The current shape's version, from 1. Bump it, and add a migration, when the shape changes. */\n readonly version: number;\n readonly schema: Validator<T>;\n /** Pure steps from each older version to the next (the contracts' `Migration`). */\n readonly migrations: readonly Migration[];\n /**\n * What happens to a record that fails to decode: `discard` deletes it; `archive` moves its\n * raw value to the `archive` store first, where nothing reads it as trusted data, so a bug\n * report or a later build can still recover it. Saves are archived (offline-and-updates.md,\n * Rule 3).\n */\n readonly onInvalid: 'discard' | 'archive';\n /** How notices name this record to a player, e.g. \"saved game\". */\n readonly label: string;\n};\n\n/** Fills in a record kind's defaults: no migrations, and invalid records discarded. */\nexport const defineRecordKind = <T extends Json>(\n kind: Omit<RecordKind<T>, 'migrations' | 'onInvalid'> & Partial<Pick<RecordKind<T>, 'migrations' | 'onInvalid'>>,\n): RecordKind<T> => ({ migrations: [], onInvalid: 'discard', ...kind });\n\n/** Why the store is telling the player something. */\nexport type StorageNoticeCode =\n /** A record failed to decode and was deleted. */\n | 'RecordDiscarded'\n /** A record failed to decode and was moved to the archive. */\n | 'RecordArchived'\n /** A record was written by a newer build. It is kept, but this build can't read it. */\n | 'RecordFromFuture'\n /** A write failed because storage is full, even after clearing the archive. */\n | 'QuotaExceeded'\n /** Storage can't be opened at all. */\n | 'Unavailable';\n\n/**\n * Something the frame should show the player, shaped like the contracts' `Notice` plus where\n * it happened. `issues` is developer detail for logs; never show it to players.\n */\nexport type StorageNotice = {\n readonly code: StorageNoticeCode;\n readonly message: string;\n /** Where it happened; null for a notice about storage as a whole. */\n readonly store: StoreName | null;\n readonly key: string | null;\n readonly issues: readonly Issue[];\n};\n\n/** What every record store shares: the backend, the notice sink and the clock. */\nexport type RecordContext = {\n readonly backend: StorageBackend;\n readonly notify: (notice: StorageNotice) => void;\n /** Epoch milliseconds, recorded with each write for v2 sync (\"newest wins\"). */\n readonly now: () => number;\n};\n\n/** Typed, validated access to one kind of record. */\nexport interface RecordStore<T extends Json> {\n /** The decoded value, or null when it is absent or failed to decode (with a notice). */\n get(key: string): Promise<Result<T | null, StorageError>>;\n /** Validates `value` against the kind's schema, then writes it at the current version. */\n set(key: string, value: T): Promise<Result<void, StorageError>>;\n /**\n * Validates `value`, then writes it only when the key holds no readable record, atomically\n * (see `StorageBackend.putIfAbsent`). Resolves to the record the key holds afterwards: this\n * value, or the one another caller, perhaps in another tab, wrote first. A record that fails\n * to decode is discarded or archived first, as `get` does. When the key holds a record from\n * a newer build, the value is returned unwritten.\n */\n setIfAbsent(key: string, value: T): Promise<Result<T, StorageError>>;\n delete(key: string): Promise<Result<void, StorageError>>;\n /** Keys that start with `prefix`, ascending. */\n keys(prefix?: string): Promise<Result<readonly string[], StorageError>>;\n /**\n * Discards or archives a record (by the kind's `onInvalid`) that decoded but failed a later\n * check, such as a game's own save schema, and tells the player.\n */\n reject(key: string, issues: readonly Issue[]): Promise<void>;\n}\n\n/** How every record sits in a backend: its shape's version, when it was written, and the value. */\nconst storedRecord = z.object({\n version: z.int().check(z.positive()),\n writtenAt: z.number().check(z.nonnegative()),\n value: json,\n});\n\n/** What the archive store holds for a record that failed to decode. */\nexport type ArchivedRecord = {\n readonly store: StoreName;\n readonly key: string;\n readonly archivedAt: number;\n readonly issues: readonly string[];\n /** The raw record, or null when it wasn't even JSON. */\n readonly raw: Json | null;\n};\n\ntype Decoded<T> =\n | { readonly kind: 'absent' }\n | { readonly kind: 'valid'; readonly value: T; readonly migrated: boolean }\n | { readonly kind: 'future'; readonly issues: readonly Issue[] }\n | { readonly kind: 'invalid'; readonly issues: readonly Issue[] };\n\n/**\n * Decodes a raw backend value as a record of `kind`: checks the record's own shape, runs the\n * migrations from its version to the current one, then validates the value with the kind's\n * schema. Pure apart from the schema, which may be async.\n */\nexport const decodeRecord = async <T extends Json>(kind: RecordKind<T>, raw: unknown): Promise<Decoded<T>> => {\n if (raw === undefined) return { kind: 'absent' };\n const record = await validate(storedRecord, raw);\n if (!record.ok) return { kind: 'invalid', issues: record.error };\n const { version, value } = record.value;\n const migrated = runMigrations(kind.migrations, value, version, kind.version);\n if (!migrated.ok) {\n const issues = [`${migrated.error.code}: ${migrated.error.message}`];\n return migrated.error.code === 'SaveFromFuture' ? { kind: 'future', issues } : { kind: 'invalid', issues };\n }\n const decoded = await validate(kind.schema, migrated.value);\n if (!decoded.ok) return { kind: 'invalid', issues: decoded.error };\n return { kind: 'valid', value: decoded.value, migrated: version !== kind.version };\n};\n\n/**\n * Writes a value, and when storage is full, clears the archive (the least valuable data the\n * site keeps) and tries once more.\n */\nexport const putWithQuotaRetry = async (\n context: RecordContext,\n store: StoreName,\n key: string,\n value: Json,\n): Promise<Result<void, StorageError>> => {\n const first = await context.backend.put(store, key, value);\n if (first.ok || first.error.code !== 'QuotaExceeded' || store === 'archive') return first;\n const cleared = await context.backend.clear('archive');\n if (!cleared.ok) return first;\n return context.backend.put(store, key, value);\n};\n\nconst failureNotice = (store: StoreName, key: string, error: StorageError): StorageNotice | null =>\n error.code === 'QuotaExceeded' || error.code === 'Unavailable'\n ? { code: error.code, message: error.message, store, key, issues: [] }\n : null;\n\n/** Creates the typed store for one kind of record. */\nexport const createRecordStore = <T extends Json>(context: RecordContext, kind: RecordKind<T>): RecordStore<T> => {\n const { backend, notify } = context;\n\n const reportFailure = <V>(key: string, result: Result<V, StorageError>): Result<V, StorageError> => {\n if (!result.ok) {\n const notice = failureNotice(kind.store, key, result.error);\n if (notice !== null) notify(notice);\n }\n return result;\n };\n\n const write = async (key: string, value: Json): Promise<Result<void, StorageError>> =>\n reportFailure(\n key,\n await putWithQuotaRetry(context, kind.store, key, { version: kind.version, writtenAt: context.now(), value }),\n );\n\n const reject = async (key: string, raw: unknown, issues: readonly Issue[]): Promise<void> => {\n if (kind.onInvalid === 'archive') {\n const archivedAt = context.now();\n const entry: ArchivedRecord = { store: kind.store, key, archivedAt, issues, raw: isJson(raw) ? raw : null };\n // Best effort: a full archive must not keep a broken record in the live store.\n await backend.put('archive', `${kind.store}/${key}@${archivedAt}`, entry);\n }\n await backend.delete(kind.store, key);\n const message =\n kind.onInvalid === 'archive'\n ? `A ${kind.label} couldn't be opened, so it was set aside.`\n : `A ${kind.label} couldn't be read, so it was removed.`;\n notify({\n code: kind.onInvalid === 'archive' ? 'RecordArchived' : 'RecordDiscarded',\n message,\n store: kind.store,\n key,\n issues,\n });\n };\n\n const get = async (key: string): Promise<Result<T | null, StorageError>> => {\n const raw = await backend.get(kind.store, key);\n if (!raw.ok) return reportFailure(key, raw);\n const decoded = await decodeRecord(kind, raw.value);\n switch (decoded.kind) {\n case 'absent':\n return ok(null);\n case 'future':\n notify({\n code: 'RecordFromFuture',\n message: `This ${kind.label} was saved by a newer version of the site. Reload the page to open it.`,\n store: kind.store,\n key,\n issues: decoded.issues,\n });\n return ok(null);\n case 'invalid':\n await reject(key, raw.value, decoded.issues);\n return ok(null);\n case 'valid':\n // Store the upgraded shape, so the migration runs once. Failing to is harmless.\n if (decoded.migrated) await write(key, decoded.value);\n return ok(decoded.value);\n }\n };\n\n const check = async (value: T): Promise<Result<T, StorageError>> => {\n if (!isJson(value)) return err(storageError('InvalidValue', MESSAGES.InvalidValue));\n const checked = await validate(kind.schema, value);\n return checked.ok\n ? ok(checked.value)\n : err(storageError('InvalidValue', MESSAGES.InvalidValue, [...checked.error]));\n };\n\n /** One atomic attempt, with the same quota retry as `write`. */\n const putIfAbsent = async (key: string, value: T) => {\n const record = { version: kind.version, writtenAt: context.now(), value };\n const first = await backend.putIfAbsent(kind.store, key, record);\n if (first.ok || first.error.code !== 'QuotaExceeded') return reportFailure(key, first);\n const cleared = await backend.clear('archive');\n return reportFailure(key, cleared.ok ? await backend.putIfAbsent(kind.store, key, record) : first);\n };\n\n return {\n get,\n set: async (key, value) => {\n const checked = await check(value);\n return checked.ok ? write(key, checked.value) : checked;\n },\n setIfAbsent: async (key, value) => {\n const checked = await check(value);\n if (!checked.ok) return checked;\n // A second attempt follows a record that failed to decode and was removed.\n for (let attempt = 0; attempt < 2; attempt += 1) {\n const put = await putIfAbsent(key, checked.value);\n if (!put.ok) return put;\n if (put.value.written) return ok(checked.value);\n const existing = await get(key);\n if (!existing.ok) return existing;\n if (existing.value !== null) return ok(existing.value);\n }\n return ok(checked.value);\n },\n delete: async (key) => reportFailure(key, await backend.delete(kind.store, key)),\n keys: async (prefix = '') => reportFailure(prefix, await backend.keys(kind.store, prefix)),\n reject: async (key, issues) => {\n const raw = await backend.get(kind.store, key);\n await reject(key, raw.ok ? raw.value : undefined, issues);\n },\n };\n};\n"],"mappings":"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"}
|
package/dist/saves.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Json, type Migration, type Result, type SaveEnvelope, type Validator } from '@joystyk/contracts';
|
|
2
|
+
import { type StorageError } from './backend.js';
|
|
3
|
+
import { type RecordContext } from './record.js';
|
|
4
|
+
/** One saved game in a slot. */
|
|
5
|
+
export type SavedGame = {
|
|
6
|
+
readonly slot: string;
|
|
7
|
+
/** Epoch milliseconds of the write. */
|
|
8
|
+
readonly savedAt: number;
|
|
9
|
+
readonly envelope: SaveEnvelope;
|
|
10
|
+
};
|
|
11
|
+
/** What a game supplies to bring its saved state up to date: the game module's chain and schema. */
|
|
12
|
+
export type SaveDecoder = {
|
|
13
|
+
/** The game's current save version (`manifest.versions.save`). */
|
|
14
|
+
readonly saveVersion: number;
|
|
15
|
+
readonly migrations: readonly Migration[];
|
|
16
|
+
/** The engine's `schemas.state` (or the simulation's). */
|
|
17
|
+
readonly state: Validator<Json>;
|
|
18
|
+
};
|
|
19
|
+
type SaveRecord = {
|
|
20
|
+
readonly savedAt: number;
|
|
21
|
+
readonly envelope: SaveEnvelope;
|
|
22
|
+
};
|
|
23
|
+
/** Version 1 of the save record: the contracts' envelope plus the time it was written. */
|
|
24
|
+
export declare const SAVE_RECORD: import("./record.js").RecordKind<SaveRecord>;
|
|
25
|
+
/** One player's save slots, by game id and slot name. */
|
|
26
|
+
export interface SaveStore {
|
|
27
|
+
/** Every readable save of one game, by slot name. Unreadable ones are archived with a notice. */
|
|
28
|
+
list(gameId: string): Promise<Result<readonly SavedGame[], StorageError>>;
|
|
29
|
+
/**
|
|
30
|
+
* The save in a slot with its envelope decoded, or null. The game's own state is not
|
|
31
|
+
* migrated or checked; use `load` to resume play.
|
|
32
|
+
*/
|
|
33
|
+
read(gameId: string, slot: string): Promise<Result<SavedGame | null, StorageError>>;
|
|
34
|
+
/**
|
|
35
|
+
* The save in a slot, ready to resume: its state migrated from the saved version to the
|
|
36
|
+
* game's current one and validated with the game's schema. A save that can't be brought up
|
|
37
|
+
* to date is archived with a notice and comes back as null. The caller still compares
|
|
38
|
+
* `rulesVersion` with the game's (offline-and-updates.md, Rule 3).
|
|
39
|
+
*/
|
|
40
|
+
load(gameId: string, slot: string, decoder: SaveDecoder): Promise<Result<SavedGame | null, StorageError>>;
|
|
41
|
+
/** Writes an envelope to a slot. The envelope's `gameId` must be `gameId`. */
|
|
42
|
+
write(gameId: string, slot: string, envelope: SaveEnvelope): Promise<Result<void, StorageError>>;
|
|
43
|
+
delete(gameId: string, slot: string): Promise<Result<void, StorageError>>;
|
|
44
|
+
}
|
|
45
|
+
export declare const createSaveStore: (context: RecordContext, playerId: string) => SaveStore;
|
|
46
|
+
export {};
|