@pylonsync/sync 0.3.366 → 0.3.368

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/dist/index.d.ts CHANGED
@@ -56,6 +56,25 @@ export interface SyncEngineConfig {
56
56
  * becomes visible).
57
57
  */
58
58
  reconcileOnVisibility?: boolean;
59
+ /**
60
+ * Keep the local replica across ORG switches (same user). Default
61
+ * `true`: a tenant flip wipes the replica and re-bootstraps, because
62
+ * tenant-scoped read policies (`auth.tenantId == data.orgId`) mean
63
+ * the visible set genuinely changed.
64
+ *
65
+ * Set `false` ONLY when every synced entity's read policy is
66
+ * MEMBERSHIP-scoped (`exists(OrgMember where orgId == data.orgId and
67
+ * userId == auth.userId)`) — then the replica is valid across all the
68
+ * user's orgs at once, the wipe is pure waste, and an org switch
69
+ * becomes instant: components filter by the live `tenantId` while the
70
+ * engine runs a pull + reconcile in the background (which is what
71
+ * brings in rows of an org the user only just joined). With
72
+ * tenant-scoped policies this flag KEEPS STALE ROWS across switches —
73
+ * don't set it unless the policies match.
74
+ *
75
+ * USER flips (login/logout/account switch) always wipe regardless.
76
+ */
77
+ resetOnTenantFlip?: boolean;
59
78
  /**
60
79
  * Client → server keepalive interval (ms). The Pylon dev server's
61
80
  * dedicated :port+1 WS listener uses a dual-thread design where the
@@ -19,6 +19,10 @@ export interface CreateTestEnvOptions {
19
19
  * "poll" to disable the WS-onopen reconcile race in scenarios
20
20
  * that only want to pin the in-start pipeline. */
21
21
  transport?: "websocket" | "poll" | "sse";
22
+ /** Passed through to the engine config — see SyncEngineConfig.
23
+ * resetOnTenantFlip. Scenarios covering the member-scoped
24
+ * no-wipe org switch set false. */
25
+ resetOnTenantFlip?: boolean;
22
26
  /** Base delay (ms) for WS reconnect backoff. The default value used
23
27
  * inside the harness (`undefined` → 1000) introduces seconds of
24
28
  * wall-clock waiting on every reconnect scenario; pass a small
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.366",
6
+ "version": "0.3.368",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -147,6 +147,25 @@ export interface SyncEngineConfig {
147
147
  * becomes visible).
148
148
  */
149
149
  reconcileOnVisibility?: boolean;
150
+ /**
151
+ * Keep the local replica across ORG switches (same user). Default
152
+ * `true`: a tenant flip wipes the replica and re-bootstraps, because
153
+ * tenant-scoped read policies (`auth.tenantId == data.orgId`) mean
154
+ * the visible set genuinely changed.
155
+ *
156
+ * Set `false` ONLY when every synced entity's read policy is
157
+ * MEMBERSHIP-scoped (`exists(OrgMember where orgId == data.orgId and
158
+ * userId == auth.userId)`) — then the replica is valid across all the
159
+ * user's orgs at once, the wipe is pure waste, and an org switch
160
+ * becomes instant: components filter by the live `tenantId` while the
161
+ * engine runs a pull + reconcile in the background (which is what
162
+ * brings in rows of an org the user only just joined). With
163
+ * tenant-scoped policies this flag KEEPS STALE ROWS across switches —
164
+ * don't set it unless the policies match.
165
+ *
166
+ * USER flips (login/logout/account switch) always wipe regardless.
167
+ */
168
+ resetOnTenantFlip?: boolean;
150
169
  /**
151
170
  * Client → server keepalive interval (ms). The Pylon dev server's
152
171
  * dedicated :port+1 WS listener uses a dual-thread design where the
@@ -2369,7 +2388,14 @@ export class SyncEngine {
2369
2388
  // new tenant while useQuery still has the old tenant's rows.
2370
2389
  const verdict = this.session.inspectSession(next);
2371
2390
  if (verdict.tenantChanged) {
2372
- if (verdict.replicaInvalidated) {
2391
+ // `resetOnTenantFlip: false` — the app has declared its read
2392
+ // policies MEMBERSHIP-scoped, so the replica is already valid
2393
+ // for every org the user belongs to and the wipe would be pure
2394
+ // waste (the "switch orgs → blank dashboard for seconds" cost).
2395
+ // Only the TENANT verdict honors the flag; user flips (token
2396
+ // change, guardReplicaIdentity) always wipe.
2397
+ const skipReset = this.config.resetOnTenantFlip === false;
2398
+ if (verdict.replicaInvalidated && !skipReset) {
2373
2399
  // Route reset through the public (queued) method so the
2374
2400
  // wipe serializes against in-flight pulls / WS-event
2375
2401
  // applies / pushes. sessionChain serializes session
@@ -2384,6 +2410,13 @@ export class SyncEngine {
2384
2410
  // Only the leader pulls — followers receive subsequent
2385
2411
  // applied broadcasts that close the catch-up window.
2386
2412
  await this.pull();
2413
+ if (skipReset) {
2414
+ // No wipe happened, so rows of an org the user only just
2415
+ // JOINED aren't in the replica and no change event will
2416
+ // re-deliver them (their events predate membership). The
2417
+ // membership-scoped reconcile sweep upserts them.
2418
+ void this.reconcile();
2419
+ }
2387
2420
  }
2388
2421
  }
2389
2422
  this.session.commitObservation(next);
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { afterEach, describe, expect, test } from "bun:test";
13
13
 
14
+ import type { SyncEngine } from "./index";
14
15
  import { createTestEnv, type TestEnv } from "./test-harness";
15
16
 
16
17
  const tenantScopedVisibility = (
@@ -272,6 +273,39 @@ describe("sync scenarios", () => {
272
273
  expect(env.engine.store.get("Recording", "b1")).not.toBeNull();
273
274
  });
274
275
 
276
+ // MEMBER-SCOPED ORG SWITCH (pins resetOnTenantFlip: false). When every
277
+ // read policy is membership-scoped, the replica is valid across ALL the
278
+ // user's orgs at once — the tenant-flip wipe + from-zero re-bootstrap is
279
+ // pure waste (the "switch orgs → blank dashboard for seconds" cost). With
280
+ // the flag, a switch keeps every row and does NOT re-snapshot; the UI
281
+ // just filters by the live tenantId.
282
+ test("resetOnTenantFlip:false keeps the replica and skips the re-snapshot on org switch", async () => {
283
+ // Default visibility (no tenant filter) models membership-scoped reads:
284
+ // the caller sees every org's rows regardless of the active tenant.
285
+ env = createTestEnv({ transport: "poll", resetOnTenantFlip: false });
286
+ env.server.seed("Recording", [
287
+ { id: "a1", orgId: "org-a", title: "alice" },
288
+ { id: "b1", orgId: "org-b", title: "bob" },
289
+ ]);
290
+ env.signIn({ userId: "u1", tenantId: "org-a" });
291
+ await env.start();
292
+ await env.flush();
293
+ expect(env.engine.store.get("Recording", "a1")).not.toBeNull();
294
+ expect(env.engine.store.get("Recording", "b1")).not.toBeNull();
295
+
296
+ const snapshotsBefore = env.server.snapshotPullCount;
297
+ env.selectOrg("org-b");
298
+ await env.engine.notifySessionChanged();
299
+ await env.flush(100);
300
+
301
+ // No wipe: every org's rows survived the switch.
302
+ expect(env.engine.store.get("Recording", "a1")).not.toBeNull();
303
+ expect(env.engine.store.get("Recording", "b1")).not.toBeNull();
304
+ // No re-bootstrap: zero additional since=0 snapshot pulls. (The
305
+ // default-path test above proves the flag's absence still wipes.)
306
+ expect(env.server.snapshotPullCount - snapshotsBefore).toBe(0);
307
+ });
308
+
275
309
  // Reactive query subscription delivers a result over WS AND emits
276
310
  // the outbound `reactive-subscribe` envelope. Both directions of
277
311
  // the wire contract are pinned: the engine must SEND the
@@ -62,6 +62,10 @@ export interface CreateTestEnvOptions {
62
62
  * "poll" to disable the WS-onopen reconcile race in scenarios
63
63
  * that only want to pin the in-start pipeline. */
64
64
  transport?: "websocket" | "poll" | "sse";
65
+ /** Passed through to the engine config — see SyncEngineConfig.
66
+ * resetOnTenantFlip. Scenarios covering the member-scoped
67
+ * no-wipe org switch set false. */
68
+ resetOnTenantFlip?: boolean;
65
69
  /** Base delay (ms) for WS reconnect backoff. The default value used
66
70
  * inside the harness (`undefined` → 1000) introduces seconds of
67
71
  * wall-clock waiting on every reconnect scenario; pass a small
@@ -122,6 +126,7 @@ export function createTestEnv(opts: CreateTestEnvOptions = {}): TestEnv {
122
126
  storage,
123
127
  transport: opts.transport ?? "websocket",
124
128
  reconnectDelay: opts.reconnectDelay,
129
+ resetOnTenantFlip: opts.resetOnTenantFlip,
125
130
  // Tight timings so scenarios don't have to sleep seconds. The
126
131
  // engine's reconcile debounce is also relaxed so back-to-back
127
132
  // visibility-change triggers don't get coalesced away in tests.