@joystyk/storage 0.0.0 → 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/README.md +166 -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 +45 -11
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/README.md
CHANGED
|
@@ -1,5 +1,169 @@
|
|
|
1
1
|
# @joystyk/storage
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
6
|
|
|
5
|
-
|
|
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
|
+
}
|
package/dist/backend.js
ADDED
|
@@ -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>>;
|