@preventive/triage 1.0.0-alpha.25 → 1.0.0-alpha.27
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/common/server-info.ts +1 -0
- package/out/client-managed-import.js +18 -18
- package/out/client-managed.js +66 -65
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +182 -182
- package/package.json +12 -2
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +5 -1
- package/server-common/vercel-blob.ts +6 -3
- package/server-e2e/config.ts +4 -0
- package/server-managed/avatar-store.ts +1 -40
- package/server-managed/blob-store.ts +3 -52
- package/server-managed/bundle-cache.ts +0 -28
- package/server-managed/bundle-store.ts +3 -7
- package/server-managed/cache-storage.ts +0 -25
- package/server-managed/cli.js +10 -2
- package/server-managed/config.ts +14 -2
- package/server-managed/db-methods.ts +56 -25
- package/server-managed/db-neon.ts +9 -4
- package/server-managed/db.ts +7 -1
- package/server-managed/http.ts +93 -62
- package/server-managed/import-triage.ts +6 -3
- package/server-managed/index.ts +32 -9
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +60 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/report-query.ts +6 -0
- package/server-managed/repository-browser.ts +5 -1
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql.ts +0 -5
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +168 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +182 -0
- package/server-managed/storage-payload.ts +60 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +46 -24
- package/server-managed/uploads.ts +30 -12
- package/server-managed/blob-vercel.ts +0 -125
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Logical stores share one encryption boundary. Compression, identifiers and
|
|
2
|
+
// public response bytes stay the same with either physical backend.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
5
|
+
import type { BlobStore } from './blob-store.ts'
|
|
6
|
+
import type { BundleCacheStorage } from './bundle-cache.ts'
|
|
7
|
+
import { createBundleStore } from './bundle-store.ts'
|
|
8
|
+
import { type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
9
|
+
import { type ObjectStorage, isBlobId } from './object-storage.ts'
|
|
10
|
+
|
|
11
|
+
function id(value: string): string {
|
|
12
|
+
if (!isBlobId(value)) throw new Error('Invalid managed blob id')
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function blobStore(objects: ObjectStorage, name: string, suffix = ''): BlobStore {
|
|
17
|
+
const path = (value: string) => `${name}/${id(value)}${suffix}`
|
|
18
|
+
return {
|
|
19
|
+
put: (value, bytes) => objects.put(path(value), bytes),
|
|
20
|
+
get: value => objects.get(path(value)),
|
|
21
|
+
open: value => objects.open(path(value)),
|
|
22
|
+
delete: value => objects.delete(path(value)),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cacheStore(objects: ObjectStorage, name: string): CacheStorage {
|
|
27
|
+
const path = (key: string) => `cache/${name}/${validateCacheKey(key)}`
|
|
28
|
+
return {
|
|
29
|
+
exists: key => objects.exists(path(key)),
|
|
30
|
+
put: async (key, bytes) => { await objects.put(path(key), bytes) },
|
|
31
|
+
async open(key) {
|
|
32
|
+
const value = await objects.open(path(key))
|
|
33
|
+
if (!value) throw new Error('Managed cache unavailable')
|
|
34
|
+
return value
|
|
35
|
+
},
|
|
36
|
+
delete: prefix => objects.deletePrefix(`${path(prefix)}/`),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
|
|
41
|
+
return {
|
|
42
|
+
async put(value, contentType, bytes) {
|
|
43
|
+
const path = `avatars/${id(value)}`, type = contentType.split(';', 1)[0]!.trim()
|
|
44
|
+
if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
|
|
45
|
+
if (sidecar) {
|
|
46
|
+
await objects.put(path, bytes)
|
|
47
|
+
await objects.put(`${path}.type`, Buffer.from(type))
|
|
48
|
+
} else await objects.put(path, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
|
|
49
|
+
},
|
|
50
|
+
async get(value) {
|
|
51
|
+
const path = `avatars/${id(value)}`
|
|
52
|
+
const bytes = await objects.get(path)
|
|
53
|
+
if (!bytes) return null
|
|
54
|
+
if (sidecar) {
|
|
55
|
+
const type = await objects.get(`${path}.type`)
|
|
56
|
+
return type ? { bytes, contentType: type.toString().trim() || 'application/octet-stream' } : null
|
|
57
|
+
}
|
|
58
|
+
const split = bytes.indexOf(10)
|
|
59
|
+
if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
|
|
60
|
+
return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createManagedStores(objects: ObjectStorage, avatarSidecar: boolean) {
|
|
66
|
+
const bundles = cacheStore(objects, 'bundles')
|
|
67
|
+
const cacheStorage: BundleCacheStorage = {
|
|
68
|
+
exists: (value, file) => bundles.exists(`${id(value)}/${file}`),
|
|
69
|
+
put: (value, file, bytes) => bundles.put(`${id(value)}/${file}`, bytes),
|
|
70
|
+
open: (value, file) => bundles.open(`${id(value)}/${file}`),
|
|
71
|
+
delete: value => bundles.delete(id(value)),
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
reportStore: blobStore(objects, 'reports'),
|
|
75
|
+
bundleStore: createBundleStore(blobStore(objects, 'bundles'), blobStore(objects, 'bundles', '.map.br')),
|
|
76
|
+
uploadStore: blobStore(objects, 'uploads'),
|
|
77
|
+
avatarStore: avatarStore(objects, avatarSidecar),
|
|
78
|
+
cacheStorage,
|
|
79
|
+
reportSourcesStorage: cacheStore(objects, 'report-sources'),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,34 +1,56 @@
|
|
|
1
1
|
// Backend selection is shared by standalone, combined and function entrypoints.
|
|
2
|
-
import { dirname
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { createDiskBundleStore } from './bundle-store.ts'
|
|
6
|
-
import { createBundleCache, createDiskBundleCache } from './bundle-cache.ts'
|
|
7
|
-
import { createDiskCacheStorage } from './cache-storage.ts'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { parseStorageKey } from '../server-common/storage-crypto.ts'
|
|
4
|
+
import { createBundleCache } from './bundle-cache.ts'
|
|
8
5
|
import { createReportSourcesCache } from './report-sources.ts'
|
|
9
|
-
import { openManagedVercelStorage } from './blob-vercel.ts'
|
|
10
6
|
import { openNeonManagedDb } from './db-neon.ts'
|
|
11
7
|
import type { ManagedConfig } from './config.ts'
|
|
8
|
+
import { createEncryptedObjectStorage } from './storage-encryption.ts'
|
|
9
|
+
import { createManagedStores } from './storage-stores.ts'
|
|
10
|
+
import { createDiskObjectStorage } from './object-storage-disk.ts'
|
|
11
|
+
import { openVercelObjectStorage } from './object-storage-vercel.ts'
|
|
12
|
+
import { migrateStorage, reapStorageUploads } from './storage-maintenance.ts'
|
|
13
|
+
|
|
14
|
+
// Database-only callers (notably status) must not activate byte encryption.
|
|
15
|
+
export async function openManagedStorageDb(config: ManagedConfig) {
|
|
16
|
+
const options = { triageHistoryLimit: config.triageHistoryLimit, storageEncryptionKey: parseStorageKey(config.storageEncryptionKey) }
|
|
17
|
+
return config.neonUrl ? openNeonManagedDb(config.neonUrl, options)
|
|
18
|
+
: (await import('./db.ts')).openSqliteManagedDb(config.dbPath, options)
|
|
19
|
+
}
|
|
12
20
|
|
|
13
21
|
export async function openManagedStorage(config: ManagedConfig) {
|
|
14
|
-
const
|
|
15
|
-
if (config.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
const key = parseStorageKey(config.storageEncryptionKey)
|
|
23
|
+
if (config.storageEncryptionMigrate && !key) throw new Error('MANAGED_STORAGE_ENCRYPTION_MIGRATE requires MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
24
|
+
if (config.neonUrl && !config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
|
|
25
|
+
if (config.serverless && !config.neonUrl) throw new Error('Serverless managed storage requires Neon')
|
|
26
|
+
const db = await openManagedStorageDb(config)
|
|
27
|
+
try {
|
|
28
|
+
const raw = config.neonUrl ? await openVercelObjectStorage(config.blobToken!) : createDiskObjectStorage(dirname(config.dbPath))
|
|
29
|
+
// Enable before exposing stores or token writers. Concurrent first starts
|
|
30
|
+
// serialize activation in the database and validate the winning key.
|
|
31
|
+
if (key && !await db.getStorageEncryption()) {
|
|
32
|
+
if (config.vercelPreview) throw new Error('Storage encryption must be enabled outside a Vercel preview deployment')
|
|
33
|
+
await db.enableStorageEncryption()
|
|
34
|
+
}
|
|
35
|
+
const objects = await createEncryptedObjectStorage(raw, db, key)
|
|
36
|
+
const storage = createManagedStores(objects, !config.neonUrl)
|
|
37
|
+
return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
|
|
38
|
+
bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
|
|
20
39
|
reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
|
|
40
|
+
async reapStorage() {
|
|
41
|
+
if (!config.storageEncryptionMigrate) return
|
|
42
|
+
const state = await db.getStorageEncryption()
|
|
43
|
+
if (state?.complete && state.cleanupComplete) return
|
|
44
|
+
const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs })
|
|
45
|
+
console.info('managed-storage-migration:', JSON.stringify({
|
|
46
|
+
complete: result.complete === 1, cleanupComplete: result.cleanupComplete === 1,
|
|
47
|
+
migrated: result.migrated, cursor: result.cursor, retryAt: result.retryAt,
|
|
48
|
+
}))
|
|
49
|
+
},
|
|
50
|
+
...(config.neonUrl ? { reapUploads: () => reapStorageUploads(raw, db) } : {}),
|
|
21
51
|
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const db = openSqliteManagedDb(config.dbPath, options)
|
|
26
|
-
const dir = dirname(config.dbPath)
|
|
27
|
-
const reportStore = createDiskBlobStore(join(dir, 'reports'))
|
|
28
|
-
const bundleStore = createDiskBundleStore(join(dir, 'bundles'))
|
|
29
|
-
return { db, reportStore, bundleStore,
|
|
30
|
-
reportSourcesCache: createReportSourcesCache(createDiskCacheStorage(join(dir, 'cache', 'report-sources')), db, reportStore, bundleStore),
|
|
31
|
-
avatarStore: createDiskAvatarStore(join(dir, 'avatars')),
|
|
32
|
-
bundleCache: createDiskBundleCache(join(dir, 'cache', 'bundles'), db, bundleStore),
|
|
52
|
+
} catch (err) {
|
|
53
|
+
await db.close()
|
|
54
|
+
throw err
|
|
33
55
|
}
|
|
34
56
|
}
|
|
@@ -7,6 +7,8 @@ import type { IncomingMessage } from 'node:http'
|
|
|
7
7
|
import type { BlobStore } from './blob-store.ts'
|
|
8
8
|
|
|
9
9
|
export const UPLOAD_CHUNK_BYTES = 3 * 1024 * 1024
|
|
10
|
+
export const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024
|
|
11
|
+
const MAX_UPLOAD_PARTS = Math.ceil(MAX_UPLOAD_BYTES / UPLOAD_CHUNK_BYTES)
|
|
10
12
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
11
13
|
export type UploadKind = 'reports' | 'bundles'
|
|
12
14
|
|
|
@@ -16,24 +18,40 @@ function partId(session: string, kind: UploadKind, id: string, index: number): s
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export function validUploadPart(id: string, index: number, maxBytes: number): boolean {
|
|
19
|
-
return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(maxBytes / UPLOAD_CHUNK_BYTES)
|
|
21
|
+
return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(Math.min(maxBytes, MAX_UPLOAD_BYTES) / UPLOAD_CHUNK_BYTES)
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export function
|
|
23
|
-
return
|
|
24
|
+
export function validUpload(id: string, count: number): boolean {
|
|
25
|
+
return UUID.test(id) && Number.isSafeInteger(count) && count > 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Limit cleanup to indices that any configured server could have accepted,
|
|
29
|
+
// even if the request advertises an enormous count or the limit was lowered.
|
|
30
|
+
export async function deleteUpload(store: BlobStore, session: string, kind: UploadKind, id: string, count: number): Promise<void> {
|
|
31
|
+
if (!validUpload(id, count)) throw new Error('bad-upload')
|
|
32
|
+
const end = Math.min(count, MAX_UPLOAD_PARTS), errors = []
|
|
33
|
+
for (let start = 0; start < end; start += 8) {
|
|
34
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(8, end - start) },
|
|
35
|
+
(_, index) => store.delete(partId(session, kind, id, start + index))))
|
|
36
|
+
for (const result of results) if (result.status === 'rejected') errors.push(result.reason)
|
|
37
|
+
}
|
|
38
|
+
if (errors.length > 0) throw new AggregateError(errors, 'Upload cleanup failed')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function putUploadPart(store: BlobStore, session: string, kind: UploadKind, id: string, index: number, bytes: Buffer): Promise<void> {
|
|
42
|
+
await store.put(partId(session, kind, id, index), bytes)
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
export async function readUpload(store: BlobStore, req: IncomingMessage, session: string, kind: UploadKind, maxBytes: number): Promise<Buffer> {
|
|
27
46
|
const id = req.headers['x-upload-id']
|
|
28
47
|
const count = Number(req.headers['x-upload-parts']), size = Number(req.headers['x-upload-size'])
|
|
29
|
-
if (typeof id !== 'string' || !
|
|
30
|
-
|| !Number.isSafeInteger(count) || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
|
|
31
|
-
if (size > maxBytes) throw new Error('too-large')
|
|
32
|
-
const ids = Array.from({ length: count }, (_, index) => partId(session, kind, id, index))
|
|
33
|
-
const parts: Buffer[] = []
|
|
48
|
+
if (typeof id !== 'string' || !validUpload(id, count)) throw new Error('bad-upload')
|
|
34
49
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
if (!Number.isSafeInteger(size) || size <= 0 || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
|
|
51
|
+
if (size > maxBytes || size > MAX_UPLOAD_BYTES) throw new Error('too-large')
|
|
52
|
+
const parts: Buffer[] = []
|
|
53
|
+
for (let index = 0; index < count; index++) {
|
|
54
|
+
const bytes = await store.get(partId(session, kind, id, index))
|
|
37
55
|
const expected = Math.min(UPLOAD_CHUNK_BYTES, size - index * UPLOAD_CHUNK_BYTES)
|
|
38
56
|
if (!bytes || bytes.length !== expected) throw new Error('bad-upload')
|
|
39
57
|
parts.push(bytes)
|
|
@@ -41,7 +59,7 @@ export async function readUpload(store: BlobStore, req: IncomingMessage, session
|
|
|
41
59
|
return Buffer.concat(parts, size)
|
|
42
60
|
} finally {
|
|
43
61
|
// These bytes can no longer be finalized. Failed/abandoned chunk uploads
|
|
44
|
-
// are also covered by
|
|
45
|
-
|
|
62
|
+
// are also covered by automatic staging maintenance.
|
|
63
|
+
await deleteUpload(store, session, kind, id, count).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
46
64
|
}
|
|
47
65
|
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { Buffer } from 'node:buffer'
|
|
2
|
-
import { Readable } from 'node:stream'
|
|
3
|
-
import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../server-common/vercel-blob.ts'
|
|
4
|
-
import type { AvatarStore } from './avatar-store.ts'
|
|
5
|
-
import type { BlobStore } from './blob-store.ts'
|
|
6
|
-
import { type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
7
|
-
import type { BundleCacheStorage } from './bundle-cache.ts'
|
|
8
|
-
import { createBundleStore } from './bundle-store.ts'
|
|
9
|
-
|
|
10
|
-
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
11
|
-
function validate(id: string): string {
|
|
12
|
-
if (!ID.test(id)) throw new Error('Invalid managed blob id')
|
|
13
|
-
return id
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
type ListedBlob = Awaited<ReturnType<VercelBlobSdk['list']>>['blobs'][number]
|
|
17
|
-
|
|
18
|
-
async function deletePrefix(blobs: VercelBlobSdk, token: string, prefix: string, remove: (path: string) => Promise<void>, matches = (_blob: ListedBlob) => true): Promise<void> {
|
|
19
|
-
const cursors = new Set<string>(), paths = new Set<string>()
|
|
20
|
-
let cursor: string | undefined
|
|
21
|
-
// Finish listing before deleting so pagination cannot skip objects as the
|
|
22
|
-
// listed set shrinks. Callers supply a trailing slash to isolate a namespace.
|
|
23
|
-
do {
|
|
24
|
-
const page = await blobs.list({ token, prefix, ...(cursor ? { cursor } : {}) })
|
|
25
|
-
for (const blob of page.blobs) if (blob.pathname.startsWith(prefix) && matches(blob)) paths.add(blob.pathname)
|
|
26
|
-
if (!page.hasMore) break
|
|
27
|
-
if (!page.cursor || cursors.has(page.cursor)) throw new Error('Invalid blob pagination')
|
|
28
|
-
cursor = page.cursor
|
|
29
|
-
cursors.add(cursor)
|
|
30
|
-
} while (cursor)
|
|
31
|
-
for (const path of paths) await remove(path)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function createBlobAvatarStore(avatarBlobs: BlobStore): AvatarStore {
|
|
35
|
-
return {
|
|
36
|
-
put(id, contentType, bytes) {
|
|
37
|
-
const type = contentType.split(';', 1)[0]!.trim()
|
|
38
|
-
if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
|
|
39
|
-
return avatarBlobs.put(id, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
|
|
40
|
-
},
|
|
41
|
-
async get(id) {
|
|
42
|
-
const bytes = await avatarBlobs.get(id)
|
|
43
|
-
if (!bytes) return null
|
|
44
|
-
const split = bytes.indexOf(10)
|
|
45
|
-
if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
|
|
46
|
-
return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
|
|
47
|
-
},
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSdk) {
|
|
52
|
-
const blobs = sdk ?? await loadVercelBlobSdk()
|
|
53
|
-
const options = { token, access: 'private' as const, useCache: false }
|
|
54
|
-
async function put(path: string, bytes: Buffer) {
|
|
55
|
-
await blobs.put(path, bytes, { token, access: 'private', addRandomSuffix: false,
|
|
56
|
-
allowOverwrite: true, contentType: 'application/octet-stream', cacheControlMaxAge: 60 })
|
|
57
|
-
}
|
|
58
|
-
async function open(path: string) {
|
|
59
|
-
try {
|
|
60
|
-
const result = await blobs.get(path, options)
|
|
61
|
-
if (!result) return null
|
|
62
|
-
if (result.statusCode !== 200 || !result.stream) throw new Error('Unexpected blob response')
|
|
63
|
-
// Private SDK GETs may report zero for a nonempty body. Stream without
|
|
64
|
-
// Content-Length in that case so HTTP clients do not truncate the body.
|
|
65
|
-
const size = result.blob.size === 0 ? null : result.blob.size
|
|
66
|
-
return { size, stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
|
|
67
|
-
} catch (err) { if (isNotFound(err, blobs)) return null; throw err }
|
|
68
|
-
}
|
|
69
|
-
async function get(path: string) {
|
|
70
|
-
const result = await open(path)
|
|
71
|
-
if (!result) return null
|
|
72
|
-
const parts: Buffer[] = []
|
|
73
|
-
for await (const chunk of result.stream) parts.push(Buffer.from(chunk))
|
|
74
|
-
return Buffer.concat(parts)
|
|
75
|
-
}
|
|
76
|
-
async function remove(path: string) {
|
|
77
|
-
try { await blobs.del(path, { token }) } catch (err) { if (!isNotFound(err, blobs)) throw err }
|
|
78
|
-
}
|
|
79
|
-
function store(name: string, suffix = ''): BlobStore {
|
|
80
|
-
const path = (id: string) => `.managed/${name}/${validate(id)}${suffix}`
|
|
81
|
-
return {
|
|
82
|
-
async put(id, bytes) { await put(path(id), bytes) },
|
|
83
|
-
async get(id) { return await get(path(id)) },
|
|
84
|
-
open: id => open(path(id)),
|
|
85
|
-
async delete(id) { await remove(path(id)) },
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const avatarStore = createBlobAvatarStore(store('avatars'))
|
|
89
|
-
const cachePath = (id: string, file: string) => `.managed/cache/bundles/${validate(id)}/${file}`
|
|
90
|
-
const cacheStorage: BundleCacheStorage = {
|
|
91
|
-
async exists(id, file) {
|
|
92
|
-
try { await blobs.head(cachePath(id, file), { token }); return true }
|
|
93
|
-
catch (err) { if (isNotFound(err, blobs)) return false; throw err }
|
|
94
|
-
},
|
|
95
|
-
put: (id, file, bytes) => put(cachePath(id, file), bytes),
|
|
96
|
-
async open(id, file) {
|
|
97
|
-
const result = await open(cachePath(id, file))
|
|
98
|
-
if (!result) throw new Error('Bundle cache unavailable')
|
|
99
|
-
return result
|
|
100
|
-
},
|
|
101
|
-
delete: id => deletePrefix(blobs, token, cachePath(id, ''), remove),
|
|
102
|
-
}
|
|
103
|
-
// Staging uploads are never published. An interrupted browser leaves only
|
|
104
|
-
// parts here; the authenticated cron removes them after a full day.
|
|
105
|
-
function reapUploads(now = Date.now()) {
|
|
106
|
-
return deletePrefix(blobs, token, '.managed/uploads/', remove,
|
|
107
|
-
blob => new Date(blob.uploadedAt ?? now).getTime() < now - 86_400_000)
|
|
108
|
-
}
|
|
109
|
-
const sourcesPath = (key: string) => `.managed/cache/report-sources/${validateCacheKey(key)}`
|
|
110
|
-
const reportSourcesStorage: CacheStorage = {
|
|
111
|
-
async exists(key) {
|
|
112
|
-
try { await blobs.head(sourcesPath(key), { token }); return true }
|
|
113
|
-
catch (err) { if (isNotFound(err, blobs)) return false; throw err }
|
|
114
|
-
},
|
|
115
|
-
put: (key, bytes) => put(sourcesPath(key), bytes),
|
|
116
|
-
async open(key) {
|
|
117
|
-
const result = await open(sourcesPath(key))
|
|
118
|
-
if (!result) throw new Error('Report sources cache unavailable')
|
|
119
|
-
return result
|
|
120
|
-
},
|
|
121
|
-
delete: prefix => deletePrefix(blobs, token, `${sourcesPath(prefix)}/`, remove),
|
|
122
|
-
}
|
|
123
|
-
const bundleStore = createBundleStore(store('bundles'), store('bundles', '.map.br'))
|
|
124
|
-
return { reportStore: store('reports'), bundleStore, uploadStore: store('uploads'), avatarStore, cacheStorage, reportSourcesStorage, reapUploads }
|
|
125
|
-
}
|