@protoyard/database 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +92 -2
- package/dist/backend.d.ts +17 -0
- package/dist/backend.js +31 -0
- package/dist/control-store.d.ts +59 -0
- package/dist/control-store.js +116 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +70 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/names.d.ts +38 -0
- package/dist/names.js +70 -0
- package/dist/prototype-database.d.ts +34 -0
- package/dist/prototype-database.js +82 -0
- package/dist/storage.d.ts +48 -0
- package/dist/storage.js +147 -0
- package/dist/world-io.d.ts +21 -0
- package/dist/world-io.js +84 -0
- package/package.json +54 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dailybot, Inc.
|
|
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/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @protoyard/database
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
IndexedDB persistence for Protoyard prototypes, built on [Dexie 4](https://dexie.org).
|
|
4
|
+
Each prototype owns exactly two databases, named from its manifest namespace:
|
|
5
|
+
|
|
6
|
+
| Database | Contents |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `protoyard.<namespace>.data` | the prototype's domain tables + reserved `py_organizations`, `py_personas`, `py_memberships`, `py_events` |
|
|
9
|
+
| `protoyard.<namespace>.control` | active scenario, overrides, clock, actor/org, timeline checkpoint, per-persona preferences, snapshots |
|
|
10
|
+
|
|
11
|
+
Keeping them apart means session, theme or snapshot bookkeeping can never touch domain
|
|
12
|
+
rows, and loading a scenario never deletes saved snapshots.
|
|
13
|
+
|
|
14
|
+
## API
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import {
|
|
18
|
+
openPrototypeStorage, // both DBs + StorageStatus, with honest in-memory fallback
|
|
19
|
+
openPrototypeDatabase, // data DB only (runs migrations)
|
|
20
|
+
openControlStore,
|
|
21
|
+
dumpTables, restoreTables, clearWorld, countTables,
|
|
22
|
+
deleteProtoyardDatabases,
|
|
23
|
+
probeStorage, requestPersistence,
|
|
24
|
+
StorageError, mapStorageError,
|
|
25
|
+
liveQuery,
|
|
26
|
+
} from '@protoyard/database';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Data database and migrations
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const db = await openPrototypeDatabase(definition); // { manifest.storage, database.versions }
|
|
33
|
+
const deals = db.rows<Deal>('deals'); // typed Dexie table; unknown names throw
|
|
34
|
+
db.domainTables; // ['companies', 'deals']
|
|
35
|
+
db.allTables; // domain + reserved
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- Every `database.versions` entry is declared in order; the reserved tables are merged into
|
|
39
|
+
every version. An `upgrade(tx)` runs through Dexie's `.upgrade()` with a Dexie `Transaction`.
|
|
40
|
+
- Tables starting with `py_` are reserved and rejected.
|
|
41
|
+
- Opening an **older** database migrates it. Opening a database written by **newer** code
|
|
42
|
+
throws `StorageError('version-newer')` and deletes nothing (Dexie 4 would otherwise open
|
|
43
|
+
it silently, so the native version is compared explicitly).
|
|
44
|
+
|
|
45
|
+
### Control store
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const control = await openControlStore('crm-demo');
|
|
49
|
+
await control.writeState({ actorId: 'user_0002', overrides: null }); // one transaction; null deletes
|
|
50
|
+
await control.setPreference('user_0002', { themeId: 'ocean', mode: 'dark' });
|
|
51
|
+
await control.putSnapshot(snapshot); // summary and table payload stored separately
|
|
52
|
+
await control.listSnapshots(); // newest first, never loads rows
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Snapshot I/O
|
|
56
|
+
|
|
57
|
+
- `dumpTables(db)` reads every domain + reserved table in one read transaction.
|
|
58
|
+
- `restoreTables(db, tables, { knownTables })` clears and refills **all** known tables in ONE
|
|
59
|
+
read-write transaction. Unknown tables are skipped and reported. Any failure aborts the
|
|
60
|
+
transaction, so current data stays untouched.
|
|
61
|
+
- `clearWorld(db)` empties domain + reserved tables in one transaction.
|
|
62
|
+
|
|
63
|
+
### Storage status, errors and degraded mode
|
|
64
|
+
|
|
65
|
+
`StorageError.code` is one of `unavailable | quota-exceeded | version-newer | blocked | unknown`,
|
|
66
|
+
with a friendly `message`. `mapStorageError(err)` recognises DOM and Dexie errors, including
|
|
67
|
+
errors wrapped in Dexie's `inner` (for example `OpenFailedError` → `InvalidStateError` in
|
|
68
|
+
Firefox private mode).
|
|
69
|
+
|
|
70
|
+
`openPrototypeStorage()` falls back to an in-memory IndexedDB (`fake-indexeddb`, loaded lazily
|
|
71
|
+
with a dynamic `import()` so it never ships in the normal path) **only** when IndexedDB is
|
|
72
|
+
unavailable. It then reports `{ backend: 'memory', persistent: false, warning }`. Quota,
|
|
73
|
+
version and blocked errors are thrown rather than hidden behind memory storage.
|
|
74
|
+
|
|
75
|
+
`probeStorage()` reports availability, `navigator.storage.estimate()` and `persisted()`.
|
|
76
|
+
`requestPersistence()` asks for persistent storage and returns `false` when refused.
|
|
77
|
+
|
|
78
|
+
`deleteProtoyardDatabases(namespace)` deletes only that prototype's two databases.
|
|
79
|
+
|
|
80
|
+
**Sandboxes:** `sandboxNamespace(namespace, name)` returns `<namespace>~<name>`; the `~`
|
|
81
|
+
never appears in a manifest namespace. `listProtoyardDatabases(namespace)` returns
|
|
82
|
+
`{ supported, databases, sandboxes }` using `indexedDB.databases()`, and reports
|
|
83
|
+
`supported: false` where the browser can't enumerate. `parseDatabaseName(name)` reads any
|
|
84
|
+
`protoyard.<namespace>[~<sandbox>].<data|control>` name.
|
|
85
|
+
|
|
86
|
+
## Tests
|
|
87
|
+
|
|
88
|
+
`pnpm vitest run packages/database` covers migrations, version-newer, atomic restore, the
|
|
89
|
+
control store, error mapping, memory fallback and namespace-scoped deletion.
|
|
90
|
+
|
|
91
|
+
## Package output
|
|
92
|
+
|
|
93
|
+
The manifest keeps TypeScript source exports for workspace development. `pnpm build:packages --filter engine` creates the ESM and declaration files under `dist/` that the packed manifest exposes.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which IndexedDB implementation to use. Omitted fields mean "the browser's".
|
|
3
|
+
* Tests and the degraded in-memory mode inject `fake-indexeddb` here.
|
|
4
|
+
*/
|
|
5
|
+
export interface StorageBackend {
|
|
6
|
+
indexedDB?: IDBFactory;
|
|
7
|
+
IDBKeyRange?: typeof IDBKeyRange;
|
|
8
|
+
}
|
|
9
|
+
export declare function resolveFactory(backend?: StorageBackend): IDBFactory;
|
|
10
|
+
/** Dexie constructor options for an explicit backend (empty = browser default). */
|
|
11
|
+
export declare function dexieBackendOptions(backend?: StorageBackend): StorageBackend;
|
|
12
|
+
/**
|
|
13
|
+
* In-memory IndexedDB for when the real one cannot open (private mode,
|
|
14
|
+
* disabled storage). Loaded lazily so `fake-indexeddb` never ships in the
|
|
15
|
+
* normal path; data lives only as long as the page.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadMemoryBackend(): Promise<Required<StorageBackend>>;
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { StorageError } from "./errors.js";
|
|
2
|
+
export function resolveFactory(backend = {}) {
|
|
3
|
+
const factory = backend.indexedDB ?? globalThis.indexedDB;
|
|
4
|
+
if (!factory) {
|
|
5
|
+
throw new StorageError('unavailable', {
|
|
6
|
+
message: 'IndexedDB is not available in this environment. Changes will not persist after you close the tab.',
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
return factory;
|
|
10
|
+
}
|
|
11
|
+
/** Dexie constructor options for an explicit backend (empty = browser default). */
|
|
12
|
+
export function dexieBackendOptions(backend = {}) {
|
|
13
|
+
const options = {};
|
|
14
|
+
if (backend.indexedDB)
|
|
15
|
+
options.indexedDB = backend.indexedDB;
|
|
16
|
+
if (backend.IDBKeyRange)
|
|
17
|
+
options.IDBKeyRange = backend.IDBKeyRange;
|
|
18
|
+
return options;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* In-memory IndexedDB for when the real one cannot open (private mode,
|
|
22
|
+
* disabled storage). Loaded lazily so `fake-indexeddb` never ships in the
|
|
23
|
+
* normal path; data lives only as long as the page.
|
|
24
|
+
*/
|
|
25
|
+
export async function loadMemoryBackend() {
|
|
26
|
+
const mod = await import('fake-indexeddb');
|
|
27
|
+
return {
|
|
28
|
+
indexedDB: new mod.IDBFactory(),
|
|
29
|
+
IDBKeyRange: mod.IDBKeyRange,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ActiveScenarioInfo, ClockState, ColorMode, Overrides, Snapshot, SnapshotSummary, Theme } from '@protoyard/core';
|
|
2
|
+
import { type StorageBackend } from './backend.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The control database holds everything ABOUT the world (active scenario,
|
|
5
|
+
* overrides, clock, session, preferences, snapshots) and never domain rows.
|
|
6
|
+
* Keeping it separate means appearance or session changes can never corrupt
|
|
7
|
+
* prototype data, and a scenario load never wipes saved snapshots.
|
|
8
|
+
*/
|
|
9
|
+
export interface TimelineCheckpoint {
|
|
10
|
+
/** Committed clock offset the timeline has been applied up to. */
|
|
11
|
+
committedOffsetMs: number;
|
|
12
|
+
/** ISO instant matching that offset (for humans and DevTools). */
|
|
13
|
+
at: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ControlState {
|
|
16
|
+
activeScenario: ActiveScenarioInfo;
|
|
17
|
+
overrides: Overrides;
|
|
18
|
+
clock: ClockState;
|
|
19
|
+
actorId: string;
|
|
20
|
+
organizationId: string;
|
|
21
|
+
/** Simulated sign-out (stored only while signed out). */
|
|
22
|
+
signedOut: boolean;
|
|
23
|
+
timelineCheckpoint: TimelineCheckpoint;
|
|
24
|
+
/** Theme Studio themes; re-validated with `ThemeSchema` on every boot. */
|
|
25
|
+
customThemes: Theme[];
|
|
26
|
+
}
|
|
27
|
+
export type ControlStateKey = keyof ControlState;
|
|
28
|
+
export interface PersonaPreference {
|
|
29
|
+
personaId: string;
|
|
30
|
+
themeId?: string;
|
|
31
|
+
mode?: ColorMode;
|
|
32
|
+
reducedMotion?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export type ControlStatePatch = {
|
|
35
|
+
[K in ControlStateKey]?: ControlState[K] | null;
|
|
36
|
+
};
|
|
37
|
+
export interface ControlStore {
|
|
38
|
+
readonly namespace: string;
|
|
39
|
+
readonly name: string;
|
|
40
|
+
getState<K extends ControlStateKey>(key: K): Promise<ControlState[K] | undefined>;
|
|
41
|
+
setState<K extends ControlStateKey>(key: K, value: ControlState[K]): Promise<void>;
|
|
42
|
+
deleteState(...keys: ControlStateKey[]): Promise<void>;
|
|
43
|
+
readState(): Promise<Partial<ControlState>>;
|
|
44
|
+
/** Writes several keys in one transaction; `null` deletes a key. */
|
|
45
|
+
writeState(patch: ControlStatePatch): Promise<void>;
|
|
46
|
+
getPreference(personaId: string): Promise<PersonaPreference | undefined>;
|
|
47
|
+
/** Merges into the stored preference; `undefined` fields are removed. */
|
|
48
|
+
setPreference(personaId: string, patch: Omit<PersonaPreference, 'personaId'>): Promise<PersonaPreference>;
|
|
49
|
+
listPreferences(): Promise<PersonaPreference[]>;
|
|
50
|
+
/** Newest first, without table payloads. */
|
|
51
|
+
listSnapshots(): Promise<SnapshotSummary[]>;
|
|
52
|
+
getSnapshot(id: string): Promise<Snapshot | undefined>;
|
|
53
|
+
putSnapshot(snapshot: Snapshot): Promise<void>;
|
|
54
|
+
deleteSnapshot(id: string): Promise<void>;
|
|
55
|
+
/** Clears state, preferences and snapshots. */
|
|
56
|
+
clearAll(): Promise<void>;
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
|
59
|
+
export declare function openControlStore(namespace: string, backend?: StorageBackend): Promise<ControlStore>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Dexie } from 'dexie';
|
|
2
|
+
import { dexieBackendOptions, resolveFactory } from "./backend.js";
|
|
3
|
+
import { mapStorageError } from "./errors.js";
|
|
4
|
+
import { controlDatabaseName } from "./names.js";
|
|
5
|
+
class ControlDatabase extends Dexie {
|
|
6
|
+
state;
|
|
7
|
+
preferences;
|
|
8
|
+
// Summaries and payloads are split so listing snapshots never loads rows.
|
|
9
|
+
snapshots;
|
|
10
|
+
snapshotTables;
|
|
11
|
+
constructor(namespace, backend) {
|
|
12
|
+
super(controlDatabaseName(namespace), dexieBackendOptions(backend));
|
|
13
|
+
this.version(1).stores({
|
|
14
|
+
state: 'key',
|
|
15
|
+
preferences: 'personaId',
|
|
16
|
+
snapshots: 'id, createdAt',
|
|
17
|
+
snapshotTables: 'id',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function wrap(promise) {
|
|
22
|
+
return promise.catch((error) => {
|
|
23
|
+
throw mapStorageError(error);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export async function openControlStore(namespace, backend = {}) {
|
|
27
|
+
resolveFactory(backend);
|
|
28
|
+
const db = new ControlDatabase(namespace, backend);
|
|
29
|
+
try {
|
|
30
|
+
await db.open();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
db.close();
|
|
34
|
+
throw mapStorageError(error);
|
|
35
|
+
}
|
|
36
|
+
const store = {
|
|
37
|
+
namespace,
|
|
38
|
+
name: db.name,
|
|
39
|
+
async getState(key) {
|
|
40
|
+
const row = await wrap(db.state.get(key));
|
|
41
|
+
return row?.value;
|
|
42
|
+
},
|
|
43
|
+
async setState(key, value) {
|
|
44
|
+
await wrap(db.state.put({ key, value }));
|
|
45
|
+
},
|
|
46
|
+
async deleteState(...keys) {
|
|
47
|
+
await wrap(db.state.bulkDelete(keys));
|
|
48
|
+
},
|
|
49
|
+
async readState() {
|
|
50
|
+
const rows = await wrap(db.state.toArray());
|
|
51
|
+
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
|
52
|
+
},
|
|
53
|
+
async writeState(patch) {
|
|
54
|
+
const puts = [];
|
|
55
|
+
const deletes = [];
|
|
56
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
if (value === null)
|
|
60
|
+
deletes.push(key);
|
|
61
|
+
else
|
|
62
|
+
puts.push({ key, value });
|
|
63
|
+
}
|
|
64
|
+
await wrap(db.transaction('rw', db.state, async () => {
|
|
65
|
+
if (deletes.length)
|
|
66
|
+
await db.state.bulkDelete(deletes);
|
|
67
|
+
if (puts.length)
|
|
68
|
+
await db.state.bulkPut(puts);
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
getPreference: (personaId) => wrap(db.preferences.get(personaId)),
|
|
72
|
+
async setPreference(personaId, patch) {
|
|
73
|
+
return wrap(db.transaction('rw', db.preferences, async () => {
|
|
74
|
+
const current = (await db.preferences.get(personaId)) ?? { personaId };
|
|
75
|
+
const next = { ...current, ...patch, personaId };
|
|
76
|
+
for (const key of Object.keys(next)) {
|
|
77
|
+
if (next[key] === undefined)
|
|
78
|
+
delete next[key];
|
|
79
|
+
}
|
|
80
|
+
await db.preferences.put(next);
|
|
81
|
+
return next;
|
|
82
|
+
}));
|
|
83
|
+
},
|
|
84
|
+
listPreferences: () => wrap(db.preferences.toArray()),
|
|
85
|
+
async listSnapshots() {
|
|
86
|
+
return wrap(db.snapshots.orderBy('createdAt').reverse().toArray());
|
|
87
|
+
},
|
|
88
|
+
async getSnapshot(id) {
|
|
89
|
+
const [summary, payload] = await wrap(db.transaction('r', db.snapshots, db.snapshotTables, () => Promise.all([db.snapshots.get(id), db.snapshotTables.get(id)])));
|
|
90
|
+
if (!summary)
|
|
91
|
+
return undefined;
|
|
92
|
+
return { ...summary, tables: payload?.tables ?? [] };
|
|
93
|
+
},
|
|
94
|
+
async putSnapshot(snapshot) {
|
|
95
|
+
const { tables, ...summary } = snapshot;
|
|
96
|
+
await wrap(db.transaction('rw', db.snapshots, db.snapshotTables, async () => {
|
|
97
|
+
await db.snapshots.put(summary);
|
|
98
|
+
await db.snapshotTables.put({ id: snapshot.id, tables });
|
|
99
|
+
}));
|
|
100
|
+
},
|
|
101
|
+
async deleteSnapshot(id) {
|
|
102
|
+
await wrap(db.transaction('rw', db.snapshots, db.snapshotTables, async () => {
|
|
103
|
+
await db.snapshots.delete(id);
|
|
104
|
+
await db.snapshotTables.delete(id);
|
|
105
|
+
}));
|
|
106
|
+
},
|
|
107
|
+
async clearAll() {
|
|
108
|
+
await wrap(db.transaction('rw', db.tables, async () => {
|
|
109
|
+
for (const table of db.tables)
|
|
110
|
+
await table.clear();
|
|
111
|
+
}));
|
|
112
|
+
},
|
|
113
|
+
close: () => db.close(),
|
|
114
|
+
};
|
|
115
|
+
return store;
|
|
116
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage failures surfaced to people, not stack traces. Every IndexedDB/Dexie
|
|
3
|
+
* failure the runtime can hit is mapped to one of these codes so the renderer
|
|
4
|
+
* can show a friendly, actionable message.
|
|
5
|
+
*/
|
|
6
|
+
export type StorageErrorCode = 'unavailable' | 'quota-exceeded' | 'version-newer' | 'blocked' | 'unknown';
|
|
7
|
+
export declare const STORAGE_ERROR_MESSAGES: Record<StorageErrorCode, string>;
|
|
8
|
+
export declare class StorageError extends Error {
|
|
9
|
+
readonly code: StorageErrorCode;
|
|
10
|
+
/** The original error, kept for DevTools diagnostics. */
|
|
11
|
+
readonly cause: unknown;
|
|
12
|
+
constructor(code: StorageErrorCode, options?: {
|
|
13
|
+
message?: string;
|
|
14
|
+
cause?: unknown;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export declare function isStorageError(error: unknown): error is StorageError;
|
|
18
|
+
/**
|
|
19
|
+
* Maps any thrown value to a {@link StorageError}. Dexie wraps the native
|
|
20
|
+
* failure in `inner` (e.g. `OpenFailedError` → `InvalidStateError`), so the
|
|
21
|
+
* chain is walked until a recognised name appears.
|
|
22
|
+
*/
|
|
23
|
+
export declare function mapStorageError(error: unknown): StorageError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage failures surfaced to people, not stack traces. Every IndexedDB/Dexie
|
|
3
|
+
* failure the runtime can hit is mapped to one of these codes so the renderer
|
|
4
|
+
* can show a friendly, actionable message.
|
|
5
|
+
*/
|
|
6
|
+
export const STORAGE_ERROR_MESSAGES = {
|
|
7
|
+
unavailable: 'Browser storage (IndexedDB) is not available here — private browsing or a strict privacy setting may be blocking it. Changes will not persist after you close the tab.',
|
|
8
|
+
'quota-exceeded': 'Browser storage is full. Delete old snapshots or reset a prototype to free space.',
|
|
9
|
+
'version-newer': 'This browser holds data from a newer version of this prototype. Update the prototype, or reset its data from DevTools. Nothing was deleted.',
|
|
10
|
+
blocked: 'The prototype database is open in another tab with an older version. Close the other tabs of this prototype and reload.',
|
|
11
|
+
unknown: 'The prototype database could not be used because of an unexpected storage error.',
|
|
12
|
+
};
|
|
13
|
+
export class StorageError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
/** The original error, kept for DevTools diagnostics. */
|
|
16
|
+
cause;
|
|
17
|
+
constructor(code, options = {}) {
|
|
18
|
+
super(options.message ?? STORAGE_ERROR_MESSAGES[code]);
|
|
19
|
+
this.name = 'StorageError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.cause = options.cause;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function isStorageError(error) {
|
|
25
|
+
return error instanceof StorageError;
|
|
26
|
+
}
|
|
27
|
+
/** Names of DOM exceptions and Dexie errors (Dexie names end with `Error`). */
|
|
28
|
+
const CODE_BY_NAME = {
|
|
29
|
+
QuotaExceededError: 'quota-exceeded',
|
|
30
|
+
VersionError: 'version-newer',
|
|
31
|
+
MissingAPIError: 'unavailable',
|
|
32
|
+
// Firefox private mode and Safari with storage disabled throw these on open.
|
|
33
|
+
// (Chrome's UnknownError — a transient/corrupt backing store on a profile that
|
|
34
|
+
// may hold real data — is deliberately NOT here: it must be shown, never
|
|
35
|
+
// silently replaced by an empty in-memory world.)
|
|
36
|
+
InvalidStateError: 'unavailable',
|
|
37
|
+
SecurityError: 'unavailable',
|
|
38
|
+
NotAllowedError: 'unavailable',
|
|
39
|
+
BlockedError: 'blocked',
|
|
40
|
+
VersionChangeError: 'blocked',
|
|
41
|
+
};
|
|
42
|
+
function errorName(error) {
|
|
43
|
+
if (error && typeof error === 'object' && 'name' in error) {
|
|
44
|
+
const name = error.name;
|
|
45
|
+
return typeof name === 'string' ? name : undefined;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Maps any thrown value to a {@link StorageError}. Dexie wraps the native
|
|
51
|
+
* failure in `inner` (e.g. `OpenFailedError` → `InvalidStateError`), so the
|
|
52
|
+
* chain is walked until a recognised name appears.
|
|
53
|
+
*/
|
|
54
|
+
export function mapStorageError(error) {
|
|
55
|
+
if (error instanceof StorageError)
|
|
56
|
+
return error;
|
|
57
|
+
let current = error;
|
|
58
|
+
for (let depth = 0; current && depth < 5; depth++) {
|
|
59
|
+
const name = errorName(current);
|
|
60
|
+
const code = name ? CODE_BY_NAME[name] : undefined;
|
|
61
|
+
if (code)
|
|
62
|
+
return new StorageError(code, { cause: error });
|
|
63
|
+
if (name && /quota/i.test(name))
|
|
64
|
+
return new StorageError('quota-exceeded', { cause: error });
|
|
65
|
+
current =
|
|
66
|
+
current.inner ??
|
|
67
|
+
current.cause;
|
|
68
|
+
}
|
|
69
|
+
return new StorageError('unknown', { cause: error });
|
|
70
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { Table, Transaction } from 'dexie';
|
|
2
|
+
export { Dexie, liveQuery } from 'dexie';
|
|
3
|
+
export { loadMemoryBackend, type StorageBackend } from './backend.ts';
|
|
4
|
+
export { type ControlState, type ControlStateKey, type ControlStatePatch, type ControlStore, openControlStore, type PersonaPreference, type TimelineCheckpoint, } from './control-store.ts';
|
|
5
|
+
export { isStorageError, mapStorageError, STORAGE_ERROR_MESSAGES, StorageError, type StorageErrorCode, } from './errors.ts';
|
|
6
|
+
export { allTableNames, controlDatabaseName, DATABASE_PREFIX, dataDatabaseName, domainTableNames, latestVersion, type ParsedDatabaseName, parseDatabaseName, RESERVED_PREFIX, RESERVED_TABLE_NAMES, reviewDatabaseName, SANDBOX_SEPARATOR, sandboxNamespace, } from './names.ts';
|
|
7
|
+
export { createPrototypeDatabase, type DatabaseSource, openPrototypeDatabase, PrototypeDatabase, } from './prototype-database.ts';
|
|
8
|
+
export { type DatabaseListing, deleteProtoyardDatabases, listProtoyardDatabases, MEMORY_STORAGE_WARNING, openPrototypeStorage, type PrototypeStorage, probeStorage, requestPersistence, } from './storage.ts';
|
|
9
|
+
export { clearWorld, countTables, dumpTables, type RestoreResult, restoreTables, type SnapshotTable, } from './world-io.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Dexie, liveQuery } from 'dexie';
|
|
2
|
+
export { loadMemoryBackend } from "./backend.js";
|
|
3
|
+
export { openControlStore, } from "./control-store.js";
|
|
4
|
+
export { isStorageError, mapStorageError, STORAGE_ERROR_MESSAGES, StorageError, } from "./errors.js";
|
|
5
|
+
export { allTableNames, controlDatabaseName, DATABASE_PREFIX, dataDatabaseName, domainTableNames, latestVersion, parseDatabaseName, RESERVED_PREFIX, RESERVED_TABLE_NAMES, reviewDatabaseName, SANDBOX_SEPARATOR, sandboxNamespace, } from "./names.js";
|
|
6
|
+
export { createPrototypeDatabase, openPrototypeDatabase, PrototypeDatabase, } from "./prototype-database.js";
|
|
7
|
+
export { deleteProtoyardDatabases, listProtoyardDatabases, MEMORY_STORAGE_WARNING, openPrototypeStorage, probeStorage, requestPersistence, } from "./storage.js";
|
|
8
|
+
export { clearWorld, countTables, dumpTables, restoreTables, } from "./world-io.js";
|
package/dist/names.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type DatabaseDefinition, RESERVED_TABLES } from '@protoyard/core';
|
|
2
|
+
/**
|
|
3
|
+
* Every prototype owns separate data, control, and review IndexedDB databases. Names are derived
|
|
4
|
+
* from the manifest namespace so prototypes on the same origin (the gallery
|
|
5
|
+
* serves them all from one host) never see each other's data.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DATABASE_PREFIX = "protoyard";
|
|
8
|
+
export declare function dataDatabaseName(namespace: string): string;
|
|
9
|
+
export declare function controlDatabaseName(namespace: string): string;
|
|
10
|
+
/** The prototype's local review comments live outside snapshots and scenario data. */
|
|
11
|
+
export declare function reviewDatabaseName(namespace: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Sandboxes are disposable copies of a prototype's world (`?sandbox=a`). Their
|
|
14
|
+
* namespace is `<namespace>~<sandbox>`: `~` never appears in a manifest
|
|
15
|
+
* namespace, so a sandbox can't collide with another prototype.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SANDBOX_SEPARATOR = "~";
|
|
18
|
+
export declare function sandboxNamespace(namespace: string, sandbox: string): string;
|
|
19
|
+
export interface ParsedDatabaseName {
|
|
20
|
+
name: string;
|
|
21
|
+
/** Base namespace (without the sandbox suffix). */
|
|
22
|
+
namespace: string;
|
|
23
|
+
sandbox: string | null;
|
|
24
|
+
kind: 'data' | 'control';
|
|
25
|
+
}
|
|
26
|
+
/** Reads `protoyard.<namespace>[~<sandbox>].<data|control>`; null for anything else. */
|
|
27
|
+
export declare function parseDatabaseName(name: string): ParsedDatabaseName | null;
|
|
28
|
+
export declare const RESERVED_TABLE_NAMES: readonly (keyof typeof RESERVED_TABLES)[];
|
|
29
|
+
/** `py_` belongs to the platform; prototypes cannot declare such tables. */
|
|
30
|
+
export declare const RESERVED_PREFIX = "py_";
|
|
31
|
+
/**
|
|
32
|
+
* Domain tables that exist after applying every version in order. A store
|
|
33
|
+
* set to `null` deletes the table from that version on (Dexie semantics).
|
|
34
|
+
*/
|
|
35
|
+
export declare function domainTableNames(definition: DatabaseDefinition): string[];
|
|
36
|
+
/** Domain tables followed by the reserved platform tables. */
|
|
37
|
+
export declare function allTableNames(definition: DatabaseDefinition): string[];
|
|
38
|
+
export declare function latestVersion(definition: DatabaseDefinition): number;
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RESERVED_TABLES, SANDBOX_PATTERN } from '@protoyard/core';
|
|
2
|
+
/**
|
|
3
|
+
* Every prototype owns separate data, control, and review IndexedDB databases. Names are derived
|
|
4
|
+
* from the manifest namespace so prototypes on the same origin (the gallery
|
|
5
|
+
* serves them all from one host) never see each other's data.
|
|
6
|
+
*/
|
|
7
|
+
export const DATABASE_PREFIX = 'protoyard';
|
|
8
|
+
export function dataDatabaseName(namespace) {
|
|
9
|
+
return `${DATABASE_PREFIX}.${namespace}.data`;
|
|
10
|
+
}
|
|
11
|
+
export function controlDatabaseName(namespace) {
|
|
12
|
+
return `${DATABASE_PREFIX}.${namespace}.control`;
|
|
13
|
+
}
|
|
14
|
+
/** The prototype's local review comments live outside snapshots and scenario data. */
|
|
15
|
+
export function reviewDatabaseName(namespace) {
|
|
16
|
+
return `${DATABASE_PREFIX}.${namespace}.review`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Sandboxes are disposable copies of a prototype's world (`?sandbox=a`). Their
|
|
20
|
+
* namespace is `<namespace>~<sandbox>`: `~` never appears in a manifest
|
|
21
|
+
* namespace, so a sandbox can't collide with another prototype.
|
|
22
|
+
*/
|
|
23
|
+
export const SANDBOX_SEPARATOR = '~';
|
|
24
|
+
export function sandboxNamespace(namespace, sandbox) {
|
|
25
|
+
if (!SANDBOX_PATTERN.test(sandbox)) {
|
|
26
|
+
throw new Error(`Invalid sandbox name "${sandbox}" (lowercase letters, digits and dashes).`);
|
|
27
|
+
}
|
|
28
|
+
return `${namespace}${SANDBOX_SEPARATOR}${sandbox}`;
|
|
29
|
+
}
|
|
30
|
+
/** Reads `protoyard.<namespace>[~<sandbox>].<data|control>`; null for anything else. */
|
|
31
|
+
export function parseDatabaseName(name) {
|
|
32
|
+
const match = /^protoyard\.([a-z][a-z0-9-]*)(?:~([a-z0-9][a-z0-9-]*))?\.(data|control)$/.exec(name);
|
|
33
|
+
if (!match)
|
|
34
|
+
return null;
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
namespace: match[1],
|
|
38
|
+
sandbox: match[2] ?? null,
|
|
39
|
+
kind: match[3],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export const RESERVED_TABLE_NAMES = Object.keys(RESERVED_TABLES);
|
|
43
|
+
/** `py_` belongs to the platform; prototypes cannot declare such tables. */
|
|
44
|
+
export const RESERVED_PREFIX = 'py_';
|
|
45
|
+
/**
|
|
46
|
+
* Domain tables that exist after applying every version in order. A store
|
|
47
|
+
* set to `null` deletes the table from that version on (Dexie semantics).
|
|
48
|
+
*/
|
|
49
|
+
export function domainTableNames(definition) {
|
|
50
|
+
const tables = new Set();
|
|
51
|
+
for (const version of definition.versions) {
|
|
52
|
+
for (const [name, schema] of Object.entries(version.stores)) {
|
|
53
|
+
if (schema === null)
|
|
54
|
+
tables.delete(name);
|
|
55
|
+
else
|
|
56
|
+
tables.add(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...tables];
|
|
60
|
+
}
|
|
61
|
+
/** Domain tables followed by the reserved platform tables. */
|
|
62
|
+
export function allTableNames(definition) {
|
|
63
|
+
return [...domainTableNames(definition), ...RESERVED_TABLE_NAMES];
|
|
64
|
+
}
|
|
65
|
+
export function latestVersion(definition) {
|
|
66
|
+
const last = definition.versions[definition.versions.length - 1];
|
|
67
|
+
if (!last)
|
|
68
|
+
throw new Error('database.versions must declare at least one version');
|
|
69
|
+
return last.version;
|
|
70
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type DatabaseDefinition, type PrototypeManifest, type Row } from '@protoyard/core';
|
|
2
|
+
import { Dexie, type Table } from 'dexie';
|
|
3
|
+
import { type StorageBackend } from './backend.ts';
|
|
4
|
+
/** The parts of a prototype definition the database layer needs. */
|
|
5
|
+
export interface DatabaseSource {
|
|
6
|
+
manifest: Pick<PrototypeManifest, 'storage'>;
|
|
7
|
+
database: DatabaseDefinition;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A prototype's data database: its own versioned domain tables plus the
|
|
11
|
+
* reserved `py_*` platform tables, merged into every version so identities and
|
|
12
|
+
* events survive any domain migration.
|
|
13
|
+
*/
|
|
14
|
+
export declare class PrototypeDatabase extends Dexie {
|
|
15
|
+
readonly namespace: string;
|
|
16
|
+
/** Latest schema version declared by the code. */
|
|
17
|
+
readonly schemaVersion: number;
|
|
18
|
+
readonly domainTables: readonly string[];
|
|
19
|
+
readonly reservedTables: readonly string[];
|
|
20
|
+
constructor(source: DatabaseSource, backend?: StorageBackend);
|
|
21
|
+
/** Domain tables first, then reserved tables. */
|
|
22
|
+
get allTables(): readonly string[];
|
|
23
|
+
hasTable(name: string): boolean;
|
|
24
|
+
/** Typed table accessor that refuses unknown names with a clear message. */
|
|
25
|
+
rows<T extends Row = Row>(name: string): Table<T, string>;
|
|
26
|
+
}
|
|
27
|
+
/** Builds the Dexie instance without opening it (Dexie opens lazily). */
|
|
28
|
+
export declare function createPrototypeDatabase(source: DatabaseSource, backend?: StorageBackend): PrototypeDatabase;
|
|
29
|
+
/**
|
|
30
|
+
* Opens (and migrates) the prototype database. Older databases are upgraded
|
|
31
|
+
* through each version's `upgrade`; a database written by NEWER code is never
|
|
32
|
+
* deleted — it is closed and reported as `version-newer`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function openPrototypeDatabase(source: DatabaseSource, backend?: StorageBackend): Promise<PrototypeDatabase>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { RESERVED_TABLES, } from '@protoyard/core';
|
|
2
|
+
import { Dexie } from 'dexie';
|
|
3
|
+
import { dexieBackendOptions, resolveFactory } from "./backend.js";
|
|
4
|
+
import { mapStorageError, StorageError } from "./errors.js";
|
|
5
|
+
import { dataDatabaseName, domainTableNames, latestVersion, RESERVED_PREFIX, RESERVED_TABLE_NAMES, } from "./names.js";
|
|
6
|
+
/**
|
|
7
|
+
* A prototype's data database: its own versioned domain tables plus the
|
|
8
|
+
* reserved `py_*` platform tables, merged into every version so identities and
|
|
9
|
+
* events survive any domain migration.
|
|
10
|
+
*/
|
|
11
|
+
export class PrototypeDatabase extends Dexie {
|
|
12
|
+
namespace;
|
|
13
|
+
/** Latest schema version declared by the code. */
|
|
14
|
+
schemaVersion;
|
|
15
|
+
domainTables;
|
|
16
|
+
reservedTables = RESERVED_TABLE_NAMES;
|
|
17
|
+
constructor(source, backend = {}) {
|
|
18
|
+
const namespace = source.manifest.storage.namespace;
|
|
19
|
+
super(dataDatabaseName(namespace), dexieBackendOptions(backend));
|
|
20
|
+
this.namespace = namespace;
|
|
21
|
+
this.schemaVersion = latestVersion(source.database);
|
|
22
|
+
this.domainTables = domainTableNames(source.database);
|
|
23
|
+
for (const version of source.database.versions) {
|
|
24
|
+
for (const name of Object.keys(version.stores)) {
|
|
25
|
+
if (name.startsWith(RESERVED_PREFIX)) {
|
|
26
|
+
throw new Error(`Table "${name}" uses the reserved "${RESERVED_PREFIX}" prefix. Rename it in database/schema.ts.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const declared = this.version(version.version).stores({
|
|
30
|
+
...version.stores,
|
|
31
|
+
...RESERVED_TABLES,
|
|
32
|
+
});
|
|
33
|
+
const upgrade = version.upgrade;
|
|
34
|
+
if (upgrade)
|
|
35
|
+
declared.upgrade((tx) => upgrade(tx));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Domain tables first, then reserved tables. */
|
|
39
|
+
get allTables() {
|
|
40
|
+
return [...this.domainTables, ...this.reservedTables];
|
|
41
|
+
}
|
|
42
|
+
hasTable(name) {
|
|
43
|
+
return this.domainTables.includes(name) || this.reservedTables.includes(name);
|
|
44
|
+
}
|
|
45
|
+
/** Typed table accessor that refuses unknown names with a clear message. */
|
|
46
|
+
rows(name) {
|
|
47
|
+
if (!this.hasTable(name)) {
|
|
48
|
+
throw new Error(`Unknown table "${name}". Declared tables: ${this.allTables.join(', ')}.`);
|
|
49
|
+
}
|
|
50
|
+
return this.table(name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Builds the Dexie instance without opening it (Dexie opens lazily). */
|
|
54
|
+
export function createPrototypeDatabase(source, backend = {}) {
|
|
55
|
+
return new PrototypeDatabase(source, backend);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Opens (and migrates) the prototype database. Older databases are upgraded
|
|
59
|
+
* through each version's `upgrade`; a database written by NEWER code is never
|
|
60
|
+
* deleted — it is closed and reported as `version-newer`.
|
|
61
|
+
*/
|
|
62
|
+
export async function openPrototypeDatabase(source, backend = {}) {
|
|
63
|
+
resolveFactory(backend);
|
|
64
|
+
const db = new PrototypeDatabase(source, backend);
|
|
65
|
+
try {
|
|
66
|
+
await db.open();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
db.close();
|
|
70
|
+
throw mapStorageError(error);
|
|
71
|
+
}
|
|
72
|
+
// Dexie 4 opens newer databases silently (and stores version × 10, plus one
|
|
73
|
+
// per implicit schema bump), so compare the native version ourselves.
|
|
74
|
+
const installed = Math.floor(db.backendDB().version / 10);
|
|
75
|
+
if (installed > db.schemaVersion) {
|
|
76
|
+
db.close();
|
|
77
|
+
throw new StorageError('version-newer', {
|
|
78
|
+
message: `This browser holds data for schema v${installed} of this prototype, but the code only knows v${db.schemaVersion}. Update the prototype or reset its data from DevTools. Nothing was deleted.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return db;
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { StorageStatus } from '@protoyard/core';
|
|
2
|
+
import { type StorageBackend } from './backend.ts';
|
|
3
|
+
import { type ControlStore } from './control-store.ts';
|
|
4
|
+
import { type ParsedDatabaseName } from './names.ts';
|
|
5
|
+
import { type DatabaseSource, type PrototypeDatabase } from './prototype-database.ts';
|
|
6
|
+
export declare const MEMORY_STORAGE_WARNING = "IndexedDB is unavailable (private browsing or blocked storage). This prototype is running from memory: changes are lost when you close or reload the tab.";
|
|
7
|
+
/**
|
|
8
|
+
* Reports what storage this browser offers. Never throws: missing APIs simply
|
|
9
|
+
* leave fields undefined.
|
|
10
|
+
*/
|
|
11
|
+
export declare function probeStorage(backend?: StorageBackend): Promise<StorageStatus>;
|
|
12
|
+
/**
|
|
13
|
+
* Asks the browser to keep this origin's data under storage pressure. Returns
|
|
14
|
+
* `false` when refused or unsupported — persistence is never assumed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function requestPersistence(): Promise<boolean>;
|
|
17
|
+
/** Deletes ONLY this prototype's data + control databases. */
|
|
18
|
+
export declare function deleteProtoyardDatabases(namespace: string, backend?: StorageBackend): Promise<void>;
|
|
19
|
+
export interface DatabaseListing {
|
|
20
|
+
/** False when the browser can't enumerate databases (no `indexedDB.databases()`). */
|
|
21
|
+
supported: boolean;
|
|
22
|
+
/** This prototype's databases: the default world and every sandbox. */
|
|
23
|
+
databases: ParsedDatabaseName[];
|
|
24
|
+
/** Sandbox names with at least one database, sorted. */
|
|
25
|
+
sandboxes: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Lists a prototype's databases, including sandboxes, so DevTools can offer
|
|
29
|
+
* "Clear N sandboxes". Never throws: an unsupported or failing enumeration
|
|
30
|
+
* reports `supported: false` with empty lists.
|
|
31
|
+
*/
|
|
32
|
+
export declare function listProtoyardDatabases(namespace: string, backend?: StorageBackend): Promise<DatabaseListing>;
|
|
33
|
+
export interface PrototypeStorage {
|
|
34
|
+
db: PrototypeDatabase;
|
|
35
|
+
control: ControlStore;
|
|
36
|
+
status: StorageStatus;
|
|
37
|
+
/** The backend actually used (memory backend in degraded mode). */
|
|
38
|
+
backend: StorageBackend;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Opens both databases. When IndexedDB cannot be used at all, falls back to an
|
|
42
|
+
* in-memory backend and says so in `status` — persistence is never faked.
|
|
43
|
+
* Other failures (quota, newer version, blocked) are thrown: silently switching
|
|
44
|
+
* to memory there would hide the user's real data.
|
|
45
|
+
*/
|
|
46
|
+
export declare function openPrototypeStorage(source: DatabaseSource, options?: StorageBackend & {
|
|
47
|
+
allowMemoryFallback?: boolean;
|
|
48
|
+
}): Promise<PrototypeStorage>;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { loadMemoryBackend, resolveFactory } from "./backend.js";
|
|
2
|
+
import { openControlStore } from "./control-store.js";
|
|
3
|
+
import { isStorageError, StorageError } from "./errors.js";
|
|
4
|
+
import { controlDatabaseName, dataDatabaseName, parseDatabaseName, } from "./names.js";
|
|
5
|
+
import { openPrototypeDatabase, } from "./prototype-database.js";
|
|
6
|
+
function storageManager() {
|
|
7
|
+
const nav = globalThis.navigator;
|
|
8
|
+
return nav?.storage && typeof nav.storage.estimate === 'function' ? nav.storage : undefined;
|
|
9
|
+
}
|
|
10
|
+
export const MEMORY_STORAGE_WARNING = 'IndexedDB is unavailable (private browsing or blocked storage). This prototype is running from memory: changes are lost when you close or reload the tab.';
|
|
11
|
+
/**
|
|
12
|
+
* Reports what storage this browser offers. Never throws: missing APIs simply
|
|
13
|
+
* leave fields undefined.
|
|
14
|
+
*/
|
|
15
|
+
export async function probeStorage(backend = {}) {
|
|
16
|
+
let available = true;
|
|
17
|
+
try {
|
|
18
|
+
resolveFactory(backend);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
available = false;
|
|
22
|
+
}
|
|
23
|
+
const status = available
|
|
24
|
+
? { backend: 'indexeddb', persistent: true }
|
|
25
|
+
: { backend: 'memory', persistent: false, warning: MEMORY_STORAGE_WARNING };
|
|
26
|
+
const manager = storageManager();
|
|
27
|
+
if (manager) {
|
|
28
|
+
try {
|
|
29
|
+
const estimate = await manager.estimate();
|
|
30
|
+
if (estimate.usage !== undefined)
|
|
31
|
+
status.usageBytes = estimate.usage;
|
|
32
|
+
if (estimate.quota !== undefined)
|
|
33
|
+
status.quotaBytes = estimate.quota;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Some browsers reject estimate() in private contexts; it is optional.
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
if (typeof manager.persisted === 'function')
|
|
40
|
+
status.persisted = await manager.persisted();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Optional API.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return status;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Asks the browser to keep this origin's data under storage pressure. Returns
|
|
50
|
+
* `false` when refused or unsupported — persistence is never assumed.
|
|
51
|
+
*/
|
|
52
|
+
export async function requestPersistence() {
|
|
53
|
+
const manager = storageManager();
|
|
54
|
+
if (!manager || typeof manager.persist !== 'function')
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
return await manager.persist();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function deleteDatabase(factory, name) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const request = factory.deleteDatabase(name);
|
|
66
|
+
request.onsuccess = () => resolve();
|
|
67
|
+
request.onerror = () => reject(request.error);
|
|
68
|
+
// Another tab still has it open; the delete completes once it closes.
|
|
69
|
+
request.onblocked = () => reject(new StorageError('blocked'));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Deletes ONLY this prototype's data + control databases. */
|
|
73
|
+
export async function deleteProtoyardDatabases(namespace, backend = {}) {
|
|
74
|
+
const factory = resolveFactory(backend);
|
|
75
|
+
await deleteDatabase(factory, dataDatabaseName(namespace));
|
|
76
|
+
await deleteDatabase(factory, controlDatabaseName(namespace));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Lists a prototype's databases, including sandboxes, so DevTools can offer
|
|
80
|
+
* "Clear N sandboxes". Never throws: an unsupported or failing enumeration
|
|
81
|
+
* reports `supported: false` with empty lists.
|
|
82
|
+
*/
|
|
83
|
+
export async function listProtoyardDatabases(namespace, backend = {}) {
|
|
84
|
+
let factory;
|
|
85
|
+
try {
|
|
86
|
+
factory = resolveFactory(backend);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { supported: false, databases: [], sandboxes: [] };
|
|
90
|
+
}
|
|
91
|
+
if (typeof factory.databases !== 'function') {
|
|
92
|
+
return { supported: false, databases: [], sandboxes: [] };
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const infos = await factory.databases();
|
|
96
|
+
const databases = infos
|
|
97
|
+
.map((info) => (info.name ? parseDatabaseName(info.name) : null))
|
|
98
|
+
.filter((db) => db !== null && db.namespace === namespace)
|
|
99
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
100
|
+
const sandboxes = [
|
|
101
|
+
...new Set(databases.flatMap((db) => (db.sandbox ? [db.sandbox] : []))),
|
|
102
|
+
].sort();
|
|
103
|
+
return { supported: true, databases, sandboxes };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { supported: false, databases: [], sandboxes: [] };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Opens both databases. When IndexedDB cannot be used at all, falls back to an
|
|
111
|
+
* in-memory backend and says so in `status` — persistence is never faked.
|
|
112
|
+
* Other failures (quota, newer version, blocked) are thrown: silently switching
|
|
113
|
+
* to memory there would hide the user's real data.
|
|
114
|
+
*/
|
|
115
|
+
export async function openPrototypeStorage(source, options = {}) {
|
|
116
|
+
const backend = {};
|
|
117
|
+
if (options.indexedDB)
|
|
118
|
+
backend.indexedDB = options.indexedDB;
|
|
119
|
+
if (options.IDBKeyRange)
|
|
120
|
+
backend.IDBKeyRange = options.IDBKeyRange;
|
|
121
|
+
const namespace = source.manifest.storage.namespace;
|
|
122
|
+
try {
|
|
123
|
+
const db = await openPrototypeDatabase(source, backend);
|
|
124
|
+
try {
|
|
125
|
+
const control = await openControlStore(namespace, backend);
|
|
126
|
+
return { db, control, status: await probeStorage(backend), backend };
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
db.close();
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const unavailable = isStorageError(error) && error.code === 'unavailable';
|
|
135
|
+
if (!unavailable || options.allowMemoryFallback === false)
|
|
136
|
+
throw error;
|
|
137
|
+
const memory = await loadMemoryBackend();
|
|
138
|
+
const db = await openPrototypeDatabase(source, memory);
|
|
139
|
+
const control = await openControlStore(namespace, memory);
|
|
140
|
+
return {
|
|
141
|
+
db,
|
|
142
|
+
control,
|
|
143
|
+
status: { backend: 'memory', persistent: false, warning: MEMORY_STORAGE_WARNING },
|
|
144
|
+
backend: memory,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Snapshot } from '@protoyard/core';
|
|
2
|
+
import type { PrototypeDatabase } from './prototype-database.ts';
|
|
3
|
+
export type SnapshotTable = Snapshot['tables'][number];
|
|
4
|
+
/** Reads every domain + reserved table in one read transaction (consistent view). */
|
|
5
|
+
export declare function dumpTables(db: PrototypeDatabase): Promise<SnapshotTable[]>;
|
|
6
|
+
export interface RestoreResult {
|
|
7
|
+
restored: string[];
|
|
8
|
+
/** Tables present in the input but unknown to this schema. */
|
|
9
|
+
skipped: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Replaces the whole world in ONE read-write transaction: every known table is
|
|
13
|
+
* cleared and refilled. If anything fails (bad row, quota) IndexedDB aborts the
|
|
14
|
+
* transaction and the current data stays exactly as it was.
|
|
15
|
+
*/
|
|
16
|
+
export declare function restoreTables(db: PrototypeDatabase, tables: readonly SnapshotTable[], options?: {
|
|
17
|
+
knownTables?: readonly string[];
|
|
18
|
+
}): Promise<RestoreResult>;
|
|
19
|
+
/** Clears domain + reserved tables in one transaction (used before seeding). */
|
|
20
|
+
export declare function clearWorld(db: PrototypeDatabase): Promise<void>;
|
|
21
|
+
export declare function countTables(db: PrototypeDatabase): Promise<Record<string, number>>;
|
package/dist/world-io.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mapStorageError } from "./errors.js";
|
|
2
|
+
/** Rows per bulkAdd inside the single restore transaction. */
|
|
3
|
+
const RESTORE_CHUNK = 1000;
|
|
4
|
+
/** Reads every domain + reserved table in one read transaction (consistent view). */
|
|
5
|
+
export async function dumpTables(db) {
|
|
6
|
+
const names = db.allTables;
|
|
7
|
+
try {
|
|
8
|
+
return await db.transaction('r', [...names], async () => {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const name of names) {
|
|
11
|
+
out.push({ name, rows: (await db.table(name).toArray()) });
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw mapStorageError(error);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Replaces the whole world in ONE read-write transaction: every known table is
|
|
22
|
+
* cleared and refilled. If anything fails (bad row, quota) IndexedDB aborts the
|
|
23
|
+
* transaction and the current data stays exactly as it was.
|
|
24
|
+
*/
|
|
25
|
+
export async function restoreTables(db, tables, options = {}) {
|
|
26
|
+
const known = new Set(options.knownTables ?? db.allTables);
|
|
27
|
+
const restored = [];
|
|
28
|
+
const skipped = [];
|
|
29
|
+
const byName = new Map();
|
|
30
|
+
for (const table of tables) {
|
|
31
|
+
if (known.has(table.name) && db.hasTable(table.name)) {
|
|
32
|
+
byName.set(table.name, [...(byName.get(table.name) ?? []), ...table.rows]);
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
skipped.push(table.name);
|
|
36
|
+
}
|
|
37
|
+
const targets = db.allTables.filter((name) => known.has(name));
|
|
38
|
+
try {
|
|
39
|
+
await db.transaction('rw', targets, async () => {
|
|
40
|
+
for (const name of targets) {
|
|
41
|
+
const table = db.table(name);
|
|
42
|
+
await table.clear();
|
|
43
|
+
const rows = byName.get(name);
|
|
44
|
+
if (rows?.length) {
|
|
45
|
+
// Chunked so big restores keep the page responsive (same as scenario loads).
|
|
46
|
+
for (let i = 0; i < rows.length; i += RESTORE_CHUNK) {
|
|
47
|
+
await table.bulkAdd(rows.slice(i, i + RESTORE_CHUNK));
|
|
48
|
+
}
|
|
49
|
+
restored.push(name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw mapStorageError(error);
|
|
56
|
+
}
|
|
57
|
+
return { restored, skipped };
|
|
58
|
+
}
|
|
59
|
+
/** Clears domain + reserved tables in one transaction (used before seeding). */
|
|
60
|
+
export async function clearWorld(db) {
|
|
61
|
+
try {
|
|
62
|
+
await db.transaction('rw', [...db.allTables], async () => {
|
|
63
|
+
for (const name of db.allTables)
|
|
64
|
+
await db.table(name).clear();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw mapStorageError(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function countTables(db) {
|
|
72
|
+
const names = db.allTables;
|
|
73
|
+
try {
|
|
74
|
+
return await db.transaction('r', [...names], async () => {
|
|
75
|
+
const counts = {};
|
|
76
|
+
for (const name of names)
|
|
77
|
+
counts[name] = await db.table(name).count();
|
|
78
|
+
return counts;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw mapStorageError(error);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protoyard/database",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "IndexedDB persistence for Protoyard prototypes (Dexie): namespaced databases, migrations, control store, snapshot I/O and storage diagnostics.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@protoyard/core": "1.1.0",
|
|
17
|
+
"dexie": "^4.4.7",
|
|
18
|
+
"fake-indexeddb": "~6.2.6"
|
|
19
|
+
},
|
|
20
|
+
"protoyard": {
|
|
21
|
+
"build": {
|
|
22
|
+
"group": "engine",
|
|
23
|
+
"assets": []
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"provenance": true
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=24.20.0"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/DailybotHQ/protoyard",
|
|
41
|
+
"directory": "packages/database"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://protoyard.app",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/DailybotHQ/protoyard/issues"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"protoyard",
|
|
49
|
+
"indexeddb",
|
|
50
|
+
"dexie",
|
|
51
|
+
"prototype"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
55
|
+
},
|
|
56
|
+
"types": "./dist/index.d.ts"
|
|
6
57
|
}
|