@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
|
-
|
|
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.
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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.
|
|
5135
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
await this.connect(bucketId);
|
|
242
|
+
await this.openInternal(bucketId);
|
|
243
|
+
});
|
|
214
244
|
}
|
|
215
245
|
|
|
216
246
|
async close(): Promise<void> {
|