@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // `StreamProcessorService.reset()` is the SSP half of a local-bucket switch:
4
+ // the old circuit holds the previous user's rows and views registered with the
5
+ // previous `$auth`, so reset must swap in a FRESH processor (no state load),
6
+ // and a checkpoint racing the reset must not persist the old circuit into the
7
+ // new bucket's store.
8
+
9
+ const processorInstances: any[] = [];
10
+
11
+ vi.mock('@spooky-sync/ssp-wasm', () => ({
12
+ default: vi.fn(async () => {}),
13
+ Sp00kyProcessor: vi.fn(() => {
14
+ const instance = {
15
+ ingest: vi.fn(() => []),
16
+ register_view: vi.fn(() => ({ query_id: 'q1', result_data: [] })),
17
+ unregister_view: vi.fn(),
18
+ set_permissions: vi.fn(),
19
+ set_projection: vi.fn(),
20
+ save_store_state: vi.fn(() => new Uint8Array([1, 2, 3])),
21
+ load_store_state: vi.fn(() => []),
22
+ reconcile: vi.fn(() => ({ fetch: [], deleted: 0, updates: [] })),
23
+ free: vi.fn(),
24
+ };
25
+ processorInstances.push(instance);
26
+ return instance;
27
+ }),
28
+ }));
29
+
30
+ import { StreamProcessorService } from './index';
31
+
32
+ const silentLogger = {
33
+ child: () => silentLogger,
34
+ trace: () => {},
35
+ debug: () => {},
36
+ info: () => {},
37
+ warn: () => {},
38
+ error: () => {},
39
+ } as any;
40
+
41
+ function makeService() {
42
+ const snapshots: Record<string, { bytes: Uint8Array; meta: any }> = {};
43
+ const db = {
44
+ epoch: 0,
45
+ engineKind: 'sqlite' as const,
46
+ scanVersions: vi.fn(async () => ({})),
47
+ selectByIds: vi.fn(async () => []),
48
+ getSnapshot: vi.fn(async (key: string) => snapshots[key] ?? null),
49
+ putSnapshot: vi.fn(async (key: string, bytes: Uint8Array, meta: any) => {
50
+ snapshots[key] = { bytes, meta };
51
+ }),
52
+ };
53
+ const service = new StreamProcessorService({} as any, db as any, silentLogger);
54
+ return { service, db, snapshots };
55
+ }
56
+
57
+ const primeCtx = { tables: [], schemaHash: 'schema-1', pendingIds: new Set<string>() };
58
+
59
+ beforeEach(() => {
60
+ processorInstances.length = 0;
61
+ });
62
+
63
+ describe('StreamProcessorService.reset', () => {
64
+ it('swaps in a fresh processor without loading persisted state', async () => {
65
+ const { service } = makeService();
66
+ await service.init();
67
+ expect(processorInstances).toHaveLength(1);
68
+ const first = processorInstances[0];
69
+
70
+ await service.reset();
71
+ expect(processorInstances).toHaveLength(2);
72
+ const second = processorInstances[1];
73
+ // Fresh circuit: nothing loaded into it (a persisted snapshot references
74
+ // views under a dead sessionId salt and the previous user's data).
75
+ expect(second.load_store_state).not.toHaveBeenCalled();
76
+
77
+ // New registrations land on the fresh processor, not the old circuit.
78
+ service.registerQueryPlan({
79
+ queryHash: 'q1',
80
+ surql: 'SELECT * FROM thing;',
81
+ params: {},
82
+ ttl: '10m',
83
+ lastActiveAt: new Date(),
84
+ localArray: [],
85
+ remoteArray: [],
86
+ meta: { tableName: 'thing' },
87
+ } as any);
88
+ expect(second.register_view).toHaveBeenCalled();
89
+ expect(first.register_view).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it('writes a store-only snapshot through the engine on checkpoint', async () => {
93
+ const { service, db, snapshots } = makeService();
94
+ service.configureCircuitPersistence(true);
95
+ await service.init();
96
+ await service.primeFromLocal(primeCtx);
97
+ await service.checkpoint('test');
98
+ expect(processorInstances[0].save_store_state).toHaveBeenCalledTimes(1);
99
+ expect(db.putSnapshot).toHaveBeenCalledTimes(1);
100
+ expect(snapshots.circuit.bytes).toEqual(new Uint8Array([1, 2, 3]));
101
+ expect(snapshots.circuit.meta.schemaHash).toBe('schema-1');
102
+ expect(snapshots.circuit.meta.formatVersion).toBe(1);
103
+ });
104
+
105
+ it('drops a checkpoint that raced a reset (old circuit never persists into the new bucket)', async () => {
106
+ const { service, db } = makeService();
107
+ service.configureCircuitPersistence(true);
108
+ await service.init();
109
+ await service.primeFromLocal(primeCtx);
110
+ // The bytes are taken, then a reset lands before the write step,
111
+ // simulated by resetting from inside save_store_state().
112
+ processorInstances[0].save_store_state.mockImplementation(() => {
113
+ void service.reset();
114
+ return new Uint8Array([9]);
115
+ });
116
+ await service.checkpoint('test');
117
+ expect(db.putSnapshot).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it('does not write a snapshot from a follower tab', async () => {
121
+ const { service, db } = makeService();
122
+ service.configureCircuitPersistence(true);
123
+ await service.init();
124
+ await service.primeFromLocal(primeCtx);
125
+ service.setPersistenceEnabled(false);
126
+ await service.checkpoint('test');
127
+ expect(db.putSnapshot).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('frees the replaced wasm circuit on reset', async () => {
131
+ const { service } = makeService();
132
+ await service.init();
133
+ const first = processorInstances[0];
134
+ await service.reset();
135
+ // V8 cannot see wasm-internal bytes, so the FinalizationRegistry may never
136
+ // run. The old circuit must be freed explicitly or its whole store stays
137
+ // resident for the rest of the session.
138
+ expect(first.free).toHaveBeenCalledTimes(1);
139
+ });
140
+ });
141
+
142
+ // The renderer-OOM guardrail. A snapshot walks every row of the store, so it
143
+ // must never run inline with an ingest or a query register/unregister: on a
144
+ // large windowed list that meant hundreds of whole-store serializations per
145
+ // scroll. Snapshots are checkpointed on a timer, and only once enough rows
146
+ // changed to be worth the write.
147
+ describe('StreamProcessorService circuit snapshots', () => {
148
+ const plan = {
149
+ queryHash: 'q1',
150
+ surql: 'SELECT * FROM thing;',
151
+ params: {},
152
+ ttl: '10m',
153
+ lastActiveAt: new Date(),
154
+ localArray: [],
155
+ remoteArray: [],
156
+ meta: { tableName: 'thing' },
157
+ } as any;
158
+
159
+ it('never snapshots on ingest or register/unregister when persistence is off', async () => {
160
+ const { service, db } = makeService();
161
+ service.configureCircuitPersistence(false);
162
+ await service.init();
163
+ const processor = processorInstances[0];
164
+
165
+ service.registerQueryPlan(plan);
166
+ service.ingest('thing', 'CREATE', 'thing:a', { id: 'thing:a' });
167
+ service.ingestMany([
168
+ { table: 'thing', op: 'CREATE', id: 'thing:b', record: { id: 'thing:b' } },
169
+ { table: 'thing', op: 'CREATE', id: 'thing:c', record: { id: 'thing:c' } },
170
+ ]);
171
+ service.unregisterQueryPlan('q1');
172
+
173
+ expect(processor.save_store_state).not.toHaveBeenCalled();
174
+ expect(db.putSnapshot).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('still never snapshots inline when persistence is on', async () => {
178
+ const { service, db } = makeService();
179
+ service.configureCircuitPersistence(true, 30_000);
180
+ await service.init();
181
+ await service.primeFromLocal(primeCtx);
182
+ const processor = processorInstances[0];
183
+
184
+ service.registerQueryPlan(plan);
185
+ service.ingest('thing', 'CREATE', 'thing:a', { id: 'thing:a' });
186
+ service.unregisterQueryPlan('q1');
187
+
188
+ // Marked dirty, but the write waits for the checkpoint interval.
189
+ expect(processor.save_store_state).not.toHaveBeenCalled();
190
+ expect(db.putSnapshot).not.toHaveBeenCalled();
191
+ service.stopCheckpoints();
192
+ });
193
+
194
+ it('writes at most one snapshot per checkpoint tick, and none for a handful of rows', async () => {
195
+ vi.useFakeTimers();
196
+ try {
197
+ const { service, db } = makeService();
198
+ service.configureCircuitPersistence(true, 1000);
199
+ await service.init();
200
+ await service.primeFromLocal(primeCtx);
201
+ const processor = processorInstances[0];
202
+
203
+ // Below the row threshold: the tick stays quiet.
204
+ for (let i = 0; i < 10; i++) {
205
+ service.ingest('thing', 'CREATE', `thing:${i}`, { id: `thing:${i}` });
206
+ }
207
+ await vi.advanceTimersByTimeAsync(1000);
208
+ expect(processor.save_store_state).not.toHaveBeenCalled();
209
+
210
+ for (let i = 10; i < 60; i++) {
211
+ service.ingest('thing', 'CREATE', `thing:${i}`, { id: `thing:${i}` });
212
+ }
213
+ await vi.advanceTimersByTimeAsync(1000);
214
+ expect(processor.save_store_state).toHaveBeenCalledTimes(1);
215
+ expect(db.putSnapshot).toHaveBeenCalledTimes(1);
216
+
217
+ // Idle interval: nothing changed, so nothing is serialized.
218
+ await vi.advanceTimersByTimeAsync(1000);
219
+ expect(processor.save_store_state).toHaveBeenCalledTimes(1);
220
+
221
+ service.stopCheckpoints();
222
+ } finally {
223
+ vi.useRealTimers();
224
+ }
225
+ });
226
+ });
@@ -127,7 +127,7 @@ describe('StreamProcessor Ingest Behavior', () => {
127
127
  const params = { id: new MockRecordId('user', '2dng4ngbicbl0scod87i') };
128
128
  const normalizedParams = normalizeValue(params);
129
129
 
130
- const ingestedRecord = {
130
+ const _ingestedRecord = {
131
131
  id: 'user:2dng4ngbicbl0scod87i',
132
132
  username: 'sara',
133
133
  };
@@ -1,9 +1,17 @@
1
- import { RecordVersionArray } from '../../types';
1
+ import type { RecordVersionArray } from '../../types';
2
2
 
3
3
  export interface WasmStreamUpdate {
4
4
  query_id: string;
5
5
  result_hash: string;
6
6
  result_data: RecordVersionArray; // Match Rust 'result_data' field
7
+ // Per-phase SSP processing time (ms). Ingest path: store_apply/circuit_step/
8
+ // transform. Register path: parse/plan/snapshot. Unused side is 0.
9
+ timing_store_apply_ms?: number;
10
+ timing_circuit_step_ms?: number;
11
+ timing_transform_ms?: number;
12
+ timing_parse_ms?: number;
13
+ timing_plan_ms?: number;
14
+ timing_snapshot_ms?: number;
7
15
  }
8
16
 
9
17
  export interface WasmQueryConfig {
@@ -23,9 +31,57 @@ export interface WasmIngestItem {
23
31
  version?: number;
24
32
  }
25
33
 
26
- // Interface matching the SpookyProcessor class from WASM
34
+ /** `register_view`'s result: the initial update plus, under projection, the
35
+ * fields this plan evaluates that already-stored rows were kept without. */
36
+ export interface WasmRegistration extends WasmStreamUpdate {
37
+ missing_fields?: Record<string, string[]>;
38
+ }
39
+
40
+ /** `reconcile`'s result. */
41
+ export interface WasmReconciled {
42
+ /** Ids (caller's spelling) whose body the store lacks or holds stale. */
43
+ fetch: string[];
44
+ /** Rows deleted because the caller's list did not have them. */
45
+ deleted: number;
46
+ /** View updates produced by those deletes. */
47
+ updates: WasmStreamUpdate[];
48
+ }
49
+
50
+ // Interface matching the Sp00kyProcessor class from WASM
27
51
  export interface WasmProcessor {
28
52
  ingest(table: string, op: string, id: string, record: any): WasmStreamUpdate[];
29
- register_view(config: WasmQueryConfig): WasmStreamUpdate | undefined;
53
+ // Bulk ingest: ONE circuit step for the whole array, returning the coalesced
54
+ // updates. Optional because a stale WASM build won't have it — callers guard
55
+ // with `typeof x === 'function'` and fall back to a loop over `ingest`.
56
+ ingest_many?(items: WasmIngestItem[]): WasmStreamUpdate[];
57
+ register_view(config: WasmQueryConfig): WasmRegistration | undefined;
30
58
  unregister_view(id: string): void;
59
+ // Seed per-table `select` permission predicates ({ [table]: whereText }) so
60
+ // register_view can inject them instead of default-denying the table.
61
+ set_permissions(permissions: Record<string, string>): void;
62
+ // Persistence hooks: present on current WASM builds, absent on stale ones.
63
+ // Always guarded with `typeof x === 'function'` before calling so an older
64
+ // build degrades gracefully instead of throwing.
65
+ load_state?(state: string): void;
66
+ save_state?(): string;
67
+ // Store-only snapshot as bytes (views are re-registered per session, so
68
+ // they are never persisted). `load_store_state` installs it UNDER the views
69
+ // already registered and returns their re-primed results.
70
+ save_store_state?(): Uint8Array;
71
+ load_store_state?(bytes: Uint8Array): WasmStreamUpdate[];
72
+ // Client-side `_00_rv` catch-up against the durable store's `[id, rv][]`.
73
+ reconcile?(table: string, entries: [string, number][]): WasmReconciled;
74
+ max_row_versions?(): Record<string, number>;
75
+ // Row-arena hygiene. `compact` decodes every row, so checkpoint-time only.
76
+ compact?(): number;
77
+ dead_bytes?(): number;
78
+ live_bytes?(): number;
79
+ // Keep only the fields registered plans evaluate per stored row.
80
+ set_projection?(enabled: boolean): void;
81
+ size_report?(): unknown;
82
+ // wasm-bindgen destructor. Releases the circuit (store + every view cache)
83
+ // inside wasm linear memory. Without it the bytes only come back when V8
84
+ // happens to GC the JS wrapper, which it has no reason to hurry since it
85
+ // cannot see how much wasm memory the wrapper is holding.
86
+ free?(): void;
31
87
  }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Per-tab connection to the SharedWorker broker. Owns: the hello handshake,
3
+ * pong replies + the ping watchdog (a silent broker means the browser killed
4
+ * the SharedWorker; reconnect with a fresh one), visibility reporting, and the
5
+ * pagehide/freeze shutdown notifications. Role decisions and port handling are
6
+ * the coordinator's job; this class only surfaces them as events.
7
+ */
8
+ import type { Logger } from '../logger/index';
9
+ import {
10
+ PING_INTERVAL_MS,
11
+ PONG_TIMEOUT_MS,
12
+ type BrokerToTabMessage,
13
+ type HeldLeadership,
14
+ type TabId,
15
+ type TabToBrokerMessage,
16
+ type TabVisibility,
17
+ } from './protocol';
18
+
19
+ export interface BrokerClientEvents {
20
+ onBecomeLeader(msg: {
21
+ leadershipId: number;
22
+ forceTakeover: boolean;
23
+ allowMemoryFallback: boolean;
24
+ resumeHeld: boolean;
25
+ }): void;
26
+ onDemote(leadershipId: number): void;
27
+ onLeaderReady(leadershipId: number, leaderTabId: TabId): void;
28
+ /** Leader side: the broker minted ports for a follower. */
29
+ onAttachFollowerPorts(
30
+ followerTabId: TabId,
31
+ leadershipId: number,
32
+ dbPort: MessagePort,
33
+ syncPort: MessagePort
34
+ ): void;
35
+ /** Follower side: our ports into the current leader. */
36
+ onUseFollowerPorts(
37
+ leaderTabId: TabId,
38
+ leadershipId: number,
39
+ dbPort: MessagePort,
40
+ syncPort: MessagePort
41
+ ): void;
42
+ onCloseFollowerPorts(leadershipId: number): void;
43
+ onUnsupported(reason: string): void;
44
+ /** The broker restarted (instance id changed or pings stopped); the client
45
+ * already re-helloed. Roles survive only via become-leader/use-ports. */
46
+ onBrokerRestarted(): void;
47
+ }
48
+
49
+ /** What the tab reports about itself on (re)connect. */
50
+ export interface BrokerHello {
51
+ fingerprint: string;
52
+ bucketId: string;
53
+ heldLeadership: () => HeldLeadership | null;
54
+ }
55
+
56
+ export class TabBrokerClient {
57
+ private worker: SharedWorker | null = null;
58
+ private brokerInstanceId: string | null = null;
59
+ private watchdog: ReturnType<typeof setInterval> | null = null;
60
+ private lastPingAt = 0;
61
+ private closed = false;
62
+ private helloState: BrokerHello | null = null;
63
+
64
+ constructor(
65
+ private workerUrl: URL,
66
+ readonly tabId: TabId,
67
+ private events: BrokerClientEvents,
68
+ private logger: Logger
69
+ ) {}
70
+
71
+ /** Connect and hello. Resolves once broker-hello arrives (or rejects on
72
+ * timeout / SharedWorker error, in which case the caller goes solo). */
73
+ connect(hello: BrokerHello): Promise<void> {
74
+ this.helloState = hello;
75
+ this.installLifecycleListeners();
76
+ return this.openWorker();
77
+ }
78
+
79
+ private openWorker(): Promise<void> {
80
+ if (this.closed || !this.helloState) return Promise.reject(new Error('broker client closed'));
81
+ return new Promise<void>((resolve, reject) => {
82
+ let settled = false;
83
+ const settle = (fn: () => void) => {
84
+ if (!settled) {
85
+ settled = true;
86
+ fn();
87
+ }
88
+ };
89
+ let worker: SharedWorker;
90
+ try {
91
+ worker = new SharedWorker(this.workerUrl, { type: 'module', name: 'sp00ky-tabs-broker' });
92
+ } catch (e) {
93
+ reject(e);
94
+ return;
95
+ }
96
+ this.worker = worker;
97
+ const timeout = setTimeout(() => settle(() => reject(new Error('broker hello timeout'))), 5000);
98
+ worker.onerror = () => {
99
+ clearTimeout(timeout);
100
+ settle(() => reject(new Error('SharedWorker failed to start')));
101
+ };
102
+ worker.port.onmessage = (ev: MessageEvent) => {
103
+ const msg = ev.data as BrokerToTabMessage;
104
+ if (!msg || typeof msg !== 'object') return;
105
+ if (msg.type === 'broker-hello') {
106
+ clearTimeout(timeout);
107
+ this.noteInstance(msg.brokerInstanceId);
108
+ this.lastPingAt = Date.now();
109
+ this.startWatchdog();
110
+ settle(resolve);
111
+ return;
112
+ }
113
+ this.handleMessage(msg, (ev.ports ?? []) as readonly MessagePort[]);
114
+ };
115
+ worker.port.onmessageerror = () => this.reconnect('messageerror');
116
+ worker.port.start?.();
117
+ this.send({
118
+ type: 'hello',
119
+ tabId: this.tabId,
120
+ fingerprint: this.helloState!.fingerprint,
121
+ bucketId: this.helloState!.bucketId,
122
+ visibility: currentVisibility(),
123
+ heldLeadership: this.helloState!.heldLeadership(),
124
+ });
125
+ });
126
+ }
127
+
128
+ private noteInstance(id: string): void {
129
+ if (this.brokerInstanceId !== null && this.brokerInstanceId !== id) {
130
+ this.events.onBrokerRestarted();
131
+ }
132
+ this.brokerInstanceId = id;
133
+ }
134
+
135
+ private handleMessage(msg: BrokerToTabMessage, ports: readonly MessagePort[]): void {
136
+ // Any message from a NEW instance implies a restart happened while our
137
+ // watchdog had not fired yet.
138
+ if ('brokerInstanceId' in msg) this.noteInstance(msg.brokerInstanceId);
139
+ switch (msg.type) {
140
+ case 'ping':
141
+ this.lastPingAt = Date.now();
142
+ this.send({ type: 'pong', tabId: this.tabId });
143
+ break;
144
+ case 'become-leader':
145
+ this.events.onBecomeLeader({
146
+ leadershipId: msg.leadershipId,
147
+ forceTakeover: msg.forceTakeover,
148
+ allowMemoryFallback: msg.allowMemoryFallback,
149
+ resumeHeld: msg.resumeHeld,
150
+ });
151
+ break;
152
+ case 'demote':
153
+ this.events.onDemote(msg.leadershipId);
154
+ break;
155
+ case 'leader-ready':
156
+ this.events.onLeaderReady(msg.leadershipId, msg.leaderTabId);
157
+ break;
158
+ case 'attach-follower-ports':
159
+ if (ports.length >= 2) {
160
+ this.events.onAttachFollowerPorts(msg.followerTabId, msg.leadershipId, ports[0], ports[1]);
161
+ }
162
+ break;
163
+ case 'use-follower-ports':
164
+ if (ports.length >= 2) {
165
+ this.events.onUseFollowerPorts(msg.leaderTabId, msg.leadershipId, ports[0], ports[1]);
166
+ }
167
+ break;
168
+ case 'close-follower-ports':
169
+ this.events.onCloseFollowerPorts(msg.leadershipId);
170
+ break;
171
+ case 'unsupported':
172
+ this.logger.warn(
173
+ { reason: msg.reason, Category: 'sp00ky-client::BrokerClient' },
174
+ 'Broker rejected this tab; running solo'
175
+ );
176
+ this.events.onUnsupported(msg.reason);
177
+ this.close();
178
+ break;
179
+ }
180
+ }
181
+
182
+ send(msg: TabToBrokerMessage): void {
183
+ try {
184
+ this.worker?.port.postMessage(msg);
185
+ } catch {
186
+ /* dead port; watchdog reconnects */
187
+ }
188
+ }
189
+
190
+ /** The tab moved to a different bucket: re-hello under the new namespace.
191
+ * The broker treats a re-hello of a known tabId as leave + rejoin. */
192
+ rehello(hello: BrokerHello): Promise<void> {
193
+ this.helloState = hello;
194
+ if (!this.worker) return this.openWorker();
195
+ this.send({
196
+ type: 'hello',
197
+ tabId: this.tabId,
198
+ fingerprint: hello.fingerprint,
199
+ bucketId: hello.bucketId,
200
+ visibility: currentVisibility(),
201
+ heldLeadership: hello.heldLeadership(),
202
+ });
203
+ return Promise.resolve();
204
+ }
205
+
206
+ private startWatchdog(): void {
207
+ if (this.watchdog) return;
208
+ this.watchdog = setInterval(() => {
209
+ if (Date.now() - this.lastPingAt > PING_INTERVAL_MS + PONG_TIMEOUT_MS) {
210
+ this.reconnect('broker pings stopped');
211
+ }
212
+ }, PING_INTERVAL_MS);
213
+ }
214
+
215
+ private reconnect(reason: string): void {
216
+ if (this.closed) return;
217
+ this.logger.info(
218
+ { reason, Category: 'sp00ky-client::BrokerClient' },
219
+ 'Reconnecting to the tabs broker'
220
+ );
221
+ try {
222
+ this.worker?.port.close();
223
+ } catch {
224
+ /* ignore */
225
+ }
226
+ this.worker = null;
227
+ this.brokerInstanceId = null;
228
+ this.events.onBrokerRestarted();
229
+ void this.openWorker().catch((e) => {
230
+ this.logger.warn(
231
+ { err: e, Category: 'sp00ky-client::BrokerClient' },
232
+ 'Broker reconnect failed; staying detached until the next watchdog tick'
233
+ );
234
+ });
235
+ }
236
+
237
+ private installLifecycleListeners(): void {
238
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
239
+ document.addEventListener('visibilitychange', () => {
240
+ if (!this.helloState) return;
241
+ this.send({
242
+ type: 'visibility',
243
+ tabId: this.tabId,
244
+ bucketId: this.helloState.bucketId,
245
+ visibility: currentVisibility(),
246
+ });
247
+ });
248
+ // pagehide fires for both close and bfcache entry; treat both as a full
249
+ // departure (a bfcache restore re-hellos via pageshow below). freeze is
250
+ // Chromium's battery-saver signal; leaving proactively hands leadership
251
+ // over in ~0s instead of waiting out the pong timeout.
252
+ const leave = () => {
253
+ if (this.helloState) {
254
+ this.send({ type: 'shutdown', tabId: this.tabId, bucketId: this.helloState.bucketId });
255
+ }
256
+ };
257
+ window.addEventListener('pagehide', leave);
258
+ document.addEventListener('freeze', leave);
259
+ window.addEventListener('pageshow', (e: PageTransitionEvent) => {
260
+ if (e.persisted && this.helloState) void this.rehello(this.helloState);
261
+ });
262
+ document.addEventListener('resume', () => {
263
+ if (this.helloState) void this.rehello(this.helloState);
264
+ });
265
+ }
266
+
267
+ close(): void {
268
+ this.closed = true;
269
+ if (this.watchdog) clearInterval(this.watchdog);
270
+ this.watchdog = null;
271
+ try {
272
+ this.worker?.port.close();
273
+ } catch {
274
+ /* ignore */
275
+ }
276
+ this.worker = null;
277
+ }
278
+ }
279
+
280
+ function currentVisibility(): TabVisibility {
281
+ if (typeof document === 'undefined') return 'visible';
282
+ return document.visibilityState === 'hidden' ? 'hidden' : 'visible';
283
+ }