@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50

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 (366) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,30 @@
1
+ // Values by key, the least recently used going first once their weights
2
+ // (`weight`, 1 each unless given) are past `limit`.
3
+ export interface Lru<V> { get(key: string): V | undefined; set(key: string, value: V): void }
4
+ export function lru<V>(limit: number, weight: (value: V) => number = () => 1): Lru<V> {
5
+ const kept = new Map<string, V>()
6
+ let total = 0
7
+ const forget = (key: string) => {
8
+ const value = kept.get(key)
9
+ if (value === undefined) return
10
+ kept.delete(key)
11
+ total -= weight(value)
12
+ }
13
+ return {
14
+ get(key) {
15
+ const value = kept.get(key)
16
+ if (value !== undefined) { kept.delete(key); kept.set(key, value) }
17
+ return value
18
+ },
19
+ set(key, value) {
20
+ forget(key)
21
+ if (weight(value) > limit) return
22
+ kept.set(key, value)
23
+ total += weight(value)
24
+ for (const first of kept.keys()) {
25
+ if (total <= limit) break
26
+ forget(first)
27
+ }
28
+ },
29
+ }
30
+ }
@@ -0,0 +1,78 @@
1
+ import type { ManagedSql } from './sql.ts'
2
+ import type { TeamReportAccessSnapshot } from './db.ts'
3
+ import { parseGithubPrUrl } from '../common/github-pr.ts'
4
+
5
+ // One immutable reference per finding, shared across reports and teams. Keep
6
+ // reservations across crashes/uncertain GitHub responses to prevent duplicates.
7
+ // No triage/import/admin patch accepts these fields.
8
+ export const MANAGED_ISSUE_SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS managed_finding_issue (
10
+ finding_id TEXT PRIMARY KEY,
11
+ repo_id INTEGER NOT NULL,
12
+ repository TEXT NOT NULL,
13
+ request_id TEXT NOT NULL,
14
+ issue_url TEXT,
15
+ auto_fix_url TEXT,
16
+ auto_fix_checked_at INTEGER,
17
+ created_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
18
+ created_at INTEGER NOT NULL
19
+ ) STRICT;
20
+ `
21
+ export interface ManagedIssue {
22
+ findingId: string
23
+ repoId: number
24
+ repository: string
25
+ requestId: string
26
+ issueUrl: string | null
27
+ autoFixUrl: string | null
28
+ autoFixCheckedAt: number | null
29
+ createdBy: string | null
30
+ createdAt: number
31
+ }
32
+ export interface ManagedIssueStore {
33
+ getManagedIssue(findingId: string): Promise<ManagedIssue | null>
34
+ listManagedIssues(findingIds: readonly string[]): Promise<ManagedIssue[]>
35
+ claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt'>): Promise<boolean>
36
+ finishManagedIssue(findingId: string, requestId: string, url: string): Promise<boolean>
37
+ releaseManagedIssue(findingId: string, requestId: string): Promise<void>
38
+ setManagedIssueAutoFix(findingId: string, issueUrl: string, previous: string | null, next: string | null, checkedAt: number): Promise<boolean>
39
+ }
40
+
41
+ // A repeated finding ID never grants access to another team's repository.
42
+ export function visibleManagedIssues(issues: ManagedIssue[], snapshot: TeamReportAccessSnapshot) {
43
+ if (snapshot.user.id.startsWith('share:')) return []
44
+ const repos = new Set(snapshot.repositories.map(repo => repo.repoId))
45
+ const names = new Set(snapshot.repositories.map(repo => repo.github.toLowerCase()))
46
+ return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue => {
47
+ const ref = parseGithubPrUrl(issue.autoFixUrl)
48
+ return { ...issue, autoFixUrl: ref && names.has(ref.repo.toLowerCase()) ? issue.autoFixUrl : null }
49
+ })
50
+ }
51
+ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
52
+ const fields = `finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
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
+ const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
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.
59
+ const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
60
+ WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
61
+ AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
62
+ const claim = db.prepare(`INSERT INTO managed_finding_issue (finding_id, repo_id, repository, request_id, created_by, created_at)
63
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (finding_id) DO NOTHING`)
64
+ const finish = db.prepare(`UPDATE managed_finding_issue SET issue_url = ? WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
65
+ const release = db.prepare(`DELETE FROM managed_finding_issue WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
66
+ return {
67
+ async getManagedIssue(findingId) { return (await get.get(findingId) as ManagedIssue | undefined) ?? null },
68
+ async listManagedIssues(ids) { return await list.all(JSON.stringify(ids)) as ManagedIssue[] },
69
+ async setManagedIssueAutoFix(findingId, issueUrl, previous, next, checkedAt) {
70
+ return Number((await autoFix.run(next, checkedAt, findingId, issueUrl, previous, checkedAt)).changes) > 0
71
+ },
72
+ async claimManagedIssue(issue) {
73
+ return Number((await claim.run(issue.findingId, issue.repoId, issue.repository, issue.requestId, issue.createdBy, issue.createdAt)).changes) > 0
74
+ },
75
+ async finishManagedIssue(findingId, requestId, url) { return Number((await finish.run(url, findingId, requestId)).changes) > 0 },
76
+ async releaseManagedIssue(findingId, requestId) { await release.run(findingId, requestId) },
77
+ }
78
+ }
@@ -0,0 +1,45 @@
1
+ import { roleAtLeast } from '../common/managed/roles.ts'
2
+ import { type RepoScope, contentAccess } from './content-access.ts'
3
+ import type { AdminBundle, AdminReport, ManagedDb } from './db-methods.ts'
4
+ import { ManagedMutationError } from './management.ts'
5
+
6
+ type CatalogAccess = { repos: { repoId: number; fullName: string }[]; repoScopes: RepoScope[] | null }
7
+ export interface ManagementCatalogStore {
8
+ getReportCatalog(sessionId: string, now: number): Promise<CatalogAccess & { reports: (AdminReport & { canChangeRepo: boolean })[] }>
9
+ getBundleCatalog(sessionId: string, now: number): Promise<CatalogAccess & { bundles: (AdminBundle & { canChangeRepo: boolean })[] }>
10
+ }
11
+ type CatalogDb = Pick<ManagedDb, 'getReportAccessSnapshot' | 'listRepoScopesForUser' | 'listSelectedRepos'
12
+ | 'listReports' | 'listBundles' | 'listReadableBundleIds'>
13
+
14
+ // The outer store method owns one read snapshot. Use raw methods here so the
15
+ // session, grants, rows and action flags agree without per-row transactions.
16
+ export function managementCatalogMethods(db: CatalogDb): ManagementCatalogStore {
17
+ async function access(sessionId: string, now: number) {
18
+ const snapshot = await db.getReportAccessSnapshot(sessionId, now, [])
19
+ if (!snapshot) throw new ManagedMutationError(401, 'unauthenticated')
20
+ if (!roleAtLeast(snapshot.user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
21
+ const scope = await contentAccess(db, snapshot.user)
22
+ const repos = (await db.listSelectedRepos()).filter(scope.bundle).map(({ repoId, fullName }) => ({ repoId, fullName }))
23
+ return { userId: snapshot.user.role === 'admin' ? undefined : snapshot.user.id, scope, repos }
24
+ }
25
+ return {
26
+ async getReportCatalog(sessionId, now) {
27
+ const { userId, scope, repos } = await access(sessionId, now)
28
+ const reports = await db.listReports(userId)
29
+ const linked = reports.flatMap(report => report.bundleId ? [report.bundleId] : [])
30
+ const readable = userId === undefined ? new Set(linked) : new Set(await db.listReadableBundleIds(userId, linked))
31
+ return { repos, repoScopes: scope.scopes, reports: reports.map(report => ({ ...report,
32
+ canChangeRepo: report.repoId === null || scope.report(report),
33
+ bundleId: report.bundleId && readable.has(report.bundleId) ? report.bundleId : null,
34
+ bundleFilename: report.bundleId && readable.has(report.bundleId) ? report.bundleFilename : null,
35
+ })) }
36
+ },
37
+ async getBundleCatalog(sessionId, now) {
38
+ const { userId, scope, repos } = await access(sessionId, now)
39
+ const bundles = await db.listBundles(userId)
40
+ return { repos, repoScopes: scope.scopes, bundles: bundles.map(bundle => ({ ...bundle,
41
+ canChangeRepo: bundle.repoId === null || scope.report(bundle),
42
+ })) }
43
+ },
44
+ }
45
+ }
@@ -0,0 +1,99 @@
1
+ import type { AdminReport, BundleReuse, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
2
+ import { roleAtLeast } from '../common/managed/roles.ts'
3
+
4
+ export class ManagedMutationError extends Error {
5
+ status: number
6
+ constructor(status: number, message: string) { super(message); this.status = status }
7
+ }
8
+
9
+ export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string } | { type: 'visibility'; visible: boolean }
10
+ export type ReportMutation = ContentMutation
11
+ type RemovalRepo = Pick<ManagedRepo, 'repoId' | 'fullName' | 'addedAt'>
12
+ type RemovalAnnotations = { reports: string; ids: string[] }
13
+ export interface ManagementStore {
14
+ mutateReport(sessionId: string, id: string, change: ReportMutation): Promise<{ report: ReportRecord; user: StoredUser }>
15
+ mutateBundle(sessionId: string, id: string, change: ContentMutation): Promise<{ bundle: ManagedBundle; user: StoredUser }>
16
+ // A repeated upload/build of stored bytes (see reuseBundle). Renames need the
17
+ // access other bundle mutations need; a server build labels the row anyway.
18
+ reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>): Promise<boolean>
19
+ removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null): Promise<{
20
+ reports: RepoReportItem[]; bundles: RepoDataItem[]; deletedReports: number; deletedBundles: number; deletedTriage: number
21
+ }>
22
+ }
23
+ type ManagementDb = Pick<ManagedDb, 'sessionWithUser' | 'getReport' | 'getBundle' | 'listReports' | 'listReportsForRepo'
24
+ | 'listBundlesForRepo' | 'listAllRepos' | 'listSelectedRepos' | 'userCanReadReport' | 'userCanReadBundle' | 'userCanReadRepoPath'
25
+ | 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo' | 'setBundleVisible' | 'reuseBundle'
26
+ | 'deleteReportsForRepo' | 'deleteBundlesForRepo' | 'deleteRepo' | 'deleteTriage' | 'recordActivity'>
27
+
28
+ // Only immutable bytes/format and repository membership affect the overlap
29
+ // scan. Ignore publication, user display names, and unrelated bundle links.
30
+ export function reportReferenceSnapshot(reports: readonly Pick<AdminReport, 'id' | 'sha256' | 'filename' | 'repoId'>[]): string {
31
+ return JSON.stringify(reports.map(r => [r.id, r.sha256, r.filename, r.repoId]).toSorted((a, b) => String(a[0]).localeCompare(String(b[0]))))
32
+ }
33
+
34
+ // These methods are wrapped by scopeManagedMethods: authorization, comparison,
35
+ // metadata changes (and repository-removal audit) share the writer transaction. Blob reads
36
+ // and deletes stay outside it. Use the raw store here, never nested scopes.
37
+ export function managementMethods(db: ManagementDb): ManagementStore {
38
+ async function authorize(sessionId: string, role: 'admin' | 'manage') {
39
+ const s = await db.sessionWithUser(sessionId, Date.now())
40
+ if (!s) throw new ManagedMutationError(401, 'unauthenticated')
41
+ if (!roleAtLeast(s.user.role, role)) throw new ManagedMutationError(403, 'forbidden')
42
+ return s.user
43
+ }
44
+ async function destination(user: { id: string; role: string }, change: ContentMutation) {
45
+ if (change.type !== 'repo' || change.repoId === null) return
46
+ if (!(await db.listSelectedRepos()).some(repo => repo.repoId === change.repoId)) throw new ManagedMutationError(400, 'bad-repo')
47
+ if (user.role !== 'admin' && !await db.userCanReadRepoPath(user.id, change.repoId, change.directory)) throw new ManagedMutationError(403, 'repo-forbidden')
48
+ }
49
+ return {
50
+ async mutateReport(sessionId: string, id: string, change: ReportMutation) {
51
+ const user = await authorize(sessionId, 'manage')
52
+ const report = await db.getReport(id)
53
+ if (!report || (user.role !== 'admin' && report.uploadedBy !== user.id && !await db.userCanReadReport(user.id, id))) throw new ManagedMutationError(404, 'no-report')
54
+ if (user.role !== 'admin' && report.repoId !== null && !await db.userCanReadReport(user.id, id)) throw new ManagedMutationError(403, 'repo-forbidden')
55
+ if (change.type === 'repo' && report.repoEmbedded) throw new ManagedMutationError(409, 'repo-in-report')
56
+ if (change.type !== 'visibility') await destination(user, change)
57
+ if (change.type === 'delete') await db.deleteReport(id)
58
+ else if (change.type === 'visibility') await db.setReportVisible(id, change.visible)
59
+ else await db.setReportRepo(id, change.repoId, change.directory)
60
+ return { report, user }
61
+ },
62
+ async mutateBundle(sessionId: string, id: string, change: ContentMutation) {
63
+ const user = await authorize(sessionId, 'manage')
64
+ const bundle = await db.getBundle(id)
65
+ if (!bundle || (user.role !== 'admin' && !await db.userCanReadBundle(user.id, id))) throw new ManagedMutationError(404, 'no-bundle')
66
+ if (user.role !== 'admin' && bundle.repoId !== null && !await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)) throw new ManagedMutationError(403, 'repo-forbidden')
67
+ await destination(user, change)
68
+ if (change.type === 'delete') await db.deleteBundle(id)
69
+ else if (change.type === 'visibility') await db.setBundleVisible(id, change.visible)
70
+ else await db.setBundleRepo(id, change.repoId, change.directory)
71
+ return { bundle, user }
72
+ },
73
+ async reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>) {
74
+ const user = await authorize(sessionId, 'manage')
75
+ const bundle = await db.getBundle(id)
76
+ if (!bundle) return false
77
+ const rename = user.role === 'admin' || (await db.userCanReadBundle(user.id, id)
78
+ && (bundle.repoId === null || await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)))
79
+ if (!rename && reuse.provenance !== 'build') return false
80
+ return db.reuseBundle(id, { ...reuse, rename })
81
+ },
82
+ async removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null) {
83
+ const user = await authorize(sessionId, 'admin')
84
+ const repo = (await db.listAllRepos()).find(r => r.repoId === expected.repoId)
85
+ if (!repo) throw new ManagedMutationError(404, 'no-repo')
86
+ if (repo.fullName !== expected.fullName || repo.addedAt !== expected.addedAt) throw new ManagedMutationError(409, 'repository-changed')
87
+ if (annotations && reportReferenceSnapshot(await db.listReports()) !== annotations.reports) throw new ManagedMutationError(409, 'repository-changed')
88
+ const reports = await db.listReportsForRepo(repo.repoId)
89
+ const bundles = await db.listBundlesForRepo(repo.repoId)
90
+ const deletedReports = await db.deleteReportsForRepo(repo.repoId)
91
+ const deletedBundles = await db.deleteBundlesForRepo(repo.repoId)
92
+ const deletedTriage = await db.deleteTriage(annotations?.ids ?? [])
93
+ await db.deleteRepo(repo.repoId)
94
+ await db.recordActivity({ kind: 'delete', actor: user.login, actorId: user.id, repo: repo.fullName,
95
+ action: `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)` }, Date.now())
96
+ return { reports, bundles, deletedReports, deletedBundles, deletedTriage }
97
+ },
98
+ }
99
+ }
@@ -0,0 +1,23 @@
1
+ // The lines a package's files change from one side to another (npm-delta.ts),
2
+ // as @preventive/compare's compareTrees counts them (git's --numstat), off
3
+ // the server's event loop: of its text files, renames found as git finds
4
+ // them, a side not there empty; and, apart, those of its changes it stamps
5
+ // `typeOnly`, declaration files changed in types alone.
6
+ import { parentPort, workerData } from 'node:worker_threads'
7
+ import { compareTrees } from '@preventive/compare'
8
+
9
+ const tree = files => files.map(([name, data]) => ({ name, data }))
10
+ const changes = compareTrees(tree(workerData.base), tree(workerData.other), { paths: 'keys', lines: true, renames: true })
11
+ const answer = { additions: 0, deletions: 0, typeAdditions: 0, typeDeletions: 0, typeFiles: 0 }
12
+ for (const change of changes) {
13
+ if (change.binary) continue
14
+ answer.additions += change.insertions ?? 0
15
+ answer.deletions += change.deletions ?? 0
16
+ if (change.typeOnly !== true) continue
17
+ answer.typeAdditions += change.insertions ?? 0
18
+ answer.typeDeletions += change.deletions ?? 0
19
+ answer.typeFiles++
20
+ }
21
+ // Node worker messages have no browser target origin.
22
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
23
+ parentPort.postMessage(answer)
@@ -0,0 +1,194 @@
1
+ // The lines a version of a package changes against another, or against none,
2
+ // its whole package's (GET /api/npm/delta), as a list of versions wanting
3
+ // reviews tells them beside each: added and removed (`additions`,
4
+ // `deletions`), as git's --numstat counts them, of their text files, a
5
+ // renamed one diffed with what it was; and, where some of the files changed
6
+ // are declaration files @preventive/compare's compareTrees proves changed in
7
+ // types alone (`typeOnly`, proveTypeOnly: types and comments alone erased,
8
+ // the runtime they leave as it was, an added one's empty or a single bare
9
+ // `export {}`), those of the rest (`typeless`), what a reviewer may pass by
10
+ // leaving those out; and whether a binary file, which they count none of,
11
+ // was added, removed or changed (`binary`), one moved as it was not. Worked
12
+ // out in a worker, off the event loop, as many at once per process as
13
+ // MAX_ACTIVE, both sides' files read together only once their turn has come,
14
+ // those waiting past MAX_WAITING refused, each waiting a minute at most and
15
+ // counting in a minute at most; one asked again meanwhile sharing the
16
+ // first's; and kept by both sides' integrities, in this instance and beyond
17
+ // it (`kept`), what a version holds never changing. Access is the caller's
18
+ // to check.
19
+ import { Worker } from 'node:worker_threads'
20
+ import { createHash } from 'node:crypto'
21
+ import type { KeptStore } from './npm-kept.ts'
22
+ import { type Lru, lru } from './lru.ts'
23
+ import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
24
+
25
+ export interface NpmDelta { additions: number; deletions: number; typeless: { additions: number; deletions: number } | null; binary: boolean }
26
+
27
+ const MAX_ACTIVE = 2
28
+ const MAX_WAITING = 64
29
+ const DELTA_TIMEOUT_MS = 60_000
30
+ // The text the two sides may hold together, past which none is counted.
31
+ const MAX_TEXT_LENGTH = 64 * 1024 * 1024
32
+
33
+ const deltas: Lru<NpmDelta> = lru(20_000)
34
+ const working = new Map<string, Promise<NpmDelta>>()
35
+ let active = 0
36
+ const waiting: Array<() => void> = []
37
+
38
+ async function turn(deadline: number): Promise<void> {
39
+ if (active < MAX_ACTIVE) { active++; return }
40
+ if (waiting.length >= MAX_WAITING) throw new NpmPackageError(429, 'delta-busy')
41
+ await new Promise<void>((resolve, reject) => {
42
+ const next = () => { clearTimeout(timer); resolve() }
43
+ const timer = setTimeout(() => {
44
+ waiting.splice(waiting.indexOf(next), 1)
45
+ reject(new NpmPackageError(504, 'delta-timeout'))
46
+ }, deadline - Date.now())
47
+ waiting.push(next)
48
+ })
49
+ }
50
+ function done() {
51
+ const next = waiting.shift()
52
+ if (next) next()
53
+ else active--
54
+ }
55
+
56
+ const lines = (told: unknown): told is { additions: number; deletions: number } => typeof told === 'object' && told !== null
57
+ && Number.isSafeInteger((told as { additions?: unknown }).additions) && Number.isSafeInteger((told as { deletions?: unknown }).deletions)
58
+ // A delta as kept, none where it isn't one, or was kept before it told of
59
+ // binary files.
60
+ const deltaOf = (told: unknown): NpmDelta | null => lines(told) && ((told as NpmDelta).typeless === null || lines((told as NpmDelta).typeless))
61
+ && typeof (told as NpmDelta).binary === 'boolean' ? { additions: told.additions, deletions: told.deletions, typeless: (told as NpmDelta).typeless,
62
+ binary: (told as NpmDelta).binary } : null
63
+
64
+ type Counted = { additions: number; deletions: number; typeAdditions: number; typeDeletions: number; typeFiles: number }
65
+ const isCounted = (told: unknown): told is Counted => typeof told === 'object' && told !== null
66
+ && ['additions', 'deletions', 'typeAdditions', 'typeDeletions', 'typeFiles'].every(key => Number.isSafeInteger((told as Record<string, unknown>)[key]))
67
+
68
+ // What the worker counts of `base` against `other`, each its text files' paths and texts.
69
+ function counted(base: [string, string][], other: [string, string][], deadline: number): Promise<Counted> {
70
+ let worker: Worker
71
+ try {
72
+ worker = new Worker(new URL('./npm-delta-worker.js', import.meta.url), {
73
+ workerData: { base, other }, execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1536 },
74
+ })
75
+ } catch (err) {
76
+ console.warn('managed: a package delta could not start:', err)
77
+ throw new NpmPackageError(503, 'unavailable')
78
+ }
79
+ return new Promise((resolve, reject) => {
80
+ let answer: Counted | null = null, failure = new NpmPackageError(422, 'delta-failed')
81
+ const timer = setTimeout(() => { failure = new NpmPackageError(504, 'delta-timeout'); void worker.terminate() }, deadline - Date.now())
82
+ worker.once('message', (told: unknown) => {
83
+ if (isCounted(told)) answer = told
84
+ void worker.terminate()
85
+ })
86
+ worker.once('error', err => { console.warn('managed: a package delta failed:', err) })
87
+ worker.once('exit', () => {
88
+ clearTimeout(timer)
89
+ if (answer) resolve(answer)
90
+ else reject(failure)
91
+ })
92
+ })
93
+ }
94
+
95
+ // A side's text files, by path, how long their texts are, and its other,
96
+ // binary, files.
97
+ function texts(files: NpmPackageFile[] | null) {
98
+ let length = 0
99
+ const binaries: NpmPackageFile[] = []
100
+ const read = (files ?? []).flatMap(file => {
101
+ const text = npmFileText(file.bytes)
102
+ if (text === null) { binaries.push(file); return [] }
103
+ length += text.length
104
+ return [[file.path, text] as [string, string]]
105
+ })
106
+ return { read, length, binaries }
107
+ }
108
+
109
+ const same = (a: Uint8Array, b: Uint8Array) => Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(b)
110
+ const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('base64')
111
+
112
+ // Whether `news`, a side's binary files, differ from `olds`, the other's:
113
+ // one added, removed or changed, one moved where it is the same as one that
114
+ // was elsewhere not, matched by their digests, each file looked at once.
115
+ function binaryChanged(olds: NpmPackageFile[], news: NpmPackageFile[]): boolean {
116
+ if (olds.length !== news.length) return true
117
+ const left = new Map(olds.map(({ path, bytes }) => [path, bytes]))
118
+ const moved: Uint8Array[] = []
119
+ for (const { path, bytes } of news) {
120
+ const was = left.get(path)
121
+ if (was && same(was, bytes)) left.delete(path)
122
+ else moved.push(bytes)
123
+ }
124
+ // Those not where they were, by their digests, as many of each as there are.
125
+ const unmoved = new Map<string, number>()
126
+ for (const bytes of left.values()) {
127
+ const key = digest(bytes)
128
+ unmoved.set(key, (unmoved.get(key) ?? 0) + 1)
129
+ }
130
+ return moved.some(bytes => {
131
+ const key = digest(bytes)
132
+ const count = unmoved.get(key) ?? 0
133
+ if (count === 0) return true
134
+ unmoved.set(key, count - 1)
135
+ return false
136
+ })
137
+ }
138
+
139
+ type Side = { integrity: string; files: () => Promise<NpmPackageFile[]> }
140
+
141
+ // The delta of `other` (its integrity, and how to read its files) against
142
+ // `base`, or none, as held, else shared with one asked before it, else
143
+ // looked for where it is kept, else worked out and kept: shared from before
144
+ // it is looked for, lest those asked together each look, find none, and work
145
+ // it out.
146
+ export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null = null): Promise<NpmDelta> {
147
+ const key = `${base?.integrity ?? '(empty)'}\0${other.integrity}`
148
+ const known = deltas.get(key)
149
+ if (known) return Promise.resolve(known)
150
+ const shared = working.get(key)
151
+ if (shared) return shared
152
+ const work = worked(key, base, other, kept).finally(() => working.delete(key))
153
+ working.set(key, work)
154
+ return work
155
+ }
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
+
173
+ // As kept, else its turn awaited, then both sides' files read, counted and kept.
174
+ async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
175
+ const known = deltaOf((await kept?.read(key))?.value)
176
+ if (known) { deltas.set(key, known); return known }
177
+ await turn(Date.now() + DELTA_TIMEOUT_MS)
178
+ let binary, told
179
+ try {
180
+ // Its turn held till both sides are read, or have failed.
181
+ const [baseRead, otherRead] = await Promise.allSettled([base ? base.files() : Promise.resolve(null), other.files()])
182
+ if (baseRead.status === 'rejected') throw baseRead.reason
183
+ if (otherRead.status === 'rejected') throw otherRead.reason
184
+ const [olds, news] = [texts(baseRead.value), texts(otherRead.value)]
185
+ if (olds.length + news.length > MAX_TEXT_LENGTH) throw new NpmPackageError(413, 'package-too-large')
186
+ binary = binaryChanged(olds.binaries, news.binaries)
187
+ told = await counted(olds.read, news.read, Date.now() + DELTA_TIMEOUT_MS)
188
+ } finally { done() }
189
+ const delta = { additions: told.additions, deletions: told.deletions,
190
+ typeless: told.typeFiles > 0 ? { additions: told.additions - told.typeAdditions, deletions: told.deletions - told.typeDeletions } : null, binary }
191
+ deltas.set(key, delta)
192
+ await kept?.write(key, delta, Date.now())
193
+ return delta
194
+ }