@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,327 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
|
|
3
|
+
import { fakeChannel, flush, FakePort } from './fake-ports.fixture';
|
|
4
|
+
import type { BrokerToTabMessage, TabToBrokerMessage } from './protocol';
|
|
5
|
+
|
|
6
|
+
/** A connected fake tab: records broker messages, exposes a send helper. */
|
|
7
|
+
interface TestTab {
|
|
8
|
+
tabId: string;
|
|
9
|
+
received: { msg: BrokerToTabMessage; ports: FakePort[] }[];
|
|
10
|
+
send: (msg: TabToBrokerMessage) => void;
|
|
11
|
+
port: FakePort;
|
|
12
|
+
/** Messages of one type, newest last. */
|
|
13
|
+
ofType: <T extends BrokerToTabMessage['type']>(
|
|
14
|
+
t: T
|
|
15
|
+
) => Extract<BrokerToTabMessage, { type: T }>[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function connectTab(tabId: string, opts: { bucket?: string; fingerprint?: string } = {}): TestTab {
|
|
19
|
+
const { port1, port2 } = fakeChannel();
|
|
20
|
+
handleConnect(port1 as unknown as MessagePort);
|
|
21
|
+
const received: TestTab['received'] = [];
|
|
22
|
+
port2.onmessage = (ev) => received.push({ msg: ev.data, ports: ev.ports });
|
|
23
|
+
const tab: TestTab = {
|
|
24
|
+
tabId,
|
|
25
|
+
received,
|
|
26
|
+
port: port2,
|
|
27
|
+
send: (msg) => port2.postMessage(msg),
|
|
28
|
+
ofType: (t) => received.filter((r) => r.msg.type === t).map((r) => r.msg) as any,
|
|
29
|
+
};
|
|
30
|
+
tab.send({
|
|
31
|
+
type: 'hello',
|
|
32
|
+
tabId,
|
|
33
|
+
fingerprint: opts.fingerprint ?? 'fp-test',
|
|
34
|
+
bucketId: opts.bucket ?? 'anon',
|
|
35
|
+
visibility: 'visible',
|
|
36
|
+
heldLeadership: null,
|
|
37
|
+
});
|
|
38
|
+
return tab;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Complete the promotion handshake for whichever tab got become-leader. */
|
|
42
|
+
async function ackLeadership(tab: TestTab, bucket = 'anon'): Promise<number> {
|
|
43
|
+
await flush();
|
|
44
|
+
const grants = tab.ofType('become-leader');
|
|
45
|
+
expect(grants.length).toBeGreaterThan(0);
|
|
46
|
+
const leadershipId = grants[grants.length - 1].leadershipId;
|
|
47
|
+
tab.send({ type: 'leader-ready', tabId: tab.tabId, bucketId: bucket, leadershipId });
|
|
48
|
+
await flush();
|
|
49
|
+
return leadershipId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
__resetBrokerForTests();
|
|
54
|
+
vi.useFakeTimers();
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
__resetBrokerForTests();
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The broker is the single arbiter of who owns the OPFS pool. These tests
|
|
62
|
+
// drive it with fake ports exactly like real tabs would.
|
|
63
|
+
describe('tabs broker: election', () => {
|
|
64
|
+
it('elects exactly one leader for the first tab and announces readiness', async () => {
|
|
65
|
+
const a = connectTab('tab-a');
|
|
66
|
+
await flush();
|
|
67
|
+
expect(a.ofType('broker-hello')).toHaveLength(1);
|
|
68
|
+
const id = await ackLeadership(a);
|
|
69
|
+
expect(a.ofType('leader-ready').map((m) => m.leaderTabId)).toContain('tab-a');
|
|
70
|
+
expect(id).toBeGreaterThan(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('attaches a second tab as follower with a db+sync port pair', async () => {
|
|
74
|
+
const a = connectTab('tab-a');
|
|
75
|
+
await ackLeadership(a);
|
|
76
|
+
const b = connectTab('tab-b');
|
|
77
|
+
await flush();
|
|
78
|
+
|
|
79
|
+
// No second become-leader; instead ports flow both ways.
|
|
80
|
+
expect(b.ofType('become-leader')).toHaveLength(0);
|
|
81
|
+
const attach = a.received.filter((r) => r.msg.type === 'attach-follower-ports');
|
|
82
|
+
const use = b.received.filter((r) => r.msg.type === 'use-follower-ports');
|
|
83
|
+
expect(attach).toHaveLength(1);
|
|
84
|
+
expect(use).toHaveLength(1);
|
|
85
|
+
expect(attach[0].ports).toHaveLength(2);
|
|
86
|
+
expect(use[0].ports).toHaveLength(2);
|
|
87
|
+
// The two db ports are entangled ends of ONE channel: a message posted on
|
|
88
|
+
// the leader's end arrives on the follower's end. (The broker mints REAL
|
|
89
|
+
// node MessageChannels here; only the control-plane ports are fakes.)
|
|
90
|
+
const leaderDb = attach[0].ports[0] as unknown as MessagePort;
|
|
91
|
+
const followerDb = use[0].ports[0] as unknown as MessagePort;
|
|
92
|
+
const got = new Promise<unknown>((resolve) => {
|
|
93
|
+
followerDb.onmessage = (ev: MessageEvent) => resolve(ev.data);
|
|
94
|
+
});
|
|
95
|
+
leaderDb.postMessage('entangled?');
|
|
96
|
+
vi.useRealTimers();
|
|
97
|
+
await expect(got).resolves.toBe('entangled?');
|
|
98
|
+
leaderDb.close();
|
|
99
|
+
followerDb.close();
|
|
100
|
+
vi.useFakeTimers();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('stops re-minting follower ports once the leader confirms attachment', async () => {
|
|
104
|
+
const a = connectTab('tab-a');
|
|
105
|
+
const leadershipId = await ackLeadership(a);
|
|
106
|
+
const b = connectTab('tab-b');
|
|
107
|
+
await flush();
|
|
108
|
+
a.send({
|
|
109
|
+
type: 'follower-port-attached',
|
|
110
|
+
tabId: 'tab-a',
|
|
111
|
+
bucketId: 'anon',
|
|
112
|
+
leadershipId,
|
|
113
|
+
followerTabId: 'tab-b',
|
|
114
|
+
});
|
|
115
|
+
await flush();
|
|
116
|
+
const before = b.ofType('use-follower-ports').length;
|
|
117
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
118
|
+
expect(b.ofType('use-follower-ports').length).toBe(before);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('re-mints follower ports with backoff while unconfirmed', async () => {
|
|
122
|
+
const a = connectTab('tab-a');
|
|
123
|
+
await ackLeadership(a);
|
|
124
|
+
const b = connectTab('tab-b');
|
|
125
|
+
await flush();
|
|
126
|
+
expect(b.ofType('use-follower-ports')).toHaveLength(1);
|
|
127
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
128
|
+
await flush();
|
|
129
|
+
expect(b.ofType('use-follower-ports')).toHaveLength(2);
|
|
130
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
131
|
+
await flush();
|
|
132
|
+
expect(b.ofType('use-follower-ports')).toHaveLength(3);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects a tab whose fingerprint differs from the first hello', async () => {
|
|
136
|
+
connectTab('tab-a', { fingerprint: 'fp-1' });
|
|
137
|
+
const b = connectTab('tab-b', { fingerprint: 'fp-2' });
|
|
138
|
+
await flush();
|
|
139
|
+
expect(b.ofType('unsupported').map((m) => m.reason)).toEqual(['fingerprint-mismatch']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('keeps leadership ids monotonic across handoffs', async () => {
|
|
143
|
+
const a = connectTab('tab-a');
|
|
144
|
+
const id1 = await ackLeadership(a);
|
|
145
|
+
const b = connectTab('tab-b');
|
|
146
|
+
await flush();
|
|
147
|
+
a.send({ type: 'shutdown', tabId: 'tab-a', bucketId: 'anon' });
|
|
148
|
+
await flush();
|
|
149
|
+
const id2 = await ackLeadership(b);
|
|
150
|
+
expect(id2).toBeGreaterThan(id1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('tabs broker: failover', () => {
|
|
155
|
+
it('promotes the follower when the leader shuts down gracefully', async () => {
|
|
156
|
+
const a = connectTab('tab-a');
|
|
157
|
+
await ackLeadership(a);
|
|
158
|
+
const b = connectTab('tab-b');
|
|
159
|
+
await flush();
|
|
160
|
+
a.send({ type: 'shutdown', tabId: 'tab-a', bucketId: 'anon' });
|
|
161
|
+
await flush();
|
|
162
|
+
expect(b.ofType('become-leader').length).toBeGreaterThan(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('evicts a leader that misses pongs and re-elects', async () => {
|
|
166
|
+
const a = connectTab('tab-a');
|
|
167
|
+
await ackLeadership(a);
|
|
168
|
+
const b = connectTab('tab-b');
|
|
169
|
+
await flush();
|
|
170
|
+
// b keeps answering pings, a goes silent.
|
|
171
|
+
b.port.onmessage = (ev) => {
|
|
172
|
+
b.received.push({ msg: ev.data, ports: ev.ports });
|
|
173
|
+
if (ev.data.type === 'ping') b.send({ type: 'pong', tabId: 'tab-b' });
|
|
174
|
+
};
|
|
175
|
+
await vi.advanceTimersByTimeAsync(21_000);
|
|
176
|
+
await flush();
|
|
177
|
+
expect(b.ofType('become-leader').length).toBeGreaterThan(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('applies a failure backoff and grants allowMemoryFallback after 3 opfs cycles', async () => {
|
|
181
|
+
const a = connectTab('tab-a');
|
|
182
|
+
await flush();
|
|
183
|
+
for (let cycle = 0; cycle < 3; cycle++) {
|
|
184
|
+
const grants = a.ofType('become-leader');
|
|
185
|
+
const last = grants[grants.length - 1];
|
|
186
|
+
expect(last.allowMemoryFallback).toBe(false);
|
|
187
|
+
a.send({
|
|
188
|
+
type: 'leader-failed',
|
|
189
|
+
tabId: 'tab-a',
|
|
190
|
+
bucketId: 'anon',
|
|
191
|
+
leadershipId: last.leadershipId,
|
|
192
|
+
reason: 'opfs-unavailable: locked',
|
|
193
|
+
});
|
|
194
|
+
await flush();
|
|
195
|
+
// The 1s candidate backoff must pass before the next nomination.
|
|
196
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
197
|
+
await flush();
|
|
198
|
+
}
|
|
199
|
+
const grants = a.ofType('become-leader');
|
|
200
|
+
expect(grants[grants.length - 1].allowMemoryFallback).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('re-elects when a promoted tab never answers become-leader', async () => {
|
|
204
|
+
// The wedge this guards: a tab that hangs mid-promotion sends neither
|
|
205
|
+
// leader-ready nor leader-failed, and keeps answering pings, so without a
|
|
206
|
+
// deadline the namespace keeps a leader that is never `ready` - no follower
|
|
207
|
+
// ports are minted and no re-election ever runs.
|
|
208
|
+
const a = connectTab('tab-a');
|
|
209
|
+
const b = connectTab('tab-b');
|
|
210
|
+
await flush();
|
|
211
|
+
const grantedTo = a.ofType('become-leader').length > 0 ? a : b;
|
|
212
|
+
const other = grantedTo === a ? b : a;
|
|
213
|
+
expect(grantedTo.ofType('become-leader').length).toBe(1);
|
|
214
|
+
// Both tabs keep answering pings; the promoted one just never reports.
|
|
215
|
+
for (const t of [a, b]) {
|
|
216
|
+
t.port.onmessage = (ev) => {
|
|
217
|
+
t.received.push({ msg: ev.data, ports: ev.ports });
|
|
218
|
+
if (ev.data.type === 'ping') t.send({ type: 'pong', tabId: t.tabId });
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
await vi.advanceTimersByTimeAsync(21_000);
|
|
222
|
+
await flush();
|
|
223
|
+
// The other tab gets a shot, under a NEW leadership id.
|
|
224
|
+
const handover = other.ofType('become-leader');
|
|
225
|
+
expect(handover.length).toBeGreaterThan(0);
|
|
226
|
+
expect(handover[handover.length - 1].leadershipId).toBeGreaterThan(
|
|
227
|
+
grantedTo.ofType('become-leader')[0].leadershipId
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('stops the promotion deadline once leader-ready lands', async () => {
|
|
232
|
+
const a = connectTab('tab-a');
|
|
233
|
+
const id = await ackLeadership(a);
|
|
234
|
+
const b = connectTab('tab-b');
|
|
235
|
+
await flush();
|
|
236
|
+
a.port.onmessage = (ev) => {
|
|
237
|
+
a.received.push({ msg: ev.data, ports: ev.ports });
|
|
238
|
+
if (ev.data.type === 'ping') a.send({ type: 'pong', tabId: 'tab-a' });
|
|
239
|
+
};
|
|
240
|
+
b.port.onmessage = (ev) => {
|
|
241
|
+
b.received.push({ msg: ev.data, ports: ev.ports });
|
|
242
|
+
if (ev.data.type === 'ping') b.send({ type: 'pong', tabId: 'tab-b' });
|
|
243
|
+
};
|
|
244
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
245
|
+
await flush();
|
|
246
|
+
// A confirmed leader is never torn down by the promotion deadline.
|
|
247
|
+
expect(a.ofType('demote')).toHaveLength(0);
|
|
248
|
+
expect(b.ofType('become-leader')).toHaveLength(0);
|
|
249
|
+
expect(a.ofType('become-leader')[0].leadershipId).toBe(id);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('demotes a stale leader-ready from a superseded promotion', async () => {
|
|
253
|
+
const a = connectTab('tab-a');
|
|
254
|
+
const staleId = await ackLeadership(a);
|
|
255
|
+
const b = connectTab('tab-b');
|
|
256
|
+
await flush();
|
|
257
|
+
a.send({ type: 'shutdown', tabId: 'tab-a', bucketId: 'anon' });
|
|
258
|
+
await flush();
|
|
259
|
+
await ackLeadership(b);
|
|
260
|
+
// The departed tab reconnects and replays its OLD leader-ready.
|
|
261
|
+
const a2 = connectTab('tab-a');
|
|
262
|
+
await flush();
|
|
263
|
+
a2.send({ type: 'leader-ready', tabId: 'tab-a', bucketId: 'anon', leadershipId: staleId });
|
|
264
|
+
await flush();
|
|
265
|
+
expect(a2.ofType('demote').map((m) => m.leadershipId)).toContain(staleId);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('prefers a candidate that still holds a live worker (broker restart path)', async () => {
|
|
269
|
+
// Fresh broker instance, two tabs hello simultaneously; one held leadership
|
|
270
|
+
// from before the restart.
|
|
271
|
+
const a = connectTab('tab-a');
|
|
272
|
+
await flush();
|
|
273
|
+
const idA = await ackLeadership(a);
|
|
274
|
+
void idA;
|
|
275
|
+
__resetBrokerForTests();
|
|
276
|
+
const b2 = connectTab('tab-b');
|
|
277
|
+
void b2;
|
|
278
|
+
const { port1, port2 } = fakeChannel();
|
|
279
|
+
handleConnect(port1 as unknown as MessagePort);
|
|
280
|
+
const received: TestTab['received'] = [];
|
|
281
|
+
port2.onmessage = (ev) => received.push({ msg: ev.data, ports: ev.ports });
|
|
282
|
+
port2.postMessage({
|
|
283
|
+
type: 'hello',
|
|
284
|
+
tabId: 'tab-a',
|
|
285
|
+
fingerprint: 'fp-test',
|
|
286
|
+
bucketId: 'anon',
|
|
287
|
+
visibility: 'hidden',
|
|
288
|
+
heldLeadership: { leadershipId: 7, workerLockName: 'sp00ky-tabs:fp-test:anon:worker:7' },
|
|
289
|
+
});
|
|
290
|
+
await flush();
|
|
291
|
+
// Despite being hidden (b is visible), the holder wins with resumeHeld.
|
|
292
|
+
const grant = received.find((r) => r.msg.type === 'become-leader');
|
|
293
|
+
expect(grant).toBeDefined();
|
|
294
|
+
expect((grant!.msg as { resumeHeld: boolean }).resumeHeld).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('tabs broker: namespaces', () => {
|
|
299
|
+
it('separates tabs by bucket: one leader per bucket', async () => {
|
|
300
|
+
const a = connectTab('tab-a', { bucket: 'user1' });
|
|
301
|
+
await ackLeadership(a, 'user1');
|
|
302
|
+
const b = connectTab('tab-b', { bucket: 'user2' });
|
|
303
|
+
await flush();
|
|
304
|
+
expect(b.ofType('become-leader').length).toBeGreaterThan(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('re-elects the old namespace immediately when a leader re-hellos into a new bucket', async () => {
|
|
308
|
+
const a = connectTab('tab-a');
|
|
309
|
+
await ackLeadership(a);
|
|
310
|
+
const b = connectTab('tab-b');
|
|
311
|
+
await flush();
|
|
312
|
+
// a switches buckets: same tabId, new bucket. b must be promoted in anon
|
|
313
|
+
// without waiting for a pong eviction.
|
|
314
|
+
a.send({
|
|
315
|
+
type: 'hello',
|
|
316
|
+
tabId: 'tab-a',
|
|
317
|
+
fingerprint: 'fp-test',
|
|
318
|
+
bucketId: 'user1',
|
|
319
|
+
visibility: 'visible',
|
|
320
|
+
heldLeadership: null,
|
|
321
|
+
});
|
|
322
|
+
await flush();
|
|
323
|
+
expect(b.ofType('become-leader').length).toBeGreaterThan(0);
|
|
324
|
+
// and a is offered leadership of the new bucket.
|
|
325
|
+
expect(a.ofType('become-leader').length).toBeGreaterThan(1);
|
|
326
|
+
});
|
|
327
|
+
});
|