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

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,752 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
4
+ import {
5
+ ChannelManager,
6
+ applyOverlay,
7
+ checkPatch,
8
+ compileScope,
9
+ matchesWhere,
10
+ unusedArgs,
11
+ decodeValue,
12
+ encodeValue,
13
+ peerKey,
14
+ peerUsers,
15
+ splitPatch,
16
+ type ChannelTransport,
17
+ type ChannelTransportFactory,
18
+ type OverlayEntry,
19
+ type PeerRef,
20
+ type Row,
21
+ type TransportHooks,
22
+ } from './index';
23
+ import { webrtcTransport, type Signal, type SignalPort } from './webrtc';
24
+
25
+ const schema = {
26
+ tables: [
27
+ {
28
+ name: 'call',
29
+ columns: {
30
+ id: { type: 'string', optional: false, recordId: true },
31
+ caller: { type: 'string', optional: false, recordId: true },
32
+ callee: { type: 'string', optional: false, recordId: true },
33
+ view_id: { type: 'string', optional: true },
34
+ view_pos: { type: 'string', optional: true, ephemeral: true },
35
+ count: { type: 'number', optional: true },
36
+ },
37
+ channel: true,
38
+ primaryKey: ['id'],
39
+ },
40
+ {
41
+ name: 'msg',
42
+ columns: {
43
+ id: { type: 'string', optional: false, recordId: true },
44
+ convo: { type: 'string', optional: false },
45
+ text: { type: 'string', optional: false },
46
+ mood: { type: 'string', optional: true, ephemeral: true },
47
+ },
48
+ channel: true,
49
+ primaryKey: ['id'],
50
+ },
51
+ {
52
+ name: 'note',
53
+ columns: { id: { type: 'string', optional: false, recordId: true }, title: { type: 'string', optional: false } },
54
+ primaryKey: ['id'],
55
+ },
56
+ ],
57
+ relationships: [],
58
+ backends: {},
59
+ } as unknown as SchemaStructure;
60
+
61
+ const entry = (value: unknown, ephemeral = true): OverlayEntry => ({ value, hlc: [1, 0, 'p'], from: 'x', ephemeral, expiresAt: null });
62
+
63
+ describe('pure helpers', () => {
64
+ it('leaves rows untouched when the overlay is empty or misses them', () => {
65
+ const rows: Row[] = [{ id: 'call:1', view_pos: null }];
66
+ expect(applyOverlay(rows, new Map())).toBe(rows);
67
+ expect(applyOverlay(rows, new Map([['call:2', new Map([['view_pos', entry('3')]])]]))).toBe(rows);
68
+ });
69
+
70
+ it('folds overlay values into matching rows, nested ones included', () => {
71
+ const child = { id: new RecordId('call', 'c'), view_pos: null };
72
+ const rows: Row[] = [{ id: new RecordId('note', 'n'), title: 't', calls: [child] }, { id: 'call:1', view_pos: null }];
73
+ const out = applyOverlay(
74
+ rows,
75
+ new Map([
76
+ ['call:c', new Map([['view_pos', entry('7')]])],
77
+ ['call:1', new Map([['view_pos', entry('9')]])],
78
+ ])
79
+ );
80
+ expect((out[0].calls as Row[])[0].view_pos).toBe('7');
81
+ expect(out[1].view_pos).toBe('9');
82
+ expect(child.view_pos).toBeNull();
83
+ });
84
+
85
+ it('round-trips record ids, dates and bytes', () => {
86
+ const value = { at: new Date('2026-10-26T10:00:00Z'), who: new RecordId('user', 'a'), blob: new Uint8Array([1, 2, 3]), n: [1, 'x'] };
87
+ const back = decodeValue(JSON.parse(JSON.stringify(encodeValue(value)))) as typeof value;
88
+ expect(back.at).toBeInstanceOf(Date);
89
+ expect(back.at.toISOString()).toBe('2026-10-26T10:00:00.000Z');
90
+ expect(String(back.who.table.name)).toBe('user');
91
+ expect(Array.from(back.blob)).toEqual([1, 2, 3]);
92
+ expect(back.n).toEqual([1, 'x']);
93
+ });
94
+
95
+ it('checks patches against the columns', () => {
96
+ const cols = schema.tables[0].columns;
97
+ expect(checkPatch(cols, { view_pos: '3' })).toBeNull();
98
+ expect(checkPatch(cols, { view_pos: null })).toBeNull();
99
+ expect(checkPatch(cols, { nope: 1 })).toMatch(/unknown field/);
100
+ expect(checkPatch(cols, { id: 'call:2' })).toMatch(/id/);
101
+ expect(checkPatch(cols, { count: 'x' })).toMatch(/type/);
102
+ expect(checkPatch(cols, {})).toMatch(/empty/);
103
+ });
104
+
105
+ it('splits ephemeral fields off a write', () => {
106
+ const cols = schema.tables[0].columns;
107
+ expect(splitPatch(cols, { view_id: 'a', view_pos: '1' })).toEqual({ persistent: { view_id: 'a' }, ephemeral: { view_pos: '1' } });
108
+ expect(splitPatch(cols, { view_id: 'a' })).toEqual({ persistent: { view_id: 'a' }, ephemeral: null });
109
+ });
110
+
111
+ it('normalizes peers and skips self', () => {
112
+ const users = peerUsers([new RecordId('user', 'a'), 'user:b', null, 'not-an-id'], 'user:a');
113
+ expect([...users]).toEqual(['user:b']);
114
+ });
115
+ });
116
+
117
+ // ---- managers over an in-memory transport --------------------------------------------
118
+
119
+ /** Tabs connect as soon as both want each other's user, like the real one. */
120
+ function memoryNet() {
121
+ const nodes = new Map<string, { self: PeerRef; hooks: TransportHooks; wanted: Set<string>; links: Set<string> }>();
122
+ const reconcile = () => {
123
+ for (const a of nodes.values()) {
124
+ for (const b of nodes.values()) {
125
+ if (a === b) continue;
126
+ const kb = peerKey(b.self);
127
+ const mutual = a.wanted.has(b.self.user) && b.wanted.has(a.self.user);
128
+ if (mutual && !a.links.has(kb)) {
129
+ a.links.add(kb);
130
+ a.hooks.onPeer(b.self, true);
131
+ } else if (!mutual && a.links.has(kb)) {
132
+ a.links.delete(kb);
133
+ a.hooks.onPeer(b.self, false);
134
+ }
135
+ }
136
+ }
137
+ };
138
+ const factory: ChannelTransportFactory = (hooks): ChannelTransport => {
139
+ const self = hooks.self()!;
140
+ const node = { self, hooks, wanted: new Set<string>(), links: new Set<string>() };
141
+ nodes.set(peerKey(self), node);
142
+ return {
143
+ supported: true,
144
+ want(users) {
145
+ node.wanted = new Set(users);
146
+ reconcile();
147
+ },
148
+ send(to, message) {
149
+ if (!node.links.has(peerKey(to))) return;
150
+ nodes.get(peerKey(to))?.hooks.onMessage(self, JSON.parse(JSON.stringify(message)));
151
+ },
152
+ close() {
153
+ node.wanted = new Set();
154
+ reconcile();
155
+ nodes.delete(peerKey(self));
156
+ },
157
+ };
158
+ };
159
+ return { factory, nodes };
160
+ }
161
+
162
+ const callRow = (extra: Row = {}): Row => ({
163
+ id: new RecordId('call', '1'),
164
+ caller: new RecordId('user', 'a'),
165
+ callee: new RecordId('user', 'b'),
166
+ view_id: '',
167
+ view_pos: null,
168
+ ...extra,
169
+ });
170
+
171
+ describe('channel filters', () => {
172
+ const eq = (field: string, value: unknown) => ({ field, op: '=', value });
173
+
174
+ it('matches equality across record id spellings, dates and nested paths', () => {
175
+ expect(matchesWhere([eq('caller', new RecordId('user', 'a'))], { caller: 'user:a' })).toBe(true);
176
+ expect(matchesWhere([eq('caller', new RecordId('user', 'a'))], { caller: new RecordId('user', 'b') })).toBe(false);
177
+ expect(matchesWhere([eq('at', new Date(5))], { at: new Date(5) })).toBe(true);
178
+ expect(matchesWhere([eq('meta.kind', 'x')], { meta: { kind: 'x' } })).toBe(true);
179
+ expect(matchesWhere([eq('meta.kind', 'x')], { meta: null })).toBe(false);
180
+ expect(matchesWhere([eq('tags', ['a'])], { tags: ['a'] })).toBe(true);
181
+ expect(matchesWhere([eq('missing', null)], {})).toBe(true);
182
+ expect(matchesWhere([], {})).toBe(true);
183
+ });
184
+
185
+ it('supports comparisons, membership, _or groups and swapped operands', () => {
186
+ const row = { n: 5, s: 'b', tags: ['x', 'y'], who: 'user:a' };
187
+ expect(matchesWhere([{ field: 'n', op: '>', value: 4 }, { field: 'n', op: '<=', value: 5 }], row)).toBe(true);
188
+ expect(matchesWhere([{ field: 'n', op: '<', value: 5 }], row)).toBe(false);
189
+ expect(matchesWhere([{ field: 'n', op: '>=', value: 'a' }], row)).toBe(false);
190
+ expect(matchesWhere([{ field: 'n', op: '!=', value: 4 }], row)).toBe(true);
191
+ expect(matchesWhere([{ field: 's', op: 'IN', value: ['a', 'b'] }], row)).toBe(true);
192
+ expect(matchesWhere([{ field: 's', op: 'NOT IN', value: ['a', 'b'] }], row)).toBe(false);
193
+ expect(matchesWhere([{ field: 'tags', op: 'CONTAINS', value: 'y' }], row)).toBe(true);
194
+ expect(matchesWhere([{ field: 'tags', op: 'CONTAINSNOT', value: 'y' }], row)).toBe(false);
195
+ expect(matchesWhere([{ field: 'tags', op: '∋', value: 'z', swap: false }], row)).toBe(false);
196
+ expect(matchesWhere([{ field: 'who', op: '∈', value: [new RecordId('user', 'a')] }], row)).toBe(true);
197
+ expect(matchesWhere([{ field: 's', op: '∈', value: ['b'], swap: true }], { s: ['b'] })).toBe(false);
198
+ expect(matchesWhere([{ or: [eq('s', 'z'), eq('n', 5)] }], row)).toBe(true);
199
+ expect(matchesWhere([{ or: [eq('s', 'z'), eq('n', 6)] }], row)).toBe(false);
200
+ });
201
+
202
+ it('names the parts of a query a channel ignores', () => {
203
+ expect(unusedArgs({ table: 'msg', where: [] })).toEqual([]);
204
+ expect(unusedArgs({ table: 'msg', orderBy: [['text', 'asc']], limit: 5, offset: 2, select: ['text'] })).toEqual(['orderBy', 'limit', 'offset', 'select']);
205
+ expect(unusedArgs({ table: 'msg', limit: 1 }, true)).toEqual(['.one()']);
206
+ expect(unusedArgs({ table: 'msg', orderBy: [], select: [] })).toEqual([]);
207
+ });
208
+
209
+ it('warns once per query shape about ignored parts, never with ignoreUnused', () => {
210
+ const warn = vi.fn();
211
+ const m = new ChannelManager(schema, { logger: { warn, debug: () => {} } });
212
+ const plan = { table: 'msg', where: [], limit: 50, orderBy: [['text', 'asc'] as ['text', 'asc']] };
213
+ m.scope(plan);
214
+ m.scope(plan);
215
+ expect(warn).toHaveBeenCalledTimes(1);
216
+ expect(warn.mock.calls[0][1]).toMatch(/orderBy, limit.*\.ignoreUnusedArgs\(\)/);
217
+ m.scope({ table: 'msg', limit: 1 }, { isOne: true });
218
+ expect(warn).toHaveBeenCalledTimes(2);
219
+ m.scope({ table: 'msg', offset: 3 }, { ignoreUnused: true });
220
+ m.scope({ table: 'msg', where: [] });
221
+ expect(warn).toHaveBeenCalledTimes(2);
222
+ new ChannelManager(schema).scope(plan);
223
+ });
224
+
225
+ it('compiles a plan into a scope, refusing what the row alone cannot decide', () => {
226
+ expect(compileScope(schema, { table: 'msg', where: [eq('convo', 'c1')] })).toEqual({ table: 'msg', where: [eq('convo', 'c1')] });
227
+ expect(compileScope(schema, { table: 'call' })).toEqual({ table: 'call', where: [] });
228
+ expect(() => compileScope(schema, { table: 'note' })).toThrow(/@channel/);
229
+ expect(() => compileScope(schema, { table: 'msg', relations: [{} as never] })).toThrow(/related/);
230
+ expect(() => compileScope(schema, { table: 'msg', where: [{ field: 'text', op: '@@', value: 'x' }] })).toThrow(/operator/);
231
+ expect(() => compileScope(schema, { table: 'msg', where: [{ or: [{ field: 'convo', op: '=', value: undefined, paramRef: 'x' }] }] })).toThrow(/parameter/);
232
+ expect(() => compileScope(schema, { table: 'call', where: [eq('caller.name', 'x')] })).toThrow(/record link/);
233
+ });
234
+ });
235
+
236
+ /** The runtime's peer sagas, as a map: refuses an id it already holds. */
237
+ function fakeRows() {
238
+ const store = new Map<string, Row>();
239
+ const calls: string[] = [];
240
+ return {
241
+ store,
242
+ calls,
243
+ rows: {
244
+ create: async (_t: string, id: string, row: Row, ttl: number) => {
245
+ calls.push(`create ${id} ${ttl}`);
246
+ if (store.has(id)) return false;
247
+ store.set(id, { id, ...row });
248
+ return true;
249
+ },
250
+ delete: async (_t: string, id: string, ttl: number) => {
251
+ calls.push(`delete ${id} ${ttl}`);
252
+ return store.delete(id);
253
+ },
254
+ read: async (id: string) => store.get(id) ?? null,
255
+ },
256
+ };
257
+ }
258
+
259
+ type Deps = ConstructorParameters<typeof ChannelManager>[1];
260
+ const allCalls = { table: 'call', where: [] };
261
+ const convo = (c: string) => ({ table: 'msg', where: [{ field: 'convo', op: '=', value: c }] });
262
+
263
+ /** One tab: a manager, an open channel and one subscription to watch. */
264
+ function client(
265
+ user: string,
266
+ net: ReturnType<typeof memoryNet>,
267
+ deps: Deps = {},
268
+ scopes: Array<{ table: string; where: any[] }> = [allCalls, convo('c1')],
269
+ peers: unknown[] = ['user:a', 'user:b']
270
+ ) {
271
+ const fr = fakeRows();
272
+ const m = new ChannelManager(schema, { transport: net.factory, rows: fr.rows, ...deps });
273
+ m.setUser(user);
274
+ const ch = m.open(scopes.map((sc) => m.scope(sc)), peers);
275
+ const seen: Row[][] = [];
276
+ const tap = m.tap((rows) => seen.push(rows));
277
+ return { m, ch, tap, fr, seen, last: () => seen[seen.length - 1]?.[0] };
278
+ }
279
+
280
+ /** Let the reads and sends behind a write settle (fake timers too). */
281
+ const flush = async () => {
282
+ for (let i = 0; i < 20; i++) await Promise.resolve();
283
+ };
284
+ const msg = (extra: Row = {}): Row => ({ id: 'msg:1', convo: 'c1', text: 'hi', ...extra });
285
+ const hlcNow = () => [Date.now(), 0, 'x'];
286
+
287
+ afterEach(() => {
288
+ vi.useRealTimers();
289
+ });
290
+
291
+ describe('ChannelManager: opening', () => {
292
+ it('connects the two parties and reports it', () => {
293
+ const net = memoryNet();
294
+ const a = client('user:a', net);
295
+ const states: string[] = [];
296
+ a.ch.subscribe((s) => states.push(s.status));
297
+ expect(a.ch.state().status).toBe('connecting');
298
+ const b = client('user:b', net);
299
+ expect(a.ch.state()).toEqual({ status: 'connected', peers: ['user:b'] });
300
+ expect(b.ch.state().peers).toEqual(['user:a']);
301
+ expect(states).toEqual(['connecting', 'connected']);
302
+ expect(a.ch.tables()).toEqual(['call', 'msg']);
303
+ });
304
+
305
+ it('needs at least one query', () => {
306
+ const m = new ChannelManager(schema);
307
+ expect(() => m.open([], [])).toThrow(/at least one query/);
308
+ const ch = m.open([m.scope(allCalls)], []);
309
+ expect(() => ch.setScopes([])).toThrow(/at least one query/);
310
+ ch.setScopes([m.scope(convo('c2'))]);
311
+ expect(ch.tables()).toEqual(['msg']);
312
+ });
313
+
314
+ it('is idle with nobody but yourself, and skips you once you sign in', () => {
315
+ const net = memoryNet();
316
+ const m = new ChannelManager(schema, { transport: net.factory });
317
+ const ch = m.open([m.scope(allCalls)], ['user:a', 'user:b']);
318
+ expect(ch.state().status).toBe('connecting');
319
+ m.setUser('user:a');
320
+ expect(net.nodes.size).toBe(1);
321
+ ch.setPeers([new RecordId('user', 'a')]);
322
+ expect(ch.state().status).toBe('idle');
323
+ });
324
+
325
+ it('setPeers and close change who it wants', () => {
326
+ const net = memoryNet();
327
+ const a = client('user:a', net);
328
+ const b = client('user:b', net);
329
+ a.ch.setPeers(['user:c']);
330
+ expect(b.ch.state().status).toBe('connecting');
331
+ a.ch.setPeers(['user:b']);
332
+ expect(b.ch.state().status).toBe('connected');
333
+ a.ch.close();
334
+ a.ch.close();
335
+ a.ch.setPeers(['user:b']);
336
+ a.ch.setScopes([]);
337
+ expect(b.ch.state().status).toBe('connecting');
338
+ expect(() => a.m.assertCarries(a.ch, 'call')).toThrow(/closed/);
339
+ });
340
+ });
341
+
342
+ describe('ChannelManager: updates', () => {
343
+ it('sends ephemeral values to the peer and shows them locally, never as a write', async () => {
344
+ const net = memoryNet();
345
+ const a = client('user:a', net);
346
+ const b = client('user:b', net);
347
+ a.tap.feed([callRow()]);
348
+ b.tap.feed([callRow()]);
349
+ const { persistent, ephemeral } = a.m.split('call', { view_pos: '12' });
350
+ expect(persistent).toEqual({});
351
+ a.m.local('call', 'call:1', persistent, ephemeral);
352
+ expect(a.last()?.view_pos).toBe('12');
353
+ await flush();
354
+ expect(b.last()?.view_pos).toBe('12');
355
+ });
356
+
357
+ it('shows a persistent peer value until the synced row catches up', async () => {
358
+ const net = memoryNet();
359
+ const a = client('user:a', net);
360
+ const b = client('user:b', net);
361
+ b.tap.feed([callRow()]);
362
+ a.m.local('call', 'call:1', { view_id: 'game:x' }, null);
363
+ await flush();
364
+ expect(b.last()?.view_id).toBe('game:x');
365
+ b.tap.feed([callRow({ view_id: 'game:x' })]);
366
+ b.tap.feed([callRow({ view_id: 'game:y' })]);
367
+ expect(b.last()?.view_id).toBe('game:y');
368
+ });
369
+
370
+ it('drops an unconfirmed persistent peer value after the timeout', async () => {
371
+ vi.useFakeTimers();
372
+ let now = 1000;
373
+ const net = memoryNet();
374
+ const a = client('user:a', net, { now: () => now });
375
+ const b = client('user:b', net, { now: () => now, config: { confirmTimeoutMs: 5000 } });
376
+ b.tap.feed([callRow()]);
377
+ a.m.local('call', 'call:1', { view_id: 'forged' }, null);
378
+ await flush();
379
+ expect(b.last()?.view_id).toBe('forged');
380
+ now += 6000;
381
+ vi.advanceTimersByTime(1000);
382
+ expect(b.last()?.view_id).toBe('');
383
+ });
384
+
385
+ it('ignores an unlisted user, a non-channel table, a row outside the filter and a row it does not hold', async () => {
386
+ const net = memoryNet();
387
+ const b = client('user:b', net);
388
+ b.tap.feed([callRow(), msg({ id: 'msg:2', convo: 'c2' })]);
389
+ const from = { user: 'user:a', peer: 'x' };
390
+ b.m.receive({ user: 'user:c', peer: 'x' }, { t: 'op', table: 'call', id: 'call:1', patch: { view_pos: '1' }, hlc: hlcNow() });
391
+ b.m.receive(from, { t: 'op', table: 'note', id: 'note:1', patch: { title: 'x' }, hlc: hlcNow() });
392
+ b.m.receive(from, { t: 'op', table: 'msg', id: 'msg:2', patch: { text: 'x' }, hlc: hlcNow() });
393
+ b.m.receive(from, { t: 'op', table: 'msg', id: 'msg:9', patch: { text: 'x' }, hlc: hlcNow() });
394
+ b.m.receive(from, { t: 'op', table: 'call', id: 'note:1', patch: { view_pos: '1' }, hlc: hlcNow() });
395
+ b.m.receive(from, { t: 'op', table: 'call', id: 'call:1', patch: { view_pos: 1 }, hlc: hlcNow() });
396
+ await flush();
397
+ expect(b.seen).toHaveLength(1);
398
+ });
399
+
400
+ it('takes a row moving out of the filter (it matched before the patch)', async () => {
401
+ const net = memoryNet();
402
+ const b = client('user:b', net);
403
+ b.fr.store.set('msg:1', msg());
404
+ b.tap.feed([msg()]);
405
+ b.m.receive({ user: 'user:a', peer: 'x' }, { t: 'op', table: 'msg', id: 'msg:1', patch: { convo: 'c2' }, hlc: hlcNow() });
406
+ await flush();
407
+ expect(b.last()?.convo).toBe('c2');
408
+ });
409
+
410
+ it('applies the app accept rule', async () => {
411
+ const net = memoryNet();
412
+ const a = client('user:a', net);
413
+ const b = client('user:b', net, { config: { accept: { call: ({ from, row }) => from === String(row?.callee) } } });
414
+ b.tap.feed([callRow()]);
415
+ a.m.local('call', 'call:1', {}, { view_pos: '5' });
416
+ await flush();
417
+ expect(b.last()?.view_pos).toBeNull();
418
+ });
419
+
420
+ it('keeps the newest write when two arrive out of order', async () => {
421
+ const net = memoryNet();
422
+ const b = client('user:b', net);
423
+ b.tap.feed([callRow()]);
424
+ const from = { user: 'user:a', peer: 'x' };
425
+ b.m.receive(from, { t: 'op', table: 'call', id: 'call:1', patch: { view_pos: 'new' }, hlc: [2000, 0, 'x'] });
426
+ await flush();
427
+ b.m.receive(from, { t: 'op', table: 'call', id: 'call:1', patch: { view_pos: 'old' }, hlc: [1000, 0, 'x'] });
428
+ await flush();
429
+ expect(b.last()?.view_pos).toBe('new');
430
+ });
431
+
432
+ it('sends its ephemeral state to a peer that connects later, and forgets a peer that leaves', async () => {
433
+ const net = memoryNet();
434
+ const a = client('user:a', net);
435
+ a.fr.store.set('call:1', callRow());
436
+ a.m.local('call', 'call:1', {}, { view_pos: '3' });
437
+ a.m.local('msg', 'msg:7', {}, { mood: 'x' });
438
+ const b = client('user:b', net);
439
+ b.tap.feed([callRow()]);
440
+ await flush();
441
+ expect(b.last()?.view_pos).toBe('3');
442
+ a.m.close();
443
+ expect(b.last()?.view_pos).toBeNull();
444
+ });
445
+
446
+ it('passes writes on non-channel tables through untouched', () => {
447
+ const m = new ChannelManager(schema);
448
+ expect(m.split('note', { title: 'x' })).toEqual({ persistent: { title: 'x' }, ephemeral: null });
449
+ m.local('note', 'note:1', { title: 'x' }, null);
450
+ m.localCreate('note', 'note:1', {});
451
+ });
452
+
453
+ it('an own persistent write supersedes a peer value for the same field', async () => {
454
+ const net = memoryNet();
455
+ const a = client('user:a', net);
456
+ const b = client('user:b', net);
457
+ b.tap.feed([callRow()]);
458
+ a.m.local('call', 'call:1', { view_id: 'game:a' }, null);
459
+ await flush();
460
+ expect(b.last()?.view_id).toBe('game:a');
461
+ b.m.local('call', 'call:1', { view_id: 'game:b' }, null);
462
+ b.tap.feed([callRow({ view_id: 'game:b' })]);
463
+ expect(b.last()?.view_id).toBe('game:b');
464
+ });
465
+ });
466
+
467
+ describe('ChannelManager: routing by filter', () => {
468
+ function threeWay() {
469
+ const net = memoryNet();
470
+ const a = client('user:a', net, {}, [convo('c1')], ['user:b']);
471
+ const toC = a.m.open([a.m.scope(convo('c2'))], ['user:c']);
472
+ const b = client('user:b', net, {}, [convo('c1')], ['user:a']);
473
+ const c = client('user:c', net, {}, [convo('c2')], ['user:a']);
474
+ return { a, b, c, toC };
475
+ }
476
+
477
+ it("sends a message only to the participants of its conversation's channel", async () => {
478
+ const { a, b, c } = threeWay();
479
+ a.m.localCreate('msg', 'msg:1', { convo: 'c1', text: 'hi', _00_rv: 1, junk: 2 });
480
+ await flush();
481
+ expect(b.fr.calls).toEqual(['create msg:1 5000']);
482
+ expect(b.fr.store.get('msg:1')).toEqual({ id: 'msg:1', convo: 'c1', text: 'hi' });
483
+ expect(c.fr.calls).toEqual([]);
484
+ });
485
+
486
+ it('routes updates and deletes by the stored row; a row changing conversation reaches both', async () => {
487
+ const { a, b, c } = threeWay();
488
+ a.fr.store.set('msg:1', msg());
489
+ for (const t of [b, c]) {
490
+ t.fr.store.set('msg:1', msg());
491
+ t.tap.feed([msg()]);
492
+ }
493
+ a.m.local('msg', 'msg:1', { text: 'edited' }, null);
494
+ await flush();
495
+ expect(b.last()?.text).toBe('edited');
496
+ expect(c.last()?.text).toBe('hi');
497
+ await a.m.localDelete('msg', 'msg:1');
498
+ await flush();
499
+ expect(b.fr.calls).toEqual(['delete msg:1 5000']);
500
+ expect(c.fr.calls).toEqual([]);
501
+ // Moving a row to another conversation: both channels hear it.
502
+ const moved = threeWay();
503
+ moved.a.fr.store.set('msg:1', msg());
504
+ for (const t of [moved.b, moved.c]) {
505
+ t.fr.store.set('msg:1', msg());
506
+ t.tap.feed([msg()]);
507
+ }
508
+ moved.a.m.local('msg', 'msg:1', { convo: 'c2' }, null);
509
+ await flush();
510
+ expect(moved.b.last()?.convo).toBe('c2');
511
+ expect(moved.c.last()?.convo).toBe('c2');
512
+ });
513
+
514
+ it('a scoped write goes to that channel only, and only for rows its filter matches', async () => {
515
+ const { a, b, c, toC } = threeWay();
516
+ a.m.localCreate('msg', 'msg:1', { convo: 'c1', text: 'hi' }, toC);
517
+ a.m.localCreate('msg', 'msg:2', { convo: 'c2', text: 'hi' }, toC);
518
+ await flush();
519
+ expect(b.fr.calls).toEqual([]);
520
+ expect(c.fr.calls).toEqual(['create msg:2 5000']);
521
+ expect(() => a.m.assertCarries(toC, 'call')).toThrow(/no query on 'call'/);
522
+ expect(() => a.m.assertCarries(toC, 'msg')).not.toThrow();
523
+ });
524
+
525
+ it('an update of a row it does not hold is matched on the patch alone', async () => {
526
+ const { a, b } = threeWay();
527
+ b.fr.store.set('msg:5', msg({ id: 'msg:5' }));
528
+ b.tap.feed([msg({ id: 'msg:5' })]);
529
+ a.m.local('msg', 'msg:5', { convo: 'c1', text: 'x' }, null);
530
+ await flush();
531
+ expect(b.last()?.text).toBe('x');
532
+ await a.m.localDelete('msg', 'msg:404');
533
+ });
534
+ });
535
+
536
+ describe('ChannelManager: creates and deletes', () => {
537
+ it('carries ephemeral fields of a create to the peer overlay, never into its store', async () => {
538
+ const net = memoryNet();
539
+ const a = client('user:a', net);
540
+ const b = client('user:b', net);
541
+ a.tap.feed([msg()]);
542
+ a.m.localCreate('msg', 'msg:1', { convo: 'c1', text: 'hi', mood: 'happy' });
543
+ expect(a.last()?.mood).toBe('happy');
544
+ await flush();
545
+ expect(b.fr.store.get('msg:1')).toEqual({ id: 'msg:1', convo: 'c1', text: 'hi' });
546
+ b.tap.feed([msg()]);
547
+ expect(b.last()?.mood).toBe('happy');
548
+ });
549
+
550
+ it('refuses a create from an unlisted user, outside the filter, against the accept rule, of the wrong shape or kind', async () => {
551
+ const net = memoryNet();
552
+ const b = client('user:b', net, { config: { accept: { msg: ({ kind, patch }) => kind !== 'create' || patch.text !== 'spam' } } });
553
+ const send = (user: string, m: Row) => b.m.receive({ user, peer: 'x' }, { t: 'op', hlc: hlcNow(), ...m });
554
+ send('user:c', { kind: 'create', table: 'msg', id: 'msg:1', patch: { convo: 'c1', text: 'hi' } });
555
+ send('user:a', { kind: 'create', table: 'msg', id: 'msg:1', patch: { convo: 'c9', text: 'hi' } });
556
+ send('user:a', { kind: 'create', table: 'msg', id: 'msg:1', patch: { convo: 'c1', text: 'spam' } });
557
+ send('user:a', { kind: 'create', table: 'msg', id: 'msg:1', patch: { convo: 1 } });
558
+ send('user:a', { kind: 'nuke', table: 'msg', id: 'msg:1', patch: {} });
559
+ await flush();
560
+ expect(b.fr.calls).toEqual([]);
561
+ const noRows = new ChannelManager(schema);
562
+ noRows.setUser('user:b');
563
+ noRows.open([noRows.scope(convo('c1'))], ['user:a']);
564
+ noRows.receive({ user: 'user:a', peer: 'x' }, { t: 'op', kind: 'create', table: 'msg', id: 'msg:1', patch: { convo: 'c1' }, hlc: hlcNow() });
565
+ noRows.receive({ user: 'user:a', peer: 'x' }, { t: 'op', kind: 'delete', table: 'msg', id: 'msg:1', patch: {}, hlc: hlcNow() });
566
+ await flush();
567
+ });
568
+
569
+ it('sends an own delete; the peer hides it', async () => {
570
+ const net = memoryNet();
571
+ const a = client('user:a', net);
572
+ const b = client('user:b', net);
573
+ a.fr.store.set('msg:1', msg());
574
+ b.fr.store.set('msg:1', msg());
575
+ await a.m.localDelete('msg', 'msg:1');
576
+ await flush();
577
+ expect(b.fr.calls).toEqual(['delete msg:1 5000']);
578
+ });
579
+
580
+ it('ignores a delete from an unlisted user, outside the filter, of a row it does not hold, or against the accept rule', async () => {
581
+ const net = memoryNet();
582
+ const b = client('user:b', net, { config: { accept: { call: () => false } } });
583
+ b.fr.store.set('msg:2', msg({ id: 'msg:2', convo: 'c2' }));
584
+ b.fr.store.set('call:1', callRow());
585
+ const del = (user: string, table: string, id: string) =>
586
+ b.m.receive({ user, peer: 'x' }, { t: 'op', kind: 'delete', table, id, patch: {}, hlc: hlcNow() });
587
+ del('user:c', 'msg', 'msg:1');
588
+ del('user:a', 'msg', 'msg:2');
589
+ del('user:a', 'msg', 'msg:404');
590
+ del('user:a', 'call', 'call:1');
591
+ await flush();
592
+ expect(b.fr.calls).toEqual([]);
593
+ });
594
+ });
595
+
596
+ // ---- WebRTC transport against a fake RTCPeerConnection -------------------------------
597
+
598
+ class FakeChannel {
599
+ readyState = 'connecting';
600
+ onopen: (() => void) | null = null;
601
+ onclose: (() => void) | null = null;
602
+ onmessage: ((e: { data: unknown }) => void) | null = null;
603
+ other: FakeChannel | null = null;
604
+ send(data: string) {
605
+ queueMicrotask(() => this.other?.onmessage?.({ data }));
606
+ }
607
+ close() {
608
+ if (this.readyState === 'closed') return;
609
+ this.readyState = 'closed';
610
+ this.onclose?.();
611
+ this.other?.close();
612
+ }
613
+ open() {
614
+ this.readyState = 'open';
615
+ this.onopen?.();
616
+ }
617
+ }
618
+
619
+ const offers = new Map<string, FakePC>();
620
+ let sdpSeq = 0;
621
+
622
+ class FakePC {
623
+ connectionState = 'new';
624
+ localDescription: { type: string; sdp: string } | null = null;
625
+ onicecandidate: ((e: { candidate: { toJSON(): unknown } | null }) => void) | null = null;
626
+ onconnectionstatechange: (() => void) | null = null;
627
+ ondatachannel: ((e: { channel: FakeChannel }) => void) | null = null;
628
+ dc: FakeChannel | null = null;
629
+ remote: FakePC | null = null;
630
+ createDataChannel() {
631
+ this.dc = new FakeChannel();
632
+ return this.dc;
633
+ }
634
+ async createOffer() {
635
+ const sdp = `offer-${++sdpSeq}`;
636
+ offers.set(sdp, this);
637
+ return { type: 'offer', sdp };
638
+ }
639
+ async createAnswer() {
640
+ return { type: 'answer', sdp: `answer-${++sdpSeq}` };
641
+ }
642
+ async setLocalDescription(d: { type: string; sdp: string }) {
643
+ this.localDescription = d;
644
+ queueMicrotask(() => this.onicecandidate?.({ candidate: { toJSON: () => ({ candidate: 'c' }) } }));
645
+ }
646
+ async setRemoteDescription(d: { type: string; sdp: string }) {
647
+ if (d.type === 'offer') this.remote = offers.get(d.sdp) ?? null;
648
+ if (d.type === 'answer') {
649
+ // The offerer learns the answer: wire both data channels and open them.
650
+ const answerer = [...answerers].find((p) => p.remote === this);
651
+ if (!answerer || !this.dc) return;
652
+ const theirs = new FakeChannel();
653
+ this.dc.other = theirs;
654
+ theirs.other = this.dc;
655
+ answerer.ondatachannel?.({ channel: theirs });
656
+ queueMicrotask(() => {
657
+ this.dc?.open();
658
+ theirs.open();
659
+ });
660
+ }
661
+ }
662
+ async addIceCandidate() {}
663
+ close() {
664
+ this.dc?.close();
665
+ }
666
+ constructor() {
667
+ answerers.add(this);
668
+ }
669
+ }
670
+ const answerers = new Set<FakePC>();
671
+
672
+ /** Signals between users, delivered like the LIVE would: to every tab of the recipient. */
673
+ function memorySignals() {
674
+ const listeners = new Map<string, Set<(s: Signal) => void>>();
675
+ const sent: Array<{ from: string; kind: string }> = [];
676
+ const port = (user: string): SignalPort => ({
677
+ async send(s) {
678
+ sent.push({ from: user, kind: s.kind });
679
+ const signal: Signal = { from: user, fromPeer: s.fromPeer, toPeer: s.toPeer, kind: s.kind, payload: s.payload };
680
+ queueMicrotask(() => listeners.get(s.to)?.forEach((cb) => cb(signal)));
681
+ },
682
+ listen(self, cb) {
683
+ const set = listeners.get(self.user) ?? new Set();
684
+ set.add(cb);
685
+ listeners.set(self.user, set);
686
+ return () => set.delete(cb);
687
+ },
688
+ });
689
+ return { port, sent };
690
+ }
691
+
692
+ const settle = async () => {
693
+ for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
694
+ };
695
+
696
+ function tab(user: string, peer: string, signals: ReturnType<typeof memorySignals>) {
697
+ const events: string[] = [];
698
+ const messages: unknown[] = [];
699
+ const hooks: TransportHooks = {
700
+ self: () => ({ user, peer }),
701
+ onMessage: (_from, m) => messages.push(m),
702
+ onPeer: (p, up) => events.push(`${up ? 'up' : 'down'} ${p.user}`),
703
+ onStatus: () => {},
704
+ };
705
+ const t = webrtcTransport(hooks, { signal: signals.port(user), RTCPeerConnection: FakePC as unknown as typeof RTCPeerConnection });
706
+ return { t, events, messages };
707
+ }
708
+
709
+ describe('webrtcTransport', () => {
710
+ it('connects two tabs that want each other, whichever starts first', async () => {
711
+ const signals = memorySignals();
712
+ const a = tab('user:a', 'pa', signals);
713
+ const b = tab('user:b', 'pb', signals);
714
+ a.t.want(new Set(['user:b']));
715
+ await settle();
716
+ expect(a.events).toEqual([]);
717
+ b.t.want(new Set(['user:a']));
718
+ await settle();
719
+ expect(a.events).toEqual(['up user:b']);
720
+ expect(b.events).toEqual(['up user:a']);
721
+ a.t.send({ user: 'user:b', peer: 'pb' }, { t: 'op', table: 'call', id: 'call:1', patch: { view_pos: '1' }, hlc: [1, 0, 'pa'] });
722
+ await settle();
723
+ expect(b.messages).toEqual([{ t: 'op', table: 'call', id: 'call:1', patch: { view_pos: '1' }, hlc: [1, 0, 'pa'] }]);
724
+ });
725
+
726
+ it('never connects to a user who does not want it back', async () => {
727
+ const signals = memorySignals();
728
+ const a = tab('user:a', 'pa', signals);
729
+ const b = tab('user:b', 'pb', signals);
730
+ a.t.want(new Set(['user:b']));
731
+ b.t.want(new Set(['user:c']));
732
+ await settle();
733
+ expect(a.events).toEqual([]);
734
+ expect(b.events).toEqual([]);
735
+ expect(signals.sent.filter((s) => s.kind === 'offer')).toHaveLength(0);
736
+ });
737
+
738
+ it('closes the link when the user is no longer wanted', async () => {
739
+ const signals = memorySignals();
740
+ const a = tab('user:a', 'pa', signals);
741
+ const b = tab('user:b', 'pb', signals);
742
+ a.t.want(new Set(['user:b']));
743
+ b.t.want(new Set(['user:a']));
744
+ await settle();
745
+ a.t.want(new Set());
746
+ await settle();
747
+ expect(a.events).toEqual(['up user:b', 'down user:b']);
748
+ expect(b.events).toEqual(['up user:a', 'down user:a']);
749
+ a.t.close();
750
+ b.t.close();
751
+ });
752
+ });