@pylonsync/sync 0.3.197 → 0.3.200

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.197",
6
+ "version": "0.3.200",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1217,13 +1217,29 @@ export class SyncEngine {
1217
1217
  // bypass the tombstone — re-creation server-side still propagates.
1218
1218
  const tombstoneSeq = this.cursor.last_seq;
1219
1219
  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.
1220
+ // Capture cursor + resolved session BEFORE the fetch so we can
1221
+ // detect drift mid-reconcile. Two distinct races:
1222
+ //
1223
+ // 1. Cursor moves: a WS event for this (or another) entity
1224
+ // landed while the page-paginated fetch was in flight. Our
1225
+ // snapshot is stale; applying it would clobber the fresher
1226
+ // WS-delivered row.
1227
+ //
1228
+ // 2. Session flips: the resolved tenant/user changed while
1229
+ // the fetch was in flight (e.g., the app called
1230
+ // /api/auth/select-org just after we issued the fetch).
1231
+ // The server filtered the response under the OLD tenant
1232
+ // context, so applying the result would tombstone rows
1233
+ // that ARE visible under the NEW tenant. This is the
1234
+ // "dashboard flashes data away on first load" bug — the
1235
+ // engine starts before the app calls selectOrg, fetches
1236
+ // under tenant=null, returns 0 rows, then the apply pass
1237
+ // nukes every locally-cached row. Skip the apply when
1238
+ // the session signature changed; the next reconcile
1239
+ // (triggered by session-changed envelope) will re-fetch
1240
+ // under the new context.
1226
1241
  const cursorBeforeFetch = this.cursor.last_seq;
1242
+ const sessionBeforeFetch = sessionSignature(this._resolvedSession);
1227
1243
  let serverRows: Row[];
1228
1244
  try {
1229
1245
  serverRows = await this.fetchEntityRows(entity);
@@ -1248,6 +1264,14 @@ export class SyncEngine {
1248
1264
  // state for the affected row.
1249
1265
  continue;
1250
1266
  }
1267
+ if (sessionSignature(this._resolvedSession) !== sessionBeforeFetch) {
1268
+ // Session changed (token flipped, tenant switched, user
1269
+ // signed out → in, etc.). The rows we fetched reflect the
1270
+ // OLD session's policy view; applying them now would
1271
+ // tombstone rows visible under the NEW session. Bail and let
1272
+ // the session-changed envelope drive the next reconcile.
1273
+ continue;
1274
+ }
1251
1275
  await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
1252
1276
  }
1253
1277
  }
@@ -2132,6 +2156,19 @@ function rowsDiffer(a: Row, b: Row): boolean {
2132
2156
  return stableStringify(a) !== stableStringify(b);
2133
2157
  }
2134
2158
 
2159
+ /**
2160
+ * Compact, comparable fingerprint of a resolved session. Used by
2161
+ * reconcile() to detect mid-fetch identity flips — if the signature
2162
+ * differs, the rows we just fetched were policy-filtered under a
2163
+ * stale auth context and applying them would tombstone rows visible
2164
+ * under the new context. Roles array is sorted+joined so insertion
2165
+ * order doesn't trip the equality check.
2166
+ */
2167
+ function sessionSignature(s: ResolvedSession): string {
2168
+ const roles = (s.roles ?? []).slice().sort().join(",");
2169
+ return `${s.userId ?? ""}|${s.tenantId ?? ""}|${s.isAdmin ? "1" : "0"}|${roles}`;
2170
+ }
2171
+
2135
2172
  function stableStringify(value: unknown): string {
2136
2173
  if (value === null || typeof value !== "object") return JSON.stringify(value);
2137
2174
  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();