@preventive/triage 1.0.0-alpha.46 → 1.0.0-alpha.48

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 (108) hide show
  1. package/client/dependency-paths.d.ts +2 -1
  2. package/client/dependency-paths.js +16 -4
  3. package/common/managed/agent-tasks.ts +12 -12
  4. package/common/managed/github-agents.ts +29 -10
  5. package/out/activity.js +4 -0
  6. package/out/chunk-6LNQTKAV.js +52 -0
  7. package/out/chunk-7BKKYVWR.js +7 -0
  8. package/out/chunk-7VAWINQF.js +6 -0
  9. package/out/chunk-C2AVODGZ.js +36 -0
  10. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  11. package/out/chunk-MOACFRJ4.js +3 -0
  12. package/out/chunk-O6M574JI.js +1 -0
  13. package/out/chunk-PBZPYIG7.js +1 -0
  14. package/out/chunk-PENGGKX7.js +233 -0
  15. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  16. package/out/chunk-SZD5CW5R.js +3 -0
  17. package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
  18. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  19. package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
  20. package/out/chunk-X6PJAK3T.js +1 -0
  21. package/out/client-managed-import.js +1 -1
  22. package/out/client-managed.js +26 -26
  23. package/out/client-sync.js +2 -2
  24. package/out/compare.js +14 -14
  25. package/out/dialogs.js +40 -40
  26. package/out/graph.js +11 -11
  27. package/out/index.html +12 -12
  28. package/out/scan.js +2 -2
  29. package/out/terminal.js +215 -215
  30. package/out/view.css +1 -1
  31. package/out/view.js +15 -15
  32. package/package.json +9 -5
  33. package/risk/index.d.ts +4 -2
  34. package/risk/npm/assess.js +15 -4
  35. package/risk/npm/registry.js +6 -1
  36. package/risk/npm/score.js +1 -1
  37. package/server-managed/activity.ts +24 -11
  38. package/server-managed/agent-merges.ts +10 -8
  39. package/server-managed/app-agents.ts +8 -21
  40. package/server-managed/bundle-build-worker.js +7 -3
  41. package/server-managed/bundle-build.ts +5 -3
  42. package/server-managed/bundle-cache.ts +9 -3
  43. package/server-managed/bundle.ts +3 -4
  44. package/server-managed/cache-storage.ts +3 -2
  45. package/server-managed/commit-coauthors.ts +11 -10
  46. package/server-managed/commit-lockfiles.ts +22 -21
  47. package/server-managed/db-methods.ts +338 -187
  48. package/server-managed/db-neon.ts +31 -8
  49. package/server-managed/db.ts +2 -0
  50. package/server-managed/dependabot-access.ts +1 -1
  51. package/server-managed/dependency-review-comments.ts +7 -4
  52. package/server-managed/dependency-reviews.ts +71 -33
  53. package/server-managed/github-accounts.ts +28 -1
  54. package/server-managed/github-app.ts +27 -23
  55. package/server-managed/github-graphql.ts +39 -0
  56. package/server-managed/github-issue-links.ts +8 -1
  57. package/server-managed/github-metadata.ts +2 -2
  58. package/server-managed/http-response.ts +28 -10
  59. package/server-managed/http.ts +736 -247
  60. package/server-managed/index.ts +2 -2
  61. package/server-managed/link-reports.ts +5 -1
  62. package/server-managed/managed-issues.ts +3 -0
  63. package/server-managed/npm-delta.ts +16 -0
  64. package/server-managed/npm-kept.ts +51 -10
  65. package/server-managed/npm-loads.ts +48 -28
  66. package/server-managed/npm-manifest.ts +1 -1
  67. package/server-managed/npm-packages.ts +31 -15
  68. package/server-managed/npm-risk.ts +64 -17
  69. package/server-managed/object-storage.ts +4 -3
  70. package/server-managed/pretty-print.ts +45 -12
  71. package/server-managed/public-workspace.ts +3 -3
  72. package/server-managed/pull-dependencies.ts +84 -36
  73. package/server-managed/pull-dependency-reviews.ts +13 -10
  74. package/server-managed/rename-diff-worker.js +1 -1
  75. package/server-managed/report-store.ts +16 -11
  76. package/server-managed/repository-agents.ts +9 -6
  77. package/server-managed/repository-browser.ts +17 -5
  78. package/server-managed/repository-code.ts +180 -17
  79. package/server-managed/repository-dependency-reviews.ts +7 -0
  80. package/server-managed/repository-history.ts +85 -152
  81. package/server-managed/repository-issues.ts +27 -13
  82. package/server-managed/repository-pulse.ts +9 -3
  83. package/server-managed/repository-squashes.ts +145 -0
  84. package/server-managed/request-timing.ts +63 -0
  85. package/server-managed/reviewed-dependencies.ts +3 -2
  86. package/server-managed/session.ts +34 -12
  87. package/server-managed/solidity-entry-points.ts +5 -3
  88. package/server-managed/sql-postgres.ts +3 -0
  89. package/server-managed/sql.ts +17 -8
  90. package/server-managed/storage-db.ts +11 -3
  91. package/server-managed/storage-encryption.ts +23 -12
  92. package/server-managed/storage-maintenance.ts +5 -2
  93. package/server-managed/storage-payload.ts +18 -0
  94. package/server-managed/storage-stores.ts +3 -2
  95. package/server-managed/storage.ts +4 -2
  96. package/server-managed/team-feed.ts +19 -12
  97. package/server-managed/team-npm-scopes.ts +29 -0
  98. package/server-managed/team-reports.ts +74 -17
  99. package/server-managed/uploads.ts +29 -9
  100. package/out/chunk-4UFAMJAX.js +0 -1
  101. package/out/chunk-BOIXMUU5.js +0 -3
  102. package/out/chunk-CC4OM6TZ.js +0 -3
  103. package/out/chunk-JKGQ3O7O.js +0 -6
  104. package/out/chunk-LE5ZWLG4.js +0 -235
  105. package/out/chunk-MSION3G4.js +0 -36
  106. package/out/chunk-S6GIXWQW.js +0 -52
  107. package/out/chunk-V3KWNNMY.js +0 -1
  108. package/out/chunk-WQ74ZBZF.js +0 -7
@@ -6,9 +6,11 @@
6
6
  // review and its checks, which GraphQL alone gives, so it needs a token; an
7
7
  // open one's, and its popup's, whether it changes a lockfile whose new
8
8
  // packages are told (pull-dependencies.ts touchesLockfile), and its reviews
9
- // (pull-reviews.ts), each reviewer's. A nonpublic repository's are read with
10
- // its GitHub App's installation, which reads pull requests only with their
11
- // own permission (README).
9
+ // (pull-reviews.ts), each reviewer's; and an open one's base's and head's
10
+ // commits, kept with its page but never told, by which what its lockfiles
11
+ // add is read (http.ts teamRepositoryDependencies). A nonpublic repository's
12
+ // are read with its GitHub App's installation, which reads pull requests only
13
+ // with their own permission (doc/code-viewer.md).
12
14
  import { GithubApiError } from './github-app.ts'
13
15
  import { touchesLockfile } from './pull-dependencies.ts'
14
16
  import { ACTOR, type GraphAccount, type GraphqlError, actorOf, githubGraphql } from './repository-history.ts'
@@ -16,11 +18,12 @@ import { ITEM_THREADS, type ReviewNode, TILE_THREADS, pullReviews, reviewFields,
16
18
 
17
19
  export const ISSUES_PAGE = 50
18
20
  // What a kept page or item was read as: kept ones read before differ.
19
- export const ISSUES_READ_VERSION = 3
21
+ export const ISSUES_READ_VERSION = 4
20
22
  const MAX_LABELS = 20
21
23
  const MAX_TITLE = 1000
22
24
  const MAX_NAME = 256
23
25
  const COLOR = /^[a-f\d]{6}$/iu
26
+ const SHA = /^[a-f\d]{40}$/u
24
27
  // A page's cursor as GitHub gives it, to read on from: opaque, base64 or
25
28
  // base64url as it is now (`…rOrK_u7Q==`), passed back to GitHub alone, so any
26
29
  // printable one is, but none with spaces or control characters.
@@ -43,21 +46,22 @@ const connection = { issues: 'issues', pulls: 'pullRequests' }
43
46
  const states = { issues: { open: '[OPEN]', closed: '[CLOSED]' }, pulls: { open: '[OPEN]', closed: '[CLOSED, MERGED]' } }
44
47
 
45
48
  // A page of `kind`, those in `state`, after the page `after` ends, and how
46
- // many of each state there are; open pull requests with their reviews.
49
+ // many of each state there are; open pull requests with their commits and
50
+ // their reviews.
47
51
  const pageQuery = (kind: IssuesKind, state: IssuesState) => `query($owner: String!, $name: String!, $after: String) {
48
52
  repository(owner: $owner, name: $name) {
49
53
  open: ${connection[kind]}(states: ${states[kind].open}) { totalCount }
50
54
  closed: ${connection[kind]}(states: ${states[kind].closed}) { totalCount }
51
55
  page: ${connection[kind]}(states: ${states[kind][state]}, first: ${ISSUES_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
52
56
  pageInfo { hasNextPage endCursor }
53
- nodes { ${fields[kind]}${kind === 'pulls' && state === 'open' ? ` ${files} ${reviewFields(TILE_THREADS, false)}` : ''} }
57
+ nodes { ${fields[kind]}${kind === 'pulls' && state === 'open' ? ` baseRefOid headRefOid ${files} ${reviewFields(TILE_THREADS, false)}` : ''} }
54
58
  }
55
59
  }
56
60
  }`
57
61
 
58
62
  type Node = ReviewNode & { number?: unknown; title?: unknown; createdAt?: unknown; updatedAt?: unknown; closedAt?: unknown; mergedAt?: unknown; author?: GraphAccount
59
63
  comments?: { totalCount?: unknown }; labels?: { totalCount?: unknown; nodes?: { name?: unknown; color?: unknown }[] }; state?: unknown; stateReason?: unknown
60
- isDraft?: unknown; reviewDecision?: unknown; additions?: unknown; deletions?: unknown; headRefName?: unknown; baseRefName?: unknown
64
+ isDraft?: unknown; reviewDecision?: unknown; additions?: unknown; deletions?: unknown; headRefName?: unknown; baseRefName?: unknown; baseRefOid?: unknown
61
65
  commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: unknown } | null } | null }[] }
62
66
  files?: { totalCount?: unknown; nodes?: ({ path?: unknown } | null)[] } | null }
63
67
  type Page = { repository?: { open?: { totalCount?: unknown }; closed?: { totalCount?: unknown }
@@ -159,9 +163,17 @@ export async function repositoryItem(fullName: string, token: string, number: nu
159
163
  mergedBy: merger.login, mergedById: merger.id, reviews: pullReviews(node, tile.author, true) }
160
164
  }
161
165
 
166
+ // An open pull request's base's and head's commits, by its number, as GitHub
167
+ // has them now: those its REST API names `base.sha` and `head.sha`
168
+ // (pull-dependencies.ts pullCommits). None where either isn't a commit's id.
169
+ const commitsOf = (number: number, { baseRefOid: base, headRefOid: head }: Node) =>
170
+ typeof base === 'string' && SHA.test(base) && typeof head === 'string' && SHA.test(head) ? [{ number, base, head }] : []
171
+
162
172
  // A page of the repository's issues or pull requests in `state`, after the
163
173
  // page `after` ended (null for the first): its `items`, how many are open and
164
- // closed (`counts`), and, where more follow, the cursor reading them (`after`).
174
+ // closed (`counts`), and, where more follow, the cursor reading them
175
+ // (`after`); open pull requests' commits (`commits`, commitsOf) too, which
176
+ // the code viewer is never told (http.ts handleRepositoryIssues).
165
177
  export async function repositoryIssues(fullName: string, token: string, kind: IssuesKind, state: IssuesState, after: string | null, fetchImpl: typeof fetch = globalThis.fetch) {
166
178
  const [owner, name] = fullName.split('/') as [string, string]
167
179
  const data = await githubGraphql(pageQuery(kind, state), { owner, name, after }, token, fetchImpl, partRefused) as Page
@@ -169,13 +181,15 @@ export async function repositoryIssues(fullName: string, token: string, kind: Is
169
181
  if (!repository) throw new GithubApiError(404, 'github-not-found')
170
182
  const info = repository.page?.pageInfo
171
183
  const next = info?.hasNextPage === true && typeof info.endCursor === 'string' && ISSUES_CURSOR.test(info.endCursor) ? info.endCursor : null
184
+ const open = kind === 'pulls' && state === 'open'
185
+ const tiles = (repository.page?.nodes ?? []).flatMap(node => {
186
+ const tile = item(kind, node)
187
+ return tile && node ? [{ node, tile }] : []
188
+ })
172
189
  return {
173
- items: (repository.page?.nodes ?? []).flatMap(node => {
174
- const tile = item(kind, node)
175
- if (!tile) return []
176
- return kind === 'pulls' && state === 'open' && node ? [{ ...tile, reviews: pullReviews(node, tile.author) }] : [tile]
177
- }),
190
+ items: tiles.map(({ node, tile }) => open ? { ...tile, reviews: pullReviews(node, tile.author) } : tile),
178
191
  counts: { open: count(repository.open?.totalCount), closed: count(repository.closed?.totalCount) },
179
192
  more: next !== null, after: next,
193
+ ...open ? { commits: tiles.flatMap(({ node, tile }) => commitsOf(tile.number, node)) } : {},
180
194
  }
181
195
  }
@@ -20,7 +20,7 @@ import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accoun
20
20
  export const PULSE_WEEKS = [1, 4, 13, 52]
21
21
  // What a year counted from its commits holds, its version: kept years of
22
22
  // another are counted again.
23
- export const PULSE_YEAR_VERSION = 8
23
+ export const PULSE_YEAR_VERSION = 11
24
24
  const WEEK_MS = 7 * 24 * 60 * 60_000
25
25
  const DAY_MS = 24 * 60 * 60_000
26
26
  const MAX_CONTRIBUTORS = 100
@@ -227,15 +227,21 @@ export async function yearCommits(fullName: string, token: string, head: string,
227
227
  return { complete: true, commits: [...found.values()].filter(dated), passed: [...found.values()].filter(commit => !dated(commit) && within(commit)) }
228
228
  }
229
229
 
230
+ // Whether an author is a GitHub App's bot: by its login (`<app>[bot]`, as a
231
+ // commit's no-reply address names it, github-accounts.ts commitAccount),
232
+ // else, for one GitHub names no account for, by the name it commits as.
233
+ const bot = (login: string | null | undefined, name: string | null | undefined) => /\[bot\]$/iu.test(login ?? name ?? '')
234
+
230
235
  // Whether an agent made `commit` or co-authored it: its author one of
231
236
  // GitHub's agents (common/managed/github-agents.ts githubAgent), or any of
232
237
  // its co-authors an agent (commit-coauthors.ts coauthorsOf), as Commits
233
- // marks them; or one an agent's pull request brought (`merges`,
238
+ // marks them, or any bot, whichever GitHub App's; or one an agent's pull request brought (`merges`,
234
239
  // agent-merges.ts agentCommits); or one an account agents work as pushed to
235
240
  // the branch directly (`direct`, github-agents.ts agentOperated).
236
241
  const withAgent = (commit: HistoryCommit, merges: ReadonlySet<string>, direct: ReadonlySet<string>) => merges.has(commit.sha)
237
242
  || (direct.has(commit.sha) && agentOperated(commit.authorLogin, commit.authorId ?? null))
238
- || githubAgent(commit.authorLogin, commit.authorId ?? null, commit.authorName) !== null || (commit.coauthors ?? []).some(coauthor => coauthor.agent)
243
+ || githubAgent(commit.authorLogin, commit.authorId ?? null, commit.authorName) !== null || bot(commit.authorLogin, commit.authorName)
244
+ || (commit.coauthors ?? []).some(coauthor => Boolean(coauthor.agent) || bot(coauthor.login, coauthor.name))
239
245
 
240
246
  // The year from `first` (pulseSince(52, …)) of `commits`, as GitHub's
241
247
  // statistics count them, merges left out: a week a row, its commits each day
@@ -0,0 +1,145 @@
1
+ // The pull requests squashed into a repository's commits, as the code
2
+ // viewer's Commits tab draws them (repository-history.ts), each looked up
3
+ // with GraphQL once and kept by its commit.
4
+ import { linkNoreplyUsers } from './github-accounts.ts'
5
+ import { type GraphqlError, githubGraphql } from './github-graphql.ts'
6
+ import { COMMIT_FRAGMENT, type GraphCommit, type HistoryCommit, commitActors, graphCommit, inBatches, variablesOf } from './repository-history.ts'
7
+
8
+ const SHA = /^[a-f\d]{40}$/u
9
+
10
+ // A pull request squashed into a commit of the default branch's history
11
+ // (`merge`): its number, the commit its head was at when merged (`head`,
12
+ // GitHub's own of it, which a branch reused or pushed over since never
13
+ // moves), and its own commits, the latest MAX_SQUASHED of them, `more`
14
+ // where it had more. Drawn as the merge it was, its commits on a lane of
15
+ // their own (repository-history.js).
16
+ export interface SquashedPull { merge: string; number: number; head: string; commits: HistoryCommit[]; more: boolean }
17
+ // The pull requests squashed into commits, kept by each commit (a merged pull
18
+ // request's commits never change); null for a commit none was.
19
+ export interface Squashes {
20
+ read(shas: string[]): Promise<Map<string, SquashedPull | null>>
21
+ write(sha: string, pull: SquashedPull | null): Promise<void>
22
+ }
23
+
24
+ const MAX_SQUASHED = 100
25
+ // Looked up of an answer's commits not yet kept, at most, the newest first;
26
+ // in queries of as many commits, or pull requests (their commits' lines
27
+ // counted, as a history's are), as many at once.
28
+ export const MAX_SQUASH_LOOKUPS = 500
29
+ const SQUASH_COMMITS = 100
30
+ const SQUASH_PULLS = 5
31
+ const SQUASH_PARALLEL = 4
32
+
33
+ // The merged pull request that brought each commit in, `c<n>` the `n`th's.
34
+ const mergedPullsQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
35
+ repository(owner: $owner, name: $name) {
36
+ ${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { associatedPullRequests(first: 1) { nodes { number mergeCommit { oid } } } } }`).join('\n ')}
37
+ }
38
+ }`
39
+ // Each pull request's head when merged and its latest commits, `p<n>` the `n`th's.
40
+ const squashedQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('n', count, 'Int!')}) {
41
+ repository(owner: $owner, name: $name) {
42
+ ${Array.from({ length: count }, (_, at) => `p${at}: pullRequest(number: $n${at}) { headRefOid commits(last: ${MAX_SQUASHED}) { totalCount nodes { commit { ...historyCommit } } } }`).join('\n ')}
43
+ }
44
+ }
45
+ ${COMMIT_FRAGMENT}`
46
+
47
+ type MergedPulls = { repository?: Record<string, { associatedPullRequests?: { nodes?: ({ number?: unknown; mergeCommit?: { oid?: unknown } | null } | null)[] } } | null> | null }
48
+ type SquashedPage = { repository?: Record<string, { headRefOid?: unknown; commits?: { totalCount?: unknown; nodes?: ({ commit?: GraphCommit | null } | null)[] } } | null> | null }
49
+
50
+ // A pull request or commit GitHub has none of, as one deleted: its field null.
51
+ const notFound = (error: GraphqlError) => error?.type === 'NOT_FOUND'
52
+
53
+ // Each repository's lookup of squashed pull requests going on, by its full
54
+ // name: one at a time, those asking meanwhile waiting on it, and their
55
+ // commits it doesn't look up looked up once it is done.
56
+ const lookingUp = new Map<string, Promise<void>>()
57
+
58
+ // Whether `promise` settles within `ms`, waited on that long at most.
59
+ const within = (promise: Promise<unknown>, ms: number) => ms === Number.POSITIVE_INFINITY ? promise
60
+ : new Promise<void>(resolve => {
61
+ const timer = setTimeout(resolve, ms)
62
+ void promise.finally(() => { clearTimeout(timer); resolve() })
63
+ })
64
+
65
+ // The pull requests squashed into `commits`, a merged pull request's merge
66
+ // commit of one parent, not one rebased (its commits copied onto the branch,
67
+ // told by their subjects), kept by each commit (`squashes`), those not yet
68
+ // kept looked up with GraphQL, as many as MAX_SQUASH_LOOKUPS an answer, each
69
+ // kept as it is found (lookUpSquashes). Answered with those kept once the
70
+ // repository's lookup is done, or `waitMs` on, that lookup going on behind:
71
+ // `behind`, one this started, for the caller to keep alive, and `pending`
72
+ // while one does. Another's lookup done within it, but not of all of these,
73
+ // those it left are looked up as this one's, within what is left of it.
74
+ export async function squashedPulls(fullName: string, token: string, commits: HistoryCommit[], squashes: Squashes,
75
+ waitMs = Number.POSITIVE_INFINITY, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ pulls: SquashedPull[]; pending: boolean; behind: Promise<void> | null }> {
76
+ const single = commits.filter(commit => commit.parents.length === 1)
77
+ const kept = await squashes.read(single.map(commit => commit.sha))
78
+ let unknown = single.filter(commit => !kept.has(commit.sha))
79
+ if (unknown.length === 0) return { pulls: single.flatMap(commit => kept.get(commit.sha) ?? []), pending: false, behind: null }
80
+ const listed = new Map(commits.map(commit => [commit.sha, commit]))
81
+ const deadline = Date.now() + waitMs
82
+ const found = new Map<string, SquashedPull | null>()
83
+ // Waited on, as long as is left, and those of `unknown` it kept found.
84
+ const settle = async (looking: Promise<void>) => {
85
+ await within(looking, Math.max(0, deadline - Date.now()))
86
+ for (const [sha, pull] of await squashes.read(unknown.map(commit => commit.sha))) found.set(sha, pull)
87
+ unknown = unknown.filter(commit => !found.has(commit.sha))
88
+ }
89
+ const another = lookingUp.get(fullName)
90
+ if (another) await settle(another)
91
+ let behind: Promise<void> | null = null
92
+ if (!another || (unknown.length > 0 && !lookingUp.has(fullName))) {
93
+ const lookup = lookUpSquashes(fullName, token, unknown.slice(0, MAX_SQUASH_LOOKUPS), listed, squashes, fetchImpl)
94
+ const done: Promise<void> = lookup.catch(() => {}).finally(() => { if (lookingUp.get(fullName) === done) lookingUp.delete(fullName) })
95
+ lookingUp.set(fullName, done)
96
+ behind = lookup
97
+ await settle(done)
98
+ }
99
+ return { pulls: single.flatMap(commit => kept.get(commit.sha) ?? found.get(commit.sha) ?? []), pending: lookingUp.has(fullName), behind }
100
+ }
101
+
102
+ // The pull requests squashed into `unknown`, of the commits `listed`, looked
103
+ // up, each kept as it is found (squashedPulls).
104
+ async function lookUpSquashes(fullName: string, token: string, unknown: HistoryCommit[], listed: Map<string, HistoryCommit>, squashes: Squashes,
105
+ fetchImpl: typeof fetch) {
106
+ const [owner, name] = fullName.split('/') as [string, string]
107
+ const merged: { commit: HistoryCommit; number: number }[] = []
108
+ await inBatches(unknown, SQUASH_COMMITS, SQUASH_PARALLEL, async batch => {
109
+ const data = await githubGraphql(mergedPullsQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((commit, at) => [`o${at}`, commit.sha])) },
110
+ token, fetchImpl, notFound) as MergedPulls
111
+ for (const [at, commit] of batch.entries()) {
112
+ const node = data?.repository?.[`c${at}`]
113
+ if (!node) continue
114
+ const pull = node.associatedPullRequests?.nodes?.[0]
115
+ const number = pull?.mergeCommit?.oid === commit.sha && Number.isSafeInteger(pull.number) && (pull.number as number) > 0 ? pull.number as number : null
116
+ if (number === null) await squashes.write(commit.sha, null)
117
+ else merged.push({ commit, number })
118
+ }
119
+ })
120
+ await inBatches(merged, SQUASH_PULLS, SQUASH_PARALLEL, async batch => {
121
+ const data = await githubGraphql(squashedQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map(({ number }, at) => [`n${at}`, number])) },
122
+ token, fetchImpl, notFound) as SquashedPage
123
+ await linkNoreplyUsers(Object.values(data?.repository ?? {}).flatMap(pull => (pull?.commits?.nodes ?? []).flatMap(item => commitActors(item?.commit))), token, fetchImpl)
124
+ for (const [at, { commit, number }] of batch.entries()) {
125
+ const node = data?.repository?.[`p${at}`]
126
+ if (!node) continue
127
+ const own = (node.commits?.nodes ?? []).flatMap(item => graphCommit(item?.commit) ?? [])
128
+ const head = typeof node.headRefOid === 'string' && SHA.test(node.headRefOid) ? node.headRefOid : own.at(-1)?.sha
129
+ // Fast-forwarded, its last commit the merge's own; or rebased, its
130
+ // commits, more than one, copied onto the branch: the merge and those
131
+ // below it on their first parents, by their subjects, its commits'
132
+ // (one squashed is told by its commit's title too, by default, and
133
+ // drawn as it was either way).
134
+ const below = [commit]
135
+ for (let next = listed.get(commit.parents[0] ?? ''); next && below.length < own.length; next = listed.get(next.parents[0] ?? '')) below.push(next)
136
+ const copies = own.toReversed().slice(0, below.length).every((item, step) => below[step]!.subject === item.subject)
137
+ // Its commits' copies as far as those listed go, but not all of them
138
+ // listed: told, and kept, once they are.
139
+ if (own.length > 1 && copies && below.length < own.length) continue
140
+ const rebased = own.length > 1 && copies
141
+ const squashed = head !== undefined && head !== commit.sha && own.length > 0 && !own.some(item => item.sha === commit.sha) && !rebased
142
+ await squashes.write(commit.sha, squashed ? { merge: commit.sha, number, head, commits: own, more: Number(node.commits?.totalCount) > own.length } : null)
143
+ }
144
+ })
145
+ }
@@ -0,0 +1,63 @@
1
+ // Where a request's time goes, told back in its Server-Timing header, which
2
+ // DevTools shows beside the request: the database's statements and the time
3
+ // spent in its operations (sql.ts), new pooled connections and their
4
+ // handshakes (db-neon.ts), GitHub's calls (github-app.ts githubJson), and
5
+ // the whole, from the handler's start to its answer's head; an instance's
6
+ // first request marked cold. Counts and durations alone, nothing of what was
7
+ // asked; a streamed answer's head tells what was known as it was written.
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
9
+ import { ServerResponse } from 'node:http'
10
+
11
+ interface Timing {
12
+ started: number
13
+ cold: boolean
14
+ statements: number
15
+ db: number; dbMs: number
16
+ conn: number; connMs: number
17
+ gh: number; ghMs: number
18
+ }
19
+ const requests = new AsyncLocalStorage<Timing>()
20
+ let served = false
21
+
22
+ // `work` timed, its response's head carrying what it spent where; a test's
23
+ // stand-in for a response, not a ServerResponse, is left as it is.
24
+ export function runTimed<T>(res: ServerResponse, work: () => T): T {
25
+ const timing: Timing = { started: performance.now(), cold: !served, statements: 0, db: 0, dbMs: 0, conn: 0, connMs: 0, gh: 0, ghMs: 0 }
26
+ served = true
27
+ if (res instanceof ServerResponse) {
28
+ const writeHead = res.writeHead
29
+ res.writeHead = function (this: ServerResponse, ...args: Parameters<ServerResponse['writeHead']>) {
30
+ if (!this.headersSent) this.setHeader('server-timing', header(timing))
31
+ return writeHead.apply(this, args)
32
+ } as ServerResponse['writeHead']
33
+ }
34
+ return requests.run(timing, work)
35
+ }
36
+
37
+ // `work`, its time counted as `kind`'s where a request is timed.
38
+ export async function timed<T>(kind: 'db' | 'conn' | 'gh', work: () => Promise<T>): Promise<T> {
39
+ const timing = requests.getStore()
40
+ if (!timing) return work()
41
+ const start = performance.now()
42
+ try { return await work() } finally {
43
+ timing[kind]++
44
+ timing[`${kind}Ms`] += performance.now() - start
45
+ }
46
+ }
47
+
48
+ // A statement sent to the database, where a request is timed.
49
+ export function countStatement(): void {
50
+ const timing = requests.getStore()
51
+ if (timing) timing.statements++
52
+ }
53
+
54
+ function header(timing: Timing): string {
55
+ const ms = (value: number) => value.toFixed(1)
56
+ return [
57
+ ...timing.db > 0 || timing.statements > 0 ? [`db;dur=${ms(timing.dbMs)};desc="${timing.statements} statements"`] : [],
58
+ ...timing.conn > 0 ? [`conn;dur=${ms(timing.connMs)}`] : [],
59
+ ...timing.gh > 0 ? [`gh;dur=${ms(timing.ghMs)};desc="${timing.gh} calls"`] : [],
60
+ ...timing.cold ? ['cold;desc="first request of this instance"'] : [],
61
+ `app;dur=${ms(performance.now() - timing.started)}`,
62
+ ].join(', ')
63
+ }
@@ -64,8 +64,9 @@ export interface CommentReview {
64
64
  dependencies: { ecosystem?: 'npm'; name: string; version: string; integrity: string; patchHash?: string; status: ReviewVerdict }[]
65
65
  }
66
66
  // A version a dependency review is of, or compared it with: its package's
67
- // name, its version and integrity, and its tarball's shasum and its patch's
68
- // hash where they are known (a lockfile's, pnpm's patched dependencies').
67
+ // name, its version, the integrity and, where npm tells one, the shasum of
68
+ // its tarball, each held to the tarball reviewed, and its patch's hash where
69
+ // it is known (pnpm's patched dependencies').
69
70
  export interface ReviewedVersion { name: string; version: string; integrity: string; shasum?: string; patchHash?: string }
70
71
  // One of one's own published dependency reviews, as `formatDependencyReviews`
71
72
  // is given it: the version it is of (`new`) and the one it compared it with
@@ -77,29 +77,51 @@ export async function createSession(
77
77
  // A request's sign-in, whose presence its first read to find it records.
78
78
  // Every read after it in that request (the rechecks after its reads of
79
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 }>()
80
+ // moment before, by the read that let the request in. What the router's
81
+ // gate read is handed to the read after it that says nothing was read
82
+ // between them (`handed`), asking nothing again; once only, and to none
83
+ // once another read came first.
84
+ type SessionRead = { session: ManagedSession; user: StoredUser }
85
+ const requests = new AsyncLocalStorage<{ seen: boolean; handed: { cookieHeader: string | undefined; read: SessionRead } | null }>()
82
86
  export function runRequestPresence<T>(work: () => T): T {
83
- return requests.run({ seen: false }, work)
87
+ return requests.run({ seen: false, handed: null }, work)
88
+ }
89
+ export function handOverSession(cookieHeader: string | undefined, read: SessionRead): void {
90
+ const request = requests.getStore()
91
+ if (request) request.handed = { cookieHeader, read }
84
92
  }
85
93
 
86
94
  // Resolve the current session (+ user) from the request's cookies, or null.
87
95
  // A view cookie resolves only to that admin's view as another user: a view
88
96
  // that has ended never falls back to the admin's own session. `own` skips the
89
- // view and reads the signed-in session itself.
97
+ // view and reads the signed-in session itself. `handed` takes the gate's
98
+ // read, where it is this read's first since (handOverSession).
90
99
  export async function readSession(
91
- config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false } = {},
92
- ): Promise<{ session: ManagedSession; user: StoredUser } | null> {
100
+ config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false, handed = false } = {},
101
+ ): Promise<SessionRead | null> {
102
+ const request = requests.getStore()
103
+ const given = request?.handed
104
+ if (request) request.handed = null
105
+ if (handed && !own && given && given.cookieHeader === cookieHeader) return given.read
106
+ const ids = sessionIds(config, cookieHeader, { own })
107
+ if (!ids) return null
108
+ const seen = request?.seen === true
109
+ const s = ids.viewer === null
110
+ ? await (seen ? db.getSessionWithUser : db.sessionWithUser)(ids.id, now)
111
+ : await (seen ? db.getViewSessionWithUser : db.viewSessionWithUser)(ids.id, ids.viewer, now)
112
+ if (s && request) request.seen = true
113
+ return s
114
+ }
115
+
116
+ // The sessions the request's cookies name, as readSession reads them: the
117
+ // one acting (`id`), a view where an admin views as another user, with the
118
+ // admin's own it is a view of (`viewer`); none signed out.
119
+ export function sessionIds(config: ManagedConfig, cookieHeader: string | undefined, { own = false } = {}): { id: string; viewer: string | null } | null {
93
120
  const cookies = parseCookies(cookieHeader)
94
121
  const token = cookies.get(config.sessionCookieName)
95
122
  if (token == null || token === '') return null
96
123
  const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
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
124
+ return view != null && view !== '' ? { id: hashToken(view), viewer: hashToken(token) } : { id: hashToken(token), viewer: null }
103
125
  }
104
126
 
105
127
  // 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) => {
@@ -2,6 +2,7 @@
2
2
  // its single connection across awaits; Neon can reuse a request-owned connection.
3
3
  import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
4
4
  import type { ManagedDb } from './db-methods.ts'
5
+ import { countStatement, timed } from './request-timing.ts'
5
6
 
6
7
  type MaybePromise<T> = T | Promise<T>
7
8
  export interface ManagedSql {
@@ -21,8 +22,13 @@ export interface ManagedSqlDriver extends ManagedSql {
21
22
  // Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
22
23
  const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption', 'getTeam', 'getComment',
23
24
  'userCanReadReport', 'userCanReadRepo', 'userCanReadRepoPath', 'userCanReadBundle', 'reportPermissionsFor', 'listComments', 'listManagedIssues',
24
- 'listTriageHistory', 'listUsers', 'listUserOptions', 'listActivityReports', 'getRepoFullName', 'getSessionWithUser', 'getViewSessionWithUser',
25
- 'listTriageFixes',
25
+ 'listTriageHistory', 'listUsers', 'listUserOptions', 'listActivityReports', 'getRepoFullName', 'getUser', 'getSessionWithUser', 'getViewSessionWithUser',
26
+ 'listTriageFixes', 'getRepoDependencyReviews', 'listTeamDependencyReviews', 'getNpmReviewReader',
27
+ 'listDependencyReviewTeams', 'listDependencyReviewEnabledTeams', 'listUserNpmScopes', 'listTeamNpmScopes', 'hasTeamNpmScope',
28
+ 'listGithubMetadata', 'listGithubRepositoryVisibility', 'setGithubMetadata', 'recordGithubMetadataAttempts', 'setGithubRepositoryVisibility',
29
+ 'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForRepo', 'listBundlesForRepo',
30
+ 'listFindingCatalogReports', 'userCanReadBundleAdvisories',
31
+ 'sessionWithUser', 'getTeamReportAccessSnapshot', 'getReportAccessSnapshot', 'getBundleAccessSnapshot', 'getReportView', 'getUserGithubId', 'getUserTeamFeedSnapshot', 'getAnnotations', 'getAnnotationRevision', 'getUserTokens',
26
32
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
27
33
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
28
34
  'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
@@ -33,12 +39,13 @@ const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensu
33
39
  export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
34
40
  const entries = Object.entries(methods).map(([name, method]) => {
35
41
  if (name === 'close') return [name, async () => { await driver.close() }]
36
- const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
37
- return [name, (...args: unknown[]) => driver.scope(write, () => method(...args), {
42
+ const write = !/^(?:get|list|has|userCanRead|reportPermissionsFor)/u.test(name)
43
+ // Each operation's time told in its request's Server-Timing (request-timing.ts).
44
+ return [name, (...args: unknown[]) => timed('db', () => driver.scope(write, () => method(...args), {
38
45
  // Presence writes only one user's timestamp; they need no application
39
46
  // writer lock. Keep the session/user read and timestamp in one transaction.
40
47
  lock: write && !UNLOCKED_WRITES.has(name), statement: SINGLE_STATEMENTS.has(name),
41
- })]
48
+ }))]
42
49
  })
43
50
  return Object.fromEntries(entries) as ManagedDb
44
51
  }
@@ -55,17 +62,19 @@ export function createSqliteDriver(db: DatabaseSync): ManagedSqlDriver {
55
62
  prepare(sql) {
56
63
  const stmt = db.prepare(sql)
57
64
  return {
58
- get: (...args) => stmt.get(...args as SQLInputValue[]),
59
- all: (...args) => stmt.all(...args as SQLInputValue[]),
60
- run: (...args) => stmt.run(...args as SQLInputValue[]),
65
+ get: (...args) => { countStatement(); return stmt.get(...args as SQLInputValue[]) },
66
+ all: (...args) => { countStatement(); return stmt.all(...args as SQLInputValue[]) },
67
+ run: (...args) => { countStatement(); return stmt.run(...args as SQLInputValue[]) },
61
68
  }
62
69
  },
63
70
  scope(write, work) {
64
71
  if (closed) return Promise.reject(new Error('Managed database is closed'))
65
72
  return enqueue(async () => {
73
+ countStatement()
66
74
  db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN')
67
75
  try {
68
76
  const result = await work()
77
+ countStatement()
69
78
  db.exec('COMMIT')
70
79
  return result
71
80
  } catch (err) {
@@ -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)
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream'
4
4
  import { type StorageKey, decryptStorageStream, encryptStorageStream, wrapStorageValue } from '../server-common/storage-crypto.ts'
5
5
  import { ENCRYPTED_CACHE_PREFIX, type ObjectStorage, type RawObject, type RawObjectStorage, deleteObjects, objectPath, openObjectVersion } from './object-storage.ts'
6
6
  import { type StorageDb, type StorageEncryptionState, type StorageRow, type StorageRowKind, dataKeyIdentity, unwrapDataKey } from './storage-db.ts'
7
- import { PUBLIC_CACHE_PREFIX, inspectStorageObject, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
7
+ import { PUBLIC_CACHE_PREFIX, inspectStorageObject, masterKeyCache, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
8
8
 
9
9
  const STORAGE_WRITE_MS = 180_000
10
10
  export const STORAGE_UPLOAD_TTL_MS = 86_400_000
@@ -68,6 +68,12 @@ async function decryptOwned(stored: RawObject, key: StorageKey, type: StorageRow
68
68
  async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageKey, identity: string, state: StorageEncryptionState): ReturnType<ObjectStorage['open']> {
69
69
  const owner = storageOwner(identity)
70
70
  if (!owner) {
71
+ // A cache no row owns: its ciphertext alone, what was kept as plaintext
72
+ // before activation never read.
73
+ if (masterKeyCache(identity)) {
74
+ const stored = await raw.open(encryptedCachePath(identity))
75
+ return stored && decryptStorageStream(stored.stream, key.bytes, identity)
76
+ }
71
77
  if (!identity.startsWith('uploads/')) throw new Error('Unknown managed storage owner')
72
78
  const stored = await raw.open(identity)
73
79
  if (!stored) return null
@@ -180,8 +186,10 @@ async function convertStoredReport(raw: RawObjectStorage, db: StorageDb, key: St
180
186
 
181
187
  // Startup enables encryption before constructing stores; validate the persisted
182
188
  // 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()
189
+ // `known` is the state the caller read a moment before, where it found it
190
+ // enabled, so a cold start reads it once.
191
+ async function storagePolicy(db: StorageDb, key: StorageKey | null, known: StorageEncryptionState | null) {
192
+ let enabled = known ?? await db.getStorageEncryption()
185
193
  let checkedAt = Date.now()
186
194
  return async (fresh = false) => {
187
195
  // Enabled is irreversible. Keyless instances cache "off" briefly, while
@@ -196,8 +204,9 @@ async function storagePolicy(db: StorageDb, key: StorageKey | null) {
196
204
  }
197
205
  }
198
206
 
199
- export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null): Promise<ObjectStorage> {
200
- const mode = await storagePolicy(db, key)
207
+ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null,
208
+ known: StorageEncryptionState | null = null): Promise<ObjectStorage> {
209
+ const mode = await storagePolicy(db, key, known)
201
210
  async function open(identity: string): ReturnType<ObjectStorage['open']> {
202
211
  logicalKey(identity)
203
212
  const state = await mode()
@@ -226,7 +235,7 @@ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: St
226
235
  if (!row || (owner.cache ? !row.dataKey : !storageRowPaths(owner.type, row).includes(identity))) return false
227
236
  }
228
237
  // Existence is metadata only; open() authenticates the actual bytes.
229
- return await raw.head(state && owner?.cache ? encryptedCachePath(identity) : identity) !== null
238
+ return await raw.head(state && (owner?.cache || masterKeyCache(identity)) ? encryptedCachePath(identity) : identity) !== null
230
239
  },
231
240
  async put(identity, bytes, { replaceReport = false } = {}) {
232
241
  logicalKey(identity)
@@ -245,10 +254,10 @@ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: St
245
254
  dataKey = randomBytes(32)
246
255
  wrapped = wrapStorageValue(key!, dataKeyIdentity(owner.type, owner.id), dataKey)
247
256
  } else {
248
- if (!identity.startsWith('uploads/')) throw new Error('Unknown managed storage owner')
257
+ if (!identity.startsWith('uploads/') && !masterKeyCache(identity)) throw new Error('Unknown managed storage owner')
249
258
  dataKey = Buffer.from(key!.bytes)
250
259
  }
251
- const target = owner?.cache ? encryptedCachePath(identity) : identity
260
+ const target = owner?.cache || masterKeyCache(identity) ? encryptedCachePath(identity) : identity
252
261
  const encrypted = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
253
262
  try {
254
263
  await raw.put(target, encrypted, AbortSignal.timeout(STORAGE_WRITE_MS), undefined, bytes.length)
@@ -265,13 +274,15 @@ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: St
265
274
  // when migration is disabled or another instance has just enabled it.
266
275
  for (const target of deletionPaths(identity)) await raw.delete(target)
267
276
  },
268
- async deletePrefix(prefix) {
277
+ async deletePrefix(prefix, keep) {
269
278
  logicalKey(`${prefix}validate`)
270
- if (!prefix.endsWith('/')) throw new Error('Invalid storage prefix')
279
+ if (!prefix.endsWith('/') || (keep !== undefined && (!keep.startsWith(prefix) || !keep.endsWith('/')))) throw new Error('Invalid storage prefix')
271
280
  await mode()
272
281
  const signal = AbortSignal.timeout(STORAGE_WRITE_MS)
273
- for (const target of deletionPaths(prefix)) {
274
- await deleteObjects(raw, target, signal)
282
+ // What is kept, in the same generation as each removed.
283
+ const kept = keep === undefined ? [] : deletionPaths(keep)
284
+ for (const [i, target] of deletionPaths(prefix).entries()) {
285
+ await deleteObjects(raw, target, signal, undefined, kept[i])
275
286
  await raw.prune?.(target, signal)
276
287
  }
277
288
  },