@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
@@ -44,7 +44,7 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
44
44
 
45
45
  async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
46
46
  const storage = await openManagedStorage(config)
47
- const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage } = storage
47
+ const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage } = storage
48
48
  rollback.defer(() => db.close())
49
49
  const originGate = createOriginGate(config.host, config.trustProxyEnv)
50
50
 
@@ -60,7 +60,7 @@ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options
60
60
  indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
61
61
  }) : () => false
62
62
  const routeRequest = createManagedRequestHandler({
63
- ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, originGate, serveStatic,
63
+ ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage, originGate, serveStatic,
64
64
  ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
65
65
  isShuttingDown, track,
66
66
  })
@@ -39,7 +39,11 @@ function decryptGroups(key: StorageKey | null, id: string, payload: string): str
39
39
  try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
40
40
  }
41
41
  export async function linkRevision(db: ManagedSql): Promise<string> {
42
- const rows = await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all()
42
+ return linkRevisionOf(await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all())
43
+ }
44
+ // The revision of the enabled link reports' `{ id, sha256 }` rows, by id,
45
+ // however they were read.
46
+ export function linkRevisionOf(rows: unknown[]): string {
43
47
  return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
44
48
  }
45
49
  function parseLinkReport(content: string) {
@@ -53,6 +53,9 @@ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
53
53
  issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
54
54
  const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
55
55
  const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
56
+ // A lookup's derived Fix replaces the one it read, unless one started later
57
+ // changed it since: the check time, which only changes stamp
58
+ // (github-issue-links.ts issueUpdates), orders them.
56
59
  const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
57
60
  WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
58
61
  AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
@@ -154,6 +154,22 @@ export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelt
154
154
  return work
155
155
  }
156
156
 
157
+ // The deltas of those of `sides` (each the integrities of the version it is
158
+ // against, or none, and of the version) held or kept, by their keys
159
+ // (npmDelta's), in one read of `kept` for those not held: none worked out.
160
+ export async function keptNpmDeltas(sides: [string | null, string][], kept: KeptStore<NpmDelta>): Promise<Map<string, NpmDelta>> {
161
+ const keys = [...new Set(sides.map(([base, other]) => `${base ?? '(empty)'}\0${other}`))]
162
+ const found = new Map(keys.flatMap(key => { const known = deltas.get(key); return known ? [[key, known] as const] : [] }))
163
+ const unheld = keys.filter(key => !found.has(key))
164
+ for (const [key, row] of unheld.length > 0 ? await kept.readMany(unheld) : []) {
165
+ const known = deltaOf(row.value)
166
+ if (!known) continue
167
+ deltas.set(key, known)
168
+ found.set(key, known)
169
+ }
170
+ return found
171
+ }
172
+
157
173
  // As kept, else its turn awaited, then both sides' files read, counted and kept.
158
174
  async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
159
175
  const known = deltaOf((await kept?.read(key))?.value)
@@ -11,10 +11,14 @@
11
11
  // of a repository's GitHub, its issues, pull requests and agents' work, for a
12
12
  // day (http.ts keptAnswer), and its history, a page from a commit at a time,
13
13
  // for a month (repository-history.ts historyFrom), as are the files each of
14
- // its commits changes and holds (repository-changes.ts), and the packages a
15
- // pull request's lockfiles add (pull-dependencies.ts). Each store removes its own rows kept longer
16
- // than that as it writes, at most once an hour a database (pruneKept), so
17
- // that rows no read will take again don't pile up.
14
+ // its commits changes and holds (repository-changes.ts), its trees as the
15
+ // code viewer reads them (codeTreeStore), the packages a pull request's
16
+ // lockfiles add (pull-dependencies.ts), and the lockfiles a commit lists and
17
+ // each one's packages, as its dependency review reads them
18
+ // (commit-lockfiles.ts); and the commit the code viewer keeps its
19
+ // directories at, for as long as it does (codeHeadStore). Each store removes
20
+ // its own rows kept longer than that as it writes, at most once an hour a
21
+ // database (pruneKept), so that rows no read will take again don't pile up.
18
22
  // The database is an optimization: a failure reads as a miss, and writes
19
23
  // nothing.
20
24
  import { createHash } from 'node:crypto'
@@ -38,15 +42,25 @@ export interface KeptStore<T> {
38
42
  // When each database last had the rows under each prefix removed.
39
43
  const PRUNE_EVERY_MS = 60 * 60_000
40
44
  const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
45
+ // Who sees each database's prunes through (index.ts: its app, which waits
46
+ // for them before closing it, and on serverless keeps its invocation alive
47
+ // till they settle), as a write no longer waits for one.
48
+ const pruneOwners = new WeakMap<UpstreamCacheStore, (prune: Promise<unknown>) => void>()
49
+ export function ownKeptPrunes(db: UpstreamCacheStore, own: (prune: Promise<unknown>) => void) {
50
+ pruneOwners.set(db, own)
51
+ }
41
52
 
42
53
  // Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
43
54
  // hour a database, as rows under it are written: here, and risk's downloads
44
- // (npm-risk.ts). A failure is `warn`ed of, and removes nothing.
45
- export async function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void) {
55
+ // (npm-risk.ts), each one run handed to the database's owner. A failure is
56
+ // `warn`ed of, and removes nothing.
57
+ export function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void): Promise<void> {
46
58
  const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
47
- if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
59
+ if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return Promise.resolve()
48
60
  times.set(prefix, at)
49
- try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
61
+ const prune = (async () => { try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) } })()
62
+ pruneOwners.get(db)?.(prune)
63
+ return prune
50
64
  }
51
65
 
52
66
  // Values of `type`, each read for `keptMs` after it was kept.
@@ -79,7 +93,11 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
79
93
  },
80
94
  async write(key, value, at) {
81
95
  try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
82
- await prune(at)
96
+ // Behind the write, not before it is done: the prune reads the whole
97
+ // table (no index serves its prefix), which no writer need wait on.
98
+ // Its hour is marked before it runs, so writes meanwhile start none,
99
+ // and its database's owner sees it through (ownKeptPrunes).
100
+ void prune(at)
83
101
  },
84
102
  async remove(key) {
85
103
  try { await db.deleteUpstreamCacheEntries(keyOf(key), Number.MAX_SAFE_INTEGER) } catch (error) { warn('remove', error) }
@@ -141,7 +159,7 @@ export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): Kept
141
159
  export const HISTORY_PAGE_KEPT_MS = 30 * 24 * 60 * 60_000
142
160
  export const historyPageStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/history', HISTORY_PAGE_KEPT_MS, debug)
143
161
  // The pull requests squashed into a repository's commits, by the commit
144
- // (repository-history.ts squashedPulls): kept as its history's pages are.
162
+ // (repository-squashes.ts squashedPulls): kept as its history's pages are.
145
163
  export const historySquashStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/squashes', HISTORY_PAGE_KEPT_MS, debug)
146
164
  // The files each of a repository's commits changes (repository-changes.ts
147
165
  // repositoryChanges), by the commit, which never change: kept as its
@@ -151,6 +169,21 @@ export const historyChangeStore = <T>(db: UpstreamCacheStore, debug = false): Ke
151
169
  // repositoryTree), by the commit, which never change: kept as its history's
152
170
  // pages are.
153
171
  export const historyTreeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/trees', HISTORY_PAGE_KEPT_MS, debug)
172
+ // What the code viewer read of a repository's trees (http.ts
173
+ // repositoryTreesOf), which never changes once a commit or tree names it: the
174
+ // tree at a directory of a commit, by the repository's id, the commit and the
175
+ // directory (`trees`), and how many files a tree holds and their bytes, by
176
+ // the repository's id and the tree's (`tree-files`): kept as its history's
177
+ // pages are.
178
+ export const codeTreeStore = <T>(db: UpstreamCacheStore, type: 'trees' | 'tree-files', debug = false): KeptStore<T> =>
179
+ keptStore<T>(db, `repository/code-${type}`, HISTORY_PAGE_KEPT_MS, debug)
180
+ // The commit the code viewer keeps a repository's directories at in byte
181
+ // storage, its default branch's head as last read, and the repository's
182
+ // name they are kept under (repository-code.ts repositoryCodeKept), by the
183
+ // repository's id: kept until the head moves, the repository is renamed or
184
+ // it is removed, one for each repository.
185
+ export const codeHeadStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ commit: string; repo: string }> =>
186
+ keptStore<{ commit: string; repo: string }>(db, 'repository/code-heads', Infinity, debug)
154
187
  // The packages a repository's pull request's lockfiles add
155
188
  // (pull-dependencies.ts), by the commits of its base and head, which never
156
189
  // change: kept as its history's pages are.
@@ -164,3 +197,11 @@ export const pullDependencyStore = <T>(db: UpstreamCacheStore, debug = false): K
164
197
  export const npmDeltaStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'npm/deltas', HISTORY_PAGE_KEPT_MS, debug)
165
198
  export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ versions: string[]; unread: string[] }> =>
166
199
  keptStore<{ versions: string[]; unread: string[] }>(db, 'repository/review-baselines', HISTORY_PAGE_KEPT_MS, debug)
200
+ // What a repository's dependency review read of GitHub (pull-dependencies.ts
201
+ // LockfileStore): the lockfiles a commit lists (`listings`), by the commit,
202
+ // and a lockfile's packages with what their reviews are held to
203
+ // (`lockfiles`), by its blob, which never change: kept as its history's
204
+ // pages are, apart from a scan's (scanReadStore), which reads them without
205
+ // what their reviews are held to.
206
+ export const lockfileReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles', debug = false): KeptStore<T> =>
207
+ keptStore<T>(db, `repository/review-${type}`, HISTORY_PAGE_KEPT_MS, debug)
@@ -9,8 +9,8 @@ import { join, resolve } from 'node:path'
9
9
  import { CompressionError, decompress } from '@preventive/archive/compression.js'
10
10
  import { encodeBrotli } from './brotli.ts'
11
11
  import { keptBriefly } from './kept-briefly.ts'
12
- import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, askUpstream,
13
- npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
12
+ import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, type TarReading,
13
+ askUpstream, npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
14
14
  import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
15
15
 
16
16
  // The tar stream a package's files may come in, headers and padding included.
@@ -39,9 +39,10 @@ interface Load { job: Promise<unknown>; readers: number; settled: boolean; slot:
39
39
  // keeps its place among the active ones until its work is done and each
40
40
  // reader has released it, as a reader does once its response is written or
41
41
  // abandoned, so what responses still hold counts too. Places by the slot
42
- // their loads share, with how many hold each.
42
+ // their loads share, with how many hold each and how many places it takes.
43
43
  const loads = new Map<string, Load>()
44
- const slots = new Map<string, number>()
44
+ const slots = new Map<string, { holding: number; places: number }>()
45
+ const placesTaken = () => [...slots.values()].reduce((sum, slot) => sum + slot.places, 0)
45
46
 
46
47
  const refused = <T>(status: number, code: string): NpmLoad<T> => {
47
48
  const result = Promise.reject(new NpmPackageError(status, code))
@@ -49,30 +50,40 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
49
50
  return { result, release() {} }
50
51
  }
51
52
 
52
- // What names a version's tarball, and so its files: its integrity and URL,
53
- // and whether it is read with the token.
54
- const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`
53
+ // What names a version's tarball, and so its files: its integrity, its
54
+ // shasum and URL, and whether it is read with the token.
55
+ const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.shasum ?? ''} ${doc.dist.tarball} ${doc.private}`
55
56
 
56
57
  function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
57
58
  const { unpackedSize, fileCount } = doc.dist
58
59
  if ((unpackedSize ?? 0) > MAX_NPM_PACKAGE_BYTES || (fileCount ?? 0) > MAX_NPM_PACKAGE_FILES) return refused(413, 'package-too-large')
59
60
  const slot = tarballKey(doc)
60
- return sharedLoad(`${what} ${slot}`, work, 'npm-busy', slot)
61
+ return sharedLoad(`${what} ${slot}`, work, 'npm-busy', { slot })
62
+ }
63
+
64
+ // The places among the active ones a load takes whose files hold `files` and
65
+ // `bytes`: as many as packages they would fill, up to every one, each place
66
+ // holding what a package may.
67
+ export function loadPlaces({ files, bytes }: { files: number; bytes: number }): number {
68
+ return Math.min(MAX_ACTIVE_LOADS, Math.max(1, Math.ceil(files / MAX_NPM_PACKAGE_FILES), Math.ceil(bytes / MAX_NPM_PACKAGE_BYTES)))
61
69
  }
62
70
 
63
71
  // A load among the active ones, by `key`, refused as `busy` past them: a
64
72
  // repository directory's (repository-code.ts) as a package's, each holding
65
73
  // alike a tarball, its tar, its files and their encoded JSON. Loads of one
66
- // `slot` take one place between them.
67
- export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string, slot = key): NpmLoad<T> {
74
+ // `slot` take one place between them, or `places` (loadPlaces) where they
75
+ // hold more.
76
+ export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string,
77
+ { slot = key, places = 1 }: { slot?: string; places?: number } = {}): NpmLoad<T> {
68
78
  let load = loads.get(key)
69
79
  if (!load) {
70
- if (!slots.has(slot) && slots.size >= MAX_ACTIVE_LOADS) return refused(429, busy)
80
+ if (!slots.has(slot) && placesTaken() + places > MAX_ACTIVE_LOADS) return refused(429, busy)
71
81
  const created: Load = { job: work(), readers: 0, settled: false, slot }
72
82
  const finish = () => { settle(key, created) }
73
83
  created.job.then(finish, finish)
74
84
  loads.set(key, created)
75
- slots.set(slot, (slots.get(slot) ?? 0) + 1)
85
+ const taken = slots.get(slot)
86
+ slots.set(slot, { holding: (taken?.holding ?? 0) + 1, places: taken?.places ?? places })
76
87
  load = created
77
88
  }
78
89
  const held = load
@@ -99,15 +110,21 @@ function settle(key: string, load: Load) {
99
110
  function done(key: string, load: Load) {
100
111
  if (load.readers !== 0 || !load.settled || loads.get(key) !== load) return
101
112
  loads.delete(key)
102
- const holding = slots.get(load.slot)! - 1
103
- if (holding === 0) slots.delete(load.slot)
104
- else slots.set(load.slot, holding)
113
+ const taken = slots.get(load.slot)!
114
+ if (taken.holding === 1) slots.delete(load.slot)
115
+ else taken.holding--
105
116
  }
106
117
 
107
118
  // The sha512 an integrity names, in base64, or null where it names none.
108
119
  const sha512Of = (integrity: string) => /(?:^|\s)sha512-([\d+/A-Za-z]{86}==)(?=\s|$)/u.exec(integrity)?.[1] ?? null
109
120
  // Hashed off the event loop: a tarball runs to tens of MiB.
110
- const sha512 = async (bytes: Uint8Array) => Buffer.from(await crypto.subtle.digest('SHA-512', bytes as Uint8Array<ArrayBuffer>)).toString('base64')
121
+ const digest = async (algorithm: string, bytes: Uint8Array) => Buffer.from(await crypto.subtle.digest(algorithm, bytes as Uint8Array<ArrayBuffer>))
122
+ // Whether `bytes` are those of the sha512 an integrity names and, where there
123
+ // is one, of the shasum (a sha1, in hex).
124
+ async function hashedAs(bytes: Uint8Array, sha512: string, shasum: string | null): Promise<boolean> {
125
+ const [own512, own1] = await Promise.all([digest('SHA-512', bytes), shasum === null ? null : digest('SHA-1', bytes)])
126
+ return own512.toString('base64') === sha512 && (own1 === null || own1.toString('hex') === shasum)
127
+ }
111
128
 
112
129
  // Upstream's disk cache (setCacheDir), where tarballs are kept between
113
130
  // loads, or null to keep none and read none.
@@ -140,23 +157,25 @@ async function downloadTarball(url: string, isPrivate: boolean): Promise<Buffer>
140
157
  return bytes
141
158
  }
142
159
 
143
- // The version's tarball, its bytes held to the document's sha512 wherever
144
- // they come from: kept in the cache, where bundle builds keep what they
145
- // fetch with the token, or in npm's; else downloaded, and then kept. Bytes
146
- // matching the sha512 the reader's own document names are the tarball that
147
- // reader may download, wherever they were kept and whoever fetched them.
160
+ // The version's tarball, its bytes held to the document's sha512, and to its
161
+ // shasum where it tells one, wherever they come from: kept in the cache,
162
+ // where bundle builds keep what they fetch with the token, or in npm's; else
163
+ // downloaded, and then kept. Bytes matching the hashes the reader's own
164
+ // document names are the tarball that reader may download, wherever they
165
+ // were kept and whoever fetched them.
148
166
  async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
149
- const expected = sha512Of(doc.dist.integrity), url = doc.dist.tarball
150
- if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null) throw new NpmPackageError(502, 'upstream-invalid')
167
+ const expected = sha512Of(doc.dist.integrity), shasum = doc.dist.shasum ?? null, url = doc.dist.tarball
168
+ if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null
169
+ || (shasum !== null && !/^[\da-f]{40}$/u.test(shasum))) throw new NpmPackageError(502, 'upstream-invalid')
151
170
  const kept = tarballCache === null ? null : upstreamCachePath(tarballCache, 'npm/tarballs', `${doc.name}@${doc.version}.tgz`)
152
171
  if (kept !== null) {
153
172
  for (const path of [kept, npmCachePath(expected)]) {
154
173
  const bytes = path === null ? null : await readKeptFile(path, MAX_TAR_BYTES)
155
- if (bytes && await sha512(bytes) === expected) return bytes
174
+ if (bytes && await hashedAs(bytes, expected, shasum)) return bytes
156
175
  }
157
176
  }
158
177
  const bytes = await downloadTarball(url, doc.private)
159
- if (await sha512(bytes) !== expected) throw new NpmPackageError(502, 'upstream-invalid')
178
+ if (!await hashedAs(bytes, expected, shasum)) throw new NpmPackageError(502, 'upstream-invalid')
160
179
  if (kept !== null) await keepFile(kept, bytes)
161
180
  return bytes
162
181
  }
@@ -201,10 +220,11 @@ function unpackedOf(doc: NpmVersionDocument): Promise<Unpacked> {
201
220
 
202
221
  // A gzipped tarball's files, its top directory left out, as npm extracts
203
222
  // a package's: a repository tree's as GitHub archives it, too, its symlinks
204
- // let climb as readNpmTar's `symlinksAbove` says.
205
- export async function readTarballFiles(tarball: Uint8Array, options: { symlinksAbove?: number } = {}): Promise<NpmPackageFile[]> {
223
+ // let climb as readNpmTar's `symlinksAbove` says, and its tar stream and
224
+ // files bounded as `options` give, a package's bounds unless given.
225
+ export async function readTarballFiles(tarball: Uint8Array, { maxTarBytes = MAX_TAR_BYTES, ...options }: TarReading & { maxTarBytes?: number } = {}): Promise<NpmPackageFile[]> {
206
226
  let tar: Uint8Array
207
- try { tar = await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }) }
227
+ try { tar = await decompress(tarball, 'gzip', { limit: maxTarBytes }) }
208
228
  catch (err) {
209
229
  const limited = err instanceof CompressionError && err.limited
210
230
  throw new NpmPackageError(limited ? 413 : 422, limited ? 'package-too-large' : 'bad-tarball')
@@ -102,7 +102,7 @@ export function npmManifest(json: Record<string, unknown>): Record<string, unkno
102
102
  // as they read npm's.
103
103
  const VERSION_FIELDS = ['name', 'version', 'description', 'license', 'homepage', 'main', 'module', 'types', 'type', 'deprecated', 'gitHead', 'author', '_npmUser',
104
104
  'maintainers', 'keywords', 'bin', 'dependencies', 'peerDependencies', 'optionalDependencies', 'engines', 'hasInstallScript', 'repository', 'bugs']
105
- const DIST_FIELDS = ['tarball', 'integrity', 'unpackedSize', 'fileCount']
105
+ const DIST_FIELDS = ['tarball', 'integrity', 'shasum', 'unpackedSize', 'fileCount']
106
106
  const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
107
107
  const picked = (json: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(key => Object.hasOwn(json, key)).map(key => [key, json[key]]))
108
108
 
@@ -31,11 +31,12 @@ const PACKUMENT_BYTES = 64 * 1024 * 1024
31
31
  // limit until it is read and parsed: four packuments, or 32 version documents.
32
32
  const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
33
33
  // What a package may unpack to: the files' bytes and their count; the tar
34
- // stream holding them is bounded in npm-loads.ts.
34
+ // stream holding them is bounded in npm-loads.ts. A repository directory's
35
+ // are its own (repository-code.ts).
35
36
  export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
36
37
  export const MAX_NPM_PACKAGE_FILES = 20_000
37
38
  // The entries its tar stream may hold, read before any is counted as a file:
38
- // a directory's too, as a GitHub tree's tarball has one for each, npm's none.
39
+ // a directory's too, though npm packs none.
39
40
  export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
40
41
  // What the files may take as the viewer's JSON. Escaping grows a text up to
41
42
  // six times (a control character becomes `\u0001`), so it is counted before
@@ -227,7 +228,9 @@ export interface NpmVersionDocument {
227
228
  name: string
228
229
  version: string
229
230
  private: boolean
230
- dist: { tarball: string; integrity: string; unpackedSize: number | null; fileCount: number | null }
231
+ // `shasum`, the tarball's sha1 in hex, where npm tells it: held, with the
232
+ // integrity, to the tarball its files are read from (npm-loads.ts).
233
+ dist: { tarball: string; integrity: string; shasum: string | null; unpackedSize: number | null; fileCount: number | null }
231
234
  manifest: Record<string, unknown>
232
235
  }
233
236
 
@@ -237,10 +240,12 @@ const count = (value: unknown) => Number.isSafeInteger(value) && (value as numbe
237
240
  function versionDocument(name: string, json: Record<string, unknown>, isPrivate: boolean): NpmVersionDocument {
238
241
  const dist = json['dist']
239
242
  if (json['name'] !== name || typeof json['version'] !== 'string' || !isNpmPackageSpec(json['version']) || !plainObject(dist)
240
- || typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string') throw new NpmPackageError(502, 'upstream-invalid')
243
+ || typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
244
+ || (dist['shasum'] !== undefined && typeof dist['shasum'] !== 'string')) throw new NpmPackageError(502, 'upstream-invalid')
241
245
  return {
242
246
  name, version: json['version'], private: isPrivate,
243
- dist: { tarball: dist['tarball'], integrity: dist['integrity'], unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
247
+ dist: { tarball: dist['tarball'], integrity: dist['integrity'], shasum: typeof dist['shasum'] === 'string' ? dist['shasum'] : null,
248
+ unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
244
249
  manifest: npmManifest(json),
245
250
  }
246
251
  }
@@ -261,13 +266,18 @@ export interface NpmVersionStore {
261
266
  // changes a version's files, but its document can change: a deprecation, the
262
267
  // package made private (a scoped one can be), the version removed (which whoever
263
268
  // reads it from a store learns elsewhere too: risk does). A day bounds each.
269
+ // One kept with no shasum, as none was before it was kept (npmVersionFields),
270
+ // is asked of npm again, which tells one of each version.
264
271
  export const VERSION_KEPT_MS = 24 * 60 * 60_000
265
272
  export function readNpmVersion(name: string, spec: string, privileged: boolean, signal: AbortSignal, store?: NpmVersionStore): Promise<NpmVersionDocument | null> {
266
273
  if (!isNpmPackageName(name) || !isNpmPackageSpec(spec)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
267
274
  // A kept one is read within the same budget as npm's answer.
268
275
  return withDocumentBudget(VERSION_DOCUMENT_BYTES, async () => {
269
276
  const kept = store && isExactVersion(spec) ? await store.read(name, spec) : null
270
- if (kept && Date.now() - kept.at < VERSION_KEPT_MS) try { return versionDocument(name, kept.json, false) } catch {}
277
+ const keptDist = kept?.json['dist']
278
+ if (kept && Date.now() - kept.at < VERSION_KEPT_MS && plainObject(keptDist) && typeof keptDist['shasum'] === 'string') {
279
+ try { return versionDocument(name, kept.json, false) } catch {}
280
+ }
271
281
  const found = await readDocument(name, registryUrl(name, spec), privileged, { accept: 'application/json', limit: VERSION_DOCUMENT_BYTES, signal })
272
282
  if (!found) return null
273
283
  const doc = versionDocument(name, found.json, found.private)
@@ -367,18 +377,23 @@ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version'
367
377
 
368
378
  export interface NpmPackageFile { path: string; bytes: Uint8Array }
369
379
 
380
+ // How a tar stream is read (readNpmTar): how far its symlinks may climb above
381
+ // it, and the entries, files and bytes it may hold, a package's unless given.
382
+ export interface TarReading { symlinksAbove?: number; maxEntries?: number; maxFiles?: number; maxBytes?: number }
383
+
370
384
  // The regular files of a tar stream, by package path: npm drops the first
371
385
  // component (`package/`, or whatever the archive used), and a file at the top
372
386
  // is not extracted. The stream is read by @preventive/archive, which refuses
373
387
  // one malformed, a name or a symlink that would climb out of it -- a symlink
374
388
  // past `symlinksAbove` levels above it, none unless given -- and a name
375
- // repeated as another entry, and gives up past MAX_NPM_TAR_ENTRIES before
376
- // reading on; a file under another top directory at the same path replaces
377
- // an earlier one, as extraction would. Directories, links and devices hold no
389
+ // repeated as another entry, and gives up past `maxEntries` before reading
390
+ // on; a file under another top directory at the same path replaces an
391
+ // earlier one, as extraction would. Directories, links and devices hold no
378
392
  // file content and are skipped.
379
- export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbove?: number } = {}): NpmPackageFile[] {
393
+ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0, maxEntries = MAX_NPM_TAR_ENTRIES, maxFiles = MAX_NPM_PACKAGE_FILES,
394
+ maxBytes = MAX_NPM_PACKAGE_BYTES }: TarReading = {}): NpmPackageFile[] {
380
395
  let entries: Entry[]
381
- try { entries = unpack(tar, { maxEntries: MAX_NPM_TAR_ENTRIES, symlinksAbove }) }
396
+ try { entries = unpack(tar, { maxEntries, symlinksAbove }) }
382
397
  catch (err) {
383
398
  if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
384
399
  throw err
@@ -391,7 +406,7 @@ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbo
391
406
  const path = name.slice(slash + 1)
392
407
  total += data.length - (files.get(path)?.length ?? 0)
393
408
  files.set(path, data)
394
- if (files.size > MAX_NPM_PACKAGE_FILES || total > MAX_NPM_PACKAGE_BYTES) throw new NpmPackageError(413, 'package-too-large')
409
+ if (files.size > maxFiles || total > maxBytes) throw new NpmPackageError(413, 'package-too-large')
395
410
  }
396
411
  return [...files].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
397
412
  }
@@ -421,13 +436,14 @@ const ROW_LENGTH = 80
421
436
 
422
437
  // The files as the viewer gets them: `[path, bytes, text]`, text null for a
423
438
  // file that is not UTF-8, which instead carries its bytes' sha256, so a
424
- // comparison of two versions tells a changed one from an unchanged one.
425
- export function npmFileRows(files: NpmPackageFile[]): ([string, number, string] | [string, number, null, string])[] {
439
+ // comparison of two versions tells a changed one from an unchanged one. They
440
+ // may take `maxLength` characters of JSON, a package's unless given.
441
+ export function npmFileRows(files: NpmPackageFile[], maxLength = MAX_NPM_JSON_LENGTH): ([string, number, string] | [string, number, null, string])[] {
426
442
  let length = 0
427
443
  return files.map(({ path, bytes }) => {
428
444
  const text = npmFileText(bytes)
429
445
  length += ROW_LENGTH + jsonLength(path) + (text === null ? 0 : jsonLength(text))
430
- if (length > MAX_NPM_JSON_LENGTH) throw new NpmPackageError(413, 'package-too-large')
446
+ if (length > maxLength) throw new NpmPackageError(413, 'package-too-large')
431
447
  return text === null ? [path, bytes.byteLength, null, `sha256-${createHash('sha256').update(bytes).digest('base64')}`] : [path, bytes.byteLength, text]
432
448
  })
433
449
  }
@@ -14,8 +14,15 @@
14
14
  // instance, or this one restarted, asks npm's downloads API, which answers
15
15
  // some 40 lookups a minute, none of them again that week, each removed a
16
16
  // day past it (npm-kept.ts pruneKept); a private one's in memory.
17
- // - `risk/npm/packages`, `risk/npm/public` and `risk/npm/advisories`, kept an
18
- // hour or a day by risk itself: in memory.
17
+ // - `risk/npm/packages`, `risk/npm/old-versions`, `risk/npm/public` and
18
+ // `risk/npm/advisories`, which risk asks again after an hour: a public
19
+ // package's in memory and in the database too, read from memory first, so
20
+ // that a cold instance, as a serverless one starts, reads no whole document
21
+ // of npm's again that one before it read within the hour; by a hash of
22
+ // their type and key, as a dependency's spec runs to any length, each
23
+ // removed a day on, and one past 256 KiB as JSON (a package's deprecated
24
+ // versions, its messages and all, run long) in memory alone. A private
25
+ // one's in memory.
19
26
  // - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
20
27
  // read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
21
28
  // an assessment kept needs none.
@@ -69,11 +76,20 @@ function remember(key: string, value: unknown) {
69
76
  while (memory.size > MEMORY_ENTRIES || memoryLength > MEMORY_LENGTH) forget(memory.keys().next().value!)
70
77
  }
71
78
 
72
- const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
73
- // What of a public package is kept in the database, and for how long its
74
- // downloads are: risk's week, and a day more.
79
+ const digest = (type: string, key: string) => createHash('sha256').update(`${type}\0${key}`).digest('hex')
80
+ const hashed = (type: string, key: string) => `risk/${digest(type, key)}`
81
+ // What of a public package is kept in the database by its key, and for how
82
+ // long its downloads are: risk's week, and a day more.
75
83
  const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
76
84
  const DOWNLOADS_KEPT_MS = 8 * 24 * 60 * 60_000
85
+ // What of a public package risk asks again after an hour, kept in memory and
86
+ // in the database (HOURLY_PREFIX), each a day, none past HOURLY_LENGTH as
87
+ // JSON there.
88
+ const HOURLY = new Set(['risk/npm/packages', 'risk/npm/old-versions', 'risk/npm/public', 'risk/npm/advisories'])
89
+ const HOURLY_PREFIX = 'risk/hourly/'
90
+ const HOURLY_KEPT_MS = 24 * 60 * 60_000
91
+ const HOURLY_LENGTH = 256 * 1024
92
+ const databaseKey = (type: string, key: string) => HOURLY.has(type) ? `${HOURLY_PREFIX}${digest(type, key)}` : `${type}/${key}`
77
93
 
78
94
  export interface NpmRiskStores {
79
95
  db: UpstreamCacheStore
@@ -82,10 +98,13 @@ export interface NpmRiskStores {
82
98
  }
83
99
 
84
100
  // A cache that reads and writes many of a type at once too, as risk's
85
- // readying of downloads asks (npmReadyDownloads): one read and one write of
86
- // the database each, not one a key, as a request's queries take turns on its
87
- // connection (db-neon.ts).
101
+ // readying of downloads asks (npmReadyDownloads), and reads records of any
102
+ // types at once, as risk reads a package's record and its versions'
103
+ // (registry.js npmPackageFacts): one read and one write of the database each,
104
+ // not one a key, as a request's queries take turns on its connection
105
+ // (db-neon.ts).
88
106
  export interface NpmRiskCache extends CacheStore {
107
+ readEach(entries: [type: string, key: string][]): Promise<unknown[]>
89
108
  readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
90
109
  writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
91
110
  }
@@ -94,13 +113,32 @@ export interface NpmRiskCache extends CacheStore {
94
113
  // that what is kept of it can be shared.
95
114
  export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
96
115
  const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
116
+ const memoryKey = (type: string, key: string) => `${isPublic ? '' : 'private\0'}${type}\0${key}`
117
+ // Whether a record of `type` is read of the database: unless it is one
118
+ // kept in memory too (HOURLY) and there.
119
+ const ofDatabase = (type: string, key: string) => isPublic && (IN_DATABASE.has(type) || HOURLY.has(type) && !memory.has(memoryKey(type, key)))
120
+ // A record as the database keeps it, read: an hourly one kept in memory
121
+ // too, for the next to ask.
122
+ const readOf = (type: string, key: string, row: string | null | undefined) => {
123
+ const value: unknown = row == null ? null : JSON.parse(row)
124
+ if (value !== null && HOURLY.has(type)) remember(memoryKey(type, key), value)
125
+ return value
126
+ }
97
127
  const cache: NpmRiskCache = {
128
+ async readEach(entries) {
129
+ const asked = new Set(entries.flatMap(([type, key], i) => ofDatabase(type, key) ? [i] : []))
130
+ let rows = new Map<string, string>()
131
+ if (asked.size > 0) {
132
+ try { rows = await db.getUpstreamCacheEntries([...asked].map(i => databaseKey(...entries[i]!))) } catch (error) { warn('read', error) }
133
+ }
134
+ return Promise.all(entries.map(([type, key], i) => {
135
+ if (!asked.has(i)) return cache.read(type, key)
136
+ try { return readOf(type, key, rows.get(databaseKey(type, key))) } catch (error) { warn('read', error); return null }
137
+ }))
138
+ },
98
139
  async readMany(type, keys) {
99
- if (!isPublic || !IN_DATABASE.has(type)) return new Map(await Promise.all(keys.map(async key => [key, await cache.read(type, key)] as const)))
100
- try {
101
- const found = await db.getUpstreamCacheEntries(keys.map(key => `${type}/${key}`))
102
- return new Map(keys.map(key => { const value = found.get(`${type}/${key}`); return [key, value === undefined ? null : JSON.parse(value)] }))
103
- } catch (error) { warn('read', error); return new Map() }
140
+ const values = await cache.readEach(keys.map(key => [type, key]))
141
+ return new Map(keys.map((key, i) => [key, values[i]]))
104
142
  },
105
143
  async writeMany(type, entries) {
106
144
  if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
@@ -112,22 +150,31 @@ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: b
112
150
  if (type === 'npm/tarballs') return null
113
151
  try {
114
152
  if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
115
- if (isPublic && IN_DATABASE.has(type)) { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
153
+ if (ofDatabase(type, key)) return readOf(type, key, await db.getUpstreamCacheEntry(databaseKey(type, key)))
116
154
  } catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
117
- return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
155
+ return memory.get(memoryKey(type, key))?.value ?? null
118
156
  },
119
157
  async write(type, key, value) {
120
158
  if (type === 'npm/tarballs') return
159
+ if (isPublic && HOURLY.has(type)) remember(memoryKey(type, key), value)
121
160
  try {
122
161
  if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
123
162
  if (isPublic && IN_DATABASE.has(type)) {
124
163
  const at = Date.now()
125
- await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), at)
164
+ await db.setUpstreamCacheEntry(databaseKey(type, key), JSON.stringify(value), at)
126
165
  if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
127
166
  return
128
167
  }
168
+ if (isPublic && HOURLY.has(type)) {
169
+ const json = JSON.stringify(value)
170
+ if (json.length > HOURLY_LENGTH) return
171
+ const at = Date.now()
172
+ await db.setUpstreamCacheEntry(databaseKey(type, key), json, at)
173
+ await pruneKept(db, HOURLY_PREFIX, HOURLY_KEPT_MS, at, error => { warn('prune', error) })
174
+ return
175
+ }
129
176
  } catch (error) { warn('write', error); return }
130
- remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
177
+ remember(memoryKey(type, key), value)
131
178
  },
132
179
  }
133
180
  return cache
@@ -30,7 +30,8 @@ export interface ObjectStorage {
30
30
  exists(key: string): Promise<boolean>
31
31
  put(key: string, bytes: Buffer, options?: { replaceReport?: boolean }): Promise<string | null>
32
32
  delete(key: string): Promise<void>
33
- deletePrefix(prefix: string): Promise<void>
33
+ // Every object under `prefix`, but those under `keep`, a prefix within it.
34
+ deletePrefix(prefix: string, keep?: string): Promise<void>
34
35
  }
35
36
 
36
37
  export function isBlobId(value: string): boolean {
@@ -52,14 +53,14 @@ export async function openObjectVersion(raw: RawObjectStorage, key: string, vers
52
53
  return null
53
54
  }
54
55
 
55
- export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number): Promise<number> {
56
+ export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number, keep?: string): Promise<number> {
56
57
  const eligible = (modifiedAt: number) => before === undefined || modifiedAt < before
57
58
  // Finish listing before deleting: provider cursors may be offset-based.
58
59
  const cursors = new Set<string>(), keys = new Set<string>()
59
60
  let cursor: string | null = null
60
61
  do {
61
62
  const page = await raw.list(prefix, cursor, 100, signal)
62
- for (const object of page.objects) if (eligible(object.modifiedAt)) keys.add(object.key)
63
+ for (const object of page.objects) if (eligible(object.modifiedAt) && (keep === undefined || !object.key.startsWith(keep))) keys.add(object.key)
63
64
  cursor = page.cursor
64
65
  if (cursor !== null && cursors.has(cursor)) throw new Error('Invalid blob pagination')
65
66
  if (cursor !== null) cursors.add(cursor)