@spooky-sync/core 0.0.1-canary.154 → 0.0.1-canary.156
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/dist/index.d.ts +151 -0
- package/dist/index.js +1520 -120
- package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
- package/dist/sqlite-worker.js +268 -108
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +32 -0
- package/package.json +4 -4
- package/scripts/check-broker-bundle.mjs +33 -0
- package/src/modules/cache/index.ts +35 -0
- package/src/modules/data/index.ts +13 -3
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/devtools/index.ts +12 -1
- package/src/modules/devtools/storage-info.ts +27 -0
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
- package/src/modules/sync/queue/queue-up.ts +74 -38
- package/src/modules/sync/scheduler.ts +4 -2
- package/src/modules/sync/sync.ts +186 -8
- package/src/services/database/engine-factory.ts +3 -2
- package/src/services/database/sqlite-cache-engine.test.ts +160 -103
- package/src/services/database/sqlite-cache-engine.ts +285 -72
- package/src/services/database/sqlite-open.ts +36 -1
- package/src/services/database/sqlite-select.test.ts +13 -46
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +219 -0
- package/src/services/database/sqlite-worker.ts +340 -55
- package/src/services/stream-processor/index.ts +11 -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 +192 -0
- package/src/services/tabs/coordinator.ts +567 -0
- package/src/services/tabs/fake-ports.fixture.ts +70 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +239 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +581 -0
- package/src/sp00ky.ts +176 -6
- package/src/types.ts +25 -0
- package/tsdown.config.ts +19 -10
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
|
|
3
|
+
import { installBrokerGlobals } 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) {
|
|
66
|
+
const { hooks, log } = makeHooks();
|
|
67
|
+
const coordinator = new TabsCoordinator({
|
|
68
|
+
tabId,
|
|
69
|
+
fingerprint: 'fp-coord-test',
|
|
70
|
+
hooks,
|
|
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
|
+
it('moves buckets by leaving and rejoining: old namespace re-elects', async () => {
|
|
175
|
+
const a = makeCoordinator('tab-1');
|
|
176
|
+
await a.coordinator.start('anon');
|
|
177
|
+
const b = makeCoordinator('tab-2');
|
|
178
|
+
await b.coordinator.start('anon');
|
|
179
|
+
|
|
180
|
+
const rolesB: string[] = [];
|
|
181
|
+
b.coordinator.onRoleChange((r) => rolesB.push(r));
|
|
182
|
+
const newRole = await a.coordinator.moveToBucket('user1');
|
|
183
|
+
|
|
184
|
+
expect(newRole).toBe('leader');
|
|
185
|
+
expect(a.log.adoptOwner.map((o) => o.bucketId)).toEqual(['anon', 'user1']);
|
|
186
|
+
expect(a.log.released).toBeGreaterThan(0);
|
|
187
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
188
|
+
expect(rolesB).toContain('leader');
|
|
189
|
+
await b.coordinator.stop();
|
|
190
|
+
await a.coordinator.stop();
|
|
191
|
+
});
|
|
192
|
+
});
|