@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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/AGENTS.md +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|