@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.
Files changed (148) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2184 -54
  3. package/dist/index.js +11515 -2399
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +421 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +688 -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 +35 -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/events/index.ts +2 -1
  25. package/src/modules/auth/index.ts +59 -20
  26. package/src/modules/cache/index.ts +112 -32
  27. package/src/modules/cache/types.ts +2 -2
  28. package/src/modules/crdt/crdt-field.ts +294 -0
  29. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  30. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  31. package/src/modules/crdt/index.ts +463 -0
  32. package/src/modules/crdt/loro-loader.ts +25 -0
  33. package/src/modules/data/data.hydration.test.ts +142 -0
  34. package/src/modules/data/data.membership.test.ts +462 -0
  35. package/src/modules/data/data.rebind.test.ts +147 -0
  36. package/src/modules/data/data.run.test.ts +113 -0
  37. package/src/modules/data/data.settled-writes.test.ts +206 -0
  38. package/src/modules/data/data.status.test.ts +249 -0
  39. package/src/modules/data/id-set-plan.test.ts +122 -0
  40. package/src/modules/data/index.ts +1580 -130
  41. package/src/modules/data/mutation-id.test.ts +25 -0
  42. package/src/modules/data/mutation-id.ts +35 -0
  43. package/src/modules/data/window-query.test.ts +52 -0
  44. package/src/modules/data/window-query.ts +194 -0
  45. package/src/modules/devtools/flags.ts +349 -0
  46. package/src/modules/devtools/index.ts +386 -37
  47. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  48. package/src/modules/devtools/storage-info.test.ts +79 -0
  49. package/src/modules/devtools/storage-info.ts +168 -0
  50. package/src/modules/devtools/versions.test.ts +74 -0
  51. package/src/modules/devtools/versions.ts +110 -0
  52. package/src/modules/feature-flag/index.test.ts +251 -0
  53. package/src/modules/feature-flag/index.ts +308 -0
  54. package/src/modules/ref-tables.test.ts +91 -0
  55. package/src/modules/ref-tables.ts +88 -0
  56. package/src/modules/sync/engine.ts +101 -37
  57. package/src/modules/sync/events/index.ts +9 -2
  58. package/src/modules/sync/queue/queue-down.test.ts +107 -0
  59. package/src/modules/sync/queue/queue-down.ts +35 -6
  60. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  61. package/src/modules/sync/queue/queue-up.ts +241 -57
  62. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  63. package/src/modules/sync/scheduler.retry.test.ts +156 -0
  64. package/src/modules/sync/scheduler.ts +158 -11
  65. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  66. package/src/modules/sync/sync.health.test.ts +149 -0
  67. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  68. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  69. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  70. package/src/modules/sync/sync.subquery.test.ts +82 -0
  71. package/src/modules/sync/sync.ts +1558 -99
  72. package/src/modules/sync/utils.test.ts +269 -2
  73. package/src/modules/sync/utils.ts +201 -17
  74. package/src/otel/index.ts +13 -10
  75. package/src/services/blobs/blob-cache.test.ts +359 -0
  76. package/src/services/blobs/blob-cache.ts +603 -0
  77. package/src/services/blobs/blob-manifest.ts +227 -0
  78. package/src/services/blobs/blob-store.test.ts +77 -0
  79. package/src/services/blobs/blob-store.ts +359 -0
  80. package/src/services/blobs/blob.fixture.ts +90 -0
  81. package/src/services/blobs/index.ts +70 -0
  82. package/src/services/database/cache-engine.ts +160 -0
  83. package/src/services/database/connection-supervisor.test.ts +289 -0
  84. package/src/services/database/connection-supervisor.ts +415 -0
  85. package/src/services/database/database.query-timeout.test.ts +83 -0
  86. package/src/services/database/database.ts +32 -12
  87. package/src/services/database/engine-factory.ts +33 -0
  88. package/src/services/database/events/index.ts +2 -1
  89. package/src/services/database/index.ts +7 -0
  90. package/src/services/database/local-migrator.ts +30 -27
  91. package/src/services/database/local.test.ts +64 -0
  92. package/src/services/database/local.ts +478 -67
  93. package/src/services/database/plan-render.test.ts +159 -0
  94. package/src/services/database/plan-render.ts +108 -0
  95. package/src/services/database/relation-resolver.test.ts +413 -0
  96. package/src/services/database/relation-resolver.ts +0 -0
  97. package/src/services/database/remote.ts +110 -14
  98. package/src/services/database/sqlite-cache-engine.test.ts +558 -0
  99. package/src/services/database/sqlite-cache-engine.ts +1257 -0
  100. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  101. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  102. package/src/services/database/sqlite-open.test.ts +150 -0
  103. package/src/services/database/sqlite-open.ts +164 -0
  104. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  105. package/src/services/database/sqlite-plan-sql.ts +106 -0
  106. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  107. package/src/services/database/sqlite-select.test.ts +246 -0
  108. package/src/services/database/sqlite-select.ts +121 -0
  109. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  110. package/src/services/database/sqlite-transport.ts +221 -0
  111. package/src/services/database/sqlite-worker.ts +437 -0
  112. package/src/services/database/surql-translate.ts +416 -0
  113. package/src/services/database/surreal-cache-engine.ts +141 -0
  114. package/src/services/logger/index.ts +3 -2
  115. package/src/services/persistence/localstorage.ts +2 -2
  116. package/src/services/persistence/resilient.ts +11 -4
  117. package/src/services/persistence/surrealdb.ts +10 -10
  118. package/src/services/stream-processor/index.ts +444 -52
  119. package/src/services/stream-processor/permissions.test.ts +47 -0
  120. package/src/services/stream-processor/permissions.ts +53 -0
  121. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  122. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  123. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  124. package/src/services/stream-processor/wasm-types.ts +23 -2
  125. package/src/services/tabs/broker-client.ts +283 -0
  126. package/src/services/tabs/broker.test.ts +278 -0
  127. package/src/services/tabs/coordinator.test.ts +244 -0
  128. package/src/services/tabs/coordinator.ts +576 -0
  129. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  130. package/src/services/tabs/leader-locks.ts +75 -0
  131. package/src/services/tabs/protocol.ts +242 -0
  132. package/src/services/tabs/support.ts +36 -0
  133. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  134. package/src/sp00ky.auth-order.test.ts +92 -0
  135. package/src/sp00ky.init-query.test.ts +183 -0
  136. package/src/sp00ky.ts +1543 -0
  137. package/src/types.ts +496 -13
  138. package/src/utils/blurhash.ts +90 -0
  139. package/src/utils/error-classification.test.ts +44 -0
  140. package/src/utils/error-classification.ts +7 -0
  141. package/src/utils/index.ts +73 -13
  142. package/src/utils/parser.ts +3 -2
  143. package/src/utils/semver.test.ts +32 -0
  144. package/src/utils/semver.ts +30 -0
  145. package/src/utils/surql.ts +30 -18
  146. package/src/utils/withRetry.test.ts +1 -1
  147. package/tsdown.config.ts +86 -1
  148. package/src/spooky.ts +0 -395
@@ -0,0 +1,121 @@
1
+ import type { QueryPlan } from '@spooky-sync/query-builder';
2
+ import { resolveRelations, stableKey } from './relation-resolver';
3
+ import { renderOrderSql, renderWhereSql, reviveRow, project } from './sqlite-plan-sql';
4
+ import type { OrderBy, RelationFetch, Row, RowFetcher } from './cache-engine';
5
+
6
+ /**
7
+ * Worker-side execution of a whole {@link QueryPlan} — table creation, base
8
+ * select (either the `ids` window or where/order/limit), projection, and the
9
+ * full `.related()` tree via the SHARED `resolveRelations` — against an
10
+ * injected DB handle. Runs inside `sqlite-worker.ts` so the engine pays ONE
11
+ * postMessage round-trip per select instead of one per table/relation level;
12
+ * extracted into its own module so the logic is unit-testable off-worker
13
+ * (parity with the engine's legacy multi-hop path).
14
+ *
15
+ * Semantics mirror `SqliteCacheEngine.selectLegacy` exactly: same SQL, same
16
+ * ordering rules, same projection, same resolver.
17
+ */
18
+
19
+ /** The slice of the worker's DB surface `executeSelect` needs. */
20
+ export interface SelectDb {
21
+ /** Run a row-returning statement; rows come back as `{ data: <json> }`. */
22
+ exec(sql: string, bind?: unknown[]): { data: string }[];
23
+ /** Run a statement for effect only (CREATE TABLE). */
24
+ run(sql: string, bind?: unknown[]): void;
25
+ /** Tables already CREATEd on this handle (caller owns the lifecycle). */
26
+ knownTables: Set<string>;
27
+ }
28
+
29
+ function ensureTable(db: SelectDb, table: string): void {
30
+ if (db.knownTables.has(table)) return;
31
+ db.run(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`);
32
+ db.knownTables.add(table);
33
+ }
34
+
35
+ function execRows(db: SelectDb, sql: string, bind: unknown[]): Row[] {
36
+ return db.exec(sql, bind).map((r) => reviveRow(r.data));
37
+ }
38
+
39
+ /** Mirrors the engine's `selectByIds`: fetch by primary id, preserving `ids`
40
+ * order unless an ORDER BY overrides it. */
41
+ function selectByIds(
42
+ db: SelectDb,
43
+ table: string,
44
+ ids: unknown[],
45
+ opts?: { select?: string[]; orderBy?: OrderBy }
46
+ ): Row[] {
47
+ if (ids.length === 0) return [];
48
+ ensureTable(db, table);
49
+ const keys = ids.map(stableKey);
50
+ const placeholders = keys.map(() => '?').join(', ');
51
+ let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
52
+ if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
53
+ let rows = execRows(db, sql, keys);
54
+ if (!opts?.orderBy || opts.orderBy.length === 0) {
55
+ const pos = new Map(keys.map((k, i) => [k, i]));
56
+ rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
57
+ }
58
+ return opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
59
+ }
60
+
61
+ /** Mirrors the engine's `fetchRelation` SQL exactly. */
62
+ function fetchRelation(db: SelectDb, req: RelationFetch): Row[] {
63
+ ensureTable(db, req.table);
64
+ const keys = req.keys.map(stableKey);
65
+ const placeholders = keys.map(() => '?').join(', ');
66
+ const bind: unknown[] = [...keys];
67
+ const lhs = req.matchField === 'id' ? 'id' : `json_extract(data, '$.${req.matchField}')`;
68
+ let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
69
+ if (req.where && req.where.length > 0) {
70
+ sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
71
+ }
72
+ if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
73
+ const rows = execRows(db, sql, bind);
74
+ return req.select ? rows.map((r) => project(r, req.select!)) : rows;
75
+ }
76
+
77
+ export async function executeSelect(
78
+ plan: QueryPlan,
79
+ params: Record<string, unknown>,
80
+ db: SelectDb
81
+ ): Promise<{ rows: Row[]; relationFetches: number }> {
82
+ // Per-call fetch counter (the engine folds it into `__sqliteStats`).
83
+ const counter = { n: 0 };
84
+ const fetcher: RowFetcher = {
85
+ fetchRelation: (req) => {
86
+ counter.n++;
87
+ return Promise.resolve(fetchRelation(db, req));
88
+ },
89
+ };
90
+ // Window materialization: base rows are exactly `plan.ids`, ordered.
91
+ if (plan.ids) {
92
+ const rows = selectByIds(db, plan.table, plan.ids, {
93
+ select: plan.select,
94
+ orderBy: plan.orderBy,
95
+ });
96
+ await resolveRelations(rows, plan.relations, fetcher);
97
+ return { rows, relationFetches: counter.n };
98
+ }
99
+ ensureTable(db, plan.table);
100
+ const bind: unknown[] = [];
101
+ let sql = `SELECT data FROM "${plan.table}"`;
102
+ if (plan.where && plan.where.length > 0) {
103
+ sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
104
+ }
105
+ if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
106
+ // A query with no ORDER BY still has to render in SOME order, and "whatever
107
+ // SQLite hands back" is insertion order — which disagrees with the order the
108
+ // same query gets once it renders from server membership, and disagrees with
109
+ // SurrealDB, whose natural order is by id. That mismatch is visible: the
110
+ // first paint comes from this scan and the second from membership, so an
111
+ // unordered list visibly reshuffled about a second after load. Ordering by
112
+ // id here makes the two agree and makes the result stable across reloads.
113
+ else sql += ` ORDER BY id`;
114
+ if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
115
+ if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
116
+ const rows = execRows(db, sql, bind);
117
+ // Optional projection trimming to match `SELECT <fields>`.
118
+ const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
119
+ await resolveRelations(projected, plan.relations, fetcher);
120
+ return { rows: projected, relationFetches: counter.n };
121
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Test-only helper: stubs `SqliteCacheEngine.createTransport` with a fake
3
+ * transport driven by a plain handler function, replacing the old pattern of
4
+ * faking a whole Worker plus the engine's pending-map wiring. Not exported
5
+ * from the package; imported only by *.test.ts files.
6
+ */
7
+ import type { SqliteTransport } from './sqlite-transport';
8
+
9
+ export type FakeTransportHandler = (type: string, payload: any) => unknown | Promise<unknown>;
10
+
11
+ /** Replace the engine's transport factory. Each open spawns a fresh fake, like
12
+ * the real factory spawns a fresh Worker. The handler returns the reply rest
13
+ * (without id/ok/wt) or throws to produce an error reply. */
14
+ export function stubTransport(engine: unknown, handler: FakeTransportHandler): void {
15
+ (engine as { createTransport: () => SqliteTransport }).createTransport = () => {
16
+ let closed = false;
17
+ return {
18
+ kind: 'worker',
19
+ get connected() {
20
+ return !closed;
21
+ },
22
+ call: <T>(type: string, payload?: unknown) =>
23
+ Promise.resolve().then(() => handler(type, payload)) as Promise<T>,
24
+ failAll() {},
25
+ close() {
26
+ closed = true;
27
+ },
28
+ } as SqliteTransport;
29
+ };
30
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The wire between `SqliteCacheEngine` and the SQLite worker, extracted so the
3
+ * engine can swap it at runtime without touching any SQL logic. Two shapes:
4
+ *
5
+ * - {@link WorkerSqliteTransport}: owns a dedicated Worker (solo mode, and the
6
+ * leader tab in shared-tabs mode). Controls the worker's lifecycle and can
7
+ * forward follower MessagePorts into it (`add-client`).
8
+ * - {@link PortSqliteTransport}: speaks the same request/response protocol over
9
+ * a MessagePort handed out by the leader (follower tabs). No lifecycle
10
+ * control; the port dying surfaces through `onPortDead`.
11
+ *
12
+ * Both keep the pending-map semantics the engine had inline before: requests
13
+ * keyed by a locally-minted numeric id, one resolve/reject per id, `failAll`
14
+ * rejects everything in flight (worker crash, port loss, role change).
15
+ */
16
+ import type { Logger } from '../logger/index';
17
+
18
+ export interface SqliteTransport {
19
+ readonly kind: 'worker' | 'port';
20
+ /** True while the transport can carry a request. */
21
+ readonly connected: boolean;
22
+ call<T = unknown>(type: string, payload?: unknown): Promise<T>;
23
+ /** Reject every pending request with `reason`. Safe to call repeatedly. */
24
+ failAll(reason: string): void;
25
+ /** failAll + release the underlying channel. Terminal. `err` overrides the
26
+ * transport's own error shape — used for a deliberate teardown (role change)
27
+ * so callers see a retryable "transport lost", not "the worker crashed". */
28
+ close(reason?: string, err?: Error): void;
29
+ }
30
+
31
+ /** Thrown into pending follower calls when the leader (or its port) goes away.
32
+ * Callers treat it like a transient error: the op may be retried once a new
33
+ * leader is attached; nothing was necessarily executed. */
34
+ export class BrokerPortClosedError extends Error {
35
+ readonly retryable = true;
36
+ constructor(reason: string) {
37
+ super(`sqlite transport lost: ${reason}`);
38
+ this.name = 'BrokerPortClosedError';
39
+ }
40
+ }
41
+
42
+ interface Pending {
43
+ resolve: (v: any) => void;
44
+ reject: (e: unknown) => void;
45
+ }
46
+
47
+ /** Shared request/response bookkeeping over any postMessage-shaped channel. */
48
+ abstract class BaseTransport implements SqliteTransport {
49
+ abstract readonly kind: 'worker' | 'port';
50
+ protected pending = new Map<number, Pending>();
51
+ protected seq = 0;
52
+ protected closed = false;
53
+
54
+ constructor(protected logger: Logger) {}
55
+
56
+ get connected(): boolean {
57
+ return !this.closed;
58
+ }
59
+
60
+ protected abstract post(msg: unknown, transfer?: Transferable[]): void;
61
+ protected abstract makeError(reason: string): Error;
62
+
63
+ protected handleMessage(data: any): void {
64
+ const { id, ok, error, ...rest } = data ?? {};
65
+ const p = this.pending.get(id);
66
+ if (!p) return;
67
+ this.pending.delete(id);
68
+ if (ok) p.resolve(rest);
69
+ else p.reject(new Error(error));
70
+ }
71
+
72
+ call<T = unknown>(type: string, payload?: unknown): Promise<T> {
73
+ if (this.closed) return Promise.reject(this.makeError('transport closed'));
74
+ const id = ++this.seq;
75
+ return new Promise<T>((resolve, reject) => {
76
+ this.pending.set(id, { resolve, reject });
77
+ try {
78
+ this.post({ id, type, payload });
79
+ } catch (e) {
80
+ this.pending.delete(id);
81
+ reject(e);
82
+ }
83
+ });
84
+ }
85
+
86
+ failAll(reason: string, err?: Error): void {
87
+ if (this.pending.size === 0) return;
88
+ const e = err ?? this.makeError(reason);
89
+ for (const [, p] of this.pending) p.reject(e);
90
+ this.pending.clear();
91
+ }
92
+
93
+ close(reason = 'closed', err?: Error): void {
94
+ if (this.closed) return;
95
+ this.closed = true;
96
+ this.failAll(reason, err);
97
+ }
98
+ }
99
+
100
+ export class WorkerSqliteTransport extends BaseTransport {
101
+ readonly kind = 'worker' as const;
102
+ private worker: Worker;
103
+ /** Fired when the worker reports it fenced itself after a leadership steal
104
+ * (see sqlite-worker.ts). The engine must treat the store as gone. */
105
+ onLockLost: ((reason: string) => void) | null = null;
106
+
107
+ constructor(logger: Logger) {
108
+ super(logger);
109
+ // Source references the `.ts` so the monorepo's src-bundling consumers
110
+ // (e.g. the example app, which aliases `@spooky-sync/core` to `src`)
111
+ // resolve it — Vite handles `.ts` workers. For the published package, the
112
+ // tsdown build rewrites this to `./sqlite-worker.js` (the top-level
113
+ // emitted entry; see tsdown.config.ts), which the flat `dist/index.js`
114
+ // resolves. The worker (+ `@sqlite.org/sqlite-wasm`) still loads lazily —
115
+ // only when `localEngine: 'sqlite'` is used.
116
+ this.worker = new Worker(new URL('./sqlite-worker.ts', import.meta.url), { type: 'module' });
117
+ this.worker.onmessage = (ev: MessageEvent) => {
118
+ // Unsolicited worker-initiated notification, not a reply.
119
+ if (ev.data?.type === 'lock-lost') {
120
+ this.logger.error(
121
+ { reason: ev.data.reason, Category: 'sp00ky-client::SqliteCacheEngine::worker' },
122
+ 'SQLite worker fenced after leadership loss'
123
+ );
124
+ this.failAll('worker fenced');
125
+ this.onLockLost?.(String(ev.data.reason ?? 'lock-lost'));
126
+ return;
127
+ }
128
+ this.handleMessage(ev.data);
129
+ };
130
+ // Surface a worker crash (wasm abort / OOM) instead of leaving every
131
+ // pending call hung forever — reject them all with a clear error.
132
+ const crash = (msg: string) => {
133
+ this.logger.error(
134
+ { err: this.makeError(msg), Category: 'sp00ky-client::SqliteCacheEngine::worker' },
135
+ 'Worker error'
136
+ );
137
+ this.failAll(msg);
138
+ };
139
+ this.worker.onerror = (e: ErrorEvent) => crash(e.message || 'onerror');
140
+ this.worker.onmessageerror = () => crash('messageerror');
141
+ }
142
+
143
+ protected post(msg: unknown, transfer?: Transferable[]): void {
144
+ if (transfer) this.worker.postMessage(msg, transfer);
145
+ else this.worker.postMessage(msg);
146
+ }
147
+
148
+ protected makeError(reason: string): Error {
149
+ return new Error(`SQLite worker crashed: ${reason}`);
150
+ }
151
+
152
+ /** Forward a follower's MessagePort into the worker as an extra client. */
153
+ addClientPort(clientId: string, port: MessagePort): Promise<void> {
154
+ if (this.closed) return Promise.reject(this.makeError('transport closed'));
155
+ const id = ++this.seq;
156
+ return new Promise<void>((resolve, reject) => {
157
+ this.pending.set(id, { resolve: () => resolve(), reject });
158
+ this.worker.postMessage({ id, type: 'add-client', payload: { clientId } }, [port]);
159
+ });
160
+ }
161
+
162
+ removeClientPort(clientId: string): Promise<void> {
163
+ return this.call('remove-client', { clientId }).then(() => undefined);
164
+ }
165
+
166
+ /** Ask the worker to close + pauseVfs + self-close (graceful pagehide). */
167
+ shutdown(): Promise<void> {
168
+ return this.call('shutdown').then(() => undefined);
169
+ }
170
+
171
+ close(reason = 'closed', err?: Error): void {
172
+ if (this.closed) return;
173
+ super.close(reason, err);
174
+ this.worker.terminate();
175
+ }
176
+ }
177
+
178
+ export class PortSqliteTransport extends BaseTransport {
179
+ readonly kind = 'port' as const;
180
+
181
+ constructor(
182
+ private port: MessagePort,
183
+ private onPortDead: (reason: string) => void,
184
+ logger: Logger
185
+ ) {
186
+ super(logger);
187
+ port.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);
188
+ port.onmessageerror = () => this.dead('messageerror');
189
+ port.start?.();
190
+ }
191
+
192
+ /** The broker/coordinator learned the leader is gone; the port itself has no
193
+ * close event, so the coordinator calls this explicitly. */
194
+ markDead(reason: string): void {
195
+ this.dead(reason);
196
+ }
197
+
198
+ private dead(reason: string, err?: Error): void {
199
+ if (this.closed) return;
200
+ this.closed = true;
201
+ this.failAll(reason, err);
202
+ try {
203
+ this.port.close();
204
+ } catch {
205
+ /* ignore */
206
+ }
207
+ this.onPortDead(reason);
208
+ }
209
+
210
+ protected post(msg: unknown): void {
211
+ this.port.postMessage(msg);
212
+ }
213
+
214
+ protected makeError(reason: string): Error {
215
+ return new BrokerPortClosedError(reason);
216
+ }
217
+
218
+ close(reason = 'closed', err?: Error): void {
219
+ this.dead(reason, err);
220
+ }
221
+ }