@pylonsync/sync 0.3.227 → 0.3.228
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/package.json +1 -1
- package/src/index.ts +49 -2
- package/src/scenarios.test.ts +38 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1414,6 +1414,17 @@ export class SyncEngine {
|
|
|
1414
1414
|
* entity twice within seconds. Configurable via `reconcileMinIntervalMs`. */
|
|
1415
1415
|
private lastReconcileAt = 0;
|
|
1416
1416
|
|
|
1417
|
+
/** Entities the app has subscribed to via `useQuery` / `useQueryOne`,
|
|
1418
|
+
* even ones the local replica has zero rows for. The reconcile
|
|
1419
|
+
* safety net defaults to `store.entityNames()` — entities with at
|
|
1420
|
+
* least one local row — so a server row in a NEVER-cached entity (a
|
|
1421
|
+
* row created on another surface, or a freshly-added entity) stayed
|
|
1422
|
+
* invisible until a full snapshot / cache clear: `useQuery` reads
|
|
1423
|
+
* the local store and a delta `pull()` can't recover a row created
|
|
1424
|
+
* before the cursor. Tracking observed entities lets the no-arg
|
|
1425
|
+
* reconcile sweep them too. See `observeEntity`. */
|
|
1426
|
+
private observedEntities = new Set<string>();
|
|
1427
|
+
|
|
1417
1428
|
/**
|
|
1418
1429
|
* Reconcile the local replica against server truth.
|
|
1419
1430
|
*
|
|
@@ -1447,8 +1458,38 @@ export class SyncEngine {
|
|
|
1447
1458
|
*
|
|
1448
1459
|
* Pass an explicit entity list to scope the reconcile (callers like
|
|
1449
1460
|
* `db.useQueryOne` that know what they care about). When called with
|
|
1450
|
-
* no arg, every entity with local rows
|
|
1461
|
+
* no arg, every entity with local rows OR observed via `useQuery`
|
|
1462
|
+
* (see `observeEntity`) is checked.
|
|
1463
|
+
*/
|
|
1464
|
+
/**
|
|
1465
|
+
* Register interest in an entity — called by `useQuery` /
|
|
1466
|
+
* `useQueryOne` on mount. Two effects:
|
|
1467
|
+
*
|
|
1468
|
+
* 1. Adds the entity to the reconcile sweep so the safety net
|
|
1469
|
+
* covers it even with zero local rows (see `observedEntities`).
|
|
1470
|
+
* 2. The FIRST time an entity is observed while the replica is
|
|
1471
|
+
* hydrated and that entity is locally empty, fires a one-shot
|
|
1472
|
+
* scoped reconcile so a server row this client never cached
|
|
1473
|
+
* appears on page-open — instead of waiting for the next
|
|
1474
|
+
* reconnect / visibility-change trigger. Bounded: at most once
|
|
1475
|
+
* per entity per engine (the `observedEntities` guard).
|
|
1476
|
+
*
|
|
1477
|
+
* Genuinely-empty entities just pay one cheap policy-filtered fetch;
|
|
1478
|
+
* entities where the client missed an insert get the row back.
|
|
1451
1479
|
*/
|
|
1480
|
+
observeEntity(entity: string): void {
|
|
1481
|
+
if (this.observedEntities.has(entity)) return;
|
|
1482
|
+
this.observedEntities.add(entity);
|
|
1483
|
+
// Only the leader talks to the network; follower tabs converge via
|
|
1484
|
+
// the multi-tab channel once the leader reconciles.
|
|
1485
|
+
if (!this.isMultiTabLeader) return;
|
|
1486
|
+
if (this.isHydrated() && this.store.list(entity).length === 0) {
|
|
1487
|
+
// Scoped reconcile bypasses the no-arg debounce and reuses the
|
|
1488
|
+
// session-flip / cursor-drift guards in reconcileInner.
|
|
1489
|
+
void this.reconcile([entity]);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1452
1493
|
async reconcile(entities?: string[]): Promise<void> {
|
|
1453
1494
|
const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
|
|
1454
1495
|
const now = Date.now();
|
|
@@ -1472,7 +1513,13 @@ export class SyncEngine {
|
|
|
1472
1513
|
// Same reasoning as pullInner: the leader reconciles, broadcasts
|
|
1473
1514
|
// results, and follower replicas converge via the channel.
|
|
1474
1515
|
if (!this.isMultiTabLeader) return;
|
|
1475
|
-
|
|
1516
|
+
// Sweep entities with local rows PLUS entities the app has observed
|
|
1517
|
+
// via useQuery (even when empty locally). Without the observed set,
|
|
1518
|
+
// a server row in a never-cached entity is never reconciled and
|
|
1519
|
+
// stays invisible until a full snapshot.
|
|
1520
|
+
const names =
|
|
1521
|
+
entities ??
|
|
1522
|
+
[...new Set([...this.store.entityNames(), ...this.observedEntities])];
|
|
1476
1523
|
if (names.length === 0) return;
|
|
1477
1524
|
// Tombstone seq for any local row the server doesn't return. Using
|
|
1478
1525
|
// the current cursor means future inserts (which have higher seqs)
|
package/src/scenarios.test.ts
CHANGED
|
@@ -487,6 +487,44 @@ describe("sync scenarios", () => {
|
|
|
487
487
|
expect(env.server.snapshotPullCount - before).toBe(1);
|
|
488
488
|
});
|
|
489
489
|
|
|
490
|
+
// EMPTY-ENTITY RECONCILE GAP (pins observeEntity). A server row in an
|
|
491
|
+
// entity the local replica has NEVER cached stays invisible: useQuery
|
|
492
|
+
// reads the empty local store, a delta pull can't recover a row
|
|
493
|
+
// created before the cursor, and the no-arg reconcile sweeps only
|
|
494
|
+
// entities with ≥1 local row (store.entityNames()) — so it skips the
|
|
495
|
+
// empty entity entirely. The user hit this as "I attached the domain
|
|
496
|
+
// but the Domains list is empty"; clearing IndexedDB (cursor→0→
|
|
497
|
+
// snapshot) was the only recovery. observeEntity (called by useQuery
|
|
498
|
+
// on mount) adds the entity to the sweep + fires a one-shot fetch.
|
|
499
|
+
test("observing an entity recovers a server row the local cache never had", async () => {
|
|
500
|
+
env = createTestEnv({ transport: "poll" });
|
|
501
|
+
env.signIn({ userId: "u1" });
|
|
502
|
+
// Client has rows for Note, but none for Domain.
|
|
503
|
+
env.server.seed("Note", [{ id: "n1", title: "x" }]);
|
|
504
|
+
await env.start();
|
|
505
|
+
await env.flush();
|
|
506
|
+
expect(env.engine.store.list("Domain")).toHaveLength(0);
|
|
507
|
+
|
|
508
|
+
// A Domain row exists server-side but was never delivered to this
|
|
509
|
+
// client (created on another surface / a missed insert). Inserted
|
|
510
|
+
// after start so the initial snapshot didn't include it; poll
|
|
511
|
+
// transport means no auto-delivery.
|
|
512
|
+
env.server.insert("Domain", { id: "d1", host: "chat.example.com" });
|
|
513
|
+
|
|
514
|
+
// A no-arg reconcile sweeps only entities with local rows (Note),
|
|
515
|
+
// so it never touches Domain — the row stays invisible. The bug.
|
|
516
|
+
await env.engine.reconcile(["Note"]);
|
|
517
|
+
await env.flush();
|
|
518
|
+
expect(env.engine.store.get("Domain", "d1")).toBeNull();
|
|
519
|
+
|
|
520
|
+
// observeEntity (what useQuery now calls on mount) adds Domain to
|
|
521
|
+
// the sweep and fires a one-shot scoped reconcile — the row appears
|
|
522
|
+
// without a cache clear.
|
|
523
|
+
env.engine.observeEntity("Domain");
|
|
524
|
+
await env.flush();
|
|
525
|
+
expect(env.engine.store.get("Domain", "d1")).not.toBeNull();
|
|
526
|
+
});
|
|
527
|
+
|
|
490
528
|
// Row-revoked envelope: server pushes `row-revoked` to a
|
|
491
529
|
// subscriber whose read policy was revoked for a specific row.
|
|
492
530
|
// The engine must drop the row from the local replica and
|