@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201
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 +2184 -54
- package/dist/index.js +11515 -2399
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +421 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +688 -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 +35 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- 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 +462 -0
- package/src/modules/data/data.rebind.test.ts +147 -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 +1580 -130
- 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 +386 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -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 +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +35 -6
- 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 +156 -0
- package/src/modules/sync/scheduler.ts +158 -11
- 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 +134 -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.ts +1558 -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 +160 -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 +32 -12
- package/src/services/database/engine-factory.ts +33 -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 +478 -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 +558 -0
- package/src/services/database/sqlite-cache-engine.ts +1257 -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-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 +106 -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 +121 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +221 -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 +141 -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 +444 -52
- 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 +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +244 -0
- package/src/services/tabs/coordinator.ts +576 -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 +242 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +586 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1543 -0
- package/src/types.ts +496 -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 +73 -13
- package/src/utils/parser.ts +3 -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,244 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
|
|
3
|
+
import { installBrokerGlobals, installFakeLocks } from './fake-ports.fixture';
|
|
4
|
+
import { TabsCoordinator, type CoordinatorHooks, type LeaderSyncHub, type SyncForwarder } from './coordinator';
|
|
5
|
+
import type { StorageHealth } from '../../types';
|
|
6
|
+
import type { LeaderToFollowerMessage } from './protocol';
|
|
7
|
+
|
|
8
|
+
function makeLogger(): any {
|
|
9
|
+
const noop = () => {};
|
|
10
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
11
|
+
l.child = () => l;
|
|
12
|
+
return l;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface HookLog {
|
|
16
|
+
adoptOwner: { bucketId: string; workerLockName: string; resumeHeld: boolean }[];
|
|
17
|
+
adoptAttached: { bucketId: string; storageHealth: StorageHealth }[];
|
|
18
|
+
released: number;
|
|
19
|
+
leaderLost: string[];
|
|
20
|
+
exposedPorts: string[];
|
|
21
|
+
hub: LeaderSyncHub | null;
|
|
22
|
+
forwarder: SyncForwarder | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeHooks(): { hooks: CoordinatorHooks; log: HookLog } {
|
|
26
|
+
const log: HookLog = {
|
|
27
|
+
adoptOwner: [],
|
|
28
|
+
adoptAttached: [],
|
|
29
|
+
released: 0,
|
|
30
|
+
leaderLost: [],
|
|
31
|
+
exposedPorts: [],
|
|
32
|
+
hub: null,
|
|
33
|
+
forwarder: null,
|
|
34
|
+
};
|
|
35
|
+
const hooks: CoordinatorHooks = {
|
|
36
|
+
async adoptOwner(bucketId, opts) {
|
|
37
|
+
log.adoptOwner.push({ bucketId, workerLockName: opts.workerLockName, resumeHeld: opts.resumeHeld });
|
|
38
|
+
return { status: 'persistent', fallback: false, role: 'leader' };
|
|
39
|
+
},
|
|
40
|
+
async adoptAttached(_dbPort, snapshot) {
|
|
41
|
+
log.adoptAttached.push({ bucketId: snapshot.bucketId, storageHealth: snapshot.storageHealth });
|
|
42
|
+
},
|
|
43
|
+
async releaseOwnership() {
|
|
44
|
+
log.released++;
|
|
45
|
+
},
|
|
46
|
+
onLeaderLost(reason) {
|
|
47
|
+
log.leaderLost.push(reason);
|
|
48
|
+
},
|
|
49
|
+
async exposeClientPort(clientId) {
|
|
50
|
+
log.exposedPorts.push(clientId);
|
|
51
|
+
},
|
|
52
|
+
async removeClientPort() {},
|
|
53
|
+
async becomeSyncLeader(hub) {
|
|
54
|
+
log.hub = hub;
|
|
55
|
+
},
|
|
56
|
+
becomeSyncFollower(forwarder) {
|
|
57
|
+
log.forwarder = forwarder;
|
|
58
|
+
},
|
|
59
|
+
becomeSyncSolo() {},
|
|
60
|
+
currentStorageHealth: () => ({ status: 'persistent', fallback: false, role: 'leader' }),
|
|
61
|
+
};
|
|
62
|
+
return { hooks, log };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeCoordinator(tabId: string, overrides: Partial<CoordinatorHooks> = {}) {
|
|
66
|
+
const { hooks, log } = makeHooks();
|
|
67
|
+
const coordinator = new TabsCoordinator({
|
|
68
|
+
tabId,
|
|
69
|
+
fingerprint: 'fp-coord-test',
|
|
70
|
+
hooks: { ...hooks, ...overrides },
|
|
71
|
+
logger: makeLogger(),
|
|
72
|
+
});
|
|
73
|
+
return { coordinator, log };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let restoreGlobals: () => void;
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
__resetBrokerForTests();
|
|
79
|
+
restoreGlobals = installBrokerGlobals(handleConnect);
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
restoreGlobals();
|
|
83
|
+
__resetBrokerForTests();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// End-to-end role assignment: two real coordinators, the real broker module,
|
|
87
|
+
// fake SharedWorker/MessageChannel plumbing (node has no SharedWorker).
|
|
88
|
+
describe('TabsCoordinator integration', () => {
|
|
89
|
+
it('boots the first tab as leader with a per-leadership worker lock name', async () => {
|
|
90
|
+
const { coordinator, log } = makeCoordinator('tab-1');
|
|
91
|
+
const role = await coordinator.start('anon');
|
|
92
|
+
expect(role).toBe('leader');
|
|
93
|
+
expect(log.adoptOwner).toHaveLength(1);
|
|
94
|
+
expect(log.adoptOwner[0].bucketId).toBe('anon');
|
|
95
|
+
expect(log.adoptOwner[0].workerLockName).toMatch(/^sp00ky-tabs:fp-coord-test:anon:worker:\d+$/);
|
|
96
|
+
expect(log.hub).not.toBeNull();
|
|
97
|
+
await coordinator.stop();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('attaches the second tab as follower with the leader-reported snapshot', async () => {
|
|
101
|
+
const a = makeCoordinator('tab-1');
|
|
102
|
+
await a.coordinator.start('anon');
|
|
103
|
+
const b = makeCoordinator('tab-2');
|
|
104
|
+
const role = await b.coordinator.start('anon');
|
|
105
|
+
|
|
106
|
+
expect(role).toBe('follower');
|
|
107
|
+
// Leader forwarded the follower's db port into its worker...
|
|
108
|
+
expect(a.log.exposedPorts).toEqual(['tab-2']);
|
|
109
|
+
// ...and the follower adopted the leader's store snapshot.
|
|
110
|
+
expect(b.log.adoptAttached).toHaveLength(1);
|
|
111
|
+
expect(b.log.adoptAttached[0].bucketId).toBe('anon');
|
|
112
|
+
expect(b.log.adoptAttached[0].storageHealth.status).toBe('persistent');
|
|
113
|
+
expect(b.log.forwarder).not.toBeNull();
|
|
114
|
+
await b.coordinator.stop();
|
|
115
|
+
await a.coordinator.stop();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('relays hub messages to the follower forwarder (ingest fan-out)', async () => {
|
|
119
|
+
const a = makeCoordinator('tab-1');
|
|
120
|
+
await a.coordinator.start('anon');
|
|
121
|
+
const b = makeCoordinator('tab-2');
|
|
122
|
+
await b.coordinator.start('anon');
|
|
123
|
+
|
|
124
|
+
const seen: LeaderToFollowerMessage[] = [];
|
|
125
|
+
b.log.forwarder!.onLeaderMessage = (msg) => seen.push(msg);
|
|
126
|
+
a.log.hub!.relayIngest([{ table: 'game', op: 'CREATE', id: 'game:1', record: { id: 'game:1' } }]);
|
|
127
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
128
|
+
|
|
129
|
+
const relay = seen.filter((m) => m.type === 'ingest-relay');
|
|
130
|
+
expect(relay).toHaveLength(1);
|
|
131
|
+
expect((relay[0] as { seq: number }).seq).toBe(1);
|
|
132
|
+
await b.coordinator.stop();
|
|
133
|
+
await a.coordinator.stop();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('forwards follower messages to the leader hub (mutation notify)', async () => {
|
|
137
|
+
const a = makeCoordinator('tab-1');
|
|
138
|
+
await a.coordinator.start('anon');
|
|
139
|
+
const b = makeCoordinator('tab-2');
|
|
140
|
+
await b.coordinator.start('anon');
|
|
141
|
+
|
|
142
|
+
const seen: { tabId: string; msg: unknown }[] = [];
|
|
143
|
+
a.log.hub!.onFollowerMessage = (tabId, msg) => seen.push({ tabId, msg });
|
|
144
|
+
b.log.forwarder!.mutationEnqueued('_00_pending_mutations:0000000000123_0001_tab2');
|
|
145
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
146
|
+
|
|
147
|
+
const notify = seen.filter((s) => (s.msg as { type: string }).type === 'mutation-enqueued');
|
|
148
|
+
expect(notify).toHaveLength(1);
|
|
149
|
+
expect(notify[0].tabId).toBe('tab-2');
|
|
150
|
+
await b.coordinator.stop();
|
|
151
|
+
await a.coordinator.stop();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('promotes the follower when the leader stops (failover)', async () => {
|
|
155
|
+
const a = makeCoordinator('tab-1');
|
|
156
|
+
await a.coordinator.start('anon');
|
|
157
|
+
const b = makeCoordinator('tab-2');
|
|
158
|
+
await b.coordinator.start('anon');
|
|
159
|
+
|
|
160
|
+
const roles: string[] = [];
|
|
161
|
+
b.coordinator.onRoleChange((r) => roles.push(r));
|
|
162
|
+
await a.coordinator.stop();
|
|
163
|
+
// Election + promotion are async hops across the fake ports.
|
|
164
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
165
|
+
|
|
166
|
+
expect(roles).toContain('leader');
|
|
167
|
+
expect(b.log.adoptOwner).toHaveLength(1);
|
|
168
|
+
// The new leadership got a HIGHER id, so a fresh worker lock name.
|
|
169
|
+
expect(b.log.adoptOwner[0].workerLockName).not.toBe(a.log.adoptOwner[0].workerLockName);
|
|
170
|
+
expect(b.log.hub).not.toBeNull();
|
|
171
|
+
await b.coordinator.stop();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// A failed promotion must give the leader TAB lock back. The name is shared
|
|
175
|
+
// per namespace, so a leaked one makes every later election fail with
|
|
176
|
+
// 'leader tab lock unavailable': all tabs time out in start(), boot solo, and
|
|
177
|
+
// then contend for the OPFS pool individually — one busy pool wedging the
|
|
178
|
+
// whole app into the pre-shared-tabs behavior it exists to replace.
|
|
179
|
+
describe('with Web Locks', () => {
|
|
180
|
+
let locks: ReturnType<typeof installFakeLocks>;
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
locks = installFakeLocks();
|
|
183
|
+
});
|
|
184
|
+
afterEach(() => locks.restore());
|
|
185
|
+
|
|
186
|
+
/** A tab whose store can never open. Resolves once it has failed a
|
|
187
|
+
* promotion, so tests don't wait out start()'s 15s timeout. */
|
|
188
|
+
function makeDoomedTab(tabId: string) {
|
|
189
|
+
let failed!: () => void;
|
|
190
|
+
const hasFailed = new Promise<void>((r) => {
|
|
191
|
+
failed = r;
|
|
192
|
+
});
|
|
193
|
+
const tab = makeCoordinator(tabId, {
|
|
194
|
+
async adoptOwner() {
|
|
195
|
+
queueMicrotask(failed);
|
|
196
|
+
throw new Error('opfs-unavailable: NoModificationAllowedError (after 10 attempts)');
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
// start() only settles on a role or the timeout; neither is the point.
|
|
200
|
+
void tab.coordinator.start('anon').catch(() => {});
|
|
201
|
+
return { ...tab, hasFailed };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
it('releases the leader tab lock when adoptOwner throws', async () => {
|
|
205
|
+
const a = makeDoomedTab('tab-1');
|
|
206
|
+
await a.hasFailed;
|
|
207
|
+
// Well inside the 1s re-nomination backoff, so a held lock here is a leak
|
|
208
|
+
// and not the next attempt's legitimate acquisition.
|
|
209
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
210
|
+
expect(locks.heldNames()).not.toContain('sp00ky-tabs:fp-coord-test:anon:tab');
|
|
211
|
+
await a.coordinator.stop();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('lets a later tab lead after another tab failed to open the store', async () => {
|
|
215
|
+
const a = makeDoomedTab('tab-1');
|
|
216
|
+
await a.hasFailed;
|
|
217
|
+
|
|
218
|
+
const b = makeCoordinator('tab-2');
|
|
219
|
+
await expect(b.coordinator.start('anon')).resolves.toBe('leader');
|
|
220
|
+
expect(b.log.adoptOwner).toHaveLength(1);
|
|
221
|
+
await b.coordinator.stop();
|
|
222
|
+
await a.coordinator.stop();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('moves buckets by leaving and rejoining: old namespace re-elects', async () => {
|
|
227
|
+
const a = makeCoordinator('tab-1');
|
|
228
|
+
await a.coordinator.start('anon');
|
|
229
|
+
const b = makeCoordinator('tab-2');
|
|
230
|
+
await b.coordinator.start('anon');
|
|
231
|
+
|
|
232
|
+
const rolesB: string[] = [];
|
|
233
|
+
b.coordinator.onRoleChange((r) => rolesB.push(r));
|
|
234
|
+
const newRole = await a.coordinator.moveToBucket('user1');
|
|
235
|
+
|
|
236
|
+
expect(newRole).toBe('leader');
|
|
237
|
+
expect(a.log.adoptOwner.map((o) => o.bucketId)).toEqual(['anon', 'user1']);
|
|
238
|
+
expect(a.log.released).toBeGreaterThan(0);
|
|
239
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
240
|
+
expect(rolesB).toContain('leader');
|
|
241
|
+
await b.coordinator.stop();
|
|
242
|
+
await a.coordinator.stop();
|
|
243
|
+
});
|
|
244
|
+
});
|