@holo-js/media 0.1.4 → 0.1.5

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 +263 -73
  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,
@@ -356,6 +369,7 @@ import { basename as basename2 } from "path";
356
369
  import { readFile } from "fs/promises";
357
370
  import { randomUUID } from "crypto";
358
371
  import { Storage as Storage3 } from "@holo-js/storage/runtime";
372
+ import { connectionAsyncContext as connectionAsyncContext4 } from "@holo-js/db";
359
373
 
360
374
  // src/queue.ts
361
375
  import {
@@ -364,10 +378,59 @@ import {
364
378
  getRegisteredQueueJob,
365
379
  registerQueueJob
366
380
  } from "@holo-js/queue";
367
- import { connectionAsyncContext } from "@holo-js/db";
381
+ import { connectionAsyncContext as connectionAsyncContext2 } from "@holo-js/db";
368
382
 
369
383
  // src/model/conversions.ts
370
384
  import { Storage } from "@holo-js/storage/runtime";
385
+ import { connectionAsyncContext } from "@holo-js/db";
386
+ function getActiveTransaction() {
387
+ const active = connectionAsyncContext.getActive()?.connection;
388
+ if (!active || active.getScope().kind === "root") {
389
+ return void 0;
390
+ }
391
+ return active;
392
+ }
393
+ function registerStorageWriteRollback(snapshot) {
394
+ const active = getActiveTransaction();
395
+ if (!active) {
396
+ return;
397
+ }
398
+ active.afterRollback(async () => {
399
+ await restoreStoredFileSnapshot(snapshot);
400
+ });
401
+ }
402
+ async function restoreStoredFileSnapshot(snapshot) {
403
+ if (snapshot.contents) {
404
+ await Storage.disk(snapshot.disk).put(snapshot.path, snapshot.contents);
405
+ return;
406
+ }
407
+ await Storage.disk(snapshot.disk).delete(snapshot.path);
408
+ }
409
+ async function restoreStoredFileSnapshots(snapshots) {
410
+ for (const snapshot of [...snapshots].reverse()) {
411
+ await restoreStoredFileSnapshot(snapshot).catch(() => void 0);
412
+ }
413
+ }
414
+ async function putFileWithRollbackRestore(disk, path, contents) {
415
+ const previous = await Storage.disk(disk).getBytes(path);
416
+ await Storage.disk(disk).put(path, contents);
417
+ const snapshot = {
418
+ disk,
419
+ path,
420
+ contents: previous
421
+ };
422
+ registerStorageWriteRollback(snapshot);
423
+ return snapshot;
424
+ }
425
+ async function deleteFileWithRollbackRestore(disk, path) {
426
+ const previous = await Storage.disk(disk).getBytes(path);
427
+ await Storage.disk(disk).delete(path);
428
+ registerStorageWriteRollback({
429
+ disk,
430
+ path,
431
+ contents: previous
432
+ });
433
+ }
371
434
  function fallbackCollectionDefinition(collectionName) {
372
435
  return Object.freeze({
373
436
  kind: "collection",
@@ -447,6 +510,7 @@ async function generateStoredConversions(options) {
447
510
  }
448
511
  const generatedConversions = /* @__PURE__ */ Object.create(null);
449
512
  const executor = getMediaConversionExecutor();
513
+ const writtenSnapshots = [];
450
514
  try {
451
515
  for (const conversion2 of matchingConversions) {
452
516
  const generated = await executor.generate({
@@ -469,7 +533,7 @@ async function generateStoredConversions(options) {
469
533
  const targetDisk = generated.disk ?? options.conversionsDisk;
470
534
  const conversionMimeType = inferMimeType(generatedFileName, generated.mimeType);
471
535
  const conversionContents = await toBinaryContent(generated.contents);
472
- await Storage.disk(targetDisk).put(conversionPath, conversionContents);
536
+ writtenSnapshots.push(await putFileWithRollbackRestore(targetDisk, conversionPath, conversionContents));
473
537
  generatedConversions[conversion2.name] = Object.freeze({
474
538
  path: conversionPath,
475
539
  disk: targetDisk,
@@ -479,19 +543,11 @@ async function generateStoredConversions(options) {
479
543
  });
480
544
  }
481
545
  } catch (error) {
482
- await deleteStoredConversions(
483
- generatedConversions,
484
- options.conversionsDisk
485
- );
546
+ await restoreStoredFileSnapshots(writtenSnapshots);
486
547
  throw error;
487
548
  }
488
549
  return Object.freeze(generatedConversions);
489
550
  }
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
551
  async function deleteObsoleteConversions(current, next, fallbackDisk, requested) {
496
552
  for (const [name, conversion2] of Object.entries(current)) {
497
553
  if (requested?.length && !requested.includes(name)) {
@@ -504,7 +560,7 @@ async function deleteObsoleteConversions(current, next, fallbackDisk, requested)
504
560
  if (nextConversion?.path === conversion2.path && nextConversion.disk === (conversion2.disk ?? fallbackDisk)) {
505
561
  continue;
506
562
  }
507
- await Storage.disk(conversion2.disk ?? fallbackDisk).delete(conversion2.path);
563
+ await deleteFileWithRollbackRestore(conversion2.disk ?? fallbackDisk, conversion2.path);
508
564
  }
509
565
  }
510
566
  async function regenerateMediaEntityConversions(options) {
@@ -588,22 +644,30 @@ function normalizePayload(payload) {
588
644
  }
589
645
  async function runMediaGenerateConversionsJob(payload) {
590
646
  const normalized = normalizePayload(payload);
591
- const media = await Media.find(normalized.mediaId);
592
- if (!media) {
647
+ if (normalized.conversionNames.length === 0) {
593
648
  return Object.freeze({
594
- status: "missing-media",
595
- conversionNames: Object.freeze([])
649
+ status: "processed",
650
+ conversionNames: normalized.conversionNames
596
651
  });
597
652
  }
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
- });
653
+ const media = typeof normalized.mediaId === "number" ? await Media.find(normalized.mediaId) : await Media.where("id", normalized.mediaId).first();
654
+ switch (media) {
655
+ case void 0:
656
+ return Object.freeze({
657
+ status: "missing-media",
658
+ conversionNames: Object.freeze([])
659
+ });
660
+ default:
661
+ await regenerateMediaEntityConversions({
662
+ media,
663
+ conversions: normalized.conversionNames,
664
+ includeQueued: true
665
+ });
666
+ return Object.freeze({
667
+ status: "processed",
668
+ conversionNames: normalized.conversionNames
669
+ });
670
+ }
607
671
  }
608
672
  function ensureMediaQueueJobRegistered() {
609
673
  if (getRegisteredQueueJob(MEDIA_GENERATE_CONVERSIONS_JOB)) {
@@ -618,8 +682,8 @@ function ensureMediaQueueJobRegistered() {
618
682
  name: MEDIA_GENERATE_CONVERSIONS_JOB
619
683
  });
620
684
  }
621
- function getActiveTransaction() {
622
- const active = connectionAsyncContext.getActive()?.connection;
685
+ function getActiveTransaction2() {
686
+ const active = connectionAsyncContext2.getActive()?.connection;
623
687
  if (!active || active.getScope().kind === "root") {
624
688
  return void 0;
625
689
  }
@@ -637,7 +701,7 @@ async function dispatchQueuedMediaConversions(payload) {
637
701
  return;
638
702
  }
639
703
  ensureMediaQueueJobRegistered();
640
- const activeTransaction = getActiveTransaction();
704
+ const activeTransaction = getActiveTransaction2();
641
705
  if (activeTransaction) {
642
706
  activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized));
643
707
  return;
@@ -650,7 +714,7 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
650
714
  return;
651
715
  }
652
716
  ensureMediaQueueJobRegistered();
653
- const activeTransaction = getActiveTransaction();
717
+ const activeTransaction = getActiveTransaction2();
654
718
  if (activeTransaction) {
655
719
  activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized, onSynchronousDispatch));
656
720
  return;
@@ -660,6 +724,29 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
660
724
 
661
725
  // src/model/item.ts
662
726
  import { Storage as Storage2 } from "@holo-js/storage/runtime";
727
+ import { connectionAsyncContext as connectionAsyncContext3 } from "@holo-js/db";
728
+ function toUrlPath(url) {
729
+ if (url.startsWith("/")) {
730
+ return url;
731
+ }
732
+ try {
733
+ const parsed = new URL(url);
734
+ return `${parsed.pathname}${parsed.search}`;
735
+ } catch {
736
+ return `/${url.replace(/^\/+/, "")}`;
737
+ }
738
+ }
739
+ function registerDeletedVariantRollbackRestore(snapshots) {
740
+ const active = connectionAsyncContext3.getActive()?.connection;
741
+ if (!active || active.getScope().kind === "root") {
742
+ return;
743
+ }
744
+ active.afterRollback(async () => {
745
+ for (const snapshot of [...snapshots].reverse()) {
746
+ await Storage2.disk(snapshot.disk).put(snapshot.path, snapshot.contents).catch(() => void 0);
747
+ }
748
+ });
749
+ }
663
750
  var MediaItem = class {
664
751
  constructor(entity, owner) {
665
752
  this.entity = entity;
@@ -690,15 +777,8 @@ var MediaItem = class {
690
777
  );
691
778
  }
692
779
  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
- }
780
+ const url = this.getUrl(conversion2);
781
+ return url ? toUrlPath(url) : null;
702
782
  }
703
783
  getUrl(conversion2) {
704
784
  const variant = this.resolveVariant(conversion2);
@@ -737,6 +817,7 @@ var MediaItem = class {
737
817
  }
738
818
  }
739
819
  await this.entity.delete();
820
+ registerDeletedVariantRollbackRestore(deletedSnapshots);
740
821
  this.owner?.forgetRelation("media");
741
822
  } catch (error) {
742
823
  for (const snapshot of deletedSnapshots.reverse()) {
@@ -806,6 +887,15 @@ var MediaItem = class {
806
887
  };
807
888
 
808
889
  // src/model/adder.ts
890
+ var MediaAddValidationException = class extends Error {
891
+ error;
892
+ constructor(details) {
893
+ const error = createMediaAddError(details);
894
+ super(error.message);
895
+ this.name = "MediaAddValidationException";
896
+ this.error = error;
897
+ }
898
+ };
809
899
  function resolveImplicitDiskName() {
810
900
  const defaultDisk = Storage3.disk();
811
901
  if (defaultDisk.visibility === "public") {
@@ -828,10 +918,44 @@ function parseRemoteFileName(url) {
828
918
  return "media.bin";
829
919
  }
830
920
  }
831
- function createMaxSizeError(fileName, collectionName) {
832
- return new Error(
833
- `[Holo Media] File "${fileName}" exceeds the max size for collection "${collectionName}".`
834
- );
921
+ function formatBytes(bytes) {
922
+ if (!Number.isFinite(bytes) || bytes < 0) {
923
+ return "0 bytes";
924
+ }
925
+ const units = ["bytes", "KB", "MB", "GB"];
926
+ let value = bytes;
927
+ let unitIndex = 0;
928
+ while (value >= 1024 && unitIndex < units.length - 1) {
929
+ value /= 1024;
930
+ unitIndex += 1;
931
+ }
932
+ const formatted = unitIndex === 0 ? String(Math.round(value)) : value.toFixed(value >= 10 ? 0 : 1).replace(/\.0$/, "");
933
+ return `${formatted} ${units[unitIndex]}`;
934
+ }
935
+ function createMediaValidationMessage(details) {
936
+ if (details.code === "max_size_exceeded") {
937
+ return `The selected file must be ${formatBytes(details.maxSize)} or smaller.`;
938
+ }
939
+ if (details.code === "invalid_mime_type") {
940
+ return `The selected file must be one of these types: ${details.acceptedMimeTypes.join(", ")}.`;
941
+ }
942
+ return `The selected file must use one of these extensions: ${details.acceptedExtensions.join(", ")}.`;
943
+ }
944
+ function createMediaAddError(details) {
945
+ return Object.freeze({
946
+ ...details,
947
+ status: 422,
948
+ message: createMediaValidationMessage(details)
949
+ });
950
+ }
951
+ function createMaxSizeError(fileName, collectionName, maxSize, actualSize) {
952
+ return new MediaAddValidationException({
953
+ code: "max_size_exceeded",
954
+ collection: collectionName,
955
+ fileName,
956
+ maxSize,
957
+ actualSize
958
+ });
835
959
  }
836
960
  async function readRemoteMediaContents(response, fileName, maxSize, collectionName) {
837
961
  if (typeof maxSize !== "number" || !response.body) {
@@ -852,7 +976,7 @@ async function readRemoteMediaContents(response, fileName, maxSize, collectionNa
852
976
  totalSize += value.byteLength;
853
977
  if (totalSize > maxSize) {
854
978
  await reader.cancel().catch(() => void 0);
855
- throw createMaxSizeError(fileName, collectionName);
979
+ throw createMaxSizeError(fileName, collectionName ?? "default", maxSize, totalSize);
856
980
  }
857
981
  chunks.push(value);
858
982
  }
@@ -912,16 +1036,24 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
912
1036
  };
913
1037
  }
914
1038
  const contents = await toBinaryContent(input);
915
- const fileName = sanitizeFileName(overrideFileName ?? "media.bin");
1039
+ const fileName = sanitizeFileName(overrideFileName ?? getBinaryFileName(input));
916
1040
  return {
917
1041
  contents,
918
1042
  fileName,
919
- mimeType: inferMimeType(fileName),
1043
+ mimeType: inferMimeType(fileName, getBinaryMimeType(input)),
920
1044
  extension: getExtension(fileName),
921
1045
  size: getContentSize(contents),
922
1046
  name: getDisplayName(fileName, overrideName)
923
1047
  };
924
1048
  }
1049
+ function getBinaryFileName(input) {
1050
+ const name = input.name;
1051
+ return typeof name === "string" && name.trim() ? name : "media.bin";
1052
+ }
1053
+ function getBinaryMimeType(input) {
1054
+ const type = input.type;
1055
+ return typeof type === "string" && type.trim() ? type : void 0;
1056
+ }
925
1057
  function isPathInput(input) {
926
1058
  return typeof input === "object" && input !== null && "path" in input;
927
1059
  }
@@ -942,7 +1074,7 @@ async function resolveRemoteMediaSource(input, overrideFileName, overrideName, m
942
1074
  const contentLengthHeader = response.headers.get("content-length");
943
1075
  const contentLength = contentLengthHeader?.trim() ? Number(contentLengthHeader) : Number.NaN;
944
1076
  if (typeof maxSize === "number" && Number.isFinite(contentLength) && contentLength > maxSize) {
945
- throw createMaxSizeError(fileName, collectionName);
1077
+ throw createMaxSizeError(fileName, collectionName ?? "default", maxSize, contentLength);
946
1078
  }
947
1079
  const contents = await readRemoteMediaContents(
948
1080
  response,
@@ -961,23 +1093,29 @@ async function resolveRemoteMediaSource(input, overrideFileName, overrideName, m
961
1093
  }
962
1094
  function validateSource(collection2, source) {
963
1095
  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
- );
1096
+ throw createMaxSizeError(source.fileName, collection2.name, collection2.maxSize, source.size);
967
1097
  }
968
1098
  if (collection2.acceptedMimeTypes.length > 0) {
969
1099
  const mimeType = source.mimeType?.trim().toLowerCase();
970
1100
  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
- );
1101
+ throw new MediaAddValidationException({
1102
+ code: "invalid_mime_type",
1103
+ collection: collection2.name,
1104
+ fileName: source.fileName,
1105
+ mimeType,
1106
+ acceptedMimeTypes: collection2.acceptedMimeTypes
1107
+ });
974
1108
  }
975
1109
  }
976
1110
  if (collection2.acceptedExtensions.length > 0) {
977
1111
  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
- );
1112
+ throw new MediaAddValidationException({
1113
+ code: "invalid_extension",
1114
+ collection: collection2.name,
1115
+ fileName: source.fileName,
1116
+ extension: source.extension,
1117
+ acceptedExtensions: collection2.acceptedExtensions
1118
+ });
981
1119
  }
982
1120
  }
983
1121
  }
@@ -994,23 +1132,29 @@ async function deleteMediaItemsWithRollback(items) {
994
1132
  deletedSnapshots.push(snapshot);
995
1133
  }
996
1134
  } catch (error) {
997
- for (const snapshot of deletedSnapshots.reverse()) {
998
- await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
999
- }
1135
+ await restoreDeletedMediaSnapshots(deletedSnapshots);
1000
1136
  throw error;
1001
1137
  }
1138
+ return deletedSnapshots;
1002
1139
  }
1003
1140
  async function deleteOverflowItems(items, limit) {
1004
1141
  if (items.length <= limit) {
1005
- return;
1142
+ return [];
1006
1143
  }
1007
- await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
1144
+ return await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
1008
1145
  }
1009
1146
  async function cleanupGeneratedConversions(conversions, _fallbackDisk) {
1010
1147
  for (const conversion2 of Object.values(conversions)) {
1011
1148
  await Storage3.disk(conversion2.disk).delete(conversion2.path).catch(() => void 0);
1012
1149
  }
1013
1150
  }
1151
+ function registerCreatedMediaRollbackCleanup(cleanup) {
1152
+ const active = connectionAsyncContext4.getActive()?.connection;
1153
+ if (!active || active.getScope().kind === "root") {
1154
+ return;
1155
+ }
1156
+ active.afterRollback(cleanup);
1157
+ }
1014
1158
  async function snapshotDeletedMediaItem(item) {
1015
1159
  const record = item.record;
1016
1160
  const fileTargets = [{
@@ -1060,6 +1204,11 @@ async function restoreDeletedMediaSnapshot(snapshot) {
1060
1204
  }
1061
1205
  await Media.create(snapshot.record);
1062
1206
  }
1207
+ async function restoreDeletedMediaSnapshots(snapshots) {
1208
+ for (const snapshot of snapshots.reverse()) {
1209
+ await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
1210
+ }
1211
+ }
1063
1212
  var MediaAdder = class _MediaAdder {
1064
1213
  constructor(entity, source, state = {}) {
1065
1214
  this.entity = entity;
@@ -1085,6 +1234,23 @@ var MediaAdder = class _MediaAdder {
1085
1234
  });
1086
1235
  }
1087
1236
  async toMediaCollection(collectionName = "default") {
1237
+ try {
1238
+ const media = await this.storeInMediaCollection(collectionName);
1239
+ return Object.assign(media, {
1240
+ data: media,
1241
+ error: null
1242
+ });
1243
+ } catch (error) {
1244
+ if (error instanceof MediaAddValidationException) {
1245
+ return {
1246
+ data: null,
1247
+ error: error.error
1248
+ };
1249
+ }
1250
+ throw error;
1251
+ }
1252
+ }
1253
+ async storeInMediaCollection(collectionName = "default") {
1088
1254
  const mediaDefinition = requireMediaDefinition(this.entity);
1089
1255
  const ownerDefinition = this.entity.getRepository().definition;
1090
1256
  const ownerId = this.entity.get(ownerDefinition.primaryKey);
@@ -1117,9 +1283,14 @@ var MediaAdder = class _MediaAdder {
1117
1283
  let originalStored = false;
1118
1284
  let createdMedia;
1119
1285
  let generatedConversions = Object.freeze({});
1286
+ const deletedMediaSnapshots = [];
1120
1287
  try {
1121
1288
  await Storage3.disk(diskName).put(originalPath, source.contents);
1122
1289
  originalStored = true;
1290
+ registerCreatedMediaRollbackCleanup(async () => {
1291
+ await cleanupGeneratedConversions(generatedConversions, conversionsDisk).catch(() => void 0);
1292
+ await Storage3.disk(diskName).delete(originalPath).catch(() => void 0);
1293
+ });
1123
1294
  generatedConversions = await generateStoredConversions({
1124
1295
  definition: mediaDefinition,
1125
1296
  collection: collection2,
@@ -1155,10 +1326,10 @@ var MediaAdder = class _MediaAdder {
1155
1326
  });
1156
1327
  createdMedia = media;
1157
1328
  this.entity.forgetRelation("media");
1158
- await deleteMediaItemsWithRollback(existing);
1329
+ deletedMediaSnapshots.push(...await deleteMediaItemsWithRollback(existing));
1159
1330
  if (typeof collection2.onlyKeepLatest === "number") {
1160
1331
  const items = await this.entity.getMedia(collectionName);
1161
- await deleteOverflowItems(items, collection2.onlyKeepLatest);
1332
+ deletedMediaSnapshots.push(...await deleteOverflowItems(items, collection2.onlyKeepLatest));
1162
1333
  }
1163
1334
  const queuedConversions = resolveQueuedConversionNames({
1164
1335
  definition: mediaDefinition,
@@ -1180,6 +1351,7 @@ var MediaAdder = class _MediaAdder {
1180
1351
  await createdMedia.refresh().catch(() => void 0);
1181
1352
  await createdMedia.delete().catch(() => void 0);
1182
1353
  }
1354
+ await restoreDeletedMediaSnapshots(deletedMediaSnapshots);
1183
1355
  this.entity.forgetRelation("media");
1184
1356
  throw error;
1185
1357
  }
@@ -1286,6 +1458,12 @@ function installEntityMediaMethods() {
1286
1458
  import { morphMany } from "@holo-js/db";
1287
1459
 
1288
1460
  // src/definitions/collections.ts
1461
+ function normalizePositiveInteger(value, fieldName) {
1462
+ if (!Number.isFinite(value)) {
1463
+ throw new Error(`[Holo Media] Media collection ${fieldName} must be a finite number.`);
1464
+ }
1465
+ return Math.max(1, Math.floor(value));
1466
+ }
1289
1467
  function decorateCollection(definition) {
1290
1468
  const clone = (overrides) => decorateCollection(Object.freeze({
1291
1469
  ...definition,
@@ -1306,7 +1484,7 @@ function decorateCollection(definition) {
1306
1484
  },
1307
1485
  onlyKeepLatest(limit) {
1308
1486
  return clone({
1309
- onlyKeepLatest: Math.max(1, Math.floor(limit))
1487
+ onlyKeepLatest: normalizePositiveInteger(limit, "onlyKeepLatest")
1310
1488
  });
1311
1489
  },
1312
1490
  acceptsMimeTypes(mimeTypes) {
@@ -1325,7 +1503,7 @@ function decorateCollection(definition) {
1325
1503
  },
1326
1504
  maxSize(bytes) {
1327
1505
  return clone({
1328
- maxFileSize: Math.max(1, Math.floor(bytes))
1506
+ maxFileSize: normalizePositiveInteger(bytes, "maxFileSize")
1329
1507
  });
1330
1508
  }
1331
1509
  });
@@ -1346,19 +1524,31 @@ function normalizeCollectionDefinitions(definitions) {
1346
1524
  disk: definition.diskName?.trim() || void 0,
1347
1525
  conversionsDisk: definition.conversionsDiskName?.trim() || void 0,
1348
1526
  singleFile: Boolean(definition.singleFile),
1349
- onlyKeepLatest: typeof definition.onlyKeepLatest === "number" ? Math.max(1, Math.floor(definition.onlyKeepLatest)) : void 0,
1527
+ onlyKeepLatest: typeof definition.onlyKeepLatest === "number" ? normalizePositiveInteger(definition.onlyKeepLatest, "onlyKeepLatest") : void 0,
1350
1528
  acceptedMimeTypes: Object.freeze(
1351
1529
  definition.acceptedMimeTypes.map((mimeType) => mimeType.trim()).map((mimeType) => mimeType.toLowerCase()).filter(Boolean)
1352
1530
  ),
1353
1531
  acceptedExtensions: Object.freeze(
1354
1532
  definition.acceptedExtensions.map((extension) => extension.trim().replace(/^\./, "").toLowerCase()).filter(Boolean)
1355
1533
  ),
1356
- maxSize: typeof definition.maxFileSize === "number" ? Math.max(1, Math.floor(definition.maxFileSize)) : void 0
1534
+ maxSize: typeof definition.maxFileSize === "number" ? normalizePositiveInteger(definition.maxFileSize, "maxFileSize") : void 0
1357
1535
  });
1358
1536
  }));
1359
1537
  }
1360
1538
 
1361
1539
  // src/definitions/conversions.ts
1540
+ function normalizePositiveInteger2(value, fieldName) {
1541
+ if (!Number.isFinite(value)) {
1542
+ throw new Error(`[Holo Media] Media conversion ${fieldName} must be a finite number.`);
1543
+ }
1544
+ return Math.max(1, Math.floor(value));
1545
+ }
1546
+ function normalizeQuality(value) {
1547
+ if (!Number.isFinite(value)) {
1548
+ throw new Error("[Holo Media] Media conversion quality must be a finite number.");
1549
+ }
1550
+ return Math.max(1, Math.min(100, Math.floor(value)));
1551
+ }
1362
1552
  function decorateConversion(definition) {
1363
1553
  const clone = (overrides) => decorateConversion(Object.freeze({
1364
1554
  ...definition,
@@ -1377,12 +1567,12 @@ function decorateConversion(definition) {
1377
1567
  },
1378
1568
  width(pixels) {
1379
1569
  return clone({
1380
- width: Math.max(1, Math.floor(pixels))
1570
+ width: normalizePositiveInteger2(pixels, "width")
1381
1571
  });
1382
1572
  },
1383
1573
  height(pixels) {
1384
1574
  return clone({
1385
- height: Math.max(1, Math.floor(pixels))
1575
+ height: normalizePositiveInteger2(pixels, "height")
1386
1576
  });
1387
1577
  },
1388
1578
  fit(mode) {
@@ -1393,7 +1583,7 @@ function decorateConversion(definition) {
1393
1583
  },
1394
1584
  quality(value) {
1395
1585
  return clone({
1396
- quality: Math.max(1, Math.min(100, Math.floor(value)))
1586
+ quality: normalizeQuality(value)
1397
1587
  });
1398
1588
  },
1399
1589
  queued() {
@@ -1419,9 +1609,9 @@ function normalizeConversionDefinitions(definitions) {
1419
1609
  collections: Object.freeze(
1420
1610
  definition.collections.filter(Boolean)
1421
1611
  ),
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,
1612
+ width: typeof definition.width === "number" ? normalizePositiveInteger2(definition.width, "width") : void 0,
1613
+ height: typeof definition.height === "number" ? normalizePositiveInteger2(definition.height, "height") : void 0,
1614
+ quality: typeof definition.quality === "number" ? normalizeQuality(definition.quality) : void 0,
1425
1615
  queued: Boolean(definition.queued)
1426
1616
  });
1427
1617
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holo-js/media",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Holo-JS Framework - media collections and conversions for Holo-JS models",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,20 +16,20 @@
16
16
  "dist"
17
17
  ],
18
18
  "scripts": {
19
- "build": "bun x tsup",
20
- "stub": "bun x tsup",
19
+ "build": "tsup",
20
+ "stub": "tsup",
21
21
  "typecheck": "tsc -p tsconfig.json --noEmit",
22
22
  "test": "vitest --run"
23
23
  },
24
24
  "dependencies": {
25
- "@holo-js/db": "^0.1.4",
26
- "@holo-js/queue": "^0.1.4",
27
- "@holo-js/storage": "^0.1.4",
25
+ "@holo-js/db": "^0.1.5",
26
+ "@holo-js/queue": "^0.1.5",
27
+ "@holo-js/storage": "^0.1.5",
28
28
  "sharp": "^0.34.4"
29
29
  },
30
30
  "devDependencies": {
31
31
  "tsup": "^8.3.5",
32
32
  "typescript": "^5.7.2",
33
- "vitest": "^2.1.8"
33
+ "vitest": "^4.1.5"
34
34
  }
35
35
  }