@holo-js/media 0.1.4 → 0.1.6

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 (3) hide show
  1. package/dist/index.d.ts +906 -6001
  2. package/dist/index.mjs +336 -83
  3. package/package.json +7 -7
package/dist/index.mjs CHANGED
@@ -76,6 +76,9 @@ function getContentSize(value) {
76
76
  if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
77
77
  return value.byteLength;
78
78
  }
79
+ if (value instanceof ArrayBuffer) {
80
+ return value.byteLength;
81
+ }
79
82
  return value.size;
80
83
  }
81
84
  function isImageSource(mimeType, extension) {
@@ -269,7 +272,17 @@ function resolveMediaCollection(target, collectionName = "default") {
269
272
  acceptedExtensions: Object.freeze([])
270
273
  });
271
274
  }
272
- return definition.collectionsByName[collectionName] ?? Object.freeze({
275
+ const collection2 = definition.collectionsByName[collectionName];
276
+ if (collection2) {
277
+ return collection2;
278
+ }
279
+ if (collectionName !== "default") {
280
+ const model = resolveDefinition(target);
281
+ throw new Error(
282
+ `[Holo Media] Unknown media collection "${collectionName}" for model "${model.name}".`
283
+ );
284
+ }
285
+ return Object.freeze({
273
286
  kind: "collection",
274
287
  name: collectionName,
275
288
  singleFile: false,
@@ -352,10 +365,13 @@ var Media = defineModel(mediaTable, {
352
365
  });
353
366
 
354
367
  // src/model/adder.ts
355
- import { basename as basename2 } from "path";
356
- import { readFile } from "fs/promises";
368
+ import { isIP } from "net";
369
+ import { tmpdir } from "os";
370
+ import { basename as basename2, resolve, sep } from "path";
371
+ import { readFile, realpath } from "fs/promises";
357
372
  import { randomUUID } from "crypto";
358
373
  import { Storage as Storage3 } from "@holo-js/storage/runtime";
374
+ import { connectionAsyncContext as connectionAsyncContext4 } from "@holo-js/db";
359
375
 
360
376
  // src/queue.ts
361
377
  import {
@@ -364,10 +380,59 @@ import {
364
380
  getRegisteredQueueJob,
365
381
  registerQueueJob
366
382
  } from "@holo-js/queue";
367
- import { connectionAsyncContext } from "@holo-js/db";
383
+ import { connectionAsyncContext as connectionAsyncContext2 } from "@holo-js/db";
368
384
 
369
385
  // src/model/conversions.ts
370
386
  import { Storage } from "@holo-js/storage/runtime";
387
+ import { connectionAsyncContext } from "@holo-js/db";
388
+ function getActiveTransaction() {
389
+ const active = connectionAsyncContext.getActive()?.connection;
390
+ if (!active || active.getScope().kind === "root") {
391
+ return void 0;
392
+ }
393
+ return active;
394
+ }
395
+ function registerStorageWriteRollback(snapshot) {
396
+ const active = getActiveTransaction();
397
+ if (!active) {
398
+ return;
399
+ }
400
+ active.afterRollback(async () => {
401
+ await restoreStoredFileSnapshot(snapshot);
402
+ });
403
+ }
404
+ async function restoreStoredFileSnapshot(snapshot) {
405
+ if (snapshot.contents) {
406
+ await Storage.disk(snapshot.disk).put(snapshot.path, snapshot.contents);
407
+ return;
408
+ }
409
+ await Storage.disk(snapshot.disk).delete(snapshot.path);
410
+ }
411
+ async function restoreStoredFileSnapshots(snapshots) {
412
+ for (const snapshot of [...snapshots].reverse()) {
413
+ await restoreStoredFileSnapshot(snapshot).catch(() => void 0);
414
+ }
415
+ }
416
+ async function putFileWithRollbackRestore(disk, path, contents) {
417
+ const previous = await Storage.disk(disk).getBytes(path);
418
+ await Storage.disk(disk).put(path, contents);
419
+ const snapshot = {
420
+ disk,
421
+ path,
422
+ contents: previous
423
+ };
424
+ registerStorageWriteRollback(snapshot);
425
+ return snapshot;
426
+ }
427
+ async function deleteFileWithRollbackRestore(disk, path) {
428
+ const previous = await Storage.disk(disk).getBytes(path);
429
+ await Storage.disk(disk).delete(path);
430
+ registerStorageWriteRollback({
431
+ disk,
432
+ path,
433
+ contents: previous
434
+ });
435
+ }
371
436
  function fallbackCollectionDefinition(collectionName) {
372
437
  return Object.freeze({
373
438
  kind: "collection",
@@ -447,6 +512,7 @@ async function generateStoredConversions(options) {
447
512
  }
448
513
  const generatedConversions = /* @__PURE__ */ Object.create(null);
449
514
  const executor = getMediaConversionExecutor();
515
+ const writtenSnapshots = [];
450
516
  try {
451
517
  for (const conversion2 of matchingConversions) {
452
518
  const generated = await executor.generate({
@@ -469,7 +535,7 @@ async function generateStoredConversions(options) {
469
535
  const targetDisk = generated.disk ?? options.conversionsDisk;
470
536
  const conversionMimeType = inferMimeType(generatedFileName, generated.mimeType);
471
537
  const conversionContents = await toBinaryContent(generated.contents);
472
- await Storage.disk(targetDisk).put(conversionPath, conversionContents);
538
+ writtenSnapshots.push(await putFileWithRollbackRestore(targetDisk, conversionPath, conversionContents));
473
539
  generatedConversions[conversion2.name] = Object.freeze({
474
540
  path: conversionPath,
475
541
  disk: targetDisk,
@@ -479,19 +545,11 @@ async function generateStoredConversions(options) {
479
545
  });
480
546
  }
481
547
  } catch (error) {
482
- await deleteStoredConversions(
483
- generatedConversions,
484
- options.conversionsDisk
485
- );
548
+ await restoreStoredFileSnapshots(writtenSnapshots);
486
549
  throw error;
487
550
  }
488
551
  return Object.freeze(generatedConversions);
489
552
  }
490
- async function deleteStoredConversions(conversions, _fallbackDisk) {
491
- for (const conversion2 of Object.values(conversions)) {
492
- await Storage.disk(conversion2.disk).delete(conversion2.path);
493
- }
494
- }
495
553
  async function deleteObsoleteConversions(current, next, fallbackDisk, requested) {
496
554
  for (const [name, conversion2] of Object.entries(current)) {
497
555
  if (requested?.length && !requested.includes(name)) {
@@ -504,7 +562,7 @@ async function deleteObsoleteConversions(current, next, fallbackDisk, requested)
504
562
  if (nextConversion?.path === conversion2.path && nextConversion.disk === (conversion2.disk ?? fallbackDisk)) {
505
563
  continue;
506
564
  }
507
- await Storage.disk(conversion2.disk ?? fallbackDisk).delete(conversion2.path);
565
+ await deleteFileWithRollbackRestore(conversion2.disk ?? fallbackDisk, conversion2.path);
508
566
  }
509
567
  }
510
568
  async function regenerateMediaEntityConversions(options) {
@@ -588,22 +646,30 @@ function normalizePayload(payload) {
588
646
  }
589
647
  async function runMediaGenerateConversionsJob(payload) {
590
648
  const normalized = normalizePayload(payload);
591
- const media = await Media.find(normalized.mediaId);
592
- if (!media) {
649
+ if (normalized.conversionNames.length === 0) {
593
650
  return Object.freeze({
594
- status: "missing-media",
595
- conversionNames: Object.freeze([])
651
+ status: "processed",
652
+ conversionNames: normalized.conversionNames
596
653
  });
597
654
  }
598
- await regenerateMediaEntityConversions({
599
- media,
600
- conversions: normalized.conversionNames,
601
- includeQueued: true
602
- });
603
- return Object.freeze({
604
- status: "processed",
605
- conversionNames: normalized.conversionNames
606
- });
655
+ const media = typeof normalized.mediaId === "number" ? await Media.find(normalized.mediaId) : await Media.where("id", normalized.mediaId).first();
656
+ switch (media) {
657
+ case void 0:
658
+ return Object.freeze({
659
+ status: "missing-media",
660
+ conversionNames: Object.freeze([])
661
+ });
662
+ default:
663
+ await regenerateMediaEntityConversions({
664
+ media,
665
+ conversions: normalized.conversionNames,
666
+ includeQueued: true
667
+ });
668
+ return Object.freeze({
669
+ status: "processed",
670
+ conversionNames: normalized.conversionNames
671
+ });
672
+ }
607
673
  }
608
674
  function ensureMediaQueueJobRegistered() {
609
675
  if (getRegisteredQueueJob(MEDIA_GENERATE_CONVERSIONS_JOB)) {
@@ -618,8 +684,8 @@ function ensureMediaQueueJobRegistered() {
618
684
  name: MEDIA_GENERATE_CONVERSIONS_JOB
619
685
  });
620
686
  }
621
- function getActiveTransaction() {
622
- const active = connectionAsyncContext.getActive()?.connection;
687
+ function getActiveTransaction2() {
688
+ const active = connectionAsyncContext2.getActive()?.connection;
623
689
  if (!active || active.getScope().kind === "root") {
624
690
  return void 0;
625
691
  }
@@ -637,7 +703,7 @@ async function dispatchQueuedMediaConversions(payload) {
637
703
  return;
638
704
  }
639
705
  ensureMediaQueueJobRegistered();
640
- const activeTransaction = getActiveTransaction();
706
+ const activeTransaction = getActiveTransaction2();
641
707
  if (activeTransaction) {
642
708
  activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized));
643
709
  return;
@@ -650,7 +716,7 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
650
716
  return;
651
717
  }
652
718
  ensureMediaQueueJobRegistered();
653
- const activeTransaction = getActiveTransaction();
719
+ const activeTransaction = getActiveTransaction2();
654
720
  if (activeTransaction) {
655
721
  activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized, onSynchronousDispatch));
656
722
  return;
@@ -660,6 +726,29 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
660
726
 
661
727
  // src/model/item.ts
662
728
  import { Storage as Storage2 } from "@holo-js/storage/runtime";
729
+ import { connectionAsyncContext as connectionAsyncContext3 } from "@holo-js/db";
730
+ function toUrlPath(url) {
731
+ if (url.startsWith("/")) {
732
+ return url;
733
+ }
734
+ try {
735
+ const parsed = new URL(url);
736
+ return `${parsed.pathname}${parsed.search}`;
737
+ } catch {
738
+ return `/${url.replace(/^\/+/, "")}`;
739
+ }
740
+ }
741
+ function registerDeletedVariantRollbackRestore(snapshots) {
742
+ const active = connectionAsyncContext3.getActive()?.connection;
743
+ if (!active || active.getScope().kind === "root") {
744
+ return;
745
+ }
746
+ active.afterRollback(async () => {
747
+ for (const snapshot of [...snapshots].reverse()) {
748
+ await Storage2.disk(snapshot.disk).put(snapshot.path, snapshot.contents).catch(() => void 0);
749
+ }
750
+ });
751
+ }
663
752
  var MediaItem = class {
664
753
  constructor(entity, owner) {
665
754
  this.entity = entity;
@@ -690,15 +779,8 @@ var MediaItem = class {
690
779
  );
691
780
  }
692
781
  getPath(conversion2) {
693
- const variant = this.resolveVariant(conversion2);
694
- if (!variant) {
695
- return null;
696
- }
697
- try {
698
- return Storage2.disk(variant.disk).path(variant.path);
699
- } catch {
700
- return null;
701
- }
782
+ const url = this.getUrl(conversion2);
783
+ return url ? toUrlPath(url) : null;
702
784
  }
703
785
  getUrl(conversion2) {
704
786
  const variant = this.resolveVariant(conversion2);
@@ -737,6 +819,7 @@ var MediaItem = class {
737
819
  }
738
820
  }
739
821
  await this.entity.delete();
822
+ registerDeletedVariantRollbackRestore(deletedSnapshots);
740
823
  this.owner?.forgetRelation("media");
741
824
  } catch (error) {
742
825
  for (const snapshot of deletedSnapshots.reverse()) {
@@ -806,6 +889,20 @@ var MediaItem = class {
806
889
  };
807
890
 
808
891
  // src/model/adder.ts
892
+ var DEFAULT_REMOTE_MEDIA_MAX_SIZE = 10 * 1024 * 1024;
893
+ var LOCAL_MEDIA_SOURCE_ROOTS = Object.freeze([
894
+ tmpdir(),
895
+ resolve(process.cwd(), "storage")
896
+ ]);
897
+ var MediaAddValidationException = class extends Error {
898
+ error;
899
+ constructor(details) {
900
+ const error = createMediaAddError(details);
901
+ super(error.message);
902
+ this.name = "MediaAddValidationException";
903
+ this.error = error;
904
+ }
905
+ };
809
906
  function resolveImplicitDiskName() {
810
907
  const defaultDisk = Storage3.disk();
811
908
  if (defaultDisk.visibility === "public") {
@@ -828,14 +925,53 @@ function parseRemoteFileName(url) {
828
925
  return "media.bin";
829
926
  }
830
927
  }
831
- function createMaxSizeError(fileName, collectionName) {
832
- return new Error(
833
- `[Holo Media] File "${fileName}" exceeds the max size for collection "${collectionName}".`
834
- );
928
+ function formatBytes(bytes) {
929
+ if (!Number.isFinite(bytes) || bytes < 0) {
930
+ return "0 bytes";
931
+ }
932
+ const units = ["bytes", "KB", "MB", "GB"];
933
+ let value = bytes;
934
+ let unitIndex = 0;
935
+ while (value >= 1024 && unitIndex < units.length - 1) {
936
+ value /= 1024;
937
+ unitIndex += 1;
938
+ }
939
+ const formatted = unitIndex === 0 ? String(Math.round(value)) : value.toFixed(value >= 10 ? 0 : 1).replace(/\.0$/, "");
940
+ return `${formatted} ${units[unitIndex]}`;
941
+ }
942
+ function createMediaValidationMessage(details) {
943
+ if (details.code === "max_size_exceeded") {
944
+ return `The selected file must be ${formatBytes(details.maxSize)} or smaller.`;
945
+ }
946
+ if (details.code === "invalid_mime_type") {
947
+ return `The selected file must be one of these types: ${details.acceptedMimeTypes.join(", ")}.`;
948
+ }
949
+ return `The selected file must use one of these extensions: ${details.acceptedExtensions.join(", ")}.`;
950
+ }
951
+ function createMediaAddError(details) {
952
+ return Object.freeze({
953
+ ...details,
954
+ status: 422,
955
+ message: createMediaValidationMessage(details)
956
+ });
957
+ }
958
+ function createMaxSizeError(fileName, collectionName, maxSize, actualSize) {
959
+ return new MediaAddValidationException({
960
+ code: "max_size_exceeded",
961
+ collection: collectionName,
962
+ fileName,
963
+ maxSize,
964
+ actualSize
965
+ });
835
966
  }
836
967
  async function readRemoteMediaContents(response, fileName, maxSize, collectionName) {
837
- if (typeof maxSize !== "number" || !response.body) {
838
- return new Uint8Array(await response.arrayBuffer());
968
+ const resolvedMaxSize = typeof maxSize === "number" ? maxSize : DEFAULT_REMOTE_MEDIA_MAX_SIZE;
969
+ if (!response.body) {
970
+ const contents2 = new Uint8Array(await response.arrayBuffer());
971
+ if (contents2.byteLength > resolvedMaxSize) {
972
+ throw createMaxSizeError(fileName, collectionName ?? "default", resolvedMaxSize, contents2.byteLength);
973
+ }
974
+ return contents2;
839
975
  }
840
976
  const reader = response.body.getReader();
841
977
  const chunks = [];
@@ -850,9 +986,9 @@ async function readRemoteMediaContents(response, fileName, maxSize, collectionNa
850
986
  continue;
851
987
  }
852
988
  totalSize += value.byteLength;
853
- if (totalSize > maxSize) {
989
+ if (totalSize > resolvedMaxSize) {
854
990
  await reader.cancel().catch(() => void 0);
855
- throw createMaxSizeError(fileName, collectionName);
991
+ throw createMaxSizeError(fileName, collectionName ?? "default", resolvedMaxSize, totalSize);
856
992
  }
857
993
  chunks.push(value);
858
994
  }
@@ -887,7 +1023,7 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
887
1023
  );
888
1024
  }
889
1025
  if (typeof input === "string" || isPathInput(input)) {
890
- const path = typeof input === "string" ? input : input.path;
1026
+ const path = await resolveLocalMediaSourcePath(typeof input === "string" ? input : input.path);
891
1027
  const contents2 = await readFile(path);
892
1028
  const fileName2 = sanitizeFileName(overrideFileName ?? basename2(path));
893
1029
  return {
@@ -912,16 +1048,24 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
912
1048
  };
913
1049
  }
914
1050
  const contents = await toBinaryContent(input);
915
- const fileName = sanitizeFileName(overrideFileName ?? "media.bin");
1051
+ const fileName = sanitizeFileName(overrideFileName ?? getBinaryFileName(input));
916
1052
  return {
917
1053
  contents,
918
1054
  fileName,
919
- mimeType: inferMimeType(fileName),
1055
+ mimeType: inferMimeType(fileName, getBinaryMimeType(input)),
920
1056
  extension: getExtension(fileName),
921
1057
  size: getContentSize(contents),
922
1058
  name: getDisplayName(fileName, overrideName)
923
1059
  };
924
1060
  }
1061
+ function getBinaryFileName(input) {
1062
+ const name = input.name;
1063
+ return typeof name === "string" && name.trim() ? name : "media.bin";
1064
+ }
1065
+ function getBinaryMimeType(input) {
1066
+ const type = input.type;
1067
+ return typeof type === "string" && type.trim() ? type : void 0;
1068
+ }
925
1069
  function isPathInput(input) {
926
1070
  return typeof input === "object" && input !== null && "path" in input;
927
1071
  }
@@ -929,20 +1073,27 @@ function isUrlInput(input) {
929
1073
  return typeof input === "object" && input !== null && "url" in input;
930
1074
  }
931
1075
  async function resolveRemoteMediaSource(input, overrideFileName, overrideName, maxSize, collectionName) {
932
- const response = await fetch(input.url);
1076
+ const remoteUrl = resolveRemoteMediaUrl(input.url);
1077
+ const response = await fetch(remoteUrl, {
1078
+ redirect: "manual"
1079
+ });
1080
+ if (response.status >= 300 && response.status < 400) {
1081
+ throw new Error("[@holo-js/media] Remote media downloads do not follow redirects.");
1082
+ }
933
1083
  if (!response.ok) {
934
1084
  throw new Error(
935
- `[Holo Media] Failed to download media from "${input.url}" (${response.status} ${response.statusText}).`
1085
+ `[Holo Media] Failed to download media from "${remoteUrl}" (${response.status} ${response.statusText}).`
936
1086
  );
937
1087
  }
938
1088
  const fileName = sanitizeFileName(
939
- overrideFileName ?? input.fileName ?? parseRemoteFileName(input.url)
1089
+ overrideFileName ?? input.fileName ?? parseRemoteFileName(remoteUrl)
940
1090
  );
941
1091
  const responseMimeType = response.headers.get("content-type")?.split(";")[0];
942
1092
  const contentLengthHeader = response.headers.get("content-length");
943
1093
  const contentLength = contentLengthHeader?.trim() ? Number(contentLengthHeader) : Number.NaN;
944
- if (typeof maxSize === "number" && Number.isFinite(contentLength) && contentLength > maxSize) {
945
- throw createMaxSizeError(fileName, collectionName);
1094
+ const resolvedMaxSize = typeof maxSize === "number" ? maxSize : DEFAULT_REMOTE_MEDIA_MAX_SIZE;
1095
+ if (Number.isFinite(contentLength) && contentLength > resolvedMaxSize) {
1096
+ throw createMaxSizeError(fileName, collectionName ?? "default", resolvedMaxSize, contentLength);
946
1097
  }
947
1098
  const contents = await readRemoteMediaContents(
948
1099
  response,
@@ -959,25 +1110,75 @@ async function resolveRemoteMediaSource(input, overrideFileName, overrideName, m
959
1110
  name: getDisplayName(fileName, overrideName ?? input.name)
960
1111
  };
961
1112
  }
1113
+ async function resolveLocalMediaSourcePath(path) {
1114
+ const resolvedPath = await realpath(path);
1115
+ const allowedRoots = await Promise.all(LOCAL_MEDIA_SOURCE_ROOTS.map(async (root) => await realpath(root).catch(() => null)));
1116
+ if (allowedRoots.some((root) => root && isPathWithinRoot(resolvedPath, root))) {
1117
+ return resolvedPath;
1118
+ }
1119
+ throw new Error("[@holo-js/media] Media path sources must resolve inside an allowed upload directory.");
1120
+ }
1121
+ function isPathWithinRoot(path, root) {
1122
+ return path === root || path.startsWith(`${root}${sep}`);
1123
+ }
1124
+ function resolveRemoteMediaUrl(value) {
1125
+ let url;
1126
+ try {
1127
+ url = new URL(value);
1128
+ } catch {
1129
+ throw new Error("[@holo-js/media] Remote media URLs must be valid absolute URLs.");
1130
+ }
1131
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1132
+ throw new Error("[@holo-js/media] Remote media URLs must use http or https.");
1133
+ }
1134
+ if (url.username || url.password) {
1135
+ throw new Error("[@holo-js/media] Remote media URLs must not include credentials.");
1136
+ }
1137
+ if (isBlockedRemoteMediaHost(url.hostname)) {
1138
+ throw new Error("[@holo-js/media] Remote media URLs must not target local or private hosts.");
1139
+ }
1140
+ return url.toString();
1141
+ }
1142
+ function isBlockedRemoteMediaHost(hostname) {
1143
+ const normalized = hostname.toLowerCase();
1144
+ if (normalized === "localhost" || normalized.endsWith(".localhost")) {
1145
+ return true;
1146
+ }
1147
+ const ipVersion = isIP(normalized);
1148
+ if (ipVersion === 4) {
1149
+ const [first = 0, second = 0] = normalized.split(".").map(Number);
1150
+ return first === 0 || first === 10 || first === 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first >= 224;
1151
+ }
1152
+ if (ipVersion === 6) {
1153
+ return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:");
1154
+ }
1155
+ return false;
1156
+ }
962
1157
  function validateSource(collection2, source) {
963
1158
  if (typeof collection2.maxSize === "number" && source.size > collection2.maxSize) {
964
- throw new Error(
965
- `[Holo Media] File "${source.fileName}" exceeds the max size for collection "${collection2.name}".`
966
- );
1159
+ throw createMaxSizeError(source.fileName, collection2.name, collection2.maxSize, source.size);
967
1160
  }
968
1161
  if (collection2.acceptedMimeTypes.length > 0) {
969
1162
  const mimeType = source.mimeType?.trim().toLowerCase();
970
1163
  if (!mimeType || !collection2.acceptedMimeTypes.includes(mimeType)) {
971
- throw new Error(
972
- `[Holo Media] File "${source.fileName}" is not an accepted MIME type for collection "${collection2.name}".`
973
- );
1164
+ throw new MediaAddValidationException({
1165
+ code: "invalid_mime_type",
1166
+ collection: collection2.name,
1167
+ fileName: source.fileName,
1168
+ mimeType,
1169
+ acceptedMimeTypes: collection2.acceptedMimeTypes
1170
+ });
974
1171
  }
975
1172
  }
976
1173
  if (collection2.acceptedExtensions.length > 0) {
977
1174
  if (!source.extension || !collection2.acceptedExtensions.includes(source.extension)) {
978
- throw new Error(
979
- `[Holo Media] File "${source.fileName}" is not an accepted extension for collection "${collection2.name}".`
980
- );
1175
+ throw new MediaAddValidationException({
1176
+ code: "invalid_extension",
1177
+ collection: collection2.name,
1178
+ fileName: source.fileName,
1179
+ extension: source.extension,
1180
+ acceptedExtensions: collection2.acceptedExtensions
1181
+ });
981
1182
  }
982
1183
  }
983
1184
  }
@@ -994,23 +1195,29 @@ async function deleteMediaItemsWithRollback(items) {
994
1195
  deletedSnapshots.push(snapshot);
995
1196
  }
996
1197
  } catch (error) {
997
- for (const snapshot of deletedSnapshots.reverse()) {
998
- await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
999
- }
1198
+ await restoreDeletedMediaSnapshots(deletedSnapshots);
1000
1199
  throw error;
1001
1200
  }
1201
+ return deletedSnapshots;
1002
1202
  }
1003
1203
  async function deleteOverflowItems(items, limit) {
1004
1204
  if (items.length <= limit) {
1005
- return;
1205
+ return [];
1006
1206
  }
1007
- await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
1207
+ return await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
1008
1208
  }
1009
1209
  async function cleanupGeneratedConversions(conversions, _fallbackDisk) {
1010
1210
  for (const conversion2 of Object.values(conversions)) {
1011
1211
  await Storage3.disk(conversion2.disk).delete(conversion2.path).catch(() => void 0);
1012
1212
  }
1013
1213
  }
1214
+ function registerCreatedMediaRollbackCleanup(cleanup) {
1215
+ const active = connectionAsyncContext4.getActive()?.connection;
1216
+ if (!active || active.getScope().kind === "root") {
1217
+ return;
1218
+ }
1219
+ active.afterRollback(cleanup);
1220
+ }
1014
1221
  async function snapshotDeletedMediaItem(item) {
1015
1222
  const record = item.record;
1016
1223
  const fileTargets = [{
@@ -1060,6 +1267,11 @@ async function restoreDeletedMediaSnapshot(snapshot) {
1060
1267
  }
1061
1268
  await Media.create(snapshot.record);
1062
1269
  }
1270
+ async function restoreDeletedMediaSnapshots(snapshots) {
1271
+ for (const snapshot of snapshots.reverse()) {
1272
+ await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
1273
+ }
1274
+ }
1063
1275
  var MediaAdder = class _MediaAdder {
1064
1276
  constructor(entity, source, state = {}) {
1065
1277
  this.entity = entity;
@@ -1085,6 +1297,23 @@ var MediaAdder = class _MediaAdder {
1085
1297
  });
1086
1298
  }
1087
1299
  async toMediaCollection(collectionName = "default") {
1300
+ try {
1301
+ const media = await this.storeInMediaCollection(collectionName);
1302
+ return Object.assign(media, {
1303
+ data: media,
1304
+ error: null
1305
+ });
1306
+ } catch (error) {
1307
+ if (error instanceof MediaAddValidationException) {
1308
+ return {
1309
+ data: null,
1310
+ error: error.error
1311
+ };
1312
+ }
1313
+ throw error;
1314
+ }
1315
+ }
1316
+ async storeInMediaCollection(collectionName = "default") {
1088
1317
  const mediaDefinition = requireMediaDefinition(this.entity);
1089
1318
  const ownerDefinition = this.entity.getRepository().definition;
1090
1319
  const ownerId = this.entity.get(ownerDefinition.primaryKey);
@@ -1117,9 +1346,14 @@ var MediaAdder = class _MediaAdder {
1117
1346
  let originalStored = false;
1118
1347
  let createdMedia;
1119
1348
  let generatedConversions = Object.freeze({});
1349
+ const deletedMediaSnapshots = [];
1120
1350
  try {
1121
1351
  await Storage3.disk(diskName).put(originalPath, source.contents);
1122
1352
  originalStored = true;
1353
+ registerCreatedMediaRollbackCleanup(async () => {
1354
+ await cleanupGeneratedConversions(generatedConversions, conversionsDisk).catch(() => void 0);
1355
+ await Storage3.disk(diskName).delete(originalPath).catch(() => void 0);
1356
+ });
1123
1357
  generatedConversions = await generateStoredConversions({
1124
1358
  definition: mediaDefinition,
1125
1359
  collection: collection2,
@@ -1155,10 +1389,10 @@ var MediaAdder = class _MediaAdder {
1155
1389
  });
1156
1390
  createdMedia = media;
1157
1391
  this.entity.forgetRelation("media");
1158
- await deleteMediaItemsWithRollback(existing);
1392
+ deletedMediaSnapshots.push(...await deleteMediaItemsWithRollback(existing));
1159
1393
  if (typeof collection2.onlyKeepLatest === "number") {
1160
1394
  const items = await this.entity.getMedia(collectionName);
1161
- await deleteOverflowItems(items, collection2.onlyKeepLatest);
1395
+ deletedMediaSnapshots.push(...await deleteOverflowItems(items, collection2.onlyKeepLatest));
1162
1396
  }
1163
1397
  const queuedConversions = resolveQueuedConversionNames({
1164
1398
  definition: mediaDefinition,
@@ -1180,6 +1414,7 @@ var MediaAdder = class _MediaAdder {
1180
1414
  await createdMedia.refresh().catch(() => void 0);
1181
1415
  await createdMedia.delete().catch(() => void 0);
1182
1416
  }
1417
+ await restoreDeletedMediaSnapshots(deletedMediaSnapshots);
1183
1418
  this.entity.forgetRelation("media");
1184
1419
  throw error;
1185
1420
  }
@@ -1286,6 +1521,12 @@ function installEntityMediaMethods() {
1286
1521
  import { morphMany } from "@holo-js/db";
1287
1522
 
1288
1523
  // src/definitions/collections.ts
1524
+ function normalizePositiveInteger(value, fieldName) {
1525
+ if (!Number.isFinite(value)) {
1526
+ throw new Error(`[Holo Media] Media collection ${fieldName} must be a finite number.`);
1527
+ }
1528
+ return Math.max(1, Math.floor(value));
1529
+ }
1289
1530
  function decorateCollection(definition) {
1290
1531
  const clone = (overrides) => decorateCollection(Object.freeze({
1291
1532
  ...definition,
@@ -1306,7 +1547,7 @@ function decorateCollection(definition) {
1306
1547
  },
1307
1548
  onlyKeepLatest(limit) {
1308
1549
  return clone({
1309
- onlyKeepLatest: Math.max(1, Math.floor(limit))
1550
+ onlyKeepLatest: normalizePositiveInteger(limit, "onlyKeepLatest")
1310
1551
  });
1311
1552
  },
1312
1553
  acceptsMimeTypes(mimeTypes) {
@@ -1325,7 +1566,7 @@ function decorateCollection(definition) {
1325
1566
  },
1326
1567
  maxSize(bytes) {
1327
1568
  return clone({
1328
- maxFileSize: Math.max(1, Math.floor(bytes))
1569
+ maxFileSize: normalizePositiveInteger(bytes, "maxFileSize")
1329
1570
  });
1330
1571
  }
1331
1572
  });
@@ -1346,19 +1587,31 @@ function normalizeCollectionDefinitions(definitions) {
1346
1587
  disk: definition.diskName?.trim() || void 0,
1347
1588
  conversionsDisk: definition.conversionsDiskName?.trim() || void 0,
1348
1589
  singleFile: Boolean(definition.singleFile),
1349
- onlyKeepLatest: typeof definition.onlyKeepLatest === "number" ? Math.max(1, Math.floor(definition.onlyKeepLatest)) : void 0,
1590
+ onlyKeepLatest: typeof definition.onlyKeepLatest === "number" ? normalizePositiveInteger(definition.onlyKeepLatest, "onlyKeepLatest") : void 0,
1350
1591
  acceptedMimeTypes: Object.freeze(
1351
1592
  definition.acceptedMimeTypes.map((mimeType) => mimeType.trim()).map((mimeType) => mimeType.toLowerCase()).filter(Boolean)
1352
1593
  ),
1353
1594
  acceptedExtensions: Object.freeze(
1354
1595
  definition.acceptedExtensions.map((extension) => extension.trim().replace(/^\./, "").toLowerCase()).filter(Boolean)
1355
1596
  ),
1356
- maxSize: typeof definition.maxFileSize === "number" ? Math.max(1, Math.floor(definition.maxFileSize)) : void 0
1597
+ maxSize: typeof definition.maxFileSize === "number" ? normalizePositiveInteger(definition.maxFileSize, "maxFileSize") : void 0
1357
1598
  });
1358
1599
  }));
1359
1600
  }
1360
1601
 
1361
1602
  // src/definitions/conversions.ts
1603
+ function normalizePositiveInteger2(value, fieldName) {
1604
+ if (!Number.isFinite(value)) {
1605
+ throw new Error(`[Holo Media] Media conversion ${fieldName} must be a finite number.`);
1606
+ }
1607
+ return Math.max(1, Math.floor(value));
1608
+ }
1609
+ function normalizeQuality(value) {
1610
+ if (!Number.isFinite(value)) {
1611
+ throw new Error("[Holo Media] Media conversion quality must be a finite number.");
1612
+ }
1613
+ return Math.max(1, Math.min(100, Math.floor(value)));
1614
+ }
1362
1615
  function decorateConversion(definition) {
1363
1616
  const clone = (overrides) => decorateConversion(Object.freeze({
1364
1617
  ...definition,
@@ -1377,12 +1630,12 @@ function decorateConversion(definition) {
1377
1630
  },
1378
1631
  width(pixels) {
1379
1632
  return clone({
1380
- width: Math.max(1, Math.floor(pixels))
1633
+ width: normalizePositiveInteger2(pixels, "width")
1381
1634
  });
1382
1635
  },
1383
1636
  height(pixels) {
1384
1637
  return clone({
1385
- height: Math.max(1, Math.floor(pixels))
1638
+ height: normalizePositiveInteger2(pixels, "height")
1386
1639
  });
1387
1640
  },
1388
1641
  fit(mode) {
@@ -1393,7 +1646,7 @@ function decorateConversion(definition) {
1393
1646
  },
1394
1647
  quality(value) {
1395
1648
  return clone({
1396
- quality: Math.max(1, Math.min(100, Math.floor(value)))
1649
+ quality: normalizeQuality(value)
1397
1650
  });
1398
1651
  },
1399
1652
  queued() {
@@ -1419,9 +1672,9 @@ function normalizeConversionDefinitions(definitions) {
1419
1672
  collections: Object.freeze(
1420
1673
  definition.collections.filter(Boolean)
1421
1674
  ),
1422
- width: typeof definition.width === "number" ? Math.max(1, Math.floor(definition.width)) : void 0,
1423
- height: typeof definition.height === "number" ? Math.max(1, Math.floor(definition.height)) : void 0,
1424
- quality: typeof definition.quality === "number" ? Math.max(1, Math.min(100, Math.floor(definition.quality))) : void 0,
1675
+ width: typeof definition.width === "number" ? normalizePositiveInteger2(definition.width, "width") : void 0,
1676
+ height: typeof definition.height === "number" ? normalizePositiveInteger2(definition.height, "height") : void 0,
1677
+ quality: typeof definition.quality === "number" ? normalizeQuality(definition.quality) : void 0,
1425
1678
  queued: Boolean(definition.queued)
1426
1679
  });
1427
1680
  }));