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

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 (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.0-alpha.10",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -28,46 +28,65 @@
28
28
  "#client/sync.js": "./client/sync.js",
29
29
  "#client/sync-host.js": "./client/sync-host.js"
30
30
  },
31
+ "exports": {
32
+ "./reap": "./api/reap.ts",
33
+ "./server": "./server-e2e/index.ts",
34
+ "./strip-types-loader": "./strip-types-loader.js",
35
+ "./package.json": "./package.json"
36
+ },
31
37
  "bin": {
32
- "triage-server": "server/index.ts"
38
+ "triage-server": "server-e2e/cli.js"
33
39
  },
34
40
  "files": [
35
- "server/auth.ts",
36
- "server/config.ts",
37
- "server/db-neon.ts",
38
- "server/db-revision-sql.ts",
39
- "server/db-stmt.ts",
40
- "server/db.ts",
41
- "server/http.ts",
42
- "server/hub.ts",
43
- "server/index.ts",
44
- "server/lifecycle.ts",
45
- "server/neon-driver.ts",
46
- "server/objstore/blob-fs.ts",
47
- "server/objstore/blob-vercel.ts",
48
- "server/objstore/blob.ts",
49
- "server/objstore/fs.ts",
50
- "server/objstore/handlers.ts",
51
- "server/objstore/init.ts",
52
- "server/objstore/reaper.ts",
53
- "server/objstore/rest.ts",
54
- "server/objstore/sign.ts",
55
- "server/objstore/store-neon.ts",
56
- "server/objstore/store.ts",
57
- "server/objstore/tokens.ts",
58
- "server/origin.ts",
59
- "server/peer.ts",
60
- "server/sign.ts",
61
- "server/static.ts",
62
- "server/sync-handlers.ts",
63
- "server/util.ts",
64
- "server/validation.ts",
65
- "server/ws-server.ts",
66
- "server/config.example.json",
41
+ "api/reap.ts",
42
+ "server-e2e/auth.ts",
43
+ "server-e2e/bus-receiver.ts",
44
+ "server-e2e/cli.js",
45
+ "server-e2e/config.ts",
46
+ "server-e2e/db-neon.ts",
47
+ "server-e2e/db-revision-sql.ts",
48
+ "server-e2e/db-stmt.ts",
49
+ "server-e2e/db.ts",
50
+ "server-e2e/http.ts",
51
+ "server-e2e/hub.ts",
52
+ "server-e2e/index.ts",
53
+ "server-e2e/lifecycle.ts",
54
+ "server-e2e/neon-driver.ts",
55
+ "server-e2e/npm-proxy.ts",
56
+ "server-e2e/objstore/blob-fs.ts",
57
+ "server-e2e/objstore/blob-vercel.ts",
58
+ "server-e2e/objstore/blob.ts",
59
+ "server-e2e/objstore/fetch-mint-guard.ts",
60
+ "server-e2e/objstore/fs.ts",
61
+ "server-e2e/objstore/handlers.ts",
62
+ "server-e2e/objstore/init.ts",
63
+ "server-e2e/objstore/reaper.ts",
64
+ "server-e2e/objstore/rest-deny.ts",
65
+ "server-e2e/objstore/rest-mint.ts",
66
+ "server-e2e/objstore/rest.ts",
67
+ "server-e2e/objstore/sign.ts",
68
+ "server-e2e/objstore/store-neon.ts",
69
+ "server-e2e/objstore/store.ts",
70
+ "server-e2e/objstore/tokens.ts",
71
+ "server-e2e/peer.ts",
72
+ "server-e2e/pubsub.ts",
73
+ "server-e2e/sign.ts",
74
+ "server-e2e/sse-server.ts",
75
+ "server-e2e/sse-session.ts",
76
+ "server-e2e/static.ts",
77
+ "server-e2e/sync-handlers.ts",
78
+ "server-e2e/util.ts",
79
+ "server-e2e/validation.ts",
80
+ "server-e2e/ws-server.ts",
81
+ "server-e2e/config.example.json",
82
+ "server-common/origin.ts",
67
83
  "common/save-error-reason.ts",
84
+ "common/server-info.ts",
68
85
  "common/utf8.d.ts",
69
86
  "common/utf8.js",
70
87
  "out/brotli-fallback.js",
88
+ "out/client-admin.js",
89
+ "out/client-managed.js",
71
90
  "out/client-sync.js",
72
91
  "out/graph.js",
73
92
  "out/icon-maskable.svg",
@@ -78,37 +97,39 @@
78
97
  "out/terminal.js",
79
98
  "out/view.css",
80
99
  "out/view.html",
81
- "out/view.js"
100
+ "out/view.js",
101
+ "strip-types-loader.js"
82
102
  ],
83
103
  "scripts": {
84
104
  "lint": "node --run lint:js && node --run lint:css && node --run lint:ts",
85
- "lint:js": "oxlint -c .oxlintrc.json --ignore-pattern=ui/logs.js ./tests/ ./common/ ./server/ ./client/ ./ui/",
105
+ "lint:js": "oxlint -c .oxlintrc.json --ignore-pattern=ui/logs.js ./tests/ ./common/ ./server-common/ ./server-e2e/ ./server-managed/ ./client/ ./ui/ ./api/",
86
106
  "lint:css": "stylelint 'ui/**/*.css'",
87
107
  "lint:ts": "tsc --noEmit",
88
108
  "prepack": "node --run build",
89
109
  "build": "node build.js build",
90
110
  "serve": "node build.js serve",
91
- "server": "node server/index.ts",
92
- "test": "node --js-base-64 --experimental-test-module-mocks --test ./tests/*.test.js"
111
+ "server": "node server-e2e/index.ts",
112
+ "server-managed": "node server-managed/index.ts",
113
+ "test": "node --js-base-64 --experimental-test-module-mocks --test-timeout=120000 --test ./tests/*.test.js"
93
114
  },
94
115
  "devDependencies": {
95
- "@electric-sql/pglite": "^0.4.5",
96
- "@exodus/stasis": "^1.0.0-alpha.1",
97
- "@noble/ciphers": "^2.2.0",
98
- "@preventive/terminal": "^1.2.2",
116
+ "@electric-sql/pglite": "^0.5.5",
117
+ "@exodus/stasis-core": "^1.0.0-beta.4",
118
+ "@noble/ciphers": "^2.3.0",
119
+ "@preventive/terminal": "^1.3.0",
99
120
  "@rray/frontend": "^1.0.0",
100
- "@stylistic/stylelint-plugin": "^5.1.0",
101
- "@types/node": "^25.6.2",
121
+ "@stylistic/stylelint-plugin": "^5.3.0",
122
+ "@types/node": "^26.2.0",
102
123
  "@types/ws": "^8.18.1",
103
124
  "brotli": "^1.3.3",
104
- "esbuild": "^0.27.4",
105
- "lit": "^3.3.2",
125
+ "esbuild": "^0.28.2",
126
+ "lit": "^3.3.3",
106
127
  "minify-html-literals": "^1.3.5",
107
- "oxlint": "^1.63.0",
128
+ "oxlint": "^1.79.0",
108
129
  "prismjs": "^1.30.0",
109
- "stylelint": "^17.11.0",
130
+ "stylelint": "^17.14.1",
110
131
  "stylelint-config-recommended": "^18.0.0",
111
- "typescript": "^6.0.3"
132
+ "typescript": "^7.0.2"
112
133
  },
113
134
  "dependencies": {
114
135
  "ws": "^8.20.1"
@@ -1,8 +1,8 @@
1
- // Same-origin gate for the WS upgrade and REST data plane. We don't
2
- // support cross-origin browser clients, so any Origin header present
3
- // on an incoming request MUST match the server's own host (derived
4
- // from `req.headers.host`, or from `X-Forwarded-Host` /
5
- // `X-Forwarded-Proto` when a trusted reverse proxy is in front).
1
+ // Same-origin gate, shared by the e2e and managed servers (server-common).
2
+ // We don't support cross-origin browser clients, so any Origin header present
3
+ // on an incoming request MUST match the server's own host (derived from
4
+ // `req.headers.host`, or from `X-Forwarded-Host` / `X-Forwarded-Proto` when a
5
+ // trusted reverse proxy is in front).
6
6
  //
7
7
  // Why "present-must-match" rather than "always required":
8
8
  // - Browser WebSocket handshakes always carry Origin (RFC 6455), so a
@@ -31,6 +31,10 @@ export type AuthenticateMsg = { password?: unknown }
31
31
  export type Auth = {
32
32
  // True iff the gate is configured AND this socket hasn't authenticated.
33
33
  requiresAuth(socket: WebSocket): boolean
34
+ // Static "is a server password configured" flag — the socket-less
35
+ // predicate the REST put-begin gate needs (a REST request can never be
36
+ // operator-authorized, so `requiresAuth` collapses to this there).
37
+ passwordConfigured: boolean
34
38
  handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void
35
39
  sendUnauthorized(socket: WebSocket, ctx: UnauthorizedContext): void
36
40
  }
@@ -76,6 +80,17 @@ export function createAuth(deps: {
76
80
  }
77
81
  function handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void {
78
82
  if (typeof msg.password !== 'string' || msg.password.length === 0 || msg.password.length > MAX_AUTH_PASSWORD_LEN) return
83
+ // Already-authorized short-circuit. The SSE plane carries
84
+ // `body.password` on every POST (sse-server.ts:dispatchBody
85
+ // synthesises a fresh `authenticate` frame each time), so re-
86
+ // emitting `authenticated` per POST would chatter the wire AND
87
+ // re-fire triage-sync's `authenticated` consumer hook (its "kick
88
+ // deferred sends" path) on every flush. The wire signal is needed
89
+ // exactly once per `unauthorized → authorized` transition; after
90
+ // that, the client already knows. WS plane unaffected (sends
91
+ // authenticate at most once per socket per the runAuthFlow guard
92
+ // in client/sync/socket-transport.ts).
93
+ if (isAuthorized(socket)) return
79
94
  // No-config short-circuit: when not gating, treat any authenticate
80
95
  // as success so a client can cache + replay its password on
81
96
  // reconnect even against an un-gated server (wire shape stays
@@ -95,5 +110,5 @@ export function createAuth(deps: {
95
110
  send(socket, { type: 'authenticated' })
96
111
  }
97
112
 
98
- return { requiresAuth, handleAuthenticate, sendUnauthorized }
113
+ return { requiresAuth, passwordConfigured: CONFIGURED_PASSWORD_HMAC != null, handleAuthenticate, sendUnauthorized }
99
114
  }
@@ -0,0 +1,95 @@
1
+ // Cross-instance bus receiver: maps a `BusMessage` envelope (lookup
2
+ // hint + sender from the Postgres LISTEN/NOTIFY channel) into a local
3
+ // wire broadcast. Pulled out of `server-e2e/index.ts` so the rev / objput /
4
+ // objdel branches — including the keyframe boolean coercion and the
5
+ // `objectMetaWire` shape — are testable without bringing up the whole
6
+ // server.
7
+ //
8
+ // The receiver mirrors the publisher's commit→broadcast→publish
9
+ // ordering, just shifted by the bus round-trip:
10
+ // 1. NOTIFY lands → bus envelope arrives here.
11
+ // 2. For hint-only kinds (rev, objput) re-fetch the row from the
12
+ // shared DB; for self-contained kinds (objdel) skip the fetch.
13
+ // 3. Broadcast to LOCAL subscribers via `broadcastLocalRaw` with no
14
+ // `except` — the originator is on a DIFFERENT instance by
15
+ // construction, so it must NOT be filtered out (the symmetric
16
+ // same-instance broadcast already happened on the publisher).
17
+
18
+ import type { Handle as DbHandle } from './db.ts'
19
+ import { type Handle as ObjstoreHandle, getLive, objectMetaWire } from './objstore/store.ts'
20
+ import type { BusMessage } from './pubsub.ts'
21
+
22
+ export type BusReceiverDeps = {
23
+ // Read-side of the workspace_revision Handle (the bus uses only
24
+ // `revisionById`; the publisher path uses the full Handle). Typed
25
+ // as the broader Handle for ease of wiring at the call site.
26
+ handle: DbHandle
27
+ // Read-side of the objstore Handle (used for `getLive` on objput).
28
+ objstoreHandle: ObjstoreHandle
29
+ // Local-only fan-out into this instance's subscriber map. The
30
+ // payload is pre-serialised so the hub can stringify once for N
31
+ // subscribers (mirrors the existing `broadcast` fast path).
32
+ broadcastLocalRaw: (tag: string, payload: string) => void
33
+ debug: boolean
34
+ }
35
+
36
+ export function createBusReceiver(deps: BusReceiverDeps): (msg: BusMessage) => Promise<void> {
37
+ const { handle, objstoreHandle, broadcastLocalRaw, debug } = deps
38
+ return async (msg) => {
39
+ if (msg.kind === 'rev') {
40
+ const row = await handle.revisionById.get(msg.tag, msg.id)
41
+ if (!row) {
42
+ // Not found is expected when a peer instance published a
43
+ // revision id that hasn't replicated to this instance yet —
44
+ // shouldn't happen on a single-primary Neon endpoint, but a
45
+ // future read-replica deployment could see it transiently.
46
+ // Drop the broadcast; the client will reconcile via chain
47
+ // re-pull on its next subscribe.
48
+ if (debug) console.warn(`pubsub: revision ${msg.id.slice(0, 8)}… not found for ${msg.tag.slice(0, 12)}…`)
49
+ return
50
+ }
51
+ broadcastLocalRaw(msg.tag, JSON.stringify({
52
+ type: 'workspace-state',
53
+ workspaceTag: msg.tag,
54
+ // Mirror `chainForWire`'s strict-boolean coercion in
55
+ // ./sync-handlers.ts — the DB stores `keyframe` as an integer
56
+ // (0/1) but the wire contract uses strict `=== true`.
57
+ revisions: [{ ...row, keyframe: row.keyframe === 1 }],
58
+ }))
59
+ return
60
+ }
61
+ if (msg.kind === 'objput') {
62
+ const row = await getLive(objstoreHandle, msg.tag, msg.res)
63
+ if (!row) {
64
+ // Possible after a remote put → remote delete sequence where
65
+ // both NOTIFYs landed by the time we processed the put. The
66
+ // subsequent objdel NOTIFY will (or has) drive the right
67
+ // broadcast — skip silently. Also possible under a future
68
+ // read-replica deployment with replication lag.
69
+ if (debug) console.warn(`pubsub: objput ${msg.res.slice(0, 8)}… missing for ${msg.tag.slice(0, 12)}…`)
70
+ return
71
+ }
72
+ // `getLive` returns the CURRENT live row, which may be a strictly
73
+ // NEWER version than the NOTIFY referred to (two closely-spaced
74
+ // puts on one resourceTag arrive with the DB already showing v2
75
+ // for both). Broadcasting v2 twice is sound — client `putHandlers`
76
+ // already absorb same-instance PUT echoes (rest.ts uses
77
+ // `except: null`), so handlers must be idempotent on
78
+ // (resourceTag, version) anyway.
79
+ broadcastLocalRaw(msg.tag, JSON.stringify({
80
+ type: 'objstore-put',
81
+ workspaceTag: msg.tag,
82
+ ...objectMetaWire(row),
83
+ }))
84
+ return
85
+ }
86
+ // 'objdel' — the row is gone from workspace_object post-commit, so
87
+ // the bus payload itself IS the wire data; no DB lookup.
88
+ broadcastLocalRaw(msg.tag, JSON.stringify({
89
+ type: 'objstore-deleted',
90
+ workspaceTag: msg.tag,
91
+ resourceTag: msg.res,
92
+ version: msg.ver,
93
+ }))
94
+ }
95
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Executable entry for the `triage-server` bin.
3
+ //
4
+ // Node refuses to strip TypeScript types from files under node_modules
5
+ // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so once this package is
6
+ // installed as a dependency, server-e2e/index.ts can't be executed directly.
7
+ // This launcher registers the built-in-type-stripping hook FIRST
8
+ // (synchronous, in-thread — see ../strip-types-loader.js), then loads the
9
+ // server with the hook active and starts it.
10
+ //
11
+ // The `await import()` is load-bearing: a static `import './index.ts'`
12
+ // would be fetched during this module's instantiation — before the body
13
+ // runs and before the hook is registered — and would hit the node_modules
14
+ // strip error. Deferring to a dynamic import runs it only after the static
15
+ // `../strip-types-loader.js` import has evaluated and registered the hook.
16
+ //
17
+ // Because the server is imported here (not the process entry), its
18
+ // `import.meta.main` auto-start gate stays off, so we call the exported
19
+ // `start()` ourselves.
20
+ import '../strip-types-loader.js'
21
+ const { start } = await import('./index.ts')
22
+ start()
@@ -18,6 +18,7 @@ export type Config = {
18
18
  dbPath: string
19
19
  objstoreDir: string
20
20
  reapIntervalMs: number
21
+ reapDisabled: boolean
21
22
  maxInflightPerSocket: number
22
23
  debug: boolean
23
24
  neonUrl: string | null
@@ -44,11 +45,11 @@ function intEnv(name: string, def: number, min: number, max: number, hint = ''):
44
45
  return n
45
46
  }
46
47
 
47
- const HELP = `Usage: node server/index.ts
48
+ const HELP = `Usage: node server-e2e/index.ts
48
49
  Environment:
49
50
  PORT listen port (default 8765)
50
51
  HOST bind host (default 127.0.0.1)
51
- DB_PATH sqlite file (default: server/data/data.db);
52
+ DB_PATH sqlite file (default: server-e2e/data/data.db);
52
53
  ignored when DATABASE_URL is set
53
54
  DATABASE_URL Neon Postgres connection string; if set,
54
55
  selects the Neon backend instead of
@@ -78,6 +79,13 @@ Environment:
78
79
  DATABASE_URL + BLOB_READ_WRITE_TOKEN
79
80
  are set (bytes live in Vercel Blob).
80
81
  OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
82
+ OBJSTORE_REAP_DISABLED set '1' / 'true' to disable the orphan
83
+ reaper ENTIRELY — no boot sweep, no
84
+ periodic GC. Orphaned/superseded blobs and
85
+ stale staging rows then accumulate
86
+ unbounded; only set this if an external job
87
+ handles GC (e.g. a cron calling reapOrphans).
88
+ Default OFF (reaper runs).
81
89
  TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
82
90
  Host / X-Forwarded-Proto when computing
83
91
  the same-origin gate's expected origin.
@@ -95,7 +103,7 @@ Environment:
95
103
  that need to deterministically
96
104
  exercise the cap.
97
105
  CONFIG_PATH operator config JSON path (default:
98
- server/config.json). Currently the
106
+ server-e2e/config.json). Currently the
99
107
  only field is { "password": "..." }
100
108
  which gates first-action creation of
101
109
  a new workspace on the
@@ -156,13 +164,18 @@ export function loadConfig(): Config {
156
164
  // 0 = OS-assigned ephemeral port (the test harness boots with PORT=0).
157
165
  const port = intEnv('PORT', 8765, 0, 65535)
158
166
  const host = env['HOST'] ?? '127.0.0.1'
159
- // `fileURLToPath` decodes percent-escapes / non-ASCII path segments
160
- // correctly (the older `new URL(...).pathname` left `%20` raw).
167
+ // `fileURLToPath` decodes percent-escapes / non-ASCII path segments;
168
+ // `new URL(...).pathname` would leave `%20` raw.
161
169
  const dbPath = env['DB_PATH'] ?? fileURLToPath(new URL('./data/data.db', import.meta.url))
162
170
  // `path.join` so a Windows DB_PATH doesn't get a mixed-separator child.
163
171
  const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
164
172
  // No practical upper bound beyond the safe-integer range.
165
173
  const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
174
+ // Hard off-switch for the orphan reaper (both the boot sweep AND the
175
+ // periodic timer). '1' / 'true' (case-insensitive) → disabled; anything
176
+ // else, including unset, leaves it ON. Same boolean shape as TRUST_PROXY.
177
+ const reapDisabledEnv = env['OBJSTORE_REAP_DISABLED']
178
+ const reapDisabled = reapDisabledEnv === '1' || reapDisabledEnv?.toLowerCase() === 'true'
166
179
  const debug = env['DEBUG'] === '1'
167
180
 
168
181
  const configPath = env['CONFIG_PATH'] ?? fileURLToPath(new URL('./config.json', import.meta.url))
@@ -174,8 +187,8 @@ export function loadConfig(): Config {
174
187
  const password = rawPassword ?? null
175
188
  // Upper bound 65_536 — bounds memory under hostile load; a deployer
176
189
  // passing MAX_SAFE_INTEGER would silently defeat the cap. Validated
177
- // here (after the config.json / password parse) to preserve the
178
- // pre-split error-precedence order.
190
+ // here, after the config.json / password parse, to keep the
191
+ // error-precedence order.
179
192
  const maxInflightPerSocket = intEnv('MAX_INFLIGHT_PER_SOCKET', 64, 1, 65_536)
180
193
 
181
194
  if (argv.includes('--help') || argv.includes('-h')) {
@@ -189,7 +202,7 @@ export function loadConfig(): Config {
189
202
  const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
190
203
 
191
204
  return {
192
- port, host, dbPath, objstoreDir, reapIntervalMs, maxInflightPerSocket,
205
+ port, host, dbPath, objstoreDir, reapIntervalMs, reapDisabled, maxInflightPerSocket,
193
206
  debug, neonUrl, blobToken, tokenSecret, password,
194
207
  trustProxyEnv: env['TRUST_PROXY'],
195
208
  }
@@ -4,15 +4,20 @@
4
4
  // dialect on the wire.
5
5
  //
6
6
  // `@neondatabase/serverless` is an OPTIONAL peer dep — selected by
7
- // the `DATABASE_URL` branch in `server/index.ts`. The peer dep
7
+ // the `DATABASE_URL` branch in `server-e2e/index.ts`. The peer dep
8
8
  // itself is loaded lazily via the dynamic `import()` inside
9
9
  // `openNeonDb` below, so a SQLite-only deployment never installs it
10
10
  // (`autoInstallPeers: false` in `pnpm-workspace.yaml`) and never
11
11
  // reaches the import.
12
12
  //
13
- // API shape: `neon(connectionString)` returns a tagged-template +
14
- // callable function. We use the function-call form
15
- // `sql(text, params)`. `tryCommitNeon` (below) folds the dup-check
13
+ // API shape: `neon(connectionString)` returns a tagged-template
14
+ // callable with a `.query(text, params)` value-placeholder method.
15
+ // We use the `sql.query(text, params)` form everywhere — our queries
16
+ // are dynamically composed strings + parameter arrays from
17
+ // `./db-revision-sql.ts`, so the tagged-template form doesn't fit.
18
+ // The function-call form (`sql(text, params)`) that the 0.x driver
19
+ // accepted was removed in `@neondatabase/serverless@1.0.0`; the peer
20
+ // dep declares `^1.0.2`. `tryCommitNeon` (below) folds the dup-check
16
21
  // + head-check + gated INSERT into a single pipelined
17
22
  // `sql.transaction([...])` — NO commit-time advisory lock. The gated
18
23
  // INSERT's head-check and `MAX(seq)` run inside one Postgres
@@ -46,7 +51,8 @@ import type { AllStmt, GetStmt, RunStmt } from './db-stmt.ts'
46
51
  import { type CommitResult, type Handle, type RevisionInsert, type RevisionRow, isUniqueViolation } from './db.ts'
47
52
  import {
48
53
  CHAIN_AFTER_SQL, CHAIN_ALL_SQL, CHAIN_FROM_SQL, GATED_INSERT_SQL_PG, HEAD_FOR_SQL,
49
- LAST_KEYFRAME_SEQ_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL, mapRevisionRow, num, numOrNull,
54
+ LAST_KEYFRAME_SEQ_SQL, REVISION_BY_ID_SQL, REVISION_EXISTS_SQL, SEQ_OF_ID_SQL,
55
+ mapRevisionRow, num, numOrNull,
50
56
  } from './db-revision-sql.ts'
51
57
 
52
58
  // `num` / `numOrNull` (safe-integer BIGINT coercion) live in the shared
@@ -70,15 +76,16 @@ export { num, numOrNull }
70
76
  // side. Used by the DDL bootstrap below to prevent partial schema
71
77
  // creation on a transient mid-batch network failure.
72
78
  //
73
- // `transaction` is typed against the query promise the call form
79
+ // `transaction` is typed against the query promise `sql.query`
74
80
  // returns (NOT plain `Promise<unknown>`) so a misuse like
75
81
  // `sql.transaction([Promise.resolve(123)])` fails at compile time
76
82
  // rather than failing opaquely inside the driver. The driver itself
77
83
  // inspects each promise's internal shape and rejects non-query
78
84
  // promises at runtime; the type narrows that to a static error.
79
- type NeonQueryPromise = ReturnType<NeonSqlCall>
80
- type NeonSqlCall = (queryText: string, params?: readonly unknown[]) => Promise<unknown[]>
81
- export type NeonSql = NeonSqlCall & {
85
+ type NeonQueryPromise = ReturnType<NeonQueryCall>
86
+ type NeonQueryCall = (queryText: string, params?: readonly unknown[]) => Promise<unknown[]>
87
+ export type NeonSql = {
88
+ query: NeonQueryCall
82
89
  transaction: (queries: NeonQueryPromise[]) => Promise<unknown[][]>
83
90
  }
84
91
 
@@ -103,7 +110,7 @@ const DDL_LOCK_KEY_REVISION_SUB = 0x7273_6e72 // 'rsnr'
103
110
  const DURABLE_SYNC_COMMIT_LEVELS = new Set(['local', 'on', 'remote_write', 'remote_apply'])
104
111
 
105
112
  export async function assertDurableSyncCommit(sql: NeonSql): Promise<void> {
106
- const rows = await sql(`SHOW synchronous_commit`, []) as Array<{ synchronous_commit?: unknown }>
113
+ const rows = await sql.query(`SHOW synchronous_commit`, []) as Array<{ synchronous_commit?: unknown }>
107
114
  const level = String(rows[0]?.synchronous_commit ?? '').trim().toLowerCase()
108
115
  if (!DURABLE_SYNC_COMMIT_LEVELS.has(level)) {
109
116
  throw new Error(
@@ -156,7 +163,7 @@ const SCHEMA_PG = [
156
163
 
157
164
  // Generic statement-builder helpers shared by both Neon planes
158
165
  // (workspace_revision here + the objstore tables in store-neon.ts).
159
- // They remove the repeated `{ run/get: async (...args) => sql(...) }`
166
+ // They remove the repeated `{ run/get: async (...args) => sql.query(...) }`
160
167
  // wrapper for the statements whose positional args map straight to
161
168
  // the query's `$1..$N` placeholders and whose rows need no coercion.
162
169
  // Statements that coerce BIGINT (via `num`) or map snake_case rows
@@ -165,14 +172,14 @@ const SCHEMA_PG = [
165
172
 
166
173
  // Trivial passthrough write: args → $1..$N in order, no result shape.
167
174
  export function runStmt<P extends unknown[]>(sql: NeonSql, query: string): RunStmt<P> {
168
- return { run: async (...args: P) => { await sql(query, args as readonly unknown[]) } }
175
+ return { run: async (...args: P) => { await sql.query(query, args as readonly unknown[]) } }
169
176
  }
170
177
 
171
178
  // First-row read with no coercion (callers needing BIGINT→number or
172
179
  // snake_case mapping build their own). Returns `undefined` on the
173
180
  // empty result set, matching the SQLite `wrapGet` contract.
174
181
  export function getRowStmt<P extends unknown[], T>(sql: NeonSql, query: string): GetStmt<P, T> {
175
- return { get: async (...args: P) => (await sql(query, args as readonly unknown[]) as T[])[0] }
182
+ return { get: async (...args: P) => (await sql.query(query, args as readonly unknown[]) as T[])[0] }
176
183
  }
177
184
 
178
185
  // Per-statement builders — extracted so `openNeonDb` stays small
@@ -184,7 +191,7 @@ function buildHeadFor(sql: NeonSql): GetStmt<[string], { id: string }> {
184
191
 
185
192
  function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }> {
186
193
  return { get: async (tag, id) => {
187
- const rows = await sql(SEQ_OF_ID_SQL, [tag, id]) as Array<{ seq: number | string }>
194
+ const rows = await sql.query(SEQ_OF_ID_SQL, [tag, id]) as Array<{ seq: number | string }>
188
195
  const r = rows[0]
189
196
  if (!r) return undefined
190
197
  const n = numOrNull(r.seq)
@@ -194,7 +201,7 @@ function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }>
194
201
 
195
202
  function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | null }> {
196
203
  return { get: async (tag) => {
197
- const rows = await sql(LAST_KEYFRAME_SEQ_SQL, [tag]) as Array<{ s: number | string | null }>
204
+ const rows = await sql.query(LAST_KEYFRAME_SEQ_SQL, [tag]) as Array<{ s: number | string | null }>
198
205
  const r = rows[0]
199
206
  return r ? { s: numOrNull(r.s) } : undefined
200
207
  } }
@@ -202,14 +209,14 @@ function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | nul
202
209
 
203
210
  function buildChain(sql: NeonSql, query: string): AllStmt<[string], RevisionRow> {
204
211
  return { all: async (tag) => {
205
- const rows = await sql(query, [tag]) as Array<Record<string, unknown>>
212
+ const rows = await sql.query(query, [tag]) as Array<Record<string, unknown>>
206
213
  return rows.map(mapRevisionRow)
207
214
  } }
208
215
  }
209
216
 
210
217
  function buildChainSeq(sql: NeonSql, query: string): AllStmt<[string, number], RevisionRow> {
211
218
  return { all: async (tag, seq) => {
212
- const rows = await sql(query, [tag, seq]) as Array<Record<string, unknown>>
219
+ const rows = await sql.query(query, [tag, seq]) as Array<Record<string, unknown>>
213
220
  return rows.map(mapRevisionRow)
214
221
  } }
215
222
  }
@@ -218,6 +225,14 @@ function buildRevisionExists(sql: NeonSql): GetStmt<[string, string], unknown> {
218
225
  return getRowStmt(sql, REVISION_EXISTS_SQL)
219
226
  }
220
227
 
228
+ function buildRevisionById(sql: NeonSql): GetStmt<[string, string], RevisionRow> {
229
+ return { get: async (tag, id) => {
230
+ const rows = await sql.query(REVISION_BY_ID_SQL, [tag, id]) as Array<Record<string, unknown>>
231
+ const r = rows[0]
232
+ return r ? mapRevisionRow(r) : undefined
233
+ } }
234
+ }
235
+
221
236
  // Atomic commit of a single revision (Neon backend). Wraps the
222
237
  // dup-check, head-check and gated INSERT in one pipelined
223
238
  // transaction — NO commit-time advisory lock. Cross-replica
@@ -274,7 +289,7 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
274
289
  // matches `base = NULL` on the first revision against the
275
290
  // empty-chain head (also NULL); plain `=` would be NULL → false
276
291
  // and the first revision would never insert.
277
- sql(
292
+ sql.query(
278
293
  GATED_INSERT_SQL_PG,
279
294
  [tag, id, baseNorm, keyframeCol, nonce, ciphertext, signature, createdAt],
280
295
  ),
@@ -288,8 +303,8 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
288
303
  // stable: our id present ⇒ duplicate, else the head moved ⇒
289
304
  // stale-base. (The INSERT itself is still authoritative — its own
290
305
  // single-statement snapshot is what prevents a chain fork.)
291
- sql(REVISION_EXISTS_SQL, [tag, id]),
292
- sql(HEAD_FOR_SQL, [tag]),
306
+ sql.query(REVISION_EXISTS_SQL, [tag, id]),
307
+ sql.query(HEAD_FOR_SQL, [tag]),
293
308
  ])
294
309
  } catch (err) {
295
310
  // A unique-violation reaches here when a cross-replica racer (or
@@ -303,9 +318,9 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
303
318
  // escaping to `handleSave`'s IIFE. Other errors (network, syntax,
304
319
  // type mismatch) rethrow.
305
320
  if (!isUniqueViolation(err)) throw err
306
- const dupRows = await sql(REVISION_EXISTS_SQL, [tag, id]) as Array<unknown>
321
+ const dupRows = await sql.query(REVISION_EXISTS_SQL, [tag, id]) as Array<unknown>
307
322
  if (dupRows.length > 0) return { kind: 'inserted' }
308
- const headRows = await sql(HEAD_FOR_SQL, [tag]) as Array<{ id: string }>
323
+ const headRows = await sql.query(HEAD_FOR_SQL, [tag]) as Array<{ id: string }>
309
324
  return { kind: 'stale-base', head: headRows[0]?.id ?? null }
310
325
  }
311
326
  const insertRows = results[0] as Array<unknown>
@@ -325,7 +340,7 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
325
340
  // in-process Postgres (PGlite) via `mock.module`: that hook can only
326
341
  // intercept a specifier it can RESOLVE, and the optional peer dep
327
342
  // isn't installed in a SQLite-only checkout. The wrapper path always
328
- // resolves — see `server/neon-driver.ts`. Cast through `unknown`
343
+ // resolves — see `server-e2e/neon-driver.ts`. Cast through `unknown`
329
344
  // because the wrapper's `export *` re-exports a `@ts-ignore`'d
330
345
  // (possibly-absent) module, so tsc can't see `neon`'s type here.
331
346
  const mod = (await import('./neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
@@ -343,8 +358,8 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
343
358
  // their DDL (the advisory lock releases at COMMIT; the
344
359
  // `IF NOT EXISTS` clauses then make the second runner a no-op).
345
360
  await sql.transaction([
346
- sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
347
- ...SCHEMA_PG.map((stmt) => sql(stmt, [])),
361
+ sql.query(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
362
+ ...SCHEMA_PG.map((stmt) => sql.query(stmt, [])),
348
363
  ])
349
364
 
350
365
  const handle: Handle = {
@@ -359,6 +374,7 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
359
374
  chainAfterSeq: buildChainSeq(sql, CHAIN_AFTER_SQL),
360
375
  chainFromSeq: buildChainSeq(sql, CHAIN_FROM_SQL),
361
376
  revisionExists: buildRevisionExists(sql),
377
+ revisionById: buildRevisionById(sql),
362
378
  tryCommit: tryCommitNeon(sql),
363
379
  // The serverless HTTP client is stateless — no socket to close.
364
380
  // Async no-op so shutdown's `handle.close()` works uniformly