@preventive/triage 1.0.0-alpha.27 → 1.0.0-alpha.28

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 (81) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +16 -16
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +18 -28
  19. package/server-e2e/static.ts +3 -4
  20. package/server-managed/bundle-advisories.ts +53 -0
  21. package/server-managed/bundle-advisory-inventory.ts +75 -0
  22. package/server-managed/bundle-build-leases.ts +41 -0
  23. package/server-managed/bundle-build-worker.js +44 -0
  24. package/server-managed/bundle-build.ts +141 -0
  25. package/server-managed/bundle-cache.ts +22 -13
  26. package/server-managed/bundle-summary-cache.ts +27 -9
  27. package/server-managed/bundle.ts +66 -1
  28. package/server-managed/cache-storage.ts +4 -0
  29. package/server-managed/content-access.ts +1 -1
  30. package/server-managed/db-methods.ts +122 -20
  31. package/server-managed/db-neon.ts +121 -32
  32. package/server-managed/db-schema.ts +2 -0
  33. package/server-managed/db.ts +5 -1
  34. package/server-managed/github-app.ts +10 -15
  35. package/server-managed/github-errors.ts +21 -0
  36. package/server-managed/github-issues.ts +1 -1
  37. package/server-managed/github-metadata.ts +34 -1
  38. package/server-managed/github-pulls.ts +142 -30
  39. package/server-managed/http.ts +244 -131
  40. package/server-managed/index.ts +25 -9
  41. package/server-managed/management-catalog.ts +45 -0
  42. package/server-managed/object-storage-vercel.ts +1 -1
  43. package/server-managed/public-workspace.ts +20 -14
  44. package/server-managed/report-migration.ts +1 -1
  45. package/server-managed/report-sources.ts +14 -4
  46. package/server-managed/repository-browser.ts +3 -0
  47. package/server-managed/repository-discovery.ts +1 -1
  48. package/server-managed/revisions.ts +35 -0
  49. package/server-managed/sql-postgres.ts +1 -0
  50. package/server-managed/sql.ts +14 -3
  51. package/server-managed/storage-db.ts +12 -5
  52. package/server-managed/storage-maintenance.ts +37 -22
  53. package/server-managed/storage-payload.ts +16 -4
  54. package/server-managed/storage-stores.ts +2 -2
  55. package/server-managed/storage.ts +6 -4
  56. package/server-managed/team-feed.ts +50 -5
  57. package/server-managed/team-reports.ts +85 -22
  58. package/server-managed/workspace-shares.ts +15 -3
  59. package/report/index.js +0 -260
  60. package/report/src/finding-id.js +0 -80
  61. package/report/src/finding.js +0 -314
  62. package/report/src/labels.js +0 -35
  63. package/report/src/md-structure.js +0 -471
  64. package/report/src/md-text.js +0 -167
  65. package/report/src/meta.js +0 -76
  66. package/report/src/parse-codex.js +0 -149
  67. package/report/src/parse-deepsec.js +0 -199
  68. package/report/src/parse-deepview-fields.js +0 -375
  69. package/report/src/parse-deepview-md.js +0 -186
  70. package/report/src/parse-md-id.js +0 -137
  71. package/report/src/parse-md.js +0 -323
  72. package/report/src/parse-piolium-id.js +0 -79
  73. package/report/src/parse-piolium-rows.js +0 -126
  74. package/report/src/parse-piolium-tokens.js +0 -185
  75. package/report/src/parse-piolium.js +0 -399
  76. package/report/src/security.js +0 -63
  77. package/report/src/severity.js +0 -18
  78. package/report/src/utf8.js +0 -21
  79. package/report/src/write-md-finding.js +0 -273
  80. package/report/src/write-md.js +0 -291
  81. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -7,6 +7,8 @@
7
7
  // report auto-links to its bundle.
8
8
  import { createHash } from 'node:crypto'
9
9
  import type { Buffer } from 'node:buffer'
10
+ import { StringDecoder } from 'node:string_decoder'
11
+ import { createBrotliDecompress } from 'node:zlib'
10
12
 
11
13
  // `sha512-<base64>` identity for a bundle's bytes. MUST stay byte-identical to
12
14
  // the client's common/integrity.js (SHA-512 → standard base64 WITH padding) so
@@ -21,12 +23,75 @@ export function bundleIntegrity(bytes: Buffer): string {
21
23
  // `bundleKind`: sourcemap (.map) or stasis (stasis.code.br / .stasis.code.br).
22
24
  // null for anything else (stored anyway; the kind is informational).
23
25
  export function bundleKind(filename: string): 'sourcemap' | 'stasis' | null {
24
- const lower = filename.toLowerCase()
26
+ // Browsers insert a duplicate-download counter before the final extension.
27
+ const lower = filename.toLowerCase().replace(/ \(\d+\)(?=\.[^.]*$)/u, '')
25
28
  if (lower.endsWith('.map')) return 'sourcemap'
26
29
  if (lower === 'stasis.code.br' || lower.endsWith('.stasis.code.br')) return 'stasis'
27
30
  return null
28
31
  }
29
32
 
33
+ interface BundleRepo { github?: unknown; directory?: unknown }
34
+ const HEADER_KEYS = new Set(['version', 'config', 'repo', 'package'])
35
+ const MAX_HEADER_BYTES = 64 * 1024
36
+
37
+ // Stasis writes origin metadata before entries, formats and source bodies.
38
+ // undefined means another chunk is needed; null means the header has no repo.
39
+ function headerRepo(text: string): BundleRepo | null | undefined {
40
+ let depth = 0, escaped = false, expectKey = false, quoted = false
41
+ let key = '', start = 0, valueStart = 0
42
+ for (let i = 0; i < text.length; i++) {
43
+ const c = text[i]
44
+ if (quoted) {
45
+ if (escaped) { escaped = false; continue }
46
+ if (c === '\\') { escaped = true; continue }
47
+ if (c !== '"') continue
48
+ quoted = false
49
+ if (depth === 1 && expectKey) {
50
+ key = JSON.parse(text.slice(start, i + 1)) as string
51
+ if (!HEADER_KEYS.has(key)) return null
52
+ expectKey = false
53
+ }
54
+ continue
55
+ }
56
+ if (c === '"') { quoted = true; start = i; continue }
57
+ if (c === '{' || c === '[') {
58
+ if (depth === 0 && c !== '{') return null
59
+ depth++
60
+ if (depth === 1) expectKey = true
61
+ } else if (depth === 1 && (c === ',' || c === '}')) {
62
+ if (key === 'repo') {
63
+ const repo: unknown = JSON.parse(text.slice(valueStart, i))
64
+ return repo != null && typeof repo === 'object' && !Array.isArray(repo) ? repo as BundleRepo : null
65
+ }
66
+ if (c === '}') return null
67
+ expectKey = true
68
+ } else if (c === '}' || c === ']') depth--
69
+ else if (depth === 1 && c === ':') valueStart = i + 1
70
+ else if (depth === 0 && c?.trim()) return null
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ // Decode only a bounded prefix, stopping as soon as repo is read or the
76
+ // header ends. Unknown/malformed archives remain uploadable as opaque bytes.
77
+ export async function bundleRepo(bytes: Buffer): Promise<BundleRepo | null> {
78
+ const stream = createBrotliDecompress({ chunkSize: 4096 })
79
+ const decoder = new StringDecoder('utf8')
80
+ let size = 0, text = ''
81
+ stream.end(bytes)
82
+ try {
83
+ for await (const chunk of stream) {
84
+ size += chunk.length
85
+ if (size > MAX_HEADER_BYTES) return null
86
+ text += decoder.write(chunk)
87
+ const repo = headerRepo(text)
88
+ if (repo !== undefined) return repo
89
+ }
90
+ return null
91
+ } catch { return null }
92
+ finally { stream.destroy() }
93
+ }
94
+
30
95
  // Extract the bundle integrities a report declares (its top-level
31
96
  // `bundleHashes`). Non-JSON reports (markdown / CSV) or a missing field → [].
32
97
  export function reportBundleHashes(bytes: Buffer): string[] {
@@ -1,6 +1,10 @@
1
1
  import type { Buffer } from 'node:buffer'
2
2
  import type { OpenedBlob } from './blob-store.ts'
3
3
 
4
+ export class CacheMissError extends Error {
5
+ constructor() { super('Managed cache unavailable') }
6
+ }
7
+
4
8
  // Keys are internal relative paths; delete removes a whole directory of variants.
5
9
  export interface CacheStorage {
6
10
  exists(key: string): Promise<boolean>
@@ -6,7 +6,7 @@ type Content = { repoId: number | null; repoDirectory?: string }
6
6
  // Read grants for each request: neither the manager role nor a cached page
7
7
  // confers repository access. Bundles have repository scope; reports also have
8
8
  // a directory scope. Unassigned content is only available to administrators.
9
- export async function contentAccess(db: ManagedDb, user: StoredUser) {
9
+ export async function contentAccess(db: Pick<ManagedDb, 'listRepoScopesForUser'>, user: StoredUser) {
10
10
  const scopes = user.role === 'admin' ? null : await db.listRepoScopesForUser(user.id)
11
11
  const bundle = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId)
12
12
  const report = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId
@@ -8,10 +8,12 @@ import { type CommentStore, commentMethods } from './comments.ts'
8
8
  import { type ActivityStore, activityMethods } from './activity.ts'
9
9
  import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
10
10
  import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
11
+ import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
11
12
  import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
12
13
  import type { ManagedSql } from './sql.ts'
13
14
  import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
14
15
  import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
16
+ import { type ManagementCatalogStore, managementCatalogMethods } from './management-catalog.ts'
15
17
  import { type StorageKey } from '../server-common/storage-crypto.ts'
16
18
  import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
17
19
 
@@ -72,6 +74,9 @@ export interface SelectedRepo {
72
74
  private: boolean
73
75
  installationId: number | null
74
76
  defaultBranch: string
77
+ // Last live default observed by the repository browser, independent of the
78
+ // selection/discovery snapshot above. NULL means unknown or unavailable.
79
+ cachedDefaultBranch: string | null
75
80
  htmlUrl: string
76
81
  addedBy: string | null
77
82
  addedAt: number
@@ -86,7 +91,7 @@ export interface ManagedRepo extends SelectedRepo {
86
91
 
87
92
  // What a caller supplies to select (upsert) a repo; the store stamps the
88
93
  // timestamps.
89
- export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt'>
94
+ export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt' | 'cachedDefaultBranch'>
90
95
 
91
96
  // A stored report's metadata. The bytes live in the blob-store keyed by `id`;
92
97
  // `contentType` + `filename` ride here so a download can label them, `sha256`
@@ -313,7 +318,12 @@ export interface UserTeam {
313
318
  }
314
319
 
315
320
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
316
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, StorageDb {
321
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, BundleBuildLeaseStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb {
322
+ claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
323
+ finishMaintenanceLease(owner: string, until: number): Promise<void>
324
+ getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
325
+ // Reuse connections within a finite request or one feed polling iteration.
326
+ withRequest?<T>(work: () => Promise<T>): Promise<T>
317
327
  // Upsert the identity; returns the user's opaque id (stable across logins).
318
328
  // Initial-admin approval comes only from trusted login configuration. It
319
329
  // promotes a matching No access identity only while it is the sole user.
@@ -333,6 +343,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
333
343
  // mutable context while keeping the original added_by/added_at; deselectRepo
334
344
  // resolves true iff a row was removed.
335
345
  selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
346
+ cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean>
336
347
  connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
337
348
  deselectRepo(repoId: number): Promise<boolean>
338
349
  listSelectedRepos(): Promise<SelectedRepo[]>
@@ -383,6 +394,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
383
394
  // stamp nor the trail. Every change also appends to managed_finding_triage_event;
384
395
  // listTriageHistory walks one finding's trail, newest first.
385
396
  listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
397
+ getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>> }>
386
398
  // A content-free fingerprint of current annotations, read in one snapshot.
387
399
  getAnnotationRevision(findingIds: readonly string[]): Promise<string>
388
400
  setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void>
@@ -399,7 +411,13 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
399
411
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
400
412
  resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
401
413
  getBundle(id: string): Promise<ManagedBundle | null>
414
+ // One read snapshot of the live session, bundle and grants. Passing a team
415
+ // (or null for any team) also checks advisory security access; omission skips it.
416
+ getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null): Promise<{
417
+ bundle: ManagedBundle | null; canReadAdvisories: boolean
418
+ } | null>
402
419
  listBundles(userId?: string): Promise<AdminBundle[]>
420
+ listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]>
403
421
  userCanReadBundle(userId: string, id: string): Promise<boolean>
404
422
  userCanReadRepo(userId: string, repoId: number): Promise<boolean>
405
423
  userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean>
@@ -430,7 +448,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
430
448
  // bundles attached to that team's repos — for that user's own sidebar Teams section.
431
449
  // Any user; only their own memberships.
432
450
  listTeamsForUser(userId: string): Promise<UserTeam[]>
433
- getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string } | null>
451
+ getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string; teams: UserTeam[]; catalog: number } | null>
434
452
  // Whether `userId` may read `reportId`: true iff the report's repo belongs to
435
453
  // a team the user is a member of. Backs the team-scoped report view endpoint.
436
454
  userCanReadReport(userId: string, reportId: string): Promise<boolean>
@@ -528,6 +546,8 @@ function prepareStatements(db: ManagedSql) {
528
546
  WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
529
547
  AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
530
548
  WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
549
+ cacheRepoDefaultBranchStmt: db.prepare(`UPDATE managed_selected_repo SET cached_default_branch = ?
550
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND active = 1`),
531
551
  deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
532
552
  deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
533
553
  reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
@@ -545,13 +565,13 @@ function prepareStatements(db: ManagedSql) {
545
565
  UNION SELECT finding_id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))) AS annotations`),
546
566
  selectReposStmt: db.prepare(
547
567
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
548
- installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
568
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
549
569
  added_by AS addedBy, added_at AS addedAt, active AS active
550
570
  FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
551
571
  ),
552
572
  selectAllReposStmt: db.prepare(
553
573
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
554
- installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
574
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
555
575
  added_by AS addedBy, added_at AS addedAt, active AS active
556
576
  FROM managed_selected_repo ORDER BY full_name ASC`,
557
577
  ),
@@ -717,6 +737,11 @@ function prepareStatements(db: ManagedSql) {
717
737
  SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
718
738
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
719
739
  ),
740
+ selectReadableBundleIdsStmt: db.prepare(
741
+ `SELECT b.id FROM managed_bundle b WHERE b.id IN (SELECT value FROM json_each(?)) AND (b.uploaded_by = ? OR EXISTS (
742
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
743
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
744
+ ),
720
745
  selectRepoReadableStmt: db.prepare(
721
746
  `SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
722
747
  WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
@@ -842,7 +867,7 @@ function prepareStatements(db: ManagedSql) {
842
867
 
843
868
  type RepoRow = {
844
869
  repoId: number; fullName: string; priv: number; installId: number | null
845
- branch: string; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
870
+ branch: string; cachedDefaultBranch: string | null; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
846
871
  }
847
872
 
848
873
  // The repo-selection slice of ManagedDb, split out to keep openSqliteManagedDb
@@ -852,7 +877,7 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
852
877
  selectReportsForRepoStmt, selectBundlesForRepoStmt, deleteReportsForRepoStmt, deleteBundlesForRepoStmt } = stmts
853
878
  const readRepo = (r: RepoRow): SelectedRepo => ({
854
879
  repoId: r.repoId, fullName: r.fullName, private: r.priv === 1,
855
- installationId: r.installId, defaultBranch: r.branch, htmlUrl: r.htmlUrl,
880
+ installationId: r.installId, defaultBranch: r.branch, cachedDefaultBranch: r.cachedDefaultBranch, htmlUrl: r.htmlUrl,
856
881
  addedBy: r.addedBy, addedAt: r.addedAt,
857
882
  })
858
883
  const readManagedRepo = (r: RepoRow): ManagedRepo => ({ ...readRepo(r), active: r.active === 1 })
@@ -863,6 +888,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
863
888
  repo.defaultBranch, repo.htmlUrl, repo.addedBy, now, now,
864
889
  )
865
890
  },
891
+ async cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean> {
892
+ return Number((await stmts.cacheRepoDefaultBranchStmt.run(branch, repo.repoId, repo.fullName, repo.addedAt)).changes) > 0
893
+ },
866
894
  async deselectRepo(repoId: number): Promise<boolean> {
867
895
  return Number((await deleteRepoStmt.run(repoId)).changes) > 0
868
896
  },
@@ -1046,7 +1074,7 @@ type TriageEventDbRow = TriageStateDbRow & {
1046
1074
  // current row is skipped altogether, so a client re-pushing what already
1047
1075
  // stands neither re-stamps the writer nor echoes into the trail. `historyLimit`
1048
1076
  // > 0 keeps only that many events per finding; 0 keeps everything.
1049
- function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimit: number) {
1077
+ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit: number, db: ManagedSql) {
1050
1078
  const { upsertTriageStmt, selectTriageStmt, selectTriageStateStmt, insertTriageEventStmt, trimTriageEventsStmt, selectTriageHistoryStmt,
1051
1079
  deleteTriageStmt, deleteTriageHistoryStmt, deleteCommentsStmt, deleteCommentHistoryStmt, countAnnotationsStmt } = stmts
1052
1080
  async function writeEntry(findingId: string, entry: TriageEntryPatch | null, batchId: string, updatedBy: string | null, updatedByLogin: string | null, now: number, reportId: string | null = null): Promise<void> {
@@ -1112,7 +1140,43 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
1112
1140
  // (and costs one fsync under synchronous = FULL, not one per row); one
1113
1141
  // batch id groups its rows in the trail.
1114
1142
  const batchId = randomUUID()
1115
- for (const [findingId, entry] of entries) await writeEntry(findingId, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1143
+ if (new Set(entries.map(([id]) => id)).size !== entries.length) {
1144
+ // Preserve the ordered history of callers that supply repeated IDs.
1145
+ for (const [id, entry] of entries) await writeEntry(id, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1146
+ return
1147
+ }
1148
+ const current = new Map((await selectTriageStmt.all(JSON.stringify(entries.map(([id]) => id))) as TriageDbRow[])
1149
+ .map(row => [row.findingId, row]))
1150
+ const changed = entries.flatMap(([id, entry]) => {
1151
+ const e = entry ?? {}, previous = current.get(id)
1152
+ const next = { color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
1153
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : e.flagged ? 1 : 0 }
1154
+ if (previous && Object.entries(next).every(([key, value]) => previous[key as keyof TriageStateDbRow] === value)) return []
1155
+ return [[id, next.color, next.triage, next.comment, next.fix, next.flagged]]
1156
+ })
1157
+ // Bound SQL parameters while keeping the entire batch/history atomic.
1158
+ for (let offset = 0; offset < changed.length; offset += 200) {
1159
+ const rows = changed.slice(offset, offset + 200), values = rows.flat()
1160
+ const input = `WITH changes(finding_id, color, triage, comment, fix, flagged, ordinal) AS
1161
+ (VALUES ${rows.map((_, index) => `(?, ?, ?, ?, ?, CAST(? AS INTEGER), ${index})`).join(',')})`
1162
+ await db.prepare(`${input}
1163
+ INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
1164
+ SELECT finding_id, color, triage, comment, fix, flagged, ?, ?, CAST(? AS BIGINT) FROM changes WHERE TRUE
1165
+ ON CONFLICT(finding_id) DO UPDATE SET color = excluded.color, triage = excluded.triage,
1166
+ comment = excluded.comment, fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
1167
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`).run(...values, updatedBy, updatedByLogin, now)
1168
+ await db.prepare(`${input}
1169
+ INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
1170
+ SELECT c.finding_id, ?, c.color, c.triage, c.comment, c.fix, c.flagged, ?, ?, CAST(? AS BIGINT), ?, r.filename, p.full_name
1171
+ FROM changes c LEFT JOIN managed_report r ON r.id = ? LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id ORDER BY c.ordinal`)
1172
+ .run(...values, batchId, updatedBy, updatedByLogin, now, reportId ?? null, reportId ?? null)
1173
+ if (historyLimit > 0) {
1174
+ await db.prepare(`DELETE FROM managed_finding_triage_event WHERE seq IN (
1175
+ SELECT seq FROM (SELECT seq, ROW_NUMBER() OVER (PARTITION BY finding_id ORDER BY seq DESC) AS position
1176
+ FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))) ranked WHERE position > ?)`)
1177
+ .run(JSON.stringify(rows.map(row => row[0])), historyLimit)
1178
+ }
1179
+ }
1116
1180
  },
1117
1181
  }
1118
1182
  }
@@ -1159,6 +1223,23 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1159
1223
  const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1160
1224
  return row == null ? null : mapBundle(row)
1161
1225
  },
1226
+ async getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null) {
1227
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1228
+ if (!session) return null
1229
+ const denied = { bundle: null, canReadAdvisories: false }
1230
+ if (!roleAtLeast(session.role, 'view')) return denied
1231
+ const row = await selectBundleStmt.get(id) as BundleRow | undefined
1232
+ if (!row) return denied
1233
+ const manager = roleAtLeast(session.role, 'manage')
1234
+ if (session.role !== 'admin') {
1235
+ const allowed = manager ? await selectBundleReadableStmt.get(id, session.uid, session.uid)
1236
+ : row.repoId !== null && await selectRepoPathReadableStmt.get(row.repoDirectory, row.repoId, session.uid)
1237
+ if (!allowed) return denied
1238
+ }
1239
+ const canReadAdvisories = manager || (advisoriesTeamId !== undefined
1240
+ && await stmts.selectBundleSecurityStmt.get(id, session.uid, advisoriesTeamId, advisoriesTeamId) != null)
1241
+ return { bundle: mapBundle(row), canReadAdvisories }
1242
+ },
1162
1243
  async listBundles(userId?: string): Promise<AdminBundle[]> {
1163
1244
  const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1164
1245
  return rows.map((r) => ({
@@ -1170,6 +1251,11 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1170
1251
  async userCanReadBundle(userId: string, id: string): Promise<boolean> {
1171
1252
  return (await selectBundleReadableStmt.get(id, userId, userId)) != null
1172
1253
  },
1254
+ async listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]> {
1255
+ if (ids.length === 0) return []
1256
+ const rows = await stmts.selectReadableBundleIdsStmt.all(JSON.stringify([...new Set(ids)]), userId, userId) as { id: string }[]
1257
+ return rows.map(row => row.id)
1258
+ },
1173
1259
  async userCanReadRepo(userId: string, repoId: number): Promise<boolean> {
1174
1260
  return (await selectRepoReadableStmt.get(repoId, userId)) != null
1175
1261
  },
@@ -1195,7 +1281,7 @@ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDepend
1195
1281
  // The team slice of ManagedDb. listTeams reads the three tables in full and
1196
1282
  // groups in JS (3 queries, not N+1) — fine for the handful of teams a managed
1197
1283
  // workspace has.
1198
- function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1284
+ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql) {
1199
1285
  const {
1200
1286
  insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, deleteTeamStmt, selectTeamStmt,
1201
1287
  selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
@@ -1258,15 +1344,19 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1258
1344
  bundles: bundlesByTeam.get(t.id) ?? [],
1259
1345
  }))
1260
1346
  },
1347
+ async getFeedState(sessionId: string, now: number) {
1348
+ const row = await db.prepare(`SELECT u.id, u.role, r.catalog, r.annotations FROM managed_session s
1349
+ JOIN managed_user u ON u.id = s.user_id JOIN managed_change_revision r ON r.id = 1
1350
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, now) as { id: string; role: Role; catalog: number; annotations: number } | undefined
1351
+ return row ? { user: { id: row.id, role: row.role }, catalog: row.catalog, annotations: row.annotations } : null
1352
+ },
1261
1353
  async getUserTeamFeedSnapshot(sessionId: string, now: number) {
1262
1354
  // One short read-only transaction, shared by SQLite and Postgres. Never
1263
1355
  // touch last-seen timestamps or include other users' memberships.
1264
- const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1265
- if (!session) return null
1266
- const teams = session.role === 'none' ? [] : await methods.listTeamsForUser(session.uid)
1267
- return { user: { id: session.uid, role: session.role },
1268
- revision: teamCatalogRevision(teams),
1269
- }
1356
+ const state = await methods.getFeedState(sessionId, now)
1357
+ if (!state) return null
1358
+ const teams = state.user.role === 'none' ? [] : await methods.listTeamsForUser(state.user.id)
1359
+ return { user: state.user, catalog: state.catalog, teams, revision: teamCatalogRevision(teams) }
1270
1360
  },
1271
1361
  async userCanReadReport(userId: string, reportId: string): Promise<boolean> {
1272
1362
  return (await selectReportReadableStmt.get(reportId, userId)) != null
@@ -1331,14 +1421,23 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1331
1421
  const activity = activityMethods(db)
1332
1422
  const stmts = prepareStatements(db)
1333
1423
  const reports = reportMethods(stmts, db, key)
1334
- const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1424
+ const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1335
1425
  const {
1336
1426
  upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1337
1427
  touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1338
1428
  } = stmts
1339
1429
 
1340
- const methods: Omit<ManagedDb, keyof ManagementStore> = {
1430
+ const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1341
1431
  ...storageMethods(db, key),
1432
+ ...bundleBuildLeaseMethods(db),
1433
+ async claimMaintenanceLease(owner, now, until, migration = false) {
1434
+ return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1435
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
1436
+ WHERE managed_maintenance_lease.expires_at <= ? RETURNING id`).get(migration ? 2 : 1, owner, until, now)
1437
+ },
1438
+ async finishMaintenanceLease(owner, until) {
1439
+ await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
1440
+ },
1342
1441
  async upsertUser(user, now, initialAdminGithubId = null) {
1343
1442
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1344
1443
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
@@ -1401,15 +1500,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1401
1500
  ...selectedRepoMethods(stmts),
1402
1501
  ...activity,
1403
1502
  ...comments,
1503
+ async getAnnotations(ids) {
1504
+ return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids) }
1505
+ },
1404
1506
  ...workspaceShareMethods(db),
1405
1507
  ...reports,
1406
1508
  ...triage,
1407
1509
  ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1408
1510
  ...bundleMethods(stmts, db, key),
1409
- ...teamMethods(stmts),
1511
+ ...teamMethods(stmts, db),
1410
1512
  async close() {
1411
1513
  await db.close()
1412
1514
  },
1413
1515
  }
1414
- return { ...methods, ...managementMethods(methods) }
1516
+ return { ...methods, ...managementMethods(methods), ...managementCatalogMethods(methods) }
1415
1517
  }
@@ -1,10 +1,12 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
2
  import { WebSocket } from 'ws'
3
3
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
4
+ import { revisionSchema } from './revisions.ts'
4
5
  import { MANAGED_SCHEMA } from './db-schema.ts'
5
6
  import { STORAGE_SCHEMA } from './storage-db.ts'
6
7
  import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
7
8
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
9
+ import { BUNDLE_BUILD_LEASE_SCHEMA } from './bundle-build-leases.ts'
8
10
  import { COMMENT_SCHEMA } from './comments.ts'
9
11
  import { ACTIVITY_SCHEMA } from './activity.ts'
10
12
  import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
@@ -21,11 +23,38 @@ export interface PgConnection {
21
23
  release(): Promise<void>
22
24
  }
23
25
  export type PgConnect = () => Promise<PgConnection>
24
- // All managed writers cooperate on this transaction-scoped lock. Reads use a
25
- // consistent snapshot without taking it. Also protects concurrent cold starts.
26
+ // Content/access mutations cooperate on this transaction-scoped lock. Reads,
27
+ // presence updates, and maintenance leases do not take it. Also fences migrations.
26
28
  const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
27
29
 
30
+ async function currentSchema(db: PgConnection): Promise<boolean> {
31
+ const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
32
+ if (!exists) return false
33
+ const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
34
+ return Array.from({ length: 14 }, (_, i) => i + 1).every(version => versions.has(version))
35
+ }
36
+
37
+ async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
38
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 12')).rows.length > 0) return
39
+ await db.query('ALTER TABLE managed_selected_repo ADD COLUMN IF NOT EXISTS cached_default_branch TEXT')
40
+ await db.query('INSERT INTO managed_schema_version VALUES (12)')
41
+ }
42
+
43
+ async function migrateBundleBuildLeases(db: PgConnection): Promise<void> {
44
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 13')).rows.length > 0) return
45
+ await db.query(postgresSchema(BUNDLE_BUILD_LEASE_SCHEMA))
46
+ await db.query('INSERT INTO managed_schema_version VALUES (13)')
47
+ }
48
+
49
+ async function migrateGithubMetadata(db: PgConnection): Promise<void> {
50
+ await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
51
+ await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
52
+ await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
53
+ await db.query('INSERT INTO managed_schema_version VALUES (14) ON CONFLICT DO NOTHING')
54
+ }
55
+
28
56
  async function initialize(db: PgConnection): Promise<void> {
57
+ if (await currentSchema(db)) return
29
58
  await db.query('BEGIN')
30
59
  try {
31
60
  await db.query(LOCK)
@@ -58,9 +87,7 @@ async function initialize(db: PgConnection): Promise<void> {
58
87
  await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
59
88
  await db.query('INSERT INTO managed_schema_version VALUES (4)')
60
89
  }
61
- await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA))
62
- await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
63
- await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
90
+ await migrateGithubMetadata(db)
64
91
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
65
92
  await db.query(`ALTER TABLE managed_workspace_share ADD COLUMN IF NOT EXISTS dependencies INTEGER NOT NULL DEFAULT 0,
66
93
  ADD COLUMN IF NOT EXISTS security INTEGER NOT NULL DEFAULT 0`)
@@ -84,10 +111,21 @@ async function initialize(db: PgConnection): Promise<void> {
84
111
  await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
85
112
  await db.query('INSERT INTO managed_schema_version VALUES (8)')
86
113
  }
87
- for (const table of ['managed_report', 'managed_bundle']) {
88
- await db.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS data_key TEXT, ADD COLUMN IF NOT EXISTS storage_encrypted INTEGER NOT NULL DEFAULT 0`)
114
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 9')).rows.length === 0) {
115
+ await db.query(postgresSchema(STORAGE_SCHEMA))
116
+ for (const table of ['managed_report', 'managed_bundle']) {
117
+ await db.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS data_key TEXT, ADD COLUMN IF NOT EXISTS storage_encrypted INTEGER NOT NULL DEFAULT 0`)
118
+ }
119
+ await db.query('ALTER TABLE managed_user ADD COLUMN IF NOT EXISTS gh_tokens_encrypted INTEGER NOT NULL DEFAULT 0')
120
+ await db.query('INSERT INTO managed_schema_version VALUES (9)')
121
+ }
122
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 10')).rows.length === 0) {
123
+ await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (10)')
124
+ }
125
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 11')).rows.length === 0) {
126
+ await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
89
127
  }
90
- await db.query('ALTER TABLE managed_user ADD COLUMN IF NOT EXISTS gh_tokens_encrypted INTEGER NOT NULL DEFAULT 0')
128
+ for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases]) await migrate(db)
91
129
  await db.query('COMMIT')
92
130
  } catch (err) {
93
131
  await db.query('ROLLBACK')
@@ -112,10 +150,53 @@ async function createUploadTrigger(db: PgConnection, type: string, createTrigger
112
150
  }
113
151
  }
114
152
 
153
+ function requestConnections(connect: PgConnect) {
154
+ const requests = new AsyncLocalStorage<{ connection?: PgConnection; queue: Promise<unknown>; ended: boolean }>()
155
+ async function withConnection<T>(work: (db: PgConnection) => Promise<T>): Promise<T> {
156
+ const request = requests.getStore()
157
+ if (!request || request.ended) {
158
+ const connection = await connect()
159
+ let result: T
160
+ try { result = await work(connection) }
161
+ catch (error) { await connection.release().catch(() => {}); throw error }
162
+ await connection.release()
163
+ return result
164
+ }
165
+ // Serialize transactions on the leased connection, never interleave BEGINs
166
+ // from concurrent Blob/catalog tasks. Remote Blob reads remain concurrent.
167
+ const result = request.queue.then(async () => {
168
+ const connection = request.connection ??= await connect()
169
+ try { return await work(connection) }
170
+ catch (error) {
171
+ delete request.connection
172
+ await connection.release().catch(() => {})
173
+ throw error
174
+ }
175
+ })
176
+ request.queue = result.catch(() => {})
177
+ return result
178
+ }
179
+ async function withRequest<T>(work: () => Promise<T>): Promise<T> {
180
+ if (requests.getStore()) return work()
181
+ const request: { connection?: PgConnection; queue: Promise<unknown>; ended: boolean } = { queue: Promise.resolve(), ended: false }
182
+ let failed = false
183
+ try { return await requests.run(request, work) }
184
+ catch (error) { failed = true; throw error }
185
+ finally {
186
+ request.ended = true
187
+ await request.queue
188
+ if (failed) await request.connection?.release().catch(() => {})
189
+ else await request.connection?.release()
190
+ }
191
+ }
192
+ return { withConnection, withRequest }
193
+ }
194
+
115
195
  // Exposed for parity tests against real PostgreSQL semantics via PGlite.
116
- export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}): Promise<ManagedDb> {
196
+ export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
117
197
  const initial = await connect()
118
198
  try { await initialize(initial) } finally { await initial.release() }
199
+ const { withConnection, withRequest } = requestConnections(connect)
119
200
  const context = new AsyncLocalStorage<PgConnection>()
120
201
  let closed = false
121
202
  const driver: ManagedSqlDriver = {
@@ -146,43 +227,51 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
146
227
  run: async (...args) => ({ changes: (await query(args)).rowCount ?? 0 }),
147
228
  }
148
229
  },
149
- async scope(write, work) {
230
+ async scope(write, work, { lock = write, statement = false } = {}) {
150
231
  if (closed) throw new Error('Managed database is closed')
151
- const db = await connect()
152
- let commitAttempted = false
153
- let result
154
- try {
155
- await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
156
- if (write) await db.query(LOCK)
157
- result = await context.run(db, work)
158
- commitAttempted = true
159
- await db.query('COMMIT')
160
- } catch (err) {
161
- await db.query('ROLLBACK').catch(() => {})
162
- await db.release().catch(() => {})
163
- if (write && commitAttempted) throw new Error('Managed commit outcome is uncertain', { cause: err })
164
- throw err
232
+ let committed = false
233
+ try { return await withConnection(async db => {
234
+ if (statement) return context.run(db, work)
235
+ let commitAttempted = false
236
+ try {
237
+ await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
238
+ if (lock) await db.query(LOCK)
239
+ const result = await context.run(db, work)
240
+ commitAttempted = true
241
+ await db.query('COMMIT')
242
+ committed = true
243
+ return result
244
+ } catch (err) {
245
+ await db.query('ROLLBACK').catch(() => {})
246
+ if (write && commitAttempted) throw new Error('Managed commit outcome is uncertain', { cause: err })
247
+ throw err
248
+ }
249
+ }) } catch (error) {
250
+ if (write && committed) throw new Error('Managed commit outcome is uncertain', { cause: error })
251
+ throw error
165
252
  }
166
- try { await db.release() }
167
- catch (err) { if (write) throw new Error('Managed commit outcome is uncertain', { cause: err }); throw err }
168
- return result
169
253
  },
170
254
  close() { closed = true },
171
255
  }
172
- return scopeManagedMethods(createManagedMethods(driver, options), driver)
256
+ const methods = scopeManagedMethods(createManagedMethods(driver, options), driver)
257
+ if (reuseConnections) methods.withRequest = withRequest
258
+ return methods
173
259
  }
174
260
 
175
261
  export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}): Promise<ManagedDb> {
176
- // Reuse the e2e optional-driver boundary. Each operation closes its socket
177
- // before returning; no open connection has to survive a frozen invocation.
262
+ // Reuse the e2e optional-driver boundary. Requests lease one connection and
263
+ // close it before returning; no socket must survive a frozen invocation.
178
264
  const { Client, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
179
- Client: new (url: string) => { connect(): Promise<void>; end(): Promise<void>; query: PgConnection['query'] }
265
+ Client: new (url: string) => { connect(): Promise<void>; end(): Promise<void>; query: PgConnection['query']; on?(event: 'error', listener: (error: Error) => void): void }
180
266
  neonConfig: { webSocketConstructor: typeof WebSocket }
181
267
  }
182
268
  neonConfig.webSocketConstructor = WebSocket
183
269
  return openPostgresManagedDb(async () => {
184
270
  const client = new Client(url)
271
+ // A remote disconnect between operations must reject the next query, not
272
+ // become an unhandled EventEmitter error while the request is reading Blob.
273
+ client.on?.('error', () => {})
185
274
  try { await client.connect() } catch (err) { await client.end().catch(() => {}); throw err }
186
275
  return { query: (sql, params) => client.query(sql, params), release: () => client.end() }
187
- }, options)
276
+ }, options, true)
188
277
  }