@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.
Files changed (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. 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
+ }