@preventive/triage 1.0.0-alpha.1 → 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 +11 -11
- package/out/graph.js +3 -3
- package/out/index.html +3 -15
- package/out/view.css +1 -1
- package/out/view.js +50 -48
- package/package.json +10 -3
- package/server/cli.js +22 -0
- package/server/db-neon.ts +29 -23
- package/server/index.ts +26 -7
- package/server/lifecycle.ts +6 -1
- package/server/objstore/blob-vercel.ts +23 -2
- package/server/objstore/store-neon.ts +12 -12
- package/strip-types-loader.js +94 -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.2",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -28,12 +28,18 @@
|
|
|
28
28
|
"#client/sync.js": "./client/sync.js",
|
|
29
29
|
"#client/sync-host.js": "./client/sync-host.js"
|
|
30
30
|
},
|
|
31
|
+
"exports": {
|
|
32
|
+
"./server": "./server/index.ts",
|
|
33
|
+
"./strip-types-loader": "./strip-types-loader.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
31
36
|
"bin": {
|
|
32
|
-
"triage-server": "server/
|
|
37
|
+
"triage-server": "server/cli.js"
|
|
33
38
|
},
|
|
34
39
|
"files": [
|
|
35
40
|
"server/auth.ts",
|
|
36
41
|
"server/bus-receiver.ts",
|
|
42
|
+
"server/cli.js",
|
|
37
43
|
"server/config.ts",
|
|
38
44
|
"server/db-neon.ts",
|
|
39
45
|
"server/db-revision-sql.ts",
|
|
@@ -83,7 +89,8 @@
|
|
|
83
89
|
"out/terminal.js",
|
|
84
90
|
"out/view.css",
|
|
85
91
|
"out/view.html",
|
|
86
|
-
"out/view.js"
|
|
92
|
+
"out/view.js",
|
|
93
|
+
"strip-types-loader.js"
|
|
87
94
|
],
|
|
88
95
|
"scripts": {
|
|
89
96
|
"lint": "node --run lint:js && node --run lint:css && node --run lint:ts",
|
package/server/cli.js
ADDED
|
@@ -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/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()
|
package/server/db-neon.ts
CHANGED
|
@@ -10,9 +10,14 @@
|
|
|
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>
|
|
@@ -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 = {
|
package/server/index.ts
CHANGED
|
@@ -452,11 +452,30 @@ installLifecycle({
|
|
|
452
452
|
|
|
453
453
|
// Bind only after the startup orphan sweep finishes — otherwise a
|
|
454
454
|
// fresh boot could serve traffic against tags whose on-disk state
|
|
455
|
-
// still has residue from a prior crash.
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
// failure unconditionally and returns void), so no outer `.catch`
|
|
460
|
-
// is needed here.
|
|
455
|
+
// still has residue from a prior crash. `startupReap` already
|
|
456
|
+
// resolves on any error (the reaper's own catch logs the failure
|
|
457
|
+
// unconditionally and returns void), so no outer `.catch` is
|
|
458
|
+
// needed here.
|
|
461
459
|
await startupReap
|
|
462
|
-
|
|
460
|
+
|
|
461
|
+
// Start serving: bind the HTTP/WS plane on the configured PORT/HOST.
|
|
462
|
+
// Exported as `start()` so a launcher (server/cli.js — the triage-server
|
|
463
|
+
// bin) or any consumer that `import`ed this module can begin serving once
|
|
464
|
+
// the top-level `await startupReap` above has settled (the server is fully
|
|
465
|
+
// ready — DB open, DDL bootstrapped, objstore reaper swept — by the time
|
|
466
|
+
// the import resolves).
|
|
467
|
+
export function start(): void {
|
|
468
|
+
httpServer.listen(PORT, HOST)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export { httpServer, wss }
|
|
472
|
+
|
|
473
|
+
// Library mode: when this module is `import`ed (rather than run as the
|
|
474
|
+
// entry script) skip the auto-start so consumers can own the bind — e.g.
|
|
475
|
+
// wrap CF Access / framework-preset shims around the assembled
|
|
476
|
+
// `httpServer`, or just call `start()` when ready. Direct invocation via
|
|
477
|
+
// `node server/index.ts` (or the `triage-server` bin) still listens.
|
|
478
|
+
if (import.meta.main) {
|
|
479
|
+
start()
|
|
480
|
+
}
|
|
481
|
+
|
package/server/lifecycle.ts
CHANGED
|
@@ -46,7 +46,12 @@ export function createLifecycle(): Lifecycle {
|
|
|
46
46
|
const inFlight = new Set<Promise<unknown>>()
|
|
47
47
|
function track(promise: Promise<unknown>): void {
|
|
48
48
|
inFlight.add(promise)
|
|
49
|
-
|
|
49
|
+
// Trailing `.catch(() => {})` swallows the rejection that `.finally`
|
|
50
|
+
// propagates through its returned promise — without it, a tracked
|
|
51
|
+
// handler that rejects (or whose caller's catch handler itself
|
|
52
|
+
// throws) trips the `unhandledRejection` catchall below and crashes
|
|
53
|
+
// the process via `fireShutdown(1)`.
|
|
54
|
+
promise.finally(() => inFlight.delete(promise)).catch(() => {})
|
|
50
55
|
}
|
|
51
56
|
let shuttingDown = false
|
|
52
57
|
// Live exit code the in-progress shutdown will pass to `process.exit`.
|
|
@@ -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
|
|
@@ -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 {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Module customization hook that erases TypeScript types using Node's
|
|
2
|
+
// built-in `module.stripTypeScriptTypes()` — the same eraser that powers
|
|
3
|
+
// Node's unflagged `.ts` execution, exposed here as an explicit loader so
|
|
4
|
+
// this package can run its own `.ts` source after it's installed as a
|
|
5
|
+
// dependency. Built on Node's bundled eraser, it needs no third-party dep
|
|
6
|
+
// (cf. `amaro`, which is just the library Node already bundles for this).
|
|
7
|
+
//
|
|
8
|
+
// Why this is needed at all — Node DISABLES built-in `.ts` stripping for
|
|
9
|
+
// any file under `node_modules` (it throws ERR_UNSUPPORTED_NODE_MODULES_
|
|
10
|
+
// TYPE_STRIPPING), which is exactly where this package's `.ts` files live
|
|
11
|
+
// once it's a dependency. A registered `load` hook is not bound by that
|
|
12
|
+
// rule, so it restores `.ts` execution from inside `node_modules`.
|
|
13
|
+
//
|
|
14
|
+
// Registered as an entry point — `@preventive/triage/strip-types-loader` —
|
|
15
|
+
// so consumers can run the server (or import `@preventive/triage/server`)
|
|
16
|
+
// with the hook active:
|
|
17
|
+
// node --import @preventive/triage/strip-types-loader <entry>
|
|
18
|
+
//
|
|
19
|
+
// `registerHooks` (synchronous, in-thread) is used over the async,
|
|
20
|
+
// worker-thread `module.register`: stripping is a pure synchronous string
|
|
21
|
+
// transform, so there's no reason to round-trip every load through a
|
|
22
|
+
// worker — and the read below is synchronous too.
|
|
23
|
+
//
|
|
24
|
+
// We must read + strip the source OURSELVES rather than delegate to
|
|
25
|
+
// `nextLoad`: under `node_modules`, `nextLoad`'s default format detection
|
|
26
|
+
// is what throws ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, before our
|
|
27
|
+
// hook ever sees the source. Reading the bytes and short-circuiting with
|
|
28
|
+
// an explicit format sidesteps that gate.
|
|
29
|
+
//
|
|
30
|
+
// `mode: 'strip'` (types elided, no enum/namespace transform) suffices —
|
|
31
|
+
// the repo is `erasableSyntaxOnly` (tsconfig.json). Strip mode replaces
|
|
32
|
+
// each elided type with equal-width whitespace, so line AND column
|
|
33
|
+
// positions survive 1:1 and stack traces stay exact without a source map.
|
|
34
|
+
|
|
35
|
+
import { isUtf8 } from 'node:buffer'
|
|
36
|
+
import { readFileSync } from 'node:fs'
|
|
37
|
+
import { registerHooks, stripTypeScriptTypes } from 'node:module'
|
|
38
|
+
import { fileURLToPath } from 'node:url'
|
|
39
|
+
|
|
40
|
+
// `.ts` / `.mts` -> ESM, `.cts` -> CommonJS. The `kind` group is the
|
|
41
|
+
// optional `m`/`c` infix; absent (plain `.ts`) defaults to ESM, which is
|
|
42
|
+
// correct for this `"type": "module"` package.
|
|
43
|
+
const TS_RE = /\.(?<kind>[cm])?ts$/u
|
|
44
|
+
|
|
45
|
+
// Defence-in-depth: assert strip mode only ERASED — every output character
|
|
46
|
+
// is either unchanged, replaced by whitespace, or replaced by a semicolon
|
|
47
|
+
// (swc's ASI guard — swc-project/swc#9331). This enforces at runtime what
|
|
48
|
+
// tsconfig's `erasableSyntaxOnly` asserts at type-check time: nothing but
|
|
49
|
+
// type syntax was removed, so the bytes Node runs can't silently diverge
|
|
50
|
+
// from the bytes we wrote.
|
|
51
|
+
//
|
|
52
|
+
// The comparison is per-CODE-POINT, not per-byte. swc preserves byte
|
|
53
|
+
// offsets by swapping each elided multi-byte character for a *same-width*
|
|
54
|
+
// Unicode whitespace char — e.g. an em-dash (U+2014) in a type-level
|
|
55
|
+
// comment becomes U+2002 EN SPACE, not ASCII 0x20 — so a raw-byte check
|
|
56
|
+
// would false-positive on the many such comments in this codebase. `\s`
|
|
57
|
+
// matches those Unicode spaces, and strip preserves code-unit length too,
|
|
58
|
+
// so string indices stay aligned.
|
|
59
|
+
const WHITESPACE = /\s/u
|
|
60
|
+
|
|
61
|
+
function assertOnlyErased(url, source, stripped) {
|
|
62
|
+
if (source.length !== stripped.length) {
|
|
63
|
+
throw new Error(`strip-types-loader: ${url} changed length ${source.length} -> ${stripped.length}; not a pure type erasure`)
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
66
|
+
const ch = stripped[i]
|
|
67
|
+
if (ch === source[i] || ch === ';' || WHITESPACE.test(ch)) continue
|
|
68
|
+
throw new Error(`strip-types-loader: ${url} char ${i} changed ${JSON.stringify(source[i])} -> ${JSON.stringify(ch)}; not a pure type erasure`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
registerHooks({
|
|
73
|
+
load(url, context, nextLoad) {
|
|
74
|
+
const match = TS_RE.exec(new URL(url).pathname)
|
|
75
|
+
if (match) {
|
|
76
|
+
const bytes = readFileSync(fileURLToPath(url))
|
|
77
|
+
// Assert the source is valid UTF-8 before decoding: a mis-encoded
|
|
78
|
+
// file (latin-1, UTF-16, a stray binary) would otherwise decode with
|
|
79
|
+
// U+FFFD replacement chars and we'd strip / execute silent garbage.
|
|
80
|
+
if (!isUtf8(bytes)) {
|
|
81
|
+
throw new Error(`strip-types-loader: ${url} is not valid UTF-8`)
|
|
82
|
+
}
|
|
83
|
+
const raw = bytes.toString('utf8')
|
|
84
|
+
const stripped = stripTypeScriptTypes(raw, { mode: 'strip' })
|
|
85
|
+
assertOnlyErased(url, raw, stripped)
|
|
86
|
+
return {
|
|
87
|
+
format: match.groups.kind === 'c' ? 'commonjs' : 'module',
|
|
88
|
+
source: stripped,
|
|
89
|
+
shortCircuit: true,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return nextLoad(url, context)
|
|
93
|
+
},
|
|
94
|
+
})
|