@pylonsync/sync 0.3.198 → 0.3.201

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.198",
6
+ "version": "0.3.201",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -165,6 +165,23 @@ export class SyncEngine {
165
165
  private reconnectAttempts = 0;
166
166
  private persistence: import("./persistence").IndexedDBPersistence | null = null;
167
167
 
168
+ /**
169
+ * Flips true once `start()` has either:
170
+ * - drained IndexedDB into the in-memory store (data path), OR
171
+ * - decided the engine has no persistence layer (test / SSR / explicit opt-out).
172
+ *
173
+ * `useQuery`'s `loading` flag consumes this so apps don't render a
174
+ * "Loading…" flash on every page refresh when the disk replica
175
+ * already has the data. Without it, even returning visits show the
176
+ * spinner for the 50–200ms gap between component mount and IndexedDB
177
+ * `loadAllEntities()` resolving — visually identical to a true
178
+ * cold-start fetch.
179
+ */
180
+ private _hydrated = false;
181
+ isHydrated(): boolean {
182
+ return this._hydrated;
183
+ }
184
+
168
185
  readonly store: LocalStore;
169
186
  readonly mutations: MutationQueue;
170
187
 
@@ -395,7 +412,10 @@ export class SyncEngine {
395
412
  // subscribers re-read. Without this, if the subsequent pull returns
396
413
  // no changes (replica already at cursor), subscribers stay stuck on
397
414
  // their initial empty snapshot until the first WS event arrives.
415
+ this._hydrated = true;
398
416
  if (hydrated) this.store.notify();
417
+ else this.store.notify(); // notify even on empty cache so useQuery
418
+ // sees `isHydrated()` flip and can drop its initial loading state.
399
419
 
400
420
  // Load cursor.
401
421
  const savedCursor = await this.persistence.loadCursor();
@@ -439,6 +459,11 @@ export class SyncEngine {
439
459
  // IndexedDB not available — continue without persistence.
440
460
  }
441
461
  }
462
+ // Always flip `_hydrated` true by this point — even when persist
463
+ // was off or IndexedDB threw. useQuery's loading semantics depend
464
+ // on it; leaving false would pin every app with persist:false
465
+ // into a permanent "Loading…" state.
466
+ this._hydrated = true;
442
467
 
443
468
  // Seed the server-resolved session before the first pull so
444
469
  // `useSession` subscribers see the right tenant from frame one, and
@@ -458,10 +483,21 @@ export class SyncEngine {
458
483
  // the local IndexedDB has rows the server doesn't (deletes made by
459
484
  // another surface while this tab was closed, or events that fell
460
485
  // off the in-memory ChangeLog before this tab's cursor caught up).
461
- // Fires after pull so we don't reconcile against rows that pull
462
- // would have applied anyway. Errors are swallowed inside
463
- // reconcileInner so a failed reconcile doesn't take down startup.
464
- void this.reconcile();
486
+ //
487
+ // Deliberately NOT fired here anymore. Apps that select-org from
488
+ // a bootstrap effect race against this reconcile pass: the engine
489
+ // resolves /api/auth/me before selectOrg lands, tenant=null, the
490
+ // entity fetch returns 0 rows, every cached row gets tombstoned,
491
+ // and the user sees a "rows render then flash away" gap until
492
+ // selectOrg fires the session-changed envelope and the engine
493
+ // re-pulls. The visibility-change + WS-reconnect reconcile triggers
494
+ // below STILL run, so deletes made while the tab was closed
495
+ // converge on the next focus / reconnect — just not in the narrow
496
+ // window where the session might still be unresolved. Net effect:
497
+ // identical safety, no flash.
498
+ //
499
+ // The session-flip guard in reconcileInner is the second line of
500
+ // defense; this is the first. Belt + braces.
465
501
 
466
502
  // Wire the visibility-change reconcile so a tab that returns from
467
503
  // the background (laptop wakes, tab unhidden) catches up against
@@ -1217,13 +1253,29 @@ export class SyncEngine {
1217
1253
  // bypass the tombstone — re-creation server-side still propagates.
1218
1254
  const tombstoneSeq = this.cursor.last_seq;
1219
1255
  for (const entity of names) {
1220
- // Capture cursor BEFORE the fetch so we can detect drift mid-
1221
- // reconcile. If a WS event lands while this entity is being
1222
- // pulled, our snapshot is already stale — applying it would
1223
- // overwrite a newer authoritative row. Skip apply in that case
1224
- // and rely on the WS event plus the next reconcile trigger to
1225
- // converge.
1256
+ // Capture cursor + resolved session BEFORE the fetch so we can
1257
+ // detect drift mid-reconcile. Two distinct races:
1258
+ //
1259
+ // 1. Cursor moves: a WS event for this (or another) entity
1260
+ // landed while the page-paginated fetch was in flight. Our
1261
+ // snapshot is stale; applying it would clobber the fresher
1262
+ // WS-delivered row.
1263
+ //
1264
+ // 2. Session flips: the resolved tenant/user changed while
1265
+ // the fetch was in flight (e.g., the app called
1266
+ // /api/auth/select-org just after we issued the fetch).
1267
+ // The server filtered the response under the OLD tenant
1268
+ // context, so applying the result would tombstone rows
1269
+ // that ARE visible under the NEW tenant. This is the
1270
+ // "dashboard flashes data away on first load" bug — the
1271
+ // engine starts before the app calls selectOrg, fetches
1272
+ // under tenant=null, returns 0 rows, then the apply pass
1273
+ // nukes every locally-cached row. Skip the apply when
1274
+ // the session signature changed; the next reconcile
1275
+ // (triggered by session-changed envelope) will re-fetch
1276
+ // under the new context.
1226
1277
  const cursorBeforeFetch = this.cursor.last_seq;
1278
+ const sessionBeforeFetch = sessionSignature(this._resolvedSession);
1227
1279
  let serverRows: Row[];
1228
1280
  try {
1229
1281
  serverRows = await this.fetchEntityRows(entity);
@@ -1248,6 +1300,14 @@ export class SyncEngine {
1248
1300
  // state for the affected row.
1249
1301
  continue;
1250
1302
  }
1303
+ if (sessionSignature(this._resolvedSession) !== sessionBeforeFetch) {
1304
+ // Session changed (token flipped, tenant switched, user
1305
+ // signed out → in, etc.). The rows we fetched reflect the
1306
+ // OLD session's policy view; applying them now would
1307
+ // tombstone rows visible under the NEW session. Bail and let
1308
+ // the session-changed envelope drive the next reconcile.
1309
+ continue;
1310
+ }
1251
1311
  await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
1252
1312
  }
1253
1313
  }
@@ -2132,6 +2192,19 @@ function rowsDiffer(a: Row, b: Row): boolean {
2132
2192
  return stableStringify(a) !== stableStringify(b);
2133
2193
  }
2134
2194
 
2195
+ /**
2196
+ * Compact, comparable fingerprint of a resolved session. Used by
2197
+ * reconcile() to detect mid-fetch identity flips — if the signature
2198
+ * differs, the rows we just fetched were policy-filtered under a
2199
+ * stale auth context and applying them would tombstone rows visible
2200
+ * under the new context. Roles array is sorted+joined so insertion
2201
+ * order doesn't trip the equality check.
2202
+ */
2203
+ function sessionSignature(s: ResolvedSession): string {
2204
+ const roles = (s.roles ?? []).slice().sort().join(",");
2205
+ return `${s.userId ?? ""}|${s.tenantId ?? ""}|${s.isAdmin ? "1" : "0"}|${roles}`;
2206
+ }
2207
+
2135
2208
  function stableStringify(value: unknown): string {
2136
2209
  if (value === null || typeof value !== "object") return JSON.stringify(value);
2137
2210
  if (Array.isArray(value)) {
@@ -284,6 +284,77 @@ describe("LocalStore.reconcileRemove", () => {
284
284
  });
285
285
  });
286
286
 
287
+ describe("SyncEngine.reconcile session guard", () => {
288
+ // Regression for the "dashboard flashes data away on first load" bug.
289
+ // The engine starts before the app calls /api/auth/select-org, runs
290
+ // its initial pull + reconcile under tenant=null, and the
291
+ // policy-filtered entity fetch returns 0 rows — which then tombstones
292
+ // every IndexedDB-hydrated row from the previous session. Once the
293
+ // app calls selectOrg the rows reappear, but the user has already
294
+ // seen them flash.
295
+ //
296
+ // The guard: if the resolved session changes mid-fetch (token, tenant,
297
+ // user, isAdmin, or roles), reconcile MUST discard the result. The
298
+ // session-changed envelope queues another reconcile under the new
299
+ // identity.
300
+ test("skips apply when tenant flips during entity fetch", async () => {
301
+ let restore: (() => void) | null = null;
302
+ try {
303
+ restore = installFetch(async (url) => {
304
+ if (url.includes("/api/entities/Recording/cursor")) {
305
+ // Server filtered under stale tenant — returns empty. Without
306
+ // the guard, applyEntityReconcile would tombstone r1.
307
+ return {
308
+ status: 200,
309
+ body: { data: [], next_cursor: null, has_more: false },
310
+ };
311
+ }
312
+ return { status: 404, body: {} };
313
+ });
314
+
315
+ const engine = makeEngine();
316
+ seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
317
+ expect(engine.store.list("Recording").length).toBe(1);
318
+
319
+ // Simulate the app calling select-org WHILE reconcile is mid-flight.
320
+ // The implementation reads `_resolvedSession` directly; we override
321
+ // via the engine's internal store mutation surface to match how the
322
+ // session-changed handler would update it during a real flip.
323
+ const engineWithSession = engine as unknown as {
324
+ _resolvedSession: {
325
+ userId: string | null;
326
+ tenantId: string | null;
327
+ isAdmin: boolean;
328
+ roles: string[];
329
+ };
330
+ };
331
+ engineWithSession._resolvedSession = {
332
+ userId: "u1",
333
+ tenantId: null,
334
+ isAdmin: false,
335
+ roles: [],
336
+ };
337
+ const reconcilePromise = engine.reconcile(["Recording"]);
338
+ // Flip the tenant before the fetch resolves. The microtask queue
339
+ // already has the in-flight fetch; this just changes the field
340
+ // they'll compare against.
341
+ engineWithSession._resolvedSession = {
342
+ userId: "u1",
343
+ tenantId: "org-42",
344
+ isAdmin: false,
345
+ roles: [],
346
+ };
347
+ await reconcilePromise;
348
+
349
+ // Row must survive — the stale fetch result was discarded.
350
+ expect(engine.store.list("Recording").length).toBe(1);
351
+ expect(engine.store.get("Recording", "r1")).not.toBeNull();
352
+ } finally {
353
+ restore?.();
354
+ }
355
+ });
356
+ });
357
+
287
358
  describe("LocalStore.entityNames", () => {
288
359
  test("returns only entities with at least one row", () => {
289
360
  const engine = makeEngine();