@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # @joystyk/storage
2
+
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - bb095c7: Add shared real-time input, pause, pace records and ghost races, plus a WebGL 2 renderer
8
+ lifecycle with context recovery and adaptive quality. Launch daily fixed-step simulations
9
+ from published configs and retain their puzzle identity through saved games.
10
+ - Updated dependencies [bb095c7]
11
+ - Updated dependencies [bb095c7]
12
+ - @joystyk/contracts@0.2.0
13
+
14
+ ## 0.1.0
15
+
16
+ ### Minor Changes
17
+
18
+ - Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies
23
+ - @joystyk/contracts@0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joystyk contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,36 @@
1
+ Third-party material in the Joystyk library release
2
+
3
+ This notice preserves third-party attribution. Original Joystyk code in the 17
4
+ platform libraries under packages/ uses the MIT license in packages/LICENSE.
5
+ That choice does not license the private applications, games or other repository
6
+ material, and does not replace the third-party terms below.
7
+
8
+ @joystyk/contracts
9
+ ------------------
10
+ Standard Schema v1 type definitions from @standard-schema/spec 1.1.0:
11
+ https://github.com/standard-schema/standard-schema
12
+ https://standardschema.dev
13
+ Copyright (c) 2024 Colin McDonnell. MIT License.
14
+ The complete license and source/change notes accompany the distributed
15
+ dist/standard-schema.d.ts definitions. Changes: individually exported declarations
16
+ and formatting; the declarations themselves are unchanged.
17
+
18
+ @joystyk/identity and @joystyk/content
19
+ -----------------------------------
20
+ Word blocklists derived from List of Dirty, Naughty, Obscene, and Otherwise Bad
21
+ Words, copyright Shutterstock, Inc. and contributors:
22
+ https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words
23
+ Licensed under Creative Commons Attribution 4.0 International:
24
+ https://creativecommons.org/licenses/by/4.0/
25
+ Identity uses the English list at commit
26
+ 5faf2ba42d7b1c0977169ec3611df25a3c08eb13. Changes include reducing or dropping
27
+ phrases, dropping words with everyday meanings, substituting stems for inflections
28
+ and adding entries. Identity's README and content's lists/blocklist.txt retain
29
+ their source-specific provenance and changes.
30
+
31
+ @joystyk/content
32
+ ---------------
33
+ SCOWL-derived common, vocabulary and ranked word lists use SCOWL 2020.12.07.
34
+ Their complete source licenses and attribution accompany the package in
35
+ lists/SCOWL-COPYRIGHT. Word-list transformations and build provenance are described
36
+ in the package README. Preserve that copyright file when redistributing the lists.
package/README.md CHANGED
@@ -1,3 +1,169 @@
1
- # Temporary Holding Version
1
+ # @joystyk/storage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Everything a player keeps on their device: save slots, stats, daily streaks, preferences and
4
+ each game's own key-value data. It sits on IndexedDB in the browser and on memory in tests
5
+ and server rendering, and it never trusts what it reads back.
6
+
7
+ ## npm package
8
+
9
+ The first `0.1.0` release is being prepared; it has not been published. Once available:
10
+
11
+ ```sh
12
+ npm install @joystyk/storage
13
+ ```
14
+
15
+ Node consumers require Node 24.15 or later. Browser entry points require a modern
16
+ browser and should be loaded in the browser.
17
+
18
+ | Import | Format |
19
+ | --- | --- |
20
+ | `@joystyk/storage` | ES module and TypeScript declarations |
21
+ | `@joystyk/storage/browser` | ES module and TypeScript declarations |
22
+
23
+ Only the entries listed above are public API. Installed packages contain compiled ESM,
24
+ type declarations and runtime assets. Repository-relative links describe the Joystyk
25
+ workspace and are not needed to use the npm package.
26
+
27
+ The design comes from offline-and-updates.md (Joystyk host reference)
28
+ (the Data tier, Rule 3 on saves) and game-contract.md (Joystyk host reference)
29
+ (the save envelope, migrations and `PlatformApi.storage`).
30
+
31
+ ## Entry points
32
+
33
+ | Import | Runs in | Contents |
34
+ | --- | --- | --- |
35
+ | `@joystyk/storage` | Anywhere (no DOM) | Record stores, the save, stats, streak and preference stores, the memory backend, schemas, the migration runner |
36
+ | `@joystyk/storage/browser` | Browser | The IndexedDB backend, `openBrowserStorage`, the persistent-storage helpers, `systemEnvironment` |
37
+
38
+ `tsconfig.core.json` type-checks the core without DOM types, so browser code can't leak into
39
+ it: anything that needs `window`, `navigator` or `indexedDB` goes in `src/browser/`.
40
+
41
+ ## How it fits together
42
+
43
+ ```ts
44
+ import { openBrowserStorage, requestPersistentStorage } from '@joystyk/storage/browser';
45
+
46
+ const storage = await openBrowserStorage({ onNotice: (notice) => frame.show(notice.message) });
47
+ const player = storage.player(profile.id); // from @joystyk/identity
48
+
49
+ await player.saves.write('nim', 'auto', envelope); // a contracts SaveEnvelope
50
+ const resumed = await player.saves.load('nim', 'auto', { // migrated and validated
51
+ saveVersion: manifest.versions.save,
52
+ migrations: module.migrations,
53
+ state: module.engine.schemas.state,
54
+ });
55
+ await player.stats.record('nim', outcome); // a contracts Outcome
56
+ await player.streaks.set('daily-word', { current: 3, best: 5, lastDay: '2026-10-08' });
57
+ await player.preferences.update({ singleKeyShortcuts: false });
58
+ const platformStorage = player.gameStorage('nim'); // PlatformApi.storage
59
+
60
+ await requestPersistentStorage(); // when the player pins a game, never on page load
61
+ ```
62
+
63
+ In tests and on the server, use `openStorage({ backend: createMemoryBackend() })`.
64
+
65
+ ### The layers
66
+
67
+ 1. **Backend** (`StorageBackend`): raw get, put, delete, list-by-prefix and clear over named
68
+ object stores, with JSON values. `createMemoryBackend` and `openIndexedDbBackend`
69
+ implement it. Every operation resolves to a contracts `Result`; expected failures such as
70
+ a full quota never reject.
71
+ 2. **Record stores** (`RecordKind`, `createRecordStore`): every record is stored as
72
+ `{ version, writtenAt, value }`. On read, the store checks that shape, runs the kind's
73
+ migrations from the record's version to the current one (the contracts' `runMigrations`),
74
+ then validates the value with the kind's Standard Schema. On write, it validates first.
75
+ 3. **Player stores** (`storage.player(id)`): saves, stats, streaks, preferences and game data,
76
+ with keys that start with the player id (`<playerId>/<gameId>/<slot>`), never a device id.
77
+
78
+ Other packages define their own record kinds on the same machinery: `@joystyk/identity` keeps
79
+ its profile in the `identity` store with `storage.records(PROFILE_RECORD)`.
80
+
81
+ ### Ports for the session runner
82
+
83
+ `SaveStore`, `StatsStore`, `StreakStore` and `PreferencesStore` are interfaces, keyed by game
84
+ id and slot name, so `@joystyk/session` can declare narrower ports and pass these stores in.
85
+ Every method returns `Promise<Result<…, StorageError>>`.
86
+
87
+ ## Never trusted
88
+
89
+ A record that fails any step is never handed to the caller:
90
+
91
+ | What went wrong | What happens |
92
+ | --- | --- |
93
+ | Not a record, not JSON, fails its schema, or a migration fails or is missing | Deleted, or for saves moved to the `archive` store, with a `RecordDiscarded` or `RecordArchived` notice. The read returns null (or the defaults). |
94
+ | Written by a newer build (`version` above the current one) | Kept for that build, with a `RecordFromFuture` notice; the read returns null. |
95
+ | A save's game state fails the game's migrations or `schemas.state` (`saves.load`) | Archived with a notice, or kept with `RecordFromFuture` when the game's save version is newer. |
96
+
97
+ `saves.load` doesn't compare `rulesVersion`; the caller decides whether an old-rules game can
98
+ continue (Rule 3).
99
+
100
+ Notices carry a player-facing `message` and developer `issues`; show only the message.
101
+
102
+ ## Quota
103
+
104
+ When a write fails with `QuotaExceeded`, the store clears the `archive` store, the least
105
+ valuable data it keeps, and tries once more. If that fails too, the write returns a
106
+ `QuotaExceeded` error and a notice. `estimateStorage()` reports usage, and
107
+ `requestPersistentStorage()` asks the browser not to evict the site's data.
108
+
109
+ A failed `PlatformApi.storage` write rejects with a `StorageFailure`, because the contract's
110
+ `Promise<void>` has no other channel; reads resolve to null.
111
+
112
+ ## Versions
113
+
114
+ - **Record shapes**: each `RecordKind` has a `version` and a migration chain. Bump the
115
+ version and add a `Migration` (from the contracts) when a shape changes; records upgrade on
116
+ their next read and are rewritten in the new shape.
117
+ - **Object stores**: `DATABASE_UPGRADES` in `src/browser/indexeddb.ts`, numbered from 1 with
118
+ no gaps. Add a step only to create or remove an object store. When another tab opens a
119
+ newer version, this connection closes, calls `onVersionChange`, and later operations
120
+ return `Unavailable`.
121
+
122
+ ## Preferences
123
+
124
+ `StoredPreferences` holds the player's choices, where `system` follows the operating system:
125
+ reduced motion (`system`, `reduce`, `no-preference`), sound and volume, single-key
126
+ shortcuts (WCAG 2.1.4) and theme (`system`, `light`, `dark`), plus an optional locale.
127
+ `resolvePreferences(stored, systemEnvironment())` turns them into the contracts'
128
+ `Preferences`, with `singleKeyShortcuts` added. `aiContentAccepted` records that the player
129
+ accepted the AI-content notice (host policy); it is optional, so records written before it read as
130
+ "not yet" without a migration.
131
+
132
+ ### Other tabs
133
+
134
+ A preference set in one tab reaches every other open tab of the site (bead host integration work).
135
+ `update` notifies this tab's subscribers, then publishes `{ kind: 'preferences', playerId }`
136
+ on the storage's `ChangeFeed`. Each other tab parses the message, reads the preferences back
137
+ from IndexedDB through their schema, and calls its subscribers with the source
138
+ `other-tab`. The message carries no values, so nothing reaches a tab without passing the
139
+ record's schema.
140
+
141
+ `openBrowserStorage` builds the feed with `openChangeFeed()`: a `BroadcastChannel` named
142
+ `joystyk-storage`, or, where the browser has none, a `storage` event fired by writing and at
143
+ once removing a localStorage key of the same name. Where neither works, and when storage
144
+ fell back to memory (which tabs don't share), changes stay in their tab. In tests,
145
+ `createMemoryChangeHub()` gives each simulated tab a feed.
146
+
147
+ ## Writing only if absent
148
+
149
+ `StorageBackend.putIfAbsent` writes a key only when it is empty, in one step. IndexedDB does
150
+ the read and the `add` in one readwrite transaction, and it runs readwrite transactions on a
151
+ store one at a time across every connection, so two tabs can't both find the key empty. The
152
+ record stores expose it as `setIfAbsent`, which resolves to the record the key holds
153
+ afterwards. `@joystyk/identity` creates the guest with it.
154
+
155
+ ## Tests
156
+
157
+ - `src/*.test.ts` run in Node against the memory backend: decoding, migrations, archive and
158
+ discard, quota retry, saves, stats, streaks, preferences and game data.
159
+ - `src/browser/*.browser.test.ts` run in real Chromium: opening and upgrading the database,
160
+ `versionchange` from another connection, corrupt and non-JSON records written behind the
161
+ store's back, quota errors and the persistent-storage request. `cross-tab.browser.test.ts`
162
+ stands two storages on one database for two tabs and checks that a preference change
163
+ reaches the other, over `BroadcastChannel` and over the `storage` event from a same-origin
164
+ iframe (Vitest's browser mode can't open a second page).
165
+
166
+ Chromium's real quota can't be reached in these tests: Playwright's browser contexts are off
167
+ the record, and `Storage.overrideQuotaForOrigin` over CDP leaves their quota unchanged. The
168
+ quota tests make `IDBObjectStore.put` throw the browser's own `QuotaExceededError` instead,
169
+ and everything around it runs for real.
@@ -0,0 +1,64 @@
1
+ import type { GameError, Json, Result } from '@joystyk/contracts';
2
+ /**
3
+ * The object stores every backend provides. IndexedDB creates one object store per name;
4
+ * the memory backend keeps one map per name.
5
+ */
6
+ export declare const STORE_NAMES: readonly ["saves", "stats", "streaks", "preferences", "game-data", "identity", "archive"];
7
+ /** One of the backend's object stores. */
8
+ export type StoreName = (typeof STORE_NAMES)[number];
9
+ /** Why a storage operation failed. */
10
+ export type StorageErrorCode =
11
+ /** The browser refused the write because the origin's storage is full. */
12
+ 'QuotaExceeded'
13
+ /** Storage can't be opened: private browsing, blocked site data, or no IndexedDB at all. */
14
+ | 'Unavailable'
15
+ /** The value isn't JSON or doesn't match its record's schema; nothing was written. */
16
+ | 'InvalidValue'
17
+ /** A key part is empty, too long or contains characters keys may not use. */
18
+ | 'InvalidKey'
19
+ /** Anything else the backend reported. */
20
+ | 'Failed';
21
+ /** An expected storage failure, worded for players. Shaped like `GameError`. */
22
+ export type StorageError = GameError & {
23
+ readonly code: StorageErrorCode;
24
+ };
25
+ /** Builds a `StorageError`, leaving out an absent `detail` so the error stays JSON. */
26
+ export declare const storageError: (code: StorageErrorCode, message: string, detail?: Json) => StorageError;
27
+ /** Player-facing wording for each failure. */
28
+ export declare const MESSAGES: {
29
+ readonly QuotaExceeded: "Your browser's storage for this site is full, so this couldn't be saved.";
30
+ readonly Unavailable: "This browser isn't letting the site store data, so progress won't be kept.";
31
+ readonly InvalidValue: "This couldn't be saved because it isn't in a form the site can store.";
32
+ readonly InvalidKey: "This couldn't be saved because its name isn't valid.";
33
+ readonly Failed: "Something went wrong while saving or loading.";
34
+ };
35
+ /** What `putIfAbsent` found: `written`, or the value another write left at the key. */
36
+ export type PutIfAbsent = {
37
+ readonly written: true;
38
+ } | {
39
+ readonly written: false;
40
+ readonly value: unknown;
41
+ };
42
+ /**
43
+ * Raw key-value persistence: the only part that differs between IndexedDB and memory.
44
+ * Values are JSON. Every operation resolves to a `Result`; expected failures, such as a full
45
+ * quota, never reject. Keys are strings, ordered by code unit, and `keys` lists those that
46
+ * start with a prefix.
47
+ */
48
+ export interface StorageBackend {
49
+ /** `undefined` when the key is absent. The value is untrusted: decode it before use. */
50
+ get(store: StoreName, key: string): Promise<Result<unknown, StorageError>>;
51
+ put(store: StoreName, key: string, value: Json): Promise<Result<void, StorageError>>;
52
+ /**
53
+ * Writes `value` only when `key` is absent, as one atomic step: of two callers racing, even
54
+ * in two tabs, exactly one writes. Resolves to whether this call wrote, and the value the
55
+ * key holds afterwards (untrusted when another caller wrote it).
56
+ */
57
+ putIfAbsent(store: StoreName, key: string, value: Json): Promise<Result<PutIfAbsent, StorageError>>;
58
+ delete(store: StoreName, key: string): Promise<Result<void, StorageError>>;
59
+ /** The keys that start with `prefix`, in ascending order. */
60
+ keys(store: StoreName, prefix: string): Promise<Result<readonly string[], StorageError>>;
61
+ clear(store: StoreName): Promise<Result<void, StorageError>>;
62
+ /** Releases the connection. Idempotent. */
63
+ close(): void;
64
+ }
@@ -0,0 +1,35 @@
1
+ //#region src/backend.ts
2
+ /**
3
+ * The object stores every backend provides. IndexedDB creates one object store per name;
4
+ * the memory backend keeps one map per name.
5
+ */
6
+ var STORE_NAMES = [
7
+ "saves",
8
+ "stats",
9
+ "streaks",
10
+ "preferences",
11
+ "game-data",
12
+ "identity",
13
+ "archive"
14
+ ];
15
+ /** Builds a `StorageError`, leaving out an absent `detail` so the error stays JSON. */
16
+ var storageError = (code, message, detail) => detail === void 0 ? {
17
+ code,
18
+ message
19
+ } : {
20
+ code,
21
+ message,
22
+ detail
23
+ };
24
+ /** Player-facing wording for each failure. */
25
+ var MESSAGES = {
26
+ QuotaExceeded: "Your browser's storage for this site is full, so this couldn't be saved.",
27
+ Unavailable: "This browser isn't letting the site store data, so progress won't be kept.",
28
+ InvalidValue: "This couldn't be saved because it isn't in a form the site can store.",
29
+ InvalidKey: "This couldn't be saved because its name isn't valid.",
30
+ Failed: "Something went wrong while saving or loading."
31
+ };
32
+ //#endregion
33
+ export { MESSAGES, STORE_NAMES, storageError };
34
+
35
+ //# sourceMappingURL=backend.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend.js","names":[],"sources":["../src/backend.ts"],"sourcesContent":["import type { GameError, Json, Result } from '@joystyk/contracts';\n\n/**\n * The object stores every backend provides. IndexedDB creates one object store per name;\n * the memory backend keeps one map per name.\n */\nexport const STORE_NAMES = ['saves', 'stats', 'streaks', 'preferences', 'game-data', 'identity', 'archive'] as const;\n\n/** One of the backend's object stores. */\nexport type StoreName = (typeof STORE_NAMES)[number];\n\n/** Why a storage operation failed. */\nexport type StorageErrorCode =\n /** The browser refused the write because the origin's storage is full. */\n | 'QuotaExceeded'\n /** Storage can't be opened: private browsing, blocked site data, or no IndexedDB at all. */\n | 'Unavailable'\n /** The value isn't JSON or doesn't match its record's schema; nothing was written. */\n | 'InvalidValue'\n /** A key part is empty, too long or contains characters keys may not use. */\n | 'InvalidKey'\n /** Anything else the backend reported. */\n | 'Failed';\n\n/** An expected storage failure, worded for players. Shaped like `GameError`. */\nexport type StorageError = GameError & { readonly code: StorageErrorCode };\n\n/** Builds a `StorageError`, leaving out an absent `detail` so the error stays JSON. */\nexport const storageError = (code: StorageErrorCode, message: string, detail?: Json): StorageError =>\n detail === undefined ? { code, message } : { code, message, detail };\n\n/** Player-facing wording for each failure. */\nexport const MESSAGES = {\n QuotaExceeded: \"Your browser's storage for this site is full, so this couldn't be saved.\",\n Unavailable: \"This browser isn't letting the site store data, so progress won't be kept.\",\n InvalidValue: \"This couldn't be saved because it isn't in a form the site can store.\",\n InvalidKey: \"This couldn't be saved because its name isn't valid.\",\n Failed: 'Something went wrong while saving or loading.',\n} as const satisfies Record<StorageErrorCode, string>;\n\n/** What `putIfAbsent` found: `written`, or the value another write left at the key. */\nexport type PutIfAbsent = { readonly written: true } | { readonly written: false; readonly value: unknown };\n\n/**\n * Raw key-value persistence: the only part that differs between IndexedDB and memory.\n * Values are JSON. Every operation resolves to a `Result`; expected failures, such as a full\n * quota, never reject. Keys are strings, ordered by code unit, and `keys` lists those that\n * start with a prefix.\n */\nexport interface StorageBackend {\n /** `undefined` when the key is absent. The value is untrusted: decode it before use. */\n get(store: StoreName, key: string): Promise<Result<unknown, StorageError>>;\n put(store: StoreName, key: string, value: Json): Promise<Result<void, StorageError>>;\n /**\n * Writes `value` only when `key` is absent, as one atomic step: of two callers racing, even\n * in two tabs, exactly one writes. Resolves to whether this call wrote, and the value the\n * key holds afterwards (untrusted when another caller wrote it).\n */\n putIfAbsent(store: StoreName, key: string, value: Json): Promise<Result<PutIfAbsent, StorageError>>;\n delete(store: StoreName, key: string): Promise<Result<void, StorageError>>;\n /** The keys that start with `prefix`, in ascending order. */\n keys(store: StoreName, prefix: string): Promise<Result<readonly string[], StorageError>>;\n clear(store: StoreName): Promise<Result<void, StorageError>>;\n /** Releases the connection. Idempotent. */\n close(): void;\n}\n"],"mappings":";;;;;AAMA,IAAa,cAAc;CAAC;CAAS;CAAS;CAAW;CAAe;CAAa;CAAY;AAAS;;AAsB1G,IAAa,gBAAgB,MAAwB,SAAiB,WACpE,WAAW,KAAA,IAAY;CAAE;CAAM;AAAQ,IAAI;CAAE;CAAM;CAAS;AAAO;;AAGrE,IAAa,WAAW;CACtB,eAAe;CACf,aAAa;CACb,cAAc;CACd,YAAY;CACZ,QAAQ;AACV"}
@@ -0,0 +1,17 @@
1
+ import type { ChangeFeed } from '../changes.js';
2
+ /** The channel name, and the localStorage key the fallback writes. */
3
+ export declare const CHANGE_CHANNEL = "joystyk-storage";
4
+ /** What `openChangeFeed` needs from a window. */
5
+ export type ChangeFeedHost = Pick<Window, 'localStorage' | 'addEventListener' | 'removeEventListener'> & {
6
+ readonly BroadcastChannel?: typeof BroadcastChannel;
7
+ };
8
+ /** A feed over BroadcastChannel, which delivers to every other channel of the same name. */
9
+ export declare const broadcastChangeFeed: (Channel: typeof BroadcastChannel, name?: string) => ChangeFeed;
10
+ /**
11
+ * The fallback: writing to localStorage fires a `storage` event in every other same-origin
12
+ * document. Each write is unique, so a repeated change still fires, and the key is removed
13
+ * at once, so nothing stays behind.
14
+ */
15
+ export declare const storageEventChangeFeed: (host: Pick<ChangeFeedHost, "localStorage" | "addEventListener" | "removeEventListener">, key?: string) => ChangeFeed;
16
+ /** BroadcastChannel when the browser has it, then the `storage` event, then nothing. */
17
+ export declare const openChangeFeed: (host?: ChangeFeedHost) => ChangeFeed;
@@ -0,0 +1,96 @@
1
+ //#region src/browser/changes.ts
2
+ /** The channel name, and the localStorage key the fallback writes. */
3
+ var CHANGE_CHANNEL = "joystyk-storage";
4
+ var listenerSet = () => {
5
+ const listeners = /* @__PURE__ */ new Set();
6
+ return {
7
+ deliver: (message) => {
8
+ for (const listener of [...listeners]) listener(message);
9
+ },
10
+ subscribe: (listener) => {
11
+ const entry = (message) => listener(message);
12
+ listeners.add(entry);
13
+ return () => {
14
+ listeners.delete(entry);
15
+ };
16
+ },
17
+ clear: () => listeners.clear()
18
+ };
19
+ };
20
+ /** A feed over BroadcastChannel, which delivers to every other channel of the same name. */
21
+ var broadcastChangeFeed = (Channel, name = CHANGE_CHANNEL) => {
22
+ const listeners = listenerSet();
23
+ let channel = new Channel(name);
24
+ channel.onmessage = (event) => listeners.deliver(event.data);
25
+ return {
26
+ publish: (change) => {
27
+ try {
28
+ channel?.postMessage(change);
29
+ } catch {}
30
+ },
31
+ subscribe: listeners.subscribe,
32
+ close: () => {
33
+ listeners.clear();
34
+ channel?.close();
35
+ channel = null;
36
+ }
37
+ };
38
+ };
39
+ /**
40
+ * The fallback: writing to localStorage fires a `storage` event in every other same-origin
41
+ * document. Each write is unique, so a repeated change still fires, and the key is removed
42
+ * at once, so nothing stays behind.
43
+ */
44
+ var storageEventChangeFeed = (host, key = CHANGE_CHANNEL) => {
45
+ const listeners = listenerSet();
46
+ let sent = 0;
47
+ let open = true;
48
+ const onStorage = (event) => {
49
+ if (event.key !== key || event.newValue === null) return;
50
+ let message;
51
+ try {
52
+ message = JSON.parse(event.newValue)?.change;
53
+ } catch {
54
+ return;
55
+ }
56
+ listeners.deliver(message);
57
+ };
58
+ host.addEventListener("storage", onStorage);
59
+ return {
60
+ publish: (change) => {
61
+ if (!open) return;
62
+ try {
63
+ sent += 1;
64
+ host.localStorage.setItem(key, JSON.stringify({
65
+ change,
66
+ write: `${Date.now()}-${sent}-${Math.random()}`
67
+ }));
68
+ host.localStorage.removeItem(key);
69
+ } catch {}
70
+ },
71
+ subscribe: listeners.subscribe,
72
+ close: () => {
73
+ open = false;
74
+ listeners.clear();
75
+ host.removeEventListener("storage", onStorage);
76
+ }
77
+ };
78
+ };
79
+ /** A feed that tells nobody, where neither BroadcastChannel nor localStorage works. */
80
+ var silentFeed = () => ({
81
+ publish: () => {},
82
+ subscribe: () => () => {},
83
+ close: () => {}
84
+ });
85
+ /** BroadcastChannel when the browser has it, then the `storage` event, then nothing. */
86
+ var openChangeFeed = (host = window) => {
87
+ if (typeof host.BroadcastChannel === "function") return broadcastChangeFeed(host.BroadcastChannel);
88
+ try {
89
+ if (typeof host.localStorage?.setItem === "function") return storageEventChangeFeed(host);
90
+ } catch {}
91
+ return silentFeed();
92
+ };
93
+ //#endregion
94
+ export { CHANGE_CHANNEL, broadcastChangeFeed, openChangeFeed, storageEventChangeFeed };
95
+
96
+ //# sourceMappingURL=changes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.js","names":[],"sources":["../../src/browser/changes.ts"],"sourcesContent":["// The browser's change feeds (gp-d4v): BroadcastChannel where it exists, and the `storage`\n// event where it doesn't. Both reach every other same-origin tab and frame, never the sender.\nimport type { ChangeFeed, StorageChange } from '../changes.ts';\n\n/** The channel name, and the localStorage key the fallback writes. */\nexport const CHANGE_CHANNEL = 'joystyk-storage';\n\ntype Listener = (message: unknown) => void;\n\n/** What `openChangeFeed` needs from a window. */\nexport type ChangeFeedHost = Pick<Window, 'localStorage' | 'addEventListener' | 'removeEventListener'> & {\n readonly BroadcastChannel?: typeof BroadcastChannel;\n};\n\nconst listenerSet = () => {\n const listeners = new Set<Listener>();\n return {\n deliver: (message: unknown): void => {\n for (const listener of [...listeners]) listener(message);\n },\n subscribe: (listener: Listener): (() => void) => {\n const entry: Listener = (message) => listener(message);\n listeners.add(entry);\n return () => {\n listeners.delete(entry);\n };\n },\n clear: (): void => listeners.clear(),\n };\n};\n\n/** A feed over BroadcastChannel, which delivers to every other channel of the same name. */\nexport const broadcastChangeFeed = (Channel: typeof BroadcastChannel, name: string = CHANGE_CHANNEL): ChangeFeed => {\n const listeners = listenerSet();\n let channel: BroadcastChannel | null = new Channel(name);\n channel.onmessage = (event: MessageEvent) => listeners.deliver(event.data);\n return {\n publish: (change: StorageChange) => {\n try {\n channel?.postMessage(change);\n } catch {\n // A closed or broken channel: the other tabs aren't told.\n }\n },\n subscribe: listeners.subscribe,\n close: () => {\n listeners.clear();\n channel?.close();\n channel = null;\n },\n };\n};\n\n/**\n * The fallback: writing to localStorage fires a `storage` event in every other same-origin\n * document. Each write is unique, so a repeated change still fires, and the key is removed\n * at once, so nothing stays behind.\n */\nexport const storageEventChangeFeed = (\n host: Pick<ChangeFeedHost, 'localStorage' | 'addEventListener' | 'removeEventListener'>,\n key: string = CHANGE_CHANNEL,\n): ChangeFeed => {\n const listeners = listenerSet();\n let sent = 0;\n let open = true;\n const onStorage = (event: StorageEvent): void => {\n if (event.key !== key || event.newValue === null) return;\n let message: unknown;\n try {\n message = (JSON.parse(event.newValue) as { readonly change?: unknown } | null)?.change;\n } catch {\n return;\n }\n listeners.deliver(message);\n };\n host.addEventListener('storage', onStorage);\n return {\n publish: (change: StorageChange) => {\n if (!open) return;\n try {\n sent += 1;\n host.localStorage.setItem(key, JSON.stringify({ change, write: `${Date.now()}-${sent}-${Math.random()}` }));\n host.localStorage.removeItem(key);\n } catch {\n // localStorage is blocked or full: the other tabs aren't told.\n }\n },\n subscribe: listeners.subscribe,\n close: () => {\n open = false;\n listeners.clear();\n host.removeEventListener('storage', onStorage);\n },\n };\n};\n\n/** A feed that tells nobody, where neither BroadcastChannel nor localStorage works. */\nconst silentFeed = (): ChangeFeed => ({ publish: () => {}, subscribe: () => () => {}, close: () => {} });\n\n/** BroadcastChannel when the browser has it, then the `storage` event, then nothing. */\nexport const openChangeFeed = (host: ChangeFeedHost = window): ChangeFeed => {\n if (typeof host.BroadcastChannel === 'function') return broadcastChangeFeed(host.BroadcastChannel);\n try {\n // Reading localStorage throws where site data is blocked.\n if (typeof host.localStorage?.setItem === 'function') return storageEventChangeFeed(host);\n } catch {\n // Fall through.\n }\n return silentFeed();\n};\n"],"mappings":";;AAKA,IAAa,iBAAiB;AAS9B,IAAM,oBAAoB;CACxB,MAAM,4BAAY,IAAI,IAAc;CACpC,OAAO;EACL,UAAU,YAA2B;GACnC,KAAK,MAAM,YAAY,CAAC,GAAG,SAAS,GAAG,SAAS,OAAO;EACzD;EACA,YAAY,aAAqC;GAC/C,MAAM,SAAmB,YAAY,SAAS,OAAO;GACrD,UAAU,IAAI,KAAK;GACnB,aAAa;IACX,UAAU,OAAO,KAAK;GACxB;EACF;EACA,aAAmB,UAAU,MAAM;CACrC;AACF;;AAGA,IAAa,uBAAuB,SAAkC,OAAe,mBAA+B;CAClH,MAAM,YAAY,YAAY;CAC9B,IAAI,UAAmC,IAAI,QAAQ,IAAI;CACvD,QAAQ,aAAa,UAAwB,UAAU,QAAQ,MAAM,IAAI;CACzE,OAAO;EACL,UAAU,WAA0B;GAClC,IAAI;IACF,SAAS,YAAY,MAAM;GAC7B,QAAQ,CAER;EACF;EACA,WAAW,UAAU;EACrB,aAAa;GACX,UAAU,MAAM;GAChB,SAAS,MAAM;GACf,UAAU;EACZ;CACF;AACF;;;;;;AAOA,IAAa,0BACX,MACA,MAAc,mBACC;CACf,MAAM,YAAY,YAAY;CAC9B,IAAI,OAAO;CACX,IAAI,OAAO;CACX,MAAM,aAAa,UAA8B;EAC/C,IAAI,MAAM,QAAQ,OAAO,MAAM,aAAa,MAAM;EAClD,IAAI;EACJ,IAAI;GACF,UAAW,KAAK,MAAM,MAAM,QAAQ,CAAC,EAA2C;EAClF,QAAQ;GACN;EACF;EACA,UAAU,QAAQ,OAAO;CAC3B;CACA,KAAK,iBAAiB,WAAW,SAAS;CAC1C,OAAO;EACL,UAAU,WAA0B;GAClC,IAAI,CAAC,MAAM;GACX,IAAI;IACF,QAAQ;IACR,KAAK,aAAa,QAAQ,KAAK,KAAK,UAAU;KAAE;KAAQ,OAAO,GAAG,KAAK,IAAI,EAAE,GAAG,KAAK,GAAG,KAAK,OAAO;IAAI,CAAC,CAAC;IAC1G,KAAK,aAAa,WAAW,GAAG;GAClC,QAAQ,CAER;EACF;EACA,WAAW,UAAU;EACrB,aAAa;GACX,OAAO;GACP,UAAU,MAAM;GAChB,KAAK,oBAAoB,WAAW,SAAS;EAC/C;CACF;AACF;;AAGA,IAAM,oBAAgC;CAAE,eAAe,CAAC;CAAG,uBAAuB,CAAC;CAAG,aAAa,CAAC;AAAE;;AAGtG,IAAa,kBAAkB,OAAuB,WAAuB;CAC3E,IAAI,OAAO,KAAK,qBAAqB,YAAY,OAAO,oBAAoB,KAAK,gBAAgB;CACjG,IAAI;EAEF,IAAI,OAAO,KAAK,cAAc,YAAY,YAAY,OAAO,uBAAuB,IAAI;CAC1F,QAAQ,CAER;CACA,OAAO,WAAW;AACpB"}
@@ -0,0 +1,18 @@
1
+ import type { SystemEnvironment } from '../preferences.js';
2
+ import { type Storage, type StorageOptions } from '../storage.js';
3
+ import { type IndexedDbOptions } from './indexeddb.js';
4
+ export { broadcastChangeFeed, CHANGE_CHANNEL, openChangeFeed, storageEventChangeFeed, type ChangeFeedHost, } from './changes.js';
5
+ export { DATABASE_UPGRADES, openIndexedDbBackend, toStorageError, type DatabaseUpgrade, type IndexedDbOptions, } from './indexeddb.js';
6
+ export { estimateStorage, isStoragePersisted, requestPersistentStorage, type PersistResult, type StorageManagerLike, } from './persist.js';
7
+ /**
8
+ * Opens storage over IndexedDB, with a change feed (`openChangeFeed`) that keeps other tabs'
9
+ * preferences in step; pass `changes` to use another. Where IndexedDB is missing or refused
10
+ * (some private modes, blocked site data), it falls back to memory so the site still works,
11
+ * and sends an `Unavailable` notice: progress lasts only until the tab closes. Memory isn't
12
+ * shared between tabs, so that storage has no change feed.
13
+ */
14
+ export declare const openBrowserStorage: (options?: Omit<StorageOptions, "backend"> & {
15
+ readonly indexedDb?: IndexedDbOptions;
16
+ }) => Promise<Storage>;
17
+ /** Reads the system settings that `resolvePreferences` falls back on. */
18
+ export declare const systemEnvironment: (view?: Window) => SystemEnvironment;
@@ -0,0 +1,44 @@
1
+ import { MESSAGES } from "../backend.js";
2
+ import { CHANGE_CHANNEL, broadcastChangeFeed, openChangeFeed, storageEventChangeFeed } from "./changes.js";
3
+ import { createMemoryBackend } from "../memory.js";
4
+ import { openStorage } from "../storage.js";
5
+ import { DATABASE_UPGRADES, openIndexedDbBackend, toStorageError } from "./indexeddb.js";
6
+ import { estimateStorage, isStoragePersisted, requestPersistentStorage } from "./persist.js";
7
+ //#region src/browser/index.ts
8
+ /**
9
+ * Opens storage over IndexedDB, with a change feed (`openChangeFeed`) that keeps other tabs'
10
+ * preferences in step; pass `changes` to use another. Where IndexedDB is missing or refused
11
+ * (some private modes, blocked site data), it falls back to memory so the site still works,
12
+ * and sends an `Unavailable` notice: progress lasts only until the tab closes. Memory isn't
13
+ * shared between tabs, so that storage has no change feed.
14
+ */
15
+ var openBrowserStorage = async (options = {}) => {
16
+ const backend = await openIndexedDbBackend(options.indexedDb);
17
+ if (backend.ok) return openStorage({
18
+ ...options,
19
+ backend: backend.value,
20
+ changes: options.changes ?? openChangeFeed()
21
+ });
22
+ options.onNotice?.({
23
+ code: "Unavailable",
24
+ message: MESSAGES.Unavailable,
25
+ store: null,
26
+ key: null,
27
+ issues: [`${backend.error.code}: ${String(backend.error.detail ?? backend.error.message)}`]
28
+ });
29
+ const { changes: _unshared, ...rest } = options;
30
+ return openStorage({
31
+ ...rest,
32
+ backend: createMemoryBackend()
33
+ });
34
+ };
35
+ /** Reads the system settings that `resolvePreferences` falls back on. */
36
+ var systemEnvironment = (view = window) => ({
37
+ prefersReducedMotion: view.matchMedia("(prefers-reduced-motion: reduce)").matches,
38
+ prefersDark: view.matchMedia("(prefers-color-scheme: dark)").matches,
39
+ locale: view.navigator.language
40
+ });
41
+ //#endregion
42
+ export { CHANGE_CHANNEL, DATABASE_UPGRADES, broadcastChangeFeed, estimateStorage, isStoragePersisted, openBrowserStorage, openChangeFeed, openIndexedDbBackend, requestPersistentStorage, storageEventChangeFeed, systemEnvironment, toStorageError };
43
+
44
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/browser/index.ts"],"sourcesContent":["// @joystyk/storage/browser: the browser-only parts. IndexedDB, persistent storage, and the\n// system settings that preferences resolve against.\nimport { createMemoryBackend } from '../memory.ts';\nimport { MESSAGES } from '../backend.ts';\nimport type { SystemEnvironment } from '../preferences.ts';\nimport { openStorage, type Storage, type StorageOptions } from '../storage.ts';\nimport { openChangeFeed } from './changes.ts';\nimport { openIndexedDbBackend, type IndexedDbOptions } from './indexeddb.ts';\n\nexport {\n broadcastChangeFeed,\n CHANGE_CHANNEL,\n openChangeFeed,\n storageEventChangeFeed,\n type ChangeFeedHost,\n} from './changes.ts';\nexport {\n DATABASE_UPGRADES,\n openIndexedDbBackend,\n toStorageError,\n type DatabaseUpgrade,\n type IndexedDbOptions,\n} from './indexeddb.ts';\nexport {\n estimateStorage,\n isStoragePersisted,\n requestPersistentStorage,\n type PersistResult,\n type StorageManagerLike,\n} from './persist.ts';\n\n/**\n * Opens storage over IndexedDB, with a change feed (`openChangeFeed`) that keeps other tabs'\n * preferences in step; pass `changes` to use another. Where IndexedDB is missing or refused\n * (some private modes, blocked site data), it falls back to memory so the site still works,\n * and sends an `Unavailable` notice: progress lasts only until the tab closes. Memory isn't\n * shared between tabs, so that storage has no change feed.\n */\nexport const openBrowserStorage = async (\n options: Omit<StorageOptions, 'backend'> & { readonly indexedDb?: IndexedDbOptions } = {},\n): Promise<Storage> => {\n const backend = await openIndexedDbBackend(options.indexedDb);\n if (backend.ok)\n return openStorage({ ...options, backend: backend.value, changes: options.changes ?? openChangeFeed() });\n options.onNotice?.({\n code: 'Unavailable',\n message: MESSAGES.Unavailable,\n store: null,\n key: null,\n issues: [`${backend.error.code}: ${String(backend.error.detail ?? backend.error.message)}`],\n });\n const { changes: _unshared, ...rest } = options;\n return openStorage({ ...rest, backend: createMemoryBackend() });\n};\n\n/** Reads the system settings that `resolvePreferences` falls back on. */\nexport const systemEnvironment = (view: Window = window): SystemEnvironment => ({\n prefersReducedMotion: view.matchMedia('(prefers-reduced-motion: reduce)').matches,\n prefersDark: view.matchMedia('(prefers-color-scheme: dark)').matches,\n locale: view.navigator.language,\n});\n"],"mappings":";;;;;;;;;;;;;;AAsCA,IAAa,qBAAqB,OAChC,UAAuF,CAAC,MACnE;CACrB,MAAM,UAAU,MAAM,qBAAqB,QAAQ,SAAS;CAC5D,IAAI,QAAQ,IACV,OAAO,YAAY;EAAE,GAAG;EAAS,SAAS,QAAQ;EAAO,SAAS,QAAQ,WAAW,eAAe;CAAE,CAAC;CACzG,QAAQ,WAAW;EACjB,MAAM;EACN,SAAS,SAAS;EAClB,OAAO;EACP,KAAK;EACL,QAAQ,CAAC,GAAG,QAAQ,MAAM,KAAK,IAAI,OAAO,QAAQ,MAAM,UAAU,QAAQ,MAAM,OAAO,GAAG;CAC5F,CAAC;CACD,MAAM,EAAE,SAAS,WAAW,GAAG,SAAS;CACxC,OAAO,YAAY;EAAE,GAAG;EAAM,SAAS,oBAAoB;CAAE,CAAC;AAChE;;AAGA,IAAa,qBAAqB,OAAe,YAA+B;CAC9E,sBAAsB,KAAK,WAAW,kCAAkC,CAAC,CAAC;CAC1E,aAAa,KAAK,WAAW,8BAA8B,CAAC,CAAC;CAC7D,QAAQ,KAAK,UAAU;AACzB"}
@@ -0,0 +1,40 @@
1
+ import { type Result } from '@joystyk/contracts';
2
+ import { type StorageBackend, type StorageError } from '../backend.js';
3
+ /** One step of the database's own schema: which object stores exist. */
4
+ export type DatabaseUpgrade = {
5
+ /** The database version this step produces, from 1, with no gaps. */
6
+ readonly version: number;
7
+ /** Runs inside the `versionchange` transaction. Create or delete object stores here. */
8
+ upgrade(db: IDBDatabase, transaction: IDBTransaction): void;
9
+ };
10
+ /**
11
+ * The database schema, oldest first. Record shapes are versioned per record (see
12
+ * `RecordKind`); this list changes only when an object store is added or removed.
13
+ */
14
+ export declare const DATABASE_UPGRADES: readonly DatabaseUpgrade[];
15
+ export type IndexedDbOptions = {
16
+ /** Defaults to "joystyk". */
17
+ readonly name?: string;
18
+ /** Defaults to the global `indexedDB`. */
19
+ readonly factory?: IDBFactory;
20
+ /** Defaults to `DATABASE_UPGRADES`. */
21
+ readonly upgrades?: readonly DatabaseUpgrade[];
22
+ /**
23
+ * Another tab holds an older connection open, so the upgrade waits. The frame can ask the
24
+ * player to close other tabs of the site.
25
+ */
26
+ readonly onBlocked?: () => void;
27
+ /**
28
+ * Another tab needs a newer database version. This connection closes so that tab can
29
+ * upgrade, and later operations fail with `Unavailable`; the frame should offer a reload.
30
+ */
31
+ readonly onVersionChange?: () => void;
32
+ };
33
+ /** Maps a DOMException (or anything thrown) to a StorageError. */
34
+ export declare const toStorageError: (cause: unknown) => StorageError;
35
+ /**
36
+ * Opens (creating or upgrading) the database and returns a backend over it. Runs every
37
+ * upgrade step newer than the stored version, in order, inside one `versionchange`
38
+ * transaction. Resolves to `Unavailable` where IndexedDB is missing or refused.
39
+ */
40
+ export declare const openIndexedDbBackend: (options?: IndexedDbOptions) => Promise<Result<StorageBackend, StorageError>>;