@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.
- package/LICENSE-APACHE +202 -0
- package/client/linked-findings.js +1 -1
- package/common/bundle-metadata.js +12 -10
- package/common/code-language.js +17 -2
- package/common/default-scan-models.ts +3 -3
- package/common/finding-id.js +1 -1
- package/common/managed/report-content.ts +1 -1
- package/common/managed/report-filter.ts +1 -1
- package/out/client-managed-import.js +16 -16
- package/out/client-managed.js +93 -68
- package/out/graph.js +11 -11
- package/out/index.html +1 -1
- package/out/prism.js +2 -2
- package/out/stasis.svg +0 -25
- package/out/terminal.js +163 -163
- package/out/view.css +1 -1
- package/out/view.js +223 -190
- package/package.json +18 -28
- package/server-e2e/static.ts +3 -4
- package/server-managed/bundle-advisories.ts +53 -0
- package/server-managed/bundle-advisory-inventory.ts +75 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +44 -0
- package/server-managed/bundle-build.ts +141 -0
- package/server-managed/bundle-cache.ts +22 -13
- package/server-managed/bundle-summary-cache.ts +27 -9
- package/server-managed/bundle.ts +66 -1
- package/server-managed/cache-storage.ts +4 -0
- package/server-managed/content-access.ts +1 -1
- package/server-managed/db-methods.ts +122 -20
- package/server-managed/db-neon.ts +121 -32
- package/server-managed/db-schema.ts +2 -0
- package/server-managed/db.ts +5 -1
- package/server-managed/github-app.ts +10 -15
- package/server-managed/github-errors.ts +21 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/github-metadata.ts +34 -1
- package/server-managed/github-pulls.ts +142 -30
- package/server-managed/http.ts +244 -131
- package/server-managed/index.ts +25 -9
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/object-storage-vercel.ts +1 -1
- package/server-managed/public-workspace.ts +20 -14
- package/server-managed/report-migration.ts +1 -1
- package/server-managed/report-sources.ts +14 -4
- package/server-managed/repository-browser.ts +3 -0
- package/server-managed/repository-discovery.ts +1 -1
- package/server-managed/revisions.ts +35 -0
- package/server-managed/sql-postgres.ts +1 -0
- package/server-managed/sql.ts +14 -3
- package/server-managed/storage-db.ts +12 -5
- package/server-managed/storage-maintenance.ts +37 -22
- package/server-managed/storage-payload.ts +16 -4
- package/server-managed/storage-stores.ts +2 -2
- package/server-managed/storage.ts +6 -4
- package/server-managed/team-feed.ts +50 -5
- package/server-managed/team-reports.ts +85 -22
- package/server-managed/workspace-shares.ts +15 -3
- package/report/index.js +0 -260
- package/report/src/finding-id.js +0 -80
- package/report/src/finding.js +0 -314
- package/report/src/labels.js +0 -35
- package/report/src/md-structure.js +0 -471
- package/report/src/md-text.js +0 -167
- package/report/src/meta.js +0 -76
- package/report/src/parse-codex.js +0 -149
- package/report/src/parse-deepsec.js +0 -199
- package/report/src/parse-deepview-fields.js +0 -375
- package/report/src/parse-deepview-md.js +0 -186
- package/report/src/parse-md-id.js +0 -137
- package/report/src/parse-md.js +0 -323
- package/report/src/parse-piolium-id.js +0 -79
- package/report/src/parse-piolium-rows.js +0 -126
- package/report/src/parse-piolium-tokens.js +0 -185
- package/report/src/parse-piolium.js +0 -399
- package/report/src/security.js +0 -63
- package/report/src/severity.js +0 -18
- package/report/src/utf8.js +0 -21
- package/report/src/write-md-finding.js +0 -273
- package/report/src/write-md.js +0 -291
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/server-managed/bundle.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
1265
|
-
if (!
|
|
1266
|
-
const teams =
|
|
1267
|
-
return { user:
|
|
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
|
-
//
|
|
25
|
-
//
|
|
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
|
|
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
|
-
|
|
88
|
-
await db.query(
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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.
|
|
177
|
-
// before returning; no
|
|
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
|
}
|