@preventive/triage 1.0.0-alpha.30 → 1.0.0-alpha.32

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.30",
3
+ "version": "1.0.0-alpha.32",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": {
@@ -265,8 +265,8 @@
265
265
  "dependencies": {
266
266
  "@exodus/stasis": "1.0.0-beta.5",
267
267
  "@exodus/stasis-core": "1.0.0-beta.5",
268
- "@preventive/report": "1.0.0-alpha.4",
269
- "@preventive/upstream": "1.0.0-alpha.4",
268
+ "@preventive/report": "1.0.0-alpha.5",
269
+ "@preventive/upstream": "1.0.0-alpha.7",
270
270
  "ws": "^8.20.1"
271
271
  },
272
272
  "peerDependencies": {
@@ -44,6 +44,7 @@ export type VercelBlobSdk = {
44
44
  access: 'private' | 'public'
45
45
  token?: string
46
46
  useCache?: boolean
47
+ headers?: Record<string, string>
47
48
  abortSignal?: AbortSignal
48
49
  },
49
50
  ) => Promise<{
@@ -21,10 +21,13 @@ const REAP_RETRY_MS = 60_000
21
21
  type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
22
22
 
23
23
  export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
24
- return await initializeApp(rollback => assembleManagedApp(config, options, rollback))
24
+ // Load the optional peer only for serverless managed deployments. Embedding
25
+ // hosts can discard listener promises, so retain maintenance inside the app.
26
+ const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
27
+ return await initializeApp(rollback => assembleManagedApp(config, options, rollback, waitUntil))
25
28
  }
26
29
 
27
- async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack) {
30
+ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
28
31
  const storage = await openManagedStorage(config)
29
32
  const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache } = storage
30
33
  rollback.defer(() => db.close())
@@ -87,10 +90,9 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
87
90
  return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
88
91
  }
89
92
  async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
90
- const pendingMaintenance = automaticReap()
91
- // Send the normal response without waiting for storage housekeeping. The
92
- // Vercel entrypoint registers this whole promise with waitUntil; awaiting
93
- // work after res.end() alone cannot extend a serverless invocation.
93
+ // Register before routing can end the response, including when init()'s
94
+ // listener is mounted in Fastify rather than using api/managed.ts.
95
+ const pendingMaintenance = automaticReap(); waitUntil?.(pendingMaintenance)
94
96
  try { await routeRequest(req, res) }
95
97
  finally { await pendingMaintenance }
96
98
  }
@@ -16,7 +16,10 @@ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk
16
16
  },
17
17
  async open(key, signal) {
18
18
  try {
19
- const result = await blobs.get(path(key), { token, access: 'private', useCache: false, ...(signal ? { abortSignal: signal } : {}) })
19
+ // Read the stored representation: transport compression can weaken its
20
+ // ETag, which cannot satisfy ifMatch, and changes the advertised size.
21
+ const result = await blobs.get(path(key), { token, access: 'private', useCache: false,
22
+ headers: { 'accept-encoding': 'identity' }, ...(signal ? { abortSignal: signal } : {}) })
20
23
  if (!result) return null
21
24
  if (result.statusCode !== 200 || !result.stream || !result.blob.etag) {
22
25
  await result.stream?.cancel()
@@ -16,6 +16,8 @@ export interface RawObjectStorage {
16
16
  // Persist an observed disk replacement before its SQL migration checkpoint.
17
17
  sync?(key: string, signal?: AbortSignal): Promise<void>
18
18
  // A plaintext size hint selects multipart uploads without buffering streams.
19
+ // Publish atomically only after consuming the body through clean EOF; a body
20
+ // error must preserve the existing object (migration verifies while streaming).
19
21
  put(key: string, bytes: Buffer | Readable, signal?: AbortSignal, expected?: string, sizeHint?: number): Promise<boolean>
20
22
  // Return false if the original version changed; do not delete its replacement.
21
23
  delete(key: string, version?: string, signal?: AbortSignal): Promise<boolean>
@@ -2,7 +2,7 @@ import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../
2
2
  import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
3
3
  import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, unwrapDataKey } from './storage-db.ts'
4
4
  import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
5
- import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
5
+ import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifiedStoragePayload, verifyStoragePayload } from './storage-payload.ts'
6
6
 
7
7
  async function replacementFailure(raw: RawObjectStorage, identity: string, version: string, signal: AbortSignal): Promise<string> {
8
8
  const format = (value: string) => value.startsWith('W/') ? 'weak' : value.startsWith('"') && value.endsWith('"') ? 'quoted' : 'unquoted'
@@ -20,12 +20,16 @@ async function replacementFailure(raw: RawObjectStorage, identity: string, versi
20
20
  return `${reason}; metadata=${format(current.version)}; comparison=${comparison}`
21
21
  }
22
22
 
23
- async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal): Promise<string | void> {
23
+ type MigrationPhase = 'prepare-key' | 'open-payload' | 'verify-existing' | 'encrypt-upload' | 'verify-replacement' | 'checkpoint'
24
+ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal,
25
+ progress: (phase: MigrationPhase) => void): Promise<string | void> {
24
26
  if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
27
+ progress('prepare-key')
25
28
  const row = await db.ensureStorageDataKey(item.type, item.id)
26
29
  if (!row || row.encrypted) return
27
30
  const dataKey = unwrapDataKey(key, item.type, row), identity = storageRowPath(item.type, row)
28
31
  try {
32
+ progress('open-payload')
29
33
  const source = await raw.open(identity, signal)
30
34
  if (!source) {
31
35
  if (!await db.getStorageRow(item.type, item.id)) return
@@ -33,6 +37,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
33
37
  }
34
38
  let inspected = await inspectStorageObject(source)
35
39
  if (inspected.encrypted) {
40
+ progress('verify-existing')
36
41
  // A previous PUT may have completed before a timeout or lost SQL ack.
37
42
  try {
38
43
  const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
@@ -47,17 +52,19 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
47
52
  }
48
53
  }
49
54
  if (!inspected.encrypted) {
50
- await verifyStoragePayload(item.type, row, inspected.stream, signal)
55
+ progress('encrypt-upload')
51
56
  signal.throwIfAborted()
52
- const current = await openObjectVersion(raw, identity, source.version, signal)
53
- if (!current) return 'Payload disappeared or changed version before encryption'
54
- const encrypted = encryptStorageStream(current.stream, dataKey, identity, current.size)
57
+ const verified = verifiedStoragePayload(item.type, row, inspected.stream, signal)
58
+ const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
55
59
  let replaced: boolean
56
- try { replaced = await raw.put(identity, encrypted, signal, source.version, current.size ?? undefined) }
57
- finally { encrypted.destroy(); current.stream.destroy() }
60
+ try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
61
+ // A provider may wrap a request-body error; keep safe payload diagnostics.
62
+ catch (err) { throw verified.errored ?? err }
63
+ finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
58
64
  if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
59
65
  // Do not mark the row encrypted until the stored bytes authenticate and
60
66
  // match the original upload (including decompression for sourcemaps).
67
+ progress('verify-replacement')
61
68
  const replacement = await raw.open(identity, signal)
62
69
  if (!replacement) {
63
70
  if (!await db.getStorageRow(item.type, item.id)) return
@@ -69,6 +76,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
69
76
  signal.throwIfAborted()
70
77
  // Another process may have renamed ciphertext but stopped before syncing
71
78
  // the directory. Persist the observed rename even on the resume path.
79
+ progress('checkpoint')
72
80
  await raw.sync?.(identity, signal)
73
81
  await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
74
82
  } finally { dataKey.fill(0) }
@@ -176,8 +184,17 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
176
184
  for (const row of rows) {
177
185
  if (Date.now() >= deadline || signal.aborted) break
178
186
  console.info('managed-storage-migration-row-start:', JSON.stringify({ type: row.type, id: row.id }))
187
+ const startedAt = Date.now()
188
+ let phase: MigrationPhase | 'tokens' = 'tokens'
189
+ const logPhase = (aborted = false) => console.info('managed-storage-migration-row-phase:', JSON.stringify({
190
+ type: row.type, id: row.id, phase, elapsedMs: Date.now() - startedAt, ...(aborted ? { aborted: true } : {}),
191
+ }))
192
+ // Log directly on cancellation even if an underlying operation stalls
193
+ // while unwinding. These fixed phases contain no object/provider data.
194
+ const aborted = () => logPhase(true)
195
+ signal.addEventListener('abort', aborted, { once: true })
179
196
  try {
180
- const pending = await migrateRow(raw, db, key, row, signal)
197
+ const pending = await migrateRow(raw, db, key, row, signal, next => { phase = next; logPhase() })
181
198
  if (pending) failures.push(rowFailure(row, pending))
182
199
  }
183
200
  catch (err) {
@@ -192,6 +209,7 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
192
209
  errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
193
210
  } else failures.push(rowFailure(row, failure))
194
211
  }
212
+ finally { signal.removeEventListener('abort', aborted) }
195
213
  if (stopping?.aborted) break
196
214
  await db.advanceStorageMigration(state.cursor, row.position)
197
215
  processed++
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { createHash } from 'node:crypto'
3
- import { Readable, Writable } from 'node:stream'
3
+ import { PassThrough, Readable, Writable } from 'node:stream'
4
4
  import { pipeline } from 'node:stream/promises'
5
5
  import { createBrotliDecompress } from 'node:zlib'
6
6
  import { STORAGE_MAGIC } from '../server-common/storage-crypto.ts'
@@ -70,3 +70,29 @@ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow
70
70
  const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
71
71
  if (actual !== row.hash) throw new StoragePayloadError('Stored payload does not match its upload hash')
72
72
  }
73
+
74
+ // Forward the stored representation with backpressure, but withhold EOF until
75
+ // its upload hash passes. Encryption cannot emit its final frame, and an atomic
76
+ // PUT cannot commit, until verification (including sourcemap decompression) ends.
77
+ export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Readable {
78
+ const input = new PassThrough(), output = new PassThrough()
79
+ const verification = verifyStoragePayload(type, row, input, signal)
80
+ // Propagate asynchronous decompression errors even if the source is stalled.
81
+ void verification.catch(err => { source.destroy(err); output.destroy(err) })
82
+ const forwarding = pipeline(source, async function* (chunks) {
83
+ try {
84
+ for await (const chunk of chunks) {
85
+ await new Promise<void>((resolve, reject) => { input.write(chunk, err => err ? reject(err) : resolve()) })
86
+ yield chunk
87
+ }
88
+ input.end()
89
+ await verification
90
+ } finally {
91
+ input.destroy()
92
+ await verification.catch(() => {})
93
+ }
94
+ }, output, signal ? { signal } : {})
95
+ // pipeline forwards errors to output and tears down input on early cancellation.
96
+ void forwarding.catch(() => {})
97
+ return output
98
+ }