@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.47

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 (110) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/common/managed/agent-tasks.ts +5 -3
  7. package/common/managed/github-agents.ts +28 -10
  8. package/out/activity.js +4 -0
  9. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  10. package/out/chunk-5AHIQFFG.js +36 -0
  11. package/out/chunk-6ANSHRFQ.js +6 -0
  12. package/out/chunk-6WF3KNTL.js +3 -0
  13. package/out/chunk-7BKKYVWR.js +7 -0
  14. package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
  15. package/out/chunk-NOZJULWS.js +52 -0
  16. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  17. package/out/chunk-S6U2J62U.js +232 -0
  18. package/out/chunk-SPEFPML6.js +2 -0
  19. package/out/chunk-UENXT2HK.js +3 -0
  20. package/out/chunk-VL43BTSO.js +1 -0
  21. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  22. package/out/chunk-ZCS4UYHI.js +1 -0
  23. package/out/chunk-ZGNMDGSB.js +1 -0
  24. package/out/client-managed-import.js +1 -1
  25. package/out/client-managed.js +15 -15
  26. package/out/client-sync.js +2 -2
  27. package/out/compare.js +12 -12
  28. package/out/dialogs.js +28 -28
  29. package/out/graph.js +11 -11
  30. package/out/index.html +11 -11
  31. package/out/scan.js +2 -2
  32. package/out/terminal.js +6 -6
  33. package/out/view.css +1 -1
  34. package/out/view.js +15 -15
  35. package/package.json +4 -1
  36. package/risk/actions/findings.js +14 -9
  37. package/risk/actions/scripts/shell.js +5 -1
  38. package/risk/index.d.ts +9 -4
  39. package/risk/npm/assess.js +15 -4
  40. package/risk/npm/registry.js +6 -1
  41. package/server-e2e/app.ts +5 -4
  42. package/server-e2e/http.ts +5 -2
  43. package/server-managed/activity.ts +24 -11
  44. package/server-managed/app-agents.ts +10 -21
  45. package/server-managed/bundle-build-worker.js +7 -3
  46. package/server-managed/bundle-build.ts +5 -3
  47. package/server-managed/bundle-cache.ts +16 -4
  48. package/server-managed/bundle.ts +3 -4
  49. package/server-managed/combined.ts +3 -2
  50. package/server-managed/commit-coauthors.ts +5 -9
  51. package/server-managed/commit-lockfiles.ts +21 -11
  52. package/server-managed/config.ts +6 -0
  53. package/server-managed/db-methods.ts +321 -156
  54. package/server-managed/db-neon.ts +71 -24
  55. package/server-managed/db-schema.ts +4 -0
  56. package/server-managed/db.ts +2 -1
  57. package/server-managed/dependency-reviews.ts +13 -3
  58. package/server-managed/github-app.ts +12 -6
  59. package/server-managed/github-issue-links.ts +11 -5
  60. package/server-managed/github-pulls.ts +2 -2
  61. package/server-managed/github-requests.ts +39 -0
  62. package/server-managed/http-response.ts +10 -2
  63. package/server-managed/http.ts +269 -143
  64. package/server-managed/index.ts +8 -7
  65. package/server-managed/kept-briefly.ts +37 -0
  66. package/server-managed/link-reports.ts +5 -1
  67. package/server-managed/npm-delta.ts +11 -6
  68. package/server-managed/npm-kept.ts +51 -7
  69. package/server-managed/npm-loads.ts +117 -35
  70. package/server-managed/npm-packages.ts +107 -14
  71. package/server-managed/npm-risk.ts +68 -18
  72. package/server-managed/pretty-print.ts +112 -35
  73. package/server-managed/pull-dependencies.ts +29 -9
  74. package/server-managed/pull-dependency-reviews.ts +8 -8
  75. package/server-managed/queue.ts +16 -0
  76. package/server-managed/rename-diff.ts +22 -13
  77. package/server-managed/report-sources.ts +1 -1
  78. package/server-managed/report-store.ts +50 -12
  79. package/server-managed/repository-actions.ts +15 -6
  80. package/server-managed/repository-agents.ts +1 -1
  81. package/server-managed/repository-browser.ts +123 -33
  82. package/server-managed/repository-code.ts +28 -11
  83. package/server-managed/repository-dependency-reviews.ts +41 -19
  84. package/server-managed/repository-history.ts +24 -8
  85. package/server-managed/repository-pulse.ts +9 -2
  86. package/server-managed/repository-risk-audit.ts +13 -0
  87. package/server-managed/repository-risk-read.ts +11 -6
  88. package/server-managed/repository-risk.ts +27 -20
  89. package/server-managed/session.ts +18 -4
  90. package/server-managed/solidity-entry-points.ts +5 -3
  91. package/server-managed/sql-postgres.ts +3 -0
  92. package/server-managed/sql.ts +6 -2
  93. package/server-managed/storage-db.ts +11 -3
  94. package/server-managed/storage-encryption.ts +7 -4
  95. package/server-managed/storage.ts +8 -4
  96. package/server-managed/team-feed.ts +3 -1
  97. package/server-managed/team-reports.ts +30 -13
  98. package/server-managed/uploads.ts +29 -9
  99. package/server-managed/upstream-cache.ts +12 -0
  100. package/server.ts +14 -5
  101. package/out/chunk-5DHL5NG3.js +0 -2
  102. package/out/chunk-6HNXNIQY.js +0 -52
  103. package/out/chunk-AFLZH7WG.js +0 -1
  104. package/out/chunk-NV7ES4EW.js +0 -1
  105. package/out/chunk-PTGDYLNS.js +0 -36
  106. package/out/chunk-Q6MVYGYV.js +0 -3
  107. package/out/chunk-QRD6GIG5.js +0 -229
  108. package/out/chunk-WI7Z5A2T.js +0 -6
  109. package/out/chunk-WQ74ZBZF.js +0 -7
  110. package/out/chunk-XVBEOGOS.js +0 -3
@@ -7,24 +7,36 @@
7
7
  // active ones (npm-loads.ts sharedLoad).
8
8
  import { Buffer } from 'node:buffer'
9
9
  import { encodeBrotli } from './brotli.ts'
10
- import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NpmPackageError, npmFileRows } from './npm-packages.ts'
11
- import { type NpmLoad, readTarballFiles, sharedLoad } from './npm-loads.ts'
10
+ import { MAX_NPM_JSON_LENGTH, NpmPackageError, npmFileRows } from './npm-packages.ts'
11
+ import { type NpmLoad, loadPlaces, readTarballFiles, sharedLoad } from './npm-loads.ts'
12
12
 
13
- // As much as a package may hold: the viewer holds every file in memory.
14
- export const MAX_REPOSITORY_CODE_FILES = MAX_NPM_PACKAGE_FILES
15
- export const MAX_REPOSITORY_CODE_BYTES = MAX_NPM_PACKAGE_BYTES
13
+ // What a directory may hold, bounded apart from a package's (npm-packages.ts),
14
+ // as the viewer holds every file in memory: its files' count and bytes.
15
+ export const MAX_REPOSITORY_CODE_FILES = 80_000
16
+ export const MAX_REPOSITORY_CODE_BYTES = 256 * 1024 * 1024
17
+ // The tarball they come in, bounded in proportion as a package's is: its tar
18
+ // stream, headers and padding included, and its entries, as many again as
19
+ // files, as GitHub's has one for each directory too.
20
+ const REPOSITORY_TAR = { maxFiles: MAX_REPOSITORY_CODE_FILES, maxBytes: MAX_REPOSITORY_CODE_BYTES, maxEntries: 2 * MAX_REPOSITORY_CODE_FILES, maxTarBytes: 384 * 1024 * 1024 }
21
+ // What the files may take as the viewer's JSON, written as one string: short
22
+ // of the longest V8 holds (2^29 - 24 characters).
23
+ const MAX_REPOSITORY_CODE_JSON_LENGTH = 384 * 1024 * 1024
16
24
 
17
25
  // Whether a directory's files fit, by GitHub's listing of its tree and every
18
26
  // tree under it (repository-browser.ts repositoryTreeFiles): asked before
19
27
  // its tarball is, which may run to hundreds of MiB. One GitHub cut short
20
28
  // (null) has too many.
21
- export function repositoryCodeFits(files: { files: number; bytes: number } | null): boolean {
29
+ export function repositoryCodeFits(files: RepositoryCodeFiles | null): files is RepositoryCodeFiles {
22
30
  return files !== null && files.files <= MAX_REPOSITORY_CODE_FILES && files.bytes <= MAX_REPOSITORY_CODE_BYTES
23
31
  }
24
32
 
33
+ // A directory's files' count and bytes, as GitHub lists its tree.
34
+ export interface RepositoryCodeFiles { files: number; bytes: number }
35
+
25
36
  // The directory read: the repository by its id and name, the commit, the
26
- // directory (`''` for the root) and its tree's id.
27
- export interface RepositoryCodeInput { repoId: number; repo: string; commit: string; path: string; tree: string }
37
+ // directory (`''` for the root), its tree's id, and its files as listed,
38
+ // which weigh its load among the active ones.
39
+ export interface RepositoryCodeInput { repoId: number; repo: string; commit: string; path: string; tree: string; listed: RepositoryCodeFiles }
28
40
 
29
41
  // A package's refusals, as a directory's: past the bounds, or an archive
30
42
  // @preventive/archive refuses, as it does one holding a symlink that climbs
@@ -39,14 +51,19 @@ const directoryError = (err: unknown) => {
39
51
  // repo, commit, path, tree, files }`, its files as npmFileRows gives them,
40
52
  // by their paths in the directory, from `tarball`, the tree's.
41
53
  export function loadRepositoryCodeBody(input: RepositoryCodeInput, tarball: () => Promise<Uint8Array>): NpmLoad<Buffer> {
42
- const { repoId, repo, commit, path, tree } = input
54
+ const { repoId, repo, commit, path, tree, listed } = input
55
+ // Its places among the active loads, by its files as listed, hold their
56
+ // JSON too, which the listing cannot tell, as escaping may grow a text
57
+ // sixfold: as much as a package's may take for each.
58
+ const places = loadPlaces(listed)
59
+ const maxLength = Math.min(MAX_REPOSITORY_CODE_JSON_LENGTH, places * MAX_NPM_JSON_LENGTH)
43
60
  return sharedLoad(`repository ${repoId} ${repo} ${commit} ${path} ${tree}`, async () => {
44
61
  let files
45
62
  // A symlink may reach the repository's root, as a monorepo package's
46
63
  // LICENSE links the root's, and no further: the directory's depth, less
47
64
  // the level the archive's root already stands above its top directory.
48
- try { files = npmFileRows(await readTarballFiles(await tarball(), { symlinksAbove: path.split('/').length - 1 })) }
65
+ try { files = npmFileRows(await readTarballFiles(await tarball(), { ...REPOSITORY_TAR, symlinksAbove: path.split('/').length - 1 }), maxLength) }
49
66
  catch (err) { throw directoryError(err) }
50
67
  return encodeBrotli(Buffer.from(JSON.stringify({ repoId, repo, commit, path, tree, files })))
51
- }, 'repository-busy')
68
+ }, 'repository-busy', { places })
52
69
  }
@@ -38,8 +38,13 @@ export interface PublishedReview {
38
38
  // published reviews of the packages asked.
39
39
  export interface RepoReviewState extends Omit<RepoDependencyReview, 'enabled'> { canReview: boolean; reviews: PublishedReview[] }
40
40
 
41
+ // A package version whose published reviews are asked.
42
+ export interface ReviewedVersionKey { name: string; version: string }
43
+
41
44
  export interface RepositoryDependencyReviewStore {
42
45
  listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
46
+ // One repository's, none where it isn't connected.
47
+ repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
43
48
  // The teams whose published reviews count for each repository (COUNTING_TEAM),
44
49
  // by name.
45
50
  listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
@@ -47,7 +52,9 @@ export interface RepositoryDependencyReviewStore {
47
52
  // `since` or `lockfiles` is undefined, which keeps it; the setting then, or
48
53
  // null for no repository.
49
54
  setRepoDependencyReview(repoId: number, setting: Omit<RepoDependencyReview, 'since' | 'lockfiles'> & { since?: string | null; lockfiles?: LockfileScope }): Promise<RepoDependencyReview | null>
50
- repoDependencyReviewState(repoId: number, user: string, packages: string[]): Promise<RepoReviewState | null>
55
+ // Its review in effect for `user`, as `setting` tells it where read
56
+ // already, with the published reviews of `versions`.
57
+ repoDependencyReviewState(repoId: number, user: string, versions: ReviewedVersionKey[], setting?: RepoDependencyReview | null): Promise<RepoReviewState | null>
51
58
  // Which of `users` publish reviews to a team counting for repository
52
59
  // `repoId`, whose reviews its pull requests' comments make count.
53
60
  listRepoReviewers(repoId: number, users: string[]): Promise<Set<string>>
@@ -59,33 +66,42 @@ const setting = (row: SettingRow): RepoDependencyReview => ({ enabled: row.enabl
59
66
  // dependencies and have it, not hidden.
60
67
  const COUNTING_TEAM = `t.hidden = 0 AND t.dependency_review = 1 AND EXISTS (
61
68
  SELECT 1 FROM managed_team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = ?)`
62
- const PACKAGES_AT_ONCE = 500
69
+ const VERSIONS_AT_ONCE = 250
63
70
 
64
- // The published and unrevoked reviews of `packages` in teams counting for
65
- // repository `repoId`, each with whether it is full, a few hundred packages
66
- // a query.
67
- async function publishedReviews(db: ManagedSql, repoId: number, packages: string[]): Promise<PublishedReview[]> {
71
+ // The published and unrevoked reviews of `versions` in teams counting for
72
+ // repository `repoId`, each with whether it is full, a couple of hundred
73
+ // versions a query: those of their packages' and versions' found, the
74
+ // versions asked alone kept, their files' reviews read of those alone.
75
+ async function publishedReviews(db: ManagedSql, repoId: number, versions: ReviewedVersionKey[]): Promise<PublishedReview[]> {
68
76
  const found: PublishedReview[] = []
69
- for (let at = 0; at < packages.length; at += PACKAGES_AT_ONCE) {
70
- const names = packages.slice(at, at + PACKAGES_AT_ONCE)
71
- const rows = await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
77
+ for (let at = 0; at < versions.length; at += VERSIONS_AT_ONCE) {
78
+ const asked = versions.slice(at, at + VERSIONS_AT_ONCE)
79
+ const keys = new Set(asked.map(({ name, version }) => `${name}@${version}`))
80
+ const [names, numbers] = [[...new Set(asked.map(({ name }) => name))], [...new Set(asked.map(({ version }) => version))]]
81
+ const rows = (await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
72
82
  NULLIF(r.base_package, '') AS basePackage, NULLIF(r.base_version, '') AS baseVersion, r.files, r.status, r.published_at AS publishedAt
73
83
  FROM managed_dependency_review r
74
84
  WHERE r.ecosystem = 'npm' AND r.published_at IS NOT NULL AND r.revoked_at IS NULL AND r.status IS NOT NULL
75
- AND r.package IN (${names.map(() => '?').join(',')})
85
+ AND r.package IN (${names.map(() => '?').join(',')}) AND r.version IN (${numbers.map(() => '?').join(',')})
76
86
  AND EXISTS (SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id
77
- WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, repoId) as
87
+ WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, ...numbers, repoId) as
78
88
  { id: string; userId: string | null; userLogin: string; package: string; version: string; integrity: string
79
- basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[]
89
+ basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[])
90
+ .filter(row => keys.has(`${row.package}@${row.version}`))
80
91
  if (rows.length === 0) continue
81
- const entries = await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
82
- WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]
92
+ const entries = new Map<string, { file: string; status: ReviewVerdict | null }[]>()
93
+ for (const { reviewId, ...entry } of await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
94
+ WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]) {
95
+ const of = entries.get(reviewId)
96
+ if (of) of.push(entry)
97
+ else entries.set(reviewId, [entry])
98
+ }
83
99
  for (const row of rows) {
84
100
  const snapshot = row.files === null ? null : JSON.parse(row.files) as string[] | { paths: string[]; nonTextFiles: string[] }
85
101
  // A snapshot of paths alone, before non-text files were told, can't be full.
86
102
  const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
87
103
  const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
88
- const full = summarizeReview(entries.filter(entry => entry.reviewId === row.id), files, nonText).coverage === 'full'
104
+ const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
89
105
  found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
90
106
  reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, status: row.status, full, time: Number(row.publishedAt) })
91
107
  }
@@ -96,11 +112,16 @@ async function publishedReviews(db: ManagedSql, repoId: number, packages: string
96
112
  export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDependencyReviewStore {
97
113
  const columns = `repo_id AS repoId, dependency_review AS enabled, dependency_reviews_required AS required, dependency_review_since AS since,
98
114
  dependency_review_lockfiles AS lockfiles`
115
+ const readSetting = async (repoId: number) => {
116
+ const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
117
+ return row ? setting(row) : null
118
+ }
99
119
  return {
100
120
  async listRepoDependencyReviews() {
101
121
  const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo`).all() as SettingRow[]
102
122
  return new Map(rows.map(row => [row.repoId, setting(row)]))
103
123
  },
124
+ repoDependencyReview: readSetting,
104
125
  async listRepoReviewTeams() {
105
126
  const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
106
127
  JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
@@ -123,13 +144,14 @@ export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDep
123
144
  WHERE u.id IN (${users.map(() => '?').join(',')}) AND ${reviewsIn(true)} AND ${COUNTING_TEAM}`).all(...users, repoId) as { id: string }[]
124
145
  return new Set(rows.map(row => row.id))
125
146
  },
126
- async repoDependencyReviewState(repoId, user, packages) {
127
- const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
128
- if (!row || row.enabled !== 1) return null
147
+ async repoDependencyReviewState(repoId, user, versions, read) {
148
+ const now = read === undefined ? await readSetting(repoId) : read
149
+ if (!now?.enabled) return null
129
150
  if (!await db.prepare(`SELECT 1 FROM managed_team t WHERE ${COUNTING_TEAM}`).get(repoId)) return null
130
151
  const canReview = await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ?
131
152
  WHERE ${reviewsIn(true)} AND ${COUNTING_TEAM}`).get(user, repoId) != null
132
- return { required: row.required, since: row.since, lockfiles: storedScope(row.lockfiles), canReview, reviews: await publishedReviews(db, repoId, [...new Set(packages)]) }
153
+ const asked = [...new Map(versions.map(version => [`${version.name}@${version.version}`, version])).values()]
154
+ return { required: now.required, since: now.since, lockfiles: now.lockfiles, canReview, reviews: await publishedReviews(db, repoId, asked) }
133
155
  },
134
156
  }
135
157
  }
@@ -122,7 +122,7 @@ const TIMED_OUT = /\btimeout\b/iu
122
122
  // are logged, those read past too.
123
123
  export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
124
124
  tolerated: (error: GraphqlError) => boolean = () => false) {
125
- const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }) as { data?: unknown; errors?: GraphqlError[] } | null
125
+ const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
126
126
  const all = Array.isArray(body?.errors) ? body.errors : []
127
127
  const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
128
128
  if (unnamed.length > 0) {
@@ -172,6 +172,9 @@ const GLUE_LOOKUP = 50
172
172
  // changes.
173
173
  export interface HistoryPages {
174
174
  read(tops: string[]): Promise<Map<string, HistoryCommit[]>>
175
+ // The pages kept of `first`; where none is, those of `then`, in one read
176
+ // (`first` whether they are `first`'s). Without it, both are read together.
177
+ readFirst?(first: string[], then: string[]): Promise<{ first: boolean; pages: Map<string, HistoryCommit[]> }>
175
178
  write(top: string, commits: HistoryCommit[]): Promise<void>
176
179
  }
177
180
 
@@ -241,8 +244,11 @@ const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node
241
244
 
242
245
  // The history from each of `starts`, a page of each, kept (`pages`), or read
243
246
  // and then kept by its top; glued on to it, the pages kept that start at a
244
- // parent of a commit listed not listed, or at a commit listed, the oldest
245
- // first (where each goes on furthest), GLUE_LOOKUP looked up at a time, and
247
+ // parent of a commit listed not listed, each adding commits none listed has,
248
+ // and, where any of those has none kept and readable, at a commit listed, the
249
+ // oldest first (where each goes on furthest; a page at a commit listed lists
250
+ // mostly commits listed already), GLUE_LOOKUP of each looked up at a time, in
251
+ // one read where none of those parents' is kept, else two, and
246
252
  // those that start where those go on, till HISTORY_GLUED are listed. The
247
253
  // pages from `starts` are listed whole, however many that is (all branches'
248
254
  // first, as many as ALL_BRANCHES of them): the cap is gluing's. Each commit
@@ -259,11 +265,21 @@ export async function historyFrom(fullName: string, token: string | null, starts
259
265
  for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
260
266
  const looked = new Set(starts)
261
267
  for (let round = 0; round < MAX_GLUE_ROUNDS && listed.size < HISTORY_GLUED; round++) {
262
- const parents = [...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent)))
263
- const tops = [...new Set([...parents, ...[...listed.keys()].toReversed()])].filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
264
- if (tops.length === 0) break
265
- for (const sha of tops) looked.add(sha)
266
- const glued = await pages.read(tops)
268
+ const parents = [...new Set([...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent) && !looked.has(parent))))].slice(0, GLUE_LOOKUP)
269
+ const within = [...listed.keys()].toReversed().filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
270
+ if (parents.length === 0 && within.length === 0) break
271
+ // Without readFirst, GLUE_LOOKUP of both at once, the parents first.
272
+ const asked = pages.readFirst ? null : [...parents, ...within].slice(0, GLUE_LOOKUP)
273
+ let { first, pages: glued } = asked ? { first: false, pages: await pages.read(asked) } : await pages.readFirst!(parents, within)
274
+ // A parent with no page kept, or none readable (one GitHub's REST API
275
+ // listed, read with GraphQL), its line going on only by a page at a
276
+ // commit listed: those read too, apart, the others' lines notwithstanding.
277
+ if (first && glued.size < parents.length && within.length > 0) {
278
+ glued = new Map([...glued, ...await pages.read(within)])
279
+ first = false
280
+ }
281
+ // Those at commits listed, unread where a parent's answered, are looked up again.
282
+ for (const sha of asked ?? (first ? parents : [...parents, ...within])) looked.add(sha)
267
283
  for (const commit of [...glued.values()].flat()) {
268
284
  if (listed.size >= HISTORY_GLUED) break
269
285
  if (!listed.has(commit.sha)) listed.set(commit.sha, commit)
@@ -14,12 +14,13 @@
14
14
  // issues closed and opened, how many and the latest of each.
15
15
  import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
16
16
  import { GithubApiError, githubJson } from './github-app.ts'
17
+ import { shareGithubRead } from './github-requests.ts'
17
18
  import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
18
19
 
19
20
  export const PULSE_WEEKS = [1, 4, 13, 52]
20
21
  // What a year counted from its commits holds, its version: kept years of
21
22
  // another are counted again.
22
- export const PULSE_YEAR_VERSION = 8
23
+ export const PULSE_YEAR_VERSION = 9
23
24
  const WEEK_MS = 7 * 24 * 60 * 60_000
24
25
  const DAY_MS = 24 * 60 * 60_000
25
26
  const MAX_CONTRIBUTORS = 100
@@ -99,7 +100,13 @@ const emptyPunch = () => Array.from({ length: 7 }, () => Array.from({ length: 24
99
100
  // One statistic: its rows once GitHub has computed it, 'pending' while it
100
101
  // computes them (202), none for a repository with no commits (204, no body),
101
102
  // 'too-large' where it counts none (lines past 10,000 commits, 422).
102
- async function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
103
+ function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch) {
104
+ // Stats and lines both need contributors. Share by the original transport;
105
+ // the per-call status wrapper below would otherwise give each its own read.
106
+ return shareGithubRead(fetchImpl, JSON.stringify(['statistic', fullName, token, kind]), () => readStatistic(fullName, token, kind, fetchImpl))
107
+ }
108
+
109
+ async function readStatistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
103
110
  let status = 0
104
111
  const answered: typeof fetch = async (input, init) => {
105
112
  const res = await fetchImpl(input, init)
@@ -33,6 +33,19 @@ const AUDIT_FAILED_KEPT_MS = 10 * 60_000
33
33
  // RISK_KEPT_MS, why they couldn't be had within AUDIT_FAILED_KEPT_MS.
34
34
  const isFresh = ({ at, audit }: { at: number; audit: Audit }) => Date.now() - at < (Array.isArray(audit) ? RISK_KEPT_MS : AUDIT_FAILED_KEPT_MS)
35
35
 
36
+ // The audits of lockfiles, each of its `packages` by its `key` (auditLockfile),
37
+ // `inTurn` with others, asked at once: each null where it has none to ask
38
+ // of, or once `signal` aborts. Each is the caller's to tell once it may.
39
+ export function auditLockfiles(source: AuditSource, memory: Lru<{ at: number; audit: Audit }>, signal: AbortSignal,
40
+ lockfiles: { key: string; packages: readonly LockedPackage[] | null }[], inTurn: <T>(work: () => Promise<T>) => Promise<T>): Promise<Audit | null>[] {
41
+ return lockfiles.map(({ key, packages }) => {
42
+ const audit = packages ? inTurn(() => (signal.aborted ? Promise.resolve(null) : auditLockfile(source, memory, signal, key, packages))) : Promise.resolve(null)
43
+ // One no longer waited on, as the scan stopped, fails alone.
44
+ audit.catch(() => {})
45
+ return audit
46
+ })
47
+ }
48
+
36
49
  // The audit of a lockfile's `packages`, by its `key` as kept (`memory`, then
37
50
  // the source's), else asked and kept; null where it locks none to ask of, or
38
51
  // once `signal` aborts.
@@ -16,6 +16,7 @@ import type { DependabotAlert } from './dependabot-alerts.ts'
16
16
  import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
17
17
  import type { Lru } from './lru.ts'
18
18
  import type { KeptStore } from './npm-kept.ts'
19
+ import { inTurns } from './queue.ts'
19
20
  import { type WorkflowsRead, lastingRead, readActions } from './repository-actions.ts'
20
21
  import { type RiskFailure, failureOf } from './repository-risk-assess.ts'
21
22
  import type { RepositoryRiskLine, RepositoryRiskRepo } from './repository-risk.ts'
@@ -28,6 +29,9 @@ const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'
28
29
  // How long the source's `listings` and `lockfiles` are read for: what
29
30
  // GitHub has at a commit or a blob doesn't change, how it is read here may.
30
31
  export const SCAN_READ_KEPT_MS = 24 * 60 * 60_000
32
+ // How many of a repository's lockfiles are read at once: each one GitHub is
33
+ // asked for where it isn't kept, then parsed.
34
+ const LOCKFILES_AT_ONCE = 4
31
35
 
32
36
  export type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
33
37
  export type LockfileRead = { packages: LockedPackage[] } | RiskFailure
@@ -140,8 +144,8 @@ async function readDependabot(opened: OpenedRepository, paths: readonly string[]
140
144
  }
141
145
  }
142
146
 
143
- // A repository read: its lockfiles listed and read, its Dependabot alerts
144
- // asked and its Actions read meanwhile; its line telling why where listing
147
+ // A repository read: its lockfiles listed and read, a few at once, its
148
+ // Dependabot alerts asked and its Actions read meanwhile; its line telling why where listing
145
149
  // failed; null where the scan stopped. A test's
146
150
  // fixture or example its reader refuses (isTestFixture) is left out, as a
147
151
  // test may keep one so on purpose.
@@ -153,11 +157,12 @@ export async function readRepository<R extends RepositoryRiskRepo>(reading: Repo
153
157
  const acting = workflows ? keptRead(reading.caches.actionFiles, reading.source.actionFiles, `${repo.repoId}@${opened.commit}`,
154
158
  () => readActions({ workflows, file: path => opened.file(path) }), lastingRead) : Promise.resolve(null)
155
159
  acting.catch(() => {})
160
+ const inTurn = inTurns(LOCKFILES_AT_ONCE)
161
+ const lines = await Promise.all(listed.lockfiles.map(lockfile => inTurn(() => (reading.signal.aborted ? Promise.resolve(null) : readLockfile(reading, repo.repoId, opened, lockfile)))))
156
162
  const lockfiles: RepositoryRead['lockfiles'] = []
157
- for (const lockfile of listed.lockfiles) {
158
- if (reading.signal.aborted) return null
159
- const line = await readLockfile(reading, repo.repoId, listed.opened, lockfile)
160
- if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
163
+ for (const [at, line] of lines.entries()) {
164
+ if (!line) return null
165
+ if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: listed.lockfiles[at]!.sha, line })
161
166
  }
162
167
  const dependabot = await alerting
163
168
  const actions = await acting
@@ -34,10 +34,10 @@ import { type Lru, lru } from './lru.ts'
34
34
  import type { KeptStore } from './npm-kept.ts'
35
35
  import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
36
36
  import type { LockedPackage } from './locked-packages.ts'
37
- import { type Queue, queue } from './queue.ts'
37
+ import { type Queue, inTurns, queue } from './queue.ts'
38
38
  import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
39
39
  import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
40
- import { type AdvisoryRow, type Audit, type AuditSource, auditLockfile } from './repository-risk-audit.ts'
40
+ import { type AdvisoryRow, type Audit, type AuditSource, auditLockfiles } from './repository-risk-audit.ts'
41
41
  import { type DependabotRead, type ListedLockfile, type LockfileRead, type OpenedRepository, type RepositoryFailure, type RepositoryRead, SCAN_READ_KEPT_MS,
42
42
  readRepository } from './repository-risk-read.ts'
43
43
  import { type DependabotAlert, alertVersions } from './dependabot-alerts.ts'
@@ -157,6 +157,9 @@ export const riskCaches = (): RiskCaches => ({
157
157
  // the one told: each asks GitHub one thing after another (opening it, and
158
158
  // its head) even where all else is kept.
159
159
  const READ_AHEAD = 6
160
+ // How many lockfiles' advisories are asked of npm at once, those of the
161
+ // repositories read ahead as they are read, each told in its turn.
162
+ const AUDITS_AT_ONCE = 4
160
163
  // How long a part's progress, written as it ends (`progress`), is waited on.
161
164
  const PROGRESS_WRITE_MS = 5000
162
165
  // How many kept risk lines are read of the source's `risks` at once.
@@ -180,6 +183,7 @@ interface Scan {
180
183
  // The repositories told whole, this part or one before (ScanProgress).
181
184
  told: Map<number, ToldRepository>
182
185
  actions: ReturnType<typeof resolvingActions> | null
186
+ audits: ReturnType<typeof inTurns>
183
187
  }
184
188
 
185
189
  // A package as a scan meets it: by its name, version and a mirror's integrity.
@@ -285,16 +289,16 @@ function assessLater(scan: Scan, packages: LockedPackage[]) {
285
289
  if (fresh.length > 0) scan.lookups.add(fresh)
286
290
  }
287
291
 
288
- type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
289
292
  type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
290
293
 
291
- // A lockfile's advisories line (repository-risk-audit.ts auditLockfile);
292
- // null where it locks none, or once the scan stops.
293
- async function readAdvisories(scan: Scan, { repo, path, format, packages }: LockfileLine & { packages: LockedPackage[] }, sha: string): Promise<AdvisoriesLine | null> {
294
- const audit = await auditLockfile(scan.source, scan.caches.advisories, scan.signal, `${format}\0${sha}`, packages)
295
- const at = { type: 'advisories', repo, path } as const
296
- return audit === null ? null : Array.isArray(audit) ? { ...at, advisories: audit } : { ...at, ...audit }
297
- }
294
+ // A lockfile's advisories line, of its audit (repository-risk-audit.ts auditLockfile).
295
+ const advisoriesLine = ({ repo, path }: LockfileLine, audit: Audit) => ({ type: 'advisories', repo, path, ...Array.isArray(audit) ? { advisories: audit } : audit } as const)
296
+
297
+ // A repository read, its lockfiles' audits asked meanwhile, AUDITS_AT_ONCE
298
+ // across the scan (auditLockfiles), to tell in turn once access is checked again.
299
+ type Audited = (RepositoryRead & { audits: Promise<Audit | null>[] }) | RepositoryFailure
300
+ const audited = (scan: Scan, read: RepositoryRead | RepositoryFailure | null): Audited | null => (read === null || 'type' in read ? read : { ...read, audits: auditLockfiles(scan.source, scan.caches.advisories,
301
+ scan.signal, read.lockfiles.map(({ sha, line }) => ({ key: `${line.format}\0${sha}`, packages: 'packages' in line ? line.packages : null })), scan.audits) })
298
302
 
299
303
  type DependabotLine = Extract<RepositoryRiskLine, { type: 'dependabot' }>
300
304
 
@@ -319,10 +323,11 @@ async function rechecked(read: DependabotRead): Promise<DependabotRead> {
319
323
 
320
324
  // A repository read (repository-risk-read.ts readRepository) told: its
321
325
  // line, or why it couldn't be read, then its lockfiles', and their
322
- // advisories, its packages assessed meanwhile, then its Dependabot alerts.
326
+ // advisories, asked as it was read, its packages assessed meanwhile, then
327
+ // its Dependabot alerts.
323
328
  // Access to it is checked again
324
329
  // just before, however long ago it was read, as GitHub may have withdrawn it.
325
- async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: RepositoryRead | RepositoryFailure | null) {
330
+ async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Audited | null) {
326
331
  if (!read || scan.signal.aborted) return
327
332
  if ('type' in read) { scan.lines.add(read); return }
328
333
  try { await read.recheck() } catch (error) {
@@ -337,10 +342,10 @@ async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Reposi
337
342
  if ('packages' in line) assessLater(scan, line.packages)
338
343
  }
339
344
  const refs = read.actions ? tellActions(scan, repo, read.actions, lockfiles) : []
340
- for (const { sha, line } of lockfiles) {
345
+ for (const [at, { line }] of lockfiles.entries()) {
341
346
  if (scan.signal.aborted) return
342
- const advisories = 'packages' in line ? await readAdvisories(scan, line, sha) : null
343
- if (advisories) scan.lines.add(advisories)
347
+ const audit = await read.audits[at]
348
+ if (audit) scan.lines.add(advisoriesLine(line, audit))
344
349
  }
345
350
  if (scan.signal.aborted) return
346
351
  const dependabot = read.dependabot && await rechecked(read.dependabot)
@@ -366,9 +371,10 @@ function tellActions(scan: Scan, repo: RepositoryRiskRepo, read: WorkflowsRead,
366
371
  async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
367
372
  const told = scan.told.get(repo.repoId)
368
373
  if (!told || told.name !== repo.fullName || JSON.stringify(told.paths) !== JSON.stringify(repo.paths ?? null)) return false
369
- const reads: LockedPackage[][] = []
374
+ const reads: LockedPackage[][] = [], unknown = told.lockfiles.filter(key => !scan.caches.lockfiles.get(key))
375
+ const stored = unknown.length > 0 && scan.source.lockfiles ? await scan.source.lockfiles.readMany(unknown) : null
370
376
  for (const key of told.lockfiles) {
371
- const read = scan.caches.lockfiles.get(key) ?? (await scan.source.lockfiles?.read(key))?.value
377
+ const read = scan.caches.lockfiles.get(key) ?? stored?.get(key)?.value
372
378
  if (!read || !('packages' in read)) return false
373
379
  reads.push(read.packages)
374
380
  }
@@ -388,11 +394,11 @@ async function readRepositories(scan: Scan) {
388
394
  const kept = await scan.source.progress?.read()
389
395
  const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
390
396
  for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
391
- const reads: Promise<RepositoryRead | RepositoryFailure | null>[] = []
397
+ const reads: Promise<Audited | null>[] = []
392
398
  const read = (at: number) => {
393
399
  if (reads[at]) return reads[at]
394
400
  const repo = repos[at]!
395
- const started = reads[at] = toldBefore(scan, repo).then(told => told ? null : readRepository(scan, repo))
401
+ const started = reads[at] = toldBefore(scan, repo).then(async told => (told ? null : audited(scan, await readRepository(scan, repo))))
396
402
  // One read ahead and no longer waited on, as the scan stopped, fails alone.
397
403
  started.catch(() => {})
398
404
  return started
@@ -438,7 +444,8 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
438
444
  const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
439
445
  const actions = source.actions ? resolvingActions({ source: source.actions, signal: running, tell: line => { lines.add(line) }, memory: caches.actions,
440
446
  answers: caches.actionAnswers, store: source.actionLines }) : null
441
- const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions }
447
+ const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions,
448
+ audits: inTurns(AUDITS_AT_ONCE) }
442
449
  const failed: unknown[] = []
443
450
  void (async () => {
444
451
  try {
@@ -5,6 +5,7 @@
5
5
  //
6
6
  // Async throughout — the store (db.ts) is async so a future non-SQLite
7
7
  // backend slots in without touching these helpers.
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
8
9
  import type { ManagedConfig } from './config.ts'
9
10
  import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
10
11
  import { hashToken, randomToken } from './crypto.ts'
@@ -73,19 +74,32 @@ export async function createSession(
73
74
  return { setCookie, csrfToken, userId }
74
75
  }
75
76
 
77
+ // A request's sign-in, whose presence its first read to find it records.
78
+ // Every read after it in that request (the rechecks after its reads of
79
+ // storage, GitHub or npm among them) only reads: last_seen_at was written a
80
+ // moment before, by the read that let the request in.
81
+ const requests = new AsyncLocalStorage<{ seen: boolean }>()
82
+ export function runRequestPresence<T>(work: () => T): T {
83
+ return requests.run({ seen: false }, work)
84
+ }
85
+
76
86
  // Resolve the current session (+ user) from the request's cookies, or null.
77
87
  // A view cookie resolves only to that admin's view as another user: a view
78
88
  // that has ended never falls back to the admin's own session. `own` skips the
79
89
  // view and reads the signed-in session itself.
80
- export function readSession(
90
+ export async function readSession(
81
91
  config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false } = {},
82
92
  ): Promise<{ session: ManagedSession; user: StoredUser } | null> {
83
93
  const cookies = parseCookies(cookieHeader)
84
94
  const token = cookies.get(config.sessionCookieName)
85
- if (token == null || token === '') return Promise.resolve(null)
95
+ if (token == null || token === '') return null
86
96
  const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
87
- if (view != null && view !== '') return db.viewSessionWithUser(hashToken(view), hashToken(token), now)
88
- return db.sessionWithUser(hashToken(token), now)
97
+ const request = requests.getStore(), seen = request?.seen === true
98
+ const s = view != null && view !== ''
99
+ ? await (seen ? db.getViewSessionWithUser : db.viewSessionWithUser)(hashToken(view), hashToken(token), now)
100
+ : await (seen ? db.getSessionWithUser : db.sessionWithUser)(hashToken(token), now)
101
+ if (s && request) request.seen = true
102
+ return s
89
103
  }
90
104
 
91
105
  // Drop the session named by the request's cookie (logout). The caller also
@@ -49,10 +49,12 @@ export async function readSolidityEntryPoints(directory: string, listing: unknow
49
49
  for (const entry of entries) {
50
50
  if (entry.type === 'file' && sourcePath(entry.path) && addSoliditySuggestion(paths, entry.path)) limited = true
51
51
  }
52
- for (const root of roots) {
53
- if (!sourcePath(`${root.path}/Contract.sol`)) continue
52
+ // Both roots' listings asked together, then read in order.
53
+ const listings = roots.map(root => Promise.resolve().then(() => (sourcePath(`${root.path}/Contract.sol`) ? read(`/git/trees/${root.sha}?recursive=1`) : null)))
54
+ for (const listed of listings) listed.catch(() => {})
55
+ for (const [i, root] of roots.entries()) {
54
56
  try {
55
- const data = await read(`/git/trees/${root.sha}?recursive=1`) as { tree?: unknown; truncated?: unknown }
57
+ const data = await listings[i] as { tree?: unknown; truncated?: unknown } | null
56
58
  if (!Array.isArray(data?.tree)) continue
57
59
  limited ||= data.truncated === true
58
60
  for (const entry of data.tree) {
@@ -10,6 +10,9 @@ export function postgresSql(source: string): { sql: string; names: string[] } {
10
10
  .replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
11
11
  .replaceAll('CROSS JOIN', 'JOIN')
12
12
  .replaceAll(/\binstr\(/gu, 'strpos(')
13
+ // Rows gathered as one JSON array value, NULL (not '[]') where none are.
14
+ .replaceAll(/\bjson_group_array\(/gu, 'json_agg(')
15
+ .replaceAll(/\bjson_object\(/gu, 'json_build_object(')
13
16
  // Quoted literals (including ':') are never placeholders or identifiers.
14
17
  sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
15
18
  .replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
@@ -19,10 +19,14 @@ export interface ManagedSqlDriver extends ManagedSql {
19
19
  // These operations execute exactly one SQL statement. Postgres already gives
20
20
  // that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
21
21
  // Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
22
- const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
22
+ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption', 'getTeam', 'getComment',
23
+ 'userCanReadReport', 'userCanReadRepo', 'userCanReadRepoPath', 'userCanReadBundle', 'reportPermissionsFor', 'listComments', 'listManagedIssues',
24
+ 'listTriageHistory', 'listUsers', 'listUserOptions', 'listActivityReports', 'getRepoFullName', 'getUser', 'getSessionWithUser', 'getViewSessionWithUser',
25
+ 'listTriageFixes',
26
+ 'sessionWithUser', 'getTeamReportAccessSnapshot', 'getReportAccessSnapshot', 'getReportView', 'getUserGithubId', 'getUserTeamFeedSnapshot', 'getAnnotations', 'getUserTokens',
23
27
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
28
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
25
- 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
29
+ 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
26
30
  'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
27
31
  // Session presence and upload keys change only their own rows.
28
32
  const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
@@ -47,10 +47,18 @@ export function unwrapDataKey(key: StorageKey, type: StorageRowKind, row: Pick<S
47
47
  return value
48
48
  }
49
49
 
50
+ // What storageState reads of managed_storage_encryption, as `alias` names it,
51
+ // for a statement reading it with more (storageStateOf).
52
+ export const storageStateColumns = (alias: string) => `${alias}.test_value AS testValue, ${alias}.enabled_at AS enabledAt,
53
+ ${alias}.cursor AS cursor, ${alias}.complete AS complete, ${alias}.migrated AS migrated, ${alias}.cleanup_cursor AS cleanupCursor,
54
+ ${alias}.cleanup_found AS cleanupFound, ${alias}.cleanup_complete AS cleanupComplete`
50
55
  export async function storageState(db: ManagedSql, key: StorageKey | null): Promise<StorageEncryptionState | null> {
51
- const row = await db.prepare(`SELECT test_value AS testValue, enabled_at AS enabledAt, cursor, complete, migrated,
52
- cleanup_cursor AS cleanupCursor, cleanup_found AS cleanupFound, cleanup_complete AS cleanupComplete
53
- FROM managed_storage_encryption WHERE id = 1`).get() as (StorageEncryptionState & { testValue: string }) | undefined
56
+ return storageStateOf(await db.prepare(`SELECT ${storageStateColumns('e')} FROM managed_storage_encryption e WHERE e.id = 1`).get() as StoredState | undefined, key)
57
+ }
58
+ type StoredState = StorageEncryptionState & { testValue: string }
59
+ // The state storageStateColumns read, none where its row is missing, its
60
+ // test value checked against `key`.
61
+ export function storageStateOf(row: StoredState | undefined, key: StorageKey | null): StorageEncryptionState | null {
54
62
  if (!row) return null
55
63
  if (!key) throw new Error('Managed storage requires its configured encryption key')
56
64
  const plain = unwrapStorageValue(key, 'managed_storage_encryption:1', row.testValue)
@@ -180,8 +180,10 @@ async function convertStoredReport(raw: RawObjectStorage, db: StorageDb, key: St
180
180
 
181
181
  // Startup enables encryption before constructing stores; validate the persisted
182
182
  // requirement even when this adapter is opened without a key.
183
- async function storagePolicy(db: StorageDb, key: StorageKey | null) {
184
- let enabled = await db.getStorageEncryption()
183
+ // `known` is the state the caller read a moment before, where it found it
184
+ // enabled, so a cold start reads it once.
185
+ async function storagePolicy(db: StorageDb, key: StorageKey | null, known: StorageEncryptionState | null) {
186
+ let enabled = known ?? await db.getStorageEncryption()
185
187
  let checkedAt = Date.now()
186
188
  return async (fresh = false) => {
187
189
  // Enabled is irreversible. Keyless instances cache "off" briefly, while
@@ -196,8 +198,9 @@ async function storagePolicy(db: StorageDb, key: StorageKey | null) {
196
198
  }
197
199
  }
198
200
 
199
- export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null): Promise<ObjectStorage> {
200
- const mode = await storagePolicy(db, key)
201
+ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null,
202
+ known: StorageEncryptionState | null = null): Promise<ObjectStorage> {
203
+ const mode = await storagePolicy(db, key, known)
201
204
  async function open(identity: string): ReturnType<ObjectStorage['open']> {
202
205
  logicalKey(identity)
203
206
  const state = await mode()