@spooky-sync/core 0.0.1-canary.237 → 0.0.1-canary.239
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 +134 -5
- package/dist/index.js +498 -83
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +40 -27
- package/package.json +3 -3
- package/src/modules/data/data.activity.test.ts +109 -0
- package/src/modules/data/data.authority.test.ts +122 -0
- package/src/modules/data/data.heartbeat-wake.test.ts +115 -0
- package/src/modules/data/data.membership.test.ts +168 -68
- package/src/modules/data/index.ts +248 -83
- package/src/modules/devtools/index.ts +3 -2
- package/src/modules/sync/engine.ts +12 -7
- package/src/modules/sync/sync.health.test.ts +13 -3
- package/src/modules/sync/sync.list-ref-meta.test.ts +193 -0
- package/src/modules/sync/sync.live-removal.test.ts +137 -23
- package/src/modules/sync/sync.ts +326 -60
- package/src/modules/sync/utils.test.ts +1 -1
- package/src/modules/sync/utils.ts +10 -2
- package/src/sp00ky.ts +64 -1
- package/src/types.ts +41 -26
- package/src/utils/error-classification.test.ts +10 -0
- package/src/utils/error-classification.ts +18 -0
|
@@ -327,140 +327,240 @@ describe('membership-authoritative rendering', () => {
|
|
|
327
327
|
expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
330
|
+
// The `_00_query` row as read alongside the edges. `present: true` with
|
|
331
|
+
// `state: null` is a server that predates the marker.
|
|
332
|
+
const meta = (
|
|
333
|
+
over: Partial<{
|
|
334
|
+
present: boolean;
|
|
335
|
+
rowCount: number | null;
|
|
336
|
+
state: 'materializing' | 'ready' | null;
|
|
337
|
+
}> = {}
|
|
338
|
+
) => ({ present: true, rowCount: null, state: null, ...over });
|
|
339
|
+
|
|
340
|
+
it('ignores an empty id-set while the server is still publishing', async () => {
|
|
341
|
+
// The registration race: the SSP writes `state = materializing`, hands
|
|
342
|
+
// the view's initial edges to a coalescing flusher and answers
|
|
343
|
+
// `fn::query::register` before they land, so the read that follows says
|
|
344
|
+
// nothing about the query being empty. Believing it rendered a blank
|
|
345
|
+
// list AND persisted the blankness.
|
|
335
346
|
const { dm, state, local, hash } = setup({
|
|
336
347
|
membershipKey: 'stable-key',
|
|
337
348
|
remoteArray: [['thread:a', 1]],
|
|
338
349
|
membershipKnown: true,
|
|
339
350
|
});
|
|
340
351
|
|
|
341
|
-
await dm.updateQueryRemoteArray(hash, []
|
|
352
|
+
const outcome = await dm.updateQueryRemoteArray(hash, [], {
|
|
353
|
+
meta: meta({ rowCount: 26, state: 'materializing' }),
|
|
354
|
+
});
|
|
342
355
|
|
|
356
|
+
expect(outcome).toBe('ignored');
|
|
343
357
|
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
344
358
|
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
359
|
+
expect(state.serverState).toBe('materializing');
|
|
345
360
|
});
|
|
346
361
|
|
|
347
|
-
it('
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
362
|
+
it('ignores an empty id-set while the server still reports rows', async () => {
|
|
363
|
+
// `ready` but `rowCount 26` with no edges: the row and the edges
|
|
364
|
+
// disagree (a stranded view, metrics lag). No number of such reads may
|
|
365
|
+
// be taken as "empty".
|
|
366
|
+
const { dm, state, local, hash } = setup({
|
|
367
|
+
membershipKey: 'stable-key',
|
|
368
|
+
remoteArray: [['thread:a', 1]],
|
|
369
|
+
membershipKnown: true,
|
|
370
|
+
});
|
|
351
371
|
|
|
352
|
-
|
|
353
|
-
|
|
372
|
+
for (let i = 0; i < 5; i++) {
|
|
373
|
+
expect(
|
|
374
|
+
await dm.updateQueryRemoteArray(hash, [], { meta: meta({ rowCount: 26, state: 'ready' }) })
|
|
375
|
+
).toBe('ignored');
|
|
376
|
+
}
|
|
354
377
|
|
|
355
|
-
expect(state.config.
|
|
356
|
-
expect(
|
|
357
|
-
expect(local.windowRows.get('stable-key')).toMatchObject({ ids: [] });
|
|
378
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
379
|
+
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
358
380
|
});
|
|
359
381
|
|
|
360
|
-
it('believes
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
382
|
+
it('believes an empty id-set the moment the server says ready with zero rows', async () => {
|
|
383
|
+
// The genuine transition — the last row left the window. The SSP flips
|
|
384
|
+
// `ready` inside the transaction that writes the edges, so this cannot
|
|
385
|
+
// be observed mid-publish. Honouring it is what stops a removed row
|
|
386
|
+
// resurrecting from the local body cache.
|
|
387
|
+
const { dm, state, local, hash } = setup({
|
|
364
388
|
membershipKey: 'stable-key',
|
|
365
389
|
remoteArray: [['thread:a', 1]],
|
|
366
390
|
membershipKnown: true,
|
|
367
391
|
});
|
|
368
392
|
|
|
369
|
-
await dm.updateQueryRemoteArray(hash, []
|
|
370
|
-
|
|
393
|
+
const outcome = await dm.updateQueryRemoteArray(hash, [], {
|
|
394
|
+
meta: meta({ rowCount: 0, state: 'ready' }),
|
|
395
|
+
});
|
|
371
396
|
|
|
372
|
-
|
|
397
|
+
expect(outcome).toBe('applied');
|
|
398
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
373
399
|
expect(state.config.remoteArray).toEqual([]);
|
|
400
|
+
expect(local.windowRows.get('stable-key')).toEqual(
|
|
401
|
+
expect.objectContaining({ ids: [], confirmed: true })
|
|
402
|
+
);
|
|
374
403
|
});
|
|
375
404
|
|
|
376
|
-
it('
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
405
|
+
it('believes ready + zero rows with nothing held yet, too', async () => {
|
|
406
|
+
// A genuinely empty query on a cold device must not sit on a spinner
|
|
407
|
+
// waiting for rows that are never coming.
|
|
408
|
+
const { dm, state, hash } = setup({ membershipKey: 'stable-key' });
|
|
409
|
+
|
|
410
|
+
const outcome = await dm.updateQueryRemoteArray(hash, [], {
|
|
411
|
+
meta: meta({ rowCount: 0, state: 'ready' }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(outcome).toBe('applied');
|
|
415
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('treats a missing _00_query row as a lost view, never as an empty one', async () => {
|
|
419
|
+
// A TTL sweep, an SSP reset or a scheduler wipe took the row (and, with
|
|
420
|
+
// it, every edge). The client keeps what it renders, says so once, and
|
|
421
|
+
// asks for a re-registration. Believing the empty here is what blanked
|
|
422
|
+
// lists after every sweep and kept them blank across reloads.
|
|
382
423
|
const { dm, state, local, hash } = setup({
|
|
383
424
|
membershipKey: 'stable-key',
|
|
384
425
|
remoteArray: [['thread:a', 1]],
|
|
385
426
|
membershipKnown: true,
|
|
386
427
|
});
|
|
428
|
+
const onViewLost = vi.fn();
|
|
429
|
+
dm.onViewLost = onViewLost;
|
|
387
430
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
431
|
+
expect(await dm.updateQueryRemoteArray(hash, [], { meta: meta({ present: false }) })).toBe(
|
|
432
|
+
'view-lost'
|
|
433
|
+
);
|
|
434
|
+
expect(await dm.updateQueryRemoteArray(hash, [], { meta: meta({ present: false }) })).toBe(
|
|
435
|
+
'view-lost'
|
|
436
|
+
);
|
|
391
437
|
|
|
438
|
+
expect(onViewLost).toHaveBeenCalledTimes(1);
|
|
439
|
+
expect(onViewLost).toHaveBeenCalledWith(hash);
|
|
440
|
+
expect(state.viewLost).toBe(true);
|
|
392
441
|
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
442
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
393
443
|
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
394
444
|
});
|
|
395
445
|
|
|
396
|
-
it('
|
|
397
|
-
// The other half: a genuinely empty query must not sit on a stale seed
|
|
398
|
-
// waiting for a retry budget to run out.
|
|
446
|
+
it('clears the lost-view mark once a readable row is back', async () => {
|
|
399
447
|
const { dm, state, hash } = setup({
|
|
400
448
|
membershipKey: 'stable-key',
|
|
401
449
|
remoteArray: [['thread:a', 1]],
|
|
402
450
|
membershipKnown: true,
|
|
403
451
|
});
|
|
452
|
+
dm.onViewLost = vi.fn();
|
|
453
|
+
await dm.updateQueryRemoteArray(hash, [], { meta: meta({ present: false }) });
|
|
454
|
+
expect(state.viewLost).toBe(true);
|
|
455
|
+
|
|
456
|
+
// The re-registration republished the view.
|
|
457
|
+
const outcome = await dm.updateQueryRemoteArray(
|
|
458
|
+
hash,
|
|
459
|
+
[
|
|
460
|
+
['thread:a', 1],
|
|
461
|
+
['thread:b', 1],
|
|
462
|
+
],
|
|
463
|
+
{ meta: meta({ rowCount: 2, state: 'ready' }) }
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
expect(outcome).toBe('applied');
|
|
467
|
+
expect(state.viewLost).toBe(false);
|
|
468
|
+
expect(state.serverState).toBe('ready');
|
|
469
|
+
});
|
|
404
470
|
|
|
405
|
-
|
|
471
|
+
it('stays non-authoritative when nothing is held and the row is missing', async () => {
|
|
472
|
+
// A cold query whose row is not readable yet: nothing to lose, nothing
|
|
473
|
+
// to believe. The register lifecycle retries; no re-registration storm.
|
|
474
|
+
const { dm, state, hash } = setup({ membershipKey: 'stable-key' });
|
|
475
|
+
const onViewLost = vi.fn();
|
|
476
|
+
dm.onViewLost = onViewLost;
|
|
406
477
|
|
|
407
|
-
expect(
|
|
408
|
-
|
|
478
|
+
expect(await dm.updateQueryRemoteArray(hash, [], { meta: meta({ present: false }) })).toBe(
|
|
479
|
+
'ignored'
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect(onViewLost).not.toHaveBeenCalled();
|
|
483
|
+
expect(state.config.membershipKnown).toBeFalsy();
|
|
484
|
+
expect(state.viewLost).toBeFalsy();
|
|
409
485
|
});
|
|
410
486
|
|
|
411
|
-
it('
|
|
412
|
-
|
|
413
|
-
|
|
487
|
+
it('treats an empty set that comes with no row information as a lost view', async () => {
|
|
488
|
+
const { dm, state, hash } = setup({
|
|
489
|
+
remoteArray: [['thread:a', 1]],
|
|
490
|
+
membershipKnown: true,
|
|
491
|
+
});
|
|
492
|
+
dm.onViewLost = vi.fn();
|
|
493
|
+
expect(await dm.updateQueryRemoteArray(hash, [])).toBe('view-lost');
|
|
494
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('judges a server that predates `state` on rowCount alone', async () => {
|
|
414
498
|
const { dm, state, hash } = setup({
|
|
415
499
|
membershipKey: 'stable-key',
|
|
416
500
|
remoteArray: [['thread:a', 1]],
|
|
417
501
|
membershipKnown: true,
|
|
418
502
|
});
|
|
419
503
|
|
|
420
|
-
await dm.updateQueryRemoteArray(hash, [], {
|
|
504
|
+
expect(await dm.updateQueryRemoteArray(hash, [], { meta: meta({ rowCount: 3 }) })).toBe(
|
|
505
|
+
'ignored'
|
|
506
|
+
);
|
|
507
|
+
expect(await dm.updateQueryRemoteArray(hash, [], { meta: meta({ rowCount: null }) })).toBe(
|
|
508
|
+
'ignored'
|
|
509
|
+
);
|
|
421
510
|
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
422
511
|
|
|
423
|
-
await dm.updateQueryRemoteArray(hash, [], {
|
|
512
|
+
expect(await dm.updateQueryRemoteArray(hash, [], { meta: meta({ rowCount: 0 }) })).toBe(
|
|
513
|
+
'applied'
|
|
514
|
+
);
|
|
424
515
|
expect(state.config.remoteArray).toEqual([]);
|
|
425
516
|
});
|
|
426
517
|
|
|
427
|
-
it('
|
|
428
|
-
|
|
518
|
+
it('never grows to believe an unexplained empty on repeated reads', async () => {
|
|
519
|
+
// There is no retry budget any more: a server that cannot explain an
|
|
520
|
+
// empty must not be able to erase a device's data by repeating itself.
|
|
521
|
+
const { dm, state, local, hash } = setup({
|
|
522
|
+
membershipKey: 'stable-key',
|
|
523
|
+
remoteArray: [['thread:a', 1]],
|
|
524
|
+
membershipKnown: true,
|
|
525
|
+
});
|
|
429
526
|
|
|
430
|
-
|
|
431
|
-
|
|
527
|
+
for (let i = 0; i < 6; i++) {
|
|
528
|
+
await dm.updateQueryRemoteArray(hash, [], { meta: meta({ rowCount: null, state: 'ready' }) });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
532
|
+
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
533
|
+
});
|
|
432
534
|
|
|
433
|
-
|
|
434
|
-
|
|
535
|
+
it('applies an empty set the caller verified upstream', async () => {
|
|
536
|
+
// The LIVE removal path checked that every held id is gone from the
|
|
537
|
+
// server before asking: that is a real answer even without a row.
|
|
538
|
+
const { dm, state, local, hash } = setup({
|
|
539
|
+
membershipKey: 'stable-key',
|
|
540
|
+
remoteArray: [['thread:a', 1]],
|
|
541
|
+
membershipKnown: true,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const outcome = await dm.updateQueryRemoteArray(hash, [], { verifiedRemoval: true });
|
|
545
|
+
|
|
546
|
+
expect(outcome).toBe('applied');
|
|
547
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
435
548
|
expect(local.windowRows.get('stable-key')).toEqual(
|
|
436
549
|
expect.objectContaining({ ids: [], confirmed: true })
|
|
437
550
|
);
|
|
438
551
|
});
|
|
439
552
|
|
|
440
|
-
it('marks
|
|
441
|
-
//
|
|
442
|
-
//
|
|
553
|
+
it('marks every applied set as confirmed', async () => {
|
|
554
|
+
// Every set that lands is server-backed now, so the durable mirror may
|
|
555
|
+
// always be trusted on the next boot, empty or not.
|
|
443
556
|
const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
557
|
+
|
|
444
558
|
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
445
|
-
|
|
446
|
-
expect(local.windowRows.get('stable-key')).toEqual(
|
|
447
|
-
expect.objectContaining({ ids: [], confirmed: true })
|
|
448
|
-
);
|
|
449
|
-
});
|
|
559
|
+
expect(local.windowRows.get('stable-key')).toMatchObject({ confirmed: true });
|
|
450
560
|
|
|
451
|
-
|
|
452
|
-
// Two unreadable-row-count empties are believed for this session, but they
|
|
453
|
-
// are a guess: the durable row must not turn that guess into a permanent
|
|
454
|
-
// empty list on the next boot.
|
|
455
|
-
const { dm, local, hash } = setup({
|
|
456
|
-
membershipKey: 'stable-key',
|
|
457
|
-
remoteArray: [['thread:a', 1]],
|
|
458
|
-
membershipKnown: true,
|
|
459
|
-
});
|
|
460
|
-
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
461
|
-
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
561
|
+
await dm.updateQueryRemoteArray(hash, [], { meta: meta({ rowCount: 0, state: 'ready' }) });
|
|
462
562
|
expect(local.windowRows.get('stable-key')).toEqual(
|
|
463
|
-
expect.objectContaining({ ids: [], confirmed:
|
|
563
|
+
expect.objectContaining({ ids: [], confirmed: true })
|
|
464
564
|
);
|
|
465
565
|
});
|
|
466
566
|
|