@moltendb-web/core 0.1.0-beta.2 → 1.0.0-rc.1
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.md +21 -35
- package/README.md +114 -9
- package/dist/index.d.ts +18 -30
- package/dist/index.js +102 -109
- package/dist/moltendb-worker.js +38 -19
- package/dist/moltendb.d.ts +3 -3
- package/dist/moltendb.js +10 -7
- package/dist/moltendb_bg.wasm +0 -0
- package/dist/moltendb_bg.wasm.d.ts +3 -3
- package/package.json +19 -6
package/LICENSE.md
CHANGED
|
@@ -1,35 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
You may make use of the Licensed Work (including running it in production) provided that your organization (including any parent companies, subsidiaries, and affiliates) meets **ALL** of the following conditions:
|
|
23
|
-
|
|
24
|
-
1. **Revenue Limit:** Your organization's total annual gross revenue does not exceed $5,000,000 USD (or equivalent).
|
|
25
|
-
2. **Not a Managed Service:** You do not offer the Licensed Work to third parties as a hosted or managed service (e.g., Database-as-a-Service, Backend-as-a-Service), regardless of your company's revenue.
|
|
26
|
-
|
|
27
|
-
If you meet these conditions, the Licensor grants you a non-exclusive, royalty-free, worldwide license to reproduce, use, merge, publish, distribute, and modify the Licensed Work.
|
|
28
|
-
|
|
29
|
-
If your organization exceeds the Revenue Limit, or if you wish to provide the Licensed Work as a managed service, you must purchase a Commercial License from the Licensor.
|
|
30
|
-
|
|
31
|
-
### BSL General Terms
|
|
32
|
-
|
|
33
|
-
If your use of the Licensed Work does not comply with the Additional Use Grant, you may still use the Licensed Work strictly for non-production, internal testing, and development purposes.
|
|
34
|
-
|
|
35
|
-
Effective on the Change Date, or the fourth anniversary of the first publicly available distribution of a specific version of the Licensed Work under this License, whichever comes first, the Licensor hereby grants you rights under the terms of the Change License (MIT License), and the terms of this Business Source License will terminate for that specific version.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maximilian Both
|
|
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
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
[Interactive Demo](https://stackblitz.com/~/github.com/maximilian27/moltendb-wasm-demo?file=package.json) • [Core Engine](https://www.npmjs.com/package/@moltendb-web/core) • [Query Builder](https://www.npmjs.com/package/@moltendb-web/query) • [Original Repository](https://github.com/maximilian27/MoltenDB) • [License](LICENSE.md)
|
|
10
10
|
|
|
11
11
|
[](https://www.npmjs.com/package/@moltendb-web/core)
|
|
12
|
-
[](LICENSE.md)
|
|
13
13
|
[](https://webassembly.org/)
|
|
14
|
+
[]()
|
|
14
15
|
|
|
15
16
|
</div>
|
|
16
17
|
|
|
@@ -20,6 +21,8 @@
|
|
|
20
21
|
|
|
21
22
|
MoltenDB is a JSON document database written in Rust that runs directly in your browser. Unlike traditional browser databases limited by `localStorage` quotas or IndexedDB's complex API, MoltenDB leverages the **Origin Private File System (OPFS)** to provide a high-performance, append-only storage engine.
|
|
22
23
|
|
|
24
|
+
> **🚀 Release Candidate** — The core engine, multi-tab sync, and storage layer are feature-complete and stabilised for v1. Server sync, encryption and analytics are planned for a future release.
|
|
25
|
+
|
|
23
26
|
### 🎮 Explore the Full Functionality
|
|
24
27
|
|
|
25
28
|
The best way to experience MoltenDB is through the **[Interactive Demo on StackBlitz](https://stackblitz.com/~/github.com/maximilian27/moltendb-wasm-demo?file=package.json)**. It provides a complete, live environment where you can test query builder expressions, perform mutations, and see real-time events with zero local setup.
|
|
@@ -32,6 +35,9 @@ Prefer to run it in your own environment? You can **[clone the demo repository](
|
|
|
32
35
|
- **Pure Rust Engine:** The same query logic used in our server binary, compiled to WebAssembly.
|
|
33
36
|
- **OPFS Persistence:** Data persists across page reloads in a dedicated, high-speed sandbox.
|
|
34
37
|
- **Worker-Threaded:** The database runs entirely inside a Web Worker—zero impact on your UI thread.
|
|
38
|
+
- **Multi-Tab Sync (stabilised):** Leader election via the Web Locks API ensures only one tab owns the OPFS handle. All other tabs proxy reads and writes through a `BroadcastChannel`. Seamless leader promotion when the active tab closes.
|
|
39
|
+
- **Automatic Compaction:** The engine automatically compacts the append-only log when it exceeds **500 entries or 5 MB**, keeping storage lean without any manual intervention.
|
|
40
|
+
- **Real-Time Pub/Sub:** Every write and delete emits a typed `DBEvent` to all open tabs instantly. The `subscribe()` pattern supports multiple independent listeners per tab — perfect for modern UI frameworks like React and Angular.
|
|
35
41
|
- **GraphQL-style Selection:** Request only the fields you need (even deeply nested ones) to save memory and CPU.
|
|
36
42
|
- **Auto-Indexing:** The engine monitors your queries and automatically creates indexes for frequently filtered fields.
|
|
37
43
|
- **Conflict Resolution:** Incoming writes with `_v ≤ stored _v` are silently skipped.
|
|
@@ -244,9 +250,66 @@ await client.collection('laptops')
|
|
|
244
250
|
|
|
245
251
|
MoltenDB uses an append-only JSON log. Every write is a new line, ensuring your data is safe even if the tab is closed unexpectedly.
|
|
246
252
|
|
|
247
|
-
- **Compaction:** When the log exceeds
|
|
253
|
+
- **Automatic Compaction:** When the log exceeds **500 entries or 5 MB**, the engine automatically "squashes" the log, removing superseded document versions to reclaim space. No manual `compact()` calls are needed in normal operation.
|
|
248
254
|
- **Persistence:** All data is stored in the Origin Private File System (OPFS). This is a special file system for web apps that provides much higher performance than IndexedDB.
|
|
249
255
|
|
|
256
|
+
### Multi-Tab Sync
|
|
257
|
+
|
|
258
|
+
MoltenDB uses the **Web Locks API** for leader election. The first tab to acquire the lock becomes the *leader* and owns the OPFS file handle directly. Every subsequent tab becomes a *follower* and proxies all reads and writes through a `BroadcastChannel` to the leader.
|
|
259
|
+
|
|
260
|
+
When the leader tab is closed, the next queued follower automatically acquires the lock and promotes itself to leader — no data loss, no manual reconnection required.
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
Tab 1 (Leader) ──owns──▶ Web Worker ──▶ WASM Engine ──▶ OPFS
|
|
264
|
+
│
|
|
265
|
+
└── BroadcastChannel ──▶ Tab 2 (Follower)
|
|
266
|
+
──▶ Tab 3 (Follower)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Real-Time Events (Pub/Sub)
|
|
270
|
+
|
|
271
|
+
MoltenDB has a built-in pub/sub system that automatically notifies **all open tabs** whenever a document is created, updated, or deleted — no polling required.
|
|
272
|
+
|
|
273
|
+
You can attach multiple independent listeners using the subscribe() method, making it trivial to keep different UI components (like React hooks or Angular signals) in sync without memory leaks:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
const db = new MoltenDB('my-app');
|
|
277
|
+
await db.init();
|
|
278
|
+
|
|
279
|
+
// Attach a listener (Returns an unsubscribe function)
|
|
280
|
+
const unsubscribe = db.subscribe((event) => {
|
|
281
|
+
console.log(event.event); // 'change' | 'delete' | 'drop'
|
|
282
|
+
console.log(event.collection); // e.g. 'laptops'
|
|
283
|
+
console.log(event.key); // e.g. 'lp1'
|
|
284
|
+
console.log(event.new_v); // new version number, or null on delete
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Later, when the UI component unmounts:
|
|
288
|
+
unsubscribe();
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The event fires on the **leader tab** (directly from the WASM engine) and is automatically broadcast over the `BroadcastChannel` so every **follower tab** receives it too. This makes it trivial to keep your UI in sync across tabs without any extra infrastructure:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
db.subscribe(({ event, collection, key }) => {
|
|
295
|
+
if (collection === 'laptops') {
|
|
296
|
+
refreshLaptopList(); // re-query and re-render
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The `DBEvent` type is exported from the package for full TypeScript support:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import { MoltenDB, DBEvent } from '@moltendb-web/core';
|
|
305
|
+
|
|
306
|
+
const db = new MoltenDB('my-app');
|
|
307
|
+
await db.init();
|
|
308
|
+
|
|
309
|
+
db.subscribe((e: DBEvent) => { /* fully typed */ });```
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
250
313
|
### Performance Note
|
|
251
314
|
|
|
252
315
|
Because MoltenDB uses OPFS, your browser must support `SharedArrayBuffer`. Most modern browsers support this, but your server must send the following headers:
|
|
@@ -256,6 +319,39 @@ Cross-Origin-Opener-Policy: same-origin
|
|
|
256
319
|
Cross-Origin-Embedder-Policy: require-corp
|
|
257
320
|
```
|
|
258
321
|
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Testing
|
|
325
|
+
|
|
326
|
+
The core package ships with a comprehensive test suite built on **Vitest**:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
cd packages/core
|
|
330
|
+
npm test # run all unit & integration tests
|
|
331
|
+
npm run test:coverage # with coverage report
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### What's covered
|
|
335
|
+
|
|
336
|
+
| Suite | Tests | What it verifies |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| `init()` | 5 | Leader election, idempotency, worker error propagation |
|
|
339
|
+
| CRUD — leader | 9 | set/get/delete/getAll round-trips, collection isolation |
|
|
340
|
+
| CRUD — follower | 3 | BroadcastChannel proxy path for all mutations |
|
|
341
|
+
| Worker error handling | 3 | Transient errors, unknown actions, request isolation |
|
|
342
|
+
| Leader promotion | 2 | Follower takes over when leader tab closes |
|
|
343
|
+
| `Pub/Sub (subscribe)` | 2 | Multi-subscriber event delivery across tabs |
|
|
344
|
+
| Follower timeout | 1 | Pending requests reject after 10 s if leader disappears |
|
|
345
|
+
| `terminate` / `disconnect` | 3 | Worker cleanup, timer teardown |
|
|
346
|
+
| Stress — rapid writes | 3 | 100 sequential, 50 concurrent, interleaved set/delete |
|
|
347
|
+
| BC name isolation | 2 | Two databases on the same origin don't bleed data |
|
|
348
|
+
| Bulk insert stress | 3 | 1 000 concurrent sets, 500 mixed ops, compact under pressure |
|
|
349
|
+
| Multi-tab parallel stress | 4 | 3 tabs × 100 writes, ID collision safety, follower reads after burst, promotion under load |
|
|
350
|
+
|
|
351
|
+
**Total: 50 tests — all green.**
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
259
355
|
## Project Structure
|
|
260
356
|
|
|
261
357
|
This monorepo contains the following packages:
|
|
@@ -265,17 +361,26 @@ This monorepo contains the following packages:
|
|
|
265
361
|
|
|
266
362
|
## Roadmap
|
|
267
363
|
|
|
268
|
-
- [
|
|
364
|
+
- [x] **Multi-Tab Sync:** Leader election for multiple tabs to share a single OPFS instance — **stabilised in RC1**.
|
|
365
|
+
- [x] **Automatic Compaction:** Log compacts automatically at 500 entries or 5 MB — **stabilised in RC1**.
|
|
366
|
+
- [x] **Rich Test Suite:** 50 unit, integration, and stress tests via Vitest — **stabilised in RC1**.
|
|
367
|
+
- [ ] **React Adapter:** Official `@moltendb-web/react` package with `useQuery` hooks and real-time context providers.
|
|
368
|
+
- [ ] **Angular Adapter:** Official `@moltendb-web/angular` package featuring RxJS observables and Signal-based data fetching.
|
|
269
369
|
- [ ] **Delta Sync:** Automatic two-way sync with the MoltenDB Rust server.
|
|
270
|
-
- [ ] **
|
|
370
|
+
- [ ] **Data Encryption:** Transparent encryption-at-rest using hardware-backed keys (Web Crypto API).
|
|
371
|
+
- [ ] **Analytics Functionality:** Run complex analytics queries straight in the browser without blocking the UI.
|
|
271
372
|
|
|
272
|
-
## License
|
|
273
373
|
|
|
274
|
-
|
|
374
|
+
## Contributing & Feedback
|
|
275
375
|
|
|
276
|
-
|
|
277
|
-
- Converts to MIT automatically 3 years after each version's release date.
|
|
376
|
+
Found a bug or have a feature request? Please open an issue on the [GitHub issue tracker](https://github.com/maximilian27/moltendb-web/issues).
|
|
278
377
|
|
|
279
|
-
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## License
|
|
381
|
+
|
|
382
|
+
The MoltenDB Web packages (`@moltendb-web/core` and `@moltendb-web/query`) are licensed under the MIT License.
|
|
280
383
|
|
|
384
|
+
The **MoltenDB Server** (Rust backend) remains under the Business Source License 1.1 (Free for organizations under $5M revenue, requires a license for managed services).
|
|
281
385
|
|
|
386
|
+
For commercial licensing or questions: [maximilian.both27@outlook.com](mailto:maximilian.both27@outlook.com)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,55 +1,43 @@
|
|
|
1
1
|
export interface MoltenDBOptions {
|
|
2
2
|
/** URL or path to moltendb-worker.js. */
|
|
3
3
|
workerUrl?: string | URL;
|
|
4
|
-
/** Enable WebSocket sync with a MoltenDB server. Default: false. */
|
|
5
|
-
syncEnabled?: boolean;
|
|
6
|
-
/** WebSocket server URL. Default: 'wss://localhost:1538/ws'. */
|
|
7
|
-
serverUrl?: string;
|
|
8
|
-
/** Sync batch flush interval in ms. Default: 5000. */
|
|
9
|
-
syncIntervalMs?: number;
|
|
10
|
-
/** JWT token for WebSocket authentication. */
|
|
11
|
-
authToken?: string;
|
|
12
4
|
}
|
|
13
|
-
export
|
|
5
|
+
export interface DBEvent {
|
|
6
|
+
type: 'event';
|
|
14
7
|
event: 'change' | 'delete' | 'drop';
|
|
15
8
|
collection: string;
|
|
16
9
|
key: string;
|
|
17
10
|
new_v: number | null;
|
|
18
|
-
}
|
|
11
|
+
}
|
|
19
12
|
export declare class MoltenDB {
|
|
20
13
|
readonly dbName: string;
|
|
21
14
|
readonly workerUrl?: string | URL;
|
|
22
15
|
worker: Worker | null;
|
|
23
|
-
private messageId;
|
|
24
16
|
private pendingRequests;
|
|
25
17
|
isLeader: boolean;
|
|
26
18
|
private bc;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
private authToken?;
|
|
31
|
-
private ws;
|
|
32
|
-
private syncCallbacks;
|
|
33
|
-
private syncQueue;
|
|
34
|
-
private syncTimer;
|
|
35
|
-
/** ⚡ Hook to listen to native real-time DB mutations (works on all tabs) */
|
|
36
|
-
onEvent?: (event: any) => void;
|
|
19
|
+
/** Legacy global hook. Use `subscribe()` for multi-component listeners. */
|
|
20
|
+
onEvent?: (event: DBEvent) => void;
|
|
21
|
+
private eventListeners;
|
|
37
22
|
constructor(dbName?: string, options?: MoltenDBOptions);
|
|
23
|
+
/**
|
|
24
|
+
* ⚡ Subscribe to real-time DB mutations.
|
|
25
|
+
* @returns An unsubscribe function to prevent memory leaks in UI frameworks.
|
|
26
|
+
*/
|
|
27
|
+
subscribe(listener: (event: DBEvent) => void): () => void;
|
|
28
|
+
/** Manually remove a specific listener */
|
|
29
|
+
unsubscribe(listener: (event: DBEvent) => void): void;
|
|
30
|
+
private dispatchEvent;
|
|
31
|
+
private initialized;
|
|
38
32
|
init(): Promise<void>;
|
|
39
33
|
private startAsLeader;
|
|
40
34
|
private startAsFollower;
|
|
41
35
|
sendMessage(action: string, payload?: Record<string, unknown>): Promise<any>;
|
|
42
|
-
set(collection: string, key: string, value:
|
|
43
|
-
skipSync?: boolean;
|
|
44
|
-
}): Promise<void>;
|
|
36
|
+
set(collection: string, key: string, value: any): Promise<void>;
|
|
45
37
|
get(collection: string, key: string): Promise<unknown>;
|
|
46
|
-
getAll(collection: string): Promise<unknown>;
|
|
47
|
-
delete(collection: string, key: string
|
|
48
|
-
skipSync?: boolean;
|
|
49
|
-
}): Promise<void>;
|
|
38
|
+
getAll(collection: string): Promise<unknown[]>;
|
|
39
|
+
delete(collection: string, key: string): Promise<void>;
|
|
50
40
|
compact(): Promise<unknown>;
|
|
51
|
-
private startSync;
|
|
52
|
-
onSyncEvent(callback: SyncCallback): void;
|
|
53
41
|
disconnect(): void;
|
|
54
42
|
terminate(): void;
|
|
55
43
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,49 +2,65 @@ export class MoltenDB {
|
|
|
2
2
|
dbName;
|
|
3
3
|
workerUrl;
|
|
4
4
|
worker = null;
|
|
5
|
-
messageId = 0;
|
|
6
5
|
pendingRequests = new Map();
|
|
7
6
|
// Multi-tab Sync State
|
|
8
7
|
isLeader = false;
|
|
9
8
|
bc;
|
|
10
|
-
|
|
11
|
-
syncEnabled;
|
|
12
|
-
serverUrl;
|
|
13
|
-
syncIntervalMs;
|
|
14
|
-
authToken;
|
|
15
|
-
ws = null;
|
|
16
|
-
syncCallbacks = [];
|
|
17
|
-
syncQueue = [];
|
|
18
|
-
syncTimer = null;
|
|
19
|
-
/** ⚡ Hook to listen to native real-time DB mutations (works on all tabs) */
|
|
9
|
+
/** Legacy global hook. Use `subscribe()` for multi-component listeners. */
|
|
20
10
|
onEvent;
|
|
11
|
+
// ── Multi-Subscriber Event System ──────────────────────────────────────────
|
|
12
|
+
eventListeners = new Set();
|
|
21
13
|
constructor(dbName = 'moltendb', options = {}) {
|
|
22
14
|
this.dbName = dbName;
|
|
23
15
|
this.workerUrl = options.workerUrl;
|
|
24
|
-
this.syncEnabled = options.syncEnabled ?? false;
|
|
25
|
-
this.serverUrl = options.serverUrl ?? 'wss://localhost:3000/ws';
|
|
26
|
-
this.syncIntervalMs = options.syncIntervalMs ?? 5000;
|
|
27
|
-
this.authToken = options.authToken;
|
|
28
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* ⚡ Subscribe to real-time DB mutations.
|
|
19
|
+
* @returns An unsubscribe function to prevent memory leaks in UI frameworks.
|
|
20
|
+
*/
|
|
21
|
+
subscribe(listener) {
|
|
22
|
+
this.eventListeners.add(listener);
|
|
23
|
+
return () => this.eventListeners.delete(listener);
|
|
24
|
+
}
|
|
25
|
+
/** Manually remove a specific listener */
|
|
26
|
+
unsubscribe(listener) {
|
|
27
|
+
this.eventListeners.delete(listener);
|
|
28
|
+
}
|
|
29
|
+
dispatchEvent(event) {
|
|
30
|
+
// Fire all subscribed component handlers
|
|
31
|
+
for (const listener of this.eventListeners) {
|
|
32
|
+
try {
|
|
33
|
+
listener(event);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error('[MoltenDB] Error in subscribed listener', err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
41
|
+
initialized = false;
|
|
29
42
|
async init() {
|
|
43
|
+
if (this.initialized)
|
|
44
|
+
return;
|
|
45
|
+
this.initialized = true;
|
|
30
46
|
this.bc = new BroadcastChannel(`moltendb_channel_${this.dbName}`);
|
|
31
|
-
return new Promise((resolveInit) => {
|
|
32
|
-
// 1. Try to grab the lock immediately (Leader Election)
|
|
47
|
+
return new Promise((resolveInit, rejectInit) => {
|
|
33
48
|
navigator.locks.request(`moltendb_lock_${this.dbName}`, { ifAvailable: true }, async (lock) => {
|
|
34
49
|
if (lock) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
try {
|
|
51
|
+
await this.startAsLeader();
|
|
52
|
+
resolveInit();
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
rejectInit(err);
|
|
56
|
+
}
|
|
57
|
+
return new Promise(() => { }); // Hold lock
|
|
40
58
|
}
|
|
41
59
|
else {
|
|
42
|
-
// Lock is taken. We are a proxy follower.
|
|
43
60
|
this.startAsFollower();
|
|
44
61
|
resolveInit();
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.log(`[MoltenDB] Previous leader disconnected. Promoting this tab to Leader.`);
|
|
62
|
+
navigator.locks.request(`moltendb_lock_${this.dbName}`, async () => {
|
|
63
|
+
console.log(`[MoltenDB] Promoting this tab to Leader.`);
|
|
48
64
|
await this.startAsLeader();
|
|
49
65
|
return new Promise(() => { }); // Hold lock
|
|
50
66
|
});
|
|
@@ -53,28 +69,26 @@ export class MoltenDB {
|
|
|
53
69
|
});
|
|
54
70
|
}
|
|
55
71
|
async startAsLeader() {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// We must inline `new URL` directly inside `new Worker` so bundlers catch it!
|
|
60
|
-
if (this.workerUrl) {
|
|
61
|
-
this.worker = new Worker(this.workerUrl, { type: 'module', name: `moltendb-${this.dbName}-leader` });
|
|
72
|
+
// Guard: OPFS is required
|
|
73
|
+
try {
|
|
74
|
+
await navigator.storage.getDirectory();
|
|
62
75
|
}
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error('[MoltenDB] Origin Private File System (OPFS) is not available in this browser context. ' +
|
|
78
|
+
'Try a non-private window or a browser that supports OPFS (Chrome 102+, Firefox 111+, Safari 15.2+).');
|
|
65
79
|
}
|
|
66
|
-
|
|
80
|
+
this.isLeader = true;
|
|
81
|
+
if (this.worker)
|
|
82
|
+
this.worker.terminate();
|
|
83
|
+
const url = this.workerUrl || new URL('./moltendb-worker.js', import.meta.url);
|
|
84
|
+
this.worker = new Worker(url, { type: 'module', name: `moltendb-${this.dbName}-leader` });
|
|
67
85
|
this.worker.onmessage = (e) => {
|
|
68
86
|
const data = e.data;
|
|
69
87
|
if (data.type === 'event') {
|
|
70
|
-
// Trigger
|
|
71
|
-
if (this.onEvent)
|
|
72
|
-
this.onEvent(data);
|
|
73
|
-
// Broadcast the native event to all Follower tabs
|
|
88
|
+
this.dispatchEvent(data); // ⬅️ Trigger new dispatcher
|
|
74
89
|
this.bc.postMessage(data);
|
|
75
90
|
return;
|
|
76
91
|
}
|
|
77
|
-
// Resolve pending local promises
|
|
78
92
|
const req = this.pendingRequests.get(data.id);
|
|
79
93
|
if (req) {
|
|
80
94
|
if (data.error)
|
|
@@ -84,13 +98,8 @@ export class MoltenDB {
|
|
|
84
98
|
this.pendingRequests.delete(data.id);
|
|
85
99
|
}
|
|
86
100
|
};
|
|
87
|
-
//
|
|
88
|
-
await
|
|
89
|
-
const id = this.messageId++;
|
|
90
|
-
this.pendingRequests.set(id, { resolve, reject });
|
|
91
|
-
this.worker.postMessage({ id, action: 'init', dbName: this.dbName });
|
|
92
|
-
});
|
|
93
|
-
// Listen to the BroadcastChannel for queries coming from Follower tabs
|
|
101
|
+
// Wait for worker to boot
|
|
102
|
+
await this.sendMessage('init', { dbName: this.dbName });
|
|
94
103
|
this.bc.onmessage = async (e) => {
|
|
95
104
|
const msg = e.data;
|
|
96
105
|
if (msg.type === 'query' && msg.action) {
|
|
@@ -103,29 +112,20 @@ export class MoltenDB {
|
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
};
|
|
106
|
-
// If backend sync is enabled, only the Leader manages the WebSocket
|
|
107
|
-
if (this.syncEnabled) {
|
|
108
|
-
this.startSync();
|
|
109
|
-
}
|
|
110
115
|
}
|
|
111
116
|
startAsFollower() {
|
|
112
117
|
this.isLeader = false;
|
|
113
|
-
// We don't need a worker, we rely on the Leader.
|
|
114
118
|
if (this.worker) {
|
|
115
119
|
this.worker.terminate();
|
|
116
120
|
this.worker = null;
|
|
117
121
|
}
|
|
118
|
-
// Listen to the BroadcastChannel for answers from the Leader
|
|
119
122
|
this.bc.onmessage = (e) => {
|
|
120
123
|
const data = e.data;
|
|
121
124
|
if (data.type === 'event') {
|
|
122
|
-
// Trigger
|
|
123
|
-
if (this.onEvent)
|
|
124
|
-
this.onEvent(data);
|
|
125
|
+
this.dispatchEvent(data); // ⬅️ Trigger new dispatcher
|
|
125
126
|
return;
|
|
126
127
|
}
|
|
127
128
|
if (data.type === 'response') {
|
|
128
|
-
// Resolve our proxied promises
|
|
129
129
|
const req = this.pendingRequests.get(data.id);
|
|
130
130
|
if (req) {
|
|
131
131
|
if (data.error)
|
|
@@ -138,82 +138,75 @@ export class MoltenDB {
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
async sendMessage(action, payload) {
|
|
141
|
-
const id =
|
|
141
|
+
const id = crypto.randomUUID();
|
|
142
142
|
return new Promise((resolve, reject) => {
|
|
143
|
-
this.pendingRequests.set(id, { resolve, reject });
|
|
144
143
|
if (this.isLeader && this.worker) {
|
|
145
|
-
|
|
144
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
146
145
|
this.worker.postMessage({ id, action, ...payload });
|
|
147
146
|
}
|
|
148
147
|
else {
|
|
149
|
-
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
if (this.pendingRequests.has(id)) {
|
|
150
|
+
this.pendingRequests.delete(id);
|
|
151
|
+
reject(new Error(`[MoltenDB] Request "${action}" timed out.`));
|
|
152
|
+
}
|
|
153
|
+
}, 10000);
|
|
154
|
+
this.pendingRequests.set(id, {
|
|
155
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
156
|
+
reject: (e) => { clearTimeout(timer); reject(e); }
|
|
157
|
+
});
|
|
150
158
|
this.bc.postMessage({ type: 'query', id, action, payload });
|
|
151
159
|
}
|
|
152
160
|
});
|
|
153
161
|
}
|
|
154
|
-
// ── Convenience CRUD helpers
|
|
155
|
-
async set(collection, key, value
|
|
162
|
+
// ── Convenience CRUD helpers ───────────────────────────────────────────────
|
|
163
|
+
async set(collection, key, value) {
|
|
156
164
|
await this.sendMessage('set', { collection, data: { [key]: value } });
|
|
157
|
-
if (this.syncEnabled && !options.skipSync && this.isLeader) {
|
|
158
|
-
this.syncQueue.push({ action: 'set', collection, data: { [key]: value } });
|
|
159
|
-
}
|
|
160
165
|
}
|
|
161
|
-
get(collection, key) {
|
|
162
|
-
|
|
166
|
+
async get(collection, key) {
|
|
167
|
+
try {
|
|
168
|
+
return await this.sendMessage('get', { collection, keys: key });
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
try {
|
|
172
|
+
const errorData = JSON.parse(err.message);
|
|
173
|
+
if (errorData.statusCode === 404)
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
163
179
|
}
|
|
164
|
-
getAll(collection) {
|
|
165
|
-
|
|
180
|
+
async getAll(collection) {
|
|
181
|
+
try {
|
|
182
|
+
const result = await this.sendMessage('get', { collection });
|
|
183
|
+
return result || [];
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
try {
|
|
187
|
+
const errorData = JSON.parse(err.message);
|
|
188
|
+
if (errorData.statusCode === 404)
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
catch { }
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
166
194
|
}
|
|
167
|
-
async delete(collection, key
|
|
195
|
+
async delete(collection, key) {
|
|
168
196
|
await this.sendMessage('delete', { collection, keys: key });
|
|
169
|
-
if (this.syncEnabled && !options.skipSync && this.isLeader) {
|
|
170
|
-
this.syncQueue.push({ action: 'delete', collection, keys: key });
|
|
171
|
-
}
|
|
172
197
|
}
|
|
173
198
|
compact() {
|
|
174
199
|
return this.sendMessage('compact');
|
|
175
200
|
}
|
|
176
|
-
// ── Server Sync Implementation (Leader Only) ──────────────────────────────
|
|
177
|
-
startSync() {
|
|
178
|
-
this.ws = new WebSocket(this.serverUrl);
|
|
179
|
-
this.ws.onopen = () => {
|
|
180
|
-
if (this.authToken) {
|
|
181
|
-
this.ws?.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
this.ws.onmessage = (e) => {
|
|
185
|
-
try {
|
|
186
|
-
const msg = JSON.parse(e.data);
|
|
187
|
-
if (msg.event) {
|
|
188
|
-
for (const cb of this.syncCallbacks)
|
|
189
|
-
cb(msg);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
catch (err) { }
|
|
193
|
-
};
|
|
194
|
-
this.syncTimer = setInterval(async () => {
|
|
195
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
196
|
-
return;
|
|
197
|
-
if (this.syncQueue.length === 0)
|
|
198
|
-
return;
|
|
199
|
-
const batch = this.syncQueue.splice(0, this.syncQueue.length);
|
|
200
|
-
this.ws.send(JSON.stringify({ type: 'batch', operations: batch }));
|
|
201
|
-
}, this.syncIntervalMs);
|
|
202
|
-
}
|
|
203
|
-
onSyncEvent(callback) {
|
|
204
|
-
this.syncCallbacks.push(callback);
|
|
205
|
-
}
|
|
206
201
|
disconnect() {
|
|
207
|
-
if (this.syncTimer)
|
|
208
|
-
clearInterval(this.syncTimer);
|
|
209
|
-
if (this.ws)
|
|
210
|
-
this.ws.close();
|
|
211
202
|
if (this.bc)
|
|
212
203
|
this.bc.close();
|
|
213
204
|
}
|
|
214
205
|
terminate() {
|
|
215
206
|
this.disconnect();
|
|
216
|
-
if (this.worker)
|
|
207
|
+
if (this.worker) {
|
|
217
208
|
this.worker.terminate();
|
|
209
|
+
this.worker = null;
|
|
210
|
+
}
|
|
218
211
|
}
|
|
219
212
|
}
|
package/dist/moltendb-worker.js
CHANGED
|
@@ -1,36 +1,55 @@
|
|
|
1
1
|
import init, { WorkerDb } from './moltendb.js';
|
|
2
|
-
let db;
|
|
2
|
+
let db = null;
|
|
3
|
+
let initPromise = null;
|
|
3
4
|
self.onmessage = async (e) => {
|
|
4
5
|
const { id, action, ...payload } = e.data;
|
|
5
|
-
// --- Initialization Phase ---
|
|
6
|
+
// --- 1. Initialization Phase ---
|
|
6
7
|
if (action === 'init') {
|
|
8
|
+
if (!initPromise) {
|
|
9
|
+
initPromise = (async () => {
|
|
10
|
+
await init();
|
|
11
|
+
// FIX: You must await the async constructor
|
|
12
|
+
const instance = await new WorkerDb(payload.dbName);
|
|
13
|
+
// Listen to Rust and broadcast events
|
|
14
|
+
instance.subscribe((eventStr) => {
|
|
15
|
+
try {
|
|
16
|
+
const eventData = JSON.parse(eventStr);
|
|
17
|
+
self.postMessage({ type: 'event', ...eventData });
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.error('[MoltenDB Worker] Event parse error', err);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
db = instance;
|
|
24
|
+
return instance;
|
|
25
|
+
})();
|
|
26
|
+
}
|
|
7
27
|
try {
|
|
8
|
-
await
|
|
9
|
-
db = await new WorkerDb(payload.dbName);
|
|
10
|
-
// THE NATIVE FEED: Listen to Rust and broadcast to the main thread
|
|
11
|
-
db.subscribe((eventStr) => {
|
|
12
|
-
try {
|
|
13
|
-
const eventData = JSON.parse(eventStr);
|
|
14
|
-
// Use type: 'event' so the transport knows it's an unsolicited broadcast
|
|
15
|
-
self.postMessage({ type: 'event', ...eventData });
|
|
16
|
-
}
|
|
17
|
-
catch (err) {
|
|
18
|
-
console.error('[MoltenDB Worker] Failed to parse event', err);
|
|
19
|
-
}
|
|
20
|
-
});
|
|
28
|
+
await initPromise;
|
|
21
29
|
self.postMessage({ id, result: { status: 'ok' } });
|
|
22
30
|
}
|
|
23
31
|
catch (error) {
|
|
24
|
-
|
|
32
|
+
// FIX: Handle Map-based errors from Rust correctly
|
|
33
|
+
const errorMsg = (error instanceof Map)
|
|
34
|
+
? JSON.stringify(Object.fromEntries(error))
|
|
35
|
+
: String(error);
|
|
36
|
+
self.postMessage({ id, error: errorMsg });
|
|
25
37
|
}
|
|
26
38
|
return;
|
|
27
39
|
}
|
|
28
|
-
// --- Standard Request/Response Phase ---
|
|
40
|
+
// --- 2. Standard Request/Response Phase ---
|
|
29
41
|
try {
|
|
30
|
-
|
|
42
|
+
if (!initPromise)
|
|
43
|
+
throw new Error("Worker not initialized");
|
|
44
|
+
const currentDb = await initPromise;
|
|
45
|
+
const result = currentDb.handle_message({ action, ...payload });
|
|
31
46
|
self.postMessage({ id, result });
|
|
32
47
|
}
|
|
33
48
|
catch (error) {
|
|
34
|
-
|
|
49
|
+
// FIX: Handle Map-based errors here too
|
|
50
|
+
const errorMsg = (error instanceof Map)
|
|
51
|
+
? JSON.stringify(Object.fromEntries(error))
|
|
52
|
+
: String(error);
|
|
53
|
+
self.postMessage({ id, error: errorMsg });
|
|
35
54
|
}
|
|
36
55
|
};
|
package/dist/moltendb.d.ts
CHANGED
|
@@ -93,9 +93,9 @@ export interface InitOutput {
|
|
|
93
93
|
readonly workerdb_handle_message: (a: number, b: number, c: number) => void;
|
|
94
94
|
readonly workerdb_new: (a: number, b: number) => number;
|
|
95
95
|
readonly workerdb_subscribe: (a: number, b: number) => void;
|
|
96
|
-
readonly
|
|
97
|
-
readonly
|
|
98
|
-
readonly
|
|
96
|
+
readonly __wasm_bindgen_func_elem_3613: (a: number, b: number) => void;
|
|
97
|
+
readonly __wasm_bindgen_func_elem_3697: (a: number, b: number, c: number, d: number) => void;
|
|
98
|
+
readonly __wasm_bindgen_func_elem_3702: (a: number, b: number, c: number, d: number) => void;
|
|
99
99
|
readonly __wbindgen_export: (a: number, b: number) => number;
|
|
100
100
|
readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
|
101
101
|
readonly __wbindgen_export3: (a: number) => void;
|
package/dist/moltendb.js
CHANGED
|
@@ -251,6 +251,9 @@ function __wbg_get_imports() {
|
|
|
251
251
|
const ret = Object.entries(getObject(arg0));
|
|
252
252
|
return addHeapObject(ret);
|
|
253
253
|
},
|
|
254
|
+
__wbg_error_8d9a8e04cd1d3588: function(arg0) {
|
|
255
|
+
console.error(getObject(arg0));
|
|
256
|
+
},
|
|
254
257
|
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
|
255
258
|
let deferred0_0;
|
|
256
259
|
let deferred0_1;
|
|
@@ -386,7 +389,7 @@ function __wbg_get_imports() {
|
|
|
386
389
|
const a = state0.a;
|
|
387
390
|
state0.a = 0;
|
|
388
391
|
try {
|
|
389
|
-
return
|
|
392
|
+
return __wasm_bindgen_func_elem_3702(a, state0.b, arg0, arg1);
|
|
390
393
|
} finally {
|
|
391
394
|
state0.a = a;
|
|
392
395
|
}
|
|
@@ -506,8 +509,8 @@ function __wbg_get_imports() {
|
|
|
506
509
|
return ret;
|
|
507
510
|
}, arguments); },
|
|
508
511
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
|
509
|
-
// Cast intrinsic for `Closure(Closure { dtor_idx:
|
|
510
|
-
const ret = makeMutClosure(arg0, arg1, wasm.
|
|
512
|
+
// Cast intrinsic for `Closure(Closure { dtor_idx: 664, function: Function { arguments: [Externref], shim_idx: 677, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
|
513
|
+
const ret = makeMutClosure(arg0, arg1, wasm.__wasm_bindgen_func_elem_3613, __wasm_bindgen_func_elem_3697);
|
|
511
514
|
return addHeapObject(ret);
|
|
512
515
|
},
|
|
513
516
|
__wbindgen_cast_0000000000000002: function(arg0) {
|
|
@@ -544,10 +547,10 @@ function __wbg_get_imports() {
|
|
|
544
547
|
};
|
|
545
548
|
}
|
|
546
549
|
|
|
547
|
-
function
|
|
550
|
+
function __wasm_bindgen_func_elem_3697(arg0, arg1, arg2) {
|
|
548
551
|
try {
|
|
549
552
|
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
|
550
|
-
wasm.
|
|
553
|
+
wasm.__wasm_bindgen_func_elem_3697(retptr, arg0, arg1, addHeapObject(arg2));
|
|
551
554
|
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
|
552
555
|
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
|
553
556
|
if (r1) {
|
|
@@ -558,8 +561,8 @@ function __wasm_bindgen_func_elem_3703(arg0, arg1, arg2) {
|
|
|
558
561
|
}
|
|
559
562
|
}
|
|
560
563
|
|
|
561
|
-
function
|
|
562
|
-
wasm.
|
|
564
|
+
function __wasm_bindgen_func_elem_3702(arg0, arg1, arg2, arg3) {
|
|
565
|
+
wasm.__wasm_bindgen_func_elem_3702(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
|
|
563
566
|
}
|
|
564
567
|
|
|
565
568
|
const WorkerDbFinalization = (typeof FinalizationRegistry === 'undefined')
|
package/dist/moltendb_bg.wasm
CHANGED
|
Binary file
|
|
@@ -6,9 +6,9 @@ export const workerdb_analytics: (a: number, b: number, c: number, d: number) =>
|
|
|
6
6
|
export const workerdb_handle_message: (a: number, b: number, c: number) => void;
|
|
7
7
|
export const workerdb_new: (a: number, b: number) => number;
|
|
8
8
|
export const workerdb_subscribe: (a: number, b: number) => void;
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
11
|
-
export const
|
|
9
|
+
export const __wasm_bindgen_func_elem_3613: (a: number, b: number) => void;
|
|
10
|
+
export const __wasm_bindgen_func_elem_3697: (a: number, b: number, c: number, d: number) => void;
|
|
11
|
+
export const __wasm_bindgen_func_elem_3702: (a: number, b: number, c: number, d: number) => void;
|
|
12
12
|
export const __wbindgen_export: (a: number, b: number) => number;
|
|
13
13
|
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
|
14
14
|
export const __wbindgen_export3: (a: number) => void;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moltendb-web/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
4
|
"description": "MoltenDB WASM runtime — the database engine, Web Worker, and main-thread client in one package.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Maximilian Both <maximilian.both27@outlook.com>",
|
|
7
|
-
"license": "
|
|
7
|
+
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "https://github.com/maximilian27/moltendb-web/tree/master/packages/core"
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"import": "./dist/index.js",
|
|
20
20
|
"types": "./dist/index.d.ts"
|
|
21
21
|
},
|
|
22
|
-
"./worker":
|
|
22
|
+
"./worker": {
|
|
23
|
+
"import": "./dist/moltendb-worker.js",
|
|
24
|
+
"types": "./dist/moltendb-worker.d.ts"
|
|
25
|
+
},
|
|
23
26
|
"./wasm": "./dist/moltendb.js",
|
|
24
27
|
"./wasm-bg": "./dist/moltendb_bg.wasm"
|
|
25
28
|
},
|
|
@@ -28,10 +31,20 @@
|
|
|
28
31
|
"scripts": {
|
|
29
32
|
"build": "tsc",
|
|
30
33
|
"dev": "tsc --watch",
|
|
31
|
-
"typecheck": "tsc --noEmit"
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"test:coverage": "vitest run --coverage",
|
|
38
|
+
"prepublishOnly": "npm run build",
|
|
39
|
+
"test:e2e": "playwright test"
|
|
32
40
|
},
|
|
33
41
|
"devDependencies": {
|
|
34
|
-
"
|
|
42
|
+
"@playwright/test": "^1.58.2",
|
|
43
|
+
"@vitest/coverage-v8": "^4.1.1",
|
|
44
|
+
"happy-dom": "^20.8.7",
|
|
45
|
+
"typescript": "^6.0.2",
|
|
46
|
+
"vite": "^8.0.2",
|
|
47
|
+
"vitest": "^4.1.1"
|
|
35
48
|
},
|
|
36
49
|
"keywords": [
|
|
37
50
|
"database",
|
|
@@ -43,4 +56,4 @@
|
|
|
43
56
|
"indexeddb",
|
|
44
57
|
"embedded"
|
|
45
58
|
]
|
|
46
|
-
}
|
|
59
|
+
}
|