@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,472 @@
1
+ import type { Surreal, Uuid } from 'surrealdb';
2
+ import type { ConnectionState } from '../../types';
3
+ import type { Logger } from '../../services/logger/index';
4
+ import { parseRecordIdString } from '../../utils/index';
5
+ import type { ChannelMessage, ChannelTransport, PeerRef, TransportHooks } from './index';
6
+ import { idKey, peerKey } from './index';
7
+
8
+ /**
9
+ * The default channel transport: one WebRTC data channel per pair of tabs,
10
+ * set up through signaling rows in the database.
11
+ *
12
+ * Signaling (all rows of `_00_channel_signal`, read through one raw LIVE):
13
+ *
14
+ * 1. A tab that wants user U writes `hello` to U. Every tab of U hears it.
15
+ * 2. Of each pair of tabs, the one with the lower `user#peer` key offers.
16
+ * A tab that hears a hello from a higher key offers at once; one that
17
+ * hears it from a lower key answers with a `hello` addressed back to that
18
+ * tab, which then offers. So both orders of arrival connect, and nobody
19
+ * loops.
20
+ * 3. `offer` / `answer` carry the SDPs, `ice` batches of candidates
21
+ * (trickled, so nothing waits for gathering to finish).
22
+ *
23
+ * A tab only ever reacts to signals from users it wants itself, so a
24
+ * connection needs both sides to list each other. The database stamps every
25
+ * row's `sender`, and the offer carries the DTLS fingerprint of the
26
+ * connection, so the user on the other end of a data channel is proven.
27
+ */
28
+
29
+ export type SignalKind = 'hello' | 'offer' | 'answer' | 'ice';
30
+
31
+ export interface Signal {
32
+ readonly from: string;
33
+ readonly fromPeer: string;
34
+ readonly toPeer: string | null;
35
+ readonly kind: SignalKind;
36
+ readonly payload: string | null;
37
+ }
38
+
39
+ export interface OutgoingSignal {
40
+ readonly to: string;
41
+ readonly toPeer: string | null;
42
+ readonly fromPeer: string;
43
+ readonly kind: SignalKind;
44
+ readonly payload: string | null;
45
+ }
46
+
47
+ /** Where signals go and come from. The database by default. */
48
+ export interface SignalPort {
49
+ send(signal: OutgoingSignal): Promise<void>;
50
+ listen(self: PeerRef, cb: (signal: Signal) => void): () => void;
51
+ }
52
+
53
+ export interface WebRtcOptions {
54
+ signal: SignalPort;
55
+ iceServers?: RTCIceServer[];
56
+ logger?: Pick<Logger, 'debug' | 'warn'>;
57
+ /** Injectable for tests and non-browser runtimes. */
58
+ RTCPeerConnection?: typeof RTCPeerConnection;
59
+ /** Give up on one attempt after this long. Default 15000. */
60
+ connectTimeoutMs?: number;
61
+ }
62
+
63
+ const DEFAULT_ICE: RTCIceServer[] = [{ urls: 'stun:stun.cloudflare.com:3478' }];
64
+ const ICE_FLUSH_MS = 20;
65
+ const RETRY_BASE_MS = 2000;
66
+ const RETRY_MAX_MS = 30_000;
67
+
68
+ interface Link {
69
+ readonly ref: PeerRef;
70
+ readonly key: string;
71
+ readonly offerer: boolean;
72
+ /** One negotiation: answers and candidates of another are ignored. */
73
+ readonly sid: string;
74
+ readonly pc: RTCPeerConnection;
75
+ dc: RTCDataChannel | null;
76
+ open: boolean;
77
+ remoteSet: boolean;
78
+ pendingIce: RTCIceCandidateInit[];
79
+ outIce: RTCIceCandidateInit[];
80
+ iceTimer: ReturnType<typeof setTimeout> | null;
81
+ connectTimer: ReturnType<typeof setTimeout> | null;
82
+ /** Signals of this link go out in order (offer before its candidates). */
83
+ queue: Promise<void>;
84
+ closed: boolean;
85
+ }
86
+
87
+ const randomSid = (): string => Math.random().toString(36).slice(2, 10);
88
+
89
+ export function webrtcTransport(hooks: TransportHooks, opts: WebRtcOptions): ChannelTransport {
90
+ const PC = opts.RTCPeerConnection ?? (globalThis as { RTCPeerConnection?: typeof RTCPeerConnection }).RTCPeerConnection;
91
+ const log = opts.logger;
92
+ const connectTimeoutMs = opts.connectTimeoutMs ?? 15_000;
93
+ const links = new Map<string, Link>();
94
+ const retries = new Map<string, { attempts: number; timer: ReturnType<typeof setTimeout> | null }>();
95
+ let wanted = new Set<string>();
96
+ let stopListen: (() => void) | null = null;
97
+ let listeningAs: string | null = null;
98
+ let closed = false;
99
+
100
+ const selfKey = (): string | null => {
101
+ const self = hooks.self();
102
+ return self ? peerKey(self) : null;
103
+ };
104
+
105
+ const send = (link: Link | null, signal: Omit<OutgoingSignal, 'fromPeer'>): void => {
106
+ const self = hooks.self();
107
+ if (!self) return;
108
+ const out: OutgoingSignal = { ...signal, fromPeer: self.peer };
109
+ const run = () =>
110
+ opts.signal.send(out).catch((error) => {
111
+ log?.debug({ error, kind: signal.kind, Category: 'sp00ky-client::channel' }, 'channel signal failed');
112
+ });
113
+ if (link) link.queue = link.queue.then(run);
114
+ else void run();
115
+ };
116
+
117
+ const dropLink = (link: Link, failed: boolean): void => {
118
+ if (link.closed) return;
119
+ link.closed = true;
120
+ if (link.iceTimer) clearTimeout(link.iceTimer);
121
+ if (link.connectTimer) clearTimeout(link.connectTimer);
122
+ try {
123
+ link.dc?.close();
124
+ link.pc.close();
125
+ } catch {
126
+ // already gone
127
+ }
128
+ if (links.get(link.key) === link) links.delete(link.key);
129
+ if (link.open) {
130
+ link.open = false;
131
+ hooks.onPeer(link.ref, false);
132
+ }
133
+ if (failed && wanted.has(link.ref.user) && !closed) {
134
+ const anyOpen = [...links.values()].some((l) => l.ref.user === link.ref.user && l.open);
135
+ if (!anyOpen) hooks.onStatus(link.ref.user, 'failed');
136
+ scheduleRetry(link.ref);
137
+ }
138
+ };
139
+
140
+ const scheduleRetry = (ref: PeerRef): void => {
141
+ const key = peerKey(ref);
142
+ const r = retries.get(key) ?? { attempts: 0, timer: null };
143
+ if (r.timer) return;
144
+ const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** r.attempts);
145
+ r.attempts++;
146
+ r.timer = setTimeout(() => {
147
+ r.timer = null;
148
+ if (closed || !wanted.has(ref.user) || links.has(key)) return;
149
+ const mine = selfKey();
150
+ if (!mine) return;
151
+ if (mine < key) startOffer(ref);
152
+ else send(null, { to: ref.user, toPeer: ref.peer, kind: 'hello', payload: null });
153
+ }, delay);
154
+ retries.set(key, r);
155
+ };
156
+
157
+ const attachChannel = (link: Link, dc: RTCDataChannel): void => {
158
+ link.dc = dc;
159
+ dc.onopen = () => {
160
+ if (link.closed) return;
161
+ link.open = true;
162
+ if (link.connectTimer) clearTimeout(link.connectTimer);
163
+ link.connectTimer = null;
164
+ retries.delete(link.key);
165
+ hooks.onPeer(link.ref, true);
166
+ hooks.onStatus(link.ref.user, 'connected');
167
+ };
168
+ dc.onclose = () => dropLink(link, true);
169
+ dc.onmessage = (event) => {
170
+ if (typeof event.data !== 'string') return;
171
+ let message: unknown;
172
+ try {
173
+ message = JSON.parse(event.data);
174
+ } catch {
175
+ return;
176
+ }
177
+ hooks.onMessage(link.ref, message);
178
+ };
179
+ };
180
+
181
+ const newLink = (ref: PeerRef, offerer: boolean, sid: string): Link | null => {
182
+ if (!PC) return null;
183
+ const key = peerKey(ref);
184
+ const prev = links.get(key);
185
+ if (prev) dropLink(prev, false);
186
+ const pc = new PC({ iceServers: opts.iceServers ?? DEFAULT_ICE });
187
+ const link: Link = {
188
+ ref,
189
+ key,
190
+ offerer,
191
+ sid,
192
+ pc,
193
+ dc: null,
194
+ open: false,
195
+ remoteSet: false,
196
+ pendingIce: [],
197
+ outIce: [],
198
+ iceTimer: null,
199
+ connectTimer: null,
200
+ queue: Promise.resolve(),
201
+ closed: false,
202
+ };
203
+ links.set(key, link);
204
+ pc.onicecandidate = (event) => {
205
+ if (!event.candidate || link.closed) return;
206
+ link.outIce.push(event.candidate.toJSON());
207
+ link.iceTimer ??= setTimeout(() => {
208
+ link.iceTimer = null;
209
+ const candidates = link.outIce.splice(0);
210
+ if (candidates.length === 0 || link.closed) return;
211
+ send(link, { to: ref.user, toPeer: ref.peer, kind: 'ice', payload: JSON.stringify({ sid, candidates }) });
212
+ }, ICE_FLUSH_MS);
213
+ };
214
+ pc.onconnectionstatechange = () => {
215
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') dropLink(link, true);
216
+ };
217
+ if (!offerer) pc.ondatachannel = (event) => attachChannel(link, event.channel);
218
+ link.connectTimer = setTimeout(() => {
219
+ if (!link.open) dropLink(link, true);
220
+ }, connectTimeoutMs);
221
+ hooks.onStatus(ref.user, 'connecting');
222
+ return link;
223
+ };
224
+
225
+ const startOffer = (ref: PeerRef): void => {
226
+ const link = newLink(ref, true, randomSid());
227
+ if (!link) return;
228
+ attachChannel(link, link.pc.createDataChannel('sp00ky', { ordered: true }));
229
+ void (async () => {
230
+ try {
231
+ const offer = await link.pc.createOffer();
232
+ await link.pc.setLocalDescription(offer);
233
+ send(link, {
234
+ to: ref.user,
235
+ toPeer: ref.peer,
236
+ kind: 'offer',
237
+ payload: JSON.stringify({ sid: link.sid, sdp: link.pc.localDescription?.sdp ?? offer.sdp }),
238
+ });
239
+ } catch (error) {
240
+ log?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel offer failed');
241
+ dropLink(link, true);
242
+ }
243
+ })();
244
+ };
245
+
246
+ const addIce = async (link: Link, candidates: RTCIceCandidateInit[]): Promise<void> => {
247
+ for (const c of candidates) {
248
+ try {
249
+ await link.pc.addIceCandidate(c);
250
+ } catch {
251
+ // a candidate of a torn-down negotiation, or one the browser rejects
252
+ }
253
+ }
254
+ };
255
+
256
+ const onSignal = (s: Signal): void => {
257
+ if (closed) return;
258
+ const self = hooks.self();
259
+ if (!self) return;
260
+ const from = idKey(s.from);
261
+ if (!from || !wanted.has(from)) return;
262
+ if (s.toPeer !== null && s.toPeer !== self.peer) return;
263
+ const ref: PeerRef = { user: from, peer: s.fromPeer };
264
+ const key = peerKey(ref);
265
+ const mine = peerKey(self);
266
+ if (key === mine) return;
267
+ const payload = parsePayload(s.payload);
268
+ const link = links.get(key);
269
+
270
+ switch (s.kind) {
271
+ case 'hello': {
272
+ if (link && !link.closed) return;
273
+ if (mine < key) startOffer(ref);
274
+ else if (s.toPeer === null) send(null, { to: from, toPeer: ref.peer, kind: 'hello', payload: null });
275
+ return;
276
+ }
277
+ case 'offer': {
278
+ if (mine < key || typeof payload?.sdp !== 'string' || typeof payload.sid !== 'string') return;
279
+ const answerer = newLink(ref, false, payload.sid);
280
+ if (!answerer) return;
281
+ void (async () => {
282
+ try {
283
+ await answerer.pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp as string });
284
+ answerer.remoteSet = true;
285
+ await addIce(answerer, answerer.pendingIce.splice(0));
286
+ const answer = await answerer.pc.createAnswer();
287
+ await answerer.pc.setLocalDescription(answer);
288
+ send(answerer, {
289
+ to: from,
290
+ toPeer: ref.peer,
291
+ kind: 'answer',
292
+ payload: JSON.stringify({ sid: answerer.sid, sdp: answerer.pc.localDescription?.sdp ?? answer.sdp }),
293
+ });
294
+ } catch (error) {
295
+ log?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel answer failed');
296
+ dropLink(answerer, true);
297
+ }
298
+ })();
299
+ return;
300
+ }
301
+ case 'answer': {
302
+ if (!link || !link.offerer || link.remoteSet || payload?.sid !== link.sid || typeof payload.sdp !== 'string') return;
303
+ link.remoteSet = true;
304
+ void link.pc
305
+ .setRemoteDescription({ type: 'answer', sdp: payload.sdp })
306
+ .then(() => addIce(link, link.pendingIce.splice(0)))
307
+ .catch(() => dropLink(link, true));
308
+ return;
309
+ }
310
+ case 'ice': {
311
+ if (!link || payload?.sid !== link.sid || !Array.isArray(payload.candidates)) return;
312
+ const candidates = payload.candidates as RTCIceCandidateInit[];
313
+ if (link.remoteSet) void addIce(link, candidates);
314
+ else link.pendingIce.push(...candidates);
315
+ return;
316
+ }
317
+ }
318
+ };
319
+
320
+ const ensureListening = (): void => {
321
+ const self = hooks.self();
322
+ const as = self ? peerKey(self) : null;
323
+ if (as === listeningAs) return;
324
+ stopListen?.();
325
+ stopListen = null;
326
+ listeningAs = as;
327
+ if (self) stopListen = opts.signal.listen(self, onSignal);
328
+ };
329
+
330
+ return {
331
+ supported: PC !== undefined,
332
+ want(users) {
333
+ if (closed || !PC) {
334
+ wanted = new Set(users);
335
+ return;
336
+ }
337
+ const before = wanted;
338
+ wanted = new Set(users);
339
+ if (wanted.size > 0) ensureListening();
340
+ for (const link of [...links.values()]) if (!wanted.has(link.ref.user)) dropLink(link, false);
341
+ for (const [key, r] of retries) {
342
+ if (!wanted.has(key.slice(0, key.lastIndexOf('#'))) && r.timer) clearTimeout(r.timer);
343
+ }
344
+ for (const user of wanted) {
345
+ if (before.has(user)) continue;
346
+ send(null, { to: user, toPeer: null, kind: 'hello', payload: null });
347
+ hooks.onStatus(user, 'connecting');
348
+ }
349
+ },
350
+ send(to, message: ChannelMessage) {
351
+ const link = links.get(peerKey(to));
352
+ if (!link?.open || !link.dc || link.dc.readyState !== 'open') return;
353
+ try {
354
+ link.dc.send(JSON.stringify(message));
355
+ } catch (error) {
356
+ log?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel send failed');
357
+ }
358
+ },
359
+ close() {
360
+ closed = true;
361
+ for (const r of retries.values()) if (r.timer) clearTimeout(r.timer);
362
+ retries.clear();
363
+ for (const link of [...links.values()]) dropLink(link, false);
364
+ stopListen?.();
365
+ stopListen = null;
366
+ },
367
+ };
368
+ }
369
+
370
+ function parsePayload(payload: string | null): Record<string, unknown> | null {
371
+ if (!payload) return null;
372
+ try {
373
+ const v = JSON.parse(payload);
374
+ return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : null;
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ // ---- signaling over SurrealDB ----------------------------------------------------
381
+
382
+ /** The bits of the remote service signaling needs. */
383
+ export interface SignalRemote {
384
+ query<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T>;
385
+ getClient(): Pick<Surreal, 'liveOf'>;
386
+ }
387
+
388
+ export interface SignalConnection {
389
+ subscribe(cb: (state: ConnectionState) => void): () => void;
390
+ }
391
+
392
+ const SIGNAL_TABLE = '_00_channel_signal';
393
+ const LISTEN_RETRY_MS = 3000;
394
+
395
+ /**
396
+ * Signals as rows of `_00_channel_signal` (generated for schemas with a
397
+ * `@channel` table): a CREATE to send, one raw `LIVE SELECT` on rows
398
+ * addressed to this user to receive. Not the outbox and not a synced query:
399
+ * each signal costs one round trip, which is what keeps connection setup
400
+ * short. The LIVE is re-armed whenever the socket reconnects.
401
+ */
402
+ export function surrealSignal(
403
+ remote: SignalRemote,
404
+ connection: SignalConnection,
405
+ logger?: Pick<Logger, 'debug' | 'warn'>
406
+ ): SignalPort {
407
+ return {
408
+ async send(s) {
409
+ const content: Record<string, unknown> = {
410
+ recipient: parseRecordIdString(s.to),
411
+ sender_peer: s.fromPeer,
412
+ kind: s.kind,
413
+ };
414
+ if (s.toPeer !== null) content.recipient_peer = s.toPeer;
415
+ if (s.payload !== null) content.payload = s.payload;
416
+ await remote.query(`CREATE ${SIGNAL_TABLE} CONTENT $c RETURN NONE`, { c: content });
417
+ },
418
+ listen(self, cb) {
419
+ let stopped = false;
420
+ let uuid: Uuid | null = null;
421
+ let retry: ReturnType<typeof setTimeout> | null = null;
422
+ let state: ConnectionState | null = null;
423
+ const arm = async () => {
424
+ if (stopped) return;
425
+ if (retry) clearTimeout(retry);
426
+ retry = null;
427
+ const old = uuid;
428
+ uuid = null;
429
+ if (old) void remote.query('KILL $u', { u: old }).catch(() => {});
430
+ try {
431
+ const [id] = await remote.query<[Uuid]>(`LIVE SELECT * FROM ${SIGNAL_TABLE} WHERE recipient = $me`, {
432
+ me: parseRecordIdString(self.user),
433
+ });
434
+ if (stopped) {
435
+ void remote.query('KILL $u', { u: id }).catch(() => {});
436
+ return;
437
+ }
438
+ uuid = id;
439
+ const live = await remote.getClient().liveOf(id);
440
+ live.subscribe((message) => {
441
+ if (stopped || message.action !== 'CREATE') return;
442
+ const v = message.value as Record<string, unknown>;
443
+ const from = idKey(v.sender);
444
+ if (!from || typeof v.sender_peer !== 'string' || typeof v.kind !== 'string') return;
445
+ cb({
446
+ from,
447
+ fromPeer: v.sender_peer,
448
+ toPeer: typeof v.recipient_peer === 'string' ? v.recipient_peer : null,
449
+ kind: v.kind as SignalKind,
450
+ payload: typeof v.payload === 'string' ? v.payload : null,
451
+ });
452
+ });
453
+ } catch (error) {
454
+ logger?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel signal listen failed; retrying');
455
+ if (!stopped) retry = setTimeout(() => void arm(), LISTEN_RETRY_MS);
456
+ }
457
+ };
458
+ const unsubscribe = connection.subscribe((next) => {
459
+ const was = state;
460
+ state = next;
461
+ if (next === 'connected' && was !== null && was !== 'connected') void arm();
462
+ });
463
+ void arm();
464
+ return () => {
465
+ stopped = true;
466
+ unsubscribe();
467
+ if (retry) clearTimeout(retry);
468
+ if (uuid) void remote.query('KILL $u', { u: uuid }).catch(() => {});
469
+ };
470
+ },
471
+ };
472
+ }
@@ -242,6 +242,22 @@ export function planDeleteTx(input: WritePlanInput): LocalTx {
242
242
  };
243
243
  }
244
244
 
245
+ /**
246
+ * A channel peer's create: the row alone, no outbox entry (the peer's own
247
+ * client pushes it). Result index 0 is the row.
248
+ */
249
+ export function planPeerCreateTx(input: { recordId: RecordId<string>; data: Record<string, unknown> }): LocalTx {
250
+ const keys = Object.keys(input.data).map((key) => ({ key, variable: `data_${key}` }));
251
+ const vars: Vars = { id: input.recordId };
252
+ for (const { key, variable } of keys) vars[variable] = input.data[key];
253
+ return { query: surql.seal<unknown>(surql.tx([surql.createSet('id', keys)]), { resultIndex: 0 }), vars };
254
+ }
255
+
256
+ /** Undo a peer's create the server never confirmed: the row alone, no outbox entry. */
257
+ export function planPeerUndoTx(recordId: RecordId<string>): LocalTx {
258
+ return { query: surql.seal<unknown>(surql.tx([surql.delete('id')])), vars: { id: recordId } };
259
+ }
260
+
245
261
  /**
246
262
  * Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
247
263
  * With a `mirror`, the merged patch so far is written to `_00_pending_writes`
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
3
  import { runPure } from '../testing/run-pure';
4
- import { buildEntry, buildState } from '../testing/build';
4
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
5
5
  import { defaultEnv } from '../query/env';
6
- import { adoptPendingWrites, flushPendingWrites, flushWrite, write } from './write.saga';
6
+ import { adoptPendingWrites, flushPendingWrites, flushWrite, peerCreate, peerDelete, peerWriteExpired, readRow, write } from './write.saga';
7
7
  import * as R from '../state/reducers';
8
8
 
9
9
  const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {}, b: {} } }] } as any);
@@ -314,3 +314,123 @@ describe('adoptPendingWrites', () => {
314
314
  expect(empty.timers.size).toBe(0);
315
315
  });
316
316
  });
317
+
318
+ describe('channel peer writes', () => {
319
+ const row = { id: rid, a: 1 };
320
+ const local = (existing: unknown) => ({ 'local.query': () => [existing] });
321
+
322
+ it('readRow: the stored row, or null', async () => {
323
+ expect((await runPure(readRow('thing:1'), { handlers: local(row) })).result).toEqual(row);
324
+ expect((await runPure(readRow('thing:1'), { handlers: local(null) })).result).toBeNull();
325
+ });
326
+
327
+ it('peerCreate: inserts without an outbox entry, feeds the circuit unversioned, shows it, arms the expiry', async () => {
328
+ const out = await runPure(peerCreate(env, { recordId: 'thing:1', data: { a: 1, junk: 2 }, ttlMs: 5000 }), {
329
+ state: withQuery(),
330
+ handlers: {
331
+ ...local(null),
332
+ 'local.execute': (e: any) => {
333
+ expect(e.query.sql).not.toContain('mutationType');
334
+ expect(e.vars).toEqual({ id: rid, data_a: 1 });
335
+ return row;
336
+ },
337
+ 'ssp.ingest': (e: any) => expect(e.records).toEqual([{ table: 'thing', op: 'CREATE', id: 'thing:1', record: { ...row, _00_rv: 0 } }]),
338
+ },
339
+ });
340
+ expect(out.result).toBe(true);
341
+ expect(out.state.outbox).toEqual([]);
342
+ expect(out.state.versions.has('thing:1')).toBe(false);
343
+ expect(out.state.peerWrites.get('thing:1')).toEqual({ kind: 'create', table: 'thing', expiresAt: 1_700_000_005_000 });
344
+ expect(out.state.dirty.has('q')).toBe(true);
345
+ expect(out.timers.get('peer:thing:1')).toEqual({ ms: 5000, event: { type: 'PeerWriteExpired', id: 'thing:1' } });
346
+ });
347
+
348
+ it('peerCreate: refuses unknown tables, confirmed rows, own pending writes and a second create', async () => {
349
+ expect((await runPure(peerCreate(env, { recordId: 'nope:1', ttlMs: 1 }))).result).toBe(false);
350
+ const confirmed = buildState([], (s) => ({ ...s, versions: new Map([['thing:1', 2]]) }));
351
+ expect((await runPure(peerCreate(env, { recordId: 'thing:1', ttlMs: 1 }), { state: confirmed })).result).toBe(false);
352
+ const own = buildState([], R.outboxReplace([buildOutboxItem({ id: 'm', type: 'update', recordId: 'thing:1' })]));
353
+ expect((await runPure(peerCreate(env, { recordId: 'thing:1', ttlMs: 1 }), { state: own })).result).toBe(false);
354
+ const twice = R.setPeerWrite('thing:1', { kind: 'create', table: 'thing', expiresAt: 1 })(buildState());
355
+ expect((await runPure(peerCreate(env, { recordId: 'thing:1', ttlMs: 1 }), { state: twice })).result).toBe(false);
356
+ });
357
+
358
+ it("peerCreate: an existing unconfirmed row (another tab's copy) is shown without a second insert", async () => {
359
+ const out = await runPure(peerCreate(env, { recordId: 'thing:1', ttlMs: 10 }), {
360
+ state: withQuery(),
361
+ handlers: { ...local(row), 'ssp.ingest': () => undefined },
362
+ });
363
+ expect(out.result).toBe(true);
364
+ expect(out.log.some((e) => e.kind === 'local.execute')).toBe(false);
365
+ });
366
+
367
+ it('peerCreate: a store refusal or an empty answer applies nothing; a circuit failure is logged and still shows', async () => {
368
+ const refused = await runPure(peerCreate(env, { recordId: 'thing:1', ttlMs: 10 }), {
369
+ handlers: { ...local(null), 'local.execute': () => { throw new Error('schema'); } },
370
+ });
371
+ expect(refused.result).toBe(false);
372
+ expect(refused.emitted[0]).toMatchObject({ type: 'log', level: 'debug' });
373
+ const empty = await runPure(peerCreate(env, { recordId: 'thing:1', ttlMs: 10 }), { handlers: { ...local(null), 'local.execute': () => [] } });
374
+ expect(empty.result).toBe(false);
375
+ const circuit = await runPure(peerCreate(env, { recordId: 'thing:1', data: { a: 1 }, ttlMs: 10 }), {
376
+ handlers: { ...local(null), 'local.execute': () => row, 'ssp.ingest': () => { throw new Error('wasm'); } },
377
+ });
378
+ expect(circuit.result).toBe(true);
379
+ expect(circuit.emitted[0]).toMatchObject({ type: 'log', level: 'error' });
380
+ });
381
+
382
+ it('peerDelete: hides an existing row, keeps it in the store, arms the expiry', async () => {
383
+ const out = await runPure(peerDelete(env, { recordId: 'thing:1', ttlMs: 3000 }), { state: withQuery(), handlers: local(row) });
384
+ expect(out.result).toBe(true);
385
+ expect(out.state.peerWrites.get('thing:1')).toMatchObject({ kind: 'delete' });
386
+ expect(out.log.some((e) => e.kind === 'local.execute')).toBe(false);
387
+ expect(out.timers.get('peer:thing:1')?.ms).toBe(3000);
388
+ expect((await runPure(peerDelete(env, { recordId: 'thing:1', ttlMs: 1 }), { handlers: local(null) })).result).toBe(false);
389
+ expect((await runPure(peerDelete(env, { recordId: 'nope:1', ttlMs: 1 }))).result).toBe(false);
390
+ });
391
+
392
+ it("peerDelete: of the peer's own unconfirmed create undoes it; of a confirmed one hides it", async () => {
393
+ const created = R.setPeerWrite('thing:1', { kind: 'create', table: 'thing', expiresAt: 1 })(withQuery());
394
+ const undo = await runPure(peerDelete(env, { recordId: 'thing:1', ttlMs: 1 }), {
395
+ state: created,
396
+ handlers: {
397
+ ...local(row),
398
+ 'local.execute': (e: any) => expect(e.query.sql).toContain('DELETE'),
399
+ 'ssp.ingest': (e: any) => expect(e.records).toEqual([{ table: 'thing', op: 'DELETE', id: 'thing:1', record: row }]),
400
+ },
401
+ });
402
+ expect(undo.result).toBe(true);
403
+ expect(undo.state.peerWrites.size).toBe(0);
404
+ const confirmed = { ...created, versions: new Map([['thing:1', 1]]) };
405
+ const hide = await runPure(peerDelete(env, { recordId: 'thing:1', ttlMs: 1 }), { state: confirmed, handlers: local(row) });
406
+ expect(hide.state.peerWrites.get('thing:1')).toMatchObject({ kind: 'delete' });
407
+ });
408
+
409
+ it('peerWriteExpired: undoes an unconfirmed create, keeps a confirmed or own one, forgets a delete', async () => {
410
+ expect((await runPure(peerWriteExpired('thing:1'))).log.filter((e) => e.kind !== 'state.read')).toEqual([]);
411
+ const created = R.setPeerWrite('thing:1', { kind: 'create', table: 'thing', expiresAt: 1 })(withQuery());
412
+ const undone = await runPure(peerWriteExpired('thing:1'), {
413
+ state: created,
414
+ handlers: { ...local(null), 'local.execute': () => undefined },
415
+ });
416
+ expect(undone.state.peerWrites.size).toBe(0);
417
+ expect(undone.log.some((e) => e.kind === 'ssp.ingest')).toBe(false);
418
+ const failed = await runPure(peerWriteExpired('thing:1'), {
419
+ state: created,
420
+ handlers: { ...local(row), 'local.execute': () => { throw new Error('locked'); } },
421
+ });
422
+ expect(failed.emitted[0]).toMatchObject({ type: 'log', level: 'warn' });
423
+ expect(failed.state.peerWrites.size).toBe(0);
424
+ const confirmed = await runPure(peerWriteExpired('thing:1'), { state: { ...created, versions: new Map([['thing:1', 1]]) } });
425
+ expect(confirmed.state.peerWrites.size).toBe(0);
426
+ expect(confirmed.log.some((e) => e.kind === 'local.execute')).toBe(false);
427
+ const own = await runPure(peerWriteExpired('thing:1'), {
428
+ state: R.outboxReplace([buildOutboxItem({ id: 'm', type: 'update', recordId: 'thing:1' })])(created),
429
+ });
430
+ expect(own.state.peerWrites.size).toBe(0);
431
+ const deleted = await runPure(peerWriteExpired('thing:1'), {
432
+ state: R.setPeerWrite('thing:1', { kind: 'delete', table: 'thing', expiresAt: 1 })(withQuery()),
433
+ });
434
+ expect(deleted.state.peerWrites.size).toBe(0);
435
+ });
436
+ });