@spooky-sync/core 0.0.1-canary.330 → 0.0.1-canary.332

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.
@@ -36,6 +36,8 @@ class World {
36
36
  for (const go of this.held.splice(0)) go();
37
37
  }
38
38
  private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
39
+ /** Every query a client registered, in order. */
40
+ watched: string[] = [];
39
41
 
40
42
  client(user: string, store = new Map<string, any>(), logger: any = silent) {
41
43
  const local = {
@@ -64,9 +66,10 @@ class World {
64
66
  return [[]];
65
67
  },
66
68
  } as any;
67
- const manager = new CrdtManager(schema, local, remote, logger, 300);
69
+ const manager = new CrdtManager(schema, local, remote, logger);
68
70
  const ports: CrdtPorts = {
69
71
  watch: async (table, where, cb) => {
72
+ this.watched.push(table);
70
73
  const w = { table, where, cb };
71
74
  this.watchers.add(w);
72
75
  if (this.slow) {
@@ -158,17 +161,60 @@ describe('CRDT fields', () => {
158
161
  expect(text(fb)).toBe(text(fa));
159
162
  });
160
163
 
161
- it('a burst of typing goes out after the pause, or after the max wait at the latest', async () => {
164
+ it('while typing continues, edits go out on a steady cadence instead of waiting for a pause', async () => {
162
165
  const world = new World();
163
166
  const a = world.client('user:a');
164
167
  const f = await a.manager.open('doc', 'doc:1', 'body');
165
- for (let i = 0; i < 10; i++) {
168
+ // A key every 50 ms for a second: a trailing debounce would send nothing
169
+ // until the typing stopped. The first edit goes out after 50 ms, the rest
170
+ // every 200 ms.
171
+ for (let i = 0; i < 20; i++) {
166
172
  f.getDoc().getText('body').insert(i, 'x');
167
173
  f.getDoc().commit();
168
- await vi.advanceTimersByTimeAsync(250);
174
+ await vi.advanceTimersByTimeAsync(50);
175
+ if (i === 0) expect(world.log).toHaveLength(1);
169
176
  }
170
- expect(world.log.length).toBeGreaterThanOrEqual(1);
171
- expect(world.log.length).toBeLessThan(10);
177
+ expect(world.log.length).toBeGreaterThanOrEqual(4);
178
+ expect(world.log.length).toBeLessThanOrEqual(6);
179
+ await vi.advanceTimersByTimeAsync(300);
180
+ const copy = new LoroDoc();
181
+ for (const row of world.log) copy.import(row.data);
182
+ expect(copy.getText('body').toString()).toBe('x'.repeat(20));
183
+ });
184
+
185
+ it('edits made while a send waits on the server go out right after it', async () => {
186
+ const world = new World();
187
+ const a = world.client('user:a');
188
+ const f = await a.manager.open('doc', 'doc:1', 'body');
189
+ world.slow = true;
190
+ // A fresh peer: the next send claims it first, and that claim hangs.
191
+ a.manager.setSessionId('');
192
+ f.getDoc().getText('body').insert(0, 'a');
193
+ f.getDoc().commit();
194
+ await vi.advanceTimersByTimeAsync(100);
195
+ f.getDoc().getText('body').insert(1, 'b');
196
+ f.getDoc().commit();
197
+ await vi.advanceTimersByTimeAsync(300);
198
+ world.slow = false;
199
+ world.release();
200
+ await vi.advanceTimersByTimeAsync(10);
201
+ const copy = new LoroDoc();
202
+ for (const row of world.log) copy.import(row.data);
203
+ expect(copy.getText('body').toString()).toBe('ab');
204
+ });
205
+
206
+ it('a first open reads the published document while its log query registers, not before', async () => {
207
+ const world = new World();
208
+ world.slow = true;
209
+ const a = world.client('user:a');
210
+ const opening = a.manager.open('doc', 'doc:1', 'body');
211
+ await vi.advanceTimersByTimeAsync(0);
212
+ // The published-document read is still out, and the log query is already registered.
213
+ expect(world.watched).toEqual(['doc__crdt_log']);
214
+ world.slow = false;
215
+ world.release();
216
+ await vi.advanceTimersByTimeAsync(10);
217
+ expect((await opening).isLoaded()).toBe(true);
172
218
  });
173
219
 
174
220
  it('send nothing until the peer is claimed, then everything at once', async () => {
@@ -80,9 +80,14 @@ interface Session {
80
80
  verifyTimer: ReturnType<typeof setTimeout> | null;
81
81
  disposers: Array<() => void>;
82
82
  flushTimer: ReturnType<typeof setTimeout> | null;
83
- firstDirtyAt: number;
83
+ /** When the armed flush timer fires. */
84
+ flushDueAt: number;
85
+ /** When this session last sent edits (0: not yet). */
86
+ lastSentAt: number;
84
87
  persistTimer: ReturnType<typeof setTimeout> | null;
85
88
  flushing: Promise<void> | null;
89
+ /** A flush asked for while one was running: it runs once that one is done. */
90
+ flushQueued: Promise<void> | null;
86
91
  refetching: boolean;
87
92
  presenceTimer: ReturnType<typeof setTimeout> | null;
88
93
  presencePending: Uint8Array | null;
@@ -99,8 +104,6 @@ interface Session {
99
104
  const LOCAL_TABLE = '_00_crdt_local';
100
105
  const LOG_SUFFIX = '__crdt_log';
101
106
  const PRESENCE_SUFFIX = '__crdt_presence';
102
- /** Longest a burst of typing waits before its edits go out. */
103
- const MAX_WAIT_MS = 2000;
104
107
  const PERSIST_DEBOUNCE_MS = 400;
105
108
  const PRESENCE_THROTTLE_MS = 250;
106
109
  const CLAIM_RETRY_MS = 5000;
@@ -129,7 +132,10 @@ export class CrdtManager {
129
132
  private local: LocalStore,
130
133
  private remote: RemoteDatabaseService,
131
134
  logger: Logger,
132
- private debounceMs: number = 300
135
+ /** First edit after a quiet spell: wait this long to batch the burst. */
136
+ private debounceMs: number = 50,
137
+ /** Shortest gap between two sends of one field, while edits keep coming. */
138
+ private sendIntervalMs: number = 200
133
139
  ) {
134
140
  this.logger = logger.child({ service: 'CrdtManager' });
135
141
  this.connectionUnsubscribes.push(
@@ -289,9 +295,11 @@ export class CrdtManager {
289
295
  verifyTimer: null,
290
296
  disposers: [],
291
297
  flushTimer: null,
292
- firstDirtyAt: 0,
298
+ flushDueAt: 0,
299
+ lastSentAt: 0,
293
300
  persistTimer: null,
294
301
  flushing: null,
302
+ flushQueued: null,
295
303
  refetching: false,
296
304
  presenceTimer: null,
297
305
  presencePending: null,
@@ -309,9 +317,11 @@ export class CrdtManager {
309
317
 
310
318
  // Claimed now, so the first edits do not wait on the round trip.
311
319
  void this.ensureClaimed();
312
- // First open on this device: start from the published document. Offline
313
- // it is read once the connection is back instead of holding the open.
314
- if (!local.snapshot && !fresh) await this.serverWait(this.refetchCanonical(session));
320
+ // First open on this device: start from the published document. Read at
321
+ // the same time as the log query below registers, not before it: each is
322
+ // a round trip, and the import order does not matter (ops whose
323
+ // predecessors are still missing wait inside Loro until they arrive).
324
+ const canonical = !local.snapshot && !fresh ? this.refetchCanonical(session) : null;
315
325
 
316
326
  session.disposers.push(
317
327
  doc.subscribeLocalUpdates(() => {
@@ -323,20 +333,22 @@ export class CrdtManager {
323
333
  })
324
334
  );
325
335
  const ports = this.ports;
326
- const log = await ports.watch(`${table}${LOG_SUFFIX}`, { doc: recordId, field }, (rows) => {
336
+ const logWatch = ports.watch(`${table}${LOG_SUFFIX}`, { doc: recordId, field }, (rows) => {
327
337
  session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
328
338
  });
339
+ const presenceWatch = cursor
340
+ ? ports.watch(`${table}${PRESENCE_SUFFIX}`, { doc: recordId, field }, (rows) => this.ingestPresence(session, rows))
341
+ : null;
342
+ const [log, presence] = await Promise.all([logWatch, presenceWatch]);
329
343
  session.disposers.push(log.stop);
344
+ if (presence) session.disposers.push(presence.stop);
330
345
  void log.settled.then(() => {
331
346
  session.logSettled = true;
332
347
  void session.ingesting.then(() => this.checkLoaded(session));
333
348
  });
334
- if (cursor) {
335
- const presence = await ports.watch(`${table}${PRESENCE_SUFFIX}`, { doc: recordId, field }, (rows) =>
336
- this.ingestPresence(session, rows)
337
- );
338
- session.disposers.push(presence.stop);
339
- }
349
+ // Offline the published document is read once the connection is back
350
+ // instead of holding the open.
351
+ if (canonical) await this.serverWait(canonical);
340
352
  // An editor binding an empty document creates its root structure, and
341
353
  // two clients doing that at once keep only one of them (with whatever was
342
354
  // typed into the other). With nothing local, hand the document out only
@@ -527,19 +539,37 @@ export class CrdtManager {
527
539
 
528
540
  // --- sending -----------------------------------------------------
529
541
 
530
- private scheduleFlush(s: Session, delay = this.debounceMs): void {
542
+ /**
543
+ * Arm the send. Edits go out on a steady cadence while they keep coming:
544
+ * the first one after a quiet spell waits `debounceMs` (one row for the
545
+ * burst), later ones at least `sendIntervalMs` after the previous send. An
546
+ * armed timer is never pushed back, so continuous typing cannot hold the
547
+ * edits; it is only brought forward (`delay`, e.g. 0 on reconnect).
548
+ */
549
+ private scheduleFlush(s: Session, delay?: number): void {
531
550
  const now = Date.now();
532
- if (!s.flushTimer) s.firstDirtyAt = now;
533
- else clearTimeout(s.flushTimer);
534
- const wait = Math.max(0, Math.min(delay, s.firstDirtyAt + MAX_WAIT_MS - now));
551
+ const due = delay !== undefined ? now + delay : Math.max(now + this.debounceMs, s.lastSentAt + this.sendIntervalMs);
552
+ if (s.flushTimer) {
553
+ if (s.flushDueAt <= due) return;
554
+ clearTimeout(s.flushTimer);
555
+ }
556
+ s.flushDueAt = due;
535
557
  s.flushTimer = setTimeout(() => {
536
558
  s.flushTimer = null;
537
559
  void this.flush(s);
538
- }, wait);
560
+ }, due - now);
539
561
  }
540
562
 
541
- private async flush(s: Session): Promise<void> {
542
- if (s.flushing) return s.flushing;
563
+ /** Send what is unsent. A call while a send runs queues one more after it,
564
+ * so edits made during that send are not left waiting for the next edit. */
565
+ private flush(s: Session): Promise<void> {
566
+ if (s.flushing) {
567
+ s.flushQueued ??= s.flushing.then(() => {
568
+ s.flushQueued = null;
569
+ return this.flush(s);
570
+ });
571
+ return s.flushQueued;
572
+ }
543
573
  s.flushing = this.sendOwnEdits(s).finally(() => {
544
574
  s.flushing = null;
545
575
  });
@@ -557,6 +587,7 @@ export class CrdtManager {
557
587
  .map((peer) => ({ peer, end: vv.get(peer as `${number}`) ?? 0, from: s.local.peers[peer]?.enqueued ?? 0 }))
558
588
  .filter((p) => p.end > p.from);
559
589
  if (due.length === 0 || !this.ports) return;
590
+ s.lastSentAt = Date.now();
560
591
  if (!(await this.ensureClaimed())) {
561
592
  this.retryLater(s);
562
593
  return;
@@ -581,7 +612,12 @@ export class CrdtManager {
581
612
  }
582
613
  s.local.peers[peer] = { enqueued: end };
583
614
  }
584
- await this.persist(s);
615
+ // The outbox marks reach the device with the next persist. An open field
616
+ // sends every few hundred ms while typing, and a persist exports the whole
617
+ // document, so it rides the persist debounce; a crash in between only
618
+ // re-sends rows, which import as no-ops. A closing field saves now.
619
+ if (this.sessions.get(s.key) === s) this.schedulePersist(s);
620
+ else await this.persist(s);
585
621
  }
586
622
 
587
623
  private retryLater(s: Session): void {
@@ -4,7 +4,7 @@ import { runPure } from '../testing/run-pure';
4
4
  import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
5
5
  import * as R from '../state/reducers';
6
6
  import { defaultEnv } from './env';
7
- import { applyMembership, applySubqueryChildren, markMembershipDirty, readDirtyMembership, readMembership, recoverLostView } from './membership.saga';
7
+ import { applyEdgeDeltas, applyMembership, applySubqueryChildren, markMembershipDirty, readDirtyMembership, readMembership, recoverLostView } from './membership.saga';
8
8
  import type { StatementResult } from '../kernel/effects';
9
9
 
10
10
  const env = defaultEnv({ tables: [] } as any);
@@ -405,3 +405,75 @@ describe('dirty helpers', () => {
405
405
  expect(stuck.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
406
406
  });
407
407
  });
408
+
409
+ describe('applyEdgeDeltas', () => {
410
+ const handlers = { 'local.upsert': () => undefined };
411
+ const live = (over: Record<string, unknown> = {}) =>
412
+ buildEntry({ def: { hash: 'a', viewKey: 'vk' }, lifecycle: { phase: 'live', remote: 'registered' }, remoteArray: [['t:1', 1]], ...over });
413
+
414
+ it('commits a joining row to a live view without reading the edges', async () => {
415
+ const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:2', version: 1 }, { hash: 'a', id: 't:1', version: 3 }]), {
416
+ state: buildState([live()]),
417
+ handlers,
418
+ });
419
+ expect([...out.result]).toEqual(['a']);
420
+ expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 3], ['t:2', 1]]);
421
+ expect(out.log.some((e) => e.kind === 'remote.query')).toBe(false);
422
+ // The durable view row follows, and the bodies are asked for.
423
+ expect(out.log.filter((e) => e.kind === 'local.upsert')).toHaveLength(1);
424
+ expect(out.dispatched).toContainEqual({ type: 'FetchRows' });
425
+ });
426
+
427
+ it('takes the fast path for a planned query with no LIMIT too', async () => {
428
+ const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:2', version: 1 }]), {
429
+ state: buildState([live({ def: { hash: 'a', viewKey: 'vk', plan: { table: 't' } } })]),
430
+ handlers,
431
+ });
432
+ expect([...out.result]).toEqual(['a']);
433
+ });
434
+
435
+ it('settles a delta it already holds without writing anything', async () => {
436
+ const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:1', version: 1 }]), { state: buildState([live()]) });
437
+ expect([...out.result]).toEqual(['a']);
438
+ expect(out.log.some((e) => e.kind === 'local.upsert')).toBe(false);
439
+ });
440
+
441
+ it('leaves to the read a view whose membership is not known and current', async () => {
442
+ const deltas = [{ hash: 'a', id: 't:2', version: 1 }];
443
+ for (const entry of [
444
+ live({ lifecycle: { phase: 'cold' } }),
445
+ live({ lifecycle: { phase: 'cached' } }),
446
+ live({ lifecycle: { phase: 'view-lost' } }),
447
+ live({ serverState: 'materializing' }),
448
+ live({ viewLostRetryAt: 9_000 }),
449
+ // A bounded view: the row this one pushes out must leave in the same render.
450
+ live({ def: { hash: 'a', surql: 'SELECT * FROM t ORDER BY at DESC LIMIT 10' } }),
451
+ live({ def: { hash: 'a', plan: { table: 't', limit: 10 } } }),
452
+ ]) {
453
+ const out = await runPure(applyEdgeDeltas(deltas), { state: buildState([entry]) });
454
+ expect(out.result.size).toBe(0);
455
+ expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 1]]);
456
+ }
457
+ expect((await runPure(applyEdgeDeltas([{ hash: 'zz', id: 't:2', version: 1 }]), { state: buildState([live()]) })).result.size).toBe(0);
458
+ });
459
+ });
460
+
461
+ describe('readMembership after a LIVE delta', () => {
462
+ const qid = (h: string) => new RecordId('_00_query', h);
463
+ it('a read sent before a row joined does not take it back', async () => {
464
+ const s = buildState([buildEntry({ def: { hash: 'a', id: qid('a') }, lifecycle: { phase: 'live' }, remoteArray: [['thing:1', 1]], serverState: 'ready' })]);
465
+ const out = await runPure(readMembership(env, ['a']), {
466
+ state: s,
467
+ handlers: {
468
+ // The server answers for the moment it ran the read; the delta lands
469
+ // while the answer travels back.
470
+ 'remote.query': (_e: any, ctx: any) => {
471
+ ctx.state = R.commitMembership('a', [['thing:1', 1], ['thing:2', 1]], true)(ctx.state);
472
+ return [ok([{ out: new RecordId('thing', '1'), version: 1 }]), ok({ rowCount: 1, state: 'ready' }), ok([])];
473
+ },
474
+ 'local.upsert': () => undefined,
475
+ },
476
+ });
477
+ expect(out.state.queries.get('a')!.remoteArray).toEqual([['thing:1', 1], ['thing:2', 1]]);
478
+ });
479
+ });
@@ -1,4 +1,4 @@
1
- import type { QueryHash, RecordVersionArray, ServerViewMeta } from '../types';
1
+ import type { EdgeDelta, QueryHash, RecordVersionArray, ServerViewMeta } from '../types';
2
2
  import type { Saga } from '../kernel/saga';
3
3
  import type { Settled, StatementResult } from '../kernel/effects';
4
4
  import { fx } from '../kernel/effects';
@@ -22,6 +22,8 @@ import * as sql from './sql';
22
22
  import {
23
23
  FAILED,
24
24
  decideMembershipOutcome,
25
+ rebaseRead,
26
+ withDeltas,
25
27
  snapshotFromSingle,
26
28
  snapshotsFromBatch,
27
29
  suspectHashes,
@@ -148,10 +150,41 @@ const stmt = (results: unknown, i: number): unknown => {
148
150
  return r && r.status === 'OK' ? r.result : FAILED;
149
151
  };
150
152
 
153
+ /**
154
+ * Commit LIVE edge deltas (rows joining a view, or moving to a newer
155
+ * version) straight to membership, without re-reading the edges: the
156
+ * notification already says what the read would. Returns the hashes it
157
+ * settled; any other change still goes through {@link readMembership}.
158
+ *
159
+ * Only for a query whose membership is known and current (`live`, not
160
+ * materializing, no view-lost recovery pending): before that the read decides
161
+ * what the set is. Removals are never applied here, so a view whose edges
162
+ * are being rewritten never loses (and garbage-collects) bodies mid-rewrite.
163
+ * Nor for a query with a `LIMIT`: a row joining it pushes another out, and
164
+ * rendering the joiner before the read removes the leaver would show one row
165
+ * too many (the id-set render drops the `LIMIT`).
166
+ */
167
+ export function* applyEdgeDeltas(deltas: EdgeDelta[]): Saga<Set<QueryHash>> {
168
+ const settled = new Set<QueryHash>();
169
+ const byHash = new Map<QueryHash, EdgeDelta[]>();
170
+ for (const d of deltas) byHash.set(d.hash, [...(byHash.get(d.hash) ?? []), d]);
171
+ for (const [hash, list] of byHash) {
172
+ const entry = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
173
+ if (!entry || entry.lifecycle.phase !== 'live' || entry.serverState === 'materializing' || entry.viewLostRetryAt !== null) continue;
174
+ if (entry.def.plan ? entry.def.plan.limit != null : /\bLIMIT\b/i.test(entry.def.surql)) continue;
175
+ const next = withDeltas(entry.remoteArray, list);
176
+ // Never empty, so always applied.
177
+ if (next !== entry.remoteArray) yield* applyMembership(hash, next);
178
+ settled.add(hash);
179
+ }
180
+ return settled;
181
+ }
182
+
151
183
  /**
152
184
  * Read the server membership of many queries in as few round trips as the
153
- * row budget allows, then apply each changed set. This is the ONLY place a
154
- * server id-set enters state: registration, the poll, LIVE dirt and view-lost
185
+ * row budget allows, then apply each changed set. Every server id-set enters
186
+ * state here, except the rows LIVE reports joining (see
187
+ * {@link applyEdgeDeltas}): registration, the poll, LIVE dirt and view-lost
155
188
  * recovery all come through here.
156
189
  */
157
190
  export function* readMembership(
@@ -226,9 +259,10 @@ export function* readMembership(
226
259
  for (const [hash, snap] of snapshots) {
227
260
  const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
228
261
  if (!current) continue;
262
+ const primary = rebaseRead(snap.primary, byHash.get(hash)!.remoteArray, current.remoteArray);
229
263
  const metaChanged = current.serverState !== (snap.meta.present ? snap.meta.state : null);
230
- if (!recordVersionArraysEqual(snap.primary, current.remoteArray) || metaChanged || current.lifecycle.phase === 'cold' || current.lifecycle.phase === 'cached') {
231
- const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
264
+ if (!recordVersionArraysEqual(primary, current.remoteArray) || metaChanged || current.lifecycle.phase === 'cold' || current.lifecycle.phase === 'cached') {
265
+ const outcome = yield* applyMembership(hash, primary, snap.meta);
232
266
  if (outcome === 'applied') changed = true;
233
267
  if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') stillMaterializing.push(hash);
234
268
  }
@@ -6,9 +6,11 @@ import {
6
6
  isResolvedBefore,
7
7
  metaFromRow,
8
8
  parseViewRow,
9
+ rebaseRead,
9
10
  snapshotFromSingle,
10
11
  snapshotsFromBatch,
11
12
  suspectHashes,
13
+ withDeltas,
12
14
  } from './membership';
13
15
 
14
16
  describe('durable view row', () => {
@@ -116,3 +118,34 @@ describe('snapshots', () => {
116
118
  expect(suspectHashes(nullCount, new Map(), 0)).toEqual([]);
117
119
  });
118
120
  });
121
+
122
+ describe('withDeltas', () => {
123
+ it('adds a joining row and moves a held one to a newer version', () => {
124
+ const held: Array<[string, number]> = [['t:1', 1], ['t:2', 1]];
125
+ expect(withDeltas(held, [{ id: 't:3', version: 1 }, { id: 't:2', version: 4 }])).toEqual([['t:1', 1], ['t:2', 4], ['t:3', 1]]);
126
+ });
127
+ it('hands back the held set itself when nothing moves', () => {
128
+ const held: Array<[string, number]> = [['t:1', 5]];
129
+ expect(withDeltas(held, [{ id: 't:1', version: 5 }, { id: 't:1', version: 2 }])).toBe(held);
130
+ expect(withDeltas(held, [])).toBe(held);
131
+ });
132
+ });
133
+
134
+ describe('rebaseRead', () => {
135
+ it('is the read itself when state did not move while it was in flight', () => {
136
+ const before: Array<[string, number]> = [['t:1', 1]];
137
+ const read: Array<[string, number]> = [['t:2', 1]];
138
+ expect(rebaseRead(read, before, before)).toBe(read);
139
+ });
140
+ it('keeps a row that joined (or moved) after the read was sent', () => {
141
+ const before: Array<[string, number]> = [['t:1', 1]];
142
+ const now: Array<[string, number]> = [['t:1', 3], ['t:2', 1]];
143
+ // The server answered before either change.
144
+ expect(rebaseRead([['t:1', 1]], before, now)).toEqual([['t:1', 3], ['t:2', 1]]);
145
+ // ...or after them: nothing is doubled or moved back.
146
+ expect(rebaseRead([['t:1', 4], ['t:2', 2]], before, now)).toEqual([['t:1', 4], ['t:2', 2]]);
147
+ });
148
+ it('drops a row that left in the meantime', () => {
149
+ expect(rebaseRead([['t:1', 1], ['t:2', 1]], [['t:1', 1], ['t:2', 1]], [['t:2', 1]])).toEqual([['t:2', 1]]);
150
+ });
151
+ });
@@ -104,6 +104,39 @@ export function decideMembershipOutcome(input: MembershipDecisionInput): Members
104
104
  return knownEmpty ? 'applied' : 'ignored';
105
105
  }
106
106
 
107
+ /**
108
+ * Commit LIVE edge deltas onto a held id-set: each names a row that joined
109
+ * (or moved to a newer version). Returns `held` itself when nothing moves.
110
+ */
111
+ export function withDeltas(held: RecordVersionArray, deltas: ReadonlyArray<{ id: string; version: number }>): RecordVersionArray {
112
+ const next = new Map(held);
113
+ let moved = false;
114
+ for (const { id, version } of deltas) {
115
+ if ((next.get(id) ?? -1) >= version) continue;
116
+ next.set(id, version);
117
+ moved = true;
118
+ }
119
+ return moved ? Array.from(next.entries()) : held;
120
+ }
121
+
122
+ /**
123
+ * A membership read answers for the moment the server ran it, which may be
124
+ * before a LIVE delta that was committed while the read was in flight. Carry
125
+ * what changed in state since the read was sent (`before` -> `now`) over onto
126
+ * the read, so a late answer never takes back a row that just joined.
127
+ */
128
+ export function rebaseRead(read: RecordVersionArray, before: RecordVersionArray, now: RecordVersionArray): RecordVersionArray {
129
+ if (before === now) return read;
130
+ const was = new Map(before);
131
+ const is = new Map(now);
132
+ const out = new Map(read);
133
+ for (const [id] of before) if (!is.has(id)) out.delete(id);
134
+ for (const [id, version] of now) {
135
+ if (version > (was.get(id) ?? -1) && version > (out.get(id) ?? -1)) out.set(id, version);
136
+ }
137
+ return Array.from(out.entries());
138
+ }
139
+
107
140
  // ---- list_ref snapshots ------------------------------------------------------
108
141
 
109
142
  /**
@@ -222,6 +222,12 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
222
222
  this.authToken = token;
223
223
  }
224
224
 
225
+ /** The token the session is signed in with (or the configured one), for
226
+ * calls that leave the socket: `r2` bucket files go to the SSP over HTTP. */
227
+ getAuthToken(): string | null {
228
+ return this.authToken ?? this.getConfig().token ?? null;
229
+ }
230
+
225
231
  /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
226
232
  getReconnectConfig(): Required<ReconnectConfig> {
227
233
  return this.reconnectConfig;
@@ -6,6 +6,7 @@ import * as R from '../state/reducers';
6
6
  import { defaultEnv } from '../query/env';
7
7
  import { liveChange, liveInvalidate, liveStart } from './live.saga';
8
8
  import { rowOfEdge } from '../client/services';
9
+ import { deltaOfEdge } from '../utils/edges';
9
10
 
10
11
  const env = defaultEnv({ tables: [] } as any);
11
12
 
@@ -176,3 +177,53 @@ describe('liveChange with the body joined on', () => {
176
177
  expect([...out.state.membershipDirty]).toEqual(['a']);
177
178
  });
178
179
  });
180
+
181
+ describe('deltaOfEdge', () => {
182
+ const q = new RecordId('_00_query', 'a');
183
+ const rid = new RecordId('thread__crdt_log', 'x1');
184
+ it('reads a joining row off a CREATE or UPDATE, with or without the body joined on', () => {
185
+ expect(deltaOfEdge('CREATE', { in: q, out: rid, version: 2 })).toEqual({ hash: 'a', id: 'thread__crdt_log:x1', version: 2 });
186
+ expect(deltaOfEdge('UPDATE', { in: q, out: { id: rid, data: 'b' }, version: 3 })).toEqual({ hash: 'a', id: 'thread__crdt_log:x1', version: 3 });
187
+ });
188
+ it('leaves everything else to the re-read', () => {
189
+ // A row leaving, or a view's edges being rewritten.
190
+ expect(deltaOfEdge('DELETE', { in: q, out: rid, version: 2 })).toBeNull();
191
+ // A subquery child.
192
+ expect(deltaOfEdge('CREATE', { in: q, out: rid, version: 2, parent: new RecordId('thread', 't') })).toBeNull();
193
+ // Nothing to place: no version, no target, a target the session may not read, no query.
194
+ expect(deltaOfEdge('CREATE', { in: q, out: rid })).toBeNull();
195
+ expect(deltaOfEdge('CREATE', { in: q, out: null, version: 2 })).toBeNull();
196
+ expect(deltaOfEdge('CREATE', { in: q, out: 'thread__crdt_log:x1', version: 2 })).toBeNull();
197
+ expect(deltaOfEdge('CREATE', { out: rid, version: 2 })).toBeNull();
198
+ });
199
+ });
200
+
201
+ describe('liveChange with edge deltas', () => {
202
+ const live = (hash: string) =>
203
+ buildEntry({ def: { hash, viewKey: `vk-${hash}` }, lifecycle: { phase: 'live', remote: 'registered' }, remoteArray: [['t:1', 1]] });
204
+
205
+ it('commits a row joining a live view at once, with no re-read', async () => {
206
+ const out = await runPure(liveChange(env, ['a'], undefined, [{ hash: 'a', id: 't:2', version: 1 }]), {
207
+ state: { ...buildState([live('a')]), tabRole: 'leader' },
208
+ handlers: { 'local.upsert': () => undefined },
209
+ });
210
+ expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 1], ['t:2', 1]]);
211
+ expect(out.state.membershipDirty.size).toBe(0);
212
+ expect(out.timers.has('membership')).toBe(false);
213
+ // Other tabs still hear about it.
214
+ expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: ['a'] } });
215
+ });
216
+
217
+ it('re-reads what it could not settle, and ignores deltas for unknown queries', async () => {
218
+ const cold = buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } });
219
+ const out = await runPure(
220
+ liveChange(env, ['a', 'b'], undefined, [
221
+ { hash: 'b', id: 't:2', version: 1 },
222
+ { hash: 'zz', id: 't:9', version: 1 },
223
+ ]),
224
+ { state: buildState([live('a'), cold]) }
225
+ );
226
+ expect([...out.state.membershipDirty].sort()).toEqual(['a', 'b']);
227
+ expect(out.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
228
+ });
229
+ });
@@ -1,17 +1,18 @@
1
- import type { InlineRow, QueryHash } from '../types';
1
+ import type { EdgeDelta, InlineRow, QueryHash } from '../types';
2
2
  import type { Saga } from '../kernel/saga';
3
3
  import { fx } from '../kernel/effects';
4
4
  import type { ClientState } from '../state/client-state';
5
5
  import * as R from '../state/reducers';
6
6
  import type { SagaEnv } from '../query/env';
7
7
  import { listRefTable } from '../query/env';
8
- import { markMembershipDirty } from '../query/membership.saga';
8
+ import { applyEdgeDeltas, markMembershipDirty } from '../query/membership.saga';
9
9
  import { landChunk } from '../query/fetch.saga';
10
10
 
11
11
  /**
12
- * LIVE on the session's `_00_list_ref` table. An event marks its query's
13
- * membership dirty and the batched re-read decides; followers get the same
14
- * dirt relayed by the leader.
12
+ * LIVE on the session's `_00_list_ref` table. A row joining a live view is
13
+ * committed straight from its edge; any other event marks its query's
14
+ * membership dirty and the batched re-read decides. Followers get the dirt
15
+ * relayed by the leader.
15
16
  *
16
17
  * When the subscription was opened with the body joined on
17
18
  * (`liveInlineBodies`) the event ALSO carries the changed row. That is an
@@ -45,8 +46,12 @@ export function* liveInvalidate(): Saga<void> {
45
46
  yield fx.state.update(R.patchSync({ liveUuid: null, liveTable: null }));
46
47
  }
47
48
 
48
- /** One or more edges of these queries changed on the server. */
49
- export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[]): Saga<void> {
49
+ /**
50
+ * One or more edges of these queries changed on the server. A row joining a
51
+ * live view (`deltas`) is committed at once; anything else marks the query
52
+ * dirty for the batched re-read.
53
+ */
54
+ export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[], deltas?: EdgeDelta[]): Saga<void> {
50
55
  const [known, role, streak] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole, s.sync.pollIdleStreak])) as [
51
56
  QueryHash[],
52
57
  ClientState['tabRole'],
@@ -73,7 +78,11 @@ export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[
73
78
  // Before marking membership dirty: recording the version here is what makes
74
79
  // the re-read's `planFetch` find nothing left to pull for this row.
75
80
  if (rows && rows.length > 0) yield* landInlineRows(env, rows);
76
- yield* markMembershipDirty(known);
81
+ // The edge says what a re-read would: commit it, and spare the round trip
82
+ // that used to sit between every remote edit and its render.
83
+ const settled = deltas && deltas.length > 0 ? yield* applyEdgeDeltas(deltas.filter((d) => known.includes(d.hash))) : new Set<QueryHash>();
84
+ const dirty = known.filter((h) => !settled.has(h));
85
+ if (dirty.length > 0) yield* markMembershipDirty(dirty);
77
86
  if (role === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: known } });
78
87
  }
79
88