@holo-js/media 0.3.15 → 0.3.17

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/dist/index.d.ts CHANGED
@@ -185,7 +185,7 @@ declare const Media: _holo_js_db.StaticModelApi<_holo_js_db.BoundTableDefinition
185
185
  updated_at: _holo_js_db.ColumnBuilder<Date, false, true, false, false>;
186
186
  }>, _holo_js_db.EmptyScopeMap, {
187
187
  model: _holo_js_db.MorphToRelationDefinition<_holo_js_db.TableDefinition<string, _holo_js_db.TableColumnsShape>>;
188
- }>;
188
+ }, readonly string[]>;
189
189
 
190
190
  type MediaRecord = ModelRecord<typeof Media.definition.table>;
191
191
  declare class MediaItem<TCollectionName extends string = string, TConversionName extends string = string, TEntity extends Entity<TableDefinition> = Entity<TableDefinition>> {
@@ -209,7 +209,6 @@ declare class MediaItem<TCollectionName extends string = string, TConversionName
209
209
  regenerate(conversions?: TConversionName | readonly TConversionName[]): Promise<this>;
210
210
  toJSON(): MediaRecord;
211
211
  private resolveVariant;
212
- private resolveStoredVariants;
213
212
  }
214
213
 
215
214
  type MediaSourceInput = string | BinaryContent | {
@@ -368,5 +367,14 @@ declare function dispatchQueuedMediaConversions(payload: MediaGenerateConversion
368
367
 
369
368
  declare function createDefaultMediaConversionExecutor(): MediaConversionExecutor;
370
369
  declare const defaultMediaConversionExecutor: MediaConversionExecutor;
370
+ interface MediaImageInspection {
371
+ readonly mimeType: 'image/gif' | 'image/jpeg' | 'image/png' | 'image/webp';
372
+ readonly width: number;
373
+ readonly height: number;
374
+ readonly pages: number;
375
+ }
376
+ declare const mediaRuntimeInternals: Readonly<{
377
+ inspectImage(contents: Uint8Array, maximumPixels?: number): Promise<MediaImageInspection>;
378
+ }>;
371
379
 
372
- export { type CollectionNamesOf, type ConversionNamesOf, MEDIA_GENERATE_CONVERSIONS_JOB, Media, type MediaAddResult, MediaAdder, type MediaCollectionBuilder, type MediaCollectionDefinition, type MediaConversionBuilder, type MediaConversionDefinition, type MediaConversionExecutor, type MediaConversionExecutorInput, type MediaConversionExecutorResult, type MediaConversionExecutorSource, type MediaConversionFit, type MediaConversionFormat, type MediaConversionPathInput, type MediaDefinitionFactory, type MediaDefinitionHelpers, type MediaDefinitionInput, type MediaEnabledEntity, type MediaEnabledModel, type MediaGenerateConversionsPayload, type MediaGenerateConversionsResult, MediaItem, type MediaOriginalPathInput, type MediaPathGenerator, type MediaSourceInput, type NormalizedMediaCollectionDefinition, type NormalizedMediaConversionDefinition, type NormalizedMediaDefinition, type StoredMediaConversion, collection, conversion, createDefaultMediaConversionExecutor, defaultMediaConversionExecutor, defineMediaModel, dispatchQueuedMediaConversions, ensureMediaQueueJobRegistered, getMediaConversionExecutor, getMediaDefinition, getMediaDefinitionForMorphClass, getMediaPathGenerator, normalizeCollectionDefinitions, normalizeConversionDefinitions, normalizeMediaDefinition, registerMediaDefinition, requireMediaDefinition, requireMediaDefinitionForMorphClass, resetMediaPathGenerator, resetMediaRuntime, resolveMediaCollection, resolveMediaConversion, resolveMediaDefinition, runMediaGenerateConversionsJob, setMediaConversionExecutor, setMediaPathGenerator };
380
+ export { type CollectionNamesOf, type ConversionNamesOf, MEDIA_GENERATE_CONVERSIONS_JOB, Media, type MediaAddResult, MediaAdder, type MediaCollectionBuilder, type MediaCollectionDefinition, type MediaConversionBuilder, type MediaConversionDefinition, type MediaConversionExecutor, type MediaConversionExecutorInput, type MediaConversionExecutorResult, type MediaConversionExecutorSource, type MediaConversionFit, type MediaConversionFormat, type MediaConversionPathInput, type MediaDefinitionFactory, type MediaDefinitionHelpers, type MediaDefinitionInput, type MediaEnabledEntity, type MediaEnabledModel, type MediaGenerateConversionsPayload, type MediaGenerateConversionsResult, MediaItem, type MediaOriginalPathInput, type MediaPathGenerator, type MediaSourceInput, type NormalizedMediaCollectionDefinition, type NormalizedMediaConversionDefinition, type NormalizedMediaDefinition, type StoredMediaConversion, collection, conversion, createDefaultMediaConversionExecutor, defaultMediaConversionExecutor, defineMediaModel, dispatchQueuedMediaConversions, ensureMediaQueueJobRegistered, getMediaConversionExecutor, getMediaDefinition, getMediaDefinitionForMorphClass, getMediaPathGenerator, mediaRuntimeInternals, normalizeCollectionDefinitions, normalizeConversionDefinitions, normalizeMediaDefinition, registerMediaDefinition, requireMediaDefinition, requireMediaDefinitionForMorphClass, resetMediaPathGenerator, resetMediaRuntime, resolveMediaCollection, resolveMediaConversion, resolveMediaDefinition, runMediaGenerateConversionsJob, setMediaConversionExecutor, setMediaPathGenerator };
package/dist/index.mjs CHANGED
@@ -110,10 +110,7 @@ function replaceFileExtension(fileName, extension) {
110
110
  // src/runtime/image.ts
111
111
  var sharpFactoryPromise;
112
112
  async function loadSharp() {
113
- sharpFactoryPromise ??= import("sharp").then((module) => {
114
- const resolved = module;
115
- return resolved.default ?? resolved;
116
- }).catch((error) => {
113
+ sharpFactoryPromise ??= import("sharp").then((module) => module.default).catch((error) => {
117
114
  sharpFactoryPromise = void 0;
118
115
  throw error;
119
116
  });
@@ -195,6 +192,30 @@ function createDefaultMediaConversionExecutor() {
195
192
  };
196
193
  }
197
194
  var defaultMediaConversionExecutor = createDefaultMediaConversionExecutor();
195
+ var imageMimeTypes = {
196
+ gif: "image/gif",
197
+ jpeg: "image/jpeg",
198
+ png: "image/png",
199
+ webp: "image/webp"
200
+ };
201
+ var mediaRuntimeInternals = Object.freeze({
202
+ async inspectImage(contents, maximumPixels = 4e7) {
203
+ if (!Number.isSafeInteger(maximumPixels) || maximumPixels < 1) throw new TypeError("[Holo Media] Image pixel limits require a positive integer.");
204
+ const factory = await loadSharp();
205
+ try {
206
+ const image = factory(Buffer.from(contents), { animated: true, failOn: "warning", limitInputPixels: maximumPixels });
207
+ const metadata = await image.metadata();
208
+ const { width, height } = metadata;
209
+ const format = metadata.format;
210
+ if (format !== "gif" && format !== "jpeg" && format !== "png" && format !== "webp") throw new Error("[Holo Media] Unsupported raster image format.");
211
+ if (!width || !height || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width * height > maximumPixels) throw new Error("[Holo Media] Image exceeds the pixel limit.");
212
+ await image.stats();
213
+ return { mimeType: imageMimeTypes[format], width, height: metadata.pageHeight ?? height, pages: metadata.pages ?? 1 };
214
+ } catch (error) {
215
+ throw new Error("[Holo Media] Image could not be decoded.", { cause: error });
216
+ }
217
+ }
218
+ });
198
219
 
199
220
  // src/registry.ts
200
221
  var mediaRegistrySymbol = /* @__PURE__ */ Symbol.for("@holo-js/media.registry");
@@ -384,8 +405,7 @@ import { BlockList, isIP } from "net";
384
405
  import { tmpdir } from "os";
385
406
  import { basename as basename2, resolve, sep } from "path";
386
407
  import { Readable } from "stream";
387
- import { Storage as Storage3 } from "@holo-js/storage/runtime";
388
- import { connectionAsyncContext as connectionAsyncContext4 } from "@holo-js/db";
408
+ import { Storage as Storage4 } from "@holo-js/storage/runtime";
389
409
 
390
410
  // src/queue.ts
391
411
  import {
@@ -394,59 +414,114 @@ import {
394
414
  getRegisteredQueueJob,
395
415
  registerQueueJob
396
416
  } from "@holo-js/queue";
397
- import { connectionAsyncContext as connectionAsyncContext2 } from "@holo-js/db";
417
+ import { connectionAsyncContext } from "@holo-js/db";
398
418
 
399
419
  // src/model/conversions.ts
420
+ import { Storage as Storage2 } from "@holo-js/storage/runtime";
421
+
422
+ // src/model/mutation.ts
423
+ import { DB } from "@holo-js/db";
400
424
  import { Storage } from "@holo-js/storage/runtime";
401
- import { connectionAsyncContext } from "@holo-js/db";
402
- function getActiveTransaction() {
403
- const active = connectionAsyncContext.getActive()?.connection;
404
- if (!active || active.getScope().kind === "root") {
405
- return void 0;
406
- }
407
- return active;
408
- }
409
- function registerStorageWriteRollback(snapshot) {
410
- const active = getActiveTransaction();
411
- if (!active) {
412
- return;
425
+ var MediaMutation = class {
426
+ written = [];
427
+ async put(disk, path, contents) {
428
+ const previous = await Storage.disk(disk).getBytes(path);
429
+ this.written.push({ disk, path, previous });
430
+ await Storage.disk(disk).put(path, contents);
431
+ }
432
+ async compensate() {
433
+ const failures = [];
434
+ for (const file of this.written.splice(0).reverse()) {
435
+ try {
436
+ if (file.previous) {
437
+ await Storage.disk(file.disk).put(file.path, file.previous);
438
+ } else {
439
+ await Storage.disk(file.disk).delete(file.path);
440
+ }
441
+ } catch (error) {
442
+ failures.push(error);
443
+ }
444
+ }
445
+ if (failures.length === 1) throw failures[0];
446
+ if (failures.length > 1) throw new AggregateError(failures, "[Holo Media] File compensation failed.");
413
447
  }
414
- active.afterRollback(async () => {
415
- await restoreStoredFileSnapshot(snapshot);
448
+ };
449
+ async function runMediaMutation(options) {
450
+ const mutation = new MediaMutation();
451
+ return await DB.writeTransaction(async (transaction) => {
452
+ transaction.afterRollback(async () => {
453
+ const failures = [];
454
+ try {
455
+ await mutation.compensate();
456
+ } catch (error) {
457
+ failures.push(error);
458
+ }
459
+ try {
460
+ await options.afterRollback?.();
461
+ } catch (error) {
462
+ failures.push(error);
463
+ }
464
+ if (failures.length === 1) throw failures[0];
465
+ if (failures.length > 1) throw new AggregateError(failures, "[Holo Media] Rollback restoration failed.");
466
+ });
467
+ const result = await options.operation(mutation);
468
+ transaction.afterCommit(async () => {
469
+ const failures = [];
470
+ for (const effect of options.afterCommit ?? []) {
471
+ try {
472
+ await effect(result);
473
+ } catch (error) {
474
+ failures.push(error);
475
+ }
476
+ }
477
+ options.afterEffects?.();
478
+ if (failures.length === 0) return;
479
+ const cause = failures.length === 1 ? failures[0] : new AggregateError(failures, "[Holo Media] Post-commit effects failed.");
480
+ throw new Error(options.committedMessage, { cause });
481
+ });
482
+ return result;
416
483
  });
417
484
  }
418
- async function restoreStoredFileSnapshot(snapshot) {
419
- if (snapshot.contents) {
420
- await Storage.disk(snapshot.disk).put(snapshot.path, snapshot.contents);
421
- return;
422
- }
423
- await Storage.disk(snapshot.disk).delete(snapshot.path);
424
- }
425
- async function restoreStoredFileSnapshots(snapshots) {
426
- for (const snapshot of [...snapshots].reverse()) {
427
- await restoreStoredFileSnapshot(snapshot).catch(() => void 0);
485
+ async function removeStoredFiles(files, retained = []) {
486
+ const failures = [];
487
+ for (const file of files) {
488
+ if (retained.some((current) => current.disk === file.disk && current.path === file.path)) continue;
489
+ try {
490
+ await Storage.disk(file.disk).delete(file.path);
491
+ } catch (error) {
492
+ failures.push(error);
493
+ }
428
494
  }
495
+ if (failures.length === 1) throw failures[0];
496
+ if (failures.length > 1) throw new AggregateError(failures, "[Holo Media] Obsolete-file cleanup failed.");
429
497
  }
430
- async function putFileWithRollbackRestore(disk, path, contents) {
431
- const previous = await Storage.disk(disk).getBytes(path);
432
- await Storage.disk(disk).put(path, contents);
433
- const snapshot = {
434
- disk,
435
- path,
436
- contents: previous
437
- };
438
- registerStorageWriteRollback(snapshot);
439
- return snapshot;
440
- }
441
- async function deleteFileWithRollbackRestore(disk, path) {
442
- const previous = await Storage.disk(disk).getBytes(path);
443
- await Storage.disk(disk).delete(path);
444
- registerStorageWriteRollback({
445
- disk,
446
- path,
447
- contents: previous
498
+ async function removeObsoleteFiles(items, retained) {
499
+ await removeStoredFiles(items.flatMap(({ record }) => [
500
+ { disk: record.disk, path: record.path },
501
+ ...Object.values(record.generated_conversions ?? {}).filter((conversion2) => conversion2?.path).map((conversion2) => ({
502
+ disk: conversion2.disk ?? record.conversions_disk ?? record.disk,
503
+ path: conversion2.path
504
+ }))
505
+ ]), retained);
506
+ }
507
+ async function deleteMediaItems(items, owner) {
508
+ if (items.length === 0) return;
509
+ await runMediaMutation({
510
+ committedMessage: "[Holo Media] Deletion remains committed; post-commit file cleanup failed.",
511
+ operation: async () => {
512
+ for (const item of items) await item.getEntity().delete();
513
+ owner?.forgetRelation("media");
514
+ },
515
+ afterRollback: () => {
516
+ owner?.forgetRelation("media");
517
+ },
518
+ afterCommit: [async () => {
519
+ await removeObsoleteFiles(items, []);
520
+ }]
448
521
  });
449
522
  }
523
+
524
+ // src/model/conversions.ts
450
525
  function fallbackCollectionDefinition(collectionName) {
451
526
  return Object.freeze({
452
527
  kind: "collection",
@@ -526,58 +601,45 @@ async function generateStoredConversions(options) {
526
601
  }
527
602
  const generatedConversions = /* @__PURE__ */ Object.create(null);
528
603
  const executor = getMediaConversionExecutor();
529
- const writtenSnapshots = [];
530
- try {
531
- for (const conversion2 of matchingConversions) {
532
- const generated = await executor.generate({
533
- source: options.source,
534
- collection: options.collection,
535
- conversion: conversion2
536
- });
537
- if (!generated) {
538
- continue;
539
- }
540
- const generatedFileName = sanitizeFileName(generated.fileName ?? options.source.fileName);
541
- const conversionPath = getMediaPathGenerator().conversionPath({
542
- uuid: options.source.uuid,
543
- fileName: options.source.fileName,
544
- extension: options.source.extension,
545
- collection: options.collection,
546
- conversion: conversion2,
547
- generatedFileName
548
- });
549
- const targetDisk = generated.disk ?? options.conversionsDisk;
550
- const conversionMimeType = inferMimeType(generatedFileName, generated.mimeType);
551
- const conversionContents = await toBinaryContent(generated.contents);
552
- writtenSnapshots.push(await putFileWithRollbackRestore(targetDisk, conversionPath, conversionContents));
553
- generatedConversions[conversion2.name] = Object.freeze({
554
- path: conversionPath,
555
- disk: targetDisk,
556
- fileName: generatedFileName,
557
- mimeType: conversionMimeType,
558
- size: getContentSize(conversionContents)
559
- });
604
+ for (const conversion2 of matchingConversions) {
605
+ const generated = await executor.generate({
606
+ source: options.source,
607
+ collection: options.collection,
608
+ conversion: conversion2
609
+ });
610
+ if (!generated) {
611
+ continue;
560
612
  }
561
- } catch (error) {
562
- await restoreStoredFileSnapshots(writtenSnapshots);
563
- throw error;
613
+ const generatedFileName = sanitizeFileName(generated.fileName ?? options.source.fileName);
614
+ const conversionPath = getMediaPathGenerator().conversionPath({
615
+ uuid: options.source.uuid,
616
+ fileName: options.source.fileName,
617
+ extension: options.source.extension,
618
+ collection: options.collection,
619
+ conversion: conversion2,
620
+ generatedFileName
621
+ });
622
+ const targetDisk = generated.disk ?? options.conversionsDisk;
623
+ const conversionMimeType = inferMimeType(generatedFileName, generated.mimeType);
624
+ const conversionContents = await toBinaryContent(generated.contents);
625
+ await options.mutation.put(targetDisk, conversionPath, conversionContents);
626
+ generatedConversions[conversion2.name] = Object.freeze({
627
+ path: conversionPath,
628
+ disk: targetDisk,
629
+ fileName: generatedFileName,
630
+ mimeType: conversionMimeType,
631
+ size: getContentSize(conversionContents)
632
+ });
564
633
  }
565
634
  return Object.freeze(generatedConversions);
566
635
  }
567
636
  async function deleteObsoleteConversions(current, next, fallbackDisk, requested) {
568
- for (const [name, conversion2] of Object.entries(current)) {
569
- if (requested?.length && !requested.includes(name)) {
570
- continue;
571
- }
572
- if (!conversion2?.path) {
573
- continue;
574
- }
637
+ const obsolete = Object.entries(current).filter(([name, conversion2]) => {
638
+ if (requested?.length && !requested.includes(name) || !conversion2?.path) return false;
575
639
  const nextConversion = next[name];
576
- if (nextConversion?.path === conversion2.path && nextConversion.disk === (conversion2.disk ?? fallbackDisk)) {
577
- continue;
578
- }
579
- await deleteFileWithRollbackRestore(conversion2.disk ?? fallbackDisk, conversion2.path);
580
- }
640
+ return nextConversion?.path !== conversion2.path || (nextConversion.disk ?? fallbackDisk) !== (conversion2.disk ?? fallbackDisk);
641
+ }).map(([, conversion2]) => ({ disk: conversion2.disk ?? fallbackDisk, path: conversion2.path }));
642
+ await removeStoredFiles(obsolete);
581
643
  }
582
644
  async function regenerateMediaEntityConversions(options) {
583
645
  const media = options.media;
@@ -591,7 +653,7 @@ async function regenerateMediaEntityConversions(options) {
591
653
  resolveMatchingConversions(definition, collectionName, requested, {
592
654
  includeQueued: options.includeQueued === true
593
655
  });
594
- const sourceContents = await Storage.disk(String(media.get("disk"))).getBytes(String(media.get("path")));
656
+ const sourceContents = await Storage2.disk(String(media.get("disk"))).getBytes(String(media.get("path")));
595
657
  if (!sourceContents) {
596
658
  throw new Error(
597
659
  `[Holo Media] Cannot regenerate conversions for media "${media.get("uuid")}" because the original file is missing.`
@@ -600,32 +662,53 @@ async function regenerateMediaEntityConversions(options) {
600
662
  const nextBase = requested?.length ? Object.fromEntries(
601
663
  Object.entries(current).filter(([name]) => !requested.includes(name))
602
664
  ) : {};
603
- const regenerated = await generateStoredConversions({
604
- definition,
605
- collection: collection2,
606
- conversionsDisk,
607
- requestedConversions: requested,
608
- includeQueued: options.includeQueued === true,
609
- source: {
610
- uuid: String(media.get("uuid")),
611
- fileName: String(media.get("file_name")),
612
- extension: media.get("extension") ?? void 0,
613
- mimeType: media.get("mime_type") ?? void 0,
614
- size: sourceContents.byteLength,
615
- contents: sourceContents
665
+ return await runMediaMutation({
666
+ committedMessage: "[Holo Media] Regeneration remains committed; post-commit cleanup or conversion dispatch failed.",
667
+ afterRollback: async () => {
668
+ options.owner?.forgetRelation("media");
669
+ await media.refresh();
670
+ },
671
+ operation: async (mutation) => {
672
+ const regenerated = await generateStoredConversions({
673
+ definition,
674
+ collection: collection2,
675
+ conversionsDisk,
676
+ requestedConversions: requested,
677
+ includeQueued: options.includeQueued === true,
678
+ mutation,
679
+ source: {
680
+ uuid: String(media.get("uuid")),
681
+ fileName: String(media.get("file_name")),
682
+ extension: media.get("extension") ?? void 0,
683
+ mimeType: media.get("mime_type") ?? void 0,
684
+ size: sourceContents.byteLength,
685
+ contents: sourceContents
686
+ }
687
+ });
688
+ const generatedConversions = Object.freeze({ ...nextBase, ...regenerated });
689
+ media.forceFill({ generated_conversions: generatedConversions });
690
+ await media.save();
691
+ options.owner?.forgetRelation("media");
692
+ return generatedConversions;
693
+ },
694
+ afterCommit: [
695
+ async (generatedConversions) => {
696
+ await deleteObsoleteConversions(current, generatedConversions, conversionsDisk, requested);
697
+ },
698
+ async () => {
699
+ if (options.includeQueued) return;
700
+ await dispatchQueuedMediaConversionsForModel({
701
+ mediaId: media.get("id"),
702
+ conversionNames: resolveQueuedConversionNames({ definition, collectionName, requestedConversions: requested })
703
+ }, async () => {
704
+ await media.refresh();
705
+ });
706
+ }
707
+ ],
708
+ afterEffects: () => {
709
+ options.owner?.forgetRelation("media");
616
710
  }
617
711
  });
618
- const generatedConversions = Object.freeze({
619
- ...nextBase,
620
- ...regenerated
621
- });
622
- media.forceFill({
623
- generated_conversions: generatedConversions
624
- });
625
- await media.save();
626
- await deleteObsoleteConversions(current, generatedConversions, conversionsDisk, requested);
627
- options.owner?.forgetRelation("media");
628
- return generatedConversions;
629
712
  }
630
713
 
631
714
  // src/queue.ts
@@ -698,8 +781,8 @@ function ensureMediaQueueJobRegistered() {
698
781
  name: MEDIA_GENERATE_CONVERSIONS_JOB
699
782
  });
700
783
  }
701
- function getActiveTransaction2() {
702
- const active = connectionAsyncContext2.getActive()?.connection;
784
+ function getActiveTransaction() {
785
+ const active = connectionAsyncContext.getActive()?.connection;
703
786
  if (!active || active.getScope().kind === "root") {
704
787
  return void 0;
705
788
  }
@@ -717,7 +800,7 @@ async function dispatchQueuedMediaConversions(payload) {
717
800
  return;
718
801
  }
719
802
  ensureMediaQueueJobRegistered();
720
- const activeTransaction = getActiveTransaction2();
803
+ const activeTransaction = getActiveTransaction();
721
804
  if (activeTransaction) {
722
805
  activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized));
723
806
  return;
@@ -730,7 +813,7 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
730
813
  return;
731
814
  }
732
815
  ensureMediaQueueJobRegistered();
733
- const activeTransaction = getActiveTransaction2();
816
+ const activeTransaction = getActiveTransaction();
734
817
  if (activeTransaction) {
735
818
  activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized, onSynchronousDispatch));
736
819
  return;
@@ -739,8 +822,7 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
739
822
  }
740
823
 
741
824
  // src/model/item.ts
742
- import { Storage as Storage2 } from "@holo-js/storage/runtime";
743
- import { connectionAsyncContext as connectionAsyncContext3 } from "@holo-js/db";
825
+ import { Storage as Storage3 } from "@holo-js/storage/runtime";
744
826
  function toUrlPath(url) {
745
827
  if (url.startsWith("/")) {
746
828
  return url;
@@ -752,17 +834,6 @@ function toUrlPath(url) {
752
834
  return `/${url.replace(/^\/+/, "")}`;
753
835
  }
754
836
  }
755
- function registerDeletedVariantRollbackRestore(snapshots) {
756
- const active = connectionAsyncContext3.getActive()?.connection;
757
- if (!active || active.getScope().kind === "root") {
758
- return;
759
- }
760
- active.afterRollback(async () => {
761
- for (const snapshot of [...snapshots].reverse()) {
762
- await Storage2.disk(snapshot.disk).put(snapshot.path, snapshot.contents).catch(() => void 0);
763
- }
764
- });
765
- }
766
837
  var MediaItem = class {
767
838
  constructor(entity, owner) {
768
839
  this.entity = entity;
@@ -802,7 +873,7 @@ var MediaItem = class {
802
873
  return null;
803
874
  }
804
875
  try {
805
- return Storage2.disk(variant.disk).url(variant.path);
876
+ return Storage3.disk(variant.disk).url(variant.path);
806
877
  } catch {
807
878
  return null;
808
879
  }
@@ -813,34 +884,13 @@ var MediaItem = class {
813
884
  return null;
814
885
  }
815
886
  try {
816
- return Storage2.disk(variant.disk).temporaryUrl(variant.path, options);
887
+ return Storage3.disk(variant.disk).temporaryUrl(variant.path, options);
817
888
  } catch {
818
889
  return null;
819
890
  }
820
891
  }
821
892
  async delete() {
822
- const deletedSnapshots = [];
823
- try {
824
- for (const variant of this.resolveStoredVariants()) {
825
- const disk = Storage2.disk(variant.disk);
826
- const contents = await disk.getBytes(variant.path);
827
- await disk.delete(variant.path);
828
- if (contents) {
829
- deletedSnapshots.push({
830
- ...variant,
831
- contents
832
- });
833
- }
834
- }
835
- await this.entity.delete();
836
- registerDeletedVariantRollbackRestore(deletedSnapshots);
837
- this.owner?.forgetRelation("media");
838
- } catch (error) {
839
- for (const snapshot of deletedSnapshots.reverse()) {
840
- await Storage2.disk(snapshot.disk).put(snapshot.path, snapshot.contents).catch(() => void 0);
841
- }
842
- throw error;
843
- }
893
+ await deleteMediaItems([this], this.owner);
844
894
  }
845
895
  async regenerate(conversions) {
846
896
  await regenerateMediaEntityConversions({
@@ -848,18 +898,6 @@ var MediaItem = class {
848
898
  owner: this.owner,
849
899
  conversions
850
900
  });
851
- const definition = this.owner ? requireMediaDefinition(this.owner) : requireMediaDefinitionForMorphClass(String(this.entity.get("model_type")));
852
- const queuedConversions = resolveQueuedConversionNames({
853
- definition,
854
- collectionName: String(this.entity.get("collection_name")),
855
- requestedConversions: conversions
856
- });
857
- await dispatchQueuedMediaConversionsForModel({
858
- mediaId: this.entity.get("id"),
859
- conversionNames: queuedConversions
860
- }, async () => {
861
- await this.entity.refresh();
862
- });
863
901
  return this;
864
902
  }
865
903
  toJSON() {
@@ -882,24 +920,6 @@ var MediaItem = class {
882
920
  disk: variant.disk ?? this.entity.get("conversions_disk") ?? this.entity.get("disk")
883
921
  };
884
922
  }
885
- resolveStoredVariants() {
886
- const variants = [{
887
- path: this.entity.get("path"),
888
- disk: this.entity.get("disk")
889
- }];
890
- const fallbackDisk = this.entity.get("conversions_disk") ?? this.entity.get("disk");
891
- const conversions = this.entity.get("generated_conversions") ?? {};
892
- for (const value of Object.values(conversions)) {
893
- if (!value?.path) {
894
- continue;
895
- }
896
- variants.push({
897
- path: value.path,
898
- disk: value.disk ?? fallbackDisk
899
- });
900
- }
901
- return variants;
902
- }
903
923
  };
904
924
 
905
925
  // src/model/adder.ts
@@ -931,12 +951,12 @@ var MediaAddValidationException = class extends Error {
931
951
  }
932
952
  };
933
953
  function resolveImplicitDiskName() {
934
- const defaultDisk = Storage3.disk();
954
+ const defaultDisk = Storage4.disk();
935
955
  if (defaultDisk.visibility === "public") {
936
956
  return defaultDisk.name;
937
957
  }
938
958
  try {
939
- const publicDisk = Storage3.disk("public");
959
+ const publicDisk = Storage4.disk("public");
940
960
  if (publicDisk.visibility === "public") {
941
961
  return publicDisk.name;
942
962
  }
@@ -1269,96 +1289,6 @@ function validateSource(collection2, source) {
1269
1289
  }
1270
1290
  }
1271
1291
  }
1272
- async function resolveNextOrderColumn(modelType, modelId, collectionName) {
1273
- const max = await Media.query().where("model_type", modelType).where("model_id", modelId).where("collection_name", collectionName).max("order_column");
1274
- return (max ?? 0) + 1;
1275
- }
1276
- async function deleteMediaItemsWithRollback(items) {
1277
- const deletedSnapshots = [];
1278
- try {
1279
- for (const item of items) {
1280
- const snapshot = await snapshotDeletedMediaItem(item);
1281
- await item.delete();
1282
- deletedSnapshots.push(snapshot);
1283
- }
1284
- } catch (error) {
1285
- await restoreDeletedMediaSnapshots(deletedSnapshots);
1286
- throw error;
1287
- }
1288
- return deletedSnapshots;
1289
- }
1290
- async function deleteOverflowItems(items, limit) {
1291
- if (items.length <= limit) {
1292
- return [];
1293
- }
1294
- return await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
1295
- }
1296
- async function cleanupGeneratedConversions(conversions, _fallbackDisk) {
1297
- for (const conversion2 of Object.values(conversions)) {
1298
- await Storage3.disk(conversion2.disk).delete(conversion2.path).catch(() => void 0);
1299
- }
1300
- }
1301
- function registerCreatedMediaRollbackCleanup(cleanup) {
1302
- const active = connectionAsyncContext4.getActive()?.connection;
1303
- if (!active || active.getScope().kind === "root") {
1304
- return;
1305
- }
1306
- active.afterRollback(cleanup);
1307
- }
1308
- async function snapshotDeletedMediaItem(item) {
1309
- const record = item.record;
1310
- const fileTargets = [{
1311
- disk: record.disk,
1312
- path: record.path
1313
- }];
1314
- const fallbackDisk = record.conversions_disk ?? record.disk;
1315
- for (const conversion2 of Object.values(record.generated_conversions ?? {})) {
1316
- if (!conversion2?.path) {
1317
- continue;
1318
- }
1319
- fileTargets.push({
1320
- disk: conversion2.disk ?? fallbackDisk,
1321
- path: conversion2.path
1322
- });
1323
- }
1324
- const files = (await Promise.all(fileTargets.map(async (file) => {
1325
- const contents = await Storage3.disk(file.disk).getBytes(file.path);
1326
- return contents ? {
1327
- ...file,
1328
- contents
1329
- } : null;
1330
- }))).filter((file) => Boolean(file));
1331
- return {
1332
- record: {
1333
- uuid: record.uuid,
1334
- model_type: record.model_type,
1335
- model_id: record.model_id,
1336
- collection_name: record.collection_name,
1337
- name: record.name,
1338
- file_name: record.file_name,
1339
- disk: record.disk,
1340
- conversions_disk: record.conversions_disk,
1341
- mime_type: record.mime_type,
1342
- extension: record.extension,
1343
- size: record.size,
1344
- path: record.path,
1345
- generated_conversions: record.generated_conversions,
1346
- order_column: record.order_column
1347
- },
1348
- files
1349
- };
1350
- }
1351
- async function restoreDeletedMediaSnapshot(snapshot) {
1352
- for (const file of snapshot.files) {
1353
- await Storage3.disk(file.disk).put(file.path, file.contents);
1354
- }
1355
- await Media.create(snapshot.record);
1356
- }
1357
- async function restoreDeletedMediaSnapshots(snapshots) {
1358
- for (const snapshot of snapshots.reverse()) {
1359
- await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
1360
- }
1361
- }
1362
1292
  var MediaAdder = class _MediaAdder {
1363
1293
  constructor(entity, source, state = {}) {
1364
1294
  this.entity = entity;
@@ -1420,93 +1350,93 @@ var MediaAdder = class _MediaAdder {
1420
1350
  }
1421
1351
  );
1422
1352
  validateSource(collection2, source);
1423
- const existing = collection2.singleFile ? await this.entity.getMedia(collectionName) : [];
1424
- const uuid = randomUUID();
1425
- const diskName = this.state.disk ?? collection2.disk ?? resolveImplicitDiskName();
1426
- const conversionsDisk = collection2.conversionsDisk ?? diskName;
1427
- const originalPath = getMediaPathGenerator().originalPath({
1428
- uuid,
1429
- fileName: source.fileName,
1430
- extension: source.extension,
1431
- collection: collection2
1353
+ return await attachMedia({
1354
+ owner: this.entity,
1355
+ collectionName,
1356
+ collection: collection2,
1357
+ definition: mediaDefinition,
1358
+ source,
1359
+ disk: this.state.disk ?? collection2.disk ?? resolveImplicitDiskName()
1432
1360
  });
1433
- let originalStored = false;
1434
- let createdMedia;
1435
- let generatedConversions = Object.freeze({});
1436
- const deletedMediaSnapshots = [];
1437
- try {
1438
- await Storage3.disk(diskName).put(originalPath, source.contents);
1439
- originalStored = true;
1440
- registerCreatedMediaRollbackCleanup(async () => {
1441
- await cleanupGeneratedConversions(generatedConversions, conversionsDisk).catch(() => void 0);
1442
- await Storage3.disk(diskName).delete(originalPath).catch(() => void 0);
1443
- });
1444
- generatedConversions = await generateStoredConversions({
1445
- definition: mediaDefinition,
1361
+ }
1362
+ };
1363
+ async function attachMedia(options) {
1364
+ const { owner, collection: collection2, source, disk } = options;
1365
+ const ownerDefinition = owner.getRepository().definition;
1366
+ const ownerId = String(owner.get(ownerDefinition.primaryKey));
1367
+ const uuid = randomUUID();
1368
+ const conversionsDisk = collection2.conversionsDisk ?? disk;
1369
+ const path = getMediaPathGenerator().originalPath({ uuid, fileName: source.fileName, extension: source.extension, collection: collection2 });
1370
+ const obsolete = [];
1371
+ return await runMediaMutation({
1372
+ committedMessage: "[Holo Media] Attachment remains committed; post-commit cleanup or conversion dispatch failed.",
1373
+ afterRollback: () => {
1374
+ owner.forgetRelation("media");
1375
+ },
1376
+ operation: async (mutation) => {
1377
+ const existing = collection2.singleFile ? await owner.getMedia(options.collectionName) : [];
1378
+ await mutation.put(disk, path, source.contents);
1379
+ const generatedConversions = await generateStoredConversions({
1380
+ definition: options.definition,
1446
1381
  collection: collection2,
1447
1382
  conversionsDisk,
1448
- source: {
1449
- uuid,
1450
- fileName: source.fileName,
1451
- extension: source.extension,
1452
- mimeType: source.mimeType,
1453
- size: source.size,
1454
- contents: source.contents
1455
- }
1383
+ source: { ...source, uuid },
1384
+ mutation
1456
1385
  });
1386
+ const max = await Media.query().where("model_type", ownerDefinition.morphClass).where("model_id", ownerId).where("collection_name", options.collectionName).max("order_column");
1457
1387
  const media = await Media.create({
1458
1388
  uuid,
1459
1389
  model_type: ownerDefinition.morphClass,
1460
- model_id: String(ownerId),
1461
- collection_name: collectionName,
1390
+ model_id: ownerId,
1391
+ collection_name: options.collectionName,
1462
1392
  name: source.name,
1463
1393
  file_name: source.fileName,
1464
- disk: diskName,
1394
+ disk,
1465
1395
  conversions_disk: conversionsDisk,
1466
1396
  mime_type: source.mimeType ?? null,
1467
1397
  extension: source.extension ?? null,
1468
1398
  size: source.size,
1469
- path: originalPath,
1399
+ path,
1470
1400
  generated_conversions: generatedConversions,
1471
- order_column: await resolveNextOrderColumn(
1472
- ownerDefinition.morphClass,
1473
- String(ownerId),
1474
- collectionName
1475
- )
1401
+ order_column: (max ?? 0) + 1
1476
1402
  });
1477
- createdMedia = media;
1478
- this.entity.forgetRelation("media");
1479
- deletedMediaSnapshots.push(...await deleteMediaItemsWithRollback(existing));
1403
+ for (const item of existing) await item.getEntity().delete();
1404
+ owner.forgetRelation("media");
1405
+ obsolete.push(...existing);
1480
1406
  if (typeof collection2.onlyKeepLatest === "number") {
1481
- const items = await this.entity.getMedia(collectionName);
1482
- deletedMediaSnapshots.push(...await deleteOverflowItems(items, collection2.onlyKeepLatest));
1407
+ const items = await owner.getMedia(options.collectionName);
1408
+ const overflow = items.slice(0, Math.max(0, items.length - collection2.onlyKeepLatest));
1409
+ for (const item of overflow) await item.getEntity().delete();
1410
+ obsolete.push(...overflow);
1411
+ owner.forgetRelation("media");
1483
1412
  }
1484
- const queuedConversions = resolveQueuedConversionNames({
1485
- definition: mediaDefinition,
1486
- collectionName: collection2.name
1487
- });
1488
- await dispatchQueuedMediaConversionsForModel({
1489
- mediaId: media.get("id"),
1490
- conversionNames: queuedConversions
1491
- }, async () => {
1492
- await media.refresh();
1493
- });
1494
- return new MediaItem(media, this.entity);
1495
- } catch (error) {
1496
- await cleanupGeneratedConversions(generatedConversions, conversionsDisk).catch(() => void 0);
1497
- if (originalStored) {
1498
- await Storage3.disk(diskName).delete(originalPath).catch(() => void 0);
1499
- }
1500
- if (createdMedia) {
1501
- await createdMedia.refresh().catch(() => void 0);
1502
- await createdMedia.delete().catch(() => void 0);
1413
+ return new MediaItem(media, owner);
1414
+ },
1415
+ afterCommit: [
1416
+ async (item) => {
1417
+ await removeObsoleteFiles(obsolete, [
1418
+ { disk, path },
1419
+ ...Object.values(item.record.generated_conversions).map((conversion2) => ({
1420
+ disk: conversion2.disk ?? conversionsDisk,
1421
+ path: conversion2.path
1422
+ }))
1423
+ ]);
1424
+ },
1425
+ async (item) => {
1426
+ const media = item.getEntity();
1427
+ await dispatchQueuedMediaConversionsForModel({
1428
+ mediaId: media.get("id"),
1429
+ conversionNames: resolveQueuedConversionNames({ definition: options.definition, collectionName: collection2.name })
1430
+ }, async () => {
1431
+ await media.refresh();
1432
+ });
1503
1433
  }
1504
- await restoreDeletedMediaSnapshots(deletedMediaSnapshots);
1505
- this.entity.forgetRelation("media");
1506
- throw error;
1434
+ ],
1435
+ afterEffects: () => {
1436
+ owner.forgetRelation("media");
1507
1437
  }
1508
- }
1509
- };
1438
+ });
1439
+ }
1510
1440
 
1511
1441
  // src/model/entity.ts
1512
1442
  async function clearEntityMedia(entity, collectionName) {
@@ -1515,9 +1445,7 @@ async function clearEntityMedia(entity, collectionName) {
1515
1445
  return;
1516
1446
  }
1517
1447
  const rows = await query.get();
1518
- for (const row of rows) {
1519
- await new MediaItem(row, entity).delete();
1520
- }
1448
+ await deleteMediaItems(rows.map((row) => new MediaItem(row, entity)), entity);
1521
1449
  }
1522
1450
  function getOwnerKey(entity) {
1523
1451
  const ownerDefinition = entity.getRepository().definition;
@@ -1872,6 +1800,7 @@ export {
1872
1800
  getMediaDefinition,
1873
1801
  getMediaDefinitionForMorphClass,
1874
1802
  getMediaPathGenerator,
1803
+ mediaRuntimeInternals,
1875
1804
  normalizeCollectionDefinitions,
1876
1805
  normalizeConversionDefinitions,
1877
1806
  normalizeMediaConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holo-js/media",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Holo-JS Framework - media collections and conversions for Holo-JS models",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,19 +28,19 @@
28
28
  "test": "vitest --run"
29
29
  },
30
30
  "dependencies": {
31
- "@holo-js/config": "^0.3.15",
32
- "@holo-js/db": "^0.3.15",
33
- "@holo-js/queue": "^0.3.15",
34
- "@holo-js/storage": "^0.3.15",
35
- "sharp": "^0.34.4"
31
+ "@holo-js/config": "^0.3.17",
32
+ "@holo-js/db": "^0.3.17",
33
+ "@holo-js/queue": "^0.3.17",
34
+ "@holo-js/storage": "^0.3.17",
35
+ "sharp": "^0.35.4"
36
36
  },
37
37
  "devDependencies": {
38
- "@holo-js/db-sqlite": "^0.3.15",
38
+ "@holo-js/db-sqlite": "^0.3.17",
39
39
  "tsup": "^8.3.5",
40
40
  "typescript": "^5.7.2",
41
41
  "vitest": "^4.1.5"
42
42
  },
43
43
  "engines": {
44
- "node": ">=22.12.0"
44
+ "node": "^22.12.0 || ^24.0.0 || ^26.0.0"
45
45
  }
46
46
  }