@spooky-sync/core 0.0.1-canary.331 → 0.0.1-canary.333
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.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +19 -4
- package/dist/index.js +220 -41
- package/dist/otel/index.d.ts +1 -1
- package/dist/pure.d.ts +16 -0
- package/dist/types.d.ts +62 -11
- package/package.json +3 -3
- package/src/client/router.ts +1 -1
- package/src/client/services.ts +4 -3
- package/src/kernel/events.ts +2 -2
- package/src/kernel/interpreter.ts +3 -3
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/index.ts +68 -1
- package/src/modules/crdt/crdt.test.ts +52 -6
- package/src/modules/crdt/index.ts +59 -23
- package/src/query/anon-query-isolation.integration.test.ts +7 -3
- package/src/query/membership.saga.test.ts +73 -1
- package/src/query/membership.saga.ts +39 -5
- package/src/query/membership.test.ts +33 -0
- package/src/query/membership.ts +33 -0
- package/src/query/session-guard.integration.test.ts +302 -0
- package/src/sync/live.saga.test.ts +51 -0
- package/src/sync/live.saga.ts +17 -8
- package/src/types.ts +29 -10
- package/src/utils/edges.ts +19 -1
- package/src/utils/error-classification.test.ts +19 -1
- package/src/utils/error-classification.ts +12 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
313
|
-
//
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
},
|
|
560
|
+
}, due - now);
|
|
539
561
|
}
|
|
540
562
|
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -58,14 +58,18 @@ function ownerGuard(): string {
|
|
|
58
58
|
|
|
59
59
|
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Just the session guard (its rule's user branch asks it) and the `_00_query`
|
|
63
|
+
* block: the rest of the file needs tables and modules this test does not.
|
|
64
|
+
*/
|
|
62
65
|
function queryTableSchema(): string {
|
|
63
66
|
const meta = cliSource('meta_tables_remote.surql');
|
|
67
|
+
const guard = meta.indexOf('-- SPOOKY SESSION GUARD');
|
|
64
68
|
const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
|
|
65
69
|
const end = meta.indexOf('-- SPOOKY LIST REF');
|
|
66
|
-
if (
|
|
70
|
+
if (guard < 0 || start < guard || end < start)
|
|
67
71
|
throw new Error('_00_query block not found in meta_tables_remote.surql');
|
|
68
|
-
return meta.slice(start, end);
|
|
72
|
+
return meta.slice(guard, meta.indexOf('-- SPOOKY INCANTATION')) + meta.slice(start, end);
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
const ACCESS = `
|
|
@@ -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.
|
|
154
|
-
*
|
|
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(
|
|
231
|
-
const outcome = yield* applyMembership(hash,
|
|
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
|
+
});
|
package/src/query/membership.ts
CHANGED
|
@@ -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
|
/**
|