@pylonsync/sync 0.3.202 → 0.3.205

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.
@@ -0,0 +1,99 @@
1
+ // Unit tests for ServerSubscriptions. Pins the replay-on-reconnect
2
+ // contract so both consumers (CRDT + reactive) get identical
3
+ // semantics from one registry.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ import { ServerSubscriptions } from "./server-subscriptions";
8
+
9
+ describe("ServerSubscriptions", () => {
10
+ test("register sends the subscribe message", () => {
11
+ const sent: unknown[] = [];
12
+ const subs = new ServerSubscriptions((m) => sent.push(m));
13
+ subs.register("k1", { type: "sub", k: "k1" });
14
+ expect(sent).toEqual([{ type: "sub", k: "k1" }]);
15
+ });
16
+
17
+ test("re-registering the same key with identical payload does NOT re-send", () => {
18
+ const sent: unknown[] = [];
19
+ const subs = new ServerSubscriptions((m) => sent.push(m));
20
+ subs.register("k1", { type: "sub", k: "k1", v: 1 });
21
+ subs.register("k1", { type: "sub", k: "k1", v: 1 });
22
+ expect(sent.length).toBe(1);
23
+ });
24
+
25
+ test("re-registering with a CHANGED payload re-sends", () => {
26
+ // The intended path for useReactiveQuery(name, args) when args
27
+ // change: same sub_id, different args; the server has to learn.
28
+ const sent: unknown[] = [];
29
+ const subs = new ServerSubscriptions((m) => sent.push(m));
30
+ subs.register("k1", { type: "sub", k: "k1", v: 1 });
31
+ subs.register("k1", { type: "sub", k: "k1", v: 2 });
32
+ expect(sent).toEqual([
33
+ { type: "sub", k: "k1", v: 1 },
34
+ { type: "sub", k: "k1", v: 2 },
35
+ ]);
36
+ // Most recent payload wins on replay.
37
+ sent.length = 0;
38
+ subs.replay();
39
+ expect(sent).toEqual([{ type: "sub", k: "k1", v: 2 }]);
40
+ });
41
+
42
+ test("payload equality is structural (key order, nested)", () => {
43
+ const sent: unknown[] = [];
44
+ const subs = new ServerSubscriptions((m) => sent.push(m));
45
+ subs.register("k1", { type: "sub", args: { a: 1, b: 2 } });
46
+ // Same logical payload, different key insertion order.
47
+ subs.register("k1", { type: "sub", args: { b: 2, a: 1 } });
48
+ expect(sent.length).toBe(1);
49
+ });
50
+
51
+ test("unregister sends the unsubscribe and forgets the spec", () => {
52
+ const sent: unknown[] = [];
53
+ const subs = new ServerSubscriptions((m) => sent.push(m));
54
+ subs.register("k1", { type: "sub", k: "k1" });
55
+ subs.unregister("k1", { type: "unsub", k: "k1" });
56
+ expect(sent).toEqual([
57
+ { type: "sub", k: "k1" },
58
+ { type: "unsub", k: "k1" },
59
+ ]);
60
+ expect(subs.has("k1")).toBe(false);
61
+ // Replay after unregister sends nothing.
62
+ sent.length = 0;
63
+ subs.replay();
64
+ expect(sent).toEqual([]);
65
+ });
66
+
67
+ test("unregister of an unknown key is a no-op", () => {
68
+ const sent: unknown[] = [];
69
+ const subs = new ServerSubscriptions((m) => sent.push(m));
70
+ subs.unregister("ghost", { type: "unsub", k: "ghost" });
71
+ expect(sent).toEqual([]);
72
+ });
73
+
74
+ test("replay re-sends every active subscribe message in order", () => {
75
+ const sent: unknown[] = [];
76
+ const subs = new ServerSubscriptions((m) => sent.push(m));
77
+ subs.register("a", { type: "sub", k: "a" });
78
+ subs.register("b", { type: "sub", k: "b" });
79
+ subs.register("c", { type: "sub", k: "c" });
80
+ sent.length = 0;
81
+ subs.replay();
82
+ expect(sent).toEqual([
83
+ { type: "sub", k: "a" },
84
+ { type: "sub", k: "b" },
85
+ { type: "sub", k: "c" },
86
+ ]);
87
+ });
88
+
89
+ test("replay skips unregistered keys", () => {
90
+ const sent: unknown[] = [];
91
+ const subs = new ServerSubscriptions((m) => sent.push(m));
92
+ subs.register("a", { type: "sub", k: "a" });
93
+ subs.register("b", { type: "sub", k: "b" });
94
+ subs.unregister("a", { type: "unsub", k: "a" });
95
+ sent.length = 0;
96
+ subs.replay();
97
+ expect(sent).toEqual([{ type: "sub", k: "b" }]);
98
+ });
99
+ });
@@ -0,0 +1,78 @@
1
+ // ServerSubscriptions — replay-on-reconnect registry shared by every
2
+ // server-side ephemeral subscription type.
3
+ //
4
+ // The server clears per-client subscription state on disconnect: CRDT
5
+ // row subscriptions, reactive query subscriptions, and any future kind
6
+ // (file streams, presence, etc.) all evaporate when the WS socket
7
+ // closes. Each kind used to track its own re-registration in a
8
+ // separate field on the engine, with its own loop in `ws.onopen`. This
9
+ // generalizes: every subscription kind records the exact WS message
10
+ // the server needs to re-create its server-side state; reconnect
11
+ // replays the bundle.
12
+ //
13
+ // Kind-specific concerns (CRDT refcount, reactive handler routing)
14
+ // stay in the engine. This module only owns the replay bookkeeping.
15
+
16
+ export class ServerSubscriptions {
17
+ private specs = new Map<string, unknown>(); // key → subscribeMessage
18
+
19
+ constructor(private readonly sendWs: (msg: unknown) => void) {}
20
+
21
+ /** Register a subscription. Sends `subscribeMessage` over WS and
22
+ * remembers it so the next reconnect re-sends it.
23
+ *
24
+ * Re-registering the same key with the SAME payload is a no-op
25
+ * (the prior subscribe is still live on the server). But a
26
+ * re-register with a DIFFERENT payload re-sends — that's the
27
+ * intended behavior of `useReactiveQuery(name, args)` when args
28
+ * change: same sub_id, new args, server must observe the change
29
+ * or the handler keeps running against stale arguments. */
30
+ register(key: string, subscribeMessage: unknown): void {
31
+ const prev = this.specs.get(key);
32
+ const changed =
33
+ prev === undefined || !sameJson(prev, subscribeMessage);
34
+ this.specs.set(key, subscribeMessage);
35
+ if (changed) this.sendWs(subscribeMessage);
36
+ }
37
+
38
+ /** Unregister. Sends `unsubscribeMessage` over WS and forgets the
39
+ * replay entry. No-op for unknown keys (matches React's
40
+ * StrictMode-friendly double-unmount semantics). */
41
+ unregister(key: string, unsubscribeMessage: unknown): void {
42
+ if (!this.specs.has(key)) return;
43
+ this.specs.delete(key);
44
+ this.sendWs(unsubscribeMessage);
45
+ }
46
+
47
+ /** Whether `key` is currently registered. */
48
+ has(key: string): boolean {
49
+ return this.specs.has(key);
50
+ }
51
+
52
+ /** Re-send every registered subscribe message. Called from
53
+ * `ws.onopen` after the socket reconnects — the server purges
54
+ * per-client subscription state on disconnect, so without this
55
+ * resync the subscriber's first event would never arrive. */
56
+ replay(): void {
57
+ for (const msg of this.specs.values()) {
58
+ this.sendWs(msg);
59
+ }
60
+ }
61
+ }
62
+
63
+ /** Stable structural equality. Used to skip the WS round-trip when a
64
+ * re-register has the same payload — and to trigger one when it
65
+ * doesn't. Cheap enough on the small JSON shapes these messages use. */
66
+ function sameJson(a: unknown, b: unknown): boolean {
67
+ if (a === b) return true;
68
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
69
+ }
70
+
71
+ function canonical(v: unknown): unknown {
72
+ if (v === null || typeof v !== "object") return v;
73
+ if (Array.isArray(v)) return v.map(canonical);
74
+ const obj = v as Record<string, unknown>;
75
+ const sorted: Record<string, unknown> = {};
76
+ for (const k of Object.keys(obj).sort()) sorted[k] = canonical(obj[k]);
77
+ return sorted;
78
+ }
@@ -0,0 +1,133 @@
1
+ // Regression tests for the sessionChain FIFO + reactiveSubOwners
2
+ // set introduced in the codex round-3 fixes. These exercise the
3
+ // engine end-to-end so the chain semantics + refcount semantics
4
+ // are pinned at the engine layer, not just at the unit-test layer
5
+ // for SessionResolver / ServerSubscriptions.
6
+
7
+ import { afterEach, describe, expect, test } from "bun:test";
8
+
9
+ import { createTestEnv, type TestEnv } from "./test-harness";
10
+
11
+ const u1OrgA = { userId: "u1", tenantId: "org-a", isAdmin: false, roles: [] };
12
+ const u1OrgB = { userId: "u1", tenantId: "org-b", isAdmin: false, roles: [] };
13
+ const u1OrgC = { userId: "u1", tenantId: "org-c", isAdmin: false, roles: [] };
14
+
15
+ describe("sessionChain ordering", () => {
16
+ let env: TestEnv | null = null;
17
+ afterEach(async () => {
18
+ if (env) await env.dispose();
19
+ env = null;
20
+ });
21
+
22
+ test("rapid back-to-back applySessionTransition commits in arrival order", async () => {
23
+ env = createTestEnv();
24
+ env.signIn({ userId: "u1", tenantId: "org-a" });
25
+ await env.start();
26
+
27
+ // applySessionTransition is private — reach via the engine. The
28
+ // public path on a leader-only test env is `notifySessionChanged`
29
+ // which fetches /api/auth/me. To test direct chain ordering we
30
+ // poke the session through inspectSession + commitObservation
31
+ // is too low-level; instead we fire three notifySessionChanged
32
+ // calls and rely on the chain to serialize them.
33
+ //
34
+ // The harness flips server-side tenant between calls so each
35
+ // refresh observes a different value. The chain must commit
36
+ // in (A, B, C) arrival order regardless of network race timing.
37
+ const engine = env.engine as unknown as {
38
+ session: {
39
+ observeSession(s: typeof u1OrgA): unknown;
40
+ resolved(): typeof u1OrgA;
41
+ };
42
+ applySessionTransition(s: typeof u1OrgA, b: boolean): Promise<void>;
43
+ };
44
+ const p1 = engine.applySessionTransition(u1OrgA, false);
45
+ const p2 = engine.applySessionTransition(u1OrgB, false);
46
+ const p3 = engine.applySessionTransition(u1OrgC, false);
47
+ await Promise.all([p1, p2, p3]);
48
+
49
+ // Final committed tenant must be the LAST one enqueued.
50
+ expect(engine.session.resolved().tenantId).toBe("org-c");
51
+ });
52
+ });
53
+
54
+ describe("reactive subscription refcount under remount", () => {
55
+ let env: TestEnv | null = null;
56
+ afterEach(async () => {
57
+ if (env) await env.dispose();
58
+ env = null;
59
+ });
60
+
61
+ test("double subscribeReactive + single unsubscribeReactive keeps the sub alive", async () => {
62
+ // Mimics React StrictMode: mount calls subscribeReactive twice
63
+ // with the same sub_id, unmount calls unsubscribeReactive twice.
64
+ // The second unsubscribe early-returns (handler already removed)
65
+ // so we only count ONE effective unsubscribe per mount cycle.
66
+ // After ONE unsubscribe, the sub should still be alive because
67
+ // the owner set hasn't dropped to empty.
68
+ env = createTestEnv();
69
+ env.signIn({ userId: "u1" });
70
+ await env.start();
71
+
72
+ let calls = 0;
73
+ env.engine.subscribeReactive("sub-1", "q", { a: 1 }, () => {
74
+ calls++;
75
+ });
76
+ env.engine.subscribeReactive("sub-1", "q", { a: 1 }, () => {
77
+ calls++;
78
+ });
79
+
80
+ // Internal owner set should have size 1 (self only, idempotent
81
+ // because Set.add). Reach into the SubscriptionCoordinator that
82
+ // the engine delegates to.
83
+ const engine = env.engine as unknown as {
84
+ serverSubs: { has(k: string): boolean };
85
+ subscriptions: {
86
+ reactiveSubOwners: Map<string, Set<string>>;
87
+ };
88
+ };
89
+ expect(engine.subscriptions.reactiveSubOwners.get("sub-1")?.size).toBe(1);
90
+ expect(engine.serverSubs.has("sub-1")).toBe(true);
91
+
92
+ // First unsubscribe: handler removed, owner count → 0 → unsub.
93
+ env.engine.unsubscribeReactive("sub-1");
94
+ expect(engine.subscriptions.reactiveSubOwners.has("sub-1")).toBe(false);
95
+ expect(engine.serverSubs.has("sub-1")).toBe(false);
96
+
97
+ // Second unsubscribe (the StrictMode double-unmount) is a no-op
98
+ // because the handler is already gone — must NOT throw, must
99
+ // not decrement anything further.
100
+ const e = env!;
101
+ expect(() => e.engine.unsubscribeReactive("sub-1")).not.toThrow();
102
+ void calls;
103
+ });
104
+
105
+ test("subscribe with new args re-sends the WS frame", async () => {
106
+ env = createTestEnv();
107
+ env.signIn({ userId: "u1" });
108
+ await env.start();
109
+
110
+ const wsMessages = env.server.receivedWsMessages;
111
+ const before = wsMessages.length;
112
+
113
+ env.engine.subscribeReactive("sub-1", "q", { v: 1 }, () => {});
114
+ await env.flush();
115
+ env.engine.subscribeReactive("sub-1", "q", { v: 2 }, () => {});
116
+ await env.flush();
117
+
118
+ const subs = wsMessages
119
+ .slice(before)
120
+ .filter(
121
+ (m) =>
122
+ typeof m.msg === "object" &&
123
+ m.msg !== null &&
124
+ (m.msg as { type?: string }).type === "reactive-subscribe" &&
125
+ (m.msg as { sub_id?: string }).sub_id === "sub-1",
126
+ );
127
+ // Two subscribe frames — one per arg change. The first was the
128
+ // initial subscribe; the second is the args-update re-send.
129
+ expect(subs.length).toBe(2);
130
+ expect((subs[0].msg as { args: { v: number } }).args.v).toBe(1);
131
+ expect((subs[1].msg as { args: { v: number } }).args.v).toBe(2);
132
+ });
133
+ });
@@ -0,0 +1,94 @@
1
+ // SessionResolver — unit tests for the verdicts the engine consumes.
2
+ // Pins the null→X / X→Y / token-flip rules in one place so the engine
3
+ // doesn't have to re-test them through its full pull/reconcile loop.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ import { SessionResolver, sessionSignature } from "./session-resolver";
8
+
9
+ const anon = { userId: null, tenantId: null, isAdmin: false, roles: [] };
10
+ const u1OrgA = { userId: "u1", tenantId: "org-a", isAdmin: false, roles: [] };
11
+ const u1OrgB = { userId: "u1", tenantId: "org-b", isAdmin: false, roles: [] };
12
+ const u1NoTenant = {
13
+ userId: "u1",
14
+ tenantId: null,
15
+ isAdmin: false,
16
+ roles: [],
17
+ };
18
+
19
+ describe("SessionResolver", () => {
20
+ test("first observation seeds state without invalidating the replica", () => {
21
+ const r = new SessionResolver();
22
+ const v = r.observeSession(u1OrgA);
23
+ expect(v.tenantChanged).toBe(false);
24
+ expect(v.replicaInvalidated).toBe(false);
25
+ expect(v.isFirstResolution).toBe(false);
26
+ expect(v.identityChanged).toBe(true); // null → u1
27
+ expect(r.resolved()).toEqual(u1OrgA);
28
+ });
29
+
30
+ test("null → X flip is first-resolution (pull but NO reset)", () => {
31
+ const r = new SessionResolver();
32
+ r.observeSession(u1NoTenant); // seed lastSeenTenant=null
33
+ const v = r.observeSession(u1OrgA);
34
+ expect(v.tenantChanged).toBe(true);
35
+ expect(v.isFirstResolution).toBe(true);
36
+ expect(v.replicaInvalidated).toBe(false);
37
+ });
38
+
39
+ test("X → Y flip invalidates replica AND requires pull", () => {
40
+ const r = new SessionResolver();
41
+ r.observeSession(u1OrgA);
42
+ const v = r.observeSession(u1OrgB);
43
+ expect(v.tenantChanged).toBe(true);
44
+ expect(v.isFirstResolution).toBe(false);
45
+ expect(v.replicaInvalidated).toBe(true);
46
+ });
47
+
48
+ test("X → null (sign out) invalidates replica", () => {
49
+ const r = new SessionResolver();
50
+ r.observeSession(u1OrgA);
51
+ const v = r.observeSession(anon);
52
+ expect(v.tenantChanged).toBe(true);
53
+ expect(v.isFirstResolution).toBe(false);
54
+ expect(v.replicaInvalidated).toBe(true);
55
+ });
56
+
57
+ test("identical session is a no-op", () => {
58
+ const r = new SessionResolver();
59
+ r.observeSession(u1OrgA);
60
+ const v = r.observeSession({ ...u1OrgA });
61
+ expect(v.identityChanged).toBe(false);
62
+ expect(v.tenantChanged).toBe(false);
63
+ expect(v.replicaInvalidated).toBe(false);
64
+ });
65
+
66
+ test("role change is identity-only (no tenant flip, no reset)", () => {
67
+ const r = new SessionResolver();
68
+ r.observeSession(u1OrgA);
69
+ const v = r.observeSession({ ...u1OrgA, roles: ["editor"] });
70
+ expect(v.identityChanged).toBe(true);
71
+ expect(v.tenantChanged).toBe(false);
72
+ expect(v.replicaInvalidated).toBe(false);
73
+ });
74
+
75
+ test("token flip detection", () => {
76
+ const r = new SessionResolver();
77
+ expect(r.observeToken("tok-a").tokenChanged).toBe(false); // first observation
78
+ expect(r.observeToken("tok-a").tokenChanged).toBe(false);
79
+ expect(r.observeToken("tok-b").tokenChanged).toBe(true);
80
+ expect(r.observeToken("tok-b").tokenChanged).toBe(false);
81
+ expect(r.observeToken(null).tokenChanged).toBe(true); // sign-out
82
+ });
83
+
84
+ test("signature changes when any field changes", () => {
85
+ expect(sessionSignature(u1OrgA)).not.toBe(sessionSignature(u1OrgB));
86
+ expect(sessionSignature(u1OrgA)).not.toBe(
87
+ sessionSignature({ ...u1OrgA, roles: ["editor"] }),
88
+ );
89
+ // Role order doesn't affect the signature (sorted before joining).
90
+ expect(
91
+ sessionSignature({ ...u1OrgA, roles: ["a", "b"] }),
92
+ ).toBe(sessionSignature({ ...u1OrgA, roles: ["b", "a"] }));
93
+ });
94
+ });
@@ -0,0 +1,133 @@
1
+ // SessionResolver — the engine's identity state machine.
2
+ //
3
+ // Before extraction, four pieces of state (`_resolvedSession`,
4
+ // `lastSeenToken`, `lastSeenTenant`, `sessionSignature`) and three
5
+ // branching rules (null→X first-resolution skip, X→Y reset, token-flip
6
+ // reset) were sprinkled across four methods in index.ts. Each call site
7
+ // re-implemented the comparison. This module collects the rules into
8
+ // one place so the engine just feeds observations in and reads back
9
+ // verdicts.
10
+ //
11
+ // No engine dependencies — pure state. Unit-tested in isolation.
12
+
13
+ import type { ResolvedSession } from "./types";
14
+
15
+ /** Verdict returned from `observeSession`. The engine acts on these
16
+ * three booleans; the resolver makes no decisions about side effects
17
+ * (replica reset, store notify, pull) — those belong upstream. */
18
+ export interface SessionTransition {
19
+ /** Resolved session differs in any field (userId / tenantId / isAdmin /
20
+ * roles). When true, the engine should notify subscribers. */
21
+ identityChanged: boolean;
22
+ /** The tenant moved AND it isn't the null→X first-resolution case —
23
+ * cached rows belong to a different tenant and the replica should
24
+ * be wiped before the next pull. */
25
+ replicaInvalidated: boolean;
26
+ /** First time tenant flipped from null to a real value (the engine
27
+ * started before /api/auth/select-org landed). The cached rows in
28
+ * this case ARE for the new tenant; reset would tombstone valid
29
+ * state. Engine should pull but NOT reset. */
30
+ isFirstResolution: boolean;
31
+ /** Tenant value moved at all (covers both null→X and X→Y). When
32
+ * true, the engine should pull regardless of reset semantics —
33
+ * cached rows under the old tenant won't include rows added under
34
+ * the new one. */
35
+ tenantChanged: boolean;
36
+ }
37
+
38
+ export interface TokenTransition {
39
+ /** Token flipped since the last observation. Engine should reset
40
+ * the replica because the visible set changed under a new identity. */
41
+ tokenChanged: boolean;
42
+ }
43
+
44
+ const EMPTY_SESSION: ResolvedSession = {
45
+ userId: null,
46
+ tenantId: null,
47
+ isAdmin: false,
48
+ roles: [],
49
+ };
50
+
51
+ export class SessionResolver {
52
+ private _resolved: ResolvedSession = EMPTY_SESSION;
53
+ /** `undefined` until the first observation — distinguishes "we've
54
+ * never seen a token" from "the token is null." Same for tenant. */
55
+ private lastSeenToken: string | null | undefined = undefined;
56
+ private lastSeenTenant: string | null | undefined = undefined;
57
+
58
+ /** Current resolved session — what `useSession` consumers should see. */
59
+ resolved(): ResolvedSession {
60
+ return this._resolved;
61
+ }
62
+
63
+ /** Stable string signature of the current session. Captured before
64
+ * long-running ops (reconcile, in particular) so the op can detect
65
+ * if anything changed during its in-flight period and bail. */
66
+ signature(): string {
67
+ return sessionSignature(this._resolved);
68
+ }
69
+
70
+ /** Compute the verdict for a freshly resolved session WITHOUT
71
+ * mutating internal state. The engine inspects the verdict to
72
+ * decide whether to reset the replica + pull, then calls
73
+ * `commitObservation(next)` once it's safe for `useSession`
74
+ * subscribers to see the new tenant.
75
+ *
76
+ * Splitting compute from commit prevents the "useSession reports
77
+ * new tenant + useQuery shows old tenant's rows" inconsistency
78
+ * window that existed when the resolved session was mutated
79
+ * before `resetReplica()` finished. */
80
+ inspectSession(next: ResolvedSession): SessionTransition {
81
+ const prev = this._resolved;
82
+ const tenantNow = next.tenantId;
83
+
84
+ const identityChanged = sessionSignature(prev) !== sessionSignature(next);
85
+ const tenantChanged =
86
+ this.lastSeenTenant !== undefined && this.lastSeenTenant !== tenantNow;
87
+ const isFirstResolution =
88
+ this.lastSeenTenant === null && tenantNow !== null;
89
+ const replicaInvalidated = tenantChanged && !isFirstResolution;
90
+
91
+ return {
92
+ identityChanged,
93
+ replicaInvalidated,
94
+ isFirstResolution,
95
+ tenantChanged,
96
+ };
97
+ }
98
+
99
+ /** Commit a previously-inspected session as the current truth.
100
+ * Engine calls this AFTER acting on the verdict (replica reset,
101
+ * pull) so subscribers never observe a half-applied transition. */
102
+ commitObservation(next: ResolvedSession): void {
103
+ this.lastSeenTenant = next.tenantId;
104
+ this._resolved = next;
105
+ }
106
+
107
+ /** Convenience for tests / migration: inspect + commit in one call.
108
+ * Production callers should use `inspectSession` + `commitObservation`
109
+ * to control the timing of state mutation. */
110
+ observeSession(next: ResolvedSession): SessionTransition {
111
+ const verdict = this.inspectSession(next);
112
+ this.commitObservation(next);
113
+ return verdict;
114
+ }
115
+
116
+ /** Feed in the current bearer token. Returns whether it flipped
117
+ * since the previous observation. The engine uses this in pull()
118
+ * to decide whether to reset before reading the cursor. */
119
+ observeToken(token: string | null): TokenTransition {
120
+ const tokenChanged =
121
+ this.lastSeenToken !== undefined && this.lastSeenToken !== token;
122
+ this.lastSeenToken = token;
123
+ return { tokenChanged };
124
+ }
125
+ }
126
+
127
+ /** Stable signature of a resolved session. Used by reconcile to detect
128
+ * mid-fetch session flips. Roles array is sorted+joined so insertion
129
+ * order doesn't trip the equality check. */
130
+ export function sessionSignature(s: ResolvedSession): string {
131
+ const roles = (s.roles ?? []).slice().sort().join(",");
132
+ return `${s.userId ?? ""}|${s.tenantId ?? ""}|${s.isAdmin ? "1" : "0"}|${roles}`;
133
+ }