@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.
- package/out/client-sync.js +14 -10
- package/out/graph.js +4 -4
- package/out/index.html +42 -38
- package/out/view.css +1 -1
- package/out/view.js +50 -47
- package/package.json +15 -3
- package/server/auth.ts +11 -0
- package/server/bus-receiver.ts +95 -0
- package/server/cli.js +22 -0
- package/server/db-neon.ts +39 -23
- 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 +150 -22
- package/server/lifecycle.ts +29 -2
- package/server/npm-proxy.ts +348 -0
- package/server/objstore/blob-vercel.ts +23 -2
- package/server/objstore/handlers.ts +12 -0
- package/server/objstore/init.ts +7 -1
- package/server/objstore/rest.ts +18 -0
- package/server/objstore/store-neon.ts +12 -12
- 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/strip-types-loader.js +94 -0
|
@@ -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
|
|
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
|
|
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
|
|
package/server/objstore/init.ts
CHANGED
|
@@ -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
|
package/server/objstore/rest.ts
CHANGED
|
@@ -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 {
|
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
|
+
}
|