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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/client/dependency-paths.d.ts +2 -1
  2. package/client/dependency-paths.js +16 -4
  3. package/common/managed/agent-tasks.ts +12 -12
  4. package/common/managed/github-agents.ts +29 -10
  5. package/out/activity.js +4 -0
  6. package/out/chunk-6LNQTKAV.js +52 -0
  7. package/out/chunk-7BKKYVWR.js +7 -0
  8. package/out/chunk-7VAWINQF.js +6 -0
  9. package/out/chunk-C2AVODGZ.js +36 -0
  10. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  11. package/out/chunk-MOACFRJ4.js +3 -0
  12. package/out/chunk-O6M574JI.js +1 -0
  13. package/out/chunk-PBZPYIG7.js +1 -0
  14. package/out/chunk-PENGGKX7.js +233 -0
  15. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  16. package/out/chunk-SZD5CW5R.js +3 -0
  17. package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
  18. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  19. package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
  20. package/out/chunk-X6PJAK3T.js +1 -0
  21. package/out/client-managed-import.js +1 -1
  22. package/out/client-managed.js +26 -26
  23. package/out/client-sync.js +2 -2
  24. package/out/compare.js +14 -14
  25. package/out/dialogs.js +40 -40
  26. package/out/graph.js +11 -11
  27. package/out/index.html +12 -12
  28. package/out/scan.js +2 -2
  29. package/out/terminal.js +215 -215
  30. package/out/view.css +1 -1
  31. package/out/view.js +15 -15
  32. package/package.json +9 -5
  33. package/risk/index.d.ts +4 -2
  34. package/risk/npm/assess.js +15 -4
  35. package/risk/npm/registry.js +6 -1
  36. package/risk/npm/score.js +1 -1
  37. package/server-managed/activity.ts +24 -11
  38. package/server-managed/agent-merges.ts +10 -8
  39. package/server-managed/app-agents.ts +8 -21
  40. package/server-managed/bundle-build-worker.js +7 -3
  41. package/server-managed/bundle-build.ts +5 -3
  42. package/server-managed/bundle-cache.ts +9 -3
  43. package/server-managed/bundle.ts +3 -4
  44. package/server-managed/cache-storage.ts +3 -2
  45. package/server-managed/commit-coauthors.ts +11 -10
  46. package/server-managed/commit-lockfiles.ts +22 -21
  47. package/server-managed/db-methods.ts +338 -187
  48. package/server-managed/db-neon.ts +31 -8
  49. package/server-managed/db.ts +2 -0
  50. package/server-managed/dependabot-access.ts +1 -1
  51. package/server-managed/dependency-review-comments.ts +7 -4
  52. package/server-managed/dependency-reviews.ts +71 -33
  53. package/server-managed/github-accounts.ts +28 -1
  54. package/server-managed/github-app.ts +27 -23
  55. package/server-managed/github-graphql.ts +39 -0
  56. package/server-managed/github-issue-links.ts +8 -1
  57. package/server-managed/github-metadata.ts +2 -2
  58. package/server-managed/http-response.ts +28 -10
  59. package/server-managed/http.ts +736 -247
  60. package/server-managed/index.ts +2 -2
  61. package/server-managed/link-reports.ts +5 -1
  62. package/server-managed/managed-issues.ts +3 -0
  63. package/server-managed/npm-delta.ts +16 -0
  64. package/server-managed/npm-kept.ts +51 -10
  65. package/server-managed/npm-loads.ts +48 -28
  66. package/server-managed/npm-manifest.ts +1 -1
  67. package/server-managed/npm-packages.ts +31 -15
  68. package/server-managed/npm-risk.ts +64 -17
  69. package/server-managed/object-storage.ts +4 -3
  70. package/server-managed/pretty-print.ts +45 -12
  71. package/server-managed/public-workspace.ts +3 -3
  72. package/server-managed/pull-dependencies.ts +84 -36
  73. package/server-managed/pull-dependency-reviews.ts +13 -10
  74. package/server-managed/rename-diff-worker.js +1 -1
  75. package/server-managed/report-store.ts +16 -11
  76. package/server-managed/repository-agents.ts +9 -6
  77. package/server-managed/repository-browser.ts +17 -5
  78. package/server-managed/repository-code.ts +180 -17
  79. package/server-managed/repository-dependency-reviews.ts +7 -0
  80. package/server-managed/repository-history.ts +85 -152
  81. package/server-managed/repository-issues.ts +27 -13
  82. package/server-managed/repository-pulse.ts +9 -3
  83. package/server-managed/repository-squashes.ts +145 -0
  84. package/server-managed/request-timing.ts +63 -0
  85. package/server-managed/reviewed-dependencies.ts +3 -2
  86. package/server-managed/session.ts +34 -12
  87. package/server-managed/solidity-entry-points.ts +5 -3
  88. package/server-managed/sql-postgres.ts +3 -0
  89. package/server-managed/sql.ts +17 -8
  90. package/server-managed/storage-db.ts +11 -3
  91. package/server-managed/storage-encryption.ts +23 -12
  92. package/server-managed/storage-maintenance.ts +5 -2
  93. package/server-managed/storage-payload.ts +18 -0
  94. package/server-managed/storage-stores.ts +3 -2
  95. package/server-managed/storage.ts +4 -2
  96. package/server-managed/team-feed.ts +19 -12
  97. package/server-managed/team-npm-scopes.ts +29 -0
  98. package/server-managed/team-reports.ts +74 -17
  99. package/server-managed/uploads.ts +29 -9
  100. package/out/chunk-4UFAMJAX.js +0 -1
  101. package/out/chunk-BOIXMUU5.js +0 -3
  102. package/out/chunk-CC4OM6TZ.js +0 -3
  103. package/out/chunk-JKGQ3O7O.js +0 -6
  104. package/out/chunk-LE5ZWLG4.js +0 -235
  105. package/out/chunk-MSION3G4.js +0 -36
  106. package/out/chunk-S6GIXWQW.js +0 -52
  107. package/out/chunk-V3KWNNMY.js +0 -1
  108. package/out/chunk-WQ74ZBZF.js +0 -7
@@ -1,4 +1,4 @@
1
- import { DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
1
+ import { DEPENDENCY_REVIEW_COLUMNS, DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
2
2
  import { LINK_REPORT_SCHEMA } from './link-reports.ts'
3
3
  import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
4
4
  import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
@@ -17,6 +17,7 @@ import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
17
17
  import { COMMENT_SCHEMA } from './comments.ts'
18
18
  import { ACTIVITY_SCHEMA, uploadAction } from './activity.ts'
19
19
  import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
20
+ import { countStatement, timed } from './request-timing.ts'
20
21
  import { postgresSchema, postgresSql } from './sql-postgres.ts'
21
22
  import { managedTableRenames } from './db-table-names.ts'
22
23
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
@@ -35,11 +36,18 @@ export type PgConnect = () => Promise<PgConnection>
35
36
  // presence updates, and maintenance leases do not take it. Also fences migrations.
36
37
  const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
37
38
 
39
+ // Whether every version up to the latest is recorded, in one round trip at
40
+ // a cold start: a database with no versions table yet (undefined_table) has
41
+ // none. Outside a transaction, so the failed read aborts nothing.
42
+ const SCHEMA_VERSIONS = 32
38
43
  async function currentSchema(db: PgConnection): Promise<boolean> {
39
- const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
40
- if (!exists) return false
41
- const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
42
- return Array.from({ length: 31 }, (_, i) => i + 1).every(version => versions.has(version))
44
+ try {
45
+ const { rows } = await db.query(`SELECT count(*) AS n FROM managed_schema_version WHERE version BETWEEN 1 AND ${SCHEMA_VERSIONS}`)
46
+ return Number(rows[0]?.['n']) === SCHEMA_VERSIONS
47
+ } catch (error) {
48
+ if ((error as { code?: unknown })?.code === '42P01') return false
49
+ throw error
50
+ }
43
51
  }
44
52
 
45
53
  async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
@@ -176,6 +184,14 @@ async function migrateRepoUploadIndexes(db: PgConnection): Promise<void> {
176
184
  await db.query('INSERT INTO managed_schema_version VALUES (31)')
177
185
  }
178
186
 
187
+ // The shasums of the tarballs a dependency review reads, none known of
188
+ // those written before.
189
+ async function migrateDependencyReviewShasums(db: PgConnection): Promise<void> {
190
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 32')).rows.length > 0) return
191
+ await db.query(`ALTER TABLE managed_dependency_review ${DEPENDENCY_REVIEW_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
192
+ await db.query('INSERT INTO managed_schema_version VALUES (32)')
193
+ }
194
+
179
195
  async function initialize(db: PgConnection): Promise<void> {
180
196
  if (await currentSchema(db)) return
181
197
  await db.query('BEGIN')
@@ -250,7 +266,7 @@ async function initialize(db: PgConnection): Promise<void> {
250
266
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
251
267
  }
252
268
  for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
253
- migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes]) await migrate(db)
269
+ migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes, migrateDependencyReviewShasums]) await migrate(db)
254
270
  await db.query('COMMIT')
255
271
  } catch (err) {
256
272
  await db.query('ROLLBACK')
@@ -318,7 +334,12 @@ function requestConnections(connect: PgConnect) {
318
334
  }
319
335
 
320
336
  // Exposed for parity tests against real PostgreSQL semantics via PGlite.
321
- export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
337
+ export async function openPostgresManagedDb(leased: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
338
+ // Each statement told in its request's Server-Timing (request-timing.ts).
339
+ const connect: PgConnect = async () => {
340
+ const connection = await leased()
341
+ return { query: (sql, params) => { countStatement(); return connection.query(sql, params) }, release: discard => connection.release(discard) }
342
+ }
322
343
  const initial = await connect()
323
344
  try { await initialize(initial) } catch (err) { await initial.release(true).catch(() => {}); throw err }
324
345
  await initial.release()
@@ -424,7 +445,9 @@ export async function openNeonManagedDb(url: string, options: ManagedDbOptions =
424
445
  let db: ManagedDb
425
446
  try {
426
447
  db = await openPostgresManagedDb(async () => {
427
- const client = await pool.connect()
448
+ // A lease's wait told in its request's Server-Timing: a new connection's
449
+ // TLS, WebSocket and Postgres handshake, where the pool had none idle.
450
+ const client = await timed('conn', () => pool.connect())
428
451
  if (!watched.has(client)) { watched.add(client); client.on('error', () => { failed.add(client) }) }
429
452
  return { query: (sql, params) => client.query(sql, params),
430
453
  release: discard => Promise.try(() => client.release(discard === true || failed.has(client))) }
@@ -10,6 +10,7 @@ import { revisionSchema } from './revisions.ts'
10
10
  import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
11
11
  import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
12
12
  import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
13
+ import { DEPENDENCY_REVIEW_COLUMNS } from './dependency-reviews.ts'
13
14
  import { STORAGE_SCHEMA } from './storage-db.ts'
14
15
  import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
15
16
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
@@ -124,6 +125,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
124
125
  ensureColumn(db, 'managed_finding_issue', 'auto_fix_checked_at', 'INTEGER')
125
126
  ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
126
127
  for (const [column, type] of REPOSITORY_DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_selected_repo', column, type)
128
+ for (const [column, type] of DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_dependency_review', column, type)
127
129
  ensureColumn(db, 'managed_finding_triage_event', 'report_id', 'TEXT')
128
130
  ensureColumn(db, 'managed_finding_triage_event', 'report', 'TEXT')
129
131
  ensureColumn(db, 'managed_finding_triage_event', 'repo', 'TEXT')
@@ -1,6 +1,6 @@
1
1
  // A repository's Dependabot alerts for a repository risk scan, read with the
2
2
  // App's installation token where the admin's own access lets them see them on
3
- // GitHub, as the README's repository risk section tells.
3
+ // GitHub, as doc/bundles.md's repository risk scan section tells.
4
4
  import type { ManagedConfig } from './config.ts'
5
5
  import type { SelectedRepo } from './db.ts'
6
6
  import { type DependabotAlert, dependabotAlerts } from './dependabot-alerts.ts'
@@ -18,11 +18,14 @@ const MAX_FORMATTED = 1_000_000
18
18
  // read after, every one formatted.
19
19
  const REVIEWS_AT_ONCE = 200
20
20
 
21
- // `review` as the hook is given it, the version it is of as `locked`, a
22
- // pull request's lockfile, locks it where it is one's.
21
+ // `review` as the hook is given it: each version with the integrity and
22
+ // shasum the review keeps of the tarball it read, and the version it is of
23
+ // with the patch `locked`, a pull request's lockfile, locks it with, where it
24
+ // is one's.
23
25
  function entryOf(review: DependencyReview, locked?: AddedPackage | undefined): DependencyReviewEntry {
24
- return { ecosystem: 'npm', old: review.basePackage === null ? null : { name: review.basePackage, version: review.baseVersion ?? '', integrity: review.baseIntegrity ?? '' },
25
- new: { name: review.package, version: review.version, integrity: review.integrity, ...locked?.shasum ? { shasum: locked.shasum } : {},
26
+ return { ecosystem: 'npm', old: review.basePackage === null ? null : { name: review.basePackage, version: review.baseVersion ?? '', integrity: review.baseIntegrity ?? '',
27
+ ...review.baseShasum ? { shasum: review.baseShasum } : {} },
28
+ new: { name: review.package, version: review.version, integrity: review.integrity, ...review.shasum ? { shasum: review.shasum } : {},
26
29
  ...locked?.patchHash ? { patchHash: locked.patchHash } : {} },
27
30
  status: review.status!, coverage: review.coverage === 'full' ? 'full' : 'partial', comment: review.comment,
28
31
  files: review.entries.filter(entry => entry.status !== null || entry.comment.trim() !== '').map(entry => ({ path: entry.file, status: entry.status, comment: entry.comment })),
@@ -7,11 +7,15 @@ export interface ReviewContext {
7
7
  ecosystem: 'npm'; package: string; version: string; integrity: string
8
8
  basePackage: string | null; baseVersion: string | null; baseIntegrity: string | null
9
9
  }
10
+ // The shasums (sha1s, in hex) of the tarballs a review reads, its version's
11
+ // and its base's, where npm tells them: each held, with its integrity, to the
12
+ // tarball its reviewer was shown, as a review is written (http.ts).
13
+ export interface ReviewedTarballs { shasum: string | null; baseShasum: string | null }
10
14
  export interface ReviewInput { file: string; comment: string; status: ReviewVerdict | null }
11
15
  export interface ReviewEntry extends ReviewInput { id: string; time: number }
12
16
  export interface ReviewTeam { id: string; name: string }
13
- export interface DependencyReview extends ReviewContext, ReviewSummary {
14
- id: string; user: string | null; userLogin: string; time: number; revision: number
17
+ export interface DependencyReview extends ReviewContext, ReviewedTarballs, ReviewSummary {
18
+ id: string; user: string | null; userLogin: string; userName: string | null; time: number; revision: number
15
19
  publishedAt: number | null; revokedAt: number | null; entries: ReviewEntry[]; teams: ReviewTeam[]; files: string[] | null
16
20
  nonTextFiles: string[] | null
17
21
  status: ReviewVerdict | null; comment: string
@@ -30,14 +34,15 @@ export interface DependencyReviewStore {
30
34
  // The reviews published to team `teamId`, revoked ones too, the latest
31
35
  // published first, a page on from `before` (when it was published, and
32
36
  // its id), for one who reads them there (reviewsIn); null for another.
37
+ // In one statement.
33
38
  listTeamDependencyReviews(teamId: string, user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null } | null>
34
- // Whether `user` reads the reviews published to team `teamId` (reviewsIn).
35
- readsTeamDependencyReviews(teamId: string, user: string): Promise<boolean>
36
39
  listDependencyReviewTeams(user: string): Promise<ReviewTeam[]>
37
40
  listDependencyReviewEnabledTeams(): Promise<string[]>
38
41
  setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
39
42
  discardDependencyReview(sessionId: string, reviewId: string, revision: number): Promise<void>
40
- changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number): Promise<DependencyReview>
43
+ // Written with the shasums of the tarballs read (`tarballs`), which a
44
+ // draft keeps as it is begun, saved and published; none where not told.
45
+ changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number, tarballs?: ReviewedTarballs): Promise<DependencyReview>
41
46
  }
42
47
 
43
48
  // An unpublished review belongs only to its author. Publication freezes all
@@ -51,7 +56,8 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review (
51
56
  package TEXT NOT NULL, version TEXT NOT NULL, integrity TEXT NOT NULL,
52
57
  base_package TEXT NOT NULL, base_version TEXT NOT NULL, base_integrity TEXT NOT NULL,
53
58
  time INTEGER NOT NULL, revision INTEGER NOT NULL, published_at INTEGER, revoked_at INTEGER, files TEXT,
54
- status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT ''
59
+ status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT '',
60
+ shasum TEXT NOT NULL DEFAULT '', base_shasum TEXT NOT NULL DEFAULT ''
55
61
  ) STRICT;
56
62
  CREATE INDEX IF NOT EXISTS managed_dependency_review_context_idx
57
63
  ON managed_dependency_review(ecosystem, package, version, integrity, base_package, base_version, base_integrity, time);
@@ -72,13 +78,19 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
72
78
  ) STRICT;
73
79
  CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id);
74
80
  `
81
+ // The columns added since the table was, for a database that has it already.
82
+ export const DEPENDENCY_REVIEW_COLUMNS = [['shasum', "TEXT NOT NULL DEFAULT ''"], ['base_shasum', "TEXT NOT NULL DEFAULT ''"]] as const
75
83
  const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
76
84
  const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
77
85
  const contextWhere = `${targetWhere} AND base_package = ? AND base_version = ? AND base_integrity = ?`
78
- const columns = `id, user_id AS user, user_login AS userLogin, ecosystem, package, version, integrity,
86
+ // Its author by the login they reviewed as, and by the name the app's user
87
+ // has now (null once they are none, or have none), as the lists show them.
88
+ const columns = `id, user_id AS user, user_login AS userLogin, (SELECT n.name FROM managed_user n WHERE n.id = user_id) AS userName, ecosystem, package, version, integrity,
79
89
  NULLIF(base_package, '') AS basePackage, NULLIF(base_version, '') AS baseVersion, NULLIF(base_integrity, '') AS baseIntegrity,
90
+ NULLIF(shasum, '') AS shasum, NULLIF(base_shasum, '') AS baseShasum,
80
91
  time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
81
92
  const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
93
+ const shasums = (tarballs: ReviewedTarballs | undefined) => [tarballs?.shasum ?? '', tarballs?.baseShasum ?? '']
82
94
 
83
95
  // Who reviews dependencies in team `t`, user `u` as the query joins them: an
84
96
  // admin, in any team; a member who manages, in theirs; a member who triages,
@@ -124,8 +136,9 @@ async function requireReviewer(db: ManagedSql, user: string) {
124
136
  if ((await teams(db, user)).length === 0) throw new ManagedMutationError(403, 'review-access-required')
125
137
  }
126
138
 
139
+ type ReviewRow = Omit<DependencyReview, 'files' | 'entries' | 'teams' | keyof ReviewSummary> & { files: string | null }
127
140
  async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
128
- const reviews = rows as (Omit<DependencyReview, 'files'> & { files: string | null })[]
141
+ const reviews = rows as ReviewRow[]
129
142
  if (reviews.length === 0) return []
130
143
  const ids = reviews.map(review => review.id), placeholders = ids.map(() => '?').join(',')
131
144
  const entries = await db.prepare(`SELECT review_id AS reviewId, id, file, comment, status, time
@@ -145,16 +158,24 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
145
158
  return of
146
159
  }
147
160
  const [entriesOf, teamsOf] = [byReview(entries), byReview(linked)]
148
- return reviews.map(review => {
149
- const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
150
- const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
151
- const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
152
- const ownEntries = fileEntries((entriesOf.get(review.id) ?? []) as ReviewEntry[])
153
- return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: (teamsOf.get(review.id) ?? []) as ReviewTeam[] }
154
- })
161
+ return reviews.map(review => expanded(review, (entriesOf.get(review.id) ?? []) as ReviewEntry[], (teamsOf.get(review.id) ?? []) as ReviewTeam[]))
155
162
  }
163
+ // A review as its row, its entries and the teams its reader reads it in
164
+ // tell it: its files' snapshot, its entries a file's each (fileEntries), and
165
+ // what they sum up to.
166
+ function expanded(review: ReviewRow, entries: ReviewEntry[], readIn: ReviewTeam[]): DependencyReview {
167
+ const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
168
+ const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
169
+ const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
170
+ const ownEntries = fileEntries(entries)
171
+ return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: readIn }
172
+ }
173
+ // Rows gathered into one JSON array value (json_group_array): its text on
174
+ // SQLite, parsed already on Postgres, where none gather as NULL.
175
+ export const gathered = <T>(value: unknown): T[] => value == null ? [] : (typeof value === 'string' ? JSON.parse(value) : value) as T[]
156
176
 
157
- async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number) {
177
+ async function publish(db: ManagedSql, current: DependencyReview | undefined, user: string, change: Extract<ReviewChange, { action: 'publish' }>, now: number,
178
+ tarballs: ReviewedTarballs | undefined) {
158
179
  if (!current || current.revokedAt !== null) return conflict()
159
180
  const allowed = new Set((await teams(db, user)).map(team => team.id))
160
181
  const selected = [...new Set(change.teamIds)]
@@ -164,8 +185,9 @@ async function publish(db: ManagedSql, current: DependencyReview | undefined, us
164
185
  if (!summarizeReview(entries, change.files, change.nonTextFiles).checkedFiles) throw new ManagedMutationError(400, 'no-reviewed-files')
165
186
  if (!change.final || !allowedReviewVerdicts(entries.filter(entry => !change.nonTextFiles.includes(entry.file))).includes(change.final.status)) throw new ManagedMutationError(400, 'bad-final-verdict')
166
187
  if (needsPackageJsonReview(entries, change.files, change.nonTextFiles)) throw new ManagedMutationError(400, 'package-json-not-reviewed')
167
- await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, revision = revision + 1 WHERE id = ?`)
168
- .run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }), change.final.status, change.final.comment, current.id)
188
+ await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, shasum = ?, base_shasum = ?, revision = revision + 1
189
+ WHERE id = ?`).run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }),
190
+ change.final.status, change.final.comment, ...shasums(tarballs), current.id)
169
191
  } else {
170
192
  if (change.final) return conflict()
171
193
  await db.prepare('UPDATE managed_dependency_review SET revision = revision + 1 WHERE id = ?').run(current.id)
@@ -193,17 +215,34 @@ async function listContext(db: ManagedSql, context: ReviewContext, user: string,
193
215
  return expandReviews(db, rows, user)
194
216
  }
195
217
 
196
- const readsTeam = async (db: ManagedSql, teamId: string, user: string) =>
197
- Boolean(await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}`).get(user, teamId))
198
-
218
+ // A team's page of reviews in one statement: a row of its reader `v` where
219
+ // they read them there (reviewsIn), none for another, with each review on
220
+ // a row of its own, its entries and the teams `v` reads it in (as
221
+ // expandReviews reads them) each one JSON array.
199
222
  async function listTeam(db: ManagedSql, teamId: string, user: string, before: { time: number; id: string } | null) {
200
- if (!await readsTeam(db, teamId, user)) return null
201
- const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
202
- WHERE rt.team_id = ? AND r.published_at IS NOT NULL ${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
203
- ORDER BY r.published_at DESC, r.id DESC LIMIT 51`).all(teamId, ...before ? [before.time, before.time, before.id] : []) as DependencyReview[]
204
- const page = rows.slice(0, 50)
223
+ const rows = await db.prepare(`WITH reader AS (
224
+ SELECT u.id AS id FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}
225
+ ), page AS (
226
+ SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
227
+ WHERE rt.team_id = ? AND r.published_at IS NOT NULL AND EXISTS (SELECT 1 FROM reader)
228
+ ${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
229
+ ORDER BY r.published_at DESC, r.id DESC LIMIT 51
230
+ )
231
+ SELECT v.id AS reader, p.*,
232
+ (SELECT json_group_array(json_object('id', e.id, 'file', e.file, 'comment', e.comment, 'status', e.status, 'time', e.time) ORDER BY e.time, e.id)
233
+ FROM managed_dependency_file_review e WHERE e.review_id = p.id) AS entries,
234
+ (SELECT json_group_array(json_object('id', t.id, 'name', t.name) ORDER BY t.name, t.id)
235
+ FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = v.id
236
+ WHERE rt.review_id = p.id AND (p.user = u.id OR (${reviewsIn(false)}))) AS teams
237
+ FROM reader v LEFT JOIN page p ON 1 = 1
238
+ ORDER BY p.publishedAt DESC, p.id DESC`).all(user, teamId, teamId, ...before ? [before.time, before.time, before.id] : []) as
239
+ (ReviewRow & { reader: string; entries: unknown; teams: unknown })[]
240
+ if (rows.length === 0) return null
241
+ const reviews = rows.filter(row => row.id != null)
242
+ const page = reviews.slice(0, 50)
205
243
  const last = page.at(-1)
206
- return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
244
+ return { reviews: page.map(({ reader: _reader, entries, teams: readIn, ...review }) => expanded(review, gathered(entries), gathered(readIn))),
245
+ next: reviews.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
207
246
  }
208
247
 
209
248
  async function saveFile(db: ManagedSql, reviewId: string, entry: ReviewInput, requestedId: string | null, now: number) {
@@ -236,7 +275,6 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
236
275
  ORDER BY time, id`).all(user, ...ids), user)
237
276
  },
238
277
  listTeamDependencyReviews: (teamId, user, before) => listTeam(db, teamId, user, before),
239
- readsTeamDependencyReviews: (teamId, user) => readsTeam(db, teamId, user),
240
278
  async getLatestOwnDependencyReview(user, target) {
241
279
  const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
242
280
  WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
@@ -258,7 +296,7 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
258
296
  },
259
297
  listDependencyReviewTeams: user => teams(db, user),
260
298
  listDependencyReviews: (context, user, allBases = false) => listContext(db, context, user, allBases),
261
- async changeDependencyReview(context, sessionId, change, now) {
299
+ async changeDependencyReview(context, sessionId, change, now, tarballs) {
262
300
  const user = await actor(db, sessionId)
263
301
  if (change.action !== 'revoke' && change.action !== 'publish') await requireReviewer(db, user.id)
264
302
  let id = change.reviewId
@@ -274,8 +312,8 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
274
312
  if (await unpublished()) return conflict()
275
313
  const next = randomUUID()
276
314
  await db.prepare(`INSERT INTO managed_dependency_review
277
- (id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, time, revision)
278
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), now)
315
+ (id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, shasum, base_shasum, time, revision)
316
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), ...shasums(tarballs), now)
279
317
  return next
280
318
  }
281
319
  if (change.action === 'start') {
@@ -294,11 +332,11 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
294
332
  if (!current || current.publishedAt === null || current.revokedAt !== null) return conflict()
295
333
  await db.prepare('UPDATE managed_dependency_review SET revoked_at = ?, revision = revision + 1 WHERE id = ?').run(now, id)
296
334
  } else if (change.action === 'publish') {
297
- await publish(db, current, user.id, change, now)
335
+ await publish(db, current, user.id, change, now, tarballs)
298
336
  } else {
299
337
  if (current?.publishedAt != null) return conflict()
300
338
  if (change.entry.status === null && !change.entryId) throw new ManagedMutationError(400, 'bad-review')
301
- if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, revision = revision + 1 WHERE id = ?').run(now, id)
339
+ if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, shasum = ?, base_shasum = ?, revision = revision + 1 WHERE id = ?').run(now, ...shasums(tarballs), id)
302
340
  else { if (change.entryId) return conflict(); id = await create() }
303
341
  await saveFile(db, id, change.entry, change.entryId, now)
304
342
  }
@@ -1,6 +1,8 @@
1
1
  // GitHub accounts as GitHub's REST and GraphQL APIs name them, where a
2
2
  // repository's activity names its authors (repository-history.ts and the
3
3
  // Commits, Issues, Agents and Pulse tabs' readers).
4
+ import { githubGraphql } from './github-graphql.ts'
5
+
4
6
  const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
5
7
 
6
8
  // An account as GitHub names it: its login, else null, and its numeric id
@@ -23,7 +25,8 @@ export const actorOf = (actor: GraphAccount) => accountOf({ login: actor?.login,
23
25
  // GitHub knows its address for; else, as GitHub's REST API does, the account
24
26
  // whose noreply address its address is, `<id>+<login>@users.noreply.github.com`:
25
27
  // a GitHub App's bot's, which is no user (GraphQL's `user` is a User's, never
26
- // a bot's).
28
+ // a bot's). One by GitHub's older no-reply address, `<login>@users.noreply.github.com`,
29
+ // naming no id, its user looked up by its login (repository-history.ts linkNoreplyUsers).
27
30
  const NOREPLY = /^([1-9]\d{0,11})\+([^@]+)@users\.noreply\.github\.com$/iu
28
31
  export type GraphActor = { email?: unknown; user?: GraphAccount | null }
29
32
  export function commitAccount(author: GraphActor | null | undefined): NamedAccount {
@@ -31,3 +34,27 @@ export function commitAccount(author: GraphActor | null | undefined): NamedAccou
31
34
  const noreply = user.id === null && typeof author?.email === 'string' ? NOREPLY.exec(author.email) : null
32
35
  return noreply ? accountOf({ login: noreply[2], id: Number(noreply[1]) }) : user
33
36
  }
37
+
38
+ // GitHub's no-reply address of old, `<login>@users.noreply.github.com`, which
39
+ // names no id: GitHub links the commits it authors to the account of its
40
+ // login, as its REST API names them, but its GraphQL API names no user for it.
41
+ const NOREPLY_LOGIN = /^([A-Za-z\d][A-Za-z\d-]{0,38})@users\.noreply\.github\.com$/iu
42
+ const MAX_NOREPLY_LOGINS = 100
43
+
44
+ // Commits' authors and co-authors (`actors`), as GitHub's GraphQL API names
45
+ // them, those it names no user for by such an address named the user of its
46
+ // login, as GitHub links them: those logins, as many as MAX_NOREPLY_LOGINS,
47
+ // looked up in one query, where any is.
48
+ export async function linkNoreplyUsers(actors: (GraphActor | null | undefined)[], token: string, fetchImpl: typeof fetch): Promise<void> {
49
+ const unnamed = actors.flatMap(actor => {
50
+ const login = actor && !actor.user && typeof actor.email === 'string' ? NOREPLY_LOGIN.exec(actor.email)?.[1]?.toLowerCase() : undefined
51
+ return login ? [{ actor: actor!, login }] : []
52
+ })
53
+ const logins = [...new Set(unnamed.map(({ login }) => login))].slice(0, MAX_NOREPLY_LOGINS)
54
+ if (logins.length === 0) return
55
+ const query = `query(${logins.map((_, at) => `$l${at}: String!`).join(', ')}) { ${logins.map((_, at) => `u${at}: user(login: $l${at}) { login databaseId }`).join(' ')} }`
56
+ const data = await githubGraphql(query, Object.fromEntries(logins.map((login, at) => [`l${at}`, login])), token, fetchImpl,
57
+ error => error?.type === 'NOT_FOUND') as Record<string, GraphAccount> | null
58
+ const users = new Map(logins.map((login, at) => [login, data?.[`u${at}`] ?? null]))
59
+ for (const { actor, login } of unnamed) actor.user = users.get(login) ?? null
60
+ }
@@ -8,6 +8,7 @@ import { createClient } from '@preventive/upstream/github.js'
8
8
  import type { ManagedConfig } from './config.ts'
9
9
  import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
10
10
  import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
11
+ import { timed } from './request-timing.ts'
11
12
 
12
13
  export { GithubApiError } from './github-errors.ts'
13
14
  export { onTokenRejected } from './github-requests.ts'
@@ -74,31 +75,34 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
74
75
  // GithubApiError (401 passes through for the caller to handle). Identical GETs
75
76
  // share an outstanding read through its body. POSTs only opt in for read-only
76
77
  // GraphQL queries (repository-history.ts), never for mutations or token mints.
77
- export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
78
+ export function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
78
79
  readOnly = method === 'GET'): Promise<unknown> {
79
80
  if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
80
- let res: Response
81
- const started = Date.now()
82
- try {
83
- res = await fetchImpl(url, {
84
- method,
85
- signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
86
- redirect: 'error',
87
- headers: {
88
- ...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
89
- ...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
90
- },
91
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
92
- })
93
- } catch (error) { throw githubUnreachable(error, started) }
94
- if (res.status === 401 && token) tokenRejected(token, fetchImpl)
95
- if (!res.ok) await throwGithubResponseError(res)
96
- // The answer's body is read within the same timeout: one not read whole is
97
- // GitHub not reached, as one not answered is; only one that isn't JSON is
98
- // malformed.
99
- try { return await res.json() } catch (error) {
100
- throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
101
- }
81
+ // Each call told in its request's Server-Timing (request-timing.ts).
82
+ return timed('gh', async () => {
83
+ let res: Response
84
+ const started = Date.now()
85
+ try {
86
+ res = await fetchImpl(url, {
87
+ method,
88
+ signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
89
+ redirect: 'error',
90
+ headers: {
91
+ ...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
92
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
93
+ },
94
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
95
+ })
96
+ } catch (error) { throw githubUnreachable(error, started) }
97
+ if (res.status === 401 && token) tokenRejected(token, fetchImpl)
98
+ if (!res.ok) await throwGithubResponseError(res)
99
+ // The answer's body is read within the same timeout: one not read whole is
100
+ // GitHub not reached, as one not answered is; only one that isn't JSON is
101
+ // malformed.
102
+ try { return await res.json() } catch (error) {
103
+ throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
104
+ }
105
+ })
102
106
  }
103
107
 
104
108
  // Validate one repo object into a ConnectedRepo (or null to skip), tagging it
@@ -0,0 +1,39 @@
1
+ // GitHub's GraphQL API, a query at a time, as the repository readers ask it
2
+ // (repository-history.ts, repository-agents.ts, repository-pulse.ts and the rest).
3
+ import { GithubApiError, githubJson } from './github-app.ts'
4
+
5
+ export type GraphqlError = { type?: unknown; path?: unknown; message?: unknown }
6
+
7
+ const NAMED_ERRORS = new Set(['RATE_LIMITED', 'NOT_FOUND', 'FORBIDDEN'])
8
+ // GitHub's own words for a query it stopped past its time (ten seconds):
9
+ // "Something went wrong while executing your query. This may be the result
10
+ // of a timeout, …", with no type.
11
+ const TIMED_OUT = /\btimeout\b/iu
12
+
13
+ // One GraphQL query with `token`, answered with its `data`: one GitHub answers
14
+ // with errors, but for those the caller reads past (`tolerated`, the fields
15
+ // they name then null), is rate limited, names what isn't there, is refused,
16
+ // as one a GitHub App lacks the permission for is, or a token the scope for
17
+ // (`INSUFFICIENT_SCOPES`, a sign-in's having none), took GitHub too long, or
18
+ // else is malformed, as one GitHub rejects unread is (a field or type its API
19
+ // doesn't know). GitHub's words for errors of none of those first three types
20
+ // are logged, those read past too.
21
+ export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
22
+ tolerated: (error: GraphqlError) => boolean = () => false) {
23
+ const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
24
+ const all = Array.isArray(body?.errors) ? body.errors : []
25
+ const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
26
+ if (unnamed.length > 0) {
27
+ console.warn('managed: GitHub answered a query with errors:', JSON.stringify(unnamed.slice(0, 3)
28
+ .map(error => ({ type: error?.type, message: String(error?.message).slice(0, 300), path: error?.path }))))
29
+ }
30
+ const errors = all.filter(error => !tolerated(error))
31
+ if (errors.length > 0) {
32
+ if (errors.some(error => error?.type === 'RATE_LIMITED')) throw new GithubApiError(429, 'github-rate-limited', 60)
33
+ if (errors.some(error => error?.type === 'NOT_FOUND')) throw new GithubApiError(404, 'github-not-found')
34
+ if (errors.some(error => error?.type === 'FORBIDDEN' || error?.type === 'INSUFFICIENT_SCOPES')) throw new GithubApiError(502, 'github-status-403')
35
+ if (errors.some(error => typeof error?.message === 'string' && TIMED_OUT.test(error.message))) throw new GithubApiError(502, 'github-timeout')
36
+ throw new GithubApiError(502, 'github-malformed')
37
+ }
38
+ return body?.data
39
+ }
@@ -71,8 +71,15 @@ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string,
71
71
  if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
72
72
  const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
73
73
  if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
74
+ // Only a changed derived Fix is written: an unchanged one's check time
75
+ // alone would bump the annotations revision that every open team feed
76
+ // polls, for nothing they show. Changes still stamp it, so an older lookup
77
+ // never replaces a newer change (managed-issues.ts). An older lookup that
78
+ // differs can now land after a newer unchanged one, as it could already
79
+ // land before it (whose compare-and-set then fails); either way the next
80
+ // lookup finds the stored Fix differs, and writes the current one.
74
81
  for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
75
- updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
82
+ if (issue.autoFixUrl !== next) updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
76
83
  }
77
84
  }
78
85
  return updates
@@ -77,8 +77,8 @@ CREATE TABLE IF NOT EXISTS managed_github_repository_visibility (
77
77
  `
78
78
  // Bundles show their commit's details and tags from these tables. Readers rely
79
79
  // on bundle access alone; only a GitHub read made with access fills them.
80
- // Commits are retained without eviction. Create a bundle refreshes a
81
- // repository's tags, which go with the repository.
80
+ // Commits are retained without eviction. Every refs read of a repository
81
+ // (Create a bundle's, the code viewer's) refreshes its tags, which go with it.
82
82
  export const GITHUB_COMMIT_SCHEMA = `
83
83
  CREATE TABLE IF NOT EXISTS managed_github_commit (
84
84
  cache_key TEXT PRIMARY KEY,
@@ -38,21 +38,39 @@ export function sendJson(res: ServerResponse, status: number, body: unknown, hea
38
38
  // before a line, has the response cut short, as is one whose lines fail: a
39
39
  // response that ends is one with every line.
40
40
  export async function sendLines(res: ServerResponse, lines: AsyncIterable<unknown>, allowed: () => Promise<boolean>) {
41
- res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
42
- const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
43
- const sent = pipeline(encoder, res).catch(() => {})
44
- const closed = new Promise(resolve => { res.once('close', resolve) })
41
+ const out = openLines(res)
45
42
  try {
46
43
  for await (const line of lines) {
47
- if (res.destroyed || !await allowed()) { res.destroy(); return }
48
- encoder.write(`${JSON.stringify(line)}\n`)
49
- await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
44
+ if (res.destroyed || !await allowed()) { out.cut(); return }
45
+ await out.write(line)
50
46
  }
51
47
  } catch (err) {
52
48
  console.warn('managed: a streamed response failed:', err)
53
- res.destroy()
49
+ out.cut()
54
50
  return
55
51
  }
56
- encoder.end()
57
- await sent
52
+ await out.end()
53
+ }
54
+
55
+ // A response of lines of JSON as sendLines sends them, written as its caller
56
+ // has each: `write` sends one, flushed, false where the reader is gone; `end`
57
+ // ends it with every line; `cut` ends it short.
58
+ export function openLines(res: ServerResponse) {
59
+ res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
60
+ const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
61
+ const sent = pipeline(encoder, res).catch(() => {})
62
+ const closed = new Promise(resolve => { res.once('close', resolve) })
63
+ return {
64
+ async write(line: unknown): Promise<boolean> {
65
+ if (res.destroyed) return false
66
+ encoder.write(`${JSON.stringify(line)}\n`)
67
+ await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
68
+ return !res.destroyed
69
+ },
70
+ async end() {
71
+ encoder.end()
72
+ await sent
73
+ },
74
+ cut() { res.destroy() },
75
+ }
58
76
  }