@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,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
+ }