@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.
Files changed (39) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1509 -120
  3. package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
  4. package/dist/sqlite-worker.js +268 -108
  5. package/dist/tabs-broker-worker.d.ts +8 -0
  6. package/dist/tabs-broker-worker.js +434 -0
  7. package/dist/types.d.ts +25 -0
  8. package/package.json +4 -4
  9. package/scripts/check-broker-bundle.mjs +33 -0
  10. package/src/modules/cache/index.ts +35 -0
  11. package/src/modules/data/index.ts +13 -3
  12. package/src/modules/data/mutation-id.test.ts +25 -0
  13. package/src/modules/data/mutation-id.ts +35 -0
  14. package/src/modules/devtools/index.ts +9 -0
  15. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  16. package/src/modules/sync/queue/queue-up.ts +74 -38
  17. package/src/modules/sync/scheduler.ts +4 -2
  18. package/src/modules/sync/sync.ts +186 -8
  19. package/src/services/database/engine-factory.ts +3 -2
  20. package/src/services/database/sqlite-cache-engine.test.ts +160 -103
  21. package/src/services/database/sqlite-cache-engine.ts +285 -72
  22. package/src/services/database/sqlite-open.ts +36 -1
  23. package/src/services/database/sqlite-select.test.ts +13 -46
  24. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  25. package/src/services/database/sqlite-transport.ts +219 -0
  26. package/src/services/database/sqlite-worker.ts +340 -55
  27. package/src/services/stream-processor/index.ts +11 -2
  28. package/src/services/tabs/broker-client.ts +283 -0
  29. package/src/services/tabs/broker.test.ts +278 -0
  30. package/src/services/tabs/coordinator.test.ts +192 -0
  31. package/src/services/tabs/coordinator.ts +567 -0
  32. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  33. package/src/services/tabs/leader-locks.ts +75 -0
  34. package/src/services/tabs/protocol.ts +239 -0
  35. package/src/services/tabs/support.ts +36 -0
  36. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  37. package/src/sp00ky.ts +164 -6
  38. package/src/types.ts +25 -0
  39. 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 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.
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
- 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 === '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
- // Replicate the real spawnWorker's onmessage wiring (resolve/reject pending).
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 as any).spawnWorker = () => {
109
- const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
110
- w.postMessage = (msg: any) => {
111
- msgs.push(msg);
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 as any).spawnWorker = () => {
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 as any).spawnWorker = () => {
235
- const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
236
- w.postMessage = (msg: any) => {
237
- Promise.resolve().then(() => {
238
- const rest =
239
- msg.type === 'open'
240
- ? { persisted: true }
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