@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 +19 -0
- package/dist/test-harness/env.d.ts +4 -0
- package/package.json +1 -1
- package/src/index.ts +34 -1
- package/src/scenarios.test.ts +34 -0
- package/src/test-harness/env.ts +5 -0
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
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
|
-
|
|
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);
|
package/src/scenarios.test.ts
CHANGED
|
@@ -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
|
package/src/test-harness/env.ts
CHANGED
|
@@ -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.
|