@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.
- package/out/client-sync.js +14 -10
- package/out/graph.js +4 -4
- package/out/index.html +39 -23
- package/out/view.css +1 -1
- package/out/view.js +47 -46
- package/package.json +6 -1
- package/server/auth.ts +11 -0
- package/server/bus-receiver.ts +95 -0
- package/server/db-neon.ts +11 -1
- package/server/db-revision-sql.ts +9 -0
- package/server/db.ts +18 -1
- package/server/http.ts +41 -5
- package/server/hub.ts +22 -2
- package/server/index.ts +124 -15
- package/server/lifecycle.ts +23 -1
- package/server/npm-proxy.ts +348 -0
- package/server/objstore/handlers.ts +12 -0
- package/server/objstore/init.ts +7 -1
- package/server/objstore/rest.ts +18 -0
- package/server/pubsub.ts +404 -0
- package/server/sse-server.ts +352 -0
- package/server/sse-session.ts +202 -0
- package/server/sync-handlers.ts +17 -1
- package/server/ws-server.ts +199 -174
package/server/pubsub.ts
ADDED
|
@@ -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
|
+
}
|