@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.1

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,404 @@
1
+ // Cross-instance pub/sub for real-time WS broadcasts. The triage-sync
2
+ // fan-out is an in-memory subscriber map (server/hub.ts) by design — it
3
+ // routes a commit only to peers on the SAME instance. A multi-instance
4
+ // deployment behind a load balancer needs commit-landed-on-A to reach
5
+ // peers-on-B with the same latency the hub gives same-instance peers.
6
+ //
7
+ // SQLite mode is single-process by construction (the local FS objstore
8
+ // can't back two writers), so it ships a no-op PubSub.
9
+ //
10
+ // Neon mode uses Postgres LISTEN/NOTIFY on a dedicated long-lived
11
+ // WebSocket connection (the `Client` form of `@neondatabase/serverless`,
12
+ // session-bound and notification-aware — the HTTP `neon()` callable used
13
+ // for normal queries is stateless and can't LISTEN). Each instance:
14
+ // - At start: opens a Client, LISTENs on the bus channel, dispatches
15
+ // notifications. Reconnects on transport failure with backoff.
16
+ // - On publish*: fire-and-forget `SELECT pg_notify(channel, payload)`.
17
+ // - Filters its own notifications by a per-process random sender id —
18
+ // Postgres delivers NOTIFY back to publishers that LISTEN on the
19
+ // same channel, and a local broadcast already happened before the
20
+ // bus publish, so re-broadcasting our own would echo.
21
+ //
22
+ // Postgres NOTIFY caps the payload at ~8 KB by default (NAMEDATALEN-
23
+ // derived; can't be raised on a managed endpoint). The triage
24
+ // `workspace-state` envelope carries a ciphertext up to MAX_CIPHERTEXT_LEN
25
+ // (2 MiB), so the workspace-revision channel ships only `(tag, revisionId)`
26
+ // and the receiver re-fetches the row from the shared workspace_revision
27
+ // table to construct the wire broadcast. Objstore-put broadcasts likewise
28
+ // ship `(tag, resourceTag)` and the receiver re-fetches from
29
+ // workspace_object. Objstore-deleted broadcasts inline the (tag,
30
+ // resourceTag, version) tuple — the row is gone from the DB, so the
31
+ // payload IS the wire data.
32
+
33
+ import { randomBytes } from 'node:crypto'
34
+ import { errStack } from './util.ts'
35
+
36
+ // One bus channel for all three message kinds; the receiver discriminates
37
+ // on the `kind` field. Single LISTEN keeps the Client wiring trivial and
38
+ // avoids a `kind`-per-channel decision tree. Channel name doubles as the
39
+ // SQL identifier we LISTEN on, so it MUST stay a valid Postgres
40
+ // identifier (no quoting / special chars). Exported so tests stay in
41
+ // sync with the production channel name (one constant, one source).
42
+ export const CHANNEL = 'triage_bus'
43
+
44
+ // Sender id is a per-process random value stamped into every outbound
45
+ // payload so the LISTENing connection on the SAME process can skip its
46
+ // own notifications. 12 bytes / 16 chars base64url — collision odds
47
+ // across any realistic cluster size are astronomical.
48
+ function newSenderId(): string {
49
+ return randomBytes(12).toString('base64url')
50
+ }
51
+
52
+ // JSON-encoded NOTIFY payloads. Each kind documents the minimum info
53
+ // the receiver needs:
54
+ // - 'rev': workspace-state broadcast. `id` is the revision id; the
55
+ // receiver SELECTs the full row by (tag, id) — the payload size
56
+ // budget can't carry the ciphertext.
57
+ // - 'objput': objstore-put broadcast. `res` is the resource tag; the
58
+ // receiver SELECTs the live row by (tag, res) for the rest of the
59
+ // metadata fields.
60
+ // - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
61
+ // — inline because the row is gone from workspace_object after the
62
+ // delete commit.
63
+ export type BusMessage =
64
+ | { kind: 'rev'; tag: string; id: string }
65
+ | { kind: 'objput'; tag: string; res: string }
66
+ | { kind: 'objdel'; tag: string; res: string; ver: number }
67
+
68
+ // Receiver wired up by the hub layer (see server/index.ts). Each handler
69
+ // runs once per remote message; failures are logged but don't crash the
70
+ // LISTEN loop — a missed broadcast surfaces to clients on reconnect
71
+ // (chain re-pull). Async because the workspace-revision handler does a
72
+ // DB lookup before broadcasting.
73
+ export type BusHandler = (msg: BusMessage) => Promise<void>
74
+
75
+ export type PubSub = {
76
+ // Resolves once LISTEN is active (Client connected + LISTEN
77
+ // acknowledged). Implementations should auto-reconnect on transport
78
+ // failure — publishes during the down window drop on the floor.
79
+ start: (onMessage: BusHandler) => Promise<void>
80
+ publish: (msg: BusMessage) => void
81
+ stop: () => Promise<void>
82
+ }
83
+
84
+ export function createNoopPubSub(): PubSub {
85
+ return {
86
+ // eslint-disable-next-line require-await
87
+ start: async () => {},
88
+ publish: () => {},
89
+ // eslint-disable-next-line require-await
90
+ stop: async () => {},
91
+ }
92
+ }
93
+
94
+ // Minimal structural shape of the `Client` form of
95
+ // `@neondatabase/serverless` — pg-compatible, with `notification` events
96
+ // and a connect/end lifecycle. We declare only the surface we touch so
97
+ // the optional peer dep stays optional (no top-level static type
98
+ // imports). The full driver type set is much larger; this slice is what
99
+ // the LISTEN loop relies on.
100
+ export type NeonClient = {
101
+ connect: () => Promise<void>
102
+ query: (text: string, params?: readonly unknown[]) => Promise<unknown>
103
+ end: () => Promise<void>
104
+ on: (event: 'notification', listener: (msg: { channel: string; payload?: string }) => void) => void
105
+ // The driver also emits 'error' on transport failures we need to
106
+ // observe to drive reconnection.
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ once?: (event: string, listener: (...args: any[]) => void) => void
109
+ }
110
+
111
+ export type NeonClientCtor = new (connectionString: string) => NeonClient
112
+
113
+ export type NeonPubSubDeps = {
114
+ // Factory returning a fresh Client. Pulled out as a dep so:
115
+ // (a) the optional peer dep stays optional (callers import lazily),
116
+ // (b) tests can swap in a PGlite-backed shim that exercises the
117
+ // publish + LISTEN loop on a single connection.
118
+ newClient: () => NeonClient
119
+ debug: boolean
120
+ // Initial-connect backoff seed and cap. Defaults are reasonable for
121
+ // production; tests override with small values to keep the suite fast.
122
+ reconnectBaseMs?: number
123
+ reconnectCapMs?: number
124
+ }
125
+
126
+ // Mutable state of one `createNeonPubSub` instance. Held in a single
127
+ // object so the LISTEN-loop helpers (`tryConnect` / `connectAndListen` /
128
+ // `reconnect`) can be defined at module scope rather than nested inside
129
+ // the factory — keeps `createNeonPubSub` itself under the 80-line cap.
130
+ type NeonState = {
131
+ newClient: () => NeonClient
132
+ debug: boolean
133
+ baseMs: number
134
+ capMs: number
135
+ senderId: string
136
+ // The current Client. Assigned EARLY in `tryConnect` (before
137
+ // `c.connect()` is awaited) so two invariants hold:
138
+ // (a) `c.once('error', ...)`'s `state.client === c` gate passes
139
+ // for errors that fire between `c.connect()` resolving and
140
+ // `c.query('LISTEN …')` resolving (the error listener can
141
+ // only attach AFTER connect returns, so errors strictly
142
+ // DURING connect are still observed via the connect Promise
143
+ // rejecting — the gate matters for the LISTEN window).
144
+ // (b) `stop()` can read `state.client` and call `c.end()` to abort
145
+ // an in-flight `await c.connect()` / `await c.query('LISTEN …')`
146
+ // — without this, a network blackhole during handshake makes
147
+ // SIGTERM hang indefinitely on the connect await.
148
+ // Cleared by `tryConnect`'s catch on failure, and by `stop()` /
149
+ // `reconnect()` when they replace the client.
150
+ client: NeonClient | null
151
+ handler: BusHandler | null
152
+ stopped: boolean
153
+ // Tracks the in-flight (re)connect attempt so `stop()` can await it —
154
+ // otherwise a SIGTERM mid-reconnect would race the Client teardown
155
+ // and leak the underlying WebSocket.
156
+ connectAttempt: Promise<void> | null
157
+ // Reconnect retry counter, reset to 0 after a successful LISTEN.
158
+ attempt: number
159
+ // Set while the loop is parked in `await sleep(delay)` during a
160
+ // reconnect backoff. `stop()` calls it (if present) to kick the
161
+ // loop out IMMEDIATELY rather than waiting up to `capMs` (30 s
162
+ // default) for the timer to fire. Cleared when the sleep returns.
163
+ cancelSleep: (() => void) | null
164
+ // In-flight bus-message handler promises. `dispatchNotification`
165
+ // fires handlers fire-and-forget, but `stop()` awaits this set before
166
+ // returning so the lifecycle's `handle.close()` (which runs after
167
+ // `pubsub.stop()` — see closeDb in server/index.ts) can't race a
168
+ // handler mid-`handle.revisionById.get` / `getLive`.
169
+ pendingHandlers: Set<Promise<void>>
170
+ }
171
+
172
+ // Notification dispatch — closes over `state.senderId` and
173
+ // `state.handler`. Filters foreign channels (defensive) and our own
174
+ // publish round-trip (Postgres NOTIFY delivers to publishers too).
175
+ function dispatchNotification(state: NeonState, n: { channel: string; payload?: string }): void {
176
+ if (n.channel !== CHANNEL) return
177
+ if (typeof n.payload !== 'string') return
178
+ let parsed: { sender?: unknown; kind?: unknown } & Record<string, unknown>
179
+ try { parsed = JSON.parse(n.payload) as typeof parsed }
180
+ catch { return }
181
+ if (parsed.sender === state.senderId) return
182
+ const msg = parseBusMessage(parsed)
183
+ if (!msg) return
184
+ const fn = state.handler
185
+ if (!fn) return
186
+ // Fire-and-forget: a slow handler can't block the Client's
187
+ // notification dispatch (which would queue further notifications
188
+ // behind it). Errors are logged but don't kill the loop — a missed
189
+ // broadcast surfaces to clients on reconnect via the chain re-pull.
190
+ //
191
+ // The promise is also tracked in `state.pendingHandlers` so `stop()`
192
+ // can drain in-flight handlers BEFORE the lifecycle teardown closes
193
+ // the DB handle. Without the tracking, `onBusMessage`'s DB queries
194
+ // (`handle.revisionById.get` / `getLive`) could race
195
+ // `handle.close()` and throw inside a half-settled handler.
196
+ const promise: Promise<void> = fn(msg).catch((err) => {
197
+ console.warn('pubsub: handler error:', errStack(err))
198
+ }).finally(() => { state.pendingHandlers.delete(promise) })
199
+ state.pendingHandlers.add(promise)
200
+ }
201
+
202
+ // Single connect attempt. Resolves once LISTEN is registered, or
203
+ // rejects on transport / LISTEN failure. Assigns `state.client = c`
204
+ // EAGERLY (before `c.connect()` is awaited) for the two invariants
205
+ // documented on `NeonState.client`: the error handler's equality gate
206
+ // must work mid-handshake, and `stop()` must be able to abort a hung
207
+ // connect by reading `state.client?.end()`.
208
+ async function tryConnect(state: NeonState): Promise<void> {
209
+ const c = state.newClient()
210
+ state.client = c
211
+ try {
212
+ await c.connect()
213
+ c.on('notification', (n) => dispatchNotification(state, n))
214
+ // Transport-level error → reconnect trigger. Defer to a microtask
215
+ // so the current notification (if any) finishes before we replace
216
+ // the client. The `state.client === c` gate skips stale error
217
+ // events from PREVIOUS clients we've already torn down.
218
+ c.once?.('error', (err: Error) => {
219
+ if (state.debug) console.warn('pubsub: client error:', errStack(err))
220
+ queueMicrotask(() => { if (state.client === c) void reconnect(state) })
221
+ })
222
+ await c.query(`LISTEN ${CHANNEL}`)
223
+ } catch (err) {
224
+ // Clear `state.client` only if it still points at OUR client (a
225
+ // racing `stop()` may have already null'd it and ended the
226
+ // half-connected socket — don't clobber that). The `c.end()`
227
+ // below may be a redundant second call in that race (stop already
228
+ // ended it); pg-style Client.end() is idempotent so the second
229
+ // call is a harmless no-op. The try/catch additionally absorbs
230
+ // any rejection from end() itself.
231
+ if (state.client === c) state.client = null
232
+ try { await c.end() } catch {}
233
+ throw err
234
+ }
235
+ }
236
+
237
+ // Outer (re)connect loop. Exits silently on `state.stopped`; otherwise
238
+ // retries with exponential backoff after a connect / LISTEN failure.
239
+ // `tryConnect` assigns `state.client` itself (see the eager-assign
240
+ // rationale on `NeonState.client`), so this loop only counts attempts
241
+ // and runs the backoff sleep.
242
+ async function connectAndListen(state: NeonState): Promise<void> {
243
+ // oxlint-disable-next-line no-unmodified-loop-condition
244
+ while (!state.stopped) {
245
+ try {
246
+ await tryConnect(state)
247
+ state.attempt = 0
248
+ if (state.debug) console.log(`pubsub: LISTEN ${CHANNEL} (sender ${state.senderId})`)
249
+ return
250
+ } catch (err) {
251
+ if (state.stopped) return
252
+ state.attempt += 1
253
+ const delay = Math.min(state.capMs, state.baseMs * 2 ** Math.min(state.attempt - 1, 8))
254
+ console.warn(`pubsub: connect failed (attempt ${state.attempt}), retrying in ${delay}ms:`, errStack(err))
255
+ await cancellableSleep(state, delay)
256
+ }
257
+ }
258
+ }
259
+
260
+ // Sleep that `stop()` can wake up. Without the cancel hook, a SIGTERM
261
+ // landing mid-backoff would stall shutdown for up to `capMs` (30 s
262
+ // default) waiting on the timer. We stash the cancel callback on the
263
+ // shared state object so `stop()` can fire it; the loop's
264
+ // `if (state.stopped) return` then exits on the next turn. The
265
+ // `settled` flag guards against a race between the timer firing and
266
+ // `stop()` racing the same wake-up (Promise resolves are idempotent
267
+ // at the runtime layer, but oxlint's `no-multiple-resolved` rule is
268
+ // stricter and the guard documents the mutual exclusion explicitly).
269
+ function cancellableSleep(state: NeonState, ms: number): Promise<void> {
270
+ return new Promise((resolve) => {
271
+ let settled = false
272
+ const finish = (): void => {
273
+ if (settled) return
274
+ settled = true
275
+ state.cancelSleep = null
276
+ resolve()
277
+ }
278
+ const timer = setTimeout(finish, ms)
279
+ timer.unref?.()
280
+ state.cancelSleep = () => { clearTimeout(timer); finish() }
281
+ })
282
+ }
283
+
284
+ async function reconnect(state: NeonState): Promise<void> {
285
+ if (state.stopped) return
286
+ const old = state.client
287
+ state.client = null
288
+ if (old) { try { await old.end() } catch {} }
289
+ if (state.connectAttempt) return // a connect is already running
290
+ state.connectAttempt = connectAndListen(state).finally(() => { state.connectAttempt = null })
291
+ await state.connectAttempt
292
+ }
293
+
294
+ // Neon-mode PubSub. Holds ONE Client for LISTEN (long-lived WebSocket)
295
+ // and reuses it for publish (`pg_notify` from the same session — no
296
+ // need for a separate write connection, and binds the publish-vs-notify
297
+ // ordering: a NOTIFY a peer publishes RIGHT AFTER a save commit is
298
+ // guaranteed to follow the commit in WAL order from the peer's POV).
299
+ //
300
+ // Reconnection: on transport error the loop retries with exponential
301
+ // backoff. Publishes during the down window are dropped — they're a
302
+ // best-effort fan-out, not a durability mechanism. The DB is the
303
+ // source of truth; a client whose peer missed a live broadcast catches
304
+ // up via the chain on its next subscribe.
305
+ export function createNeonPubSub(deps: NeonPubSubDeps): PubSub {
306
+ const state: NeonState = {
307
+ newClient: deps.newClient, debug: deps.debug,
308
+ baseMs: deps.reconnectBaseMs ?? 1_000,
309
+ capMs: deps.reconnectCapMs ?? 30_000,
310
+ senderId: newSenderId(),
311
+ client: null, handler: null, stopped: false,
312
+ connectAttempt: null, attempt: 0, cancelSleep: null,
313
+ pendingHandlers: new Set(),
314
+ }
315
+ return {
316
+ start: async (onMessage) => {
317
+ state.handler = onMessage
318
+ state.connectAttempt = connectAndListen(state)
319
+ await state.connectAttempt.finally(() => { state.connectAttempt = null })
320
+ },
321
+ publish: (msg) => publish(state, msg),
322
+ stop: async () => {
323
+ state.stopped = true
324
+ // Null `handler` BEFORE the awaits so any notification that
325
+ // sneaks in (between `c.end()` and the socket actually closing)
326
+ // sees no handler and drops in `dispatchNotification`.
327
+ state.handler = null
328
+ // Kick the loop out of its backoff sleep IMMEDIATELY rather than
329
+ // letting `stop()` block for up to `capMs` (30 s default) on the
330
+ // timer. The loop's `if (state.stopped) return` runs on the next
331
+ // turn and exits cleanly.
332
+ state.cancelSleep?.()
333
+ // End the current client to abort an in-flight handshake (cancels
334
+ // a hung `await c.connect()` / `await c.query('LISTEN …')` so
335
+ // `stop()` doesn't hang on a Neon WS blackhole) OR close an
336
+ // established LISTEN session. With `tryConnect`'s eager assign,
337
+ // `state.client` covers both cases via the same field.
338
+ const c = state.client
339
+ state.client = null
340
+ if (c) { try { await c.end() } catch {} }
341
+ // Now wait for the (now-aborted-if-applicable) connect attempt to
342
+ // unwind through its catch and resolve.
343
+ if (state.connectAttempt) { try { await state.connectAttempt } catch {} }
344
+ // Drain in-flight bus-message handlers BEFORE returning. The
345
+ // lifecycle teardown runs `pubsub.stop()` and THEN
346
+ // `handle.close()` (see closeDb in server/index.ts); a handler
347
+ // still in `handle.revisionById.get` / `getLive` would otherwise
348
+ // throw against a closed DB. `allSettled` so one handler's
349
+ // rejection doesn't abort the drain.
350
+ if (state.pendingHandlers.size > 0) {
351
+ await Promise.allSettled([...state.pendingHandlers])
352
+ }
353
+ },
354
+ }
355
+ }
356
+
357
+ function publish(state: NeonState, msg: BusMessage): void {
358
+ const c = state.client
359
+ if (!c) {
360
+ if (state.debug) console.warn('pubsub: publish dropped (no client):', msg.kind, msg.tag.slice(0, 12))
361
+ return
362
+ }
363
+ // `pg_notify(text, text)` is the parameter-bound form of NOTIFY —
364
+ // the bare `NOTIFY` statement doesn't accept params. The envelope
365
+ // carries the sender id so the LISTENing connection on this same
366
+ // process filters its own publishes (Postgres delivers NOTIFY back
367
+ // to publishers too).
368
+ const envelope = JSON.stringify({ sender: state.senderId, ...msg })
369
+ // Fire-and-forget. A failed publish only means peers on OTHER
370
+ // instances miss THIS event — local fan-out already happened before
371
+ // this call. Log + continue (the bus is a best-effort accelerator,
372
+ // not a durability layer).
373
+ c.query(`SELECT pg_notify($1, $2)`, [CHANNEL, envelope]).catch((err) => {
374
+ if (state.debug) console.warn('pubsub: publish error:', errStack(err))
375
+ })
376
+ }
377
+
378
+ // Narrow a parsed JSON object into a typed BusMessage. Rejects anything
379
+ // missing required fields, with non-string tag/id/res, or a non-integer
380
+ // version. Defensive against bus poisoning by a peer running an older /
381
+ // custom build.
382
+ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
383
+ const tag = raw['tag']
384
+ const kind = raw['kind']
385
+ if (typeof tag !== 'string') return null
386
+ if (kind === 'rev') {
387
+ const id = raw['id']
388
+ if (typeof id !== 'string') return null
389
+ return { kind: 'rev', tag, id }
390
+ }
391
+ if (kind === 'objput') {
392
+ const res = raw['res']
393
+ if (typeof res !== 'string') return null
394
+ return { kind: 'objput', tag, res }
395
+ }
396
+ if (kind === 'objdel') {
397
+ const res = raw['res']
398
+ const ver = raw['ver']
399
+ if (typeof res !== 'string') return null
400
+ if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
401
+ return { kind: 'objdel', tag, res, ver }
402
+ }
403
+ return null
404
+ }