@pylonsync/sync 0.3.201 → 0.3.203
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 +733 -642
- package/src/local-store.ts +74 -0
- package/src/multi-tab-orchestrator.test.ts +173 -0
- package/src/multi-tab-orchestrator.ts +366 -0
- package/src/multi-tab.test.ts +196 -0
- package/src/multi-tab.ts +366 -0
- package/src/mutation-queue.ts +12 -2
- package/src/op-queue.test.ts +91 -0
- package/src/op-queue.ts +73 -0
- package/src/reconcile.test.ts +31 -33
- package/src/round6-codex.test.ts +328 -0
- package/src/scenarios.test.ts +606 -0
- package/src/server-subscriptions.test.ts +99 -0
- package/src/server-subscriptions.ts +78 -0
- package/src/session-chain.test.ts +133 -0
- package/src/session-resolver.test.ts +94 -0
- package/src/session-resolver.ts +133 -0
- package/src/subscription-coordinator.test.ts +209 -0
- package/src/subscription-coordinator.ts +471 -0
- package/src/test-harness/env.ts +191 -0
- package/src/test-harness/index.ts +16 -0
- package/src/test-harness/server.ts +433 -0
- package/src/test-harness/transport.ts +256 -0
- package/src/transports/factory.test.ts +87 -0
- package/src/transports/index.ts +42 -0
- package/src/transports/polling.test.ts +102 -0
- package/src/transports/polling.ts +63 -0
- package/src/transports/reconnect.test.ts +57 -0
- package/src/transports/reconnect.ts +50 -0
- package/src/transports/sse.ts +140 -0
- package/src/transports/types.ts +116 -0
- package/src/transports/websocket.test.ts +310 -0
- package/src/transports/websocket.ts +222 -0
|
@@ -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
|
+
}
|