@spooky-sync/core 0.0.1-canary.355 → 0.0.1-canary.356

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.
@@ -0,0 +1,1153 @@
1
+ import type { ColumnSchema, QueryPlan, SchemaStructure, WhereComparison, WhereNode } from '@spooky-sync/query-builder';
2
+ import { RecordId } from 'surrealdb';
3
+ import type { Logger } from '../../services/logger/index';
4
+ import { encodeRecordId, parseRecordIdString } from '../../utils/index';
5
+
6
+ /**
7
+ * Peer to peer channels (`-- @channel` tables, `client.channel(query, peers)`).
8
+ *
9
+ * A channel is a filter (the `where` of one or more queries on `@channel`
10
+ * tables) plus the users it connects to. Writes to rows that match the
11
+ * filter reach the users' tabs straight over the transport (WebRTC by
12
+ * default), and their tabs fold them in before the database has seen them.
13
+ * The filter is evaluated here, in plain JS, against the row; the query is
14
+ * never registered. Optimistic writes, distributed:
15
+ *
16
+ * - Updates: persistent fields still go through the normal outbox. A peer
17
+ * shows the sent value until its own synced row catches up to it, or for
18
+ * `confirmTimeoutMs` at most, so a value the server rejected (or that a
19
+ * peer made up) falls back to the server's on its own.
20
+ * - `-- @ephemeral` fields never reach the database. They live in memory,
21
+ * last writer wins, and a peer's values vanish when it disconnects.
22
+ * - Creates go into the peer's store and circuit like its own optimistic
23
+ * create (minus the outbox); deletes hide the row. Both are undone unless
24
+ * the server confirms them (see the peer sagas in `mutation/write.saga.ts`).
25
+ *
26
+ * Nothing here enforces permissions beyond identity: the transport proves
27
+ * which user a peer is (the database stamps the signaling rows), a message
28
+ * is only taken from a user listed by an open channel whose filter the row
29
+ * matches, and fields must exist and fit their column type. Everything else
30
+ * is the app's call, through `channels.accept`.
31
+ *
32
+ * Update and ephemeral values are folded into subscription output only,
33
+ * never into the local store.
34
+ */
35
+
36
+ export type Row = Record<string, unknown>;
37
+
38
+ /** Hybrid logical clock: wall ms, counter, tab peer id. Orders every write. */
39
+ export type Hlc = readonly [ms: number, counter: number, peer: string];
40
+
41
+ export const compareHlc = (a: Hlc, b: Hlc): number =>
42
+ a[0] - b[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0);
43
+
44
+ /** One tab of one user. */
45
+ export interface PeerRef {
46
+ readonly user: string;
47
+ readonly peer: string;
48
+ }
49
+
50
+ export const peerKey = (p: PeerRef): string => `${p.user}#${p.peer}`;
51
+
52
+ /** What a message does to its row. */
53
+ export type ChannelOpKind = 'update' | 'create' | 'delete';
54
+
55
+ /** The one message channels exchange: a write to one row. */
56
+ export interface ChannelMessage {
57
+ readonly t: 'op';
58
+ /** Absent means `update` (a field patch). */
59
+ readonly kind?: ChannelOpKind;
60
+ readonly table: string;
61
+ readonly id: string;
62
+ /** Wire-encoded values (see `encodeValue`): the patch, the whole row for a create, `{}` for a delete. */
63
+ readonly patch: Record<string, unknown>;
64
+ readonly hlc: Hlc;
65
+ }
66
+
67
+ /** How a channel is doing, as `useChannel(...).status()` shows it. */
68
+ export type ChannelStatus = 'idle' | 'connecting' | 'connected' | 'failed' | 'unsupported';
69
+
70
+ export interface ChannelState {
71
+ readonly status: ChannelStatus;
72
+ /** Users with at least one tab connected. */
73
+ readonly peers: ReadonlyArray<string>;
74
+ }
75
+
76
+
77
+ /** What an `accept` rule decides on. */
78
+ export interface ChannelOp {
79
+ readonly kind: ChannelOpKind;
80
+ /** The sending user, as the database proved it. */
81
+ readonly from: string;
82
+ readonly table: string;
83
+ readonly id: string;
84
+ /** The row as this client currently shows it, overlay included; undefined for a create. */
85
+ readonly row: Row | undefined;
86
+ /** Decoded field values: the patch, the new row for a create, `{}` for a delete. */
87
+ readonly patch: Readonly<Row>;
88
+ }
89
+
90
+ export type ChannelAcceptRule = (op: ChannelOp) => boolean;
91
+
92
+ // ---- transport -------------------------------------------------------------------
93
+
94
+ export interface TransportHooks {
95
+ /** This tab, or null while signed out. */
96
+ self(): PeerRef | null;
97
+ onMessage(from: PeerRef, message: unknown): void;
98
+ onPeer(peer: PeerRef, up: boolean): void;
99
+ /** Per user: an attempt is running, a tab is connected, or attempts failed. */
100
+ onStatus(user: string, status: 'connecting' | 'connected' | 'failed'): void;
101
+ }
102
+
103
+ /**
104
+ * Moves messages between tabs. WebRTC by default; an app that already has a
105
+ * room (LiveKit, its own relay) can plug that in. `send` is fire and forget.
106
+ */
107
+ export interface ChannelTransport {
108
+ readonly supported: boolean;
109
+ /** Users this tab wants connections to: the union over open channels. */
110
+ want(users: ReadonlySet<string>): void;
111
+ send(to: PeerRef, message: ChannelMessage): void;
112
+ close(): void;
113
+ }
114
+
115
+ export type ChannelTransportFactory = (hooks: TransportHooks) => ChannelTransport;
116
+
117
+ export interface ChannelsConfig {
118
+ /**
119
+ * Per table: decide whether to apply a peer's update. Runs after the
120
+ * built-in checks (sender listed by an open channel carrying the table,
121
+ * fields exist and fit).
122
+ * Without a rule every such update is applied.
123
+ */
124
+ accept?: Record<string, ChannelAcceptRule>;
125
+ /** STUN/TURN servers for the default WebRTC transport. */
126
+ iceServers?: RTCIceServer[];
127
+ /** Replace the WebRTC transport. */
128
+ transport?: ChannelTransportFactory;
129
+ /** How long a peer's persistent value shows without the server confirming it. Default 5000. */
130
+ confirmTimeoutMs?: number;
131
+ }
132
+
133
+ // ---- pure helpers ----------------------------------------------------------------
134
+
135
+ const isRecordIdLike = (v: unknown): v is RecordId<string> =>
136
+ v instanceof RecordId ||
137
+ (v !== null && typeof v === 'object' && (v as { constructor?: { name?: string } }).constructor?.name === 'RecordId');
138
+
139
+ /** `table:id` for a record id in any spelling, or null. */
140
+ export function idKey(v: unknown): string | null {
141
+ if (typeof v === 'string') return v.includes(':') ? v : null;
142
+ if (isRecordIdLike(v)) return encodeRecordId(v);
143
+ if (v !== null && typeof v === 'object' && 'table' in v && 'id' in v) {
144
+ const t = (v as { table: unknown }).table;
145
+ const name = typeof t === 'string' ? t : (t as { name?: unknown })?.name;
146
+ if (typeof name === 'string') return `${name}:${String((v as { id: unknown }).id)}`;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ const toBase64 = (bytes: Uint8Array): string => {
152
+ let s = '';
153
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
154
+ return btoa(s);
155
+ };
156
+
157
+ const fromBase64 = (b64: string): Uint8Array => {
158
+ const s = atob(b64);
159
+ const out = new Uint8Array(s.length);
160
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
161
+ return out;
162
+ };
163
+
164
+ /** JSON-safe form of a field value: record ids, dates and bytes are tagged. */
165
+ export function encodeValue(v: unknown): unknown {
166
+ if (v === undefined) return null;
167
+ if (isRecordIdLike(v)) return { $rid: encodeRecordId(v) };
168
+ if (v instanceof Date) return { $date: v.toISOString() };
169
+ if (v instanceof Uint8Array) return { $bytes: toBase64(v) };
170
+ if (v instanceof ArrayBuffer) return { $bytes: toBase64(new Uint8Array(v)) };
171
+ if (Array.isArray(v)) return v.map(encodeValue);
172
+ if (v !== null && typeof v === 'object') {
173
+ const out: Row = {};
174
+ for (const [k, x] of Object.entries(v)) out[k] = encodeValue(x);
175
+ return out;
176
+ }
177
+ return v;
178
+ }
179
+
180
+ export function decodeValue(v: unknown): unknown {
181
+ if (Array.isArray(v)) return v.map(decodeValue);
182
+ if (v === null || typeof v !== 'object') return v;
183
+ const o = v as Row;
184
+ const keys = Object.keys(o);
185
+ if (keys.length === 1) {
186
+ if (typeof o.$rid === 'string') return parseRecordIdString(o.$rid);
187
+ if (typeof o.$date === 'string') return new Date(o.$date);
188
+ if (typeof o.$bytes === 'string') return fromBase64(o.$bytes);
189
+ }
190
+ const out: Row = {};
191
+ for (const [k, x] of Object.entries(o)) out[k] = decodeValue(x);
192
+ return out;
193
+ }
194
+
195
+ const sameValue = (a: unknown, b: unknown): boolean =>
196
+ a === b || JSON.stringify(encodeValue(a)) === JSON.stringify(encodeValue(b));
197
+
198
+ /** A well-formed `op` message, or null. */
199
+ export function parseMessage(raw: unknown): ChannelMessage | null {
200
+ if (raw === null || typeof raw !== 'object') return null;
201
+ const m = raw as Record<string, unknown>;
202
+ const hlc = m.hlc as unknown[];
203
+ if (m.kind !== undefined && m.kind !== 'update' && m.kind !== 'create' && m.kind !== 'delete') return null;
204
+ if (
205
+ m.t !== 'op' ||
206
+ typeof m.table !== 'string' ||
207
+ typeof m.id !== 'string' ||
208
+ m.patch === null ||
209
+ typeof m.patch !== 'object' ||
210
+ Array.isArray(m.patch) ||
211
+ !Array.isArray(hlc) ||
212
+ hlc.length !== 3 ||
213
+ typeof hlc[0] !== 'number' ||
214
+ typeof hlc[1] !== 'number' ||
215
+ typeof hlc[2] !== 'string'
216
+ ) {
217
+ return null;
218
+ }
219
+ return m as unknown as ChannelMessage;
220
+ }
221
+
222
+ /** Whether a decoded value fits a column, loosely (the shapes the client itself writes). */
223
+ export function fitsColumn(col: ColumnSchema, v: unknown): boolean {
224
+ if (v === null || v === undefined) return col.optional;
225
+ if (col.array) return Array.isArray(v) && v.every((x) => fitsScalar(col, x));
226
+ return fitsScalar(col, v);
227
+ }
228
+
229
+ function fitsScalar(col: ColumnSchema, v: unknown): boolean {
230
+ if (col.recordId) return typeof v === 'string' || isRecordIdLike(v);
231
+ if (col.dateTime) return v instanceof Date || typeof v === 'string';
232
+ switch (col.type) {
233
+ case 'string':
234
+ return typeof v === 'string';
235
+ case 'number':
236
+ return typeof v === 'number' && Number.isFinite(v);
237
+ case 'boolean':
238
+ return typeof v === 'boolean';
239
+ case 'Uint8Array':
240
+ return v instanceof Uint8Array;
241
+ case 'null':
242
+ return v === null;
243
+ default:
244
+ return true;
245
+ }
246
+ }
247
+
248
+ /** Why a peer patch is refused against the table's columns, or null when it fits. */
249
+ export function checkPatch(columns: Record<string, ColumnSchema>, patch: Row, allowEmpty = false): string | null {
250
+ const fields = Object.keys(patch);
251
+ if (fields.length === 0 && !allowEmpty) return 'empty patch';
252
+ for (const field of fields) {
253
+ if (field === 'id') return 'patches the id';
254
+ const col = columns[field];
255
+ if (!col) return `unknown field '${field}'`;
256
+ if (col.crdt) return `'${field}' is a @crdt field`;
257
+ if (!fitsColumn(col, patch[field])) return `'${field}' does not fit its column type`;
258
+ }
259
+ return null;
260
+ }
261
+
262
+ /** A patch split into the fields the database gets and the ones only peers get. */
263
+ export function splitPatch(
264
+ columns: Record<string, ColumnSchema>,
265
+ data: Row
266
+ ): { persistent: Row; ephemeral: Row | null } {
267
+ let ephemeral: Row | null = null;
268
+ const persistent: Row = {};
269
+ for (const [k, v] of Object.entries(data)) {
270
+ if (columns[k]?.ephemeral) (ephemeral ??= {})[k] = v;
271
+ else persistent[k] = v;
272
+ }
273
+ return { persistent, ephemeral };
274
+ }
275
+
276
+ /** One overlaid field: the value, who wrote it and when. */
277
+ export interface OverlayEntry {
278
+ readonly value: unknown;
279
+ readonly hlc: Hlc;
280
+ /** `peerKey` of the author, or 'self'. */
281
+ readonly from: string;
282
+ readonly ephemeral: boolean;
283
+ /** When a persistent peer value stops showing; null for ephemeral and own values. */
284
+ readonly expiresAt: number | null;
285
+ }
286
+
287
+ export type Overlay = ReadonlyMap<string, ReadonlyMap<string, OverlayEntry>>;
288
+
289
+ const OVERLAY_DEPTH = 4;
290
+
291
+ /**
292
+ * Rows with the overlay folded in, nested related rows included. Untouched
293
+ * rows (and the array itself, when nothing changed) keep their identity.
294
+ */
295
+ export function applyOverlay(rows: ReadonlyArray<Row>, overlay: Overlay): Row[] {
296
+ if (overlay.size === 0) return rows as Row[];
297
+ const out = foldArray(rows, overlay, 0);
298
+ return out as Row[];
299
+ }
300
+
301
+ function foldArray(arr: ReadonlyArray<unknown>, overlay: Overlay, depth: number): ReadonlyArray<unknown> {
302
+ let changed = false;
303
+ const next = arr.map((x) => {
304
+ const y = foldValue(x, overlay, depth);
305
+ if (y !== x) changed = true;
306
+ return y;
307
+ });
308
+ return changed ? next : arr;
309
+ }
310
+
311
+ function foldValue(v: unknown, overlay: Overlay, depth: number): unknown {
312
+ if (depth > OVERLAY_DEPTH || v === null || typeof v !== 'object') return v;
313
+ if (Array.isArray(v)) return foldArray(v, overlay, depth + 1);
314
+ if (isRecordIdLike(v) || v instanceof Date || v instanceof Uint8Array) return v;
315
+ const row = v as Row;
316
+ let next: Row | null = null;
317
+ for (const [k, x] of Object.entries(row)) {
318
+ if (k === 'id') continue;
319
+ const y = foldValue(x, overlay, depth + 1);
320
+ if (y !== x) (next ??= { ...row })[k] = y;
321
+ }
322
+ const key = 'id' in row ? idKey(row.id) : null;
323
+ const fields = key ? overlay.get(key) : undefined;
324
+ if (fields) {
325
+ for (const [field, entry] of fields) {
326
+ if (!sameValue(row[field], entry.value)) (next ??= { ...row })[field] = entry.value;
327
+ }
328
+ }
329
+ return next ?? row;
330
+ }
331
+
332
+ /** Every record id in rows, nested related rows included. */
333
+ export function collectIds(rows: ReadonlyArray<unknown>, into = new Set<string>(), depth = 0): Set<string> {
334
+ if (depth > OVERLAY_DEPTH) return into;
335
+ for (const v of rows) {
336
+ if (v === null || typeof v !== 'object') continue;
337
+ if (Array.isArray(v)) {
338
+ collectIds(v, into, depth + 1);
339
+ continue;
340
+ }
341
+ if (isRecordIdLike(v) || v instanceof Date || v instanceof Uint8Array) continue;
342
+ const row = v as Row;
343
+ const key = 'id' in row ? idKey(row.id) : null;
344
+ if (key) into.add(key);
345
+ for (const [k, x] of Object.entries(row)) {
346
+ if (k !== 'id' && x !== null && typeof x === 'object') collectIds([x], into, depth + 1);
347
+ }
348
+ }
349
+ return into;
350
+ }
351
+
352
+ /** Normalized peer ids, without `self`. */
353
+ export function peerUsers(peers: Iterable<unknown>, self: string | null): Set<string> {
354
+ const out = new Set<string>();
355
+ for (const p of peers) {
356
+ const key = idKey(p);
357
+ if (key && key !== self) out.add(key);
358
+ }
359
+ return out;
360
+ }
361
+
362
+ /** What a channel carries: the rows of one `@channel` table its filter matches. */
363
+ export interface ChannelScope {
364
+ readonly table: string;
365
+ readonly where: ReadonlyArray<WhereNode>;
366
+ }
367
+
368
+ const OPS: Record<string, (a: unknown, b: unknown) => boolean> = {
369
+ '=': (a, b) => sameKey(a, b),
370
+ '==': (a, b) => sameKey(a, b),
371
+ '!=': (a, b) => !sameKey(a, b),
372
+ '>': (a, b) => order(a, b) > 0,
373
+ '>=': (a, b) => order(a, b) >= 0,
374
+ '<': (a, b) => order(a, b) < 0,
375
+ '<=': (a, b) => order(a, b) <= 0,
376
+ IN: (a, b) => Array.isArray(b) && b.some((x) => sameKey(a, x)),
377
+ INSIDE: (a, b) => Array.isArray(b) && b.some((x) => sameKey(a, x)),
378
+ '∈': (a, b) => Array.isArray(b) && b.some((x) => sameKey(a, x)),
379
+ 'NOT IN': (a, b) => !(Array.isArray(b) && b.some((x) => sameKey(a, x))),
380
+ NOTINSIDE: (a, b) => !(Array.isArray(b) && b.some((x) => sameKey(a, x))),
381
+ '∉': (a, b) => !(Array.isArray(b) && b.some((x) => sameKey(a, x))),
382
+ CONTAINS: (a, b) => Array.isArray(a) && a.some((x) => sameKey(x, b)),
383
+ '∋': (a, b) => Array.isArray(a) && a.some((x) => sameKey(x, b)),
384
+ CONTAINSNOT: (a, b) => !(Array.isArray(a) && a.some((x) => sameKey(x, b))),
385
+ '∌': (a, b) => !(Array.isArray(a) && a.some((x) => sameKey(x, b))),
386
+ };
387
+
388
+ /** A comparable form: record ids by `table:id` (in any spelling), dates by ms. */
389
+ function keyOf(v: unknown): unknown {
390
+ if (v === undefined) return null;
391
+ if (isRecordIdLike(v)) return idKey(v);
392
+ if (v instanceof Date) return v.getTime();
393
+ return v;
394
+ }
395
+
396
+ function sameKey(a: unknown, b: unknown): boolean {
397
+ const x = isRecordIdLike(a) || isRecordIdLike(b) ? keyOfId(a) : keyOf(a);
398
+ const y = isRecordIdLike(a) || isRecordIdLike(b) ? keyOfId(b) : keyOf(b);
399
+ if (x === y) return true;
400
+ return x !== null && y !== null && typeof x === 'object' && typeof y === 'object' && sameValue(x, y);
401
+ }
402
+
403
+ /** A record id compared against a string spelling of one. */
404
+ function keyOfId(v: unknown): unknown {
405
+ return typeof v === 'string' ? v : keyOf(v);
406
+ }
407
+
408
+ function order(a: unknown, b: unknown): number {
409
+ const x = keyOf(a) as number | string | null;
410
+ const y = keyOf(b) as number | string | null;
411
+ if (x === null || y === null || typeof x !== typeof y) return Number.NaN;
412
+ return x < y ? -1 : x > y ? 1 : 0;
413
+ }
414
+
415
+ function pathValue(row: Row, path: string): unknown {
416
+ let v: unknown = row;
417
+ for (const part of path.split('.')) {
418
+ if (v === null || typeof v !== 'object') return undefined;
419
+ v = (v as Row)[part];
420
+ }
421
+ return v;
422
+ }
423
+
424
+ function matchesComparison(c: WhereComparison, row: Row): boolean {
425
+ const field = pathValue(row, c.field);
426
+ const op = OPS[c.op];
427
+ return c.swap ? op(c.value, field) : op(field, c.value);
428
+ }
429
+
430
+ /** Whether `row` passes a query's `where` (a conjunction of comparisons and `_or` groups). */
431
+ export function matchesWhere(where: ReadonlyArray<WhereNode>, row: Row): boolean {
432
+ return where.every((node) => ('or' in node ? node.or.some((c) => matchesComparison(c, row)) : matchesComparison(node, row)));
433
+ }
434
+
435
+ /**
436
+ * The parts of a channel query that do nothing there (a channel only reads
437
+ * the `where`). Not an error, worth a warning unless `.ignoreUnusedArgs()`.
438
+ */
439
+ export function unusedArgs(plan: QueryPlan, isOne = false): string[] {
440
+ const out: string[] = [];
441
+ if (isOne) out.push('.one()');
442
+ if (plan.orderBy && plan.orderBy.length > 0) out.push('orderBy');
443
+ if (plan.limit !== undefined && !(isOne && plan.limit === 1)) out.push('limit');
444
+ if (plan.offset !== undefined) out.push('offset');
445
+ if (plan.select && plan.select.length > 0) out.push('select');
446
+ return out;
447
+ }
448
+
449
+ /**
450
+ * A query plan as a channel scope, or an error saying why it cannot be one:
451
+ * the table must be `@channel`, and the filter must be decidable from the
452
+ * row alone (its own fields, literal values, supported operators). Order,
453
+ * limit and projection do not matter to a channel and are ignored.
454
+ */
455
+ export function compileScope(schema: SchemaStructure, plan: QueryPlan): ChannelScope {
456
+ const table = schema.tables.find((t) => t.name === plan.table);
457
+ if (!table?.channel) {
458
+ throw new Error(
459
+ `Cannot open a channel on '${plan.table}': the table is not marked '-- @channel'. ` +
460
+ `Add '-- @channel' above 'DEFINE TABLE ${plan.table}' and run 'spky generate'.`
461
+ );
462
+ }
463
+ if (plan.relations && plan.relations.length > 0) {
464
+ throw new Error(`A channel query on '${plan.table}' cannot use .related(): its filter must be decidable from the row alone.`);
465
+ }
466
+ const where = plan.where ?? [];
467
+ for (const node of where) {
468
+ for (const c of 'or' in node ? node.or : [node]) {
469
+ if (!(c.op in OPS)) throw new Error(`A channel query on '${plan.table}' cannot use the operator '${c.op}'.`);
470
+ if (c.value === undefined && c.paramRef) {
471
+ throw new Error(`A channel query on '${plan.table}' cannot compare '${c.field}' with a parameter ($${c.paramRef}); use a value.`);
472
+ }
473
+ const root = c.field.split('.')[0];
474
+ if (c.field.includes('.') && table.columns[root]?.recordId) {
475
+ throw new Error(
476
+ `A channel query on '${plan.table}' cannot filter on '${c.field}': it follows a record link, which the row alone does not hold.`
477
+ );
478
+ }
479
+ }
480
+ }
481
+ return { table: plan.table, where };
482
+ }
483
+
484
+ // ---- manager ---------------------------------------------------------------------
485
+
486
+ interface Tap {
487
+ readonly cb: (rows: Row[]) => void;
488
+ raw: Row[] | null;
489
+ /** Every record id in `raw`, computed on first need. */
490
+ ids: Set<string> | null;
491
+ }
492
+
493
+ interface Channel {
494
+ scopes: ReadonlyArray<ChannelScope>;
495
+ /** Peers as given, kept so a sign-in can drop the new self from them. */
496
+ listed: unknown[];
497
+ users: Set<string>;
498
+ readonly listeners: Set<(s: ChannelState) => void>;
499
+ state: ChannelState;
500
+ closed: boolean;
501
+ }
502
+
503
+ /** An open channel. `client.channel(...)` wraps it with scoped writes. */
504
+ export interface ChannelHandle {
505
+ /** The tables its queries read. */
506
+ tables(): ReadonlyArray<string>;
507
+ state(): ChannelState;
508
+ /** Called at once with the current state, then on every change. */
509
+ subscribe(cb: (s: ChannelState) => void): () => void;
510
+ /** Replace who it connects to (your own id is skipped). */
511
+ setPeers(peers: Iterable<unknown>): void;
512
+ /** Replace what it carries (the queries' filters changed). */
513
+ setScopes(scopes: ReadonlyArray<ChannelScope>): void;
514
+ close(): void;
515
+ }
516
+
517
+ /** How peers' creates and deletes reach the store (the runtime's peer sagas). */
518
+ export interface ChannelRows {
519
+ /** Insert a peer's row, unconfirmed for `ttlMs`. False when refused. */
520
+ create(table: string, id: string, row: Row, ttlMs: number): Promise<boolean>;
521
+ /** Hide a row a peer deleted (or undo its unconfirmed create). False when there was nothing to do. */
522
+ delete(table: string, id: string, ttlMs: number): Promise<boolean>;
523
+ /** The row as the local store holds it, or null. What a filter is checked against. */
524
+ read(id: string): Promise<Row | null>;
525
+ }
526
+
527
+ export interface ChannelManagerDeps {
528
+ config?: ChannelsConfig;
529
+ /** Without it, peers' creates and deletes are ignored. */
530
+ rows?: ChannelRows;
531
+ logger?: Pick<Logger, 'debug' | 'warn'>;
532
+ /** Builds the transport the first time a channel has peers. */
533
+ transport?: ChannelTransportFactory;
534
+ now?: () => number;
535
+ }
536
+
537
+ const SWEEP_MS = 1000;
538
+
539
+ const randomPeerId = (): string => {
540
+ const bytes = new Uint8Array(8);
541
+ globalThis.crypto.getRandomValues(bytes);
542
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
543
+ };
544
+
545
+ export class ChannelManager {
546
+ private readonly tables: Map<string, { columns: Record<string, ColumnSchema>; channel: boolean }>;
547
+ private readonly schema: SchemaStructure;
548
+ private readonly overlay = new Map<string, Map<string, OverlayEntry>>();
549
+ private readonly taps = new Set<Tap>();
550
+ private readonly channels = new Set<Channel>();
551
+ private readonly handles = new WeakMap<ChannelHandle, Channel>();
552
+ private readonly connected = new Map<string, PeerRef>();
553
+ private readonly userStatus = new Map<string, 'connecting' | 'connected' | 'failed'>();
554
+ /** Unused-argument warnings already given, so a reactive query does not repeat them. */
555
+ private readonly warnedUnused = new Set<string>();
556
+ private readonly peerId = randomPeerId();
557
+ private user: string | null = null;
558
+ private clock: Hlc;
559
+ private transport: ChannelTransport | null = null;
560
+ private sweepTimer: ReturnType<typeof setInterval> | null = null;
561
+ private closed = false;
562
+ private readonly now: () => number;
563
+ private readonly confirmMs: number;
564
+
565
+ constructor(
566
+ schema: SchemaStructure,
567
+ private readonly deps: ChannelManagerDeps = {}
568
+ ) {
569
+ this.schema = schema;
570
+ this.tables = new Map(schema.tables.map((t) => [t.name, { columns: t.columns, channel: t.channel === true }]));
571
+ this.now = deps.now ?? Date.now;
572
+ this.confirmMs = Math.max(0, deps.config?.confirmTimeoutMs ?? 5000);
573
+ this.clock = [0, 0, this.peerId];
574
+ }
575
+
576
+ /** True when any table is a channel table; otherwise the client skips all of this. */
577
+ get enabled(): boolean {
578
+ for (const t of this.tables.values()) if (t.channel) return true;
579
+ return false;
580
+ }
581
+
582
+ /** The signed-in user changed: peers, their values and the transport start over. */
583
+ setUser(user: string | null): void {
584
+ if (user === this.user) return;
585
+ this.user = user;
586
+ this.transport?.close();
587
+ this.transport = null;
588
+ this.connected.clear();
589
+ this.userStatus.clear();
590
+ const touched = new Set(this.overlay.keys());
591
+ this.overlay.clear();
592
+ for (const ch of this.channels) ch.users = peerUsers(ch.listed, user);
593
+ this.syncWanted();
594
+ this.emitFor(touched);
595
+ this.notifyAll();
596
+ }
597
+
598
+ /**
599
+ * Compile a query plan into a channel scope (throws when it cannot be
600
+ * one). Parts the channel ignores draw one warning per distinct query
601
+ * shape, unless the query says so itself (`ignoreUnused`).
602
+ */
603
+ scope(plan: QueryPlan, opts: { isOne?: boolean; ignoreUnused?: boolean } = {}): ChannelScope {
604
+ const scope = compileScope(this.schema, plan);
605
+ const unused = opts.ignoreUnused ? [] : unusedArgs(plan, opts.isOne);
606
+ if (unused.length > 0) {
607
+ const message =
608
+ `channel query on '${plan.table}' has ${unused.join(', ')}, which a channel ignores (it only uses the where filter). ` +
609
+ `Remove them, or add .ignoreUnusedArgs() to the query to say it is on purpose.`;
610
+ if (!this.warnedUnused.has(message)) {
611
+ this.warnedUnused.add(message);
612
+ this.deps.logger?.warn({ table: plan.table, unused, Category: 'sp00ky-client::channel' }, message);
613
+ }
614
+ }
615
+ return scope;
616
+ }
617
+
618
+ /** Open a channel carrying the rows `scopes` match to `peers`. */
619
+ open(scopes: ReadonlyArray<ChannelScope>, peers: Iterable<unknown>): ChannelHandle {
620
+ if (scopes.length === 0) throw new Error('A channel needs at least one query.');
621
+ const listed = [...peers];
622
+ const ch: Channel = {
623
+ scopes,
624
+ listed,
625
+ users: peerUsers(listed, this.user),
626
+ listeners: new Set(),
627
+ state: { status: 'idle', peers: [] },
628
+ closed: false,
629
+ };
630
+ this.channels.add(ch);
631
+ this.syncWanted();
632
+ ch.state = this.stateOf(ch);
633
+ const handle: ChannelHandle = {
634
+ tables: () => [...new Set(ch.scopes.map((sc) => sc.table))],
635
+ state: () => ch.state,
636
+ subscribe: (cb) => {
637
+ cb(ch.state);
638
+ ch.listeners.add(cb);
639
+ return () => ch.listeners.delete(cb);
640
+ },
641
+ setPeers: (next) => {
642
+ if (ch.closed) return;
643
+ ch.listed = [...next];
644
+ ch.users = peerUsers(ch.listed, this.user);
645
+ this.syncWanted();
646
+ this.notify(ch);
647
+ },
648
+ setScopes: (next) => {
649
+ if (ch.closed) return;
650
+ if (next.length === 0) throw new Error('A channel needs at least one query.');
651
+ ch.scopes = next;
652
+ },
653
+ close: () => {
654
+ if (ch.closed) return;
655
+ ch.closed = true;
656
+ this.channels.delete(ch);
657
+ ch.listeners.clear();
658
+ this.syncWanted();
659
+ },
660
+ };
661
+ this.handles.set(handle, ch);
662
+ return handle;
663
+ }
664
+
665
+ /** Throws unless `handle` is an open channel of this manager with a query on `table`. */
666
+ assertCarries(handle: ChannelHandle, table: string): void {
667
+ const ch = this.handles.get(handle);
668
+ if (!ch || ch.closed) throw new Error('This channel is closed.');
669
+ if (!ch.scopes.some((sc) => sc.table === table)) {
670
+ throw new Error(`This channel has no query on '${table}' (it reads ${[...new Set(ch.scopes.map((sc) => sc.table))].join(', ')}).`);
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Follow a subscription: `feed` it the raw rows, and `cb` gets them with
676
+ * the overlay folded in, again whenever the overlay changes for its rows.
677
+ */
678
+ tap(cb: (rows: Row[]) => void): { feed: (rows: Row[]) => void; stop: () => void } {
679
+ const tap: Tap = { cb, raw: null, ids: null };
680
+ this.taps.add(tap);
681
+ return {
682
+ feed: (rows) => {
683
+ tap.raw = rows;
684
+ tap.ids = null;
685
+ this.emit(tap);
686
+ },
687
+ stop: () => {
688
+ this.taps.delete(tap);
689
+ },
690
+ };
691
+ }
692
+
693
+ /** Split an own write on `table` into database fields and peer-only fields. */
694
+ split(table: string, data: Row): { persistent: Row; ephemeral: Row | null } {
695
+ const t = this.tables.get(table);
696
+ if (!t?.channel) return { persistent: data, ephemeral: null };
697
+ return splitPatch(t.columns, data);
698
+ }
699
+
700
+ /**
701
+ * An own update: ephemeral fields go into the overlay (they have no other
702
+ * home), persistent fields drop any peer value they supersede, and the
703
+ * whole patch goes to the peers (of `scope`, or of the channel carrying the
704
+ * table, see `targets`).
705
+ */
706
+ local(table: string, id: string, persistent: Row, ephemeral: Row | null, scope?: ChannelHandle): void {
707
+ const t = this.tables.get(table);
708
+ if (!t?.channel || this.closed) return;
709
+ const rid = normalizeId(table, id);
710
+ const hlc = this.tick();
711
+ const fields = this.overlay.get(rid);
712
+ let touched = false;
713
+ if (fields) {
714
+ for (const f of Object.keys(persistent)) touched = fields.delete(f) || touched;
715
+ }
716
+ if (ephemeral) {
717
+ const map = fields ?? new Map<string, OverlayEntry>();
718
+ for (const [f, value] of Object.entries(ephemeral)) {
719
+ map.set(f, { value, hlc, from: 'self', ephemeral: true, expiresAt: null });
720
+ }
721
+ this.overlay.set(rid, map);
722
+ touched = true;
723
+ }
724
+ if (fields && fields.size === 0) this.overlay.delete(rid);
725
+ if (touched) this.emitFor(new Set([rid]));
726
+ const patch = { ...persistent, ...(ephemeral ?? {}) };
727
+ if (Object.keys(patch).length === 0) return;
728
+ // Sent if the row matches before or after the patch, so a row moving
729
+ // out of a channel's filter still reaches its peers.
730
+ void this.rowFor(rid).then((row) => {
731
+ const after = { ...(row ?? { id: rid }), ...patch };
732
+ this.send(this.targets(table, row ? [row, after] : [after], scope), table, rid, patch, hlc, 'update');
733
+ });
734
+ }
735
+
736
+ /**
737
+ * An own create: its ephemeral fields go into the overlay (the store never
738
+ * holds them), and the whole row goes to the peers.
739
+ */
740
+ localCreate(table: string, id: string, row: Row, scope?: ChannelHandle): void {
741
+ const t = this.tables.get(table);
742
+ if (!t?.channel || this.closed) return;
743
+ const rid = normalizeId(table, id);
744
+ const full = pickColumns(t.columns, row);
745
+ const hlc = this.tick();
746
+ const ephemeral = Object.keys(full).filter((f) => t.columns[f]?.ephemeral);
747
+ if (ephemeral.length > 0) {
748
+ const map = this.overlay.get(rid) ?? new Map<string, OverlayEntry>();
749
+ for (const f of ephemeral) map.set(f, { value: full[f], hlc, from: 'self', ephemeral: true, expiresAt: null });
750
+ this.overlay.set(rid, map);
751
+ this.emitFor(new Set([rid]));
752
+ }
753
+ this.send(this.targets(table, [{ ...full, id: rid }], scope), table, rid, full, hlc, 'create');
754
+ }
755
+
756
+ /**
757
+ * An own delete: the peers hide the row at once. Resolves once it is sent;
758
+ * await it before the local delete, which the filter check reads past.
759
+ */
760
+ async localDelete(table: string, id: string, scope?: ChannelHandle): Promise<void> {
761
+ const t = this.tables.get(table);
762
+ if (!t?.channel || this.closed) return;
763
+ const rid = normalizeId(table, id);
764
+ const hlc = this.tick();
765
+ const row = await this.rowFor(rid);
766
+ if (row) this.send(this.targets(table, [row], scope), table, rid, {}, hlc, 'delete');
767
+ if (this.overlay.delete(rid)) this.emitFor(new Set([rid]));
768
+ }
769
+
770
+ /** A transport delivered a message from `from`. */
771
+ receive(from: PeerRef, raw: unknown): void {
772
+ if (this.closed) return;
773
+ const msg = parseMessage(raw);
774
+ if (!msg) return this.drop(from, 'malformed message');
775
+ this.observe(msg.hlc);
776
+ const t = this.tables.get(msg.table);
777
+ if (!t?.channel) return this.drop(from, `'${msg.table}' is not a @channel table`);
778
+ const rid = normalizeId(msg.table, msg.id);
779
+ if (rid.slice(0, rid.indexOf(':')) !== msg.table) return this.drop(from, 'id is not a row of the table');
780
+ if (!this.listedOn(msg.table, from.user)) return this.drop(from, `no channel on '${msg.table}' lists ${from.user}`);
781
+ const kind = msg.kind ?? 'update';
782
+ if (kind === 'create') return this.receiveCreate(from, msg, rid, t.columns);
783
+ if (kind === 'delete') return void this.receiveDelete(from, msg.table, rid);
784
+ const patch: Row = {};
785
+ for (const [k, v] of Object.entries(msg.patch)) patch[k] = decodeValue(v);
786
+ const problem = checkPatch(t.columns, patch);
787
+ if (problem) return this.drop(from, problem);
788
+ void this.receiveUpdate(from, msg, rid, patch, t.columns);
789
+ }
790
+
791
+ /** A peer updated a row we hold, inside a channel's filter (before or after the patch). */
792
+ private async receiveUpdate(from: PeerRef, msg: ChannelMessage, rid: string, patch: Row, columns: Record<string, ColumnSchema>): Promise<void> {
793
+ const row = await this.rowFor(rid);
794
+ if (this.closed) return;
795
+ if (!row) return this.drop(from, `${rid} is not held here, so no filter can be checked`);
796
+ if (!this.matchesFor(msg.table, [row, { ...row, ...patch }], from.user)) return this.drop(from, `${rid} is outside every channel listing ${from.user}`);
797
+ if (!this.accepts({ kind: 'update', from: from.user, table: msg.table, id: rid, row, patch })) {
798
+ return this.drop(from, 'refused by the accept rule');
799
+ }
800
+ const map = this.overlay.get(rid) ?? new Map<string, OverlayEntry>();
801
+ const key = peerKey(from);
802
+ let touched = false;
803
+ for (const [field, value] of Object.entries(patch)) {
804
+ const prev = map.get(field);
805
+ if (prev && compareHlc(prev.hlc, msg.hlc) >= 0) continue;
806
+ const ephemeral = columns[field]?.ephemeral === true;
807
+ map.set(field, { value, hlc: msg.hlc, from: key, ephemeral, expiresAt: ephemeral ? null : this.now() + this.confirmMs });
808
+ touched = true;
809
+ }
810
+ if (map.size > 0) this.overlay.set(rid, map);
811
+ if (!touched) return;
812
+ this.ensureSweep();
813
+ this.emitFor(new Set([rid]));
814
+ }
815
+
816
+ /** A peer created a row: into the store like an own optimistic create, unconfirmed. */
817
+ private receiveCreate(from: PeerRef, msg: ChannelMessage, rid: string, columns: Record<string, ColumnSchema>): void {
818
+ const rows = this.deps.rows;
819
+ if (!rows) return this.drop(from, 'creates are not wired');
820
+ const row: Row = {};
821
+ for (const [k, v] of Object.entries(msg.patch)) if (k !== 'id') row[k] = decodeValue(v);
822
+ const problem = checkPatch(columns, row, true);
823
+ if (problem) return this.drop(from, problem);
824
+ if (!this.matchesFor(msg.table, [{ ...row, id: parseRecordIdString(rid) }], from.user)) {
825
+ return this.drop(from, `the new ${rid} is outside every channel listing ${from.user}`);
826
+ }
827
+ if (!this.accepts({ kind: 'create', from: from.user, table: msg.table, id: rid, row: undefined, patch: row })) {
828
+ return this.drop(from, 'refused by the accept rule');
829
+ }
830
+ const persistent: Row = {};
831
+ const ephemeral: Row = {};
832
+ for (const [k, v] of Object.entries(row)) (columns[k]?.ephemeral ? ephemeral : persistent)[k] = v;
833
+ void rows.create(msg.table, rid, persistent, this.confirmMs).then(
834
+ (applied) => {
835
+ if (!applied || this.closed || Object.keys(ephemeral).length === 0) return;
836
+ const map = this.overlay.get(rid) ?? new Map<string, OverlayEntry>();
837
+ for (const [f, value] of Object.entries(ephemeral)) {
838
+ map.set(f, { value, hlc: msg.hlc, from: peerKey(from), ephemeral: true, expiresAt: null });
839
+ }
840
+ this.overlay.set(rid, map);
841
+ this.emitFor(new Set([rid]));
842
+ },
843
+ (error) => this.deps.logger?.debug({ error, Category: 'sp00ky-client::channel' }, 'peer create failed')
844
+ );
845
+ }
846
+
847
+ /** A peer deleted a row inside a channel's filter: hide it until the server confirms (or undo its own create). */
848
+ private async receiveDelete(from: PeerRef, table: string, rid: string): Promise<void> {
849
+ const rows = this.deps.rows;
850
+ if (!rows) return this.drop(from, 'deletes are not wired');
851
+ const row = await this.rowFor(rid);
852
+ if (this.closed) return;
853
+ if (!row) return this.drop(from, `${rid} is not held here`);
854
+ if (!this.matchesFor(table, [row], from.user)) return this.drop(from, `${rid} is outside every channel listing ${from.user}`);
855
+ if (!this.accepts({ kind: 'delete', from: from.user, table, id: rid, row, patch: {} })) {
856
+ return this.drop(from, 'refused by the accept rule');
857
+ }
858
+ if (this.overlay.delete(rid)) this.emitFor(new Set([rid]));
859
+ void rows.delete(table, rid, this.confirmMs).catch((error) =>
860
+ this.deps.logger?.debug({ error, Category: 'sp00ky-client::channel' }, 'peer delete failed')
861
+ );
862
+ }
863
+
864
+ private accepts(op: ChannelOp): boolean {
865
+ const rule = this.deps.config?.accept?.[op.table];
866
+ if (!rule) return true;
867
+ try {
868
+ return rule(op) === true;
869
+ } catch (error) {
870
+ this.deps.logger?.warn({ error, Category: 'sp00ky-client::channel' }, 'channel accept rule threw');
871
+ return false;
872
+ }
873
+ }
874
+
875
+ /** A peer tab came up (send it our ephemeral state) or went away (drop its). */
876
+ peerChanged(peer: PeerRef, up: boolean): void {
877
+ const key = peerKey(peer);
878
+ if (up) {
879
+ this.connected.set(key, peer);
880
+ this.userStatus.set(peer.user, 'connected');
881
+ this.sendSnapshot(peer);
882
+ } else {
883
+ this.connected.delete(key);
884
+ const touched = new Set<string>();
885
+ for (const [rid, fields] of this.overlay) {
886
+ for (const [f, e] of fields) {
887
+ if (e.from === key && e.ephemeral) {
888
+ fields.delete(f);
889
+ touched.add(rid);
890
+ }
891
+ }
892
+ if (fields.size === 0) this.overlay.delete(rid);
893
+ }
894
+ this.emitFor(touched);
895
+ }
896
+ this.notifyAll();
897
+ }
898
+
899
+ statusChanged(user: string, status: 'connecting' | 'connected' | 'failed'): void {
900
+ if (status !== 'connected' && [...this.connected.values()].some((p) => p.user === user)) return;
901
+ this.userStatus.set(user, status);
902
+ this.notifyAll();
903
+ }
904
+
905
+ close(): void {
906
+ this.closed = true;
907
+ this.transport?.close();
908
+ this.transport = null;
909
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
910
+ this.sweepTimer = null;
911
+ this.taps.clear();
912
+ for (const ch of this.channels) ch.closed = true;
913
+ this.channels.clear();
914
+ this.overlay.clear();
915
+ }
916
+
917
+ // ---- internals -----------------------------------------------------------------
918
+
919
+ private syncWanted(): void {
920
+ if (this.closed) return;
921
+ const wanted = new Set<string>();
922
+ for (const ch of this.channels) for (const u of ch.users) wanted.add(u);
923
+ if (!this.transport) {
924
+ if (wanted.size === 0 || !this.user || !this.deps.transport) return;
925
+ this.transport = this.deps.transport({
926
+ self: () => (this.user ? { user: this.user, peer: this.peerId } : null),
927
+ onMessage: (from, message) => this.receive(from, message),
928
+ onPeer: (peer, up) => this.peerChanged(peer, up),
929
+ onStatus: (user, status) => this.statusChanged(user, status),
930
+ });
931
+ }
932
+ this.transport.want(wanted);
933
+ }
934
+
935
+ /** Some open channel lists `user` and has a query on `table` (the cheap check before any read). */
936
+ private listedOn(table: string, user: string): boolean {
937
+ for (const ch of this.channels) if (ch.users.has(user) && ch.scopes.some((sc) => sc.table === table)) return true;
938
+ return false;
939
+ }
940
+
941
+ /** Some open channel listing `user` has a query on `table` one of `rows` matches. */
942
+ private matchesFor(table: string, rows: ReadonlyArray<Row>, user: string): boolean {
943
+ for (const ch of this.channels) if (ch.users.has(user) && carries(ch, table, rows)) return true;
944
+ return false;
945
+ }
946
+
947
+ /**
948
+ * Who an own write goes to: the peers of every open channel (or only
949
+ * `scope`) whose filter one of `rows` matches. That is what keeps a
950
+ * message of one conversation away from the participants of another.
951
+ */
952
+ private targets(table: string, rows: ReadonlyArray<Row>, scope?: ChannelHandle): ReadonlySet<string> {
953
+ const users = new Set<string>();
954
+ const pick = scope ? this.handles.get(scope) : undefined;
955
+ for (const ch of pick ? [pick] : this.channels) {
956
+ if (ch.closed || !carries(ch, table, rows)) continue;
957
+ for (const u of ch.users) users.add(u);
958
+ }
959
+ return users;
960
+ }
961
+
962
+ /** The row as shown (overlay included): from a subscription, else the store. */
963
+ private async rowFor(rid: string): Promise<Row | null> {
964
+ const shown = this.rowOf(rid);
965
+ if (shown) return shown;
966
+ const stored = await this.deps.rows?.read(rid).catch(() => null);
967
+ return stored ? applyOverlay([stored], this.overlay)[0] : null;
968
+ }
969
+
970
+ private send(users: ReadonlySet<string>, table: string, rid: string, patch: Row, hlc: Hlc, kind: ChannelOpKind): void {
971
+ const transport = this.transport;
972
+ if (!transport || users.size === 0) return;
973
+ const encoded: Row = {};
974
+ for (const [k, v] of Object.entries(patch)) encoded[k] = encodeValue(v);
975
+ const message: ChannelMessage =
976
+ kind === 'update' ? { t: 'op', table, id: rid, patch: encoded, hlc } : { t: 'op', kind, table, id: rid, patch: encoded, hlc };
977
+ for (const peer of this.connected.values()) if (users.has(peer.user)) transport.send(peer, message);
978
+ }
979
+
980
+ /** Own ephemeral values of rows inside a channel a newly connected peer is listed on. */
981
+ private sendSnapshot(peer: PeerRef): void {
982
+ for (const [rid, fields] of this.overlay) {
983
+ const own = [...fields].filter(([, e]) => e.from === 'self');
984
+ if (own.length === 0) continue;
985
+ const table = rid.slice(0, rid.indexOf(':'));
986
+ if (!this.listedOn(table, peer.user)) continue;
987
+ const patch: Row = {};
988
+ let hlc = own[0][1].hlc;
989
+ for (const [f, e] of own) {
990
+ patch[f] = encodeValue(e.value);
991
+ if (compareHlc(e.hlc, hlc) > 0) hlc = e.hlc;
992
+ }
993
+ void this.rowFor(rid).then((row) => {
994
+ if (!row || !this.matchesFor(table, [row], peer.user)) return;
995
+ this.transport?.send(peer, { t: 'op', table, id: rid, patch, hlc });
996
+ });
997
+ }
998
+ }
999
+
1000
+ private rowOf(rid: string): Row | undefined {
1001
+ for (const tap of this.taps) {
1002
+ if (!tap.raw || !idsOf(tap).has(rid)) continue;
1003
+ const row = findRow(tap.raw, rid, 0);
1004
+ if (row) return applyOverlay([row], this.overlay)[0];
1005
+ }
1006
+ return undefined;
1007
+ }
1008
+
1009
+ private emit(tap: Tap): void {
1010
+ if (!tap.raw) return;
1011
+ const rows = applyOverlay(tap.raw, this.overlay);
1012
+ this.confirm(tap.raw);
1013
+ tap.cb(rows);
1014
+ }
1015
+
1016
+ private emitFor(rids: ReadonlySet<string>): void {
1017
+ if (rids.size === 0) return;
1018
+ for (const tap of this.taps) {
1019
+ if (!tap.raw) continue;
1020
+ const ids = idsOf(tap);
1021
+ for (const rid of rids) {
1022
+ if (ids.has(rid)) {
1023
+ this.emit(tap);
1024
+ break;
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ /** Persistent peer values the synced rows already hold are done showing. */
1031
+ private confirm(raw: ReadonlyArray<Row>): void {
1032
+ if (this.overlay.size === 0) return;
1033
+ for (const rid of collectIds(raw)) {
1034
+ const fields = this.overlay.get(rid);
1035
+ if (!fields) continue;
1036
+ const row = findRow(raw, rid, 0);
1037
+ if (!row) continue;
1038
+ for (const [f, e] of fields) {
1039
+ if (!e.ephemeral && e.from !== 'self' && f in row && sameValue(row[f], e.value)) fields.delete(f);
1040
+ }
1041
+ if (fields.size === 0) this.overlay.delete(rid);
1042
+ }
1043
+ }
1044
+
1045
+ private ensureSweep(): void {
1046
+ if (this.sweepTimer || this.closed) return;
1047
+ this.sweepTimer = setInterval(() => this.sweep(), SWEEP_MS);
1048
+ }
1049
+
1050
+ private sweep(): void {
1051
+ const now = this.now();
1052
+ const touched = new Set<string>();
1053
+ let pending = false;
1054
+ for (const [rid, fields] of this.overlay) {
1055
+ for (const [f, e] of fields) {
1056
+ if (e.expiresAt === null) continue;
1057
+ if (e.expiresAt <= now) {
1058
+ fields.delete(f);
1059
+ touched.add(rid);
1060
+ } else {
1061
+ pending = true;
1062
+ }
1063
+ }
1064
+ if (fields.size === 0) this.overlay.delete(rid);
1065
+ }
1066
+ if (!pending && this.sweepTimer) {
1067
+ clearInterval(this.sweepTimer);
1068
+ this.sweepTimer = null;
1069
+ }
1070
+ this.emitFor(touched);
1071
+ }
1072
+
1073
+ private tick(): Hlc {
1074
+ const now = this.now();
1075
+ const [ms, c] = this.clock;
1076
+ this.clock = now > ms ? [now, 0, this.peerId] : [ms, c + 1, this.peerId];
1077
+ return this.clock;
1078
+ }
1079
+
1080
+ private observe(h: Hlc): void {
1081
+ if (h[0] > this.clock[0] || (h[0] === this.clock[0] && h[1] > this.clock[1])) this.clock = [h[0], h[1], this.peerId];
1082
+ }
1083
+
1084
+ private stateOf(ch: Channel): ChannelState {
1085
+ const users = [...ch.users];
1086
+ if (users.length === 0) return { status: 'idle', peers: [] };
1087
+ if (this.transport && !this.transport.supported) return { status: 'unsupported', peers: [] };
1088
+ const up = users.filter((u) => [...this.connected.values()].some((p) => p.user === u));
1089
+ if (up.length > 0) return { status: 'connected', peers: up };
1090
+ if (users.every((u) => this.userStatus.get(u) === 'failed')) return { status: 'failed', peers: [] };
1091
+ return { status: 'connecting', peers: [] };
1092
+ }
1093
+
1094
+ private notify(ch: Channel): void {
1095
+ const next = this.stateOf(ch);
1096
+ if (next.status === ch.state.status && next.peers.join(',') === ch.state.peers.join(',')) return;
1097
+ ch.state = next;
1098
+ for (const cb of ch.listeners) cb(next);
1099
+ }
1100
+
1101
+ private notifyAll(): void {
1102
+ for (const ch of this.channels) this.notify(ch);
1103
+ }
1104
+
1105
+ private drop(from: PeerRef, reason: string): void {
1106
+ this.deps.logger?.debug({ from: from.user, reason, Category: 'sp00ky-client::channel' }, 'channel message dropped');
1107
+ }
1108
+ }
1109
+
1110
+ /** Whether a channel has a query on `table` that one of `rows` matches. */
1111
+ function carries(ch: Channel, table: string, rows: ReadonlyArray<Row>): boolean {
1112
+ return ch.scopes.some((sc) => sc.table === table && rows.some((r) => matchesWhere(sc.where, r)));
1113
+ }
1114
+
1115
+ /** The fields of `row` the table has as plain columns (no id, no `@crdt`). */
1116
+ function pickColumns(columns: Record<string, ColumnSchema>, row: Row): Row {
1117
+ const out: Row = {};
1118
+ for (const [k, v] of Object.entries(row)) {
1119
+ if (k === 'id' || !columns[k] || columns[k].crdt || v === undefined) continue;
1120
+ out[k] = v;
1121
+ }
1122
+ return out;
1123
+ }
1124
+
1125
+ function idsOf(tap: Tap): Set<string> {
1126
+ return (tap.ids ??= tap.raw ? collectIds(tap.raw) : new Set());
1127
+ }
1128
+
1129
+ /** `table:id` for an id given with or without its table. */
1130
+ function normalizeId(table: string, id: string): string {
1131
+ return id.includes(':') ? id : `${table}:${id}`;
1132
+ }
1133
+
1134
+ function findRow(rows: ReadonlyArray<unknown>, rid: string, depth: number): Row | null {
1135
+ if (depth > OVERLAY_DEPTH) return null;
1136
+ for (const v of rows) {
1137
+ if (v === null || typeof v !== 'object') continue;
1138
+ if (Array.isArray(v)) {
1139
+ const hit = findRow(v, rid, depth + 1);
1140
+ if (hit) return hit;
1141
+ continue;
1142
+ }
1143
+ if (isRecordIdLike(v) || v instanceof Date || v instanceof Uint8Array) continue;
1144
+ const row = v as Row;
1145
+ if ('id' in row && idKey(row.id) === rid) return row;
1146
+ for (const [k, x] of Object.entries(row)) {
1147
+ if (k === 'id' || x === null || typeof x !== 'object') continue;
1148
+ const hit = findRow([x], rid, depth + 1);
1149
+ if (hit) return hit;
1150
+ }
1151
+ }
1152
+ return null;
1153
+ }