@preventive/triage 1.0.0-alpha.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/common/save-error-reason.ts +53 -0
  3. package/common/utf8.d.ts +13 -0
  4. package/common/utf8.js +57 -0
  5. package/out/brotli-fallback.js +3 -0
  6. package/out/client-sync.js +15 -0
  7. package/out/graph.js +4 -0
  8. package/out/icon-maskable.svg +5 -0
  9. package/out/icon.svg +5 -0
  10. package/out/index.html +78 -0
  11. package/out/manifest.webmanifest +30 -0
  12. package/out/prism.js +14 -0
  13. package/out/terminal.js +39 -0
  14. package/out/view.css +1 -0
  15. package/out/view.html +12 -0
  16. package/out/view.js +138 -0
  17. package/package.json +129 -0
  18. package/server/auth.ts +99 -0
  19. package/server/config.example.json +3 -0
  20. package/server/config.ts +196 -0
  21. package/server/db-neon.ts +374 -0
  22. package/server/db-revision-sql.ts +152 -0
  23. package/server/db-stmt.ts +53 -0
  24. package/server/db.ts +577 -0
  25. package/server/http.ts +142 -0
  26. package/server/hub.ts +98 -0
  27. package/server/index.ts +353 -0
  28. package/server/lifecycle.ts +177 -0
  29. package/server/neon-driver.ts +26 -0
  30. package/server/objstore/blob-fs.ts +164 -0
  31. package/server/objstore/blob-vercel.ts +508 -0
  32. package/server/objstore/blob.ts +169 -0
  33. package/server/objstore/fs.ts +67 -0
  34. package/server/objstore/handlers.ts +235 -0
  35. package/server/objstore/init.ts +118 -0
  36. package/server/objstore/reaper.ts +199 -0
  37. package/server/objstore/rest.ts +484 -0
  38. package/server/objstore/sign.ts +164 -0
  39. package/server/objstore/store-neon.ts +351 -0
  40. package/server/objstore/store.ts +799 -0
  41. package/server/objstore/tokens.ts +168 -0
  42. package/server/origin.ts +68 -0
  43. package/server/peer.ts +38 -0
  44. package/server/sign.ts +231 -0
  45. package/server/static.ts +374 -0
  46. package/server/sync-handlers.ts +311 -0
  47. package/server/util.ts +27 -0
  48. package/server/validation.ts +36 -0
  49. package/server/ws-server.ts +245 -0
@@ -0,0 +1,177 @@
1
+ // Process lifecycle: the in-flight request tracker, the `shuttingDown`
2
+ // gate, the graceful-shutdown choreography, and the signal / error /
3
+ // uncaught-exception handlers that drive it. Two-phase so the bits the
4
+ // HTTP + WS planes need (`track`, `isShuttingDown`) exist BEFORE those
5
+ // servers are built, while the teardown is `install()`ed afterward —
6
+ // once the server objects, heartbeat timer, and reaper exist.
7
+
8
+ import type { Server } from 'node:http'
9
+ import type { WebSocketServer } from 'ws'
10
+ import { errStack } from './util.ts'
11
+
12
+ export type ShutdownDeps = {
13
+ httpServer: Server
14
+ wss: WebSocketServer
15
+ heartbeatTimer: ReturnType<typeof setInterval>
16
+ // Stops the periodic reaper AND awaits any in-flight sweep.
17
+ stopReaper: () => Promise<void>
18
+ // App-specific teardown, run AFTER the in-flight drain: close the DB.
19
+ // Swallows its own errors (a failed close shouldn't abort the exit).
20
+ closeDb: () => Promise<void>
21
+ }
22
+
23
+ export type Lifecycle = {
24
+ // Collects in-flight async handlers so `shutdown` can drain them
25
+ // before closing the DB. Passed to the HTTP + WS planes.
26
+ track: (promise: Promise<unknown>) => void
27
+ // Read by the REST shutdown gate + the WS message loop to drop new
28
+ // work once teardown began.
29
+ isShuttingDown: () => boolean
30
+ // Wire the graceful shutdown + the error / signal / process-catchall
31
+ // handlers. Call once, after the server objects exist.
32
+ install: (deps: ShutdownDeps) => void
33
+ }
34
+
35
+ export function createLifecycle(): Lifecycle {
36
+ // In-flight async message handlers. `shutdown` awaits this set before
37
+ // closing the DB so a SIGINT mid-save can't resume against a closed
38
+ // handle (which would throw inside the commit's gated INSERT after
39
+ // the client believed its save was committed).
40
+ const inFlight = new Set<Promise<unknown>>()
41
+ function track(promise: Promise<unknown>): void {
42
+ inFlight.add(promise)
43
+ promise.finally(() => inFlight.delete(promise))
44
+ }
45
+ let shuttingDown = false
46
+ // Live exit code the in-progress shutdown will pass to `process.exit`.
47
+ // Re-entry can ESCALATE it from 0 → 1 (e.g. a `wss.error` firing
48
+ // during a SIGTERM-driven graceful shutdown shouldn't leave the
49
+ // launcher seeing a clean exit code) but can never DE-escalate.
50
+ // Audit round-13.
51
+ let pendingExitCode = 0
52
+
53
+ function install(deps: ShutdownDeps): void {
54
+ const { httpServer, wss, heartbeatTimer, stopReaper, closeDb } = deps
55
+
56
+ async function shutdown(exitCode: number = 0): Promise<void> {
57
+ // Re-entry: don't restart the teardown, but escalate the pending
58
+ // exit code if the new caller is non-zero (e.g. a wss.error during
59
+ // a SIGTERM-driven graceful shutdown). Without this, an error
60
+ // arriving mid-shutdown would silently exit 0 and the launcher
61
+ // would record a clean stop.
62
+ if (shuttingDown) {
63
+ if (exitCode !== 0 && pendingExitCode === 0) pendingExitCode = exitCode
64
+ return
65
+ }
66
+ shuttingDown = true
67
+ pendingExitCode = exitCode
68
+ console.log('Shutting down…')
69
+ // Stop the heartbeat so a tick can't fire mid-shutdown and ping a
70
+ // socket the close-loop below already started tearing down.
71
+ clearInterval(heartbeatTimer)
72
+ // Send a 1001 (going away) close frame to every open socket BEFORE
73
+ // shutting the listener. Lets clients distinguish a server-initiated
74
+ // graceful shutdown from a network drop, so they can skip their
75
+ // reconnect backoff. Fire-and-forget — `process.exit` below would
76
+ // force-kill any in-progress flush anyway. `try/catch` shrugs at
77
+ // sockets already in CLOSING / CLOSED.
78
+ for (const socket of wss.clients) {
79
+ try { socket.close(1001, 'Server shutting down') } catch {}
80
+ }
81
+ // Force-terminate any client that doesn't ack the close frame
82
+ // within a short grace window. `wss.close()` waits for every client
83
+ // to emit `'close'`, and `ws` only TCP-RSTs unresponsive peers
84
+ // after its own ~30 s `closeTimeout`. A single dead/blackholed peer
85
+ // would otherwise stretch SIGTERM/SIGINT response by that full
86
+ // timeout. Audit round-11.
87
+ const TERMINATE_GRACE_MS = 1_000
88
+ const terminateTimer = setTimeout(() => {
89
+ for (const socket of wss.clients) {
90
+ const rs = socket.readyState
91
+ if (rs === socket.OPEN || rs === socket.CLOSING) {
92
+ try { socket.terminate() } catch {}
93
+ }
94
+ }
95
+ // Same grace for HTTP keep-alive sockets that didn't respect the
96
+ // `Connection: close` hint, which would otherwise hold
97
+ // `httpServer.close()` until their TCP timeout.
98
+ try { httpServer.closeAllConnections() } catch {}
99
+ }, TERMINATE_GRACE_MS)
100
+ // Don't keep the event loop alive solely for the grace timer.
101
+ terminateTimer.unref?.()
102
+ // Stop the periodic reaper AND wait for any in-flight sweep (incl.
103
+ // the startup sweep) before the DB close — otherwise a
104
+ // readdir / unlink would race a closed DB.
105
+ await stopReaper()
106
+ // Free idle HTTP keep-alive sockets up front so close() below
107
+ // doesn't wait on them. Active in-flight requests still finish.
108
+ try { httpServer.closeIdleConnections() } catch {}
109
+ // Close http.Server first to stop accepting new upgrades + HTTP
110
+ // requests. Guard with `.listening` because `close()` throws
111
+ // ERR_SERVER_NOT_RUNNING when bind never succeeded (the http error
112
+ // handler is the path that invoked shutdown in that case).
113
+ if (httpServer.listening) {
114
+ await new Promise<void>((resolve) => { httpServer.close(() => resolve()) })
115
+ }
116
+ await new Promise<void>((resolve) => { wss.close(() => resolve()) })
117
+ clearTimeout(terminateTimer)
118
+ // Drain in-flight handlers so a save that's mid-pipeline finishes
119
+ // its commit before the DB closes. `handleSave` splits its
120
+ // canonical/id/dup-precheck/verify/commit work across awaits, so
121
+ // the window spans several yield points. `Promise.allSettled` so a
122
+ // single handler rejection doesn't abort the drain.
123
+ if (inFlight.size > 0) await Promise.allSettled([...inFlight])
124
+ // App teardown AFTER the drain: close the DB.
125
+ await closeDb()
126
+ // Read `pendingExitCode` (not the parameter) so a re-entrant
127
+ // `shutdown(1)` that landed during the drain wins over the original
128
+ // `shutdown(0)`. See round-13 escalation note.
129
+ process.exit(pendingExitCode)
130
+ }
131
+
132
+ // `.catch` defends against an unguarded `await` slipping into
133
+ // `shutdown`: an unhandled rejection there would skip the non-zero
134
+ // exit the launcher relies on.
135
+ function fireShutdown(code: number): void {
136
+ shutdown(code).catch((err) => {
137
+ console.warn('shutdown error:', errStack(err))
138
+ process.exit(code === 0 ? 1 : code)
139
+ })
140
+ }
141
+
142
+ // Route bind / post-listen failures through `shutdown` so the
143
+ // in-flight drain + DB close still run before exit. Without this,
144
+ // `ws` re-emits the error as uncaughtException and the launcher sees
145
+ // a confusing crash rather than the bind failure. Audit round-9 M2.
146
+ httpServer.on('error', (err: Error) => {
147
+ console.error('Server error:', errStack(err))
148
+ fireShutdown(1)
149
+ })
150
+ // Symmetric with the http.Server error handler — route through
151
+ // `fireShutdown(1)` so the launcher sees a non-zero exit, and the
152
+ // re-entry escalation bumps `pendingExitCode` 0 → 1 for a `wss.error`
153
+ // arriving mid-graceful-SIGTERM.
154
+ wss.on('error', (err: Error) => {
155
+ console.error('WS server error:', errStack(err))
156
+ fireShutdown(1)
157
+ })
158
+ // Wrap signal handlers so the signal name (the listener's first arg)
159
+ // doesn't bleed into shutdown's `exitCode`.
160
+ process.on('SIGINT', () => fireShutdown(0))
161
+ process.on('SIGTERM', () => fireShutdown(0))
162
+ // Process-level catchalls so a stray rejection / uncaught exception
163
+ // doesn't bypass `shutdown()` — Node 20+ exits on unhandled
164
+ // rejection, which would skip the drain + DB close. Log forensically
165
+ // and route through `fireShutdown(1)`. Audit round-11 observability.
166
+ process.on('unhandledRejection', (reason) => {
167
+ console.error('Unhandled rejection:', errStack(reason))
168
+ fireShutdown(1)
169
+ })
170
+ process.on('uncaughtException', (err) => {
171
+ console.error('Uncaught exception:', errStack(err))
172
+ fireShutdown(1)
173
+ })
174
+ }
175
+
176
+ return { track, isShuttingDown: () => shuttingDown, install }
177
+ }
@@ -0,0 +1,26 @@
1
+ // Single import indirection for the optional `@neondatabase/serverless`
2
+ // peer dep. Both Neon planes — `db-neon.ts` (workspace_revision) and
3
+ // `objstore/store-neon.ts` (the objstore tables) — dynamically import
4
+ // THIS module instead of the bare specifier, and tests mock THIS
5
+ // module to swap the real driver for an in-process Postgres (PGlite).
6
+ //
7
+ // Why the indirection: node:test's `mock.module` can only intercept a
8
+ // specifier it can RESOLVE. `@neondatabase/serverless` is an optional
9
+ // peer dep that a SQLite-only checkout never installs, so mocking the
10
+ // bare specifier fails with ERR_MODULE_NOT_FOUND at resolve time. A
11
+ // local wrapper path always resolves; and because the mock loader
12
+ // serves synthetic exports, it never evaluates this file's `export *`,
13
+ // so the absent peer dep is never loaded under test.
14
+ //
15
+ // In production the wrapper is only ever imported on the Neon path
16
+ // (`DATABASE_URL` set), where the operator has installed the peer dep
17
+ // (`pnpm add @neondatabase/serverless`) so the re-export resolves. A
18
+ // SQLite-only deploy never imports this module — the dynamic `import()`
19
+ // sites are gated behind the `DATABASE_URL` branch in `index.ts`.
20
+ //
21
+ // `@ts-ignore` rather than `@ts-expect-error`: when the peer dep IS
22
+ // installed the specifier resolves and tsc sees a real type, which
23
+ // would flip `@ts-expect-error` into a TS2578 "unused directive" error
24
+ // and break the operator's `tsc --noEmit`.
25
+ // @ts-ignore optional peer dep: '@neondatabase/serverless'
26
+ export * from '@neondatabase/serverless'
@@ -0,0 +1,164 @@
1
+ // Local-filesystem BlobBackend. Wraps the low-level fs primitives
2
+ // in ./fs.ts behind the backend-agnostic interface so the v1.objstore
3
+ // consumers (./store.ts, ./rest.ts, ./reaper.ts) can call uniformly
4
+ // against either FS or Vercel Blob (./blob-vercel.ts).
5
+ //
6
+ // Layout under `dir` (passed to `openFsBlobBackend`):
7
+ // ${dir}/${workspaceTag}/${contentHash}.bin — live
8
+ // ${dir}/${workspaceTag}/.staging/${stagingId}.bin — staging
9
+ //
10
+ // Live blobs are content-addressed (the filename is the content hash,
11
+ // not the resourceTag) so a hash names exactly one immutable byte-
12
+ // string; tests that import `liveFilePath` / `stagingFilePath` from
13
+ // ./fs.ts compute the right paths.
14
+
15
+ import { createWriteStream } from 'node:fs'
16
+ import { open, readdir, stat } from 'node:fs/promises'
17
+ import { join } from 'node:path'
18
+ import type { BlobBackend, OpenLiveResult, StagingWriter } from './blob.ts'
19
+ import {
20
+ durableRenameStagedToLive,
21
+ ensureStagingDir,
22
+ liveFilePath,
23
+ stagingFilePath,
24
+ unlinkIfExists,
25
+ } from './fs.ts'
26
+
27
+ // Internal: tolerant readdir for the reaper / list helpers — a
28
+ // missing dir (workspace never had any state, or whole-workspace
29
+ // delete already swept it) returns []. Anything else (EACCES, EIO,
30
+ // …) bubbles up so the reaper's wrapping catch logs it.
31
+ async function safeReaddir(dir: string): Promise<string[]> {
32
+ try { return await readdir(dir) } catch (err: unknown) {
33
+ const code = (err as NodeJS.ErrnoException)?.code
34
+ if (code === 'ENOENT') return []
35
+ throw err
36
+ }
37
+ }
38
+
39
+ // Open the content-addressed live blob for reading. ENOENT → the row
40
+ // references a hash whose blob is gone right now (racing delete/GC, or a
41
+ // stranded row) → `unavailable` (503). `fh.createReadStream()` (not the
42
+ // raw-fd form) binds the stream lifecycle to the FileHandle so the fd
43
+ // closes exactly once; the inode is pinned, so even if the path is
44
+ // unlinked/overwritten after open the stream reads the captured
45
+ // snapshot. The stat is wrapped so a throw between open and close
46
+ // doesn't leak the fd (PR #4 review H8).
47
+ async function fsOpenLiveReader(dir: string, tag: string, contentHash: string): Promise<OpenLiveResult> {
48
+ const path = liveFilePath(dir, tag, contentHash)
49
+ let fh
50
+ try { fh = await open(path, 'r') } catch (err: unknown) {
51
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable' }
52
+ throw err
53
+ }
54
+ let size: number
55
+ try { size = (await fh.stat()).size } catch {
56
+ await fh.close().catch(() => {})
57
+ return { ok: false, reason: 'unavailable' }
58
+ }
59
+ const stream = fh.createReadStream()
60
+ let closed = false
61
+ return {
62
+ ok: true,
63
+ reader: {
64
+ stream,
65
+ size,
66
+ // Guard double-close (caller close + stream-end auto-close).
67
+ // eslint-disable-next-line require-await
68
+ close: async () => { if (closed) return; closed = true; stream.destroy() },
69
+ },
70
+ }
71
+ }
72
+
73
+ export function openFsBlobBackend(dir: string): BlobBackend {
74
+ return {
75
+ ensureWorkspace: (tag) => ensureStagingDir(dir, tag),
76
+
77
+ // eslint-disable-next-line require-await
78
+ openStagingWriter: async (tag, stagingId): Promise<StagingWriter> => {
79
+ const path = stagingFilePath(dir, tag, stagingId)
80
+ // `flags: 'w'` truncates an existing file. Two distinct begins
81
+ // mint distinct (16-byte random) staging ids, so they never
82
+ // target the same path — a same-stagingId collision is ~1/2^128.
83
+ // If a fixture or future refactor reused a stagingId, the second
84
+ // write would clobber the first.
85
+ const writable = createWriteStream(path, { flags: 'w' })
86
+ return {
87
+ writable,
88
+ // No-op: callers pipeline(req, counter, writable) which
89
+ // already awaits 'finish' on the WriteStream. The fd is
90
+ // closed by Node's stream machinery on 'finish'.
91
+ finalize: async () => {},
92
+ // `destroy(err)` synchronously starts tearing the stream
93
+ // down; the WriteStream emits 'close' on the next tick.
94
+ // For the FS backend there's no remote upload to wait for,
95
+ // so we resolve immediately — the REST layer awaits but
96
+ // doesn't block on anything real here. eslint-disable for
97
+ // the no-await-in-async — the function signature is
98
+ // dictated by the BlobBackend contract.
99
+ // eslint-disable-next-line require-await
100
+ abort: async (err) => { writable.destroy(err as Error) },
101
+ }
102
+ },
103
+
104
+ statStaging: async (tag, stagingId): Promise<number | null> => {
105
+ try {
106
+ const s = await stat(stagingFilePath(dir, tag, stagingId))
107
+ return s.size
108
+ } catch (err: unknown) {
109
+ const code = (err as NodeJS.ErrnoException)?.code
110
+ if (code === 'ENOENT') return null
111
+ throw err
112
+ }
113
+ },
114
+
115
+ promoteStagingToLive: (tag, stagingId, contentHash) =>
116
+ durableRenameStagedToLive(
117
+ stagingFilePath(dir, tag, stagingId),
118
+ liveFilePath(dir, tag, contentHash),
119
+ ),
120
+
121
+ openLiveReader: (tag, contentHash) => fsOpenLiveReader(dir, tag, contentHash),
122
+
123
+ unlinkStaging: (tag, stagingId) => unlinkIfExists(stagingFilePath(dir, tag, stagingId)),
124
+ unlinkLive: (tag, contentHash) => unlinkIfExists(liveFilePath(dir, tag, contentHash)),
125
+
126
+ listWorkspaceTags: () => safeReaddir(dir),
127
+
128
+ // Live blobs are top-level `.bin` files under `${dir}/${tag}/`,
129
+ // named by their content hash. The `.staging` subdirectory is
130
+ // excluded — it's the staging-id namespace, returned by
131
+ // listStagingIds. Filtering by `.bin` suffix tolerates operator-
132
+ // seeded foreign files (PR #4 review H2: reaper refuses to unlink
133
+ // anything we didn't write). Each entry carries its `mtimeMs` so
134
+ // the reaper's GC grace window can skip blobs younger than the
135
+ // grace (a just-promoted blob whose live row hasn't been read
136
+ // into the GC's reference set yet). A blob that vanishes between
137
+ // readdir and stat (a racing reaper/abort) is dropped from the
138
+ // result — it's already gone, nothing to GC.
139
+ listLiveBlobs: async (tag) => {
140
+ const entries = await safeReaddir(join(dir, tag))
141
+ const out: Array<{ hash: string; modifiedMs: number }> = []
142
+ for (const name of entries) {
143
+ if (name === '.staging' || !name.endsWith('.bin')) continue
144
+ let modifiedMs: number
145
+ try { modifiedMs = (await stat(join(dir, tag, name))).mtimeMs } catch (err: unknown) {
146
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') continue
147
+ throw err
148
+ }
149
+ out.push({ hash: name.slice(0, -4), modifiedMs })
150
+ }
151
+ return out
152
+ },
153
+
154
+ listStagingIds: async (tag) => {
155
+ const entries = await safeReaddir(join(dir, tag, '.staging'))
156
+ const out: string[] = []
157
+ for (const name of entries) {
158
+ if (!name.endsWith('.bin')) continue
159
+ out.push(name.slice(0, -4))
160
+ }
161
+ return out
162
+ },
163
+ }
164
+ }