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

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.
@@ -364,9 +364,30 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
364
364
  // GET layer doesn't pass If-None-Match — but a future call
365
365
  // site could. Treat as unavailable rather than streaming a
366
366
  // null body.
367
- if (res.statusCode !== 200 || res.stream == null || res.blob.size == null) {
367
+ if (res.statusCode !== 200 || res.stream == null) {
368
368
  return { ok: false, reason: 'unavailable' }
369
369
  }
370
+ // `@vercel/blob@2.x`'s streaming `get()` for private blobs
371
+ // returns the body but does NOT populate `blob.size` and does
372
+ // NOT pass a `content-length` header through (verified
373
+ // empirically: get.size=0, content-length-hdr=null while
374
+ // head.size reports the true byte count). The REST layer
375
+ // depends on a size to set `content-length` on its response
376
+ // and for the integrity check against the DB row, so when
377
+ // get() leaves it 0/null we fall back to a head() lookup.
378
+ // Two round-trips per private read on Vercel until the SDK is
379
+ // fixed — small price vs. the alternative of 503ing every read.
380
+ let size: number | null | undefined = res.blob?.size
381
+ if (size == null || size === 0) {
382
+ try {
383
+ const h = await sdk.head(path, { token })
384
+ size = (h as { size?: number })?.size
385
+ } catch (headErr) {
386
+ if (isNotFound(headErr)) return { ok: false, reason: 'not-found' }
387
+ throw headErr
388
+ }
389
+ }
390
+ if (size == null) return { ok: false, reason: 'unavailable' }
370
391
  // SDK returns a web ReadableStream<Uint8Array>; the REST layer
371
392
  // expects a Node Readable for pipeline(). Convert via
372
393
  // Readable.fromWeb — built-in and zero-copy where possible.
@@ -376,7 +397,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
376
397
  ok: true,
377
398
  reader: {
378
399
  stream: nodeStream,
379
- size: res.blob.size,
400
+ size,
380
401
  // eslint-disable-next-line require-await
381
402
  close: async () => {
382
403
  // Destroying the Node wrapper also cancels the underlying
@@ -45,6 +45,12 @@ export type ObjstoreDeps = {
45
45
  secret: TokenSecret
46
46
  send: (socket: WebSocket, msg: object) => void
47
47
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
48
+ // Cross-instance pub/sub for objstore-deleted. Fired alongside the
49
+ // local `broadcast` after a successful delete so peers on OTHER
50
+ // server instances see the version drop in real time. Carries the
51
+ // full (tag, resourceTag, version) tuple inline — the workspace_object
52
+ // row is gone post-delete, so the bus payload IS the wire data.
53
+ publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
48
54
  getNonce: (socket: WebSocket) => string | undefined
49
55
  debug: boolean
50
56
  // Auth gate for the FIRST put-begin against a never-before-seen
@@ -188,6 +194,12 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
188
194
  // `onDeleted` lets `session.onDeleted` fire for the session's
189
195
  // own deletes — pinned by `tests/objstore-client-races.test.js`.
190
196
  deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion }, null)
197
+ // Cross-instance fan-out (Neon mode). The workspace_object row is
198
+ // gone post-delete so the bus payload carries (tag, resourceTag,
199
+ // version) inline; the receiver builds its `objstore-deleted`
200
+ // broadcast directly from the bus envelope. SQLite mode publishes
201
+ // to a no-op.
202
+ deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
191
203
  if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
192
204
  }
193
205
 
@@ -20,6 +20,11 @@ export type ObjstoreInitDeps = {
20
20
  reapIntervalMs: number
21
21
  send: (socket: WebSocket, msg: object) => void
22
22
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
23
+ // Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
24
+ // mode passes Postgres LISTEN/NOTIFY-backed implementations. See
25
+ // server/pubsub.ts for the bus design.
26
+ publishObjPut: (tag: string, resourceTag: string) => void
27
+ publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
23
28
  getNonce: (socket: WebSocket) => string | undefined
24
29
  debug: boolean
25
30
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -56,12 +61,13 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
56
61
  const handlers = createObjstoreHandlers({
57
62
  handle, secret,
58
63
  send: deps.send, broadcast: deps.broadcast,
64
+ publishObjDeleted: deps.publishObjDeleted,
59
65
  getNonce: deps.getNonce, debug: deps.debug,
60
66
  ...(deps.authGate ? { authGate: deps.authGate } : {}),
61
67
  ...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
62
68
  })
63
69
  const restDeps: ObjstoreRestDeps = {
64
- handle, secret, broadcast: deps.broadcast, debug: deps.debug,
70
+ handle, secret, broadcast: deps.broadcast, publishObjPut: deps.publishObjPut, debug: deps.debug,
65
71
  }
66
72
  // Re-entrancy guard for periodic + startup sweeps. Kicking the
67
73
  // startup sweep through the same `enqueueSweep` path means the
@@ -61,6 +61,13 @@ export type ObjstoreRestDeps = {
61
61
  handle: Handle
62
62
  secret: TokenSecret
63
63
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
64
+ // Cross-instance pub/sub for objstore-put. Fired alongside the local
65
+ // `broadcast` after a successful commitPut so peers on OTHER server
66
+ // instances see the new version in real time. Carries only
67
+ // `(tag, resourceTag)` — receivers re-fetch the live row from
68
+ // workspace_object for the full metadata (version, hash, length,
69
+ // signature). SQLite mode passes a no-op.
70
+ publishObjPut: (tag: string, resourceTag: string) => void
64
71
  debug: boolean
65
72
  }
66
73
 
@@ -286,6 +293,17 @@ async function handleRestPutBody(
286
293
  workspaceTag: route.tag,
287
294
  ...objectMetaWire(row),
288
295
  }, null)
296
+ // Cross-instance fan-out (Neon mode). The bus payload carries only
297
+ // (tag, resourceTag); peers on other instances re-fetch the live
298
+ // row from workspace_object to compose their local broadcast. The
299
+ // committed row is durable by here (commitPut's version-CAS already
300
+ // landed), so the receiver typically sees either THIS version or a
301
+ // STRICTLY newer one (also a valid broadcast — clients are
302
+ // idempotent on (resourceTag, version)). The receiver is allowed
303
+ // to find no live row at all if a subsequent delete races the
304
+ // notification; bus-receiver.ts drops that case silently. SQLite
305
+ // mode publishes to a no-op.
306
+ deps.publishObjPut(route.tag, route.resourceTag)
289
307
  if (deps.debug) console.log(`objstore put → ${route.tag.slice(0, 12)}…/${route.resourceTag.slice(0, 8)}… v${row.version}`)
290
308
  } finally {
291
309
  // Release the slot on every exit (success, commit failure, pipeline
@@ -121,7 +121,7 @@ type StagingRow = {
121
121
 
122
122
  function buildSelectStaging(sql: NeonSql): GetStmt<[string, string, string], StagingRow> {
123
123
  return { get: async (tag, resourceTag, stagingId) => {
124
- const rows = await sql(
124
+ const rows = await sql.query(
125
125
  `SELECT prev_version, prev_incarnation, expected_length, content_hash, signature, begun_at
126
126
  FROM workspace_object_staging
127
127
  WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3`,
@@ -162,7 +162,7 @@ function buildDeleteStaging(sql: NeonSql): RunStmt<[string, string, string]> {
162
162
  // Bind order: (tag, res, sid, staleBefore).
163
163
  function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, string, number], { ok: number }> {
164
164
  return { get: async (tag, resourceTag, stagingId, staleBefore) => {
165
- const rows = await sql(
165
+ const rows = await sql.query(
166
166
  `DELETE FROM workspace_object_staging
167
167
  WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3 AND begun_at < $4
168
168
  RETURNING 1 AS ok`,
@@ -175,7 +175,7 @@ function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, strin
175
175
 
176
176
  function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
177
177
  return { all: async (tag) => {
178
- const rows = await sql(
178
+ const rows = await sql.query(
179
179
  `SELECT resource_tag, version, incarnation, content_hash, content_length,
180
180
  signature, put_at
181
181
  FROM workspace_object
@@ -189,7 +189,7 @@ function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
189
189
 
190
190
  function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow> {
191
191
  return { get: async (tag, resourceTag) => {
192
- const rows = await sql(
192
+ const rows = await sql.query(
193
193
  `SELECT resource_tag, version, incarnation, content_hash, content_length,
194
194
  signature, put_at
195
195
  FROM workspace_object
@@ -208,7 +208,7 @@ function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow>
208
208
  // (tag, res, hash, len, sig, put_at); version is the literal 1.
209
209
  function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string, string, number, string, number], { ok: number }> {
210
210
  return { get: async (tag, resourceTag, incarnation, contentHash, contentLength, signature, putAt) => {
211
- const rows = await sql(
211
+ const rows = await sql.query(
212
212
  `INSERT INTO workspace_object
213
213
  (workspace_tag, resource_tag, version, incarnation, content_hash, content_length,
214
214
  signature, put_at)
@@ -230,7 +230,7 @@ function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string,
230
230
  // (tag, res, nextVersion, hash, len, sig, put_at, expectedVersion).
231
231
  function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string, number, string, number, number, string], { ok: number }> {
232
232
  return { get: async (tag, resourceTag, nextVersion, contentHash, contentLength, signature, putAt, expectedVersion, expectedIncarnation) => {
233
- const rows = await sql(
233
+ const rows = await sql.query(
234
234
  `UPDATE workspace_object
235
235
  SET version = $3,
236
236
  content_hash = $4,
@@ -252,7 +252,7 @@ function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, stri
252
252
  // not-found, never a lost update.
253
253
  function buildDeleteLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string], { ok: number }> {
254
254
  return { get: async (tag, resourceTag, expectedVersion, expectedIncarnation) => {
255
- const rows = await sql(
255
+ const rows = await sql.query(
256
256
  `DELETE FROM workspace_object
257
257
  WHERE workspace_tag = $1 AND resource_tag = $2 AND version = $3 AND incarnation = $4
258
258
  RETURNING 1 AS ok`,
@@ -268,7 +268,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
268
268
  // `WHERE begun_at < $1` uses workspace_object_staging_begun_at_idx
269
269
  // so the reaper sweep is O(stale-rows) cluster-wide. DB-layout
270
270
  // audit follow-up.
271
- const rows = await sql(
271
+ const rows = await sql.query(
272
272
  `SELECT workspace_tag, resource_tag, staging_id, begun_at
273
273
  FROM workspace_object_staging
274
274
  WHERE begun_at < $1`,
@@ -285,7 +285,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
285
285
 
286
286
  function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }> {
287
287
  return { all: async () => {
288
- const rows = await sql(
288
+ const rows = await sql.query(
289
289
  `SELECT DISTINCT workspace_tag FROM workspace_object`,
290
290
  [],
291
291
  ) as Array<Record<string, unknown>>
@@ -295,7 +295,7 @@ function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }>
295
295
 
296
296
  function buildCountLive(sql: NeonSql): GetStmt<[string], { c: number }> {
297
297
  return { get: async (tag) => {
298
- const rows = await sql(
298
+ const rows = await sql.query(
299
299
  `SELECT COUNT(*) AS c FROM workspace_object WHERE workspace_tag = $1`,
300
300
  [tag],
301
301
  ) as Array<{ c: number | string | bigint }>
@@ -324,8 +324,8 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
324
324
  // advisory lock so two replicas booting concurrently serialize
325
325
  // their DDL (the advisory lock releases at COMMIT).
326
326
  await sql.transaction([
327
- sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
328
- ...SCHEMA_PG.map((stmt) => sql(stmt, [])),
327
+ sql.query(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
328
+ ...SCHEMA_PG.map((stmt) => sql.query(stmt, [])),
329
329
  ])
330
330
 
331
331
  return {
@@ -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
+ }