@spooky-sync/core 0.0.1-canary.153 → 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 (43) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1694 -126
  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 +55 -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 +82 -0
  15. package/src/modules/devtools/storage-info.test.ts +79 -0
  16. package/src/modules/devtools/storage-info.ts +116 -0
  17. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  18. package/src/modules/sync/queue/queue-up.ts +74 -38
  19. package/src/modules/sync/scheduler.ts +4 -2
  20. package/src/modules/sync/sync.ts +186 -8
  21. package/src/services/database/cache-engine.ts +8 -0
  22. package/src/services/database/engine-factory.ts +3 -2
  23. package/src/services/database/sqlite-cache-engine.test.ts +214 -85
  24. package/src/services/database/sqlite-cache-engine.ts +345 -76
  25. package/src/services/database/sqlite-open.ts +36 -1
  26. package/src/services/database/sqlite-select.test.ts +13 -46
  27. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  28. package/src/services/database/sqlite-transport.ts +219 -0
  29. package/src/services/database/sqlite-worker.ts +340 -55
  30. package/src/services/database/surreal-cache-engine.ts +2 -0
  31. package/src/services/stream-processor/index.ts +11 -2
  32. package/src/services/tabs/broker-client.ts +283 -0
  33. package/src/services/tabs/broker.test.ts +278 -0
  34. package/src/services/tabs/coordinator.test.ts +192 -0
  35. package/src/services/tabs/coordinator.ts +567 -0
  36. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  37. package/src/services/tabs/leader-locks.ts +75 -0
  38. package/src/services/tabs/protocol.ts +239 -0
  39. package/src/services/tabs/support.ts +36 -0
  40. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  41. package/src/sp00ky.ts +164 -6
  42. package/src/types.ts +25 -0
  43. package/tsdown.config.ts +19 -10
@@ -3,6 +3,7 @@ import { RecordId } from 'surrealdb';
3
3
  import type { QueryPlan } from '@spooky-sync/query-builder';
4
4
  import { executeSelect, type SelectDb } from './sqlite-select';
5
5
  import { SqliteCacheEngine } from './sqlite-cache-engine';
6
+ import { stubTransport } from './sqlite-transport.fixture';
6
7
  import { stableKey } from './relation-resolver';
7
8
  import type { Row } from './cache-engine';
8
9
 
@@ -87,52 +88,18 @@ function makeEngine(opts: {
87
88
  makeLogger(),
88
89
  { useOpfs: false, workerSelect: opts.workerSelect }
89
90
  );
90
- (engine as any).spawnWorker = () => {
91
- const w: any = {
92
- onerror: null,
93
- onmessageerror: null,
94
- terminate() {},
95
- postMessage(msg: any) {
96
- calls.push({
97
- type: msg.type,
98
- sql: msg.payload?.sql,
99
- bind: msg.payload?.bind,
100
- payload: msg.payload,
101
- });
102
- Promise.resolve().then(async () => {
103
- let ok = true;
104
- let error: string | undefined;
105
- let rest: Record<string, unknown> = {};
106
- try {
107
- if (msg.type === 'open') rest = { persisted: false };
108
- else if (msg.type === 'exec') rest = { rows: respond(msg.payload.sql, msg.payload.bind ?? []) };
109
- else if (msg.type === 'run' || msg.type === 'batch' || msg.type === 'close') rest = {};
110
- else if (msg.type === 'select') {
111
- if (!opts.answerSelect) throw new Error(`sqlite worker: unknown message ${msg.type}`);
112
- const dbCalls: { sql: string; bind?: unknown[] }[] = [];
113
- rest = await executeSelect(
114
- msg.payload.plan,
115
- msg.payload.params ?? {},
116
- makeSelectDb(FIXTURE, dbCalls)
117
- );
118
- } else throw new Error(`sqlite worker: unknown message ${msg.type}`);
119
- } catch (e) {
120
- ok = false;
121
- error = e instanceof Error ? e.message : String(e);
122
- }
123
- (engine as any).worker &&
124
- (engine as any).pending.get(msg.id) &&
125
- ((): void => {
126
- const p = (engine as any).pending.get(msg.id);
127
- (engine as any).pending.delete(msg.id);
128
- if (ok) p.resolve(rest);
129
- else p.reject(new Error(error));
130
- })();
131
- });
132
- },
133
- };
134
- return w;
135
- };
91
+ stubTransport(engine, async (type, payload) => {
92
+ calls.push({ type, sql: payload?.sql, bind: payload?.bind, payload });
93
+ if (type === 'open') return { persisted: false };
94
+ if (type === 'exec') return { rows: respond(payload.sql, payload.bind ?? []) };
95
+ if (type === 'run' || type === 'batch' || type === 'close') return {};
96
+ if (type === 'select') {
97
+ if (!opts.answerSelect) throw new Error(`sqlite worker: unknown message ${type}`);
98
+ const dbCalls: { sql: string; bind?: unknown[] }[] = [];
99
+ return executeSelect(payload.plan, payload.params ?? {}, makeSelectDb(FIXTURE, dbCalls));
100
+ }
101
+ throw new Error(`sqlite worker: unknown message ${type}`);
102
+ });
136
103
  return { engine, calls };
137
104
  }
138
105
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Test-only helper: stubs `SqliteCacheEngine.createTransport` with a fake
3
+ * transport driven by a plain handler function, replacing the old pattern of
4
+ * faking a whole Worker plus the engine's pending-map wiring. Not exported
5
+ * from the package; imported only by *.test.ts files.
6
+ */
7
+ import type { SqliteTransport } from './sqlite-transport';
8
+
9
+ export type FakeTransportHandler = (type: string, payload: any) => unknown | Promise<unknown>;
10
+
11
+ /** Replace the engine's transport factory. Each open spawns a fresh fake, like
12
+ * the real factory spawns a fresh Worker. The handler returns the reply rest
13
+ * (without id/ok/wt) or throws to produce an error reply. */
14
+ export function stubTransport(engine: unknown, handler: FakeTransportHandler): void {
15
+ (engine as { createTransport: () => SqliteTransport }).createTransport = () => {
16
+ let closed = false;
17
+ return {
18
+ kind: 'worker',
19
+ get connected() {
20
+ return !closed;
21
+ },
22
+ call: <T>(type: string, payload?: unknown) =>
23
+ Promise.resolve().then(() => handler(type, payload)) as Promise<T>,
24
+ failAll() {},
25
+ close() {
26
+ closed = true;
27
+ },
28
+ } as SqliteTransport;
29
+ };
30
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The wire between `SqliteCacheEngine` and the SQLite worker, extracted so the
3
+ * engine can swap it at runtime without touching any SQL logic. Two shapes:
4
+ *
5
+ * - {@link WorkerSqliteTransport}: owns a dedicated Worker (solo mode, and the
6
+ * leader tab in shared-tabs mode). Controls the worker's lifecycle and can
7
+ * forward follower MessagePorts into it (`add-client`).
8
+ * - {@link PortSqliteTransport}: speaks the same request/response protocol over
9
+ * a MessagePort handed out by the leader (follower tabs). No lifecycle
10
+ * control; the port dying surfaces through `onPortDead`.
11
+ *
12
+ * Both keep the pending-map semantics the engine had inline before: requests
13
+ * keyed by a locally-minted numeric id, one resolve/reject per id, `failAll`
14
+ * rejects everything in flight (worker crash, port loss, role change).
15
+ */
16
+ import type { Logger } from '../logger/index';
17
+
18
+ export interface SqliteTransport {
19
+ readonly kind: 'worker' | 'port';
20
+ /** True while the transport can carry a request. */
21
+ readonly connected: boolean;
22
+ call<T = unknown>(type: string, payload?: unknown): Promise<T>;
23
+ /** Reject every pending request with `reason`. Safe to call repeatedly. */
24
+ failAll(reason: string): void;
25
+ /** failAll + release the underlying channel. Terminal. */
26
+ close(reason?: string): void;
27
+ }
28
+
29
+ /** Thrown into pending follower calls when the leader (or its port) goes away.
30
+ * Callers treat it like a transient error: the op may be retried once a new
31
+ * leader is attached; nothing was necessarily executed. */
32
+ export class BrokerPortClosedError extends Error {
33
+ readonly retryable = true;
34
+ constructor(reason: string) {
35
+ super(`sqlite transport lost: ${reason}`);
36
+ this.name = 'BrokerPortClosedError';
37
+ }
38
+ }
39
+
40
+ interface Pending {
41
+ resolve: (v: any) => void;
42
+ reject: (e: unknown) => void;
43
+ }
44
+
45
+ /** Shared request/response bookkeeping over any postMessage-shaped channel. */
46
+ abstract class BaseTransport implements SqliteTransport {
47
+ abstract readonly kind: 'worker' | 'port';
48
+ protected pending = new Map<number, Pending>();
49
+ protected seq = 0;
50
+ protected closed = false;
51
+
52
+ constructor(protected logger: Logger) {}
53
+
54
+ get connected(): boolean {
55
+ return !this.closed;
56
+ }
57
+
58
+ protected abstract post(msg: unknown, transfer?: Transferable[]): void;
59
+ protected abstract makeError(reason: string): Error;
60
+
61
+ protected handleMessage(data: any): void {
62
+ const { id, ok, error, ...rest } = data ?? {};
63
+ const p = this.pending.get(id);
64
+ if (!p) return;
65
+ this.pending.delete(id);
66
+ if (ok) p.resolve(rest);
67
+ else p.reject(new Error(error));
68
+ }
69
+
70
+ call<T = unknown>(type: string, payload?: unknown): Promise<T> {
71
+ if (this.closed) return Promise.reject(this.makeError('transport closed'));
72
+ const id = ++this.seq;
73
+ return new Promise<T>((resolve, reject) => {
74
+ this.pending.set(id, { resolve, reject });
75
+ try {
76
+ this.post({ id, type, payload });
77
+ } catch (e) {
78
+ this.pending.delete(id);
79
+ reject(e);
80
+ }
81
+ });
82
+ }
83
+
84
+ failAll(reason: string): void {
85
+ if (this.pending.size === 0) return;
86
+ const err = this.makeError(reason);
87
+ for (const [, p] of this.pending) p.reject(err);
88
+ this.pending.clear();
89
+ }
90
+
91
+ close(reason = 'closed'): void {
92
+ if (this.closed) return;
93
+ this.closed = true;
94
+ this.failAll(reason);
95
+ }
96
+ }
97
+
98
+ export class WorkerSqliteTransport extends BaseTransport {
99
+ readonly kind = 'worker' as const;
100
+ private worker: Worker;
101
+ /** Fired when the worker reports it fenced itself after a leadership steal
102
+ * (see sqlite-worker.ts). The engine must treat the store as gone. */
103
+ onLockLost: ((reason: string) => void) | null = null;
104
+
105
+ constructor(logger: Logger) {
106
+ super(logger);
107
+ // Source references the `.ts` so the monorepo's src-bundling consumers
108
+ // (e.g. the example app, which aliases `@spooky-sync/core` to `src`)
109
+ // resolve it — Vite handles `.ts` workers. For the published package, the
110
+ // tsdown build rewrites this to `./sqlite-worker.js` (the top-level
111
+ // emitted entry; see tsdown.config.ts), which the flat `dist/index.js`
112
+ // resolves. The worker (+ `@sqlite.org/sqlite-wasm`) still loads lazily —
113
+ // only when `localEngine: 'sqlite'` is used.
114
+ this.worker = new Worker(new URL('./sqlite-worker.ts', import.meta.url), { type: 'module' });
115
+ this.worker.onmessage = (ev: MessageEvent) => {
116
+ // Unsolicited worker-initiated notification, not a reply.
117
+ if (ev.data?.type === 'lock-lost') {
118
+ this.logger.error(
119
+ { reason: ev.data.reason, Category: 'sp00ky-client::SqliteCacheEngine::worker' },
120
+ 'SQLite worker fenced after leadership loss'
121
+ );
122
+ this.failAll('worker fenced');
123
+ this.onLockLost?.(String(ev.data.reason ?? 'lock-lost'));
124
+ return;
125
+ }
126
+ this.handleMessage(ev.data);
127
+ };
128
+ // Surface a worker crash (wasm abort / OOM) instead of leaving every
129
+ // pending call hung forever — reject them all with a clear error.
130
+ const crash = (msg: string) => {
131
+ this.logger.error(
132
+ { err: this.makeError(msg), Category: 'sp00ky-client::SqliteCacheEngine::worker' },
133
+ 'Worker error'
134
+ );
135
+ this.failAll(msg);
136
+ };
137
+ this.worker.onerror = (e: ErrorEvent) => crash(e.message || 'onerror');
138
+ this.worker.onmessageerror = () => crash('messageerror');
139
+ }
140
+
141
+ protected post(msg: unknown, transfer?: Transferable[]): void {
142
+ if (transfer) this.worker.postMessage(msg, transfer);
143
+ else this.worker.postMessage(msg);
144
+ }
145
+
146
+ protected makeError(reason: string): Error {
147
+ return new Error(`SQLite worker crashed: ${reason}`);
148
+ }
149
+
150
+ /** Forward a follower's MessagePort into the worker as an extra client. */
151
+ addClientPort(clientId: string, port: MessagePort): Promise<void> {
152
+ if (this.closed) return Promise.reject(this.makeError('transport closed'));
153
+ const id = ++this.seq;
154
+ return new Promise<void>((resolve, reject) => {
155
+ this.pending.set(id, { resolve: () => resolve(), reject });
156
+ this.worker.postMessage({ id, type: 'add-client', payload: { clientId } }, [port]);
157
+ });
158
+ }
159
+
160
+ removeClientPort(clientId: string): Promise<void> {
161
+ return this.call('remove-client', { clientId }).then(() => undefined);
162
+ }
163
+
164
+ /** Ask the worker to close + pauseVfs + self-close (graceful pagehide). */
165
+ shutdown(): Promise<void> {
166
+ return this.call('shutdown').then(() => undefined);
167
+ }
168
+
169
+ close(reason = 'closed'): void {
170
+ if (this.closed) return;
171
+ super.close(reason);
172
+ this.worker.terminate();
173
+ }
174
+ }
175
+
176
+ export class PortSqliteTransport extends BaseTransport {
177
+ readonly kind = 'port' as const;
178
+
179
+ constructor(
180
+ private port: MessagePort,
181
+ private onPortDead: (reason: string) => void,
182
+ logger: Logger
183
+ ) {
184
+ super(logger);
185
+ port.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);
186
+ port.onmessageerror = () => this.dead('messageerror');
187
+ port.start?.();
188
+ }
189
+
190
+ /** The broker/coordinator learned the leader is gone; the port itself has no
191
+ * close event, so the coordinator calls this explicitly. */
192
+ markDead(reason: string): void {
193
+ this.dead(reason);
194
+ }
195
+
196
+ private dead(reason: string): void {
197
+ if (this.closed) return;
198
+ this.closed = true;
199
+ this.failAll(reason);
200
+ try {
201
+ this.port.close();
202
+ } catch {
203
+ /* ignore */
204
+ }
205
+ this.onPortDead(reason);
206
+ }
207
+
208
+ protected post(msg: unknown): void {
209
+ this.port.postMessage(msg);
210
+ }
211
+
212
+ protected makeError(reason: string): Error {
213
+ return new BrokerPortClosedError(reason);
214
+ }
215
+
216
+ close(reason = 'closed'): void {
217
+ this.dead(reason);
218
+ }
219
+ }