@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 +1 -1
- package/src/index.ts +43 -6
- package/src/reconcile.test.ts +71 -0
package/package.json
CHANGED
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
|
|
1221
|
-
// reconcile.
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
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)) {
|
package/src/reconcile.test.ts
CHANGED
|
@@ -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();
|