@transloadit/node 6.7.0 → 6.9.0

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.
@@ -1,4 +1,4 @@
1
- import type { StoredImageReceipt } from '../../storageImage.ts'
1
+ import type { StoredAssetReceipt } from '../../storageAsset.ts'
2
2
 
3
3
  import { createHash } from 'node:crypto'
4
4
  import { createReadStream } from 'node:fs'
@@ -25,6 +25,7 @@ import {
25
25
  assertStorageCatalogOrigin,
26
26
  assertStorageWorkspace,
27
27
  defaultStorageCatalog,
28
+ isStorageImageReceipt,
28
29
  readStorageCatalog,
29
30
  storageCatalogDelivery,
30
31
  storageTypesPath,
@@ -40,7 +41,7 @@ abstract class StorageProjectCommand extends AuthenticatedCommand {
40
41
  .origin
41
42
  }
42
43
  receipts = Option.String('--receipts', defaultStorageCatalog, {
43
- description: 'Committed project catalog with workspace, public prefixes and image receipts',
44
+ description: 'Project catalog with workspace, public prefixes and asset receipts',
44
45
  })
45
46
  workspace = Option.String('--workspace', {
46
47
  description:
@@ -203,12 +204,12 @@ export class StoragePublicationsCommand extends AuthenticatedCommand {
203
204
  }
204
205
  }
205
206
 
206
- interface CliStoredImageReceipt extends StoredImageReceipt {
207
+ interface CliStoredAssetReceipt extends StoredAssetReceipt {
207
208
  source?: string
208
209
  apiOrigin?: string
209
210
  }
210
211
 
211
- async function hashImageFile(
212
+ async function hashAssetFile(
212
213
  file: string,
213
214
  signal: AbortSignal,
214
215
  ): Promise<{ md5hash: string; size: number }> {
@@ -219,7 +220,7 @@ async function hashImageFile(
219
220
  hash.update(chunk)
220
221
  size += chunk.length
221
222
  }
222
- if (size === 0) throw new Error('Cannot store an empty image')
223
+ if (size === 0) throw new Error('Cannot store an empty file')
223
224
  signal.throwIfAborted()
224
225
  return { md5hash: hash.digest('hex'), size }
225
226
  }
@@ -230,7 +231,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
230
231
 
231
232
  static override usage = Command.Usage({
232
233
  category: 'Storage',
233
- description: 'Store original images and save verified metadata for Image',
234
+ description: 'Store original files and save verified, version-pinned asset receipts',
234
235
  details: `
235
236
  Uses the CLI's Assembly credentials (environment, .env or ~/.transloadit/credentials).
236
237
  Storage writes must be enabled. Existing Storage paths conflict unless --overwrite is explicit.
@@ -239,7 +240,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
239
240
  --json includes uploaded for each receipt: false means this file reused a verified receipt.
240
241
  --placeholder blur requests server metadata on new uploads. Successful extraction adds
241
242
  metadata usage of 20% of the file bytes. Missing placeholders never retry a completed upload.
242
- The project catalog binds workspace, published prefixes and image receipts. Each successful
243
+ The project catalog binds workspace, public prefixes, images and other files. Each successful
243
244
  upload is saved atomically before the next. Do not run two writers against the same catalog.
244
245
  The catalog defaults to transloadit.images.json; --receipts selects another file.
245
246
  `,
@@ -247,6 +248,10 @@ export class StorageStoreCommand extends StorageProjectCommand {
247
248
  ['Store a hero image', 'transloadit storage store ./hero.jpg website/hero.jpg'],
248
249
  ['Store a content-addressed image', 'transloadit storage store ./hero.jpg website/ --hashed'],
249
250
  ['Store a directory of originals', 'transloadit storage store ./images/*.jpg website/'],
251
+ [
252
+ 'Store mixed media',
253
+ 'transloadit storage store ./photo.jpg ./clip.mp4 ./guide.pdf website/media/',
254
+ ],
250
255
  ],
251
256
  })
252
257
 
@@ -267,7 +272,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
267
272
  })
268
273
  protected async run(): Promise<number | undefined> {
269
274
  const file = resolve(this.receipts)
270
- let stored: { receipt?: CliStoredImageReceipt } = {}
275
+ let stored: { receipt?: CliStoredAssetReceipt } = {}
271
276
  let destination = this.destination
272
277
  let workspace: string | undefined
273
278
  let saved = false
@@ -282,9 +287,9 @@ export class StorageStoreCommand extends StorageProjectCommand {
282
287
  '--hashed cannot be combined with --overwrite; changed bytes get a new name',
283
288
  )
284
289
  const apiOrigin = this.apiOrigin
285
- const uploaded = new Map<string, CliStoredImageReceipt>()
290
+ const uploaded = new Map<string, CliStoredAssetReceipt>()
286
291
  if (this.files.length > 1 && !this.destination.endsWith('/'))
287
- throw new Error('Multiple images need a directory destination ending in /')
292
+ throw new Error('Multiple files need a directory destination ending in /')
288
293
  const inputs = this.files.map((input) => ({
289
294
  file: input,
290
295
  path: this.destination.endsWith('/')
@@ -293,14 +298,14 @@ export class StorageStoreCommand extends StorageProjectCommand {
293
298
  }))
294
299
  for (const input of inputs) {
295
300
  if (file === resolve(input.file))
296
- throw new Error('The receipts file cannot be the input image')
301
+ throw new Error('The receipts file cannot be the input file')
297
302
  if (resolve(storageTypesPath(file)) === resolve(input.file))
298
- throw new Error('The generated declarations file cannot be the input image')
303
+ throw new Error('The generated declarations file cannot be the input file')
299
304
  validateStoragePath(input.path)
300
305
  }
301
306
  if (!this.hashed && new Set(inputs.map((input) => input.path)).size !== inputs.length)
302
307
  throw new Error(
303
- 'Input image basenames collide in the destination directory; rename them first',
308
+ 'Input file basenames collide in the destination directory; rename them first',
304
309
  )
305
310
  const publicPrefix = this.publicDelivery
306
311
  ? normalizeStoragePublicPrefix(
@@ -311,6 +316,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
311
316
  for (const input of inputs) {
312
317
  let publicImage = false
313
318
  let unchanged = false
319
+ let uploadFailure: Error | undefined
314
320
  destination = input.path
315
321
  stored = {}
316
322
  saved = false
@@ -327,24 +333,36 @@ export class StorageStoreCommand extends StorageProjectCommand {
327
333
  if (receipts?.workspace === workspace)
328
334
  assertStorageCatalogOrigin(receipts, apiOrigin, this.receipts)
329
335
  signal.throwIfAborted()
330
- if (this.hashed) {
331
- // This pass chooses the path and deduplicates. storeImage independently verifies its
336
+ if (
337
+ !this.overwrite &&
338
+ (this.hashed ||
339
+ uploaded.has(destination) ||
340
+ (receipts?.workspace === workspace &&
341
+ (Object.hasOwn(receipts.images, destination) ||
342
+ Object.hasOwn(receipts.files ?? {}, destination))))
343
+ ) {
344
+ // Hash only to choose a hashed path or compare an existing receipt. storeAsset verifies its
332
345
  // upload input; sharing a caller-supplied checksum would need an immutable-file contract.
333
- const { md5hash, size } = await hashImageFile(input.file, signal)
334
- const extension = posix.extname(destination)
335
- destination = `${destination.slice(0, destination.length - extension.length)}.${md5hash.slice(0, 8)}${extension}`
346
+ const { md5hash, size } = await hashAssetFile(input.file, signal)
347
+ if (this.hashed) {
348
+ const extension = posix.extname(destination)
349
+ destination = `${destination.slice(0, destination.length - extension.length)}.${md5hash.slice(0, 8)}${extension}`
350
+ }
336
351
  validateStoragePath(destination)
337
352
  // Explicit workspace overrides leave the catalog alone; still deduplicate this batch.
338
353
  const candidate =
339
354
  uploaded.get(destination) ??
340
355
  (receipts?.workspace === workspace && Object.hasOwn(receipts.images, destination)
341
356
  ? receipts.images[destination]
342
- : undefined)
357
+ : receipts?.workspace === workspace &&
358
+ Object.hasOwn(receipts.files ?? {}, destination)
359
+ ? receipts.files?.[destination]
360
+ : undefined)
343
361
  if (candidate !== undefined) {
344
362
  const previous = hashedReceiptSchema.safeParse(candidate)
345
363
  if (!previous.success || previous.data.path !== destination)
346
364
  throw new Error(
347
- `Catalog receipt for ${JSON.stringify(destination)} is incomplete or does not match this file. Run transloadit storage receipts sync ${quoteCliArgument(destination)} --receipts ${quoteCliArgument(this.receipts)}${this.endpoint === undefined ? '' : ` --endpoint ${quoteCliArgument(this.endpoint)}`} to recover version identity from the same API environment, then retry; nothing uploaded.`,
365
+ `Catalog receipt for ${JSON.stringify(destination)} is incomplete or does not match this file. Run transloadit storage receipts sync ${quoteCliArgument(destination)} --receipts ${quoteCliArgument(this.receipts)}${this.endpoint === undefined ? '' : ` --endpoint ${quoteCliArgument(this.endpoint)}`} to recover version identity and any available checksum from the same API environment. If the checksum remains unavailable, ${this.hashed ? 'choose another destination basename' : 'choose a fresh name or use --overwrite only to deliberately replace it'}; sync cannot generate a missing checksum. Nothing uploaded.`,
348
366
  )
349
367
  if (previous.data.apiOrigin !== apiOrigin)
350
368
  throw new Error(
@@ -352,39 +370,60 @@ export class StorageStoreCommand extends StorageProjectCommand {
352
370
  )
353
371
  if (previous.data.md5hash !== md5hash || previous.data.size !== size)
354
372
  throw new Error(
355
- `Stored bytes for ${JSON.stringify(destination)} differ from this file. An older deployment may have transformed the upload, or the short hashes collided. Restoring the same receipt will not help; choose another destination basename. Nothing uploaded or replaced.`,
373
+ this.hashed
374
+ ? `Stored bytes for ${JSON.stringify(destination)} differ from this file. An older deployment may have transformed the upload, or the short hashes collided. Restoring the same receipt will not help; choose another destination basename. Nothing uploaded or replaced.`
375
+ : `Stored bytes for ${JSON.stringify(destination)} differ from this file. Choose a fresh name or use --overwrite only to deliberately replace it. Nothing uploaded or replaced.`,
356
376
  )
357
377
  stored.receipt = previous.data
358
378
  unchanged = true
359
379
  }
360
380
  }
361
- stored.receipt ??= await this.client.storeImage(input.file, {
362
- path: destination,
363
- ...(placeholder === undefined ? {} : { placeholder }),
364
- signal,
365
- onReceipt: (receipt, expected, assemblyId) => {
366
- const sizeMatches = receipt.size === expected.size
367
- const md5Matches = receipt.md5hash === expected.md5hash
368
- this.output.debug(
369
- JSON.stringify({
370
- assemblyId,
371
- result: receipt,
372
- input: expected,
373
- sizeMatches,
374
- md5Matches,
375
- }),
376
- )
377
- if (!sizeMatches || !md5Matches) {
378
- const difference = sizeMatches
379
- ? 'same size, different MD5'
380
- : `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes`
381
- this.output.warn(
382
- `Stored bytes differ from ${input.file} (${difference}); the workspace plan may have transformed the upload (for example, a Community-plan watermark on older deployments). The receipt describes the stored image.`,
381
+ stored.receipt ??= await this.client
382
+ .storeAsset(input.file, {
383
+ path: destination,
384
+ ...(placeholder === undefined ? {} : { placeholder }),
385
+ signal,
386
+ onReceipt: (receipt, expected, assemblyId) => {
387
+ // Receipt identity is validated before byte fidelity; retain an actual write even
388
+ // when the server transformed the original and storeAsset subsequently rejects.
389
+ stored.receipt = receipt
390
+ const sizeMatches = receipt.size === expected.size
391
+ const md5Matches = receipt.md5hash === expected.md5hash
392
+ this.output.debug(
393
+ JSON.stringify({
394
+ assemblyId,
395
+ result: receipt,
396
+ input: expected,
397
+ sizeMatches,
398
+ md5Matches,
399
+ }),
383
400
  )
384
- }
385
- },
386
- ...(this.overwrite ? { overwrite: true } : {}),
387
- })
401
+ if (!sizeMatches || !md5Matches) {
402
+ const difference = sizeMatches
403
+ ? 'same size, different MD5'
404
+ : `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes`
405
+ this.output.warn(
406
+ `Stored bytes differ from ${input.file} (${difference}); the workspace plan may have transformed the upload (for example, a Community-plan watermark on older deployments). The receipt describes the stored file.`,
407
+ )
408
+ }
409
+ },
410
+ ...(this.overwrite ? { overwrite: true } : {}),
411
+ })
412
+ .catch((error: unknown) => {
413
+ // The error owns recovery evidence; the optional observer need not have run.
414
+ const completed = z
415
+ .object({
416
+ receipt: storedAssetSchema.extend({
417
+ md5hash: storedAssetSchema.shape.md5hash.unwrap(),
418
+ }),
419
+ })
420
+ .safeParse(error instanceof InconsistentResponseError ? error.cause : undefined)
421
+ if (completed.success && completed.data.receipt.path === destination)
422
+ stored.receipt = completed.data.receipt
423
+ if (stored.receipt === undefined) throw error
424
+ uploadFailure = ensureError(error)
425
+ return stored.receipt
426
+ })
388
427
  if (stored.receipt.workspace !== workspace)
389
428
  throw new Error(
390
429
  `The stored receipt belongs to Workspace ${stored.receipt.workspace}, not ${workspace}. The project catalog was not changed; inspect the Assembly before retrying.`,
@@ -395,7 +434,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
395
434
  ...(this.hashed ? { source: basename(input.file) } : {}),
396
435
  apiOrigin,
397
436
  }
398
- if (this.hashed) uploaded.set(destination, stored.receipt)
437
+ uploaded.set(destination, stored.receipt)
399
438
  if (receipts !== undefined && receipts.workspace !== workspace) {
400
439
  this.output.notice(
401
440
  `Catalog ${this.receipts} was not changed; it belongs to ${receipts.workspace}. Use --receipts for a separate catalog.`,
@@ -405,6 +444,16 @@ export class StorageStoreCommand extends StorageProjectCommand {
405
444
  publicImage = receipts?.public.some((prefix) => destination.startsWith(prefix)) ?? false
406
445
  // Replays must not reorder fields or discard application metadata from a saved receipt.
407
446
  if (unchanged) return receipts
447
+ let images = { ...receipts?.images }
448
+ let files = { ...receipts?.files }
449
+ // Updating an existing key preserves catalog order; remove only a former category.
450
+ if (isStorageImageReceipt(stored.receipt)) {
451
+ delete files[destination]
452
+ images = { ...images, [destination]: stored.receipt }
453
+ } else {
454
+ delete images[destination]
455
+ files = { ...files, [destination]: stored.receipt }
456
+ }
408
457
  return {
409
458
  ...receipts,
410
459
  workspace,
@@ -413,7 +462,8 @@ export class StorageStoreCommand extends StorageProjectCommand {
413
462
  receipts?.delivery ??
414
463
  storageCatalogDelivery(this.endpoint ?? this.cliConfig.endpoint),
415
464
  public: receipts?.public ?? [],
416
- images: { ...receipts?.images, [stored.receipt.path]: stored.receipt },
465
+ images,
466
+ ...(Object.keys(files).length > 0 || receipts?.files !== undefined ? { files } : {}),
417
467
  }
418
468
  },
419
469
  () => {
@@ -421,6 +471,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
421
471
  },
422
472
  )
423
473
  if (stored.receipt === undefined) throw new Error('Storage did not return a receipt')
474
+ if (uploadFailure) throw uploadFailure
424
475
  if (publicPrefix !== undefined && published === undefined) {
425
476
  this.output.notice(
426
477
  `Publishing ${publicPrefix} recursively: all current and future objects under this prefix will be public.`,
@@ -440,6 +491,13 @@ export class StorageStoreCommand extends StorageProjectCommand {
440
491
  })
441
492
  }
442
493
  const receipt = stored.receipt
494
+ if (!isStorageImageReceipt(receipt)) {
495
+ this.output.print(
496
+ `${unchanged ? `Unchanged ${receipt.path}; no upload needed.\n` : ''}${saved ? `Saved ${receipt.path} in ${this.receipts}.` : `Stored ${receipt.path}; the different-workspace catalog was left unchanged.`}\nUse the asset/version receipt for original delivery or a Smart CDN preview.`,
497
+ { ...receipt, uploaded: !unchanged },
498
+ )
499
+ continue
500
+ }
443
501
  if (placeholder === 'blur' && receipt.thumbhash === undefined) {
444
502
  this.output.notice(
445
503
  unchanged
@@ -613,8 +671,6 @@ const uploadEvidenceSchema = storedAssetSchema.extend({
613
671
 
614
672
  const hashedReceiptSchema = uploadEvidenceSchema.extend({
615
673
  md5hash: storedAssetSchema.shape.md5hash.unwrap(),
616
- width: storedAssetSchema.shape.width.unwrap(),
617
- height: storedAssetSchema.shape.height.unwrap(),
618
674
  })
619
675
 
620
676
  /** Recovers pinned receipts from bounded native catalog pages without downloading originals. */
@@ -626,7 +682,9 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
626
682
  details: `
627
683
  Uses native catalog pages and dam:write scope to also recover declared public prefixes.
628
684
  Adds or refreshes matched paths; never prunes unmatched entries. Local placeholder metadata
629
- survives only for the same retained version. Every matched image must have catalog dimensions.
685
+ survives only for the same retained version. Known image MIME types with dimensions enter
686
+ images; other media and unknown MIME types enter files in the same catalog.
687
+ Mixed prefixes require this CLI version or newer.
630
688
  Any failure preserves the previous file. No Assembly, original download or remote write occurs.
631
689
  `,
632
690
  examples: [['Recover website images', 'transloadit storage receipts sync website/']],
@@ -663,23 +721,13 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
663
721
  assertStorageCatalogOrigin(previous, endpoint, this.receipts)
664
722
  const assets = await listStorageAssets(client, workspace, this.prefix, signal)
665
723
  const entries = assets.map((asset) => {
666
- try {
667
- validateStoragePath(asset.path)
668
- } catch (cause) {
669
- throw new Error(
670
- `Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`,
671
- { cause },
672
- )
673
- }
674
- if (asset.width === undefined || asset.height === undefined) {
675
- throw new Error(
676
- `Storage image ${JSON.stringify(asset.path)} needs positive catalog width and height. Select an image-only prefix and backfill missing dimensions before retrying.`,
677
- )
678
- }
679
724
  const evidence = uploadEvidenceSchema.safeParse(
680
725
  previous?.workspace === workspace && Object.hasOwn(previous.images, asset.path)
681
726
  ? previous.images[asset.path]
682
- : undefined,
727
+ : previous?.workspace === workspace &&
728
+ Object.hasOwn(previous.files ?? {}, asset.path)
729
+ ? previous.files?.[asset.path]
730
+ : undefined,
683
731
  )
684
732
  const retained =
685
733
  evidence.success &&
@@ -693,6 +741,18 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
693
741
  apiOrigin: endpoint,
694
742
  }
695
743
  if (asset.has_alpha !== undefined) delete merged.hasAlpha
744
+ // Listing already validates native paths. Only Viewer images use path-based delivery;
745
+ // generic files use asset/version IDs and may contain renderer delimiters such as |.
746
+ if (isStorageImageReceipt(merged)) {
747
+ try {
748
+ validateStoragePath(asset.path)
749
+ } catch (cause) {
750
+ throw new Error(
751
+ `Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`,
752
+ { cause },
753
+ )
754
+ }
755
+ }
696
756
  return [asset.path, merged]
697
757
  })
698
758
  count = entries.length
@@ -731,6 +791,17 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
731
791
  )
732
792
  })
733
793
  catalogUpdated = true
794
+ const images = new Map(Object.entries(previous?.images ?? {}))
795
+ const files = new Map(Object.entries(previous?.files ?? {}))
796
+ for (const [path, receipt] of Object.entries(synced)) {
797
+ if (isStorageImageReceipt(receipt)) {
798
+ files.delete(path)
799
+ images.set(path, receipt)
800
+ } else {
801
+ images.delete(path)
802
+ files.set(path, receipt)
803
+ }
804
+ }
734
805
  if (policy.public_prefixes.length === 0)
735
806
  this.output.notice(
736
807
  'No public prefixes are declared on the server. For public delivery, deliberately publish a directory with storage publish; otherwise configure authorize for private images. Sync never publishes files.',
@@ -743,7 +814,10 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
743
814
  previous?.delivery ??
744
815
  storageCatalogDelivery(this.endpoint ?? config.credentialsEndpoint),
745
816
  public: policy.public_prefixes.map(({ prefix }) => prefix),
746
- images: { ...previous?.images, ...synced },
817
+ images: Object.fromEntries(images),
818
+ ...(files.size > 0 || previous?.files !== undefined
819
+ ? { files: Object.fromEntries(files) }
820
+ : {}),
747
821
  }
748
822
  })
749
823
  this.output.print(
@@ -15,11 +15,12 @@ const receiptsSchema = z.custom<Record<string, unknown>>(
15
15
  (value: unknown) => typeof value === 'object' && value !== null && !Array.isArray(value),
16
16
  )
17
17
  const deliveryParameterSchema = z.union([z.string(), z.number(), z.boolean()])
18
+ const workspaceSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/)
18
19
 
19
20
  /** Project identity and rendering metadata (including optional preview pixels), without credentials. */
20
21
  export const storageCatalogSchema = z
21
22
  .object({
22
- workspace: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/),
23
+ workspace: workspaceSchema,
23
24
  apiOrigin: z.string().url().optional(),
24
25
  public: z.array(
25
26
  z.string().refine((prefix) => {
@@ -31,6 +32,7 @@ export const storageCatalogSchema = z
31
32
  }, 'Expected a normalized public directory'),
32
33
  ),
33
34
  images: receiptsSchema,
35
+ files: receiptsSchema.optional(),
34
36
  delivery: z
35
37
  .object({
36
38
  baseUrl: z.string().url().optional(),
@@ -42,6 +44,29 @@ export const storageCatalogSchema = z
42
44
  .optional(),
43
45
  })
44
46
  .passthrough()
47
+ .superRefine((catalog, context) => {
48
+ for (const [path, value] of Object.entries(catalog.files ?? {})) {
49
+ const receipt = storedAssetSchema.safeParse(value)
50
+ if (
51
+ !receipt.success ||
52
+ receipt.data.path !== path ||
53
+ receipt.data.workspace !== catalog.workspace
54
+ ) {
55
+ context.addIssue({
56
+ code: 'custom',
57
+ path: ['files', path],
58
+ message: 'Expected a matching Storage asset receipt',
59
+ })
60
+ }
61
+ if (Object.hasOwn(catalog.images, path)) {
62
+ context.addIssue({
63
+ code: 'custom',
64
+ path: ['files', path],
65
+ message: 'A path cannot occur in both images and files',
66
+ })
67
+ }
68
+ }
69
+ })
45
70
  export type StorageProjectCatalog = z.infer<typeof storageCatalogSchema>
46
71
 
47
72
  /** Default project catalog; --receipts can select a separate project explicitly. */
@@ -60,6 +85,13 @@ const imageReceiptSchema = storedAssetSchema.extend({
60
85
  height: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
61
86
  })
62
87
 
88
+ /** Only image receipts with intrinsic dimensions belong in the Viewer map. */
89
+ export function isStorageImageReceipt(value: unknown): value is z.infer<typeof imageReceiptSchema> {
90
+ const result = imageReceiptSchema.safeParse(value)
91
+ // Dimensions also describe videos; an unknown MIME type cannot establish a Viewer image.
92
+ return result.success && result.data.mime?.startsWith('image/') === true
93
+ }
94
+
63
95
  const legacyTypesHeader =
64
96
  '// Generated by transloadit storage. Commit this file with the image catalog; do not edit.'
65
97
  const typesHeader =
@@ -88,14 +120,20 @@ function catalogTypes(catalog: StorageProjectCatalog, file?: string): string {
88
120
  return [
89
121
  typesHeader,
90
122
  ...(file === undefined ? [] : [`// Catalog: ${JSON.stringify(basename(file))}`]),
91
- "import '@transloadit/viewer/next'",
92
- '',
93
- "declare module '@transloadit/viewer/next' {",
94
- ' interface RegisteredStorageImages {',
95
- ...properties,
96
- ' }',
97
- '}',
98
- '',
123
+ // Headerless legacy ownership must still match the old empty interface exactly. Newly
124
+ // generated file-only catalogs have an owner line and need no Viewer dependency.
125
+ ...(properties.length === 0 && file !== undefined
126
+ ? ['export {}', '']
127
+ : [
128
+ "import '@transloadit/viewer/next'",
129
+ '',
130
+ "declare module '@transloadit/viewer/next' {",
131
+ ' interface RegisteredStorageImages {',
132
+ ...properties,
133
+ ' }',
134
+ '}',
135
+ '',
136
+ ]),
99
137
  ].join('\n')
100
138
  }
101
139
 
@@ -128,7 +166,7 @@ export async function readStorageCatalog(file: string): Promise<StorageProjectCa
128
166
 
129
167
  /** A workspace override selects another workspace, never another key or implicit project rebinding. */
130
168
  export function assertStorageWorkspace(actual: string, project?: string, requested?: string): void {
131
- if (!storageCatalogSchema.shape.workspace.safeParse(actual).success)
169
+ if (!workspaceSchema.safeParse(actual).success)
132
170
  throw new Error(
133
171
  `Invalid Storage Workspace ${JSON.stringify(actual)}: expected a URL-safe slug beginning with a letter or digit.`,
134
172
  )
@@ -158,12 +196,13 @@ export function assertStorageCatalogOrigin(
158
196
  catalog !== undefined &&
159
197
  catalog.apiOrigin === undefined &&
160
198
  Object.keys(catalog.images).length === 0 &&
199
+ Object.keys(catalog.files ?? {}).length === 0 &&
161
200
  (catalog.public.length > 0 || catalog.delivery !== undefined)
162
201
  )
163
202
  throw new Error(
164
203
  `Catalog ${JSON.stringify(file)} has no verified API origin. Run storage receipts sync into a new --receipts file using the original API --endpoint, then review and replace this catalog. The existing file was preserved.`,
165
204
  )
166
- for (const [path, value] of Object.entries(catalog?.images ?? {})) {
205
+ for (const [path, value] of Object.entries({ ...catalog?.images, ...catalog?.files })) {
167
206
  const previous = receiptOriginSchema.safeParse(value)
168
207
  if (
169
208
  !previous.success ||