@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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only fakes for the tabs broker: entangled MessagePort pairs with
|
|
3
|
+
* microtask-async delivery (mimicking real ports), and a SharedWorker stand-in
|
|
4
|
+
* that connects straight into the broker module's handleConnect. Imported only
|
|
5
|
+
* by *.test.ts files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class FakePort {
|
|
9
|
+
other: FakePort | null = null;
|
|
10
|
+
onmessage: ((ev: { data: any; ports: FakePort[] }) => void) | null = null;
|
|
11
|
+
onmessageerror: ((ev?: unknown) => void) | null = null;
|
|
12
|
+
closed = false;
|
|
13
|
+
|
|
14
|
+
postMessage(data: any, transfer?: unknown[]): void {
|
|
15
|
+
const target = this.other;
|
|
16
|
+
if (!target || target.closed || this.closed) return;
|
|
17
|
+
queueMicrotask(() => {
|
|
18
|
+
if (!target.closed) target.onmessage?.({ data, ports: (transfer ?? []) as FakePort[] });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start(): void {}
|
|
23
|
+
|
|
24
|
+
close(): void {
|
|
25
|
+
this.closed = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fakeChannel(): { port1: FakePort; port2: FakePort } {
|
|
30
|
+
const port1 = new FakePort();
|
|
31
|
+
const port2 = new FakePort();
|
|
32
|
+
port1.other = port2;
|
|
33
|
+
port2.other = port1;
|
|
34
|
+
return { port1, port2 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Drain queued microtasks (message deliveries chain through several hops). */
|
|
38
|
+
export async function flush(times = 10): Promise<void> {
|
|
39
|
+
for (let i = 0; i < times; i++) await Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Install a minimal exclusive-only `navigator.locks` on globalThis. Node has no
|
|
44
|
+
* Web Locks, and `acquireLeaderTabLock` treats a missing LockManager as "always
|
|
45
|
+
* granted", so without this the whole leader-tab-lock path is a no-op in tests
|
|
46
|
+
* and lock leaks are invisible. `ifAvailable` resolves null while held (what a
|
|
47
|
+
* losing tab sees), `steal` evicts the holder, and a plain request queues
|
|
48
|
+
* forever — the same shapes the real API produces.
|
|
49
|
+
*/
|
|
50
|
+
export function installFakeLocks(): {
|
|
51
|
+
restore: () => void;
|
|
52
|
+
heldNames: () => string[];
|
|
53
|
+
} {
|
|
54
|
+
const g = globalThis as Record<string, unknown>;
|
|
55
|
+
const previous = g.navigator;
|
|
56
|
+
const held = new Map<string, () => void>();
|
|
57
|
+
const locks = {
|
|
58
|
+
async request(name: string, opts: any, cb: any) {
|
|
59
|
+
if (opts?.steal) held.get(name)?.();
|
|
60
|
+
if (held.has(name) && !opts?.steal) {
|
|
61
|
+
if (opts?.ifAvailable) return cb(null);
|
|
62
|
+
return new Promise(() => {});
|
|
63
|
+
}
|
|
64
|
+
// The holder keeps the lock until the callback's promise settles (the
|
|
65
|
+
// real contract) or someone steals it out from under them.
|
|
66
|
+
let stolen!: () => void;
|
|
67
|
+
const stealSignal = new Promise<void>((r) => {
|
|
68
|
+
stolen = r as () => void;
|
|
69
|
+
});
|
|
70
|
+
held.set(name, stolen);
|
|
71
|
+
try {
|
|
72
|
+
await Promise.race([Promise.resolve(cb({ name, mode: 'exclusive' })), stealSignal]);
|
|
73
|
+
} finally {
|
|
74
|
+
held.delete(name);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const define = (value: unknown) =>
|
|
79
|
+
Object.defineProperty(g, 'navigator', { value, configurable: true, writable: true });
|
|
80
|
+
define({ ...(previous ?? {}), locks });
|
|
81
|
+
return { restore: () => define(previous), heldNames: () => [...held.keys()] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Install `MessageChannel` + `SharedWorker` fakes on globalThis; the fake
|
|
85
|
+
* SharedWorker pipes its port into `handleConnect`. Returns a restore fn. */
|
|
86
|
+
export function installBrokerGlobals(handleConnect: (port: MessagePort) => void): () => void {
|
|
87
|
+
const g = globalThis as Record<string, unknown>;
|
|
88
|
+
const prevMC = g.MessageChannel;
|
|
89
|
+
const prevSW = g.SharedWorker;
|
|
90
|
+
g.MessageChannel = class {
|
|
91
|
+
port1: FakePort;
|
|
92
|
+
port2: FakePort;
|
|
93
|
+
constructor() {
|
|
94
|
+
const { port1, port2 } = fakeChannel();
|
|
95
|
+
this.port1 = port1;
|
|
96
|
+
this.port2 = port2;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
g.SharedWorker = class {
|
|
100
|
+
port: FakePort;
|
|
101
|
+
onerror: ((e: unknown) => void) | null = null;
|
|
102
|
+
constructor() {
|
|
103
|
+
const { port1, port2 } = fakeChannel();
|
|
104
|
+
this.port = port2;
|
|
105
|
+
handleConnect(port1 as unknown as MessagePort);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
return () => {
|
|
109
|
+
g.MessageChannel = prevMC;
|
|
110
|
+
g.SharedWorker = prevSW;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Lock helpers for the leader TAB side. (The worker lock lives inside
|
|
3
|
+
* sqlite-worker.ts; the broker's probe/steal helpers live in the broker
|
|
4
|
+
* worker. This module is only what a tab needs to claim leadership.)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface LeaderLockHandle {
|
|
8
|
+
/** Release the lock on purpose (demotion, shutdown). Idempotent. */
|
|
9
|
+
release(): void;
|
|
10
|
+
/** Fires exactly once if the lock is taken away rather than released. */
|
|
11
|
+
onLost(cb: () => void): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function lockManager(): LockManager | null {
|
|
15
|
+
const nav = (globalThis as { navigator?: { locks?: LockManager } }).navigator;
|
|
16
|
+
return nav?.locks && typeof nav.locks.request === 'function' ? nav.locks : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Acquire `name` exclusively. `steal: true` when the broker determined the
|
|
21
|
+
* previous holder is a frozen zombie. Resolves null when the lock is held by
|
|
22
|
+
* someone else and stealing was not requested.
|
|
23
|
+
*/
|
|
24
|
+
export function acquireLeaderTabLock(
|
|
25
|
+
name: string,
|
|
26
|
+
opts: { steal: boolean }
|
|
27
|
+
): Promise<LeaderLockHandle | null> {
|
|
28
|
+
const locks = lockManager();
|
|
29
|
+
// No Web Locks means shared-tabs support detection failed earlier; treat as
|
|
30
|
+
// granted so tests without the API can still exercise the flow.
|
|
31
|
+
if (!locks) {
|
|
32
|
+
return Promise.resolve({ release() {}, onLost() {} });
|
|
33
|
+
}
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
let lostCb: (() => void) | null = null;
|
|
36
|
+
let releasedIntentionally = false;
|
|
37
|
+
let granted = false;
|
|
38
|
+
let release: (() => void) | null = null;
|
|
39
|
+
locks
|
|
40
|
+
.request(
|
|
41
|
+
name,
|
|
42
|
+
opts.steal ? { mode: 'exclusive', steal: true } : { mode: 'exclusive', ifAvailable: true },
|
|
43
|
+
(lock) => {
|
|
44
|
+
if (!lock) {
|
|
45
|
+
resolve(null);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
granted = true;
|
|
49
|
+
resolve({
|
|
50
|
+
release() {
|
|
51
|
+
releasedIntentionally = true;
|
|
52
|
+
release?.();
|
|
53
|
+
},
|
|
54
|
+
onLost(cb) {
|
|
55
|
+
lostCb = cb;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
return new Promise<void>((r) => {
|
|
59
|
+
release = r;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
.then(
|
|
64
|
+
() => {
|
|
65
|
+
if (granted && !releasedIntentionally) lostCb?.();
|
|
66
|
+
},
|
|
67
|
+
() => {
|
|
68
|
+
// A steal against us settles the request with an AbortError-like
|
|
69
|
+
// rejection in some engines; treat exactly like a loss.
|
|
70
|
+
if (granted && !releasedIntentionally) lostCb?.();
|
|
71
|
+
else if (!granted) resolve(null);
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-tabs protocol: the message vocabulary between browser tabs, the
|
|
3
|
+
* SharedWorker broker (`tabs-broker-worker.ts`), and, once a leader exists,
|
|
4
|
+
* between follower tabs and the leader over per-follower MessagePorts.
|
|
5
|
+
*
|
|
6
|
+
* Model (adapted from Jazz's browser broker, see the shared-tabs plan):
|
|
7
|
+
* - The broker is CONTROL PLANE only. It elects one leader tab per namespace
|
|
8
|
+
* (fingerprint + bucketId), mints the MessageChannel pairs that connect each
|
|
9
|
+
* follower to the leader, and monitors liveness. No data flows through it.
|
|
10
|
+
* - Per follower there are TWO ports: `dbPort` (raw sqlite ops, forwarded into
|
|
11
|
+
* the leader's dedicated worker, so reads/writes bypass the leader's main
|
|
12
|
+
* thread) and `syncPort` (sync control RPC + ingest relay, handled by the
|
|
13
|
+
* leader main thread).
|
|
14
|
+
* - Every broker-to-tab message carries `brokerInstanceId`; a change means the
|
|
15
|
+
* browser restarted the SharedWorker and the tab must re-hello.
|
|
16
|
+
*
|
|
17
|
+
* The broker worker script must stay self-contained (bundlers cannot reliably
|
|
18
|
+
* trace `new SharedWorker(url)` graphs), so it does NOT import this module at
|
|
19
|
+
* runtime; it duplicates the few constants it needs and this file stays the
|
|
20
|
+
* single source of truth for tab-side code and for tests.
|
|
21
|
+
*/
|
|
22
|
+
import type { StorageHealth } from '../../types';
|
|
23
|
+
|
|
24
|
+
export const TABS_PROTOCOL_VERSION = 1;
|
|
25
|
+
|
|
26
|
+
export type TabId = string;
|
|
27
|
+
export type TabVisibility = 'visible' | 'hidden';
|
|
28
|
+
export type TabRole = 'solo' | 'leader' | 'follower';
|
|
29
|
+
|
|
30
|
+
// ---- timing constants (broker worker duplicates these; keep in sync) -------
|
|
31
|
+
|
|
32
|
+
/** Broker pings every tab at this cadence. */
|
|
33
|
+
export const PING_INTERVAL_MS = 5000;
|
|
34
|
+
/** A tab that has not ponged for this long is presumed dead and evicted.
|
|
35
|
+
* MUST stay above the sqlite worker's freeze-suspect threshold (10s): a
|
|
36
|
+
* freeze shorter than that never triggers a steal, which is what makes the
|
|
37
|
+
* worker-side thaw gate sufficient. */
|
|
38
|
+
export const PONG_TIMEOUT_MS = 15_000;
|
|
39
|
+
/** How long an election waits for a dead leader's Web Locks to free up before
|
|
40
|
+
* stealing them. */
|
|
41
|
+
export const FORCE_TAKEOVER_TIMEOUT_MS = 1000;
|
|
42
|
+
/** A tab whose promotion failed is not re-nominated for this long. */
|
|
43
|
+
export const LEADER_FAILURE_BACKOFF_MS = 1000;
|
|
44
|
+
/** Follower attachment retry: initial delay, doubling per attempt, capped. */
|
|
45
|
+
export const ATTACH_RETRY_INITIAL_MS = 1000;
|
|
46
|
+
export const ATTACH_RETRY_MAX_MS = 30_000;
|
|
47
|
+
/** After this many consecutive failed elections, the broker allows the next
|
|
48
|
+
* leader to open in memory (reported as degraded) rather than leaving the
|
|
49
|
+
* namespace leaderless forever. Counts EVERY failure reason, not just
|
|
50
|
+
* opfs-unavailable: any reason that keeps recurring leaves every tab timing
|
|
51
|
+
* out in `start()` and falling back to solo, which is strictly worse than one
|
|
52
|
+
* shared in-memory store. */
|
|
53
|
+
export const FAILED_CYCLES_BEFORE_MEMORY = 3;
|
|
54
|
+
|
|
55
|
+
// ---- identity ---------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export interface TabsFingerprintInput {
|
|
58
|
+
coreVersion: string;
|
|
59
|
+
/** Hash over the app schema's table names + field names. */
|
|
60
|
+
schemaHash: string;
|
|
61
|
+
endpoint: string;
|
|
62
|
+
namespace: string;
|
|
63
|
+
database: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Deterministic JSON: objects with sorted keys, so equal inputs always
|
|
67
|
+
* produce equal fingerprints regardless of construction order. */
|
|
68
|
+
export function stableStringify(value: unknown): string {
|
|
69
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
70
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
71
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
72
|
+
const body = keys
|
|
73
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`)
|
|
74
|
+
.join(',');
|
|
75
|
+
return `{${body}}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** cyrb53: tiny, fast, good-enough 53-bit hash for identity strings. */
|
|
79
|
+
export function hash53(str: string, seed = 0): string {
|
|
80
|
+
let h1 = 0xdeadbeef ^ seed;
|
|
81
|
+
let h2 = 0x41c6ce57 ^ seed;
|
|
82
|
+
for (let i = 0; i < str.length; i++) {
|
|
83
|
+
const ch = str.charCodeAt(i);
|
|
84
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
85
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
86
|
+
}
|
|
87
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
88
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
89
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function computeTabsFingerprint(input: TabsFingerprintInput): string {
|
|
93
|
+
return hash53(stableStringify({ v: TABS_PROTOCOL_VERSION, ...input }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Lock names. The worker lock embeds the leadershipId, so after a steal the
|
|
97
|
+
* exact name is never re-acquired by anyone: "not held" is an unambiguous
|
|
98
|
+
* fencing check for a thawed worker, with no clientId resolution needed. */
|
|
99
|
+
export function tabLockName(fingerprint: string, bucketId: string): string {
|
|
100
|
+
return `sp00ky-tabs:${fingerprint}:${bucketId}:tab`;
|
|
101
|
+
}
|
|
102
|
+
export function workerLockName(fingerprint: string, bucketId: string, leadershipId: number): string {
|
|
103
|
+
return `sp00ky-tabs:${fingerprint}:${bucketId}:worker:${leadershipId}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- election ---------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export interface LeaderCandidate {
|
|
109
|
+
tabId: TabId;
|
|
110
|
+
visibility: TabVisibility;
|
|
111
|
+
lastVisibleAt: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Visible tabs beat hidden ones; within the pool the most recently visible
|
|
115
|
+
* wins; ties break to the lexicographically greater tabId so every observer
|
|
116
|
+
* picks the same winner. (Duplicated in the broker worker.) */
|
|
117
|
+
export function selectLeaderCandidate<C extends LeaderCandidate>(candidates: C[]): C | null {
|
|
118
|
+
if (candidates.length === 0) return null;
|
|
119
|
+
const visible = candidates.filter((c) => c.visibility === 'visible');
|
|
120
|
+
const pool = visible.length > 0 ? visible : candidates;
|
|
121
|
+
return pool.reduce((best, c) => {
|
|
122
|
+
if (c.lastVisibleAt !== best.lastVisibleAt) {
|
|
123
|
+
return c.lastVisibleAt > best.lastVisibleAt ? c : best;
|
|
124
|
+
}
|
|
125
|
+
return c.tabId > best.tabId ? c : best;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- control plane ----------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/** A leader that survived a broker restart announces what it still holds, so
|
|
132
|
+
* the new broker instance re-promotes it instead of forcing a pool reopen. */
|
|
133
|
+
export interface HeldLeadership {
|
|
134
|
+
leadershipId: number;
|
|
135
|
+
workerLockName: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type TabToBrokerMessage =
|
|
139
|
+
| {
|
|
140
|
+
type: 'hello';
|
|
141
|
+
tabId: TabId;
|
|
142
|
+
fingerprint: string;
|
|
143
|
+
bucketId: string;
|
|
144
|
+
visibility: TabVisibility;
|
|
145
|
+
heldLeadership: HeldLeadership | null;
|
|
146
|
+
}
|
|
147
|
+
| { type: 'visibility'; tabId: TabId; bucketId: string; visibility: TabVisibility }
|
|
148
|
+
| { type: 'leader-ready'; tabId: TabId; bucketId: string; leadershipId: number }
|
|
149
|
+
| { type: 'leader-failed'; tabId: TabId; bucketId: string; leadershipId: number; reason: string }
|
|
150
|
+
| {
|
|
151
|
+
type: 'follower-port-attached';
|
|
152
|
+
tabId: TabId;
|
|
153
|
+
bucketId: string;
|
|
154
|
+
leadershipId: number;
|
|
155
|
+
followerTabId: TabId;
|
|
156
|
+
}
|
|
157
|
+
| { type: 'follower-port-closed'; tabId: TabId; bucketId: string; followerTabId: TabId }
|
|
158
|
+
| { type: 'shutdown'; tabId: TabId; bucketId: string }
|
|
159
|
+
| { type: 'pong'; tabId: TabId };
|
|
160
|
+
|
|
161
|
+
export type BrokerUnsupportedReason = 'fingerprint-mismatch' | 'protocol-version';
|
|
162
|
+
|
|
163
|
+
export type BrokerToTabMessage =
|
|
164
|
+
| { type: 'broker-hello'; brokerInstanceId: string }
|
|
165
|
+
| { type: 'ping'; brokerInstanceId: string }
|
|
166
|
+
| {
|
|
167
|
+
type: 'become-leader';
|
|
168
|
+
brokerInstanceId: string;
|
|
169
|
+
leadershipId: number;
|
|
170
|
+
/** True when the previous leader's locks may need stealing. */
|
|
171
|
+
forceTakeover: boolean;
|
|
172
|
+
/** True only after repeated opfs-unavailable failures: the leader may
|
|
173
|
+
* open in memory and report degraded instead of failing again. */
|
|
174
|
+
allowMemoryFallback: boolean;
|
|
175
|
+
/** Set when the broker is re-promoting a surviving leader after its own
|
|
176
|
+
* restart; the tab keeps its worker and just rolls the lock forward. */
|
|
177
|
+
resumeHeld: boolean;
|
|
178
|
+
}
|
|
179
|
+
| { type: 'demote'; brokerInstanceId: string; leadershipId: number }
|
|
180
|
+
| { type: 'leader-ready'; brokerInstanceId: string; leadershipId: number; leaderTabId: TabId }
|
|
181
|
+
// ev.ports = [dbPort, syncPort] on both attach messages.
|
|
182
|
+
| {
|
|
183
|
+
type: 'attach-follower-ports';
|
|
184
|
+
brokerInstanceId: string;
|
|
185
|
+
leadershipId: number;
|
|
186
|
+
followerTabId: TabId;
|
|
187
|
+
}
|
|
188
|
+
| {
|
|
189
|
+
type: 'use-follower-ports';
|
|
190
|
+
brokerInstanceId: string;
|
|
191
|
+
leadershipId: number;
|
|
192
|
+
leaderTabId: TabId;
|
|
193
|
+
}
|
|
194
|
+
| { type: 'close-follower-ports'; brokerInstanceId: string; leadershipId: number }
|
|
195
|
+
| { type: 'unsupported'; brokerInstanceId: string; reason: BrokerUnsupportedReason };
|
|
196
|
+
|
|
197
|
+
// ---- data plane (syncPort) --------------------------------------------------
|
|
198
|
+
// Deliberately narrow. Followers keep their OWN remote WebSocket, so they
|
|
199
|
+
// register/heartbeat/deregister their queries and run the list_ref poll
|
|
200
|
+
// themselves; the leader owns only what must be singular: the outbox drain
|
|
201
|
+
// and the one list_ref LIVE subscription (whose events it relays).
|
|
202
|
+
|
|
203
|
+
/** Matches `CacheIngestTuple` (modules/cache): exactly what `ingestMany`
|
|
204
|
+
* consumes, so relayed batches feed follower circuits without reshaping. */
|
|
205
|
+
export interface IngestTuple {
|
|
206
|
+
table: string;
|
|
207
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
208
|
+
id: string;
|
|
209
|
+
record: Record<string, unknown>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type FollowerToLeaderMessage =
|
|
213
|
+
| { type: 'sync-hello'; tabId: TabId }
|
|
214
|
+
/** The follower committed an outbox row (through the shared store) and the
|
|
215
|
+
* leader should drain it. Idempotent; a new leader's loadFromDatabase is
|
|
216
|
+
* the backstop for a notify lost in a failover window. */
|
|
217
|
+
| { type: 'mutation-enqueued'; mutationId: string }
|
|
218
|
+
| { type: 'request-poll' }
|
|
219
|
+
/** An optimistic write this follower committed to the SHARED store and
|
|
220
|
+
* ingested into its own circuit. The leader ingests it (no DB write, the
|
|
221
|
+
* row is already there) and fans it out to every OTHER follower as
|
|
222
|
+
* `ingest-relay`, so a follower's write lands in every tab in one hop
|
|
223
|
+
* instead of after the server round-trip. */
|
|
224
|
+
| { type: 'ingest'; tuples: IngestTuple[] };
|
|
225
|
+
|
|
226
|
+
export type LeaderToFollowerMessage =
|
|
227
|
+
| { type: 'db-ready'; leadershipId: number; bucketId: string; storageHealth: StorageHealth }
|
|
228
|
+
/** Every ingest the leader's CacheModule committed, so follower circuits
|
|
229
|
+
* stay live without their own fetch. seq detects gaps. */
|
|
230
|
+
| { type: 'ingest-relay'; tuples: IngestTuple[]; leadershipId: number; seq: number }
|
|
231
|
+
/** A `_00_list_ref` LIVE event, relayed verbatim. Each follower resolves the
|
|
232
|
+
* queryId against its own DataModule and ignores foreign queries. */
|
|
233
|
+
| {
|
|
234
|
+
type: 'list-ref-change';
|
|
235
|
+
action: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
236
|
+
queryId: string;
|
|
237
|
+
recordId: string;
|
|
238
|
+
version: number;
|
|
239
|
+
parent: boolean;
|
|
240
|
+
}
|
|
241
|
+
/** The leader's drain rolled back a mutation owned by this tab. */
|
|
242
|
+
| {
|
|
243
|
+
type: 'mutation-rolled-back';
|
|
244
|
+
mutationId: string;
|
|
245
|
+
recordId: string;
|
|
246
|
+
eventType: 'create' | 'update' | 'delete';
|
|
247
|
+
error: string;
|
|
248
|
+
}
|
|
249
|
+
/** The leader's drain pushed a mutation and deleted its outbox row from the
|
|
250
|
+
* SHARED store. Every follower starts its settled-write grace so a row it
|
|
251
|
+
* was rendering as a pending write does not blink out before its
|
|
252
|
+
* `_00_list_ref` membership arrives. */
|
|
253
|
+
| {
|
|
254
|
+
type: 'mutation-settled';
|
|
255
|
+
mutationId: string;
|
|
256
|
+
recordId: string;
|
|
257
|
+
eventType: 'create' | 'update' | 'delete';
|
|
258
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability gate for shared-tabs mode. Any missing piece routes the tab to
|
|
3
|
+
* solo mode, which is exactly the pre-shared-tabs behavior (first tab gets
|
|
4
|
+
* OPFS, later tabs fall back to memory and warn via StorageHealth).
|
|
5
|
+
*/
|
|
6
|
+
import type { Sp00kyConfig } from '../../types';
|
|
7
|
+
|
|
8
|
+
export type SharedTabsUnsupportedReason =
|
|
9
|
+
| 'flag-off'
|
|
10
|
+
| 'not-browser'
|
|
11
|
+
| 'no-shared-worker'
|
|
12
|
+
| 'no-web-locks'
|
|
13
|
+
| 'no-message-channel'
|
|
14
|
+
| 'engine-not-sqlite';
|
|
15
|
+
|
|
16
|
+
export type SharedTabsSupport =
|
|
17
|
+
| { supported: true }
|
|
18
|
+
| { supported: false; reason: SharedTabsUnsupportedReason };
|
|
19
|
+
|
|
20
|
+
export function detectSharedTabsSupport(config: Sp00kyConfig<any>): SharedTabsSupport {
|
|
21
|
+
if (config.sharedTabs !== true) return { supported: false, reason: 'flag-off' };
|
|
22
|
+
// Shared-tabs only makes sense for the SQLite engine: it is the one whose
|
|
23
|
+
// OPFS pool is single-holder. The SurrealDB engine and custom engines keep
|
|
24
|
+
// their existing per-tab behavior.
|
|
25
|
+
if (config.localEngine !== 'sqlite') return { supported: false, reason: 'engine-not-sqlite' };
|
|
26
|
+
if (typeof window === 'undefined') return { supported: false, reason: 'not-browser' };
|
|
27
|
+
if (typeof SharedWorker === 'undefined') return { supported: false, reason: 'no-shared-worker' };
|
|
28
|
+
if (typeof MessageChannel === 'undefined') {
|
|
29
|
+
return { supported: false, reason: 'no-message-channel' };
|
|
30
|
+
}
|
|
31
|
+
const locks = (navigator as { locks?: LockManager }).locks;
|
|
32
|
+
if (!locks || typeof locks.request !== 'function') {
|
|
33
|
+
return { supported: false, reason: 'no-web-locks' };
|
|
34
|
+
}
|
|
35
|
+
return { supported: true };
|
|
36
|
+
}
|