@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.291
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 +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2026 -137
- package/dist/index.js +14941 -2433
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1765 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +370 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- 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/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +750 -96
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -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 +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- 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 +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +146 -61
- 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/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- 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 +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -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 +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -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.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- 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 +58 -2
- 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 +386 -0
- package/src/services/tabs/coordinator.ts +638 -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 +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
|
|
3
|
+
import { fakeChannel, flush, installBrokerGlobals, installFakeLocks } from './fake-ports.fixture';
|
|
4
|
+
import {
|
|
5
|
+
TabsCoordinator,
|
|
6
|
+
SyncForwarder,
|
|
7
|
+
type CoordinatorHooks,
|
|
8
|
+
type LeaderSyncHub,
|
|
9
|
+
} from './coordinator';
|
|
10
|
+
import type { StorageHealth } from '../../types';
|
|
11
|
+
import type { LeaderToFollowerMessage } from './protocol';
|
|
12
|
+
|
|
13
|
+
function makeLogger(): any {
|
|
14
|
+
const noop = () => {};
|
|
15
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
16
|
+
l.child = () => l;
|
|
17
|
+
return l;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface HookLog {
|
|
21
|
+
adoptOwner: { bucketId: string; workerLockName: string; resumeHeld: boolean }[];
|
|
22
|
+
adoptAttached: { bucketId: string; storageHealth: StorageHealth }[];
|
|
23
|
+
released: number;
|
|
24
|
+
leaderLost: string[];
|
|
25
|
+
exposedPorts: string[];
|
|
26
|
+
hub: LeaderSyncHub | null;
|
|
27
|
+
forwarder: SyncForwarder | null;
|
|
28
|
+
leaderDuties: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeHooks(): { hooks: CoordinatorHooks; log: HookLog } {
|
|
32
|
+
const log: HookLog = {
|
|
33
|
+
adoptOwner: [],
|
|
34
|
+
adoptAttached: [],
|
|
35
|
+
released: 0,
|
|
36
|
+
leaderLost: [],
|
|
37
|
+
exposedPorts: [],
|
|
38
|
+
hub: null,
|
|
39
|
+
forwarder: null,
|
|
40
|
+
leaderDuties: 0,
|
|
41
|
+
};
|
|
42
|
+
const hooks: CoordinatorHooks = {
|
|
43
|
+
async adoptOwner(bucketId, opts) {
|
|
44
|
+
log.adoptOwner.push({ bucketId, workerLockName: opts.workerLockName, resumeHeld: opts.resumeHeld });
|
|
45
|
+
return { status: 'persistent', fallback: false, role: 'leader' };
|
|
46
|
+
},
|
|
47
|
+
async adoptAttached(_dbPort, snapshot) {
|
|
48
|
+
log.adoptAttached.push({ bucketId: snapshot.bucketId, storageHealth: snapshot.storageHealth });
|
|
49
|
+
},
|
|
50
|
+
async releaseOwnership() {
|
|
51
|
+
log.released++;
|
|
52
|
+
},
|
|
53
|
+
onLeaderLost(reason) {
|
|
54
|
+
log.leaderLost.push(reason);
|
|
55
|
+
},
|
|
56
|
+
async exposeClientPort(clientId) {
|
|
57
|
+
log.exposedPorts.push(clientId);
|
|
58
|
+
},
|
|
59
|
+
async removeClientPort() {},
|
|
60
|
+
becomeSyncLeader(hub) {
|
|
61
|
+
log.hub = hub;
|
|
62
|
+
},
|
|
63
|
+
async resumeSyncLeaderDuties() {
|
|
64
|
+
log.leaderDuties++;
|
|
65
|
+
},
|
|
66
|
+
becomeSyncFollower(forwarder) {
|
|
67
|
+
log.forwarder = forwarder;
|
|
68
|
+
},
|
|
69
|
+
becomeSyncSolo() {},
|
|
70
|
+
currentStorageHealth: () => ({ status: 'persistent', fallback: false, role: 'leader' }),
|
|
71
|
+
};
|
|
72
|
+
return { hooks, log };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeCoordinator(tabId: string, overrides: Partial<CoordinatorHooks> = {}) {
|
|
76
|
+
const { hooks, log } = makeHooks();
|
|
77
|
+
const coordinator = new TabsCoordinator({
|
|
78
|
+
tabId,
|
|
79
|
+
fingerprint: 'fp-coord-test',
|
|
80
|
+
hooks: { ...hooks, ...overrides },
|
|
81
|
+
logger: makeLogger(),
|
|
82
|
+
});
|
|
83
|
+
return { coordinator, log };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let restoreGlobals: () => void;
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
__resetBrokerForTests();
|
|
89
|
+
restoreGlobals = installBrokerGlobals(handleConnect);
|
|
90
|
+
});
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
restoreGlobals();
|
|
93
|
+
__resetBrokerForTests();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// End-to-end role assignment: two real coordinators, the real broker module,
|
|
97
|
+
// fake SharedWorker/MessageChannel plumbing (node has no SharedWorker).
|
|
98
|
+
describe('TabsCoordinator integration', () => {
|
|
99
|
+
it('boots the first tab as leader with a per-leadership worker lock name', async () => {
|
|
100
|
+
const { coordinator, log } = makeCoordinator('tab-1');
|
|
101
|
+
const role = await coordinator.start('anon');
|
|
102
|
+
expect(role).toBe('leader');
|
|
103
|
+
expect(log.adoptOwner).toHaveLength(1);
|
|
104
|
+
expect(log.adoptOwner[0].bucketId).toBe('anon');
|
|
105
|
+
expect(log.adoptOwner[0].workerLockName).toMatch(/^sp00ky-tabs:fp-coord-test:anon:worker:\d+$/);
|
|
106
|
+
expect(log.hub).not.toBeNull();
|
|
107
|
+
await coordinator.stop();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('attaches the second tab as follower with the leader-reported snapshot', async () => {
|
|
111
|
+
const a = makeCoordinator('tab-1');
|
|
112
|
+
await a.coordinator.start('anon');
|
|
113
|
+
const b = makeCoordinator('tab-2');
|
|
114
|
+
const role = await b.coordinator.start('anon');
|
|
115
|
+
|
|
116
|
+
expect(role).toBe('follower');
|
|
117
|
+
// Leader forwarded the follower's db port into its worker...
|
|
118
|
+
expect(a.log.exposedPorts).toEqual(['tab-2']);
|
|
119
|
+
// ...and the follower adopted the leader's store snapshot.
|
|
120
|
+
expect(b.log.adoptAttached).toHaveLength(1);
|
|
121
|
+
expect(b.log.adoptAttached[0].bucketId).toBe('anon');
|
|
122
|
+
expect(b.log.adoptAttached[0].storageHealth.status).toBe('persistent');
|
|
123
|
+
expect(b.log.forwarder).not.toBeNull();
|
|
124
|
+
await b.coordinator.stop();
|
|
125
|
+
await a.coordinator.stop();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('relays hub messages to the follower forwarder (ingest fan-out)', async () => {
|
|
129
|
+
const a = makeCoordinator('tab-1');
|
|
130
|
+
await a.coordinator.start('anon');
|
|
131
|
+
const b = makeCoordinator('tab-2');
|
|
132
|
+
await b.coordinator.start('anon');
|
|
133
|
+
|
|
134
|
+
const seen: LeaderToFollowerMessage[] = [];
|
|
135
|
+
b.log.forwarder!.onLeaderMessage = (msg) => seen.push(msg);
|
|
136
|
+
a.log.hub!.relayIngest([{ table: 'game', op: 'CREATE', id: 'game:1', record: { id: 'game:1' } }]);
|
|
137
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
138
|
+
|
|
139
|
+
const relay = seen.filter((m) => m.type === 'ingest-relay');
|
|
140
|
+
expect(relay).toHaveLength(1);
|
|
141
|
+
expect((relay[0] as { seq: number }).seq).toBe(1);
|
|
142
|
+
await b.coordinator.stop();
|
|
143
|
+
await a.coordinator.stop();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('forwards follower messages to the leader hub (mutation notify)', async () => {
|
|
147
|
+
const a = makeCoordinator('tab-1');
|
|
148
|
+
await a.coordinator.start('anon');
|
|
149
|
+
const b = makeCoordinator('tab-2');
|
|
150
|
+
await b.coordinator.start('anon');
|
|
151
|
+
|
|
152
|
+
const seen: { tabId: string; msg: unknown }[] = [];
|
|
153
|
+
a.log.hub!.onFollowerMessage = (tabId, msg) => seen.push({ tabId, msg });
|
|
154
|
+
b.log.forwarder!.mutationEnqueued('_00_pending_mutations:0000000000123_0001_tab2');
|
|
155
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
156
|
+
|
|
157
|
+
const notify = seen.filter((s) => (s.msg as { type: string }).type === 'mutation-enqueued');
|
|
158
|
+
expect(notify).toHaveLength(1);
|
|
159
|
+
expect(notify[0].tabId).toBe('tab-2');
|
|
160
|
+
await b.coordinator.stop();
|
|
161
|
+
await a.coordinator.stop();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// A follower's optimistic write must reach the leader (which ingests it and
|
|
165
|
+
// fans it out) in one hop. Before this, only the mutation id was forwarded
|
|
166
|
+
// and every other tab waited on the server round-trip + LIVE delivery.
|
|
167
|
+
it('forwards a follower ingest to the leader hub tagged with the origin tab', async () => {
|
|
168
|
+
const a = makeCoordinator('tab-1');
|
|
169
|
+
await a.coordinator.start('anon');
|
|
170
|
+
const b = makeCoordinator('tab-2');
|
|
171
|
+
await b.coordinator.start('anon');
|
|
172
|
+
|
|
173
|
+
const seen: { tabId: string; msg: unknown }[] = [];
|
|
174
|
+
a.log.hub!.onFollowerMessage = (tabId, msg) => seen.push({ tabId, msg });
|
|
175
|
+
const tuples = [{ table: 'game', op: 'CREATE' as const, id: 'game:9', record: { id: 'game:9' } }];
|
|
176
|
+
b.log.forwarder!.ingest(tuples);
|
|
177
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
178
|
+
|
|
179
|
+
const ingest = seen.filter((s) => (s.msg as { type: string }).type === 'ingest');
|
|
180
|
+
expect(ingest).toHaveLength(1);
|
|
181
|
+
expect(ingest[0].tabId).toBe('tab-2');
|
|
182
|
+
expect((ingest[0].msg as { tuples: unknown }).tuples).toEqual(tuples);
|
|
183
|
+
await b.coordinator.stop();
|
|
184
|
+
await a.coordinator.stop();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('fan-out with exceptTabId skips the origin follower and reaches the others', async () => {
|
|
188
|
+
const a = makeCoordinator('tab-1');
|
|
189
|
+
await a.coordinator.start('anon');
|
|
190
|
+
const b = makeCoordinator('tab-2');
|
|
191
|
+
await b.coordinator.start('anon');
|
|
192
|
+
const c = makeCoordinator('tab-3');
|
|
193
|
+
await c.coordinator.start('anon');
|
|
194
|
+
|
|
195
|
+
const seenB: LeaderToFollowerMessage[] = [];
|
|
196
|
+
const seenC: LeaderToFollowerMessage[] = [];
|
|
197
|
+
b.log.forwarder!.onLeaderMessage = (msg) => seenB.push(msg);
|
|
198
|
+
c.log.forwarder!.onLeaderMessage = (msg) => seenC.push(msg);
|
|
199
|
+
a.log.hub!.relayIngest(
|
|
200
|
+
[{ table: 'game', op: 'CREATE', id: 'game:9', record: { id: 'game:9' } }],
|
|
201
|
+
'tab-2'
|
|
202
|
+
);
|
|
203
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
204
|
+
|
|
205
|
+
expect(seenB.filter((m) => m.type === 'ingest-relay')).toHaveLength(0);
|
|
206
|
+
expect(seenC.filter((m) => m.type === 'ingest-relay')).toHaveLength(1);
|
|
207
|
+
await c.coordinator.stop();
|
|
208
|
+
await b.coordinator.stop();
|
|
209
|
+
await a.coordinator.stop();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Unlike a mutation notify, an ingest is NOT queued across a leaderless
|
|
213
|
+
// window: the next leader primes its circuit from the shared store, which
|
|
214
|
+
// already holds the row, and a stale replay would put an older `_00_rv` in
|
|
215
|
+
// its version memo.
|
|
216
|
+
it('drops an ingest while detached instead of queueing it', async () => {
|
|
217
|
+
const forwarder = new SyncForwarder('tab-x');
|
|
218
|
+
forwarder.ingest([{ table: 'game', op: 'CREATE', id: 'game:1', record: { id: 'game:1' } }]);
|
|
219
|
+
|
|
220
|
+
const { port1, port2 } = fakeChannel();
|
|
221
|
+
const seen: { type: string }[] = [];
|
|
222
|
+
port2.onmessage = (ev) => {
|
|
223
|
+
seen.push(ev.data as { type: string });
|
|
224
|
+
};
|
|
225
|
+
forwarder.rebind(port1 as unknown as MessagePort);
|
|
226
|
+
await flush();
|
|
227
|
+
|
|
228
|
+
expect(seen.map((m) => m.type)).toEqual(['sync-hello']);
|
|
229
|
+
forwarder.unbind();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Relay traffic that lands between `db-ready` and the sync hooks being
|
|
233
|
+
// installed (the store adopt is async) used to hit a null handler on a
|
|
234
|
+
// first attach and vanish.
|
|
235
|
+
it('buffers leader messages that land before the follower hooks are installed', async () => {
|
|
236
|
+
const a = makeCoordinator('tab-1');
|
|
237
|
+
await a.coordinator.start('anon');
|
|
238
|
+
|
|
239
|
+
let adoptStartedResolve!: () => void;
|
|
240
|
+
const adoptStartedP = new Promise<void>((r) => {
|
|
241
|
+
adoptStartedResolve = r;
|
|
242
|
+
});
|
|
243
|
+
let releaseAdopt!: () => void;
|
|
244
|
+
const adoptGate = new Promise<void>((r) => {
|
|
245
|
+
releaseAdopt = r;
|
|
246
|
+
});
|
|
247
|
+
const seen: LeaderToFollowerMessage[] = [];
|
|
248
|
+
const b = makeCoordinator('tab-2', {
|
|
249
|
+
async adoptAttached() {
|
|
250
|
+
adoptStartedResolve();
|
|
251
|
+
await adoptGate;
|
|
252
|
+
},
|
|
253
|
+
becomeSyncFollower(forwarder) {
|
|
254
|
+
forwarder.onLeaderMessage = (msg) => seen.push(msg);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const started = b.coordinator.start('anon');
|
|
258
|
+
await adoptStartedP;
|
|
259
|
+
a.log.hub!.broadcast({
|
|
260
|
+
type: 'mutation-settled',
|
|
261
|
+
mutationId: '_00_pending_mutations:1_0001_tab-1',
|
|
262
|
+
recordId: 'game:1',
|
|
263
|
+
eventType: 'create',
|
|
264
|
+
});
|
|
265
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
266
|
+
expect(seen).toHaveLength(0);
|
|
267
|
+
|
|
268
|
+
releaseAdopt();
|
|
269
|
+
await started;
|
|
270
|
+
expect(seen.map((m) => m.type)).toEqual(['mutation-settled']);
|
|
271
|
+
await b.coordinator.stop();
|
|
272
|
+
await a.coordinator.stop();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('promotes the follower when the leader stops (failover)', async () => {
|
|
276
|
+
const a = makeCoordinator('tab-1');
|
|
277
|
+
await a.coordinator.start('anon');
|
|
278
|
+
const b = makeCoordinator('tab-2');
|
|
279
|
+
await b.coordinator.start('anon');
|
|
280
|
+
|
|
281
|
+
const roles: string[] = [];
|
|
282
|
+
b.coordinator.onRoleChange((r) => roles.push(r));
|
|
283
|
+
await a.coordinator.stop();
|
|
284
|
+
// Election + promotion are async hops across the fake ports.
|
|
285
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
286
|
+
|
|
287
|
+
expect(roles).toContain('leader');
|
|
288
|
+
expect(b.log.adoptOwner).toHaveLength(1);
|
|
289
|
+
// The new leadership got a HIGHER id, so a fresh worker lock name.
|
|
290
|
+
expect(b.log.adoptOwner[0].workerLockName).not.toBe(a.log.adoptOwner[0].workerLockName);
|
|
291
|
+
expect(b.log.hub).not.toBeNull();
|
|
292
|
+
await b.coordinator.stop();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// A failed promotion must give the leader TAB lock back. The name is shared
|
|
296
|
+
// per namespace, so a leaked one makes every later election fail with
|
|
297
|
+
// 'leader tab lock unavailable': all tabs time out in start(), boot solo, and
|
|
298
|
+
// then contend for the OPFS pool individually — one busy pool wedging the
|
|
299
|
+
// whole app into the pre-shared-tabs behavior it exists to replace.
|
|
300
|
+
describe('with Web Locks', () => {
|
|
301
|
+
let locks: ReturnType<typeof installFakeLocks>;
|
|
302
|
+
beforeEach(() => {
|
|
303
|
+
locks = installFakeLocks();
|
|
304
|
+
});
|
|
305
|
+
afterEach(() => locks.restore());
|
|
306
|
+
|
|
307
|
+
/** A tab whose store can never open. Resolves once it has failed a
|
|
308
|
+
* promotion, so tests don't wait out start()'s 15s timeout. */
|
|
309
|
+
function makeDoomedTab(tabId: string) {
|
|
310
|
+
let failed!: () => void;
|
|
311
|
+
const hasFailed = new Promise<void>((r) => {
|
|
312
|
+
failed = r;
|
|
313
|
+
});
|
|
314
|
+
const tab = makeCoordinator(tabId, {
|
|
315
|
+
async adoptOwner() {
|
|
316
|
+
queueMicrotask(failed);
|
|
317
|
+
throw new Error('opfs-unavailable: NoModificationAllowedError (after 10 attempts)');
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
// start() only settles on a role or the timeout; neither is the point.
|
|
321
|
+
void tab.coordinator.start('anon').catch(() => {});
|
|
322
|
+
return { ...tab, hasFailed };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it('releases the leader tab lock when adoptOwner throws', async () => {
|
|
326
|
+
const a = makeDoomedTab('tab-1');
|
|
327
|
+
await a.hasFailed;
|
|
328
|
+
// Well inside the 1s re-nomination backoff, so a held lock here is a leak
|
|
329
|
+
// and not the next attempt's legitimate acquisition.
|
|
330
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
331
|
+
expect(locks.heldNames()).not.toContain('sp00ky-tabs:fp-coord-test:anon:tab');
|
|
332
|
+
await a.coordinator.stop();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('lets a later tab lead after another tab failed to open the store', async () => {
|
|
336
|
+
const a = makeDoomedTab('tab-1');
|
|
337
|
+
await a.hasFailed;
|
|
338
|
+
|
|
339
|
+
const b = makeCoordinator('tab-2');
|
|
340
|
+
await expect(b.coordinator.start('anon')).resolves.toBe('leader');
|
|
341
|
+
expect(b.log.adoptOwner).toHaveLength(1);
|
|
342
|
+
await b.coordinator.stop();
|
|
343
|
+
await a.coordinator.stop();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('moves buckets by leaving and rejoining: old namespace re-elects', async () => {
|
|
348
|
+
const a = makeCoordinator('tab-1');
|
|
349
|
+
await a.coordinator.start('anon');
|
|
350
|
+
const b = makeCoordinator('tab-2');
|
|
351
|
+
await b.coordinator.start('anon');
|
|
352
|
+
|
|
353
|
+
const rolesB: string[] = [];
|
|
354
|
+
b.coordinator.onRoleChange((r) => rolesB.push(r));
|
|
355
|
+
const newRole = await a.coordinator.moveToBucket('user1');
|
|
356
|
+
|
|
357
|
+
expect(newRole).toBe('leader');
|
|
358
|
+
expect(a.log.adoptOwner.map((o) => o.bucketId)).toEqual(['anon', 'user1']);
|
|
359
|
+
expect(a.log.released).toBeGreaterThan(0);
|
|
360
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
361
|
+
expect(rolesB).toContain('leader');
|
|
362
|
+
await b.coordinator.stop();
|
|
363
|
+
await a.coordinator.stop();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('signs in from an anonymous leader into a user bucket another tab already leads', async () => {
|
|
367
|
+
const signedIn = makeCoordinator('signed-in', {
|
|
368
|
+
// The real hook awaits a worker RPC. FakePort does not buffer messages
|
|
369
|
+
// before onmessage is set, so let the joining tab bind its sync port.
|
|
370
|
+
async exposeClientPort() { await flush(20); },
|
|
371
|
+
});
|
|
372
|
+
const signingIn = makeCoordinator('signing-in');
|
|
373
|
+
try {
|
|
374
|
+
await signedIn.coordinator.start('user1');
|
|
375
|
+
await signingIn.coordinator.start('anon');
|
|
376
|
+
|
|
377
|
+
await expect(signingIn.coordinator.moveToBucket('user1')).resolves.toBe('follower');
|
|
378
|
+
expect(signingIn.log.adoptAttached.map((s) => s.bucketId)).toEqual(['user1']);
|
|
379
|
+
expect(signingIn.log.released).toBe(1);
|
|
380
|
+
expect(signingIn.coordinator.leaderTabId).toBe('signed-in');
|
|
381
|
+
} finally {
|
|
382
|
+
await signingIn.coordinator.stop();
|
|
383
|
+
await signedIn.coordinator.stop();
|
|
384
|
+
}
|
|
385
|
+
}, 20_000);
|
|
386
|
+
});
|