@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.1",
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/index.ts"
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 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
@@ -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 the call form
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<NeonSqlCall>
81
- type NeonSqlCall = (queryText: string, params?: readonly unknown[]) => Promise<unknown[]>
82
- 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
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. Top-level await is fine
456
- // for an entry-point ESM module (no other module imports this for
457
- // its exports — the side effect IS the program). `startupReap`
458
- // already resolves on any error (the reaper's own catch logs the
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
- httpServer.listen(PORT, HOST)
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
+
@@ -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
- promise.finally(() => inFlight.delete(promise))
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 || res.blob.size == 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: res.blob.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
+ })