@preventive/triage 1.0.0-alpha.1 → 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.
- package/api/reap.ts +79 -0
- package/common/save-error-reason.ts +20 -7
- package/common/server-info.ts +30 -0
- package/out/brotli-fallback.js +1 -1
- package/out/client-admin.js +28 -0
- package/out/client-managed.js +1 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +5 -4
- package/out/index.html +4 -15
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +78 -52
- package/package.json +70 -54
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +8 -8
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +31 -25
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +97 -9
- package/{server → server-e2e}/hub.ts +7 -8
- package/{server → server-e2e}/index.ts +67 -43
- package/{server → server-e2e}/lifecycle.ts +14 -5
- package/{server → server-e2e}/npm-proxy.ts +1 -1
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +13 -15
- package/{server → server-e2e}/objstore/init.ts +47 -13
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +19 -19
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +21 -31
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +22 -17
- package/{server → server-e2e}/sync-handlers.ts +172 -117
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/strip-types-loader.js +94 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/neon-driver.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /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.
|
|
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,51 +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/
|
|
38
|
+
"triage-server": "server-e2e/cli.js"
|
|
33
39
|
},
|
|
34
40
|
"files": [
|
|
35
|
-
"
|
|
36
|
-
"server/
|
|
37
|
-
"server/
|
|
38
|
-
"server/
|
|
39
|
-
"server/
|
|
40
|
-
"server/db-
|
|
41
|
-
"server/db.ts",
|
|
42
|
-
"server/
|
|
43
|
-
"server/
|
|
44
|
-
"server/
|
|
45
|
-
"server/
|
|
46
|
-
"server/
|
|
47
|
-
"server/
|
|
48
|
-
"server/
|
|
49
|
-
"server/
|
|
50
|
-
"server/objstore/blob.ts",
|
|
51
|
-
"server/objstore/
|
|
52
|
-
"server/objstore/
|
|
53
|
-
"server/objstore/
|
|
54
|
-
"server/objstore/
|
|
55
|
-
"server/objstore/
|
|
56
|
-
"server/objstore/
|
|
57
|
-
"server/objstore/
|
|
58
|
-
"server/objstore/
|
|
59
|
-
"server/objstore/
|
|
60
|
-
"server/
|
|
61
|
-
"server/
|
|
62
|
-
"server/
|
|
63
|
-
"server/
|
|
64
|
-
"server/
|
|
65
|
-
"server/
|
|
66
|
-
"server/
|
|
67
|
-
"server/
|
|
68
|
-
"server/
|
|
69
|
-
"server/
|
|
70
|
-
"server/
|
|
71
|
-
"server/
|
|
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",
|
|
72
83
|
"common/save-error-reason.ts",
|
|
84
|
+
"common/server-info.ts",
|
|
73
85
|
"common/utf8.d.ts",
|
|
74
86
|
"common/utf8.js",
|
|
75
87
|
"out/brotli-fallback.js",
|
|
88
|
+
"out/client-admin.js",
|
|
89
|
+
"out/client-managed.js",
|
|
76
90
|
"out/client-sync.js",
|
|
77
91
|
"out/graph.js",
|
|
78
92
|
"out/icon-maskable.svg",
|
|
@@ -83,37 +97,39 @@
|
|
|
83
97
|
"out/terminal.js",
|
|
84
98
|
"out/view.css",
|
|
85
99
|
"out/view.html",
|
|
86
|
-
"out/view.js"
|
|
100
|
+
"out/view.js",
|
|
101
|
+
"strip-types-loader.js"
|
|
87
102
|
],
|
|
88
103
|
"scripts": {
|
|
89
104
|
"lint": "node --run lint:js && node --run lint:css && node --run lint:ts",
|
|
90
|
-
"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/",
|
|
91
106
|
"lint:css": "stylelint 'ui/**/*.css'",
|
|
92
107
|
"lint:ts": "tsc --noEmit",
|
|
93
108
|
"prepack": "node --run build",
|
|
94
109
|
"build": "node build.js build",
|
|
95
110
|
"serve": "node build.js serve",
|
|
96
|
-
"server": "node server/index.ts",
|
|
97
|
-
"
|
|
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"
|
|
98
114
|
},
|
|
99
115
|
"devDependencies": {
|
|
100
|
-
"@electric-sql/pglite": "^0.
|
|
101
|
-
"@exodus/stasis": "^1.0.0-
|
|
102
|
-
"@noble/ciphers": "^2.
|
|
103
|
-
"@preventive/terminal": "^1.
|
|
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",
|
|
104
120
|
"@rray/frontend": "^1.0.0",
|
|
105
|
-
"@stylistic/stylelint-plugin": "^5.
|
|
106
|
-
"@types/node": "^
|
|
121
|
+
"@stylistic/stylelint-plugin": "^5.3.0",
|
|
122
|
+
"@types/node": "^26.2.0",
|
|
107
123
|
"@types/ws": "^8.18.1",
|
|
108
124
|
"brotli": "^1.3.3",
|
|
109
|
-
"esbuild": "^0.
|
|
110
|
-
"lit": "^3.3.
|
|
125
|
+
"esbuild": "^0.28.2",
|
|
126
|
+
"lit": "^3.3.3",
|
|
111
127
|
"minify-html-literals": "^1.3.5",
|
|
112
|
-
"oxlint": "^1.
|
|
128
|
+
"oxlint": "^1.79.0",
|
|
113
129
|
"prismjs": "^1.30.0",
|
|
114
|
-
"stylelint": "^17.
|
|
130
|
+
"stylelint": "^17.14.1",
|
|
115
131
|
"stylelint-config-recommended": "^18.0.0",
|
|
116
|
-
"typescript": "^
|
|
132
|
+
"typescript": "^7.0.2"
|
|
117
133
|
},
|
|
118
134
|
"dependencies": {
|
|
119
135
|
"ws": "^8.20.1"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Same-origin gate
|
|
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
|
-
//
|
|
5
|
-
//
|
|
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
|
}
|
|
@@ -106,5 +110,5 @@ export function createAuth(deps: {
|
|
|
106
110
|
send(socket, { type: 'authenticated' })
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
return { requiresAuth, handleAuthenticate, sendUnauthorized }
|
|
113
|
+
return { requiresAuth, passwordConfigured: CONFIGURED_PASSWORD_HMAC != null, handleAuthenticate, sendUnauthorized }
|
|
110
114
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Cross-instance bus receiver: maps a `BusMessage` envelope (lookup
|
|
2
2
|
// hint + sender from the Postgres LISTEN/NOTIFY channel) into a local
|
|
3
|
-
// wire broadcast. Pulled out of `server/index.ts` so the rev / objput /
|
|
3
|
+
// wire broadcast. Pulled out of `server-e2e/index.ts` so the rev / objput /
|
|
4
4
|
// objdel branches — including the keyframe boolean coercion and the
|
|
5
5
|
// `objectMetaWire` shape — are testable without bringing up the whole
|
|
6
6
|
// server.
|
|
@@ -69,13 +69,13 @@ export function createBusReceiver(deps: BusReceiverDeps): (msg: BusMessage) => P
|
|
|
69
69
|
if (debug) console.warn(`pubsub: objput ${msg.res.slice(0, 8)}… missing for ${msg.tag.slice(0, 12)}…`)
|
|
70
70
|
return
|
|
71
71
|
}
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
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
79
|
broadcastLocalRaw(msg.tag, JSON.stringify({
|
|
80
80
|
type: 'objstore-put',
|
|
81
81
|
workspaceTag: msg.tag,
|
|
@@ -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
|
-
//
|
|
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
|
|
178
|
-
//
|
|
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
|
|
15
|
-
// `sql(text, params)
|
|
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
|
|
@@ -71,15 +76,16 @@ export { num, numOrNull }
|
|
|
71
76
|
// side. Used by the DDL bootstrap below to prevent partial schema
|
|
72
77
|
// creation on a transient mid-batch network failure.
|
|
73
78
|
//
|
|
74
|
-
// `transaction` is typed against the query promise
|
|
79
|
+
// `transaction` is typed against the query promise `sql.query`
|
|
75
80
|
// returns (NOT plain `Promise<unknown>`) so a misuse like
|
|
76
81
|
// `sql.transaction([Promise.resolve(123)])` fails at compile time
|
|
77
82
|
// rather than failing opaquely inside the driver. The driver itself
|
|
78
83
|
// inspects each promise's internal shape and rejects non-query
|
|
79
84
|
// promises at runtime; the type narrows that to a static error.
|
|
80
|
-
type NeonQueryPromise = ReturnType<
|
|
81
|
-
type
|
|
82
|
-
export type NeonSql =
|
|
85
|
+
type NeonQueryPromise = ReturnType<NeonQueryCall>
|
|
86
|
+
type NeonQueryCall = (queryText: string, params?: readonly unknown[]) => Promise<unknown[]>
|
|
87
|
+
export type NeonSql = {
|
|
88
|
+
query: NeonQueryCall
|
|
83
89
|
transaction: (queries: NeonQueryPromise[]) => Promise<unknown[][]>
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -104,7 +110,7 @@ const DDL_LOCK_KEY_REVISION_SUB = 0x7273_6e72 // 'rsnr'
|
|
|
104
110
|
const DURABLE_SYNC_COMMIT_LEVELS = new Set(['local', 'on', 'remote_write', 'remote_apply'])
|
|
105
111
|
|
|
106
112
|
export async function assertDurableSyncCommit(sql: NeonSql): Promise<void> {
|
|
107
|
-
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 }>
|
|
108
114
|
const level = String(rows[0]?.synchronous_commit ?? '').trim().toLowerCase()
|
|
109
115
|
if (!DURABLE_SYNC_COMMIT_LEVELS.has(level)) {
|
|
110
116
|
throw new Error(
|
|
@@ -157,7 +163,7 @@ const SCHEMA_PG = [
|
|
|
157
163
|
|
|
158
164
|
// Generic statement-builder helpers shared by both Neon planes
|
|
159
165
|
// (workspace_revision here + the objstore tables in store-neon.ts).
|
|
160
|
-
// They remove the repeated `{ run/get: async (...args) => sql(...) }`
|
|
166
|
+
// They remove the repeated `{ run/get: async (...args) => sql.query(...) }`
|
|
161
167
|
// wrapper for the statements whose positional args map straight to
|
|
162
168
|
// the query's `$1..$N` placeholders and whose rows need no coercion.
|
|
163
169
|
// Statements that coerce BIGINT (via `num`) or map snake_case rows
|
|
@@ -166,14 +172,14 @@ const SCHEMA_PG = [
|
|
|
166
172
|
|
|
167
173
|
// Trivial passthrough write: args → $1..$N in order, no result shape.
|
|
168
174
|
export function runStmt<P extends unknown[]>(sql: NeonSql, query: string): RunStmt<P> {
|
|
169
|
-
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[]) } }
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
// First-row read with no coercion (callers needing BIGINT→number or
|
|
173
179
|
// snake_case mapping build their own). Returns `undefined` on the
|
|
174
180
|
// empty result set, matching the SQLite `wrapGet` contract.
|
|
175
181
|
export function getRowStmt<P extends unknown[], T>(sql: NeonSql, query: string): GetStmt<P, T> {
|
|
176
|
-
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] }
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
// Per-statement builders — extracted so `openNeonDb` stays small
|
|
@@ -185,7 +191,7 @@ function buildHeadFor(sql: NeonSql): GetStmt<[string], { id: string }> {
|
|
|
185
191
|
|
|
186
192
|
function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }> {
|
|
187
193
|
return { get: async (tag, id) => {
|
|
188
|
-
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 }>
|
|
189
195
|
const r = rows[0]
|
|
190
196
|
if (!r) return undefined
|
|
191
197
|
const n = numOrNull(r.seq)
|
|
@@ -195,7 +201,7 @@ function buildSeqOfId(sql: NeonSql): GetStmt<[string, string], { seq: number }>
|
|
|
195
201
|
|
|
196
202
|
function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | null }> {
|
|
197
203
|
return { get: async (tag) => {
|
|
198
|
-
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 }>
|
|
199
205
|
const r = rows[0]
|
|
200
206
|
return r ? { s: numOrNull(r.s) } : undefined
|
|
201
207
|
} }
|
|
@@ -203,14 +209,14 @@ function buildLastKeyframeSeq(sql: NeonSql): GetStmt<[string], { s: number | nul
|
|
|
203
209
|
|
|
204
210
|
function buildChain(sql: NeonSql, query: string): AllStmt<[string], RevisionRow> {
|
|
205
211
|
return { all: async (tag) => {
|
|
206
|
-
const rows = await sql(query, [tag]) as Array<Record<string, unknown>>
|
|
212
|
+
const rows = await sql.query(query, [tag]) as Array<Record<string, unknown>>
|
|
207
213
|
return rows.map(mapRevisionRow)
|
|
208
214
|
} }
|
|
209
215
|
}
|
|
210
216
|
|
|
211
217
|
function buildChainSeq(sql: NeonSql, query: string): AllStmt<[string, number], RevisionRow> {
|
|
212
218
|
return { all: async (tag, seq) => {
|
|
213
|
-
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>>
|
|
214
220
|
return rows.map(mapRevisionRow)
|
|
215
221
|
} }
|
|
216
222
|
}
|
|
@@ -221,7 +227,7 @@ function buildRevisionExists(sql: NeonSql): GetStmt<[string, string], unknown> {
|
|
|
221
227
|
|
|
222
228
|
function buildRevisionById(sql: NeonSql): GetStmt<[string, string], RevisionRow> {
|
|
223
229
|
return { get: async (tag, id) => {
|
|
224
|
-
const rows = await sql(REVISION_BY_ID_SQL, [tag, id]) as Array<Record<string, unknown>>
|
|
230
|
+
const rows = await sql.query(REVISION_BY_ID_SQL, [tag, id]) as Array<Record<string, unknown>>
|
|
225
231
|
const r = rows[0]
|
|
226
232
|
return r ? mapRevisionRow(r) : undefined
|
|
227
233
|
} }
|
|
@@ -283,7 +289,7 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
283
289
|
// matches `base = NULL` on the first revision against the
|
|
284
290
|
// empty-chain head (also NULL); plain `=` would be NULL → false
|
|
285
291
|
// and the first revision would never insert.
|
|
286
|
-
sql(
|
|
292
|
+
sql.query(
|
|
287
293
|
GATED_INSERT_SQL_PG,
|
|
288
294
|
[tag, id, baseNorm, keyframeCol, nonce, ciphertext, signature, createdAt],
|
|
289
295
|
),
|
|
@@ -297,8 +303,8 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
297
303
|
// stable: our id present ⇒ duplicate, else the head moved ⇒
|
|
298
304
|
// stale-base. (The INSERT itself is still authoritative — its own
|
|
299
305
|
// single-statement snapshot is what prevents a chain fork.)
|
|
300
|
-
sql(REVISION_EXISTS_SQL, [tag, id]),
|
|
301
|
-
sql(HEAD_FOR_SQL, [tag]),
|
|
306
|
+
sql.query(REVISION_EXISTS_SQL, [tag, id]),
|
|
307
|
+
sql.query(HEAD_FOR_SQL, [tag]),
|
|
302
308
|
])
|
|
303
309
|
} catch (err) {
|
|
304
310
|
// A unique-violation reaches here when a cross-replica racer (or
|
|
@@ -312,9 +318,9 @@ function tryCommitNeon(sql: NeonSql): (input: RevisionInsert) => Promise<CommitR
|
|
|
312
318
|
// escaping to `handleSave`'s IIFE. Other errors (network, syntax,
|
|
313
319
|
// type mismatch) rethrow.
|
|
314
320
|
if (!isUniqueViolation(err)) throw err
|
|
315
|
-
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>
|
|
316
322
|
if (dupRows.length > 0) return { kind: 'inserted' }
|
|
317
|
-
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 }>
|
|
318
324
|
return { kind: 'stale-base', head: headRows[0]?.id ?? null }
|
|
319
325
|
}
|
|
320
326
|
const insertRows = results[0] as Array<unknown>
|
|
@@ -334,7 +340,7 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
|
|
|
334
340
|
// in-process Postgres (PGlite) via `mock.module`: that hook can only
|
|
335
341
|
// intercept a specifier it can RESOLVE, and the optional peer dep
|
|
336
342
|
// isn't installed in a SQLite-only checkout. The wrapper path always
|
|
337
|
-
// resolves — see `server/neon-driver.ts`. Cast through `unknown`
|
|
343
|
+
// resolves — see `server-e2e/neon-driver.ts`. Cast through `unknown`
|
|
338
344
|
// because the wrapper's `export *` re-exports a `@ts-ignore`'d
|
|
339
345
|
// (possibly-absent) module, so tsc can't see `neon`'s type here.
|
|
340
346
|
const mod = (await import('./neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
|
|
@@ -352,8 +358,8 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
|
|
|
352
358
|
// their DDL (the advisory lock releases at COMMIT; the
|
|
353
359
|
// `IF NOT EXISTS` clauses then make the second runner a no-op).
|
|
354
360
|
await sql.transaction([
|
|
355
|
-
sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_REVISION, DDL_LOCK_KEY_REVISION_SUB]),
|
|
356
|
-
...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, [])),
|
|
357
363
|
])
|
|
358
364
|
|
|
359
365
|
const handle: Handle = {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// Shared SQL + row-mapping for the `workspace_revision` chain, used by
|
|
2
2
|
// BOTH backends — `./db.ts` (SQLite) and `./db-neon.ts` (Neon/Postgres).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// duplication is collapsed here so a query edit can't silently drift
|
|
6
|
-
// between backends.
|
|
3
|
+
// Single source of truth (modulo `?`↔`$N` placeholders) so a query edit
|
|
4
|
+
// can't silently drift between backends.
|
|
7
5
|
//
|
|
8
6
|
// Single source of truth, in `$N` (Postgres) form:
|
|
9
7
|
// • the read queries (`headFor`, `seqOfId`, `lastKeyframeSeq`, the
|
|
@@ -60,11 +58,10 @@ export function numOrNull(v: unknown): number | null {
|
|
|
60
58
|
// `Record<string, unknown>` rows whose `keyframe` may be a number OR (on
|
|
61
59
|
// a future driver change) a string; `node:sqlite` hands back native
|
|
62
60
|
// numbers. The `num`/`numOrNull` coercion is safe over both — a native
|
|
63
|
-
// `0`/`1` integer passes through unchanged,
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// 0 / 1 via the `=== 1` check the chain-broadcast contract relies on.
|
|
61
|
+
// `0`/`1` integer passes through unchanged, while Neon rows keep their
|
|
62
|
+
// defensive string→number coercion. `base` is the only nullable column
|
|
63
|
+
// (first revision); `keyframe` collapses to a strict 0 / 1 via the
|
|
64
|
+
// `=== 1` check the chain-broadcast contract relies on.
|
|
68
65
|
export function mapRevisionRow(r: Record<string, unknown>): RevisionRow {
|
|
69
66
|
return {
|
|
70
67
|
base: (r['base'] as string | null) ?? null,
|
|
@@ -109,7 +106,7 @@ export const CHAIN_FROM_SQL =
|
|
|
109
106
|
export const REVISION_EXISTS_SQL =
|
|
110
107
|
`SELECT 1 AS one FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
|
|
111
108
|
// Fetch a single revision row by content-addressed id. The cross-instance
|
|
112
|
-
// pubsub (server/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
|
|
109
|
+
// pubsub (server-e2e/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
|
|
113
110
|
// because the full `workspace-state` broadcast envelope is bounded by
|
|
114
111
|
// `MAX_CIPHERTEXT_LEN` (2 MiB) and Postgres NOTIFY caps payloads at ~8 KB.
|
|
115
112
|
// The receiver re-fetches the row from this shared table to construct the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Shared async-statement primitives. Both `server/db.ts` (workspace_revision
|
|
2
|
-
// chain) and `server/objstore/store.ts` (objstore tables) expose Handles
|
|
1
|
+
// Shared async-statement primitives. Both `server-e2e/db.ts` (workspace_revision
|
|
2
|
+
// chain) and `server-e2e/objstore/store.ts` (objstore tables) expose Handles
|
|
3
3
|
// whose statements look like `{ get(...) → Promise<…>, all(...) → Promise<[…]>,
|
|
4
4
|
// run(...) → Promise<void> }`. The underlying `node:sqlite` driver is
|
|
5
5
|
// synchronous; the wrappers below catch sync errors and route them through
|