@spooky-sync/core 0.0.1-canary.132 → 0.0.1-canary.133

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/dist/index.js CHANGED
@@ -1693,9 +1693,14 @@ var SqliteCacheEngine = class {
1693
1693
  });
1694
1694
  });
1695
1695
  }
1696
- async connect(bucketId) {
1696
+ /**
1697
+ * Spawn the worker and open `bucketId`'s DB. Uses {@link rawCall} (NOT
1698
+ * {@link call}) so it can run as the body of an already-queued opQueue entry
1699
+ * without re-queuing onto itself. Callers must run it through the opQueue.
1700
+ */
1701
+ async openInternal(bucketId) {
1697
1702
  this.worker = this.spawnWorker();
1698
- const { persisted } = await this.call("open", {
1703
+ const { persisted } = await this.rawCall("open", {
1699
1704
  dbName: bucketId,
1700
1705
  useOpfs: this.useOpfs
1701
1706
  });
@@ -1707,16 +1712,28 @@ var SqliteCacheEngine = class {
1707
1712
  Category: "sp00ky-client::SqliteCacheEngine::connect"
1708
1713
  }, persisted ? "SQLite OPFS store opened" : "SQLite in-memory store opened (no OPFS)");
1709
1714
  }
1715
+ /** Enqueue `fn` as a single serialized opQueue entry (mirrors {@link call}'s
1716
+ * chaining) so it can't interleave with reads/writes at the worker. */
1717
+ enqueue(fn) {
1718
+ const result = this.opQueue.then(fn, fn);
1719
+ this.opQueue = result.then(() => void 0, () => void 0);
1720
+ return result;
1721
+ }
1722
+ async connect(bucketId) {
1723
+ await this.enqueue(() => this.openInternal(bucketId));
1724
+ }
1710
1725
  async switchBucket(bucketId) {
1711
1726
  this.storeEpoch++;
1712
- if (this.worker) {
1713
- try {
1714
- await this.call("close");
1715
- } catch {}
1716
- this.worker.terminate();
1717
- this.worker = null;
1718
- }
1719
- await this.connect(bucketId);
1727
+ await this.enqueue(async () => {
1728
+ if (this.worker) {
1729
+ try {
1730
+ await this.rawCall("close");
1731
+ } catch {}
1732
+ this.worker.terminate();
1733
+ this.worker = null;
1734
+ }
1735
+ await this.openInternal(bucketId);
1736
+ });
1720
1737
  }
1721
1738
  async close() {
1722
1739
  if (!this.worker) return;
@@ -5131,8 +5148,8 @@ function parseBackendInfo(raw) {
5131
5148
 
5132
5149
  //#endregion
5133
5150
  //#region src/modules/devtools/index.ts
5134
- const CORE_VERSION = "0.0.1-canary.132";
5135
- const WASM_VERSION = "0.0.1-canary.132";
5151
+ const CORE_VERSION = "0.0.1-canary.133";
5152
+ const WASM_VERSION = "0.0.1-canary.133";
5136
5153
  const SURREAL_VERSION = "3.0.3";
5137
5154
  var DevToolsService = class {
5138
5155
  eventsHistory = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.132",
3
+ "version": "0.0.1-canary.133",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -59,8 +59,8 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@spooky-sync/query-builder": "0.0.1-canary.132",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.132",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.133",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.133",
64
64
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
65
65
  "@surrealdb/wasm": "^3.0.3",
66
66
  "fast-json-patch": "^3.1.1",
@@ -1,10 +1,98 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
- import { pureWriteOpResult } from './sqlite-cache-engine';
3
+ import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
4
4
  import { translateSurql } from './surql-translate';
5
5
  import type { SqlOp } from './surql-translate';
6
6
  import { surql } from '../../utils/surql';
7
7
 
8
+ function makeLogger(): any {
9
+ const noop = () => {};
10
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
11
+ l.child = () => l;
12
+ return l;
13
+ }
14
+
15
+ /**
16
+ * A fake Worker that models the real SQLite worker's open/closed lifecycle:
17
+ * `open` opens the DB, `close` closes it, and `exec`/`run` REJECT with
18
+ * "sqlite: DB not open" when the DB isn't currently open — exactly the failure
19
+ * the fix targets. Records every message `type` (across worker generations) so
20
+ * tests can also inspect dispatch order.
21
+ */
22
+ class FakeWorker {
23
+ onmessage: ((ev: any) => void) | null = null;
24
+ onerror: any = null;
25
+ onmessageerror: any = null;
26
+ private dbOpen = false;
27
+ constructor(private log: string[]) {}
28
+ postMessage(msg: any) {
29
+ this.log.push(msg.type);
30
+ Promise.resolve().then(() => {
31
+ let ok = true;
32
+ let error: string | undefined;
33
+ let rest: Record<string, unknown> = {};
34
+ if (msg.type === 'open') {
35
+ this.dbOpen = true;
36
+ rest = { persisted: true };
37
+ } else if (msg.type === 'close') {
38
+ this.dbOpen = false;
39
+ } else if (msg.type === 'exec') {
40
+ if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
41
+ else rest = { rows: [] };
42
+ } else if (msg.type === 'run' || msg.type === 'batch') {
43
+ if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
44
+ }
45
+ this.onmessage?.({ data: { id: msg.id, ok, error, ...rest } });
46
+ });
47
+ }
48
+ terminate() {}
49
+ }
50
+
51
+ // Regression: switchBucket must run close → reopen as a single serialized
52
+ // opQueue entry. Otherwise a read/write enqueued during an auth/bucket change
53
+ // dispatches to the just-closed worker → "sqlite: DB not open" (the crash on
54
+ // sign-in). The invariant: no `exec` may appear between a `close` and the next
55
+ // `open` in the message stream.
56
+ describe('SqliteCacheEngine.switchBucket serialization', () => {
57
+ it('never dispatches an op to a closed DB during a bucket switch', async () => {
58
+ const log: string[] = [];
59
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
60
+ // Replicate the real spawnWorker's onmessage wiring (resolve/reject pending).
61
+ (engine as any).spawnWorker = () => {
62
+ const w = new FakeWorker(log);
63
+ w.onmessage = (ev: any) => {
64
+ const { id, ok, error, ...rest } = ev.data ?? {};
65
+ const p = (engine as any).pending.get(id);
66
+ if (!p) return;
67
+ (engine as any).pending.delete(id);
68
+ if (ok) p.resolve(rest);
69
+ else p.reject(new Error(error));
70
+ };
71
+ return w as unknown as Worker;
72
+ };
73
+
74
+ await engine.connect('anon');
75
+ expect(log).toContain('open');
76
+
77
+ // Fire a switch and a concurrent read (exactly what the sign-in query
78
+ // re-registration does). With the old non-atomic switch the read's `exec`
79
+ // dispatched to the closed/terminated worker and REJECTED ("sqlite: DB not
80
+ // open" / "not connected") — the uncaught crash. The atomic switch makes the
81
+ // read wait for the reopen and resolve against the new bucket.
82
+ const switching = engine.switchBucket('user:abc');
83
+ const reading = engine.getById('_00_query', 'h1');
84
+ await expect(Promise.all([switching, reading])).resolves.toBeDefined();
85
+ await expect(reading).resolves.toBeNull(); // missing row → null, not a throw
86
+
87
+ // And the close→reopen ran with no op wedged between them.
88
+ const closeIdx = log.indexOf('close');
89
+ const openAfter = log.indexOf('open', closeIdx + 1);
90
+ expect(openAfter).toBeGreaterThan(closeIdx);
91
+ expect(log.slice(closeIdx + 1, openAfter)).not.toContain('exec');
92
+ expect(engine.currentBucketId).toBe('user:abc');
93
+ });
94
+ });
95
+
8
96
  // `pureWriteOpResult` is the single source of truth for what a pure-write op
9
97
  // contributes to a query's per-statement results. The batched fast path in
10
98
  // `query()` and the per-op `execOp` path BOTH route through it, so a caller that
@@ -185,9 +185,14 @@ export class SqliteCacheEngine implements LocalStore {
185
185
  });
186
186
  }
187
187
 
188
- async connect(bucketId: string): Promise<void> {
188
+ /**
189
+ * Spawn the worker and open `bucketId`'s DB. Uses {@link rawCall} (NOT
190
+ * {@link call}) so it can run as the body of an already-queued opQueue entry
191
+ * without re-queuing onto itself. Callers must run it through the opQueue.
192
+ */
193
+ private async openInternal(bucketId: string): Promise<void> {
189
194
  this.worker = this.spawnWorker();
190
- const { persisted } = await this.call<{ persisted: boolean }>('open', {
195
+ const { persisted } = await this.rawCall<{ persisted: boolean }>('open', {
191
196
  dbName: bucketId,
192
197
  useOpfs: this.useOpfs,
193
198
  });
@@ -199,18 +204,43 @@ export class SqliteCacheEngine implements LocalStore {
199
204
  );
200
205
  }
201
206
 
207
+ /** Enqueue `fn` as a single serialized opQueue entry (mirrors {@link call}'s
208
+ * chaining) so it can't interleave with reads/writes at the worker. */
209
+ private enqueue<T>(fn: () => Promise<T>): Promise<T> {
210
+ const result = this.opQueue.then(fn, fn);
211
+ this.opQueue = result.then(
212
+ () => undefined,
213
+ () => undefined
214
+ );
215
+ return result;
216
+ }
217
+
218
+ async connect(bucketId: string): Promise<void> {
219
+ // Serialize through the opQueue so an op racing boot can't dispatch to a
220
+ // half-open worker.
221
+ await this.enqueue(() => this.openInternal(bucketId));
222
+ }
223
+
202
224
  async switchBucket(bucketId: string): Promise<void> {
203
225
  this.storeEpoch++;
204
- if (this.worker) {
205
- try {
206
- await this.call('close');
207
- } catch {
208
- /* ignore */
226
+ // Run close → terminate → reopen as ONE opQueue entry. Previously `close`
227
+ // and the new `open` were separate entries, so a query's `exec` (e.g. the
228
+ // query re-registration fired by an auth/bucket change) could slot in
229
+ // between and dispatch to the just-closed worker → "sqlite: DB not open".
230
+ // As a single entry, every other op runs fully before the close or after
231
+ // the reopen — never against a closed DB.
232
+ await this.enqueue(async () => {
233
+ if (this.worker) {
234
+ try {
235
+ await this.rawCall('close');
236
+ } catch {
237
+ /* ignore */
238
+ }
239
+ this.worker.terminate();
240
+ this.worker = null;
209
241
  }
210
- this.worker.terminate();
211
- this.worker = null;
212
- }
213
- await this.connect(bucketId);
242
+ await this.openInternal(bucketId);
243
+ });
214
244
  }
215
245
 
216
246
  async close(): Promise<void> {