@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.227",
6
+ "version": "0.3.228",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
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 is checked.
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
- const names = entities ?? this.store.entityNames();
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)
@@ -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