@spooky-sync/core 0.0.1-canary.154 → 0.0.1-canary.155
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.d.ts +151 -0
- package/dist/index.js +1509 -120
- package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
- package/dist/sqlite-worker.js +268 -108
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +25 -0
- package/package.json +4 -4
- package/scripts/check-broker-bundle.mjs +33 -0
- package/src/modules/cache/index.ts +35 -0
- package/src/modules/data/index.ts +13 -3
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/devtools/index.ts +9 -0
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
- package/src/modules/sync/queue/queue-up.ts +74 -38
- package/src/modules/sync/scheduler.ts +4 -2
- package/src/modules/sync/sync.ts +186 -8
- package/src/services/database/engine-factory.ts +3 -2
- package/src/services/database/sqlite-cache-engine.test.ts +160 -103
- package/src/services/database/sqlite-cache-engine.ts +285 -72
- package/src/services/database/sqlite-open.ts +36 -1
- package/src/services/database/sqlite-select.test.ts +13 -46
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +219 -0
- package/src/services/database/sqlite-worker.ts +340 -55
- package/src/services/stream-processor/index.ts +11 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +192 -0
- package/src/services/tabs/coordinator.ts +567 -0
- package/src/services/tabs/fake-ports.fixture.ts +70 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +239 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +581 -0
- package/src/sp00ky.ts +164 -6
- package/src/types.ts +25 -0
- package/tsdown.config.ts +19 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { RecordId } from 'surrealdb';
|
|
3
3
|
import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
|
|
4
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
4
5
|
import { translateSurql } from './surql-translate';
|
|
5
6
|
import type { SqlOp } from './surql-translate';
|
|
6
7
|
import { surql } from '../../utils/surql';
|
|
@@ -13,42 +14,31 @@ function makeLogger(): any {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* A fake
|
|
17
|
-
* `open` opens the DB, `close` closes it, and `exec`/`run` REJECT
|
|
18
|
-
* "sqlite: DB not open" when the DB isn't currently open — exactly the
|
|
19
|
-
* the fix targets. Records every message `type` (across
|
|
20
|
-
* tests can also inspect dispatch order.
|
|
17
|
+
* A fake transport handler that models the real SQLite worker's open/closed
|
|
18
|
+
* lifecycle: `open` opens the DB, `close` closes it, and `exec`/`run` REJECT
|
|
19
|
+
* with "sqlite: DB not open" when the DB isn't currently open — exactly the
|
|
20
|
+
* failure the fix targets. Records every message `type` (across transport
|
|
21
|
+
* generations) so tests can also inspect dispatch order.
|
|
21
22
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else rest = { rows: [] };
|
|
42
|
-
} else if (msg.type === 'select') {
|
|
43
|
-
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
44
|
-
else rest = { rows: [], relationFetches: 0 };
|
|
45
|
-
} else if (msg.type === 'run' || msg.type === 'batch') {
|
|
46
|
-
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
47
|
-
}
|
|
48
|
-
this.onmessage?.({ data: { id: msg.id, ok, error, ...rest } });
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
terminate() {}
|
|
23
|
+
function lifecycleHandler(log: string[]) {
|
|
24
|
+
let dbOpen = false;
|
|
25
|
+
return (type: string) => {
|
|
26
|
+
log.push(type);
|
|
27
|
+
if (type === 'open') {
|
|
28
|
+
dbOpen = true;
|
|
29
|
+
return { persisted: true };
|
|
30
|
+
}
|
|
31
|
+
if (type === 'close') {
|
|
32
|
+
dbOpen = false;
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
if (type === 'exec' || type === 'select' || type === 'run' || type === 'batch') {
|
|
36
|
+
if (!dbOpen) throw new Error('sqlite: DB not open');
|
|
37
|
+
if (type === 'exec') return { rows: [] };
|
|
38
|
+
if (type === 'select') return { rows: [], relationFetches: 0 };
|
|
39
|
+
}
|
|
40
|
+
return {};
|
|
41
|
+
};
|
|
52
42
|
}
|
|
53
43
|
|
|
54
44
|
// Regression: switchBucket must run close → reopen as a single serialized
|
|
@@ -60,19 +50,7 @@ describe('SqliteCacheEngine.switchBucket serialization', () => {
|
|
|
60
50
|
it('never dispatches an op to a closed DB during a bucket switch', async () => {
|
|
61
51
|
const log: string[] = [];
|
|
62
52
|
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
63
|
-
|
|
64
|
-
(engine as any).spawnWorker = () => {
|
|
65
|
-
const w = new FakeWorker(log);
|
|
66
|
-
w.onmessage = (ev: any) => {
|
|
67
|
-
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
68
|
-
const p = (engine as any).pending.get(id);
|
|
69
|
-
if (!p) return;
|
|
70
|
-
(engine as any).pending.delete(id);
|
|
71
|
-
if (ok) p.resolve(rest);
|
|
72
|
-
else p.reject(new Error(error));
|
|
73
|
-
};
|
|
74
|
-
return w as unknown as Worker;
|
|
75
|
-
};
|
|
53
|
+
stubTransport(engine, lifecycleHandler(log));
|
|
76
54
|
|
|
77
55
|
await engine.connect('anon');
|
|
78
56
|
expect(log).toContain('open');
|
|
@@ -105,28 +83,10 @@ describe('SqliteCacheEngine system-table seeding', () => {
|
|
|
105
83
|
it('passes the _00_* system tables to the worker open (fresh-bucket safe)', async () => {
|
|
106
84
|
const msgs: any[] = [];
|
|
107
85
|
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
108
|
-
(engine
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Promise.resolve().then(() => {
|
|
113
|
-
const rest = msg.type === 'open' ? { persisted: true } : {};
|
|
114
|
-
w.onmessage?.({ data: { id: msg.id, ok: true, ...rest } });
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
// Wire pending resolution like the real spawnWorker.
|
|
118
|
-
const inner = w.onmessage;
|
|
119
|
-
w.onmessage = (ev: any) => {
|
|
120
|
-
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
121
|
-
const p = (engine as any).pending.get(id);
|
|
122
|
-
if (!p) return;
|
|
123
|
-
(engine as any).pending.delete(id);
|
|
124
|
-
if (ok) p.resolve(rest);
|
|
125
|
-
else p.reject(new Error(error));
|
|
126
|
-
void inner;
|
|
127
|
-
};
|
|
128
|
-
return w as unknown as Worker;
|
|
129
|
-
};
|
|
86
|
+
stubTransport(engine, (type, payload) => {
|
|
87
|
+
msgs.push({ type, payload });
|
|
88
|
+
return type === 'open' ? { persisted: true } : {};
|
|
89
|
+
});
|
|
130
90
|
|
|
131
91
|
await engine.connect('user:fresh');
|
|
132
92
|
const open = msgs.find((m) => m.type === 'open');
|
|
@@ -154,21 +114,7 @@ describe('SqliteCacheEngine storage health', () => {
|
|
|
154
114
|
logger,
|
|
155
115
|
opts ?? {}
|
|
156
116
|
);
|
|
157
|
-
(engine
|
|
158
|
-
const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
|
|
159
|
-
w.postMessage = (msg: any) => {
|
|
160
|
-
Promise.resolve().then(() => {
|
|
161
|
-
const rest = msg.type === 'open' ? openReply : {};
|
|
162
|
-
const { id, ok, error, ...payload } = { id: msg.id, ok: true, error: undefined, ...rest };
|
|
163
|
-
const p = (engine as any).pending.get(id);
|
|
164
|
-
if (!p) return;
|
|
165
|
-
(engine as any).pending.delete(id);
|
|
166
|
-
if (ok) p.resolve(payload);
|
|
167
|
-
else p.reject(new Error(error));
|
|
168
|
-
});
|
|
169
|
-
};
|
|
170
|
-
return w as unknown as Worker;
|
|
171
|
-
};
|
|
117
|
+
stubTransport(engine, (type) => (type === 'open' ? openReply : {}));
|
|
172
118
|
return { engine, logs };
|
|
173
119
|
}
|
|
174
120
|
|
|
@@ -231,24 +177,13 @@ describe('SqliteCacheEngine.getStorageDiagnostics', () => {
|
|
|
231
177
|
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
232
178
|
logger.child = () => logger;
|
|
233
179
|
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
|
|
234
|
-
(engine
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
: msg.type === 'exec'
|
|
242
|
-
? { rows: execRows(msg.payload.sql) }
|
|
243
|
-
: {};
|
|
244
|
-
const p = (engine as any).pending.get(msg.id);
|
|
245
|
-
if (!p) return;
|
|
246
|
-
(engine as any).pending.delete(msg.id);
|
|
247
|
-
p.resolve(rest);
|
|
248
|
-
});
|
|
249
|
-
};
|
|
250
|
-
return w as unknown as Worker;
|
|
251
|
-
};
|
|
180
|
+
stubTransport(engine, (type, payload) =>
|
|
181
|
+
type === 'open'
|
|
182
|
+
? { persisted: true }
|
|
183
|
+
: type === 'exec'
|
|
184
|
+
? { rows: execRows(payload.sql) }
|
|
185
|
+
: {}
|
|
186
|
+
);
|
|
252
187
|
return engine;
|
|
253
188
|
}
|
|
254
189
|
|
|
@@ -294,6 +229,128 @@ describe('SqliteCacheEngine.getStorageDiagnostics', () => {
|
|
|
294
229
|
});
|
|
295
230
|
});
|
|
296
231
|
|
|
232
|
+
// Shared-tabs engine role modes: the engine object survives every role change
|
|
233
|
+
// (one monotonic epoch per tab), followers speak the same protocol over a
|
|
234
|
+
// MessagePort, and the leaderless window parks ops instead of failing the UI.
|
|
235
|
+
describe('SqliteCacheEngine role modes', () => {
|
|
236
|
+
function makeSharedEngine() {
|
|
237
|
+
const log: string[] = [];
|
|
238
|
+
const engine = new SqliteCacheEngine(
|
|
239
|
+
{ namespace: 'n', database: 'd' } as any,
|
|
240
|
+
makeLogger(),
|
|
241
|
+
{ shared: true, useOpfs: true }
|
|
242
|
+
);
|
|
243
|
+
stubTransport(engine, lifecycleHandler(log));
|
|
244
|
+
return { engine, log };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A fake follower dbPort: replies like the worker over postMessage. */
|
|
248
|
+
function fakeLeaderPort() {
|
|
249
|
+
const port: any = {
|
|
250
|
+
onmessage: null as null | ((ev: { data: any }) => void),
|
|
251
|
+
onmessageerror: null,
|
|
252
|
+
started: false,
|
|
253
|
+
closed: false,
|
|
254
|
+
start() {
|
|
255
|
+
this.started = true;
|
|
256
|
+
},
|
|
257
|
+
close() {
|
|
258
|
+
this.closed = true;
|
|
259
|
+
},
|
|
260
|
+
postMessage(msg: any) {
|
|
261
|
+
queueMicrotask(() => {
|
|
262
|
+
const rest =
|
|
263
|
+
msg.type === 'exec'
|
|
264
|
+
? { rows: [] }
|
|
265
|
+
: msg.type === 'select'
|
|
266
|
+
? { rows: [], relationFetches: 0 }
|
|
267
|
+
: {};
|
|
268
|
+
port.onmessage?.({ data: { id: msg.id, ok: true, wt: 0, ...rest } });
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
return port;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
it('adoptOwner opens under the worker lock and wipes stale _00_query rows', async () => {
|
|
276
|
+
const { engine, log } = makeSharedEngine();
|
|
277
|
+
const health = await engine.adoptOwner('anon', {
|
|
278
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
279
|
+
allowMemoryFallback: false,
|
|
280
|
+
resumeHeld: false,
|
|
281
|
+
});
|
|
282
|
+
expect(health.status).toBe('persistent');
|
|
283
|
+
expect(engine.storageHealth.role).toBe('leader');
|
|
284
|
+
expect(log[0]).toBe('open');
|
|
285
|
+
expect(log).toContain('run'); // the DELETE _00_query wipe
|
|
286
|
+
expect(engine.currentBucketId).toBe('anon');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('adoptAttached serves reads over the leader port and reports follower role', async () => {
|
|
290
|
+
const engine = new SqliteCacheEngine(
|
|
291
|
+
{ namespace: 'n', database: 'd' } as any,
|
|
292
|
+
makeLogger(),
|
|
293
|
+
{ shared: true }
|
|
294
|
+
);
|
|
295
|
+
const port = fakeLeaderPort();
|
|
296
|
+
await engine.adoptAttached(
|
|
297
|
+
port,
|
|
298
|
+
{ bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
|
|
299
|
+
() => {}
|
|
300
|
+
);
|
|
301
|
+
expect(port.started).toBe(true);
|
|
302
|
+
expect(engine.storageHealth).toMatchObject({ status: 'persistent', role: 'follower' });
|
|
303
|
+
await expect(engine.getById('game', 'game:1')).resolves.toBeNull();
|
|
304
|
+
expect((globalThis as any).__sqliteStats.proxiedOps).toBeGreaterThan(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('parks ops through a leader loss and releases them on promotion', async () => {
|
|
308
|
+
const { engine } = makeSharedEngine();
|
|
309
|
+
const port = fakeLeaderPort();
|
|
310
|
+
await engine.adoptAttached(
|
|
311
|
+
port,
|
|
312
|
+
{ bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
|
|
313
|
+
() => {}
|
|
314
|
+
);
|
|
315
|
+
const epochBefore = engine.epoch;
|
|
316
|
+
engine.onLeaderLost('leader tab closed');
|
|
317
|
+
expect(engine.epoch).toBe(epochBefore + 1);
|
|
318
|
+
|
|
319
|
+
// Issued during the leaderless window: must not reject immediately.
|
|
320
|
+
const read = engine.getById('_00_query', 'h1');
|
|
321
|
+
let settled = false;
|
|
322
|
+
void read.finally(() => {
|
|
323
|
+
settled = true;
|
|
324
|
+
});
|
|
325
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
326
|
+
expect(settled).toBe(false);
|
|
327
|
+
|
|
328
|
+
await engine.adoptOwner('anon', {
|
|
329
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:2',
|
|
330
|
+
allowMemoryFallback: false,
|
|
331
|
+
resumeHeld: false,
|
|
332
|
+
});
|
|
333
|
+
await expect(read).resolves.toBeNull();
|
|
334
|
+
expect(engine.storageHealth.role).toBe('leader');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('bumps the epoch on promotion after having had a store (fences in-flight chains)', async () => {
|
|
338
|
+
const { engine } = makeSharedEngine();
|
|
339
|
+
await engine.adoptOwner('anon', {
|
|
340
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
|
|
341
|
+
allowMemoryFallback: false,
|
|
342
|
+
resumeHeld: false,
|
|
343
|
+
});
|
|
344
|
+
const before = engine.epoch;
|
|
345
|
+
await engine.adoptOwner('anon', {
|
|
346
|
+
workerLockName: 'sp00ky-tabs:fp:anon:worker:3',
|
|
347
|
+
allowMemoryFallback: false,
|
|
348
|
+
resumeHeld: false,
|
|
349
|
+
});
|
|
350
|
+
expect(engine.epoch).toBeGreaterThan(before);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
297
354
|
// `pureWriteOpResult` is the single source of truth for what a pure-write op
|
|
298
355
|
// contributes to a query's per-statement results. The batched fast path in
|
|
299
356
|
// `query()` and the per-op `execOp` path BOTH route through it, so a caller that
|