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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/common/managed/agent-tasks.ts +5 -3
  7. package/common/managed/github-agents.ts +28 -10
  8. package/out/activity.js +4 -0
  9. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  10. package/out/chunk-5AHIQFFG.js +36 -0
  11. package/out/chunk-6ANSHRFQ.js +6 -0
  12. package/out/chunk-6WF3KNTL.js +3 -0
  13. package/out/chunk-7BKKYVWR.js +7 -0
  14. package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
  15. package/out/chunk-NOZJULWS.js +52 -0
  16. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  17. package/out/chunk-S6U2J62U.js +232 -0
  18. package/out/chunk-SPEFPML6.js +2 -0
  19. package/out/chunk-UENXT2HK.js +3 -0
  20. package/out/chunk-VL43BTSO.js +1 -0
  21. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  22. package/out/chunk-ZCS4UYHI.js +1 -0
  23. package/out/chunk-ZGNMDGSB.js +1 -0
  24. package/out/client-managed-import.js +1 -1
  25. package/out/client-managed.js +15 -15
  26. package/out/client-sync.js +2 -2
  27. package/out/compare.js +12 -12
  28. package/out/dialogs.js +28 -28
  29. package/out/graph.js +11 -11
  30. package/out/index.html +11 -11
  31. package/out/scan.js +2 -2
  32. package/out/terminal.js +6 -6
  33. package/out/view.css +1 -1
  34. package/out/view.js +15 -15
  35. package/package.json +4 -1
  36. package/risk/actions/findings.js +14 -9
  37. package/risk/actions/scripts/shell.js +5 -1
  38. package/risk/index.d.ts +9 -4
  39. package/risk/npm/assess.js +15 -4
  40. package/risk/npm/registry.js +6 -1
  41. package/server-e2e/app.ts +5 -4
  42. package/server-e2e/http.ts +5 -2
  43. package/server-managed/activity.ts +24 -11
  44. package/server-managed/app-agents.ts +10 -21
  45. package/server-managed/bundle-build-worker.js +7 -3
  46. package/server-managed/bundle-build.ts +5 -3
  47. package/server-managed/bundle-cache.ts +16 -4
  48. package/server-managed/bundle.ts +3 -4
  49. package/server-managed/combined.ts +3 -2
  50. package/server-managed/commit-coauthors.ts +5 -9
  51. package/server-managed/commit-lockfiles.ts +21 -11
  52. package/server-managed/config.ts +6 -0
  53. package/server-managed/db-methods.ts +321 -156
  54. package/server-managed/db-neon.ts +71 -24
  55. package/server-managed/db-schema.ts +4 -0
  56. package/server-managed/db.ts +2 -1
  57. package/server-managed/dependency-reviews.ts +13 -3
  58. package/server-managed/github-app.ts +12 -6
  59. package/server-managed/github-issue-links.ts +11 -5
  60. package/server-managed/github-pulls.ts +2 -2
  61. package/server-managed/github-requests.ts +39 -0
  62. package/server-managed/http-response.ts +10 -2
  63. package/server-managed/http.ts +269 -143
  64. package/server-managed/index.ts +8 -7
  65. package/server-managed/kept-briefly.ts +37 -0
  66. package/server-managed/link-reports.ts +5 -1
  67. package/server-managed/npm-delta.ts +11 -6
  68. package/server-managed/npm-kept.ts +51 -7
  69. package/server-managed/npm-loads.ts +117 -35
  70. package/server-managed/npm-packages.ts +107 -14
  71. package/server-managed/npm-risk.ts +68 -18
  72. package/server-managed/pretty-print.ts +112 -35
  73. package/server-managed/pull-dependencies.ts +29 -9
  74. package/server-managed/pull-dependency-reviews.ts +8 -8
  75. package/server-managed/queue.ts +16 -0
  76. package/server-managed/rename-diff.ts +22 -13
  77. package/server-managed/report-sources.ts +1 -1
  78. package/server-managed/report-store.ts +50 -12
  79. package/server-managed/repository-actions.ts +15 -6
  80. package/server-managed/repository-agents.ts +1 -1
  81. package/server-managed/repository-browser.ts +123 -33
  82. package/server-managed/repository-code.ts +28 -11
  83. package/server-managed/repository-dependency-reviews.ts +41 -19
  84. package/server-managed/repository-history.ts +24 -8
  85. package/server-managed/repository-pulse.ts +9 -2
  86. package/server-managed/repository-risk-audit.ts +13 -0
  87. package/server-managed/repository-risk-read.ts +11 -6
  88. package/server-managed/repository-risk.ts +27 -20
  89. package/server-managed/session.ts +18 -4
  90. package/server-managed/solidity-entry-points.ts +5 -3
  91. package/server-managed/sql-postgres.ts +3 -0
  92. package/server-managed/sql.ts +6 -2
  93. package/server-managed/storage-db.ts +11 -3
  94. package/server-managed/storage-encryption.ts +7 -4
  95. package/server-managed/storage.ts +8 -4
  96. package/server-managed/team-feed.ts +3 -1
  97. package/server-managed/team-reports.ts +30 -13
  98. package/server-managed/uploads.ts +29 -9
  99. package/server-managed/upstream-cache.ts +12 -0
  100. package/server.ts +14 -5
  101. package/out/chunk-5DHL5NG3.js +0 -2
  102. package/out/chunk-6HNXNIQY.js +0 -52
  103. package/out/chunk-AFLZH7WG.js +0 -1
  104. package/out/chunk-NV7ES4EW.js +0 -1
  105. package/out/chunk-PTGDYLNS.js +0 -36
  106. package/out/chunk-Q6MVYGYV.js +0 -3
  107. package/out/chunk-QRD6GIG5.js +0 -229
  108. package/out/chunk-WI7Z5A2T.js +0 -6
  109. package/out/chunk-WQ74ZBZF.js +0 -7
  110. package/out/chunk-XVBEOGOS.js +0 -3
@@ -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
@@ -153,14 +154,60 @@ async function publicWholeDocument(name: string, url: string, { limit, signal }:
153
154
  return json
154
155
  }
155
156
 
157
+ // Whole documents being asked of npm as anyone asks, by URL and limit:
158
+ // readers asking for one meanwhile share it, as opening a version asks for
159
+ // its package's for its versions, its advisories and its risk at once; each
160
+ // gives up on it alone once its own signal aborts, and it is given up on once
161
+ // they all have. None is asked with the token. Read alike by every reader,
162
+ // none changing it.
163
+ const wholeAsks = new Map<string, { asked: Promise<Record<string, unknown> | null>; controller: AbortController; readers: number }>()
164
+
165
+ function sharedWholeDocument(name: string, url: string, { limit, signal }: { limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
166
+ if (signal.aborted) return Promise.reject(signal.reason)
167
+ const key = `${url} ${limit}`
168
+ let ask = wholeAsks.get(key)
169
+ if (!ask) {
170
+ const controller = new AbortController()
171
+ const created = { asked: publicWholeDocument(name, url, { limit, signal: controller.signal }), controller, readers: 0 }
172
+ const settle = () => { if (wholeAsks.get(key) === created) wholeAsks.delete(key) }
173
+ created.asked.then(settle, settle)
174
+ wholeAsks.set(key, ask = created)
175
+ }
176
+ const held = ask
177
+ held.readers++
178
+ return new Promise((answer, fail) => {
179
+ let left = false
180
+ const leave = () => {
181
+ if (left) return false
182
+ left = true
183
+ held.readers--
184
+ signal.removeEventListener('abort', onAbort)
185
+ return true
186
+ }
187
+ const onAbort = () => {
188
+ if (!leave()) return
189
+ if (held.readers === 0) {
190
+ if (wholeAsks.get(key) === held) wholeAsks.delete(key)
191
+ held.controller.abort(signal.reason)
192
+ }
193
+ fail(signal.reason)
194
+ }
195
+ const answered = (json: Record<string, unknown> | null) => { if (leave()) answer(json) }
196
+ const failed = (err: unknown) => { if (leave()) fail(err) }
197
+ signal.addEventListener('abort', onAbort, { once: true })
198
+ held.asked.then(answered, failed)
199
+ })
200
+ }
201
+
156
202
  // Asked anonymously first, unless its caller knows npm has none for anyone
157
203
  // (`anonymous` false): a reader without private access stops there. One
158
204
  // with it asks again with the token where npmTokenReads says so, and the
159
205
  // answer is private. A whole document (`whole`) anyone may read is kept
160
- // (publicWholeDocument).
206
+ // (publicWholeDocument), and asked once for those asking at once
207
+ // (sharedWholeDocument).
161
208
  async function readDocument(name: string, url: string, privileged: boolean,
162
209
  { whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
163
- const open = anonymous ? whole ? await publicWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
210
+ const open = anonymous ? whole ? await sharedWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
164
211
  if (open) return { json: open, private: false }
165
212
  if (!npmTokenReads(name, privileged)) return null
166
213
  const json = await registryDocument(url, { ...options, token: npmToken() })
@@ -203,6 +250,8 @@ function versionDocument(name: string, json: Record<string, unknown>, isPrivate:
203
250
  // (npm-manifest.ts npmVersionFields), as the database keeps them (npm-kept.ts).
204
251
  export interface NpmVersionStore {
205
252
  read(name: string, version: string): Promise<{ at: number; json: Record<string, unknown> } | null>
253
+ // Those of `versions` kept, by `name@version`, in one read.
254
+ readMany?(versions: { name: string; version: string }[]): Promise<Map<string, { at: number; json: Record<string, unknown> }>>
206
255
  write(name: string, version: string, json: Record<string, unknown>): Promise<void>
207
256
  }
208
257
 
@@ -228,6 +277,20 @@ export function readNpmVersion(name: string, spec: string, privileged: boolean,
228
277
  })
229
278
  }
230
279
 
280
+ // `store`, where it reads many at once, with the exact ones of `versions`
281
+ // read from it in one read, for readNpmVersion to read each of those from as
282
+ // it asks: a version and the one it is compared with, read together.
283
+ export function readTogether(store: NpmVersionStore, versions: { name: string; version: string }[]): NpmVersionStore {
284
+ const exact = versions.filter(({ version }) => isExactVersion(version))
285
+ if (!store.readMany || exact.length < 2) return store
286
+ const keys = new Set(exact.map(({ name, version }) => `${name}@${version}`))
287
+ const kept = store.readMany(exact)
288
+ return { ...store, async read(name, version) {
289
+ const key = `${name}@${version}`
290
+ return keys.has(key) ? (await kept).get(key) ?? null : store.read(name, version)
291
+ } }
292
+ }
293
+
231
294
  // The package's dist-tags and versions, newest published first, from its
232
295
  // abbreviated document; with `times`, from its whole one, which alone has
233
296
  // when each was published, `times` by version (ISO dates), and `created`,
@@ -277,22 +340,51 @@ const isPublishTime = (value: string | undefined) => typeof value === 'string' &
277
340
  const PUBLISH_TIMES = 100_000
278
341
  const publishTimes = new Map<string, string>()
279
342
 
280
- export const npmPublishTime = (name: string, version: string): string | null => publishTimes.get(`${name}@${version}`) ?? null
343
+ // Where public versions' publish times are kept beyond the instance, as the
344
+ // database keeps them (npm-kept.ts npmPublishTimeStore).
345
+ export interface NpmPublishTimeStore {
346
+ read(name: string, version: string): Promise<string | null>
347
+ write(name: string, version: string, at: string): Promise<void>
348
+ }
349
+
350
+ // When `doc` was published: as noted here, else as `store` keeps a public
351
+ // version's, so that another instance, or this one restarted, has it before
352
+ // it reads the whole document again. The store is read once, at once, beside
353
+ // whatever its caller reads meanwhile, a failure read as none; `read` looks
354
+ // here again as it is asked, as the whole document read meanwhile may have
355
+ // noted it by then, and `keep` keeps there the time noted here where it read
356
+ // none.
357
+ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version' | 'private'>, store?: NpmPublishTimeStore) {
358
+ const noted = () => publishTimes.get(`${doc.name}@${doc.version}`) ?? null
359
+ const kept = store && !doc.private ? store.read(doc.name, doc.version).catch(() => null) : Promise.resolve(null)
360
+ return {
361
+ read: async (): Promise<string | null> => noted() ?? await kept ?? noted(),
362
+ async keep() {
363
+ const at = noted()
364
+ if (at !== null && store && !doc.private && await kept === null) await store.write(doc.name, doc.version, at)
365
+ },
366
+ }
367
+ }
281
368
 
282
369
  export interface NpmPackageFile { path: string; bytes: Uint8Array }
283
370
 
371
+ // How a tar stream is read (readNpmTar): how far its symlinks may climb above
372
+ // it, and the entries, files and bytes it may hold, a package's unless given.
373
+ export interface TarReading { symlinksAbove?: number; maxEntries?: number; maxFiles?: number; maxBytes?: number }
374
+
284
375
  // The regular files of a tar stream, by package path: npm drops the first
285
376
  // component (`package/`, or whatever the archive used), and a file at the top
286
377
  // is not extracted. The stream is read by @preventive/archive, which refuses
287
378
  // one malformed, a name or a symlink that would climb out of it -- a symlink
288
379
  // past `symlinksAbove` levels above it, none unless given -- and a name
289
- // repeated as another entry, and gives up past MAX_NPM_TAR_ENTRIES before
290
- // reading on; a file under another top directory at the same path replaces
291
- // an earlier one, as extraction would. Directories, links and devices hold no
380
+ // repeated as another entry, and gives up past `maxEntries` before reading
381
+ // on; a file under another top directory at the same path replaces an
382
+ // earlier one, as extraction would. Directories, links and devices hold no
292
383
  // file content and are skipped.
293
- export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbove?: number } = {}): NpmPackageFile[] {
384
+ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0, maxEntries = MAX_NPM_TAR_ENTRIES, maxFiles = MAX_NPM_PACKAGE_FILES,
385
+ maxBytes = MAX_NPM_PACKAGE_BYTES }: TarReading = {}): NpmPackageFile[] {
294
386
  let entries: Entry[]
295
- try { entries = unpack(tar, { maxEntries: MAX_NPM_TAR_ENTRIES, symlinksAbove }) }
387
+ try { entries = unpack(tar, { maxEntries, symlinksAbove }) }
296
388
  catch (err) {
297
389
  if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
298
390
  throw err
@@ -305,7 +397,7 @@ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbo
305
397
  const path = name.slice(slash + 1)
306
398
  total += data.length - (files.get(path)?.length ?? 0)
307
399
  files.set(path, data)
308
- if (files.size > MAX_NPM_PACKAGE_FILES || total > MAX_NPM_PACKAGE_BYTES) throw new NpmPackageError(413, 'package-too-large')
400
+ if (files.size > maxFiles || total > maxBytes) throw new NpmPackageError(413, 'package-too-large')
309
401
  }
310
402
  return [...files].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
311
403
  }
@@ -335,13 +427,14 @@ const ROW_LENGTH = 80
335
427
 
336
428
  // The files as the viewer gets them: `[path, bytes, text]`, text null for a
337
429
  // file that is not UTF-8, which instead carries its bytes' sha256, so a
338
- // comparison of two versions tells a changed one from an unchanged one.
339
- export function npmFileRows(files: NpmPackageFile[]): ([string, number, string] | [string, number, null, string])[] {
430
+ // comparison of two versions tells a changed one from an unchanged one. They
431
+ // may take `maxLength` characters of JSON, a package's unless given.
432
+ export function npmFileRows(files: NpmPackageFile[], maxLength = MAX_NPM_JSON_LENGTH): ([string, number, string] | [string, number, null, string])[] {
340
433
  let length = 0
341
434
  return files.map(({ path, bytes }) => {
342
435
  const text = npmFileText(bytes)
343
436
  length += ROW_LENGTH + jsonLength(path) + (text === null ? 0 : jsonLength(text))
344
- if (length > MAX_NPM_JSON_LENGTH) throw new NpmPackageError(413, 'package-too-large')
437
+ if (length > maxLength) throw new NpmPackageError(413, 'package-too-large')
345
438
  return text === null ? [path, bytes.byteLength, null, `sha256-${createHash('sha256').update(bytes).digest('base64')}`] : [path, bytes.byteLength, text]
346
439
  })
347
440
  }
@@ -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.
@@ -35,6 +42,7 @@ import { buffer } from 'node:stream/consumers'
35
42
  import type { CacheStore } from '@preventive/upstream/npm.js'
36
43
  import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
37
44
  import { CacheMissError, type CacheStorage } from './cache-storage.ts'
45
+ import { npmVersionStore } from './npm-kept.ts'
38
46
  import { loadNpmTarball } from './npm-loads.ts'
39
47
  import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
40
48
  import { npmPackageScope } from '../common/managed/npm-packages.js'
@@ -68,11 +76,20 @@ function remember(key: string, value: unknown) {
68
76
  while (memory.size > MEMORY_ENTRIES || memoryLength > MEMORY_LENGTH) forget(memory.keys().next().value!)
69
77
  }
70
78
 
71
- const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
72
- // What of a public package is kept in the database, and for how long its
73
- // 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.
74
83
  const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
75
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}`
76
93
 
77
94
  export interface NpmRiskStores {
78
95
  db: UpstreamCacheStore
@@ -81,10 +98,13 @@ export interface NpmRiskStores {
81
98
  }
82
99
 
83
100
  // A cache that reads and writes many of a type at once too, as risk's
84
- // readying of downloads asks (npmReadyDownloads): one read and one write of
85
- // the database each, not one a key, as a request's queries take turns on its
86
- // 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).
87
106
  export interface NpmRiskCache extends CacheStore {
107
+ readEach(entries: [type: string, key: string][]): Promise<unknown[]>
88
108
  readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
89
109
  writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
90
110
  }
@@ -93,13 +113,32 @@ export interface NpmRiskCache extends CacheStore {
93
113
  // that what is kept of it can be shared.
94
114
  export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
95
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
+ }
96
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
+ },
97
139
  async readMany(type, keys) {
98
- if (!isPublic || !IN_DATABASE.has(type)) return new Map(await Promise.all(keys.map(async key => [key, await cache.read(type, key)] as const)))
99
- try {
100
- const found = await db.getUpstreamCacheEntries(keys.map(key => `${type}/${key}`))
101
- return new Map(keys.map(key => { const value = found.get(`${type}/${key}`); return [key, value === undefined ? null : JSON.parse(value)] }))
102
- } 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]]))
103
142
  },
104
143
  async writeMany(type, entries) {
105
144
  if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
@@ -111,22 +150,31 @@ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: b
111
150
  if (type === 'npm/tarballs') return null
112
151
  try {
113
152
  if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
114
- 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)))
115
154
  } catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
116
- return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
155
+ return memory.get(memoryKey(type, key))?.value ?? null
117
156
  },
118
157
  async write(type, key, value) {
119
158
  if (type === 'npm/tarballs') return
159
+ if (isPublic && HOURLY.has(type)) remember(memoryKey(type, key), value)
120
160
  try {
121
161
  if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
122
162
  if (isPublic && IN_DATABASE.has(type)) {
123
163
  const at = Date.now()
124
- await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), at)
164
+ await db.setUpstreamCacheEntry(databaseKey(type, key), JSON.stringify(value), at)
125
165
  if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
126
166
  return
127
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
+ }
128
176
  } catch (error) { warn('write', error); return }
129
- remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
177
+ remember(memoryKey(type, key), value)
130
178
  },
131
179
  }
132
180
  return cache
@@ -176,7 +224,9 @@ export function npmRisk(doc: NpmVersionDocument, old: NpmVersionDocument | 'pick
176
224
  return scopes.get(scope)!
177
225
  }
178
226
  const given = old === 'pick' ? null : old
179
- const documentOf = async (version: string) => [doc, given].find(read => read?.version === version) ?? await readNpmVersion(doc.name, version, privileged, signal) ?? undefined
227
+ // The one picked, read as the viewer reads a version, kept a day where public.
228
+ const documentOf = async (version: string) => [doc, given].find(read => read?.version === version)
229
+ ?? await readNpmVersion(doc.name, version, privileged, signal, npmVersionStore(stores.db, stores.debug)) ?? undefined
180
230
  return bounded(spare, () => assessNpm(doc.name, doc.version, { oldVersion: old === 'pick' ? true : old?.version ?? null, fromEmpty,
181
231
  minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE,
182
232
  popularity: !teamScoped, cache: npmRiskCache(stores, !doc.private && !given?.private), signal, dependencies: !spare, advisories: !spare, osv: !doc.private, owned,
@@ -12,16 +12,17 @@ import { promisify } from 'node:util'
12
12
  import { brotliDecompress } from 'node:zlib'
13
13
  import { format } from '@preventive/compare/pretty.js'
14
14
  import { computeFileHash } from '@preventive/report'
15
- import { FILE_HASH } from '../common/bundle-metadata.js'
15
+ import { FILE_HASH, computeBundleFileHashes } from '../common/bundle-metadata.js'
16
16
  import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
17
17
  import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
18
18
  import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
19
19
  import type { OpenedBlob } from './blob-store.ts'
20
- import { type BundleCacheStorage, readBundleDetails } from './bundle-cache.ts'
20
+ import { type BundleCacheStorage, readSizedBundleDetails } from './bundle-cache.ts'
21
21
  import type { BundleStore } from './bundle-store.ts'
22
22
  import { encodeBrotli } from './brotli.ts'
23
23
  import { CacheMissError, type CacheStorage } from './cache-storage.ts'
24
24
  import type { ManagedBundle, ManagedDb } from './db.ts'
25
+ import { keptBriefly } from './kept-briefly.ts'
25
26
  import { loadNpmFiles } from './npm-loads.ts'
26
27
  import { type NpmVersionDocument, npmFileText } from './npm-packages.ts'
27
28
 
@@ -34,6 +35,20 @@ const PRETTY_VERSION = 4
34
35
  // after another, beside other readers'), any more refused.
35
36
  const MAX_ACTIVE_FORMATS = 2
36
37
  const MAX_WAITING_FORMATS = 16
38
+ // How long a bundle's details are kept once read, and what the two last read
39
+ // may have decoded from between them, the least lately read going first past
40
+ // it: Compare's requests for a pair of bundles come one after another, each
41
+ // reading both, and the Code tab's for one, each reading it.
42
+ const DETAILS_KEPT_MS = 60_000
43
+ const MAX_KEPT_DETAILS = 2
44
+ const MAX_KEPT_DETAILS_BYTES = 256 * 1024 * 1024
45
+ // Files hashed at once: one at a time, each waits its own round trip to the
46
+ // thread pool; all at once, every one's job is held together.
47
+ const HASHES_AT_ONCE = 32
48
+ // Kept copies a stream reads ahead of the line it is sending (readAhead):
49
+ // one at a time, each waits its own storage round trip; each read whole, as
50
+ // its line will send it.
51
+ const KEPT_AHEAD = 8
37
52
 
38
53
  export class PrettyError extends Error {
39
54
  status: number
@@ -108,6 +123,18 @@ export async function prettyBody(text: string, extension: string): Promise<Buffe
108
123
 
109
124
  const opened = (body: Buffer): OpenedBlob => ({ size: body.byteLength, stream: Readable.from([body]) })
110
125
 
126
+ // A version's file's hash, by its bytes: those of a version's files loaded
127
+ // a little while since are the same bytes (npm-loads.ts), hashed once.
128
+ const fileHashes = new WeakMap<Uint8Array, Promise<string>>()
129
+ function npmFileHash(bytes: Uint8Array): Promise<string> {
130
+ let hash = fileHashes.get(bytes)
131
+ if (!hash) {
132
+ fileHashes.set(bytes, hash = computeFileHash(bytes))
133
+ hash.catch(() => { fileHashes.delete(bytes) })
134
+ }
135
+ return hash
136
+ }
137
+
111
138
  const decompress = promisify(brotliDecompress)
112
139
 
113
140
  // A pretty-printed copy's text, from its Brotli-encoded bytes.
@@ -127,7 +154,7 @@ async function npmPrintable(doc: NpmVersionDocument): Promise<Array<PrettyReques
127
154
  const extension = prettyExtension(path)
128
155
  if (extension === null || bytes.byteLength > MAX_PRETTY_BYTES) continue
129
156
  const text = npmFileText(bytes)
130
- if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await computeFileHash(bytes), extension, text })
157
+ if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await npmFileHash(bytes), extension, text })
131
158
  }
132
159
  } finally { load.release() }
133
160
  return files.toSorted((a, b) => (a.path < b.path ? -1 : 1))
@@ -143,6 +170,29 @@ async function prettyLine({ path, hash }: PrettyRequest, copy: () => Promise<Ope
143
170
  }
144
171
  }
145
172
 
173
+ // A file's line from its kept copy, `open` opening it, or null where none is
174
+ // kept.
175
+ async function keptLine(request: PrettyRequest, open: () => Promise<OpenedBlob | null>): Promise<PrettyLine | null> {
176
+ const copy = await open()
177
+ return copy && prettyLine(request, () => Promise.resolve(copy))
178
+ }
179
+
180
+ // Each of `items` with what `read` reads of it, in their order, up to
181
+ // KEPT_AHEAD of them read at once ahead of the one iterated, each answered
182
+ // (or failing) in its turn. One iterated no further starts no more; those
183
+ // under way settle unread.
184
+ async function* readAhead<T, R>(items: readonly T[], read: (item: T) => Promise<R>): AsyncGenerator<[T, R]> {
185
+ const reads: Promise<R>[] = []
186
+ for (const [i, item] of items.entries()) {
187
+ while (reads.length < Math.min(items.length, i + KEPT_AHEAD)) {
188
+ const reading = read(items[reads.length]!)
189
+ reading.catch(() => {})
190
+ reads.push(reading)
191
+ }
192
+ yield [item, await reads[i]!]
193
+ }
194
+ }
195
+
146
196
  // The files that changed between two sides, pretty-printed: each side's
147
197
  // formattable files the other has another text at the path of, or, where it
148
198
  // has none there, none of their text at a path of its own (a file renamed, or
@@ -151,8 +201,9 @@ async function prettyLine({ path, hash }: PrettyRequest, copy: () => Promise<Ope
151
201
  // the viewer diffs but where it pairs files otherwise (among copies of one
152
202
  // text, across packages), whose copies it asks for alone. As they are
153
203
  // iterated, in path order, the base's first where both have one: those kept,
154
- // at once; then the rest, formatted one after another, the minified before
155
- // the others (needsPrettyPrint), as the viewer prints those unasked.
204
+ // at once, a few read ahead (readAhead); then the rest, formatted one after
205
+ // another, the minified before the others (needsPrettyPrint), as the viewer
206
+ // prints those unasked.
156
207
  export function compared(sides: readonly [ComparedSide, ComparedSide]): AsyncGenerator<ComparedLine> {
157
208
  // Each side's texts at the paths the other hasn't: where a file moved has its own.
158
209
  const moved = sides.map((own, i) => new Set([...own.hashes].filter(([path]) => !sides[1 - i]!.hashes.has(path)).map(([, hash]) => hash)))
@@ -162,9 +213,8 @@ export function compared(sides: readonly [ComparedSide, ComparedSide]): AsyncGen
162
213
  }).toSorted((a, b) => (a.file.path < b.file.path ? -1 : a.file.path > b.file.path ? 1 : 0))
163
214
  return (async function* () {
164
215
  const unkept: typeof changed = []
165
- for (const { own, file } of changed) {
166
- const copy = await own.kept(file)
167
- if (copy) yield { side: own.side, ...await prettyLine(file, () => Promise.resolve(copy)) }
216
+ for await (const [{ own, file }, line] of readAhead(changed, item => keptLine(item.file, () => item.own.kept(item.file)))) {
217
+ if (line) yield { side: own.side, ...line }
168
218
  else unkept.push({ own, file })
169
219
  }
170
220
  const minified = new Map(unkept.map(({ file }) => [file, needsPrettyPrint(file.path, file.text)]))
@@ -206,26 +256,35 @@ async function kept(open: () => Promise<OpenedBlob>): Promise<OpenedBlob | null>
206
256
 
207
257
  // A side's files that can be formatted, with their texts, and every one of
208
258
  // its files' hashes by path, from their paths and contents: texts, or bytes,
209
- // read as text where they are.
210
- async function sideFiles(entries: Iterable<[string, string | Uint8Array]>): Promise<Pick<ComparedSide, 'files' | 'hashes'>> {
259
+ // read as text where they are; hashed by `hashOf`, a few at once.
260
+ async function sideFiles<T extends string | Uint8Array>(entries: Array<[string, T]>, hashOf: (file: string, content: T) => Promise<string>): Promise<Pick<ComparedSide, 'files' | 'hashes'>> {
211
261
  const files: ComparedSide['files'] = [], hashes = new Map<string, string>()
262
+ for (let i = 0; i < entries.length; i += HASHES_AT_ONCE) {
263
+ const chunk = entries.slice(i, i + HASHES_AT_ONCE)
264
+ const hashed = await Promise.all(chunk.map(async ([path, content]) => [path, await hashOf(path, content)] as const))
265
+ for (const [path, hash] of hashed) hashes.set(path, hash)
266
+ }
212
267
  for (const [path, content] of entries) {
213
- const hash = await computeFileHash(content)
214
- hashes.set(path, hash)
215
268
  const extension = prettyExtension(path)
216
269
  if (extension === null || (typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength) > MAX_PRETTY_BYTES) continue
217
270
  const text = typeof content === 'string' ? content : npmFileText(content)
218
- if (text !== null) files.push({ path, hash, extension, text })
271
+ if (text !== null) files.push({ path, hash: hashes.get(path)!, extension, text })
219
272
  }
220
273
  return { files, hashes }
221
274
  }
222
275
 
223
276
  // A bundle's side, from its details: its files, as sideFiles reads them,
224
277
  // within a reason `scope` as Compare reads them there (bundleCompareFiles),
225
- // and its packages' directories.
278
+ // and its packages' directories; hashed once for its details, as long as
279
+ // they are kept (computeBundleFileHashes).
226
280
  async function bundleSide(details: Parameters<typeof bundlePackageDirs>[0], scope: string): Promise<Pick<ComparedSide, 'files' | 'hashes' | 'packageDirs'>> {
227
281
  const scoped = scope ? new Set(bundleCompareFiles(details, scope).keys()) : null, sources = bundleSourcesAsMap(details)
228
- return { ...await sideFiles(scoped ? [...sources].filter(([path]) => scoped.has(path)) : sources), packageDirs: bundlePackageDirs(details) }
282
+ const hashed: Map<string, string> = await computeBundleFileHashes(details)
283
+ return { ...await sideFiles(scoped ? [...sources].filter(([path]) => scoped.has(path)) : [...sources], (path, content) => {
284
+ const hash = hashed.get(path)
285
+ return hash === undefined ? computeFileHash(content) : Promise.resolve(hash)
286
+ }),
287
+ packageDirs: bundlePackageDirs(details) }
229
288
  }
230
289
 
231
290
  // A version's files by path, from one load of them.
@@ -251,14 +310,25 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
251
310
  }
252
311
 
253
312
  // A bundle's details, from its bytes in `store`: bundles are read one at a
254
- // time, as reading one decodes all of it.
313
+ // time, as reading one decodes all of it, readers asking for one waiting its
314
+ // turn or being read sharing it; and kept a little while once read
315
+ // (DETAILS_KEPT_MS), each reader's access to the bundle the caller's to check
316
+ // as ever.
255
317
  function detailsReader(store: BundleStore) {
256
- const enqueue = serial()
257
- return (record: ManagedBundle) => enqueue(async () => {
258
- const details = await readBundleDetails(record, store)
259
- if (!details) throw new PrettyError(422, 'bundle-unavailable')
260
- return details
261
- })
318
+ type Details = NonNullable<Awaited<ReturnType<typeof readSizedBundleDetails>>>['details']
319
+ const enqueue = serial(), share = sharing<Details>()
320
+ const held = keptBriefly<Details>({ keptMs: DETAILS_KEPT_MS, maxBytes: MAX_KEPT_DETAILS_BYTES, maxEntries: MAX_KEPT_DETAILS })
321
+ return (record: ManagedBundle): Promise<Details> => {
322
+ const key = `${record.id} ${record.integrity}`
323
+ const hit = held.get(key)
324
+ if (hit) return Promise.resolve(hit)
325
+ return share(key, () => enqueue(async () => {
326
+ const read = await readSizedBundleDetails(record, store)
327
+ if (!read) throw new PrettyError(422, 'bundle-unavailable')
328
+ held.keep(key, read.details, read.decodedBytes)
329
+ return read.details
330
+ }))
331
+ }
262
332
  }
263
333
 
264
334
  export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
@@ -282,15 +352,18 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
282
352
  return body
283
353
  })
284
354
  }
355
+ // The copy kept for a public version's file, or null where none is (and
356
+ // for a private version's, which none is kept for).
357
+ const npmKept = (doc: NpmVersionDocument, request: PrettyRequest) => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request))))
285
358
  // A version's file pretty-printed, `read` reading its text (and checking
286
359
  // it is the file named): from the copy kept for a public version, else
287
360
  // made, and kept for a public one.
288
361
  async function npmCopy(doc: NpmVersionDocument, request: PrettyRequest, read: () => Promise<string>): Promise<OpenedBlob> {
362
+ return await npmKept(doc, request) ?? npmMade(doc, request, read)
363
+ }
364
+ // The file pretty-printed from the text `read` reads, kept for a public version.
365
+ async function npmMade(doc: NpmVersionDocument, request: PrettyRequest, read: () => Promise<string>): Promise<OpenedBlob> {
289
366
  const file = prettyFile(request)
290
- if (!doc.private) {
291
- try { return await npm.open(file) }
292
- catch (err) { if (!(err instanceof CacheMissError)) throw err }
293
- }
294
367
  return opened(await shared(doc.private ? `npm ${doc.dist.integrity} ${file}` : `npm ${file}`, async () => {
295
368
  const body = await prettyBody(await read(), request.extension)
296
369
  if (!doc.private) await keep(() => npm.put(file, body))
@@ -317,7 +390,7 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
317
390
  try {
318
391
  const bytes = (await load.result).find(entry => entry.path === request.path)?.bytes
319
392
  if (!bytes) throw new PrettyError(404, 'no-file')
320
- if (await computeFileHash(bytes) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
393
+ if (await npmFileHash(bytes) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
321
394
  text = npmFileText(bytes)
322
395
  } finally { load.release() }
323
396
  if (text === null) throw new PrettyError(422, 'unformattable')
@@ -326,11 +399,14 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
326
399
  },
327
400
  // Every file of the version that needs pretty-printing (npmPrintable),
328
401
  // read before this resolves; then, as they are iterated, each
329
- // pretty-printed as `npm` has it, one after another.
402
+ // pretty-printed as `npm` has it, one after another, those kept a few
403
+ // read ahead (readAhead).
330
404
  async npmAll(doc: NpmVersionDocument): Promise<AsyncGenerator<PrettyLine>> {
331
405
  const files = await npmPrintable(doc)
332
406
  return (async function* () {
333
- for (const { text, ...request } of files) yield await prettyLine(request, () => npmCopy(doc, request, () => Promise.resolve(text)))
407
+ for await (const [file, line] of readAhead(files, request => keptLine(request, () => npmKept(doc, request)))) {
408
+ yield line ?? await prettyLine(file, () => npmMade(doc, file, () => Promise.resolve(file.text)))
409
+ }
334
410
  })()
335
411
  },
336
412
  // Two bundles' sides, or two versions', as a comparison reads them, read
@@ -338,19 +414,20 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
338
414
  // pretty-printed (compared), and their diffs with renamed names hidden
339
415
  // (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
340
416
  // where one is asked for, as Compare reads it then.
341
- async bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
417
+ bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
342
418
  const sideOf = async (record: ManagedBundle, side: Side): Promise<ComparedSide> => ({ side, ...await bundleSide(await bundleDetails(record), scope),
343
419
  kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
344
420
  made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
345
- return [await sideOf(base, 'base'), await sideOf(other, 'other')]
421
+ return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
346
422
  },
347
423
  // A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
348
424
  bundleDetails,
349
- async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
350
- const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
351
- kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
425
+ npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
426
+ const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc), (_, bytes) => npmFileHash(bytes)),
427
+ packageDirs: null,
428
+ kept: request => npmKept(doc, request),
352
429
  made: (request, text) => npmCopy(doc, request, () => Promise.resolve(text)) })
353
- return [await sideOf(base, 'base'), await sideOf(other, 'other')]
430
+ return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
354
431
  },
355
432
  }
356
433
  }