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