@pylonsync/sync 0.3.202 → 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 +732 -661
- 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,191 @@
|
|
|
1
|
+
// createTestEnv() — the scenario harness for the JS sync engine.
|
|
2
|
+
//
|
|
3
|
+
// Composes TestServer + transport mocks + a SyncEngine so tests
|
|
4
|
+
// can read like a story:
|
|
5
|
+
//
|
|
6
|
+
// const env = createTestEnv();
|
|
7
|
+
// env.server.seed("Recording", [{ id: "r1", orgId: "org-a" }]);
|
|
8
|
+
// env.signIn({ userId: "u1", tenantId: null });
|
|
9
|
+
// await env.start();
|
|
10
|
+
// expect(env.engine.store.list("Recording")).toHaveLength(1);
|
|
11
|
+
// env.server.setTenant(env.token, "org-a");
|
|
12
|
+
// await env.flush();
|
|
13
|
+
// expect(env.engine.store.list("Recording")).toHaveLength(1);
|
|
14
|
+
//
|
|
15
|
+
// The harness owns three layered concerns:
|
|
16
|
+
// 1. TestServer — canonical state + change log.
|
|
17
|
+
// 2. Transport — fetch + WebSocket mocks routing to the server.
|
|
18
|
+
// 3. SyncEngine — the real, unmodified engine, pointed at the
|
|
19
|
+
// mocks above.
|
|
20
|
+
//
|
|
21
|
+
// Everything is in-process. No real network, no IndexedDB (engine
|
|
22
|
+
// runs with persist:false). Time is real but flush() drains
|
|
23
|
+
// microtasks + the engine's internal sleeps so most scenarios feel
|
|
24
|
+
// synchronous.
|
|
25
|
+
|
|
26
|
+
import { SyncEngine } from "../index";
|
|
27
|
+
import type { Storage } from "../storage";
|
|
28
|
+
import { TestServer, type TestServerOptions, type VisibilityFilter } from "./server";
|
|
29
|
+
import { installTransport, type TransportHandle } from "./transport";
|
|
30
|
+
|
|
31
|
+
/** Bare in-memory storage adapter — same shape as the localStorage
|
|
32
|
+
* one but isolated per test so signIn() can plant a token the engine
|
|
33
|
+
* picks up via currentToken(). */
|
|
34
|
+
class MemoryStorage implements Storage {
|
|
35
|
+
private map = new Map<string, string>();
|
|
36
|
+
get(key: string): string | null {
|
|
37
|
+
return this.map.get(key) ?? null;
|
|
38
|
+
}
|
|
39
|
+
set(key: string, value: string): void {
|
|
40
|
+
this.map.set(key, value);
|
|
41
|
+
}
|
|
42
|
+
remove(key: string): void {
|
|
43
|
+
this.map.delete(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CreateTestEnvOptions {
|
|
48
|
+
/** Override visibility per-entity (tenant scoping, RLS, etc.). */
|
|
49
|
+
visible?: VisibilityFilter;
|
|
50
|
+
/** Override the appName the engine uses for storage keys. */
|
|
51
|
+
appName?: string;
|
|
52
|
+
/** Mid-fetch hook fired before /api/entities/<E>/cursor responds.
|
|
53
|
+
* Lets a scenario flip server state to drive the reconcile
|
|
54
|
+
* session-guard race. */
|
|
55
|
+
beforeListEntityRows?: import("./server").BeforeListEntityHook;
|
|
56
|
+
/** Mid-fetch hook fired before /api/sync/pull responds. */
|
|
57
|
+
beforePull?: import("./server").BeforePullHook;
|
|
58
|
+
/** Field projector — strips serverOnly-style fields before the
|
|
59
|
+
* row leaves the server. */
|
|
60
|
+
projectRow?: import("./server").FieldProjector;
|
|
61
|
+
/** Transport mode passed to the engine. Default "websocket". Use
|
|
62
|
+
* "poll" to disable the WS-onopen reconcile race in scenarios
|
|
63
|
+
* that only want to pin the in-start pipeline. */
|
|
64
|
+
transport?: "websocket" | "poll" | "sse";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TestEnv {
|
|
68
|
+
server: TestServer;
|
|
69
|
+
engine: SyncEngine;
|
|
70
|
+
transport: TransportHandle;
|
|
71
|
+
/** Token of the most recent signIn() call. */
|
|
72
|
+
token: string | undefined;
|
|
73
|
+
/** Mint a session on the server AND tell the transport to send it
|
|
74
|
+
* as Authorization: Bearer on future requests. Returns the token. */
|
|
75
|
+
signIn(input: {
|
|
76
|
+
userId: string | null;
|
|
77
|
+
tenantId?: string | null;
|
|
78
|
+
isAdmin?: boolean;
|
|
79
|
+
roles?: string[];
|
|
80
|
+
}): string;
|
|
81
|
+
/** Re-stamp tenant on the active token (mirrors select-org). */
|
|
82
|
+
selectOrg(tenantId: string | null): void;
|
|
83
|
+
/** Sign out: drops the active token. The engine still has its
|
|
84
|
+
* cached resolved session until something forces a refresh. */
|
|
85
|
+
signOut(): void;
|
|
86
|
+
/** Boot the engine and wait for the initial pull + hydration. */
|
|
87
|
+
start(): Promise<void>;
|
|
88
|
+
/** Drain pending microtasks + give the engine a moment to react
|
|
89
|
+
* to recent events (WS messages, session-changed envelopes, etc.).
|
|
90
|
+
* Most scenarios call this between mutations + assertions. */
|
|
91
|
+
flush(ms?: number): Promise<void>;
|
|
92
|
+
/** Tear down: stops the engine, restores global fetch/WebSocket. */
|
|
93
|
+
dispose(): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createTestEnv(opts: CreateTestEnvOptions = {}): TestEnv {
|
|
97
|
+
const server = new TestServer({
|
|
98
|
+
visible: opts.visible,
|
|
99
|
+
beforeListEntityRows: opts.beforeListEntityRows,
|
|
100
|
+
beforePull: opts.beforePull,
|
|
101
|
+
projectRow: opts.projectRow,
|
|
102
|
+
} as TestServerOptions);
|
|
103
|
+
const transport = installTransport(server);
|
|
104
|
+
// Per-scenario in-memory storage so signIn() can plant a token the
|
|
105
|
+
// engine actually reads via currentToken(). Without this the engine
|
|
106
|
+
// falls back to the default localStorage adapter (or no-op outside
|
|
107
|
+
// a browser), which means every fetch goes anonymous and tenant-
|
|
108
|
+
// scoped tests can't distinguish "right session" from "no session."
|
|
109
|
+
const storage = new MemoryStorage();
|
|
110
|
+
const appName = opts.appName ?? "harness";
|
|
111
|
+
const tokenStorageKey =
|
|
112
|
+
appName === "default" ? "pylon_token" : `pylon:${appName}:token`;
|
|
113
|
+
const engine = new SyncEngine({
|
|
114
|
+
baseUrl: "http://test.invalid",
|
|
115
|
+
appName,
|
|
116
|
+
persist: false,
|
|
117
|
+
storage,
|
|
118
|
+
transport: opts.transport ?? "websocket",
|
|
119
|
+
// Tight timings so scenarios don't have to sleep seconds. The
|
|
120
|
+
// engine's reconcile debounce is also relaxed so back-to-back
|
|
121
|
+
// visibility-change triggers don't get coalesced away in tests.
|
|
122
|
+
reconcileMinIntervalMs: 0,
|
|
123
|
+
// Disable multi-tab coordination by default — each scenario is a
|
|
124
|
+
// single isolated engine, and the broker's 400ms settle window
|
|
125
|
+
// would add seconds of wait across the suite. Scenarios that need
|
|
126
|
+
// the multi-tab path opt in by creating two envs with a shared
|
|
127
|
+
// appName + multiTab:true.
|
|
128
|
+
multiTab: false,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let token: string | undefined;
|
|
132
|
+
|
|
133
|
+
const env: TestEnv = {
|
|
134
|
+
server,
|
|
135
|
+
engine,
|
|
136
|
+
transport,
|
|
137
|
+
get token() {
|
|
138
|
+
return token;
|
|
139
|
+
},
|
|
140
|
+
signIn(input) {
|
|
141
|
+
token = server.signIn(input);
|
|
142
|
+
transport.setToken(token);
|
|
143
|
+
// Plant the token in the engine's storage adapter so
|
|
144
|
+
// currentToken() returns it on the very next fetch — matches
|
|
145
|
+
// what `pylon login` / cookie-set / persistSession() do in a
|
|
146
|
+
// real browser.
|
|
147
|
+
storage.set(tokenStorageKey, token);
|
|
148
|
+
return token;
|
|
149
|
+
},
|
|
150
|
+
selectOrg(tenantId) {
|
|
151
|
+
if (!token) throw new Error("selectOrg requires a prior signIn()");
|
|
152
|
+
server.setTenant(token, tenantId);
|
|
153
|
+
},
|
|
154
|
+
signOut() {
|
|
155
|
+
token = undefined;
|
|
156
|
+
transport.setToken(undefined);
|
|
157
|
+
storage.remove(tokenStorageKey);
|
|
158
|
+
},
|
|
159
|
+
async start() {
|
|
160
|
+
await engine.start();
|
|
161
|
+
await this.flush();
|
|
162
|
+
},
|
|
163
|
+
async flush(ms = 25) {
|
|
164
|
+
// Two passes: first drain microtasks so any chain reactions
|
|
165
|
+
// (WS receive → notify → re-pull) get a chance to execute,
|
|
166
|
+
// then a small real-time sleep to let the engine's internal
|
|
167
|
+
// timers fire (e.g., reconnect backoff). 25ms is plenty for
|
|
168
|
+
// anything that isn't a deliberate test of long-running poll
|
|
169
|
+
// cadence; bump it via the arg when a scenario needs more.
|
|
170
|
+
for (let i = 0; i < 4; i++) {
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
}
|
|
173
|
+
if (ms > 0) {
|
|
174
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
175
|
+
}
|
|
176
|
+
for (let i = 0; i < 4; i++) {
|
|
177
|
+
await Promise.resolve();
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
async dispose() {
|
|
181
|
+
try {
|
|
182
|
+
engine.stop?.();
|
|
183
|
+
} catch {
|
|
184
|
+
/* stop is best-effort */
|
|
185
|
+
}
|
|
186
|
+
transport.restore();
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return env;
|
|
191
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Public surface of the sync test harness. Re-export the pieces
|
|
2
|
+
// tests typically reach for so a `import { createTestEnv } from
|
|
3
|
+
// "../test-harness"` covers 90% of usage.
|
|
4
|
+
|
|
5
|
+
export { createTestEnv } from "./env";
|
|
6
|
+
export type { CreateTestEnvOptions, TestEnv } from "./env";
|
|
7
|
+
|
|
8
|
+
export { TestServer, defaultVisibilityFilter } from "./server";
|
|
9
|
+
export type {
|
|
10
|
+
AuthContext,
|
|
11
|
+
ServerSession,
|
|
12
|
+
TestServerOptions,
|
|
13
|
+
VisibilityFilter,
|
|
14
|
+
} from "./server";
|
|
15
|
+
|
|
16
|
+
export type { TransportHandle } from "./transport";
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// In-memory "server" for the sync test harness. Holds the canonical
|
|
2
|
+
// row set + session state and produces the JSON responses the engine
|
|
3
|
+
// expects from /api/auth/me, /api/sync/pull, /api/entities/<E>/cursor,
|
|
4
|
+
// and /api/sync/push.
|
|
5
|
+
//
|
|
6
|
+
// Intentionally tiny — every interesting case is expressed in test
|
|
7
|
+
// code (`server.insert(...)`, `server.setTenant(...)`) rather than
|
|
8
|
+
// behind layers of stubs. The cost: no policy DSL, no SQL query
|
|
9
|
+
// language. We model "this row is visible to this caller" as
|
|
10
|
+
// `visibleRows(entity, auth)` — the test seeds rows and decides what
|
|
11
|
+
// the caller can see for each scenario.
|
|
12
|
+
|
|
13
|
+
import type { ChangeEvent, Row, SyncCursor } from "../types";
|
|
14
|
+
|
|
15
|
+
export interface ServerSession {
|
|
16
|
+
token: string;
|
|
17
|
+
userId: string | null;
|
|
18
|
+
tenantId: string | null;
|
|
19
|
+
isAdmin: boolean;
|
|
20
|
+
roles: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AuthContext {
|
|
24
|
+
userId: string | null;
|
|
25
|
+
tenantId: string | null;
|
|
26
|
+
isAdmin: boolean;
|
|
27
|
+
roles: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VisibilityFilter {
|
|
31
|
+
/** Return the subset of `rows` the given auth context can see for
|
|
32
|
+
* this entity. Default: every row, so tests that don't care about
|
|
33
|
+
* policy just pass. Tests that DO care can pass a custom filter to
|
|
34
|
+
* exercise the "session changed mid-fetch" path. */
|
|
35
|
+
(entity: string, rows: Row[], auth: AuthContext): Row[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const defaultVisibilityFilter: VisibilityFilter = (_e, rows) => rows;
|
|
39
|
+
|
|
40
|
+
/** Hook fired right before `/api/entities/<E>/cursor` serializes its
|
|
41
|
+
* response. Lets a scenario flip server state (e.g., `setTenant`) so
|
|
42
|
+
* the engine sees a different session signature on apply than on
|
|
43
|
+
* fetch — the canonical race the reconcile session-guard pins.
|
|
44
|
+
* Receives the same auth context the visibility filter uses.
|
|
45
|
+
* Returning a Promise makes the fetch await — useful for landing a
|
|
46
|
+
* session refresh in flight before the response serializes. */
|
|
47
|
+
export type BeforeListEntityHook = (
|
|
48
|
+
entity: string,
|
|
49
|
+
auth: AuthContext,
|
|
50
|
+
) => void | Promise<void>;
|
|
51
|
+
|
|
52
|
+
/** Hook fired right before `/api/sync/pull` serializes its response.
|
|
53
|
+
* Same role as `beforeListEntityRows` but on the pull path. */
|
|
54
|
+
export type BeforePullHook = (
|
|
55
|
+
auth: AuthContext,
|
|
56
|
+
since: number,
|
|
57
|
+
) => void | Promise<void>;
|
|
58
|
+
|
|
59
|
+
/** Field projector — strips fields from a row before it leaves the
|
|
60
|
+
* server. Models the `serverOnly` projection in production: the
|
|
61
|
+
* field exists in the canonical row but is never wired to a client. */
|
|
62
|
+
export type FieldProjector = (entity: string, row: Row) => Row;
|
|
63
|
+
|
|
64
|
+
export const identityProjector: FieldProjector = (_e, row) => row;
|
|
65
|
+
|
|
66
|
+
export interface TestServerOptions {
|
|
67
|
+
/** Override visibility per-entity (tenant scoping, RLS, etc.). */
|
|
68
|
+
visible?: VisibilityFilter;
|
|
69
|
+
/** Mid-fetch hook for the entity-cursor path. */
|
|
70
|
+
beforeListEntityRows?: BeforeListEntityHook;
|
|
71
|
+
/** Mid-fetch hook for the sync-pull path. */
|
|
72
|
+
beforePull?: BeforePullHook;
|
|
73
|
+
/** Strip fields from rows on the way to the wire (mimics serverOnly). */
|
|
74
|
+
projectRow?: FieldProjector;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Subscribers attached to WS connections — receive every change
|
|
78
|
+
* event we append to the log, plus session-changed envelopes. */
|
|
79
|
+
export type ServerSubscriber = (msg: Record<string, unknown>) => void;
|
|
80
|
+
|
|
81
|
+
export class TestServer {
|
|
82
|
+
private sessions = new Map<string, ServerSession>();
|
|
83
|
+
private rows = new Map<string, Map<string, Row>>(); // entity → id → row
|
|
84
|
+
private log: ChangeEvent[] = [];
|
|
85
|
+
private subscribers = new Map<string, Set<ServerSubscriber>>(); // userId → subs
|
|
86
|
+
private visible: VisibilityFilter;
|
|
87
|
+
private beforeListEntityHook?: BeforeListEntityHook;
|
|
88
|
+
private beforePullHook?: BeforePullHook;
|
|
89
|
+
private project: FieldProjector;
|
|
90
|
+
private nextSeq = 0;
|
|
91
|
+
/** When set, the next pull() returns this status instead of normal.
|
|
92
|
+
* Used to simulate 410 RESYNC_REQUIRED and similar transient errors. */
|
|
93
|
+
private nextPullStatus: number | null = null;
|
|
94
|
+
/** Captured outbound WS messages from clients — tests assert against
|
|
95
|
+
* this to verify `reactive-subscribe`, `crdt-subscribe`, etc., were
|
|
96
|
+
* actually sent over the wire. */
|
|
97
|
+
readonly receivedWsMessages: Array<{ userId: string | null; msg: unknown }> = [];
|
|
98
|
+
|
|
99
|
+
constructor(opts: TestServerOptions = {}) {
|
|
100
|
+
this.visible = opts.visible ?? defaultVisibilityFilter;
|
|
101
|
+
this.beforeListEntityHook = opts.beforeListEntityRows;
|
|
102
|
+
this.beforePullHook = opts.beforePull;
|
|
103
|
+
this.project = opts.projectRow ?? identityProjector;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- Session management -------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** Mint a session and return its bearer token. */
|
|
109
|
+
signIn(input: {
|
|
110
|
+
userId: string | null;
|
|
111
|
+
tenantId?: string | null;
|
|
112
|
+
isAdmin?: boolean;
|
|
113
|
+
roles?: string[];
|
|
114
|
+
}): string {
|
|
115
|
+
const token = `tok_${Math.random().toString(36).slice(2, 10)}`;
|
|
116
|
+
this.sessions.set(token, {
|
|
117
|
+
token,
|
|
118
|
+
userId: input.userId,
|
|
119
|
+
tenantId: input.tenantId ?? null,
|
|
120
|
+
isAdmin: input.isAdmin ?? false,
|
|
121
|
+
roles: input.roles ?? [],
|
|
122
|
+
});
|
|
123
|
+
return token;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Re-stamp the tenant on an existing token (analogue of
|
|
127
|
+
* /api/auth/select-org). Fires session-changed to subscribers so
|
|
128
|
+
* the client can refresh its resolved session. */
|
|
129
|
+
setTenant(token: string, tenantId: string | null): void {
|
|
130
|
+
const s = this.sessions.get(token);
|
|
131
|
+
if (!s) return;
|
|
132
|
+
s.tenantId = tenantId;
|
|
133
|
+
// Mirror real Pylon: server pushes session-changed to every
|
|
134
|
+
// subscriber for this user_id so each tab learns.
|
|
135
|
+
if (s.userId) {
|
|
136
|
+
this.broadcastToUser(s.userId, { type: "session-changed" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Mutate fields on an existing session in place. Used by scenarios
|
|
141
|
+
* that need to change the sessionSignature without triggering the
|
|
142
|
+
* engine's tenant-flip resetReplica (e.g., role changes). */
|
|
143
|
+
mutateSession(
|
|
144
|
+
token: string,
|
|
145
|
+
patch: Partial<Omit<ServerSession, "token">>,
|
|
146
|
+
): void {
|
|
147
|
+
const s = this.sessions.get(token);
|
|
148
|
+
if (!s) return;
|
|
149
|
+
Object.assign(s, patch);
|
|
150
|
+
if (s.userId) {
|
|
151
|
+
this.broadcastToUser(s.userId, { type: "session-changed" });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Revoke a session (drop the token from the map). Used by signOut
|
|
156
|
+
* scenarios — subsequent /api/auth/me returns anonymous. */
|
|
157
|
+
revoke(token: string): void {
|
|
158
|
+
const s = this.sessions.get(token);
|
|
159
|
+
this.sessions.delete(token);
|
|
160
|
+
if (s?.userId) {
|
|
161
|
+
this.broadcastToUser(s.userId, { type: "session-changed" });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What /api/auth/me returns for a given token. */
|
|
166
|
+
authContextFor(token: string | undefined): AuthContext {
|
|
167
|
+
const s = token ? this.sessions.get(token) : undefined;
|
|
168
|
+
if (!s) return { userId: null, tenantId: null, isAdmin: false, roles: [] };
|
|
169
|
+
return {
|
|
170
|
+
userId: s.userId,
|
|
171
|
+
tenantId: s.tenantId,
|
|
172
|
+
isAdmin: s.isAdmin,
|
|
173
|
+
roles: s.roles,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Inject a 410 into the next pull. Engine should resetReplica and
|
|
178
|
+
* re-pull from seq=0; the second pull responds normally. */
|
|
179
|
+
primeNextPullStatus(status: number): void {
|
|
180
|
+
this.nextPullStatus = status;
|
|
181
|
+
}
|
|
182
|
+
consumeNextPullStatus(): number | null {
|
|
183
|
+
const s = this.nextPullStatus;
|
|
184
|
+
this.nextPullStatus = null;
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- Entity data --------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/** Bulk-seed rows for an entity AND emit insert events into the
|
|
191
|
+
* change log so the engine discovers them via /api/sync/pull at
|
|
192
|
+
* since=0 — same shape production clients see when they boot
|
|
193
|
+
* against a populated server. Without logging, the rows would only
|
|
194
|
+
* be discoverable via reconcile, which doesn't fire on start. */
|
|
195
|
+
seed(entity: string, rows: Row[]): void {
|
|
196
|
+
let map = this.rows.get(entity);
|
|
197
|
+
if (!map) {
|
|
198
|
+
map = new Map();
|
|
199
|
+
this.rows.set(entity, map);
|
|
200
|
+
}
|
|
201
|
+
for (const r of rows) {
|
|
202
|
+
const id = (r as { id?: string }).id;
|
|
203
|
+
if (typeof id !== "string") continue;
|
|
204
|
+
map.set(id, r);
|
|
205
|
+
this.log.push({
|
|
206
|
+
seq: this.bumpSeq(),
|
|
207
|
+
entity,
|
|
208
|
+
row_id: id,
|
|
209
|
+
kind: "insert",
|
|
210
|
+
data: r,
|
|
211
|
+
timestamp: new Date().toISOString(),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
insert(entity: string, row: Row): void {
|
|
217
|
+
const id = (row as { id?: string }).id;
|
|
218
|
+
if (typeof id !== "string") throw new Error("insert needs row.id");
|
|
219
|
+
let map = this.rows.get(entity);
|
|
220
|
+
if (!map) {
|
|
221
|
+
map = new Map();
|
|
222
|
+
this.rows.set(entity, map);
|
|
223
|
+
}
|
|
224
|
+
map.set(id, row);
|
|
225
|
+
this.appendLog({
|
|
226
|
+
seq: this.bumpSeq(),
|
|
227
|
+
entity,
|
|
228
|
+
row_id: id,
|
|
229
|
+
kind: "insert",
|
|
230
|
+
data: row,
|
|
231
|
+
timestamp: new Date().toISOString(),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
update(entity: string, id: string, patch: Partial<Row>): void {
|
|
236
|
+
const map = this.rows.get(entity);
|
|
237
|
+
if (!map) return;
|
|
238
|
+
const prev = map.get(id);
|
|
239
|
+
if (!prev) return;
|
|
240
|
+
const next = { ...prev, ...patch } as Row;
|
|
241
|
+
map.set(id, next);
|
|
242
|
+
this.appendLog({
|
|
243
|
+
seq: this.bumpSeq(),
|
|
244
|
+
entity,
|
|
245
|
+
row_id: id,
|
|
246
|
+
kind: "update",
|
|
247
|
+
data: next,
|
|
248
|
+
timestamp: new Date().toISOString(),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
delete(entity: string, id: string): void {
|
|
253
|
+
const map = this.rows.get(entity);
|
|
254
|
+
if (!map) return;
|
|
255
|
+
const prev = map.get(id);
|
|
256
|
+
if (!prev) return;
|
|
257
|
+
map.delete(id);
|
|
258
|
+
this.appendLog(
|
|
259
|
+
{
|
|
260
|
+
seq: this.bumpSeq(),
|
|
261
|
+
entity,
|
|
262
|
+
row_id: id,
|
|
263
|
+
kind: "delete",
|
|
264
|
+
data: { id } as Row,
|
|
265
|
+
timestamp: new Date().toISOString(),
|
|
266
|
+
},
|
|
267
|
+
prev,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Raw seq bump for tests that want to inject events directly. */
|
|
272
|
+
nextSeqValue(): number {
|
|
273
|
+
return this.bumpSeq();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---- Read paths the engine calls ----------------------------------------
|
|
277
|
+
|
|
278
|
+
/** /api/entities/<entity>/cursor — policy-filtered list.
|
|
279
|
+
* Async so the `beforeListEntityRows` hook can await state changes
|
|
280
|
+
* (e.g., land a session refresh mid-fetch). Auth is re-read AFTER
|
|
281
|
+
* the hook so a hook that flips roles / tenant takes effect on
|
|
282
|
+
* the response the engine sees, matching what a real server does
|
|
283
|
+
* when the session mutates mid-request. */
|
|
284
|
+
async listEntityRows(entity: string, token: string | undefined): Promise<Row[]> {
|
|
285
|
+
if (this.beforeListEntityHook) {
|
|
286
|
+
const earlyAuth = this.authContextFor(token);
|
|
287
|
+
await this.beforeListEntityHook(entity, earlyAuth);
|
|
288
|
+
}
|
|
289
|
+
const auth = this.authContextFor(token);
|
|
290
|
+
const all = Array.from(this.rows.get(entity)?.values() ?? []);
|
|
291
|
+
return this.visible(entity, all, auth).map((r) => this.project(entity, r));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** /api/sync/pull — every visible change since `since`. */
|
|
295
|
+
async pull(token: string | undefined, since: number): Promise<{
|
|
296
|
+
changes: ChangeEvent[];
|
|
297
|
+
cursor: SyncCursor;
|
|
298
|
+
has_more: boolean;
|
|
299
|
+
}> {
|
|
300
|
+
const auth = this.authContextFor(token);
|
|
301
|
+
if (this.beforePullHook) await this.beforePullHook(auth, since);
|
|
302
|
+
const visibleSet = (entity: string) => {
|
|
303
|
+
const filtered = this.visible(
|
|
304
|
+
entity,
|
|
305
|
+
Array.from(this.rows.get(entity)?.values() ?? []),
|
|
306
|
+
auth,
|
|
307
|
+
);
|
|
308
|
+
return new Set(
|
|
309
|
+
filtered.map((r) => (r as { id?: string }).id).filter(Boolean) as string[],
|
|
310
|
+
);
|
|
311
|
+
};
|
|
312
|
+
const changes: ChangeEvent[] = [];
|
|
313
|
+
for (const ev of this.log) {
|
|
314
|
+
if (ev.seq <= since) continue;
|
|
315
|
+
const visibleIds = visibleSet(ev.entity);
|
|
316
|
+
// For inserts / updates, only deliver if the row is currently
|
|
317
|
+
// visible. For deletes, deliver the tombstone only when the
|
|
318
|
+
// caller could see the row before deletion — otherwise we leak
|
|
319
|
+
// existence of rows the caller never had access to.
|
|
320
|
+
if (ev.kind === "delete") {
|
|
321
|
+
// Re-evaluate visibility against the pre-delete data, captured
|
|
322
|
+
// on the ChangeEvent at append time. The current `rows` map
|
|
323
|
+
// no longer has the row, so we can't recompute from there.
|
|
324
|
+
const prev = (ev as ChangeEvent & { prev_data?: Row }).prev_data;
|
|
325
|
+
if (prev) {
|
|
326
|
+
const visiblePrev = this.visible(ev.entity, [prev], auth);
|
|
327
|
+
if (visiblePrev.length === 0) continue;
|
|
328
|
+
}
|
|
329
|
+
changes.push(ev);
|
|
330
|
+
} else {
|
|
331
|
+
if (!visibleIds.has(ev.row_id)) continue;
|
|
332
|
+
// Project the row on the way out so serverOnly-style fields
|
|
333
|
+
// never reach the wire. Same path the WS broadcast applies.
|
|
334
|
+
changes.push({
|
|
335
|
+
...ev,
|
|
336
|
+
data: this.project(ev.entity, ev.data as Row),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
changes,
|
|
342
|
+
cursor: { last_seq: this.nextSeq },
|
|
343
|
+
has_more: false,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- WS push ------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
subscribe(userId: string, sub: ServerSubscriber): () => void {
|
|
350
|
+
let set = this.subscribers.get(userId);
|
|
351
|
+
if (!set) {
|
|
352
|
+
set = new Set();
|
|
353
|
+
this.subscribers.set(userId, set);
|
|
354
|
+
}
|
|
355
|
+
set.add(sub);
|
|
356
|
+
return () => {
|
|
357
|
+
set?.delete(sub);
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Push an arbitrary envelope to every subscriber for a user — used
|
|
362
|
+
* for `reactive-result`, `row-revoked`, `session-changed` etc. */
|
|
363
|
+
pushToUser(userId: string, msg: Record<string, unknown>): void {
|
|
364
|
+
this.broadcastToUser(userId, msg);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Record an outbound WS message from a client (subscribe / unsub /
|
|
368
|
+
* ping). Tests can assert against `receivedWsMessages` to verify
|
|
369
|
+
* the engine actually sent something over the wire. */
|
|
370
|
+
recordClientWsMessage(token: string | undefined, msg: unknown): void {
|
|
371
|
+
const auth = this.authContextFor(token);
|
|
372
|
+
this.receivedWsMessages.push({ userId: auth.userId, msg });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private broadcastToUser(userId: string, msg: Record<string, unknown>): void {
|
|
376
|
+
const subs = this.subscribers.get(userId);
|
|
377
|
+
if (!subs) return;
|
|
378
|
+
for (const sub of subs) sub(msg);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---- Internals ----------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
private bumpSeq(): number {
|
|
384
|
+
this.nextSeq += 1;
|
|
385
|
+
return this.nextSeq;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private appendLog(ev: ChangeEvent, prevForDelete?: Row): void {
|
|
389
|
+
// Stash the pre-delete row on the event so pull() and WS broadcast
|
|
390
|
+
// can evaluate visibility against the data the caller used to see.
|
|
391
|
+
if (ev.kind === "delete" && prevForDelete) {
|
|
392
|
+
(ev as ChangeEvent & { prev_data?: Row }).prev_data = prevForDelete;
|
|
393
|
+
}
|
|
394
|
+
this.log.push(ev);
|
|
395
|
+
// Per-user fanout: each subscribed user gets the event ONLY when
|
|
396
|
+
// their auth context can see the affected row. Inserts/updates use
|
|
397
|
+
// the row's current data; deletes use prev_data (the row the user
|
|
398
|
+
// used to see). Matches the real broadcast layer in pylon-cloud
|
|
399
|
+
// which strips invisible deletes so a tenant boundary can't leak
|
|
400
|
+
// "row X was deleted" to a user that never saw row X.
|
|
401
|
+
const prev = (ev as ChangeEvent & { prev_data?: Row }).prev_data;
|
|
402
|
+
const dataForVisibility = (ev.kind === "delete" ? prev : ev.data) as
|
|
403
|
+
| Row
|
|
404
|
+
| undefined;
|
|
405
|
+
if (!dataForVisibility) return;
|
|
406
|
+
for (const [userId, subs] of this.subscribers) {
|
|
407
|
+
const session = Array.from(this.sessions.values()).find(
|
|
408
|
+
(s) => s.userId === userId,
|
|
409
|
+
);
|
|
410
|
+
const auth = session
|
|
411
|
+
? {
|
|
412
|
+
userId: session.userId,
|
|
413
|
+
tenantId: session.tenantId,
|
|
414
|
+
isAdmin: session.isAdmin,
|
|
415
|
+
roles: session.roles,
|
|
416
|
+
}
|
|
417
|
+
: { userId: null, tenantId: null, isAdmin: false, roles: [] };
|
|
418
|
+
const filtered = this.visible(ev.entity, [dataForVisibility], auth);
|
|
419
|
+
if (filtered.length === 0) continue;
|
|
420
|
+
// Engine expects a flat ChangeEvent on WS — it sniffs
|
|
421
|
+
// `msg.seq && msg.entity && msg.kind` to route. Forward the
|
|
422
|
+
// event verbatim, but strip serverOnly fields the same way the
|
|
423
|
+
// wire path would.
|
|
424
|
+
const projected =
|
|
425
|
+
ev.kind === "delete"
|
|
426
|
+
? ev
|
|
427
|
+
: ({ ...ev, data: this.project(ev.entity, ev.data as Row) } as ChangeEvent);
|
|
428
|
+
for (const sub of subs) {
|
|
429
|
+
sub(projected as unknown as Record<string, unknown>);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|