@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.
@@ -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
- it('ignores an empty id-set until a real one has been seen', async () => {
331
- // The registration race: the SSP queues a view's initial edges to a
332
- // coalescing flusher and returns from `fn::query::register` before they
333
- // land, so this read says nothing about the query being empty. Believing
334
- // it rendered a blank list AND persisted the blankness.
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('believes an empty id-set once a non-empty one has arrived', async () => {
348
- // The genuine transition — the last row left the window. Honouring it is
349
- // what stops a removed row resurrecting from the local body cache.
350
- const { dm, state, local, hash } = setup({ membershipKey: 'stable-key' });
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
- await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
353
- await dm.updateQueryRemoteArray(hash, []);
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.membershipKnown).toBe(true);
356
- expect(state.config.remoteArray).toEqual([]);
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 a repeated empty id-set even with nothing seen this session', async () => {
361
- // A window that really did empty while this device was away: the durable
362
- // seed must not render forever, so the second read is taken at face value.
363
- const { dm, state, hash } = setup({
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
- expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
393
+ const outcome = await dm.updateQueryRemoteArray(hash, [], {
394
+ meta: meta({ rowCount: 0, state: 'ready' }),
395
+ });
371
396
 
372
- await dm.updateQueryRemoteArray(hash, []);
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('ignores an empty id-set while the server still reports rows', async () => {
377
- // The reported failure: registration returns before the SSP flushes the
378
- // view's edges, and the poll 500ms later is still inside that window. A
379
- // retry counter believes the second read and blanks the list ~2s after
380
- // load; `rowCount` says the query has 26 rows, so no number of empty
381
- // reads should ever be taken as "empty".
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
- for (let i = 0; i < 5; i++) {
389
- await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 26 });
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('believes an empty id-set the moment the server reports zero rows', async () => {
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
- await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 0 });
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(state.config.membershipKnown).toBe(true);
408
- expect(state.config.remoteArray).toEqual([]);
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('falls back to the retry budget when the row count is unreadable', async () => {
412
- // Older servers, or a row the client cannot select: unknown must not
413
- // strand the device on its durable seed forever.
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, [], { serverRowCount: null });
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, [], { serverRowCount: null });
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('marks a non-empty set and a server-confirmed empty set as confirmed', async () => {
428
- const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
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
- await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
431
- expect(local.windowRows.get('stable-key')).toMatchObject({ confirmed: true });
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
- // The server reports zero rows: a real answer, durable.
434
- await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 0 });
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 an empty set that follows a seen set as confirmed', async () => {
441
- // Everything the query matched was deleted while this session watched: the
442
- // transition is genuine, so the next boot must not resurrect the rows.
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
- await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
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
- it('leaves the retry-budget empty unconfirmed', async () => {
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: false })
563
+ expect.objectContaining({ ids: [], confirmed: true })
464
564
  );
465
565
  });
466
566