@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201
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 +2184 -54
- package/dist/index.js +11515 -2399
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +421 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +688 -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 +35 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- 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 +462 -0
- package/src/modules/data/data.rebind.test.ts +147 -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 +1580 -130
- 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 +386 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -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 +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +35 -6
- 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 +156 -0
- package/src/modules/sync/scheduler.ts +158 -11
- 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 +134 -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.ts +1558 -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 +160 -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 +32 -12
- package/src/services/database/engine-factory.ts +33 -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 +478 -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 +558 -0
- package/src/services/database/sqlite-cache-engine.ts +1257 -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-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 +106 -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 +121 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +221 -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 +141 -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 +444 -52
- 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 +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +244 -0
- package/src/services/tabs/coordinator.ts +576 -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 +242 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +586 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1543 -0
- package/src/types.ts +496 -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 +73 -13
- package/src/utils/parser.ts +3 -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,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,242 @@
|
|
|
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
|
+
|
|
220
|
+
export type LeaderToFollowerMessage =
|
|
221
|
+
| { type: 'db-ready'; leadershipId: number; bucketId: string; storageHealth: StorageHealth }
|
|
222
|
+
/** Every ingest the leader's CacheModule committed, so follower circuits
|
|
223
|
+
* stay live without their own fetch. seq detects gaps. */
|
|
224
|
+
| { type: 'ingest-relay'; tuples: IngestTuple[]; leadershipId: number; seq: number }
|
|
225
|
+
/** A `_00_list_ref` LIVE event, relayed verbatim. Each follower resolves the
|
|
226
|
+
* queryId against its own DataModule and ignores foreign queries. */
|
|
227
|
+
| {
|
|
228
|
+
type: 'list-ref-change';
|
|
229
|
+
action: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
230
|
+
queryId: string;
|
|
231
|
+
recordId: string;
|
|
232
|
+
version: number;
|
|
233
|
+
parent: boolean;
|
|
234
|
+
}
|
|
235
|
+
/** The leader's drain rolled back a mutation owned by this tab. */
|
|
236
|
+
| {
|
|
237
|
+
type: 'mutation-rolled-back';
|
|
238
|
+
mutationId: string;
|
|
239
|
+
recordId: string;
|
|
240
|
+
eventType: 'create' | 'update' | 'delete';
|
|
241
|
+
error: string;
|
|
242
|
+
};
|
|
@@ -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
|
+
}
|