import type { AxiosInstance } from 'axios'; export type Identifier = string | number; type Nullable = T | null; export interface AssetObject { ordinal?: number; url?: string | null; metadata?: Record | null; expires_at?: number | null; [key: string]: unknown; } export interface AssetLike { id?: Identifier; asset_type?: string; cardinality?: number | null; url?: string | null; metadata?: Record | null; expiresAt?: number | null; assets?: Record | AssetLike[] | null; objects?: AssetObject[] | null; [key: string]: unknown; } export interface DocumentVersionLike { assets?: Record | AssetLike[] | null; metadata?: Record & { page_count?: number } | null; [key: string]: unknown; } export interface DocumentLike { id?: Identifier; current_version?: DocumentVersionLike | null; [key: string]: unknown; } export type EnsureAssetUrl = ( documentId: Identifier, asset: AssetLike, options?: { start?: number | null; limit?: number | null; force?: boolean; [key: string]: unknown }, ) => Promise; export type GetAsset = (document: DocumentLike, assetType: string) => Nullable; export const getAssetFromGroup = ( assets?: AssetLike[] | Record | null, assetType: string = '', ): Nullable => { if (!assetType || !assets) { return null; } if (Array.isArray(assets)) { return assets.find((entry) => entry?.asset_type === assetType) || null; } return assets?.[assetType] || null; }; export const getAssetFromVersion = (currentVersion: Nullable, assetType: string) => { if (!currentVersion) { return null; } return getAssetFromGroup(currentVersion.assets ?? null, assetType); }; const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => { if (!Array.isArray(objects)) { return []; } return objects .filter((entry) => Number.isInteger(entry?.ordinal)) .slice() .sort((a, b) => a.ordinal - b.ordinal); }; const mergeAssetObjects = ( existingObjects?: AssetObject[] | null, incomingObjects?: AssetObject[] | null, ): AssetObject[] => { const merged = new Map(); normalizeAssetObjects(existingObjects).forEach((entry) => { if (Number.isFinite(entry.ordinal)) { merged.set(entry.ordinal as number, { ...entry }); } }); normalizeAssetObjects(incomingObjects).forEach((entry) => { if (Number.isFinite(entry.ordinal)) { const ordinal = entry.ordinal as number; const current = merged.get(ordinal) || {}; merged.set(ordinal, { ...current, ...entry }); } }); return Array.from(merged.entries()) .sort((a, b) => a[0] - b[0]) .map(([, value]) => value); }; export class AssetView { asset: AssetLike | null; private _objectsRef: AssetObject[] | null; private _sortedObjects: AssetObject[]; constructor(asset?: AssetLike | null) { this.asset = asset || null; this._objectsRef = null; this._sortedObjects = []; } getCardinality(): number { if (!this.asset) { return 0; } const reported = Number(this.asset.cardinality); if (Number.isFinite(reported) && reported > 0) { return reported; } const objectsCount = this.getObjects().length; if (objectsCount > 0) { return objectsCount; } return this.asset.metadata ? 1 : 0; } getObjects(): AssetObject[] { if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) { return []; } if (this._objectsRef === this.asset.objects) { return this._sortedObjects; } this._objectsRef = this.asset.objects; this._sortedObjects = normalizeAssetObjects(this.asset.objects); return this._sortedObjects; } getObject(ordinal = 1): AssetObject | null { const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal); if (fromObjects) { return fromObjects; } if (ordinal === 1 && this.asset) { if (this.asset.url || this.asset.metadata) { return { ordinal: 1, url: this.asset.url || null, metadata: this.asset.metadata || null, expires_at: this.asset.expiresAt ?? null, }; } } return null; } getPrimaryObject(): AssetObject | null { return this.getObject(1); } getPrimaryMetadata(): Record | null { return this.getPrimaryObject()?.metadata || null; } getPrimaryUrl(): string | null { return this.getPrimaryObject()?.url || null; } hasObject(ordinal: number): boolean { return Boolean(this.getObject(ordinal)); } } export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset); export const resolveDocumentAssetUrl = ( doc: Nullable, type: string, { ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1, }: { ensureAssetUrl?: EnsureAssetUrl; getAsset?: GetAsset; ensureOptions?: { start?: number; limit?: number; [key: string]: unknown }; objectOrdinal?: number; } = {}, ): Nullable => { if (!doc || !type) { return null; } const asset = getAsset ? getAsset(doc, type) : null; if (!asset) { return null; } const view = createAssetView(asset); const object = view.getObject(objectOrdinal); const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null); const expiresAt = Number.isFinite(object?.expires_at) ? Number(object?.expires_at) : objectOrdinal === 1 && Number.isFinite(asset.expiresAt) ? Number(asset.expiresAt) : null; const now = Date.now(); if (url && (!expiresAt || expiresAt > now)) { return url; } if (doc.id && asset.id && ensureAssetUrl) { const force = Boolean(url && expiresAt && expiresAt <= now); const options: { force: boolean; start?: number | null; limit?: number | null; [key: string]: unknown } = { force, start: objectOrdinal, limit: 1, ...(ensureOptions || {}), }; if (!options.start) { options.start = objectOrdinal; } if (!options.limit) { options.limit = 1; } ensureAssetUrl(doc.id, asset, options).catch(() => {}); } return null; }; class AssetManager { api: AxiosInstance | null; assetPresignTtlMs: number; assetCache: Map; assetInflight: Map>; constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) { this.api = api; this.assetPresignTtlMs = assetPresignTtlMs; this.assetCache = new Map(); this.assetInflight = new Map(); } setApi(api: AxiosInstance | null) { this.api = api; } rememberAsset(entry?: Nullable) { if (entry?.id) { this.assetCache.set(entry.id, entry); } } hydrateAsset(asset?: Nullable): Nullable { if (!asset || !asset.id) { return asset; } const cached = this.assetCache.get(asset.id); if (!cached) { const normalized = mergeAssetObjects(null, asset.objects); if (normalized.length) { return { ...asset, objects: normalized }; } return asset; } const merged = { ...cached, ...asset }; if (cached.url && !asset.url) { merged.url = cached.url; } if (cached.expiresAt) { const cachedExpires = Number(cached.expiresAt) || null; const assetExpires = Number(asset.expiresAt) || null; if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) { merged.expiresAt = cachedExpires; } } const mergedObjects = mergeAssetObjects(cached.objects, asset.objects); if (mergedObjects.length) { merged.objects = mergedObjects; } return merged; } hydrateDocument(document?: Nullable): Nullable { if (!document) { return document; } const currentVersion = document.current_version || null; if (!currentVersion) { return document; } let changed = false; let nextAssets = currentVersion.assets; if (nextAssets && !Array.isArray(nextAssets)) { const hydrated = {}; Object.keys(nextAssets).forEach((key) => { hydrated[key] = this.hydrateAsset(nextAssets[key]); if (hydrated[key] !== nextAssets[key]) { changed = true; } }); if (changed) { nextAssets = { ...nextAssets, ...hydrated }; } } else if (Array.isArray(nextAssets)) { const hydratedList = nextAssets.map((item) => this.hydrateAsset(item)); if ( hydratedList.length !== nextAssets.length || hydratedList.some((item, index) => item !== nextAssets[index]) ) { changed = true; nextAssets = hydratedList; } } if (!changed) { return document; } const nextCurrentVersion: DocumentVersionLike = { ...currentVersion, assets: nextAssets }; return { ...document, current_version: nextCurrentVersion }; } hydrateDocuments(documents?: DocumentLike[] | null) { if (!Array.isArray(documents)) { return documents ?? []; } return documents.map((doc) => this.hydrateDocument(doc)); } hydrateDetail(detail?: { document?: DocumentLike; assets?: AssetLike[] } | null) { if (!detail) { return detail; } let changed = false; const next = { ...detail }; if (detail.document) { const hydratedDocument = this.hydrateDocument(detail.document); if (hydratedDocument !== detail.document) { next.document = hydratedDocument; changed = true; } } if (Array.isArray(detail.assets)) { const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item)); if ( hydratedAssets.length !== detail.assets.length || hydratedAssets.some((item, index) => item !== detail.assets[index]) ) { next.assets = hydratedAssets; changed = true; } } return changed ? next : detail; } hydrateFolderContents(contents?: { documents?: DocumentLike[]; document?: DocumentLike } | null) { if (!contents) { return contents; } const next = { ...contents }; if (Array.isArray(contents.documents)) { next.documents = this.hydrateDocuments(contents.documents); } if (contents.document) { next.document = this.hydrateDocument(contents.document); } return next; } ensureAsset( documentId?: Identifier | null, asset?: Nullable, { force = false, start = null, limit = null }: { force?: boolean; start?: number | null; limit?: number | null } = {}, ): Promise> { if (!documentId || !asset?.id) { return Promise.resolve(asset ?? null); } const requestedStart = Number.isInteger(start) && start > 0 ? start : 1; const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1; const requestedEnd = requestedStart + requestedLimit - 1; const baseAsset = this.assetCache.get(asset.id) || asset; const view = createAssetView(baseAsset); const assetExpiresAt = Number.isFinite(baseAsset.expiresAt) ? Number(baseAsset.expiresAt) : null; const now = Date.now(); const isOrdinalSatisfied = (ordinal) => { const object = view.getObject(ordinal); if (!object) { return false; } if (!object.url) { return false; } if (Number.isFinite(object.expires_at)) { return Number(object.expires_at) > now; } if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) { return true; } return true; }; let needsFetch = force; if (!needsFetch) { for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) { if (!isOrdinalSatisfied(ordinal)) { needsFetch = true; break; } } } if (!needsFetch) { this.rememberAsset(baseAsset); return Promise.resolve(baseAsset); } const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`; if (!force && this.assetInflight.has(inflightKey)) { return this.assetInflight.get(inflightKey); } if (!this.api) { return Promise.reject(new Error('AssetManager API client is not configured.')); } const params: Record = {}; if (Number.isInteger(start) && start > 0) { params.start = start; } if (Number.isInteger(limit) && limit > 0) { params.limit = limit; } const requestConfig = Object.keys(params).length ? { params } : undefined; const request: Promise = this.api .get(`/assets/${asset.id}`, requestConfig) .then(({ data }) => { const incomingObjects = Array.isArray(data.objects) ? data.objects : []; const cachedEntry = this.assetCache.get(asset.id) || baseAsset; const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects); const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects }; const view = createAssetView(combined); const primaryObject = view.getPrimaryObject(); const expiresAt = Number.isFinite(primaryObject?.expires_at) ? Number(primaryObject?.expires_at) : Date.now() + this.assetPresignTtlMs; const cardinality = (() => { const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality); const objectsCount = mergedObjects.length; if (Number.isFinite(reported) && reported > 0) { return Math.max(reported, objectsCount) || null; } return objectsCount || null; })(); const entry = { ...combined, cardinality, url: view.getPrimaryUrl(), expiresAt, }; this.rememberAsset(entry); return entry; }) .finally(() => { this.assetInflight.delete(inflightKey); }); this.assetInflight.set(inflightKey, request); return request; } reset() { this.assetCache.clear(); this.assetInflight.clear(); } } export default AssetManager;