@moltendb-web/core 0.1.0-alpha.21 → 0.1.0-beta.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/README.md CHANGED
@@ -243,7 +243,7 @@ This monorepo contains the following packages:
243
243
 
244
244
  ## Roadmap
245
245
 
246
- - [ ] **Multi-Tab Sync:** Leader election for multiple tabs to share a single OPFS instance.
246
+ - [ ] ~~**Multi-Tab Sync:** Leader election for multiple tabs to share a single OPFS instance.~~ ✅
247
247
  - [ ] **Delta Sync:** Automatic two-way sync with the MoltenDB Rust server.
248
248
  - [ ] **Analytics functionality:** Run analytics queries straight in the browser.
249
249
 
@@ -256,6 +256,4 @@ MoltenDB is licensed under the **Business Source License 1.1**.
256
256
 
257
257
  For commercial licensing or questions: [maximilian.both27@outlook.com](mailto:maximilian.both27@outlook.com)
258
258
 
259
- ---
260
259
 
261
- Created with 🌋 by Maximilian Both. Coming to the world on the Equinox.
package/dist/index.d.ts CHANGED
@@ -1,56 +1,55 @@
1
1
  export interface MoltenDBOptions {
2
- /** URL or path to moltendb-worker.js. Defaults to './moltendb-worker.js'. */
3
- workerUrl?: string | URL;
4
- /** Enable WebSocket sync with a MoltenDB server. Default: false. */
5
- syncEnabled?: boolean;
6
- /** WebSocket server URL. Default: 'wss://localhost:3000/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;
2
+ /** URL or path to moltendb-worker.js. */
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
12
  }
13
-
14
13
  export type SyncCallback = (update: {
15
- event: 'change' | 'delete' | 'drop';
16
- collection: string;
17
- key: string;
18
- new_v: number | null;
14
+ event: 'change' | 'delete' | 'drop';
15
+ collection: string;
16
+ key: string;
17
+ new_v: number | null;
19
18
  }) => void;
20
-
21
- export class MoltenDB {
22
- readonly dbName: string;
23
- readonly worker: Worker | null;
24
-
25
- constructor(dbName?: string, options?: MoltenDBOptions);
26
-
27
- /** Initialise the Web Worker and open the OPFS database. */
28
- init(): Promise<void>;
29
-
30
- /** Send a raw message to the worker. */
31
- sendMessage(action: string, params?: Record<string, unknown>): Promise<unknown>;
32
-
33
- /** Insert / upsert one document. */
34
- set(collection: string, key: string, value: Record<string, unknown>, options?: { skipSync?: boolean }): Promise<void>;
35
-
36
- /** Fetch a single document by key. */
37
- get(collection: string, key: string): Promise<unknown>;
38
-
39
- /** Fetch all documents in a collection. */
40
- getAll(collection: string): Promise<unknown>;
41
-
42
- /** Delete a document by key. */
43
- delete(collection: string, key: string, options?: { skipSync?: boolean }): Promise<void>;
44
-
45
- /** Compact the OPFS log file. */
46
- compact(): Promise<unknown>;
47
-
48
- /** Subscribe to real-time server push events. Returns an unsubscribe function. */
49
- onSync(callback: SyncCallback): () => void;
50
-
51
- /** Close the WebSocket connection and stop the sync timer. */
52
- disconnect(): void;
53
-
54
- /** Terminate the Web Worker (and disconnect sync). */
55
- terminate(): void;
19
+ export declare class MoltenDB {
20
+ readonly dbName: string;
21
+ readonly workerUrl: string | URL;
22
+ worker: Worker | null;
23
+ private messageId;
24
+ private pendingRequests;
25
+ isLeader: boolean;
26
+ private bc;
27
+ private syncEnabled;
28
+ private serverUrl;
29
+ private syncIntervalMs;
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;
37
+ constructor(dbName?: string, options?: MoltenDBOptions);
38
+ init(): Promise<void>;
39
+ private startAsLeader;
40
+ private startAsFollower;
41
+ sendMessage(action: string, payload?: Record<string, unknown>): Promise<any>;
42
+ set(collection: string, key: string, value: Record<string, unknown>, options?: {
43
+ skipSync?: boolean;
44
+ }): Promise<void>;
45
+ get(collection: string, key: string): Promise<unknown>;
46
+ getAll(collection: string): Promise<unknown>;
47
+ delete(collection: string, key: string, options?: {
48
+ skipSync?: boolean;
49
+ }): Promise<void>;
50
+ compact(): Promise<unknown>;
51
+ private startSync;
52
+ onSyncEvent(callback: SyncCallback): void;
53
+ disconnect(): void;
54
+ terminate(): void;
56
55
  }
package/dist/index.js CHANGED
@@ -1,176 +1,214 @@
1
- /**
2
- * MoltenDB main-thread client.
3
- *
4
- * Usage:
5
- * import { MoltenDB } from 'moltendb-wasm';
6
- *
7
- * const db = new MoltenDB('my-db', {
8
- * // Required: URL or path to the moltendb-worker.js file.
9
- * // With a bundler: new URL('moltendb-wasm/worker', import.meta.url)
10
- * // Plain script: '/node_modules/moltendb-wasm/dist/moltendb-worker.js'
11
- * workerUrl: new URL('moltendb-wasm/worker', import.meta.url),
12
- * });
13
- * await db.init();
14
- */
15
1
  export class MoltenDB {
16
- /**
17
- * @param {string} dbName - OPFS file name (unique per database).
18
- * @param {object} [options]
19
- * @param {string|URL} [options.workerUrl] - URL to moltendb-worker.js.
20
- * Defaults to './moltendb-worker.js' (works when served from the same directory).
21
- * @param {boolean} [options.syncEnabled=false] - Enable WebSocket sync.
22
- * @param {string} [options.serverUrl='wss://localhost:3000/ws'] - WS server URL.
23
- * @param {number} [options.syncIntervalMs=5000] - Sync batch flush interval.
24
- * @param {string} [options.authToken] - JWT token for WS authentication.
25
- */
2
+ dbName;
3
+ workerUrl;
4
+ worker = null;
5
+ messageId = 0;
6
+ pendingRequests = new Map();
7
+ // Multi-tab Sync State
8
+ isLeader = false;
9
+ bc;
10
+ // Server Sync State
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) */
20
+ onEvent;
26
21
  constructor(dbName = 'moltendb', options = {}) {
27
- this.dbName = dbName;
28
- this.workerUrl = options.workerUrl ?? './moltendb-worker.js';
29
- this.worker = null;
30
- this.messageId = 0;
31
- this.pendingRequests = new Map();
32
-
33
- this.syncEnabled = options.syncEnabled ?? false;
34
- this.serverUrl = options.serverUrl ?? 'wss://localhost:3000/ws';
22
+ this.dbName = dbName;
23
+ // Zero-config default worker resolution
24
+ this.workerUrl = options.workerUrl ?? new URL('./moltendb-worker.js', import.meta.url).href;
25
+ this.syncEnabled = options.syncEnabled ?? false;
26
+ this.serverUrl = options.serverUrl ?? 'wss://localhost:3000/ws';
35
27
  this.syncIntervalMs = options.syncIntervalMs ?? 5000;
36
- this.authToken = options.authToken ?? null;
37
-
38
- this.ws = null;
39
- this.syncCallbacks = new Set();
40
- this.syncQueue = [];
41
- this.syncTimer = null;
28
+ this.authToken = options.authToken;
42
29
  }
43
-
44
- /** Initialise the Web Worker and open the OPFS database. */
45
30
  async init() {
46
- return new Promise((resolve, reject) => {
47
- this.worker = new Worker(this.workerUrl, { type: 'module' });
48
-
49
- this.worker.onmessage = (event) => {
50
- const { id, result, error } = event.data;
51
- const pending = this.pendingRequests.get(id);
52
- if (!pending) return;
53
- this.pendingRequests.delete(id);
54
- if (error) pending.reject(new Error(error));
55
- else pending.resolve(result);
56
- };
57
-
58
- this.worker.onerror = (err) => {
59
- console.error('[MoltenDB] Worker error:', err);
60
- reject(err);
61
- };
62
-
63
- this.sendMessage('init', { dbName: this.dbName })
64
- .then(() => this.syncEnabled ? this.connectSync() : undefined)
65
- .then(resolve)
66
- .catch(reject);
31
+ this.bc = new BroadcastChannel(`moltendb_channel_${this.dbName}`);
32
+ return new Promise((resolveInit) => {
33
+ // 1. Try to grab the lock immediately (Leader Election)
34
+ navigator.locks.request(`moltendb_lock_${this.dbName}`, { ifAvailable: true }, async (lock) => {
35
+ if (lock) {
36
+ // We got the lock! We are the active DB host.
37
+ await this.startAsLeader();
38
+ resolveInit();
39
+ // Return a promise that never resolves to hold the lock until the tab closes
40
+ return new Promise(() => { });
41
+ }
42
+ else {
43
+ // Lock is taken. We are a proxy follower.
44
+ this.startAsFollower();
45
+ resolveInit();
46
+ // 2. Queue up in the background. If the Leader tab closes, this lock resolves!
47
+ navigator.locks.request(`moltendb_lock_${this.dbName}`, async (fallbackLock) => {
48
+ console.log(`[MoltenDB] Previous leader disconnected. Promoting this tab to Leader.`);
49
+ await this.startAsLeader();
50
+ return new Promise(() => { }); // Hold lock
51
+ });
52
+ }
53
+ });
67
54
  });
68
55
  }
69
-
70
- /**
71
- * Send a raw message to the worker and return a Promise for the result.
72
- * @param {string} action
73
- * @param {object} [params]
74
- */
75
- sendMessage(action, params = {}) {
76
- return new Promise((resolve, reject) => {
56
+ async startAsLeader() {
57
+ this.isLeader = true;
58
+ if (this.worker)
59
+ this.worker.terminate(); // Clean slate if promoted
60
+ this.worker = new Worker(this.workerUrl, { type: 'module', name: `moltendb-${this.dbName}-leader` });
61
+ // Handle messages strictly from our local Worker
62
+ this.worker.onmessage = (e) => {
63
+ const data = e.data;
64
+ if (data.type === 'event') {
65
+ // Trigger local UI hook
66
+ if (this.onEvent)
67
+ this.onEvent(data);
68
+ // Broadcast the native event to all Follower tabs
69
+ this.bc.postMessage(data);
70
+ return;
71
+ }
72
+ // Resolve pending local promises
73
+ const req = this.pendingRequests.get(data.id);
74
+ if (req) {
75
+ if (data.error)
76
+ req.reject(new Error(data.error));
77
+ else
78
+ req.resolve(data.result);
79
+ this.pendingRequests.delete(data.id);
80
+ }
81
+ };
82
+ // Initialize the WASM Engine
83
+ await new Promise((resolve, reject) => {
77
84
  const id = this.messageId++;
78
85
  this.pendingRequests.set(id, { resolve, reject });
79
- this.worker.postMessage({ id, action, ...params });
86
+ this.worker.postMessage({ id, action: 'init', dbName: this.dbName, workerUrl: this.workerUrl });
80
87
  });
81
- }
82
-
83
- // ── WebSocket sync ────────────────────────────────────────────────────────
84
-
85
- connectSync() {
86
- return new Promise((resolve, reject) => {
87
- this.ws = new WebSocket(this.serverUrl);
88
-
89
- this.ws.onopen = () => {
90
- if (this.authToken) {
91
- this.ws.send(JSON.stringify({ action: 'AUTH', token: this.authToken }));
88
+ // Listen to the BroadcastChannel for queries coming from Follower tabs
89
+ this.bc.onmessage = async (e) => {
90
+ const msg = e.data;
91
+ if (msg.type === 'query' && msg.action) {
92
+ try {
93
+ const result = await this.sendMessage(msg.action, msg.payload);
94
+ this.bc.postMessage({ type: 'response', id: msg.id, result });
92
95
  }
93
- if (this.syncTimer) clearInterval(this.syncTimer);
94
- this.syncTimer = setInterval(() => this.flushSyncQueue(), this.syncIntervalMs);
95
- resolve();
96
- };
97
-
98
- this.ws.onmessage = (event) => {
99
- try { this.handleServerUpdate(JSON.parse(event.data)); }
100
- catch (e) { console.error('[MoltenDB] Failed to parse server message:', e); }
101
- };
102
-
103
- this.ws.onclose = () => {
104
- if (this.syncTimer) clearInterval(this.syncTimer);
105
- setTimeout(() => this.connectSync(), 3000);
106
- };
107
-
108
- this.ws.onerror = (err) => reject(err);
109
- });
110
- }
111
-
112
- handleServerUpdate(update) {
113
- if (update.event === 'change' && update.collection && update.key) {
114
- // Re-fetch the updated document from the server and apply locally.
115
- // (The WS push only carries the key + new _v, not the full document.)
96
+ catch (err) {
97
+ this.bc.postMessage({ type: 'response', id: msg.id, error: err.message });
98
+ }
99
+ }
100
+ };
101
+ // If backend sync is enabled, only the Leader manages the WebSocket
102
+ if (this.syncEnabled) {
103
+ this.startSync();
116
104
  }
117
- this.syncCallbacks.forEach(cb => cb(update));
118
105
  }
119
-
120
- /** Subscribe to real-time server push events. Returns an unsubscribe fn. */
121
- onSync(callback) {
122
- this.syncCallbacks.add(callback);
123
- return () => this.syncCallbacks.delete(callback);
106
+ startAsFollower() {
107
+ this.isLeader = false;
108
+ // We don't need a worker, we rely on the Leader.
109
+ if (this.worker) {
110
+ this.worker.terminate();
111
+ this.worker = null;
112
+ }
113
+ // Listen to the BroadcastChannel for answers from the Leader
114
+ this.bc.onmessage = (e) => {
115
+ const data = e.data;
116
+ if (data.type === 'event') {
117
+ // Trigger local UI hook as if it happened in this tab
118
+ if (this.onEvent)
119
+ this.onEvent(data);
120
+ return;
121
+ }
122
+ if (data.type === 'response') {
123
+ // Resolve our proxied promises
124
+ const req = this.pendingRequests.get(data.id);
125
+ if (req) {
126
+ if (data.error)
127
+ req.reject(new Error(data.error));
128
+ else
129
+ req.resolve(data.result);
130
+ this.pendingRequests.delete(data.id);
131
+ }
132
+ }
133
+ };
124
134
  }
125
-
126
- flushSyncQueue() {
127
- if (!this.syncQueue.length || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
128
- const batch = this.syncQueue.splice(0);
129
- this.ws.send(JSON.stringify({ action: 'set', operations: batch }));
135
+ async sendMessage(action, payload) {
136
+ const id = this.messageId++;
137
+ return new Promise((resolve, reject) => {
138
+ this.pendingRequests.set(id, { resolve, reject });
139
+ if (this.isLeader && this.worker) {
140
+ // Direct execution on the local Worker
141
+ this.worker.postMessage({ id, action, ...payload });
142
+ }
143
+ else {
144
+ // Proxy the request to the Leader tab
145
+ this.bc.postMessage({ type: 'query', id, action, payload });
146
+ }
147
+ });
130
148
  }
131
-
132
149
  // ── Convenience CRUD helpers ──────────────────────────────────────────────
133
-
134
- /** Insert / upsert one document. */
135
150
  async set(collection, key, value, options = {}) {
136
151
  await this.sendMessage('set', { collection, data: { [key]: value } });
137
- if (this.syncEnabled && !options.skipSync) {
152
+ if (this.syncEnabled && !options.skipSync && this.isLeader) {
138
153
  this.syncQueue.push({ action: 'set', collection, data: { [key]: value } });
139
154
  }
140
155
  }
141
-
142
- /** Fetch a single document by key. */
143
156
  get(collection, key) {
144
157
  return this.sendMessage('get', { collection, keys: key });
145
158
  }
146
-
147
- /** Fetch all documents in a collection. */
148
159
  getAll(collection) {
149
160
  return this.sendMessage('get', { collection });
150
161
  }
151
-
152
- /** Delete a document by key. */
153
162
  async delete(collection, key, options = {}) {
154
163
  await this.sendMessage('delete', { collection, keys: key });
155
- if (this.syncEnabled && !options.skipSync) {
164
+ if (this.syncEnabled && !options.skipSync && this.isLeader) {
156
165
  this.syncQueue.push({ action: 'delete', collection, keys: key });
157
166
  }
158
167
  }
159
-
160
- /** Compact the OPFS log file. */
161
168
  compact() {
162
169
  return this.sendMessage('compact');
163
170
  }
164
-
165
- /** Close the WebSocket connection and stop the sync timer. */
171
+ // ── Server Sync Implementation (Leader Only) ──────────────────────────────
172
+ startSync() {
173
+ this.ws = new WebSocket(this.serverUrl);
174
+ this.ws.onopen = () => {
175
+ if (this.authToken) {
176
+ this.ws?.send(JSON.stringify({ type: 'auth', token: this.authToken }));
177
+ }
178
+ };
179
+ this.ws.onmessage = (e) => {
180
+ try {
181
+ const msg = JSON.parse(e.data);
182
+ if (msg.event) {
183
+ for (const cb of this.syncCallbacks)
184
+ cb(msg);
185
+ }
186
+ }
187
+ catch (err) { }
188
+ };
189
+ this.syncTimer = setInterval(async () => {
190
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
191
+ return;
192
+ if (this.syncQueue.length === 0)
193
+ return;
194
+ const batch = this.syncQueue.splice(0, this.syncQueue.length);
195
+ this.ws.send(JSON.stringify({ type: 'batch', operations: batch }));
196
+ }, this.syncIntervalMs);
197
+ }
198
+ onSyncEvent(callback) {
199
+ this.syncCallbacks.push(callback);
200
+ }
166
201
  disconnect() {
167
- if (this.syncTimer) clearInterval(this.syncTimer);
168
- if (this.ws) this.ws.close();
202
+ if (this.syncTimer)
203
+ clearInterval(this.syncTimer);
204
+ if (this.ws)
205
+ this.ws.close();
206
+ if (this.bc)
207
+ this.bc.close();
169
208
  }
170
-
171
- /** Terminate the Web Worker (and disconnect sync). */
172
209
  terminate() {
173
210
  this.disconnect();
174
- if (this.worker) this.worker.terminate();
211
+ if (this.worker)
212
+ this.worker.terminate();
175
213
  }
176
214
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,39 +1,36 @@
1
1
  import init, { WorkerDb } from './moltendb.js';
2
-
3
2
  let db;
4
-
5
3
  self.onmessage = async (e) => {
6
4
  const { id, action, ...payload } = e.data;
7
-
8
5
  // --- Initialization Phase ---
9
6
  if (action === 'init') {
10
7
  try {
11
- await init(payload.workerUrl);
8
+ await init();
12
9
  db = await new WorkerDb(payload.dbName);
13
-
14
10
  // THE NATIVE FEED: Listen to Rust and broadcast to the main thread
15
11
  db.subscribe((eventStr) => {
16
12
  try {
17
13
  const eventData = JSON.parse(eventStr);
18
14
  // Use type: 'event' so the transport knows it's an unsolicited broadcast
19
15
  self.postMessage({ type: 'event', ...eventData });
20
- } catch (err) {
21
- console.error("[MoltenDB Worker] Failed to parse event", err);
16
+ }
17
+ catch (err) {
18
+ console.error('[MoltenDB Worker] Failed to parse event', err);
22
19
  }
23
20
  });
24
-
25
21
  self.postMessage({ id, result: { status: 'ok' } });
26
- } catch (error) {
22
+ }
23
+ catch (error) {
27
24
  self.postMessage({ id, error: String(error) });
28
25
  }
29
26
  return;
30
27
  }
31
-
32
28
  // --- Standard Request/Response Phase ---
33
29
  try {
34
30
  const result = db.handle_message({ action, ...payload });
35
31
  self.postMessage({ id, result });
36
- } catch (error) {
32
+ }
33
+ catch (error) {
37
34
  self.postMessage({ id, error: String(error) });
38
35
  }
39
- };
36
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moltendb-web/core",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-beta.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>",
@@ -25,6 +25,11 @@
25
25
  },
26
26
  "main": "./dist/index.js",
27
27
  "types": "./dist/index.d.ts",
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "dev": "tsc --watch",
31
+ "typecheck": "tsc --noEmit"
32
+ },
28
33
  "devDependencies": {
29
34
  "typescript": "^6.0.1-rc"
30
35
  },
@@ -38,4 +43,4 @@
38
43
  "indexeddb",
39
44
  "embedded"
40
45
  ]
41
- }
46
+ }