@murumets-ee/media 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.mjs +1 -1
- package/dist/{client-DNTvmvYS.mjs → client-0NOL7M_g.mjs} +2 -2
- package/dist/client-0NOL7M_g.mjs.map +1 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/image-styles-settings.d.mts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/plugin.d.mts +1 -1
- package/dist/processing.d.mts +65 -0
- package/dist/processing.d.mts.map +1 -0
- package/dist/processing.mjs +1 -0
- package/dist/query-client.d.mts +1 -1
- package/dist/{types-BV_pOm23.d.mts → types-CILN8YcP.d.mts} +1 -1
- package/dist/{types-BV_pOm23.d.mts.map → types-CILN8YcP.d.mts.map} +1 -1
- package/dist/variant-key-gVMhzKyv.mjs +2 -0
- package/dist/variant-key-gVMhzKyv.mjs.map +1 -0
- package/package.json +20 -16
- package/LICENSE +0 -94
- package/dist/client-DNTvmvYS.mjs.map +0 -1
package/dist/admin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=[`cover`,`contain`,`inside`,`outside`,`fill`],t=[`webp`,`jpeg`,`png`,`avif`];function n(n){for(let[r,i]of Object.entries(n)){if(typeof r!=`string`||r.length===0||!/^[a-z][a-z0-9_-]*$/.test(r))return{valid:!1,error:`Invalid style name "${r}": must be lowercase alphanumeric (a-z, 0-9, -, _)`};if(!i||typeof i!=`object`)return{valid:!1,error:`Style "${r}" must be an object`};let n=i;if(n.width!==void 0&&(typeof n.width!=`number`||n.width<=0))return{valid:!1,error:`Invalid width for style "${r}": must be a positive number`};if(n.height!==void 0&&(typeof n.height!=`number`||n.height<=0))return{valid:!1,error:`Invalid height for style "${r}": must be a positive number`};if(n.width===void 0&&n.height===void 0)return{valid:!1,error:`Style "${r}" must have at least width or height`};if(n.quality!==void 0&&(typeof n.quality!=`number`||n.quality<1||n.quality>100))return{valid:!1,error:`Invalid quality for style "${r}": must be 1-100`};if(n.fit!==void 0&&!e.includes(n.fit))return{valid:!1,error:`Invalid fit for style "${r}": must be one of ${e.join(`, `)}`};if(n.format!==void 0&&!t.includes(n.format))return{valid:!1,error:`Invalid format for style "${r}": must be one of ${t.join(`, `)}`}}return{valid:!0,styles:n}}const r=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function i(e){return r.test(e)}let a=null;function o(){return a||=(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`),{MediaClient:r}=await import(`./client-
|
|
1
|
+
const e=[`cover`,`contain`,`inside`,`outside`,`fill`],t=[`webp`,`jpeg`,`png`,`avif`];function n(n){for(let[r,i]of Object.entries(n)){if(typeof r!=`string`||r.length===0||!/^[a-z][a-z0-9_-]*$/.test(r))return{valid:!1,error:`Invalid style name "${r}": must be lowercase alphanumeric (a-z, 0-9, -, _)`};if(!i||typeof i!=`object`)return{valid:!1,error:`Style "${r}" must be an object`};let n=i;if(n.width!==void 0&&(typeof n.width!=`number`||n.width<=0))return{valid:!1,error:`Invalid width for style "${r}": must be a positive number`};if(n.height!==void 0&&(typeof n.height!=`number`||n.height<=0))return{valid:!1,error:`Invalid height for style "${r}": must be a positive number`};if(n.width===void 0&&n.height===void 0)return{valid:!1,error:`Style "${r}" must have at least width or height`};if(n.quality!==void 0&&(typeof n.quality!=`number`||n.quality<1||n.quality>100))return{valid:!1,error:`Invalid quality for style "${r}": must be 1-100`};if(n.fit!==void 0&&!e.includes(n.fit))return{valid:!1,error:`Invalid fit for style "${r}": must be one of ${e.join(`, `)}`};if(n.format!==void 0&&!t.includes(n.format))return{valid:!1,error:`Invalid format for style "${r}": must be one of ${t.join(`, `)}`}}return{valid:!0,styles:n}}const r=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function i(e){return r.test(e)}let a=null;function o(){return a||=(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`),{MediaClient:r}=await import(`./client-0NOL7M_g.mjs`),i=e(),a=t(n(),{app:i});return new r({db:i.db.readWrite,storage:a,logger:i.logger.child({media:!0})})})(),a}function s(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function c(e,t){return s({error:e},t)}async function l(e,{segments:t}){let n=await o();if(t.length===1&&t[0]===`settings`){let{createSettingsClient:e}=await import(`@murumets-ee/settings`),{imageStylesSettings:t}=await import(`./image-styles-settings-5zpGEfKG.mjs`),{getApp:n}=await import(`@murumets-ee/core`);return s({imageStyles:await e(t,{app:n()}).get(`imageStyles`)??{}})}if(t.length===2&&t[1]===`usage`){let e=t[0];if(!i(e))return c(`Invalid media ID format`,400);let{findMediaUsages:n}=await import(`./usage-D7Bn7Vvv.mjs`),{getApp:r}=await import(`@murumets-ee/core`);return s({usages:await n(e,r().db.readWrite)})}if(t.length>0){let e=t[0],r=await n.findById(e);if(!r)return c(`Media not found`,404);let i=await n.getUrl(e);return s({...r,url:i})}let r=new URL(e.url),a=r.searchParams.get(`search`)??void 0,l=r.searchParams.get(`mediaType`)??void 0,u=Math.min(Math.max(Number(r.searchParams.get(`limit`))||24,1),100),d=Math.max(Number(r.searchParams.get(`offset`))||0,0),f=await n.findMany({search:a,mediaType:l,limit:u,offset:d}),p=f.items.map(e=>e.id),[m,h]=await Promise.all([n.getUrls(p),n.getVariantUrls(p,`thumbnail`)]);return s({items:f.items.map(e=>({id:e.id,title:e.title,alt:e.alt,filename:e.filename,mimeType:e.mimeType,size:e.size,mediaType:e.mediaType,url:m.get(e.id)??``,thumbnailUrl:h.get(e.id),width:e.width,height:e.height})),total:f.total})}async function u(e,{segments:t,user:r,audit:i,checkPermission:a}){if(t.length===1&&t[0]===`regenerate-variants`){if(!a(`media`,`update`))return c(`Forbidden: media update permission required for variant regeneration`,403);let{createSettingsClient:e}=await import(`@murumets-ee/settings`),{imageStylesSettings:t}=await import(`./image-styles-settings-5zpGEfKG.mjs`),{regenerateAllVariants:n}=await import(`./regenerate-variants-DY7D4Ky3.mjs`),{getApp:o}=await import(`@murumets-ee/core`),{createStorageClient:l}=await import(`@murumets-ee/storage`),{getStorageConfig:u}=await import(`@murumets-ee/storage/plugin`),d=o(),f=await e(t,{app:d}).get(`imageStyles`);if(!f||Object.keys(f).length===0)return c(`No image styles configured`,400);let p=l(u(),{app:d}),m=await n({db:d.db.readWrite,storage:p,logger:d.logger.child({media:!0}),styles:f});return i?.({action:`media.regenerate_variants`,userId:r.id,userName:r.name,metadata:{total:m.total,processed:m.processed,errors:m.errors}}),s(m)}if(t.length===1&&t[0]===`settings`){if(!a(`media`,`update`))return c(`Forbidden: media update permission required for image style management`,403);let t=await e.json();if(!t.imageStyles||typeof t.imageStyles!=`object`)return c(`Body must contain "imageStyles" object`,400);let l=n(t.imageStyles);if(!l.valid)return c(l.error,400);let{createSettingsClient:u}=await import(`@murumets-ee/settings`),{imageStylesSettings:d}=await import(`./image-styles-settings-5zpGEfKG.mjs`),{getApp:f}=await import(`@murumets-ee/core`);return await u(d,{app:f()}).set(`imageStyles`,l.styles),(await o()).invalidateImageStylesCache(),i?.({action:`media.settings.update`,entityType:`settings`,userId:r.id,userName:r.name,changes:{imageStyles:l.styles}}),s({imageStyles:l.styles})}let l=await o(),u=(await e.formData()).get(`file`);if(!u||u.size===0)return c(`No file provided`,400);if(u.size>50*1024*1024)return c(`File too large: ${(u.size/1024/1024).toFixed(1)} MB exceeds 50 MB limit`,400);let d=Buffer.from(await u.arrayBuffer()),{detectMimeType:f}=await import(`@murumets-ee/storage`),{mimeType:p,mismatch:m}=await f(d,u.type||`application/octet-stream`);if(m)return c(`File content doesn't match declared type: claimed ${u.type}, detected ${p}`,400);let h=await l.upload(d,{filename:u.name,mimeType:p,size:u.size,uploadedBy:r.id}),g={id:h.media.id,title:h.media.title,alt:h.media.alt,filename:h.media.filename,mimeType:h.media.mimeType,size:h.media.size,mediaType:h.media.mediaType,url:h.url,width:h.media.width,height:h.media.height};return i?.({action:`media.upload`,entityType:`media`,entityId:h.media.id,userId:r.id,userName:r.name,changes:{filename:h.media.filename,mimeType:h.media.mimeType,size:h.media.size,mediaType:h.media.mediaType}}),s(g,201)}async function d(e,{segments:t,user:n,audit:r}){if(t.length===0)return c(`Media ID required`,400);let a=t[0];return i(a)?(await(await o()).delete(a),r?.({action:`media.delete`,entityType:`media`,entityId:a,userId:n.id,userName:n.name}),s({deleted:1})):c(`Invalid media ID format`,400)}function f(){return{prefix:`media`,resource:`media`,actions:[`view`,`create`,`update`,`delete`],handlers:{GET:l,POST:u,DELETE:d}}}export{f as mediaRoutes};
|
|
2
2
|
//# sourceMappingURL=admin.mjs.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{Media as e}from"./entity-DZFku8b7.mjs";import{n as t,r as n,t as r}from"./variant-key-DZUYURS5.mjs";import"server-only";var i=class{db;storage;logger;admin=null;imageStyles;constructor(e){this.db=e.db,this.storage=e.storage,this.logger=e.logger,this.imageStyles=e.imageStyles??null}async getAdmin(){if(!this.admin){let{AdminClient:t}=await import(`@murumets-ee/entity/admin`);this.admin=new t({entity:e,db:this.db,logger:this.logger})}return this.admin}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;try{let{createSettingsClient:e}=await import(`@murumets-ee/settings`),{imageStylesSettings:t}=await import(`./image-styles-settings-5zpGEfKG.mjs`),{getApp:n}=await import(`@murumets-ee/core`),r=await e(t,{app:n()}).get(`imageStyles`);if(r&&Object.keys(r).length>0)return this.imageStyles=r,r}catch{}try{let{getMediaConfig:e}=await import(`./plugin-DV7lvImm.mjs`);return this.imageStyles=e().imageStyles,this.imageStyles}catch{let e={thumbnail:{width:200,height:200,fit:`cover`,format:`webp`,quality:80}};return this.imageStyles=e,e}}invalidateImageStylesCache(){this.imageStyles=null}async upload(e,i){let s=await this.storage.upload(e,{filename:i.filename,mimeType:i.mimeType,size:i.size,visibility:i.visibility,uploadedBy:i.uploadedBy}),c=i.width??null,l=i.height??null,u={};if(e instanceof Buffer&&t(i.mimeType))try{let t=await n(e,await this.resolveImageStyles());c=t.width,l=t.height;let a=s.visibility;await Promise.all([...t.variants.entries()].map(async([e,t])=>{let n=r(s.key,e,t.format);try{await this.storage.upload(t.buffer,{key:n,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:a,metadata:{variantOf:s.key,style:e},uploadedBy:i.uploadedBy}),u[e]=n,this.logger?.debug({style:e,key:n,width:t.width,height:t.height},`Variant uploaded`)}catch(t){this.logger?.warn({style:e,key:n,error:t},`Failed to upload variant (non-fatal)`)}})),Object.keys(u).length>0&&await this.storage.updateMetadata(s.key,{metadata:{...s.metadata??{},variants:u}}).catch(e=>{this.logger?.warn({key:s.key,error:e},`Failed to update original file metadata with variant keys (non-fatal)`)}),this.logger?.info({width:c,height:l,variants:Object.keys(u)},`Image processed`)}catch(e){this.logger?.warn({filename:i.filename,error:e},`Image processing failed (non-fatal, original saved)`)}let d=a(i.mimeType),f=await this.getAdmin();try{let e=await f.create({title:i.title??o(i.filename),alt:i.alt??null,description:i.description??null,fileKey:s.key,filename:i.filename,mimeType:i.mimeType,size:i.size,width:c,height:l,mediaType:d}),t=await this.storage.getUrl(s.key);return this.logger?.info({id:e.id,fileKey:s.key,mediaType:d},`Media uploaded`),{media:e,url:t}}catch(e){this.logger?.error({fileKey:s.key,error:e},`Media entity creation failed, rolling back storage uploads`);for(let e of Object.values(u))await this.storage.delete(e).catch(()=>{});throw await this.storage.delete(s.key).catch(e=>{this.logger?.error({fileKey:s.key,error:e},`Storage rollback also failed`)}),e}}async findById(e,t){return await(await this.getAdmin()).findById(e,t)}async findMany(e){let t=await this.getAdmin(),{schemaRegistry:n}=await import(`@murumets-ee/db`),{and:r,asc:i,desc:a,eq:o,ilike:s,sql:
|
|
2
|
-
//# sourceMappingURL=client-
|
|
1
|
+
import{Media as e}from"./entity-DZFku8b7.mjs";import{n as t,r as n,t as r}from"./variant-key-DZUYURS5.mjs";import"server-only";var i=class{db;storage;logger;admin=null;imageStyles;constructor(e){this.db=e.db,this.storage=e.storage,this.logger=e.logger,this.imageStyles=e.imageStyles??null}async getAdmin(){if(!this.admin){let{AdminClient:t}=await import(`@murumets-ee/entity/admin`);this.admin=new t({entity:e,db:this.db,logger:this.logger})}return this.admin}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;try{let{createSettingsClient:e}=await import(`@murumets-ee/settings`),{imageStylesSettings:t}=await import(`./image-styles-settings-5zpGEfKG.mjs`),{getApp:n}=await import(`@murumets-ee/core`),r=await e(t,{app:n()}).get(`imageStyles`);if(r&&Object.keys(r).length>0)return this.imageStyles=r,r}catch{}try{let{getMediaConfig:e}=await import(`./plugin-DV7lvImm.mjs`);return this.imageStyles=e().imageStyles,this.imageStyles}catch{let e={thumbnail:{width:200,height:200,fit:`cover`,format:`webp`,quality:80}};return this.imageStyles=e,e}}invalidateImageStylesCache(){this.imageStyles=null}async upload(e,i){let s=await this.storage.upload(e,{filename:i.filename,mimeType:i.mimeType,size:i.size,visibility:i.visibility,uploadedBy:i.uploadedBy}),c=i.width??null,l=i.height??null,u={};if(e instanceof Buffer&&t(i.mimeType))try{let t=await n(e,await this.resolveImageStyles());c=t.width,l=t.height;let a=s.visibility;await Promise.all([...t.variants.entries()].map(async([e,t])=>{let n=r(s.key,e,t.format);try{await this.storage.upload(t.buffer,{key:n,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:a,metadata:{variantOf:s.key,style:e},uploadedBy:i.uploadedBy}),u[e]=n,this.logger?.debug({style:e,key:n,width:t.width,height:t.height},`Variant uploaded`)}catch(t){this.logger?.warn({style:e,key:n,error:t},`Failed to upload variant (non-fatal)`)}})),Object.keys(u).length>0&&await this.storage.updateMetadata(s.key,{metadata:{...s.metadata??{},variants:u}}).catch(e=>{this.logger?.warn({key:s.key,error:e},`Failed to update original file metadata with variant keys (non-fatal)`)}),this.logger?.info({width:c,height:l,variants:Object.keys(u)},`Image processed`)}catch(e){this.logger?.warn({filename:i.filename,error:e},`Image processing failed (non-fatal, original saved)`)}let d=a(i.mimeType),f=await this.getAdmin();try{let e=await f.create({title:i.title??o(i.filename),alt:i.alt??null,description:i.description??null,fileKey:s.key,filename:i.filename,mimeType:i.mimeType,size:i.size,width:c,height:l,mediaType:d}),t=await this.storage.getUrl(s.key);return this.logger?.info({id:e.id,fileKey:s.key,mediaType:d},`Media uploaded`),{media:e,url:t}}catch(e){this.logger?.error({fileKey:s.key,error:e},`Media entity creation failed, rolling back storage uploads`);for(let e of Object.values(u))await this.storage.delete(e).catch(()=>{});throw await this.storage.delete(s.key).catch(e=>{this.logger?.error({fileKey:s.key,error:e},`Storage rollback also failed`)}),e}}async findById(e,t){return await(await this.getAdmin()).findById(e,t)}async findMany(e){let t=await this.getAdmin(),{schemaRegistry:n}=await import(`@murumets-ee/db`),{and:r,asc:i,desc:a,eq:o,ilike:s,or:c,sql:l}=await import(`drizzle-orm`),u=n.get(`media`);if(!u)throw Error(`Media schema not registered. Is the media() plugin loaded?`);let d=[];if(e?.mediaType&&d.push(o(u.mediaType,e.mediaType)),e?.mimeTypePrefix){let t=e.mimeTypePrefix.replace(/[\\%_]/g,`\\$&`);d.push(s(u.mimeType,`${t}%`))}if(e?.search){let t=`%${e.search.replace(/[\\%_]/g,`\\$&`)}%`;d.push(c(s(u.filename,t),l`${u.fields} ->> 'title' ILIKE ${t}`))}let f=e?.limit??50,p=e?.offset??0,m=d.length>0?r(...d):void 0,[h]=await this.db.select({count:l`count(*)::int`}).from(u).where(m),g=e?.orderBy===`filename`?u.filename:u.createdAt,_=(e?.orderDirection??`desc`)===`asc`?i:a;return{items:await t.findMany({where:m,limit:f,offset:p,orderBy:_(g)}),total:h?.count??0,limit:f,offset:p}}async update(e,t){return await(await this.getAdmin()).update(e,t)}async delete(e){let t=await this.getAdmin(),n=await t.findById(e);if(!n)throw Error(`Media not found: ${e}`);let r=n.fileKey;await t.delete(e);let i=(await this.storage.getMetadata(r))?.metadata?.variants;if(i)for(let e of Object.values(i))await this.storage.delete(e).catch(t=>{this.logger?.warn({variantKey:e,error:t},`Failed to delete variant file`)});await this.storage.delete(r).catch(t=>{this.logger?.error({id:e,fileKey:r,error:t},`Failed to delete file from storage after entity deletion`)}),this.logger?.info({id:e,fileKey:r,deletedVariants:i?Object.keys(i).length:0},`Media deleted`)}async getUrl(e){let t=await(await this.getAdmin()).findById(e);if(!t)throw Error(`Media not found: ${e}`);return this.storage.getUrl(t.fileKey)}async getUrls(e){if(e.length===0)return new Map;let t=await this.getAdmin(),{schemaRegistry:n}=await import(`@murumets-ee/db`),{inArray:r}=await import(`drizzle-orm`),i=n.get(`media`);if(!i)return new Map;let a=await t.findMany({where:r(i.id,e),limit:e.length}),o=new Map;return await Promise.all(a.map(async e=>{let t=await this.storage.getUrl(e.fileKey);o.set(e.id,t)})),o}async getVariantUrl(e,t){let n=await(await this.getAdmin()).findById(e);if(!n)return null;let i=n.fileKey,a=(await this.resolveImageStyles())[t];if(a){let e=r(i,t,a.format??`webp`);try{return await this.storage.getUrl(e)}catch{}}try{return await this.storage.getUrl(i)}catch{return null}}async getVariantUrls(e,t){if(e.length===0)return new Map;let n=await this.getAdmin(),{schemaRegistry:i}=await import(`@murumets-ee/db`),{inArray:a}=await import(`drizzle-orm`),o=i.get(`media`);if(!o)return new Map;let s=await n.findMany({where:a(o.id,e),limit:e.length}),c=(await this.resolveImageStyles())[t],l=new Map;return await Promise.all(s.map(async e=>{if(c){let n=r(e.fileKey,t,c.format??`webp`);try{let t=await this.storage.getUrl(n);l.set(e.id,t);return}catch{}}try{let t=await this.storage.getUrl(e.fileKey);l.set(e.id,t)}catch{}})),l}};function a(e){return e.startsWith(`image/`)?`image`:e.startsWith(`video/`)?`video`:e.startsWith(`audio/`)?`audio`:e===`application/pdf`||e.startsWith(`application/msword`)||e.startsWith(`application/vnd.`)?`document`:`other`}function o(e){return e.replace(/\.[^.]+$/,``).replace(/[-_]/g,` `)}export{i as MediaClient};
|
|
2
|
+
//# sourceMappingURL=client-0NOL7M_g.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-0NOL7M_g.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * MediaClient — wraps AdminClient + StorageClient for media management.\n *\n * Usage:\n * import { createMediaClient } from '@murumets-ee/media/client'\n * const media = await createMediaClient(storageClient)\n * const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { FieldConfig, FieldToTS } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { Media } from './entity.js'\nimport { isProcessableImage, processImage } from './process-image.js'\nimport type {\n ImageStyle,\n MediaListOptions,\n MediaListResult,\n MediaRecord,\n MediaType,\n MediaUploadOptions,\n MediaUploadResult,\n} from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\n/** Value type that any entity field can hold */\ntype EntityFieldValue = FieldToTS<FieldConfig> | null | undefined\n\nexport interface MediaClientConfig {\n db: PostgresJsDatabase\n storage: StorageClient\n logger?: Logger\n /** Image styles to generate on upload. Loaded from plugin config if not provided. */\n imageStyles?: Record<string, ImageStyle>\n}\n\nexport class MediaClient {\n private db: PostgresJsDatabase\n private storage: StorageClient\n private logger?: Logger\n private admin: AdminClient | null = null\n private imageStyles: Record<string, ImageStyle> | null\n\n constructor(config: MediaClientConfig) {\n this.db = config.db\n this.storage = config.storage\n this.logger = config.logger\n this.imageStyles = config.imageStyles ?? null\n }\n\n private async getAdmin(): Promise<AdminClient> {\n if (!this.admin) {\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n this.admin = new AdminClient({\n entity: Media,\n db: this.db,\n logger: this.logger,\n })\n }\n return this.admin\n }\n\n /**\n * Resolve image styles: settings DB → plugin config → hardcoded defaults.\n * Result is cached; call `invalidateImageStylesCache()` after settings update.\n */\n private async resolveImageStyles(): Promise<Record<string, ImageStyle>> {\n if (this.imageStyles) return this.imageStyles\n\n // 1. Try settings DB (source of truth after first boot)\n try {\n const { createSettingsClient } = await import('@murumets-ee/settings')\n const { imageStylesSettings } = await import('./image-styles-settings.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const settingsClient = createSettingsClient(imageStylesSettings, { app })\n const stored = await settingsClient.get('imageStyles')\n if (stored && Object.keys(stored).length > 0) {\n this.imageStyles = stored\n return stored\n }\n } catch {\n // Settings not available — fall through to plugin config\n }\n\n // 2. Fall back to plugin config singleton\n try {\n const { getMediaConfig } = await import('./plugin.js')\n const config = getMediaConfig()\n this.imageStyles = config.imageStyles\n return this.imageStyles\n } catch {\n // Plugin not initialized — hardcoded defaults\n const defaults: Record<string, ImageStyle> = {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n }\n this.imageStyles = defaults\n return defaults\n }\n }\n\n /** Clear cached styles so next access re-reads from settings DB. */\n invalidateImageStylesCache(): void {\n this.imageStyles = null\n }\n\n // ---------------------------------------------------------------\n // Upload — the key convenience method\n // ---------------------------------------------------------------\n\n /**\n * Upload a file and create a media entity record in one step.\n * 1. Uploads original to storage (StorageClient)\n * 2. If image: extracts dimensions via Sharp + generates variants\n * 3. Creates media entity record (AdminClient)\n * 4. Returns the media record + URL\n *\n * Rolls back storage upload if entity creation fails.\n * Variant generation failures are logged but don't fail the upload.\n */\n async upload(\n data: Buffer | ReadableStream<Uint8Array>,\n options: MediaUploadOptions,\n ): Promise<MediaUploadResult> {\n // 1. Upload original file to storage\n const fileRecord = await this.storage.upload(data, {\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n visibility: options.visibility,\n uploadedBy: options.uploadedBy,\n })\n\n // 2. Image processing — extract dimensions + generate variants\n let width = options.width ?? null\n let height = options.height ?? null\n const variantKeys: Record<string, string> = {}\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n try {\n const styles = await this.resolveImageStyles()\n const processed = await processImage(data, styles)\n\n // Set dimensions from Sharp metadata\n width = processed.width\n height = processed.height\n\n // Upload each variant to storage\n const visibility = fileRecord.visibility\n await Promise.all(\n [...processed.variants.entries()].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(fileRecord.key, styleName, variant.format)\n try {\n await this.storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${options.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: fileRecord.key, style: styleName },\n uploadedBy: options.uploadedBy,\n })\n variantKeys[styleName] = vKey\n this.logger?.debug(\n { style: styleName, key: vKey, width: variant.width, height: variant.height },\n 'Variant uploaded',\n )\n } catch (variantErr: unknown) {\n this.logger?.warn(\n { style: styleName, key: vKey, error: variantErr },\n 'Failed to upload variant (non-fatal)',\n )\n }\n }),\n )\n\n // Store variant keys in original file's metadata\n if (Object.keys(variantKeys).length > 0) {\n await this.storage\n .updateMetadata(fileRecord.key, {\n metadata: {\n ...(fileRecord.metadata ?? {}),\n variants: variantKeys,\n },\n })\n .catch((metaErr: unknown) => {\n this.logger?.warn(\n { key: fileRecord.key, error: metaErr },\n 'Failed to update original file metadata with variant keys (non-fatal)',\n )\n })\n }\n\n this.logger?.info({ width, height, variants: Object.keys(variantKeys) }, 'Image processed')\n } catch (processErr: unknown) {\n this.logger?.warn(\n { filename: options.filename, error: processErr },\n 'Image processing failed (non-fatal, original saved)',\n )\n }\n }\n\n // 3. Create media entity record\n const mediaType = deriveMediaType(options.mimeType)\n const admin = await this.getAdmin()\n\n try {\n const entityRecord = await admin.create({\n title: options.title ?? deriveTitle(options.filename),\n alt: options.alt ?? null,\n description: options.description ?? null,\n fileKey: fileRecord.key,\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n width,\n height,\n mediaType,\n } as Record<string, EntityFieldValue>)\n\n // 4. Get URL\n const url = await this.storage.getUrl(fileRecord.key)\n\n this.logger?.info(\n { id: entityRecord.id, fileKey: fileRecord.key, mediaType },\n 'Media uploaded',\n )\n\n return {\n media: entityRecord as unknown as MediaRecord,\n url,\n }\n } catch (error) {\n // Rollback: delete variants + original if entity creation fails\n this.logger?.error(\n { fileKey: fileRecord.key, error },\n 'Media entity creation failed, rolling back storage uploads',\n )\n\n // Delete variants first (best-effort)\n for (const vKey of Object.values(variantKeys)) {\n await this.storage.delete(vKey).catch(() => {})\n }\n\n // Delete original\n await this.storage.delete(fileRecord.key).catch((rollbackErr: unknown) => {\n this.logger?.error(\n { fileKey: fileRecord.key, error: rollbackErr },\n 'Storage rollback also failed',\n )\n })\n throw error\n }\n }\n\n // ---------------------------------------------------------------\n // CRUD delegation\n // ---------------------------------------------------------------\n\n async findById(id: string, options?: { locale?: string }): Promise<MediaRecord | null> {\n const admin = await this.getAdmin()\n const result = await admin.findById(id, options)\n return result as unknown as MediaRecord | null\n }\n\n async findMany(options?: MediaListOptions): Promise<MediaListResult> {\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, desc, eq, ilike, or, sql } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n\n // Build where conditions\n const conditions = []\n\n if (options?.mediaType) {\n conditions.push(eq(table.mediaType, options.mediaType))\n }\n if (options?.mimeTypePrefix) {\n // Escape ILIKE wildcards (%, _) in user input to prevent pattern injection\n const escaped = options.mimeTypePrefix.replace(/[\\\\%_]/g, '\\\\$&')\n conditions.push(ilike(table.mimeType, `${escaped}%`))\n }\n if (options?.search) {\n // Escape ILIKE wildcards in user input, then wrap with %...%\n const escaped = options.search.replace(/[\\\\%_]/g, '\\\\$&')\n const pattern = `%${escaped}%`\n conditions.push(\n or(\n ilike(table.filename, pattern),\n sql`${table.fields} ->> 'title' ILIKE ${pattern}`,\n )!,\n )\n }\n\n const limit = options?.limit ?? 50\n const offset = options?.offset ?? 0\n const whereClause = conditions.length > 0 ? and(...conditions) : undefined\n\n // Count total\n const [countResult] = await this.db\n .select({ count: sql<number>`count(*)::int` })\n .from(table)\n .where(whereClause)\n\n // Fetch items via AdminClient for proper DTO shaping\n const orderField = options?.orderBy === 'filename' ? table.filename : table.createdAt\n const orderFn = (options?.orderDirection ?? 'desc') === 'asc' ? asc : desc\n\n const items = await admin.findMany({\n where: whereClause,\n limit,\n offset,\n orderBy: orderFn(orderField),\n })\n\n return {\n items: items as unknown as MediaRecord[],\n total: countResult?.count ?? 0,\n limit,\n offset,\n }\n }\n\n async update(\n id: string,\n data: { title?: string; alt?: string; description?: string },\n ): Promise<MediaRecord> {\n const admin = await this.getAdmin()\n const result = await admin.update(id, data as Record<string, EntityFieldValue>)\n return result as unknown as MediaRecord\n }\n\n /**\n * Delete a media entity, its variants, and its original file in storage.\n */\n async delete(id: string): Promise<void> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // 1. Delete entity first (ref checking via entity_refs happens here)\n await admin.delete(id)\n\n // 2. Look up original file record for variant metadata\n const fileRecord = await this.storage.getMetadata(fileKey)\n const variants = (fileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n\n // 3. Delete variants (best-effort)\n if (variants) {\n for (const vKey of Object.values(variants)) {\n await this.storage.delete(vKey).catch((err: unknown) => {\n this.logger?.warn({ variantKey: vKey, error: err }, 'Failed to delete variant file')\n })\n }\n }\n\n // 4. Delete original file (best-effort)\n await this.storage.delete(fileKey).catch((err: unknown) => {\n this.logger?.error(\n { id, fileKey, error: err },\n 'Failed to delete file from storage after entity deletion',\n )\n })\n\n this.logger?.info(\n { id, fileKey, deletedVariants: variants ? Object.keys(variants).length : 0 },\n 'Media deleted',\n )\n }\n\n // ---------------------------------------------------------------\n // URL resolution\n // ---------------------------------------------------------------\n\n /**\n * Get URL for a media entity by its ID.\n * Resolves entity -> fileKey -> storage URL.\n */\n async getUrl(id: string): Promise<string> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n return this.storage.getUrl((record as unknown as MediaRecord).fileKey)\n }\n\n /**\n * Get URLs for multiple media entities (batch).\n * Returns a Map of mediaId -> url.\n */\n async getUrls(ids: string[]): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n }),\n )\n\n return urlMap\n }\n\n /**\n * Get variant URL for a specific image style.\n * Falls back to original URL if the variant doesn't exist.\n *\n * @param id - Media entity ID\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns The variant URL, or original URL as fallback, or null if media not found\n */\n async getVariantUrl(id: string, styleName: string): Promise<string | null> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) return null\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // Try variant key first\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n if (style) {\n const vKey = deriveVariantKey(fileKey, styleName, style.format ?? 'webp')\n try {\n return await this.storage.getUrl(vKey)\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n return await this.storage.getUrl(fileKey)\n } catch {\n return null\n }\n }\n\n /**\n * Get variant URLs for multiple media entities (batch).\n * Falls back to original URL per item if the variant doesn't exist.\n *\n * @param ids - Media entity IDs\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns Map of mediaId -> variant URL (or original URL as fallback)\n */\n async getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n // Try variant URL\n if (style) {\n const vKey = deriveVariantKey(record.fileKey, styleName, style.format ?? 'webp')\n try {\n const url = await this.storage.getUrl(vKey)\n urlMap.set(record.id, url)\n return\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n } catch {\n // Skip — no URL available\n }\n }),\n )\n\n return urlMap\n }\n}\n\n// ---------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------\n\nfunction deriveMediaType(mimeType: string): MediaType {\n if (mimeType.startsWith('image/')) return 'image'\n if (mimeType.startsWith('video/')) return 'video'\n if (mimeType.startsWith('audio/')) return 'audio'\n if (\n mimeType === 'application/pdf' ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.')\n ) {\n return 'document'\n }\n return 'other'\n}\n\nfunction deriveTitle(filename: string): string {\n const withoutExt = filename.replace(/\\.[^.]+$/, '')\n return withoutExt.replace(/[-_]/g, ' ')\n}\n\n/**\n * Factory — creates a MediaClient with an explicit StorageClient.\n * Must be called after createApp().\n */\nexport async function createMediaClient(storage: StorageClient): Promise<MediaClient> {\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n}\n\n// ---------------------------------------------------------------------------\n// Lazy singleton — auto-discovers storage config\n// ---------------------------------------------------------------------------\n\nlet _clientPromise: Promise<MediaClient> | null = null\n\n/**\n * Lazy singleton — returns a shared MediaClient instance.\n * Auto-discovers storage configuration from the storage plugin.\n * Must be called after createApp().\n */\nexport function getMediaClient(): Promise<MediaClient> {\n if (!_clientPromise) {\n _clientPromise = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const config = getStorageConfig()\n const storage = createStorageClient(config, { app })\n\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n })()\n }\n return _clientPromise\n}\n"],"mappings":"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"}
|
package/dist/client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as MediaRecord, c as MediaUploadResult, n as MediaListOptions, r as MediaListResult, s as MediaUploadOptions, t as ImageStyle } from "./types-
|
|
1
|
+
import { a as MediaRecord, c as MediaUploadResult, n as MediaListOptions, r as MediaListResult, s as MediaUploadOptions, t as ImageStyle } from "./types-CILN8YcP.mjs";
|
|
2
2
|
import { Logger } from "@murumets-ee/core";
|
|
3
3
|
import { StorageClient } from "@murumets-ee/storage";
|
|
4
4
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
package/dist/client.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.mts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;UAgCiB,iBAAA;EACf,EAAA,EAAI,kBAAA;EACJ,OAAA,EAAS,aAAA;EACT,MAAA,GAAS,MAAA;EAFT;EAIA,WAAA,GAAc,MAAA,SAAe,UAAA;AAAA;AAAA,cAGlB,WAAA;EAAA,QACH,EAAA;EAAA,QACA,OAAA;EAAA,QACA,MAAA;EAAA,QACA,KAAA;EAAA,QACA,WAAA;cAEI,MAAA,EAAQ,iBAAA;EAAA,QAON,QAAA;EAdH;;;;EAAA,QA8BG,kBAAA;EAuDkB;EAnBhC,0BAAA,CAAA;EAoBW;;;;;;;;;;EAFL,MAAA,CACJ,IAAA,EAAM,MAAA,GAAS,cAAA,CAAe,UAAA,GAC9B,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,iBAAA;EAwIL,QAAA,CAAS,EAAA,UAAY,OAAA;IAAY,MAAA;EAAA,IAAoB,OAAA,CAAQ,WAAA;EAM7D,QAAA,CAAS,OAAA,GAAU,gBAAA,GAAmB,OAAA,CAAQ,eAAA;
|
|
1
|
+
{"version":3,"file":"client.d.mts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;UAgCiB,iBAAA;EACf,EAAA,EAAI,kBAAA;EACJ,OAAA,EAAS,aAAA;EACT,MAAA,GAAS,MAAA;EAFT;EAIA,WAAA,GAAc,MAAA,SAAe,UAAA;AAAA;AAAA,cAGlB,WAAA;EAAA,QACH,EAAA;EAAA,QACA,OAAA;EAAA,QACA,MAAA;EAAA,QACA,KAAA;EAAA,QACA,WAAA;cAEI,MAAA,EAAQ,iBAAA;EAAA,QAON,QAAA;EAdH;;;;EAAA,QA8BG,kBAAA;EAuDkB;EAnBhC,0BAAA,CAAA;EAoBW;;;;;;;;;;EAFL,MAAA,CACJ,IAAA,EAAM,MAAA,GAAS,cAAA,CAAe,UAAA,GAC9B,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,iBAAA;EAwIL,QAAA,CAAS,EAAA,UAAY,OAAA;IAAY,MAAA;EAAA,IAAoB,OAAA,CAAQ,WAAA;EAM7D,QAAA,CAAS,OAAA,GAAU,gBAAA,GAAmB,OAAA,CAAQ,eAAA;EA4D9C,MAAA,CACJ,EAAA,UACA,IAAA;IAAQ,KAAA;IAAgB,GAAA;IAAc,WAAA;EAAA,IACrC,OAAA,CAAQ,WAAA;EAlSH;;;EA2SF,MAAA,CAAO,EAAA,WAAa,OAAA;;;;;EA+CpB,MAAA,CAAO,EAAA,WAAa,OAAA;EA1R1B;;;;EAsSM,OAAA,CAAQ,GAAA,aAAgB,OAAA,CAAQ,GAAA;EAnRpC;;;;;;;;EAsTI,aAAA,CAAc,EAAA,UAAY,SAAA,WAAoB,OAAA;EA5KO;;;;;;;;EA+MrD,cAAA,CAAe,GAAA,YAAe,SAAA,WAAoB,OAAA,CAAQ,GAAA;AAAA;;;;;iBA0E5C,iBAAA,CAAkB,OAAA,EAAS,aAAA,GAAgB,OAAA,CAAQ,WAAA;;;;;;iBAqBzD,cAAA,CAAA,GAAkB,OAAA,CAAQ,WAAA"}
|
package/dist/client.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./entity-D5P2l05s.mjs";import
|
|
1
|
+
import{t as e}from"./entity-D5P2l05s.mjs";import{i as t,r as n,t as r}from"./variant-key-gVMhzKyv.mjs";import"server-only";var i=class{db;storage;logger;admin=null;imageStyles;constructor(e){this.db=e.db,this.storage=e.storage,this.logger=e.logger,this.imageStyles=e.imageStyles??null}async getAdmin(){if(!this.admin){let{AdminClient:t}=await import(`@murumets-ee/entity/admin`);this.admin=new t({entity:e,db:this.db,logger:this.logger})}return this.admin}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;try{let{createSettingsClient:e}=await import(`@murumets-ee/settings`),{imageStylesSettings:t}=await import(`./image-styles-settings.mjs`),{getApp:n}=await import(`@murumets-ee/core`),r=await e(t,{app:n()}).get(`imageStyles`);if(r&&Object.keys(r).length>0)return this.imageStyles=r,r}catch{}try{let{getMediaConfig:e}=await import(`./plugin.mjs`);return this.imageStyles=e().imageStyles,this.imageStyles}catch{let e={thumbnail:{width:200,height:200,fit:`cover`,format:`webp`,quality:80}};return this.imageStyles=e,e}}invalidateImageStylesCache(){this.imageStyles=null}async upload(e,i){let s=await this.storage.upload(e,{filename:i.filename,mimeType:i.mimeType,size:i.size,visibility:i.visibility,uploadedBy:i.uploadedBy}),c=i.width??null,l=i.height??null,u={};if(e instanceof Buffer&&n(i.mimeType))try{let n=await t(e,await this.resolveImageStyles());c=n.width,l=n.height;let a=s.visibility;await Promise.all([...n.variants.entries()].map(async([e,t])=>{let n=r(s.key,e,t.format);try{await this.storage.upload(t.buffer,{key:n,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:a,metadata:{variantOf:s.key,style:e},uploadedBy:i.uploadedBy}),u[e]=n,this.logger?.debug({style:e,key:n,width:t.width,height:t.height},`Variant uploaded`)}catch(t){this.logger?.warn({style:e,key:n,error:t},`Failed to upload variant (non-fatal)`)}})),Object.keys(u).length>0&&await this.storage.updateMetadata(s.key,{metadata:{...s.metadata??{},variants:u}}).catch(e=>{this.logger?.warn({key:s.key,error:e},`Failed to update original file metadata with variant keys (non-fatal)`)}),this.logger?.info({width:c,height:l,variants:Object.keys(u)},`Image processed`)}catch(e){this.logger?.warn({filename:i.filename,error:e},`Image processing failed (non-fatal, original saved)`)}let d=a(i.mimeType),f=await this.getAdmin();try{let e=await f.create({title:i.title??o(i.filename),alt:i.alt??null,description:i.description??null,fileKey:s.key,filename:i.filename,mimeType:i.mimeType,size:i.size,width:c,height:l,mediaType:d}),t=await this.storage.getUrl(s.key);return this.logger?.info({id:e.id,fileKey:s.key,mediaType:d},`Media uploaded`),{media:e,url:t}}catch(e){this.logger?.error({fileKey:s.key,error:e},`Media entity creation failed, rolling back storage uploads`);for(let e of Object.values(u))await this.storage.delete(e).catch(()=>{});throw await this.storage.delete(s.key).catch(e=>{this.logger?.error({fileKey:s.key,error:e},`Storage rollback also failed`)}),e}}async findById(e,t){return await(await this.getAdmin()).findById(e,t)}async findMany(e){let t=await this.getAdmin(),{schemaRegistry:n}=await import(`@murumets-ee/db`),{and:r,asc:i,desc:a,eq:o,ilike:s,or:c,sql:l}=await import(`drizzle-orm`),u=n.get(`media`);if(!u)throw Error(`Media schema not registered. Is the media() plugin loaded?`);let d=[];if(e?.mediaType&&d.push(o(u.mediaType,e.mediaType)),e?.mimeTypePrefix){let t=e.mimeTypePrefix.replace(/[\\%_]/g,`\\$&`);d.push(s(u.mimeType,`${t}%`))}if(e?.search){let t=`%${e.search.replace(/[\\%_]/g,`\\$&`)}%`;d.push(c(s(u.filename,t),l`${u.fields} ->> 'title' ILIKE ${t}`))}let f=e?.limit??50,p=e?.offset??0,m=d.length>0?r(...d):void 0,[h]=await this.db.select({count:l`count(*)::int`}).from(u).where(m),g=e?.orderBy===`filename`?u.filename:u.createdAt,_=(e?.orderDirection??`desc`)===`asc`?i:a;return{items:await t.findMany({where:m,limit:f,offset:p,orderBy:_(g)}),total:h?.count??0,limit:f,offset:p}}async update(e,t){return await(await this.getAdmin()).update(e,t)}async delete(e){let t=await this.getAdmin(),n=await t.findById(e);if(!n)throw Error(`Media not found: ${e}`);let r=n.fileKey;await t.delete(e);let i=(await this.storage.getMetadata(r))?.metadata?.variants;if(i)for(let e of Object.values(i))await this.storage.delete(e).catch(t=>{this.logger?.warn({variantKey:e,error:t},`Failed to delete variant file`)});await this.storage.delete(r).catch(t=>{this.logger?.error({id:e,fileKey:r,error:t},`Failed to delete file from storage after entity deletion`)}),this.logger?.info({id:e,fileKey:r,deletedVariants:i?Object.keys(i).length:0},`Media deleted`)}async getUrl(e){let t=await(await this.getAdmin()).findById(e);if(!t)throw Error(`Media not found: ${e}`);return this.storage.getUrl(t.fileKey)}async getUrls(e){if(e.length===0)return new Map;let t=await this.getAdmin(),{schemaRegistry:n}=await import(`@murumets-ee/db`),{inArray:r}=await import(`drizzle-orm`),i=n.get(`media`);if(!i)return new Map;let a=await t.findMany({where:r(i.id,e),limit:e.length}),o=new Map;return await Promise.all(a.map(async e=>{let t=await this.storage.getUrl(e.fileKey);o.set(e.id,t)})),o}async getVariantUrl(e,t){let n=await(await this.getAdmin()).findById(e);if(!n)return null;let i=n.fileKey,a=(await this.resolveImageStyles())[t];if(a){let e=r(i,t,a.format??`webp`);try{return await this.storage.getUrl(e)}catch{}}try{return await this.storage.getUrl(i)}catch{return null}}async getVariantUrls(e,t){if(e.length===0)return new Map;let n=await this.getAdmin(),{schemaRegistry:i}=await import(`@murumets-ee/db`),{inArray:a}=await import(`drizzle-orm`),o=i.get(`media`);if(!o)return new Map;let s=await n.findMany({where:a(o.id,e),limit:e.length}),c=(await this.resolveImageStyles())[t],l=new Map;return await Promise.all(s.map(async e=>{if(c){let n=r(e.fileKey,t,c.format??`webp`);try{let t=await this.storage.getUrl(n);l.set(e.id,t);return}catch{}}try{let t=await this.storage.getUrl(e.fileKey);l.set(e.id,t)}catch{}})),l}};function a(e){return e.startsWith(`image/`)?`image`:e.startsWith(`video/`)?`video`:e.startsWith(`audio/`)?`audio`:e===`application/pdf`||e.startsWith(`application/msword`)||e.startsWith(`application/vnd.`)?`document`:`other`}function o(e){return e.replace(/\.[^.]+$/,``).replace(/[-_]/g,` `)}async function s(e){let{getApp:t}=await import(`@murumets-ee/core`),n=t();return new i({db:n.db.readWrite,storage:e,logger:n.logger.child({media:!0})})}let c=null;function l(){return c||=(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`),r=e(),a=t(n(),{app:r});return new i({db:r.db.readWrite,storage:a,logger:r.logger.child({media:!0})})})(),c}export{i as MediaClient,s as createMediaClient,l as getMediaClient};
|
|
2
2
|
//# sourceMappingURL=client.mjs.map
|
package/dist/client.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"sources":["../src/process-image.ts","../src/variant-key.ts","../src/client.ts"],"sourcesContent":["/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { ImageStyle } from './types.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const meta = await sharp(buffer).metadata()\n const width = meta.width ?? 0\n const height = meta.height ?? 0\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n const resized = sharp(buffer).resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n const meta = await sharp(buffer).metadata()\n return { width: meta.width ?? 0, height: meta.height ?? 0 }\n}\n","/**\n * Variant key derivation convention.\n *\n * Given an original storage key and a style name, produces a deterministic\n * variant key in the same directory.\n *\n * Convention:\n * Original: uploads/2026/02/{uuid}/photo.jpg\n * Variant: uploads/2026/02/{uuid}/thumbnail_photo.webp\n */\n\n/**\n * Derive a variant storage key from the original key + style name.\n *\n * @param originalKey - The original file's storage key\n * @param styleName - The image style name (e.g., 'thumbnail', 'medium')\n * @param format - The variant output format (default: 'webp')\n * @returns The derived variant key\n */\nexport function deriveVariantKey(originalKey: string, styleName: string, format = 'webp'): string {\n const lastSlash = originalKey.lastIndexOf('/')\n const dir = originalKey.substring(0, lastSlash)\n const filename = originalKey.substring(lastSlash + 1)\n const baseName = filename.replace(/\\.[^.]+$/, '')\n return `${dir}/${styleName}_${baseName}.${format}`\n}\n","/**\n * MediaClient — wraps AdminClient + StorageClient for media management.\n *\n * Usage:\n * import { createMediaClient } from '@murumets-ee/media/client'\n * const media = await createMediaClient(storageClient)\n * const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { FieldConfig, FieldToTS } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { Media } from './entity.js'\nimport { isProcessableImage, processImage } from './process-image.js'\nimport type {\n ImageStyle,\n MediaListOptions,\n MediaListResult,\n MediaRecord,\n MediaType,\n MediaUploadOptions,\n MediaUploadResult,\n} from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\n/** Value type that any entity field can hold */\ntype EntityFieldValue = FieldToTS<FieldConfig> | null | undefined\n\nexport interface MediaClientConfig {\n db: PostgresJsDatabase\n storage: StorageClient\n logger?: Logger\n /** Image styles to generate on upload. Loaded from plugin config if not provided. */\n imageStyles?: Record<string, ImageStyle>\n}\n\nexport class MediaClient {\n private db: PostgresJsDatabase\n private storage: StorageClient\n private logger?: Logger\n private admin: AdminClient | null = null\n private imageStyles: Record<string, ImageStyle> | null\n\n constructor(config: MediaClientConfig) {\n this.db = config.db\n this.storage = config.storage\n this.logger = config.logger\n this.imageStyles = config.imageStyles ?? null\n }\n\n private async getAdmin(): Promise<AdminClient> {\n if (!this.admin) {\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n this.admin = new AdminClient({\n entity: Media,\n db: this.db,\n logger: this.logger,\n })\n }\n return this.admin\n }\n\n /**\n * Resolve image styles: settings DB → plugin config → hardcoded defaults.\n * Result is cached; call `invalidateImageStylesCache()` after settings update.\n */\n private async resolveImageStyles(): Promise<Record<string, ImageStyle>> {\n if (this.imageStyles) return this.imageStyles\n\n // 1. Try settings DB (source of truth after first boot)\n try {\n const { createSettingsClient } = await import('@murumets-ee/settings')\n const { imageStylesSettings } = await import('./image-styles-settings.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const settingsClient = createSettingsClient(imageStylesSettings, { app })\n const stored = await settingsClient.get('imageStyles')\n if (stored && Object.keys(stored).length > 0) {\n this.imageStyles = stored\n return stored\n }\n } catch {\n // Settings not available — fall through to plugin config\n }\n\n // 2. Fall back to plugin config singleton\n try {\n const { getMediaConfig } = await import('./plugin.js')\n const config = getMediaConfig()\n this.imageStyles = config.imageStyles\n return this.imageStyles\n } catch {\n // Plugin not initialized — hardcoded defaults\n const defaults: Record<string, ImageStyle> = {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n }\n this.imageStyles = defaults\n return defaults\n }\n }\n\n /** Clear cached styles so next access re-reads from settings DB. */\n invalidateImageStylesCache(): void {\n this.imageStyles = null\n }\n\n // ---------------------------------------------------------------\n // Upload — the key convenience method\n // ---------------------------------------------------------------\n\n /**\n * Upload a file and create a media entity record in one step.\n * 1. Uploads original to storage (StorageClient)\n * 2. If image: extracts dimensions via Sharp + generates variants\n * 3. Creates media entity record (AdminClient)\n * 4. Returns the media record + URL\n *\n * Rolls back storage upload if entity creation fails.\n * Variant generation failures are logged but don't fail the upload.\n */\n async upload(\n data: Buffer | ReadableStream<Uint8Array>,\n options: MediaUploadOptions,\n ): Promise<MediaUploadResult> {\n // 1. Upload original file to storage\n const fileRecord = await this.storage.upload(data, {\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n visibility: options.visibility,\n uploadedBy: options.uploadedBy,\n })\n\n // 2. Image processing — extract dimensions + generate variants\n let width = options.width ?? null\n let height = options.height ?? null\n const variantKeys: Record<string, string> = {}\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n try {\n const styles = await this.resolveImageStyles()\n const processed = await processImage(data, styles)\n\n // Set dimensions from Sharp metadata\n width = processed.width\n height = processed.height\n\n // Upload each variant to storage\n const visibility = fileRecord.visibility\n await Promise.all(\n [...processed.variants.entries()].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(fileRecord.key, styleName, variant.format)\n try {\n await this.storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${options.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: fileRecord.key, style: styleName },\n uploadedBy: options.uploadedBy,\n })\n variantKeys[styleName] = vKey\n this.logger?.debug(\n { style: styleName, key: vKey, width: variant.width, height: variant.height },\n 'Variant uploaded',\n )\n } catch (variantErr: unknown) {\n this.logger?.warn(\n { style: styleName, key: vKey, error: variantErr },\n 'Failed to upload variant (non-fatal)',\n )\n }\n }),\n )\n\n // Store variant keys in original file's metadata\n if (Object.keys(variantKeys).length > 0) {\n await this.storage\n .updateMetadata(fileRecord.key, {\n metadata: {\n ...(fileRecord.metadata ?? {}),\n variants: variantKeys,\n },\n })\n .catch((metaErr: unknown) => {\n this.logger?.warn(\n { key: fileRecord.key, error: metaErr },\n 'Failed to update original file metadata with variant keys (non-fatal)',\n )\n })\n }\n\n this.logger?.info({ width, height, variants: Object.keys(variantKeys) }, 'Image processed')\n } catch (processErr: unknown) {\n this.logger?.warn(\n { filename: options.filename, error: processErr },\n 'Image processing failed (non-fatal, original saved)',\n )\n }\n }\n\n // 3. Create media entity record\n const mediaType = deriveMediaType(options.mimeType)\n const admin = await this.getAdmin()\n\n try {\n const entityRecord = await admin.create({\n title: options.title ?? deriveTitle(options.filename),\n alt: options.alt ?? null,\n description: options.description ?? null,\n fileKey: fileRecord.key,\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n width,\n height,\n mediaType,\n } as Record<string, EntityFieldValue>)\n\n // 4. Get URL\n const url = await this.storage.getUrl(fileRecord.key)\n\n this.logger?.info(\n { id: entityRecord.id, fileKey: fileRecord.key, mediaType },\n 'Media uploaded',\n )\n\n return {\n media: entityRecord as unknown as MediaRecord,\n url,\n }\n } catch (error) {\n // Rollback: delete variants + original if entity creation fails\n this.logger?.error(\n { fileKey: fileRecord.key, error },\n 'Media entity creation failed, rolling back storage uploads',\n )\n\n // Delete variants first (best-effort)\n for (const vKey of Object.values(variantKeys)) {\n await this.storage.delete(vKey).catch(() => {})\n }\n\n // Delete original\n await this.storage.delete(fileRecord.key).catch((rollbackErr: unknown) => {\n this.logger?.error(\n { fileKey: fileRecord.key, error: rollbackErr },\n 'Storage rollback also failed',\n )\n })\n throw error\n }\n }\n\n // ---------------------------------------------------------------\n // CRUD delegation\n // ---------------------------------------------------------------\n\n async findById(id: string, options?: { locale?: string }): Promise<MediaRecord | null> {\n const admin = await this.getAdmin()\n const result = await admin.findById(id, options)\n return result as unknown as MediaRecord | null\n }\n\n async findMany(options?: MediaListOptions): Promise<MediaListResult> {\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, desc, eq, ilike, sql } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n\n // Build where conditions\n const conditions = []\n\n if (options?.mediaType) {\n conditions.push(eq(table.mediaType, options.mediaType))\n }\n if (options?.mimeTypePrefix) {\n conditions.push(ilike(table.mimeType, `${options.mimeTypePrefix}%`))\n }\n if (options?.search) {\n const pattern = `%${options.search}%`\n conditions.push(\n sql`(${ilike(table.filename, pattern)} OR ${table.fields} ->> 'title' ILIKE ${pattern})`,\n )\n }\n\n const limit = options?.limit ?? 50\n const offset = options?.offset ?? 0\n const whereClause = conditions.length > 0 ? and(...conditions) : undefined\n\n // Count total\n const [countResult] = await this.db\n .select({ count: sql<number>`count(*)::int` })\n .from(table)\n .where(whereClause)\n\n // Fetch items via AdminClient for proper DTO shaping\n const orderField = options?.orderBy === 'filename' ? table.filename : table.createdAt\n const orderFn = (options?.orderDirection ?? 'desc') === 'asc' ? asc : desc\n\n const items = await admin.findMany({\n where: whereClause,\n limit,\n offset,\n orderBy: orderFn(orderField),\n })\n\n return {\n items: items as unknown as MediaRecord[],\n total: countResult?.count ?? 0,\n limit,\n offset,\n }\n }\n\n async update(\n id: string,\n data: { title?: string; alt?: string; description?: string },\n ): Promise<MediaRecord> {\n const admin = await this.getAdmin()\n const result = await admin.update(id, data as Record<string, EntityFieldValue>)\n return result as unknown as MediaRecord\n }\n\n /**\n * Delete a media entity, its variants, and its original file in storage.\n */\n async delete(id: string): Promise<void> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // 1. Delete entity first (ref checking via entity_refs happens here)\n await admin.delete(id)\n\n // 2. Look up original file record for variant metadata\n const fileRecord = await this.storage.getMetadata(fileKey)\n const variants = (fileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n\n // 3. Delete variants (best-effort)\n if (variants) {\n for (const vKey of Object.values(variants)) {\n await this.storage.delete(vKey).catch((err: unknown) => {\n this.logger?.warn({ variantKey: vKey, error: err }, 'Failed to delete variant file')\n })\n }\n }\n\n // 4. Delete original file (best-effort)\n await this.storage.delete(fileKey).catch((err: unknown) => {\n this.logger?.error(\n { id, fileKey, error: err },\n 'Failed to delete file from storage after entity deletion',\n )\n })\n\n this.logger?.info(\n { id, fileKey, deletedVariants: variants ? Object.keys(variants).length : 0 },\n 'Media deleted',\n )\n }\n\n // ---------------------------------------------------------------\n // URL resolution\n // ---------------------------------------------------------------\n\n /**\n * Get URL for a media entity by its ID.\n * Resolves entity -> fileKey -> storage URL.\n */\n async getUrl(id: string): Promise<string> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n return this.storage.getUrl((record as unknown as MediaRecord).fileKey)\n }\n\n /**\n * Get URLs for multiple media entities (batch).\n * Returns a Map of mediaId -> url.\n */\n async getUrls(ids: string[]): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n }),\n )\n\n return urlMap\n }\n\n /**\n * Get variant URL for a specific image style.\n * Falls back to original URL if the variant doesn't exist.\n *\n * @param id - Media entity ID\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns The variant URL, or original URL as fallback, or null if media not found\n */\n async getVariantUrl(id: string, styleName: string): Promise<string | null> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) return null\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // Try variant key first\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n if (style) {\n const vKey = deriveVariantKey(fileKey, styleName, style.format ?? 'webp')\n try {\n return await this.storage.getUrl(vKey)\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n return await this.storage.getUrl(fileKey)\n } catch {\n return null\n }\n }\n\n /**\n * Get variant URLs for multiple media entities (batch).\n * Falls back to original URL per item if the variant doesn't exist.\n *\n * @param ids - Media entity IDs\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns Map of mediaId -> variant URL (or original URL as fallback)\n */\n async getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n // Try variant URL\n if (style) {\n const vKey = deriveVariantKey(record.fileKey, styleName, style.format ?? 'webp')\n try {\n const url = await this.storage.getUrl(vKey)\n urlMap.set(record.id, url)\n return\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n } catch {\n // Skip — no URL available\n }\n }),\n )\n\n return urlMap\n }\n}\n\n// ---------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------\n\nfunction deriveMediaType(mimeType: string): MediaType {\n if (mimeType.startsWith('image/')) return 'image'\n if (mimeType.startsWith('video/')) return 'video'\n if (mimeType.startsWith('audio/')) return 'audio'\n if (\n mimeType === 'application/pdf' ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.')\n ) {\n return 'document'\n }\n return 'other'\n}\n\nfunction deriveTitle(filename: string): string {\n const withoutExt = filename.replace(/\\.[^.]+$/, '')\n return withoutExt.replace(/[-_]/g, ' ')\n}\n\n/**\n * Factory — creates a MediaClient with an explicit StorageClient.\n * Must be called after createApp().\n */\nexport async function createMediaClient(storage: StorageClient): Promise<MediaClient> {\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n}\n\n// ---------------------------------------------------------------------------\n// Lazy singleton — auto-discovers storage config\n// ---------------------------------------------------------------------------\n\nlet _clientPromise: Promise<MediaClient> | null = null\n\n/**\n * Lazy singleton — returns a shared MediaClient instance.\n * Auto-discovers storage configuration from the storage plugin.\n * Must be called after createApp().\n */\nexport function getMediaClient(): Promise<MediaClient> {\n if (!_clientPromise) {\n _clientPromise = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const config = getStorageConfig()\n const storage = createStorageClient(config, { app })\n\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n })()\n }\n return _clientPromise\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * MediaClient — wraps AdminClient + StorageClient for media management.\n *\n * Usage:\n * import { createMediaClient } from '@murumets-ee/media/client'\n * const media = await createMediaClient(storageClient)\n * const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { FieldConfig, FieldToTS } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { Media } from './entity.js'\nimport { isProcessableImage, processImage } from './process-image.js'\nimport type {\n ImageStyle,\n MediaListOptions,\n MediaListResult,\n MediaRecord,\n MediaType,\n MediaUploadOptions,\n MediaUploadResult,\n} from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\n/** Value type that any entity field can hold */\ntype EntityFieldValue = FieldToTS<FieldConfig> | null | undefined\n\nexport interface MediaClientConfig {\n db: PostgresJsDatabase\n storage: StorageClient\n logger?: Logger\n /** Image styles to generate on upload. Loaded from plugin config if not provided. */\n imageStyles?: Record<string, ImageStyle>\n}\n\nexport class MediaClient {\n private db: PostgresJsDatabase\n private storage: StorageClient\n private logger?: Logger\n private admin: AdminClient | null = null\n private imageStyles: Record<string, ImageStyle> | null\n\n constructor(config: MediaClientConfig) {\n this.db = config.db\n this.storage = config.storage\n this.logger = config.logger\n this.imageStyles = config.imageStyles ?? null\n }\n\n private async getAdmin(): Promise<AdminClient> {\n if (!this.admin) {\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n this.admin = new AdminClient({\n entity: Media,\n db: this.db,\n logger: this.logger,\n })\n }\n return this.admin\n }\n\n /**\n * Resolve image styles: settings DB → plugin config → hardcoded defaults.\n * Result is cached; call `invalidateImageStylesCache()` after settings update.\n */\n private async resolveImageStyles(): Promise<Record<string, ImageStyle>> {\n if (this.imageStyles) return this.imageStyles\n\n // 1. Try settings DB (source of truth after first boot)\n try {\n const { createSettingsClient } = await import('@murumets-ee/settings')\n const { imageStylesSettings } = await import('./image-styles-settings.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const settingsClient = createSettingsClient(imageStylesSettings, { app })\n const stored = await settingsClient.get('imageStyles')\n if (stored && Object.keys(stored).length > 0) {\n this.imageStyles = stored\n return stored\n }\n } catch {\n // Settings not available — fall through to plugin config\n }\n\n // 2. Fall back to plugin config singleton\n try {\n const { getMediaConfig } = await import('./plugin.js')\n const config = getMediaConfig()\n this.imageStyles = config.imageStyles\n return this.imageStyles\n } catch {\n // Plugin not initialized — hardcoded defaults\n const defaults: Record<string, ImageStyle> = {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n }\n this.imageStyles = defaults\n return defaults\n }\n }\n\n /** Clear cached styles so next access re-reads from settings DB. */\n invalidateImageStylesCache(): void {\n this.imageStyles = null\n }\n\n // ---------------------------------------------------------------\n // Upload — the key convenience method\n // ---------------------------------------------------------------\n\n /**\n * Upload a file and create a media entity record in one step.\n * 1. Uploads original to storage (StorageClient)\n * 2. If image: extracts dimensions via Sharp + generates variants\n * 3. Creates media entity record (AdminClient)\n * 4. Returns the media record + URL\n *\n * Rolls back storage upload if entity creation fails.\n * Variant generation failures are logged but don't fail the upload.\n */\n async upload(\n data: Buffer | ReadableStream<Uint8Array>,\n options: MediaUploadOptions,\n ): Promise<MediaUploadResult> {\n // 1. Upload original file to storage\n const fileRecord = await this.storage.upload(data, {\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n visibility: options.visibility,\n uploadedBy: options.uploadedBy,\n })\n\n // 2. Image processing — extract dimensions + generate variants\n let width = options.width ?? null\n let height = options.height ?? null\n const variantKeys: Record<string, string> = {}\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n try {\n const styles = await this.resolveImageStyles()\n const processed = await processImage(data, styles)\n\n // Set dimensions from Sharp metadata\n width = processed.width\n height = processed.height\n\n // Upload each variant to storage\n const visibility = fileRecord.visibility\n await Promise.all(\n [...processed.variants.entries()].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(fileRecord.key, styleName, variant.format)\n try {\n await this.storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${options.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: fileRecord.key, style: styleName },\n uploadedBy: options.uploadedBy,\n })\n variantKeys[styleName] = vKey\n this.logger?.debug(\n { style: styleName, key: vKey, width: variant.width, height: variant.height },\n 'Variant uploaded',\n )\n } catch (variantErr: unknown) {\n this.logger?.warn(\n { style: styleName, key: vKey, error: variantErr },\n 'Failed to upload variant (non-fatal)',\n )\n }\n }),\n )\n\n // Store variant keys in original file's metadata\n if (Object.keys(variantKeys).length > 0) {\n await this.storage\n .updateMetadata(fileRecord.key, {\n metadata: {\n ...(fileRecord.metadata ?? {}),\n variants: variantKeys,\n },\n })\n .catch((metaErr: unknown) => {\n this.logger?.warn(\n { key: fileRecord.key, error: metaErr },\n 'Failed to update original file metadata with variant keys (non-fatal)',\n )\n })\n }\n\n this.logger?.info({ width, height, variants: Object.keys(variantKeys) }, 'Image processed')\n } catch (processErr: unknown) {\n this.logger?.warn(\n { filename: options.filename, error: processErr },\n 'Image processing failed (non-fatal, original saved)',\n )\n }\n }\n\n // 3. Create media entity record\n const mediaType = deriveMediaType(options.mimeType)\n const admin = await this.getAdmin()\n\n try {\n const entityRecord = await admin.create({\n title: options.title ?? deriveTitle(options.filename),\n alt: options.alt ?? null,\n description: options.description ?? null,\n fileKey: fileRecord.key,\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n width,\n height,\n mediaType,\n } as Record<string, EntityFieldValue>)\n\n // 4. Get URL\n const url = await this.storage.getUrl(fileRecord.key)\n\n this.logger?.info(\n { id: entityRecord.id, fileKey: fileRecord.key, mediaType },\n 'Media uploaded',\n )\n\n return {\n media: entityRecord as unknown as MediaRecord,\n url,\n }\n } catch (error) {\n // Rollback: delete variants + original if entity creation fails\n this.logger?.error(\n { fileKey: fileRecord.key, error },\n 'Media entity creation failed, rolling back storage uploads',\n )\n\n // Delete variants first (best-effort)\n for (const vKey of Object.values(variantKeys)) {\n await this.storage.delete(vKey).catch(() => {})\n }\n\n // Delete original\n await this.storage.delete(fileRecord.key).catch((rollbackErr: unknown) => {\n this.logger?.error(\n { fileKey: fileRecord.key, error: rollbackErr },\n 'Storage rollback also failed',\n )\n })\n throw error\n }\n }\n\n // ---------------------------------------------------------------\n // CRUD delegation\n // ---------------------------------------------------------------\n\n async findById(id: string, options?: { locale?: string }): Promise<MediaRecord | null> {\n const admin = await this.getAdmin()\n const result = await admin.findById(id, options)\n return result as unknown as MediaRecord | null\n }\n\n async findMany(options?: MediaListOptions): Promise<MediaListResult> {\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, desc, eq, ilike, or, sql } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n\n // Build where conditions\n const conditions = []\n\n if (options?.mediaType) {\n conditions.push(eq(table.mediaType, options.mediaType))\n }\n if (options?.mimeTypePrefix) {\n // Escape ILIKE wildcards (%, _) in user input to prevent pattern injection\n const escaped = options.mimeTypePrefix.replace(/[\\\\%_]/g, '\\\\$&')\n conditions.push(ilike(table.mimeType, `${escaped}%`))\n }\n if (options?.search) {\n // Escape ILIKE wildcards in user input, then wrap with %...%\n const escaped = options.search.replace(/[\\\\%_]/g, '\\\\$&')\n const pattern = `%${escaped}%`\n conditions.push(\n or(\n ilike(table.filename, pattern),\n sql`${table.fields} ->> 'title' ILIKE ${pattern}`,\n )!,\n )\n }\n\n const limit = options?.limit ?? 50\n const offset = options?.offset ?? 0\n const whereClause = conditions.length > 0 ? and(...conditions) : undefined\n\n // Count total\n const [countResult] = await this.db\n .select({ count: sql<number>`count(*)::int` })\n .from(table)\n .where(whereClause)\n\n // Fetch items via AdminClient for proper DTO shaping\n const orderField = options?.orderBy === 'filename' ? table.filename : table.createdAt\n const orderFn = (options?.orderDirection ?? 'desc') === 'asc' ? asc : desc\n\n const items = await admin.findMany({\n where: whereClause,\n limit,\n offset,\n orderBy: orderFn(orderField),\n })\n\n return {\n items: items as unknown as MediaRecord[],\n total: countResult?.count ?? 0,\n limit,\n offset,\n }\n }\n\n async update(\n id: string,\n data: { title?: string; alt?: string; description?: string },\n ): Promise<MediaRecord> {\n const admin = await this.getAdmin()\n const result = await admin.update(id, data as Record<string, EntityFieldValue>)\n return result as unknown as MediaRecord\n }\n\n /**\n * Delete a media entity, its variants, and its original file in storage.\n */\n async delete(id: string): Promise<void> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // 1. Delete entity first (ref checking via entity_refs happens here)\n await admin.delete(id)\n\n // 2. Look up original file record for variant metadata\n const fileRecord = await this.storage.getMetadata(fileKey)\n const variants = (fileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n\n // 3. Delete variants (best-effort)\n if (variants) {\n for (const vKey of Object.values(variants)) {\n await this.storage.delete(vKey).catch((err: unknown) => {\n this.logger?.warn({ variantKey: vKey, error: err }, 'Failed to delete variant file')\n })\n }\n }\n\n // 4. Delete original file (best-effort)\n await this.storage.delete(fileKey).catch((err: unknown) => {\n this.logger?.error(\n { id, fileKey, error: err },\n 'Failed to delete file from storage after entity deletion',\n )\n })\n\n this.logger?.info(\n { id, fileKey, deletedVariants: variants ? Object.keys(variants).length : 0 },\n 'Media deleted',\n )\n }\n\n // ---------------------------------------------------------------\n // URL resolution\n // ---------------------------------------------------------------\n\n /**\n * Get URL for a media entity by its ID.\n * Resolves entity -> fileKey -> storage URL.\n */\n async getUrl(id: string): Promise<string> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n return this.storage.getUrl((record as unknown as MediaRecord).fileKey)\n }\n\n /**\n * Get URLs for multiple media entities (batch).\n * Returns a Map of mediaId -> url.\n */\n async getUrls(ids: string[]): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n }),\n )\n\n return urlMap\n }\n\n /**\n * Get variant URL for a specific image style.\n * Falls back to original URL if the variant doesn't exist.\n *\n * @param id - Media entity ID\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns The variant URL, or original URL as fallback, or null if media not found\n */\n async getVariantUrl(id: string, styleName: string): Promise<string | null> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) return null\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // Try variant key first\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n if (style) {\n const vKey = deriveVariantKey(fileKey, styleName, style.format ?? 'webp')\n try {\n return await this.storage.getUrl(vKey)\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n return await this.storage.getUrl(fileKey)\n } catch {\n return null\n }\n }\n\n /**\n * Get variant URLs for multiple media entities (batch).\n * Falls back to original URL per item if the variant doesn't exist.\n *\n * @param ids - Media entity IDs\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns Map of mediaId -> variant URL (or original URL as fallback)\n */\n async getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n // Try variant URL\n if (style) {\n const vKey = deriveVariantKey(record.fileKey, styleName, style.format ?? 'webp')\n try {\n const url = await this.storage.getUrl(vKey)\n urlMap.set(record.id, url)\n return\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n } catch {\n // Skip — no URL available\n }\n }),\n )\n\n return urlMap\n }\n}\n\n// ---------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------\n\nfunction deriveMediaType(mimeType: string): MediaType {\n if (mimeType.startsWith('image/')) return 'image'\n if (mimeType.startsWith('video/')) return 'video'\n if (mimeType.startsWith('audio/')) return 'audio'\n if (\n mimeType === 'application/pdf' ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.')\n ) {\n return 'document'\n }\n return 'other'\n}\n\nfunction deriveTitle(filename: string): string {\n const withoutExt = filename.replace(/\\.[^.]+$/, '')\n return withoutExt.replace(/[-_]/g, ' ')\n}\n\n/**\n * Factory — creates a MediaClient with an explicit StorageClient.\n * Must be called after createApp().\n */\nexport async function createMediaClient(storage: StorageClient): Promise<MediaClient> {\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n}\n\n// ---------------------------------------------------------------------------\n// Lazy singleton — auto-discovers storage config\n// ---------------------------------------------------------------------------\n\nlet _clientPromise: Promise<MediaClient> | null = null\n\n/**\n * Lazy singleton — returns a shared MediaClient instance.\n * Auto-discovers storage configuration from the storage plugin.\n * Must be called after createApp().\n */\nexport function getMediaClient(): Promise<MediaClient> {\n if (!_clientPromise) {\n _clientPromise = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const config = getStorageConfig()\n const storage = createStorageClient(config, { app })\n\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n })()\n }\n return _clientPromise\n}\n"],"mappings":"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"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as MediaRecord, c as MediaUploadResult, i as MediaPluginConfig, n as MediaListOptions, o as MediaType, r as MediaListResult, s as MediaUploadOptions, t as ImageStyle } from "./types-
|
|
1
|
+
import { a as MediaRecord, c as MediaUploadResult, i as MediaPluginConfig, n as MediaListOptions, o as MediaType, r as MediaListResult, s as MediaUploadOptions, t as ImageStyle } from "./types-CILN8YcP.mjs";
|
|
2
2
|
import { defaultImageStyles, imageStylesSettings } from "./image-styles-settings.mjs";
|
|
3
3
|
import * as _$_murumets_ee_entity0 from "@murumets-ee/entity";
|
|
4
4
|
//#region src/enrich.d.ts
|
package/dist/plugin.d.mts
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { t as ImageStyle } from "./types-CILN8YcP.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/process-image.d.ts
|
|
4
|
+
/** Result of processing an image through Sharp */
|
|
5
|
+
interface ProcessedImage {
|
|
6
|
+
/** Original image width in pixels */
|
|
7
|
+
width: number;
|
|
8
|
+
/** Original image height in pixels */
|
|
9
|
+
height: number;
|
|
10
|
+
/** Generated variant buffers keyed by style name */
|
|
11
|
+
variants: Map<string, ProcessedVariant>;
|
|
12
|
+
}
|
|
13
|
+
/** A single processed variant */
|
|
14
|
+
interface ProcessedVariant {
|
|
15
|
+
buffer: Buffer;
|
|
16
|
+
format: string;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check if a MIME type is eligible for Sharp processing.
|
|
23
|
+
* Returns false for SVG, GIF, and non-image types.
|
|
24
|
+
*/
|
|
25
|
+
declare function isProcessableImage(mimeType: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Extract image dimensions and generate resized variants.
|
|
28
|
+
*
|
|
29
|
+
* @param buffer - Original image file as a Buffer
|
|
30
|
+
* @param styles - Named image style presets to generate
|
|
31
|
+
* @returns Metadata (width/height) and variant buffers
|
|
32
|
+
*/
|
|
33
|
+
declare function processImage(buffer: Buffer, styles: Record<string, ImageStyle>): Promise<ProcessedImage>;
|
|
34
|
+
/**
|
|
35
|
+
* Extract only image dimensions (no variant generation).
|
|
36
|
+
* Useful for getting width/height when styles are empty.
|
|
37
|
+
*/
|
|
38
|
+
declare function getImageDimensions(buffer: Buffer): Promise<{
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
}>;
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/variant-key.d.ts
|
|
44
|
+
/**
|
|
45
|
+
* Variant key derivation convention.
|
|
46
|
+
*
|
|
47
|
+
* Given an original storage key and a style name, produces a deterministic
|
|
48
|
+
* variant key in the same directory.
|
|
49
|
+
*
|
|
50
|
+
* Convention:
|
|
51
|
+
* Original: uploads/2026/02/{uuid}/photo.jpg
|
|
52
|
+
* Variant: uploads/2026/02/{uuid}/thumbnail_photo.webp
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Derive a variant storage key from the original key + style name.
|
|
56
|
+
*
|
|
57
|
+
* @param originalKey - The original file's storage key
|
|
58
|
+
* @param styleName - The image style name (e.g., 'thumbnail', 'medium')
|
|
59
|
+
* @param format - The variant output format (default: 'webp')
|
|
60
|
+
* @returns The derived variant key
|
|
61
|
+
*/
|
|
62
|
+
declare function deriveVariantKey(originalKey: string, styleName: string, format?: string): string;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { type ImageStyle, type ProcessedImage, type ProcessedVariant, deriveVariantKey, getImageDimensions, isProcessableImage, processImage };
|
|
65
|
+
//# sourceMappingURL=processing.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"processing.d.mts","names":[],"sources":["../src/process-image.ts","../src/variant-key.ts"],"mappings":";;;;UAWiB,cAAA;EAMf;EAJA,KAAA;EAIsB;EAFtB,MAAA;EAEsC;EAAtC,QAAA,EAAU,GAAA,SAAY,gBAAA;AAAA;;UAIP,gBAAA;EACf,MAAA,EAAQ,MAAA;EACR,MAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;AAAA;;;AAUF;;iBAAgB,kBAAA,CAAmB,QAAA;;;AAWnC;;;;;iBAAsB,YAAA,CACpB,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,MAAA,SAAe,UAAA,IACtB,OAAA,CAAQ,cAAA;;;;;iBA0CW,kBAAA,CACpB,MAAA,EAAQ,MAAA,GACP,OAAA;EAAU,KAAA;EAAe,MAAA;AAAA;;;;;;AAnF5B;;;;;;;;;;;AAUA;;;;iBCFgB,gBAAA,CAAiB,WAAA,UAAqB,SAAA,UAAmB,MAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as e,n as t,r as n,t as r}from"./variant-key-gVMhzKyv.mjs";export{r as deriveVariantKey,t as getImageDimensions,n as isProcessableImage,e as processImage};
|
package/dist/query-client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as MediaRecord } from "./types-
|
|
1
|
+
import { a as MediaRecord } from "./types-CILN8YcP.mjs";
|
|
2
2
|
import { Logger } from "@murumets-ee/core";
|
|
3
3
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
4
|
import { CountOptions, FindByIdOptions, FindManyOptions } from "@murumets-ee/entity/query";
|
|
@@ -100,4 +100,4 @@ interface MediaPluginConfig {
|
|
|
100
100
|
}
|
|
101
101
|
//#endregion
|
|
102
102
|
export { MediaRecord as a, MediaUploadResult as c, MediaPluginConfig as i, MediaListOptions as n, MediaType as o, MediaListResult as r, MediaUploadOptions as s, ImageStyle as t };
|
|
103
|
-
//# sourceMappingURL=types-
|
|
103
|
+
//# sourceMappingURL=types-CILN8YcP.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types-
|
|
1
|
+
{"version":3,"file":"types-CILN8YcP.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;;;KAGY,SAAA;AAAZ;AAAA,UAGiB,kBAAA;;EAEf,QAAA;EALmB;EAOnB,QAAA;EAJiC;EAMjC,IAAA;EAE2B;EAA3B,UAAA,GAAa,cAAA;EAJb;EAMA,KAAA;EAFA;EAIA,GAAA;EAFA;EAIA,WAAA;EAAA;EAEA,KAAA;EAEA;EAAA,MAAA;EAEU;EAAV,UAAA;AAAA;;UAIe,iBAAA;EAEG;EAAlB,KAAA,EAAO,WAAA;EAAA;EAEP,GAAA;AAAA;;UAIe,WAAA;EACf,EAAA;EACA,KAAA;EACA,GAAA;EACA,WAAA;EACA,OAAA;EACA,QAAA;EACA,QAAA;EACA,IAAA;EACA,KAAA;EACA,MAAA;EACA,SAAA,EAAW,SAAA;EACX,SAAA;EACA,SAAA,EAAW,IAAA;EACX,SAAA,EAAW,IAAA;AAAA;;UAII,gBAAA;EAPf;EASA,SAAA,GAAY,SAAA;EARZ;EAUA,cAAA;EATW;EAWX,MAAA;EAVW;EAYX,KAAA;EAZe;EAcf,MAAA;EAV+B;EAY/B,OAAA;EAVqB;EAYrB,cAAA;AAAA;;UAIe,eAAA;EACf,KAAA,EAAO,WAAA;EACP,KAAA;EACA,KAAA;EACA,MAAA;AAAA;;UAIe,UAAA;EARe;EAU9B,KAAA;EATkB;EAWlB,MAAA;EAXO;EAaP,GAAA;EAXA;EAaA,MAAA;EAZM;EAcN,OAAA;AAAA;;UAIe,iBAAA;EAdU;EAgBzB,aAAA;EAZA;EAcA,aAAA;EAVA;EAYA,iBAAA,GAAoB,cAAA;EAVb;EAYP,WAAA,GAAc,MAAA,SAAe,UAAA;AAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import e from"sharp";const t=new Set([`image/svg+xml`,`image/gif`]);function n(e){return e.startsWith(`image/`)&&!t.has(e)}async function r(t,n){let r=await e(t).metadata(),i=r.width??0,a=r.height??0,o=new Map,s=Object.entries(n);return await Promise.all(s.map(async([n,r])=>{let i=r.format??`webp`,a=r.quality??80,s=r.fit??`cover`,{data:c,info:l}=await e(t).resize({width:r.width,height:r.height,fit:s,withoutEnlargement:!0})[i]({quality:a}).toBuffer({resolveWithObject:!0});o.set(n,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:i,height:a,variants:o}}async function i(t){let n=await e(t).metadata();return{width:n.width??0,height:n.height??0}}function a(e,t,n=`webp`){let r=e.lastIndexOf(`/`);return`${e.substring(0,r)}/${t}_${e.substring(r+1).replace(/\.[^.]+$/,``)}.${n}`}export{r as i,i as n,n as r,a as t};
|
|
2
|
+
//# sourceMappingURL=variant-key-gVMhzKyv.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"variant-key-gVMhzKyv.mjs","names":[],"sources":["../src/process-image.ts","../src/variant-key.ts"],"sourcesContent":["/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { ImageStyle } from './types.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const meta = await sharp(buffer).metadata()\n const width = meta.width ?? 0\n const height = meta.height ?? 0\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n const resized = sharp(buffer).resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n const meta = await sharp(buffer).metadata()\n return { width: meta.width ?? 0, height: meta.height ?? 0 }\n}\n","/**\n * Variant key derivation convention.\n *\n * Given an original storage key and a style name, produces a deterministic\n * variant key in the same directory.\n *\n * Convention:\n * Original: uploads/2026/02/{uuid}/photo.jpg\n * Variant: uploads/2026/02/{uuid}/thumbnail_photo.webp\n */\n\n/**\n * Derive a variant storage key from the original key + style name.\n *\n * @param originalKey - The original file's storage key\n * @param styleName - The image style name (e.g., 'thumbnail', 'medium')\n * @param format - The variant output format (default: 'webp')\n * @returns The derived variant key\n */\nexport function deriveVariantKey(originalKey: string, styleName: string, format = 'webp'): string {\n const lastSlash = originalKey.lastIndexOf('/')\n const dir = originalKey.substring(0, lastSlash)\n const filename = originalKey.substring(lastSlash + 1)\n const baseName = filename.replace(/\\.[^.]+$/, '')\n return `${dir}/${styleName}_${baseName}.${format}`\n}\n"],"mappings":"qBA8BA,MAAM,EAAkB,IAAI,IAAI,CAAC,gBAAiB,YAAY,CAAC,CAM/D,SAAgB,EAAmB,EAA2B,CAC5D,OAAO,EAAS,WAAW,SAAS,EAAI,CAAC,EAAgB,IAAI,EAAS,CAUxE,eAAsB,EACpB,EACA,EACyB,CACzB,IAAM,EAAO,MAAM,EAAM,EAAO,CAAC,UAAU,CACrC,EAAQ,EAAK,OAAS,EACtB,EAAS,EAAK,QAAU,EAExB,EAAW,IAAI,IAEf,EAAU,OAAO,QAAQ,EAAO,CA4BtC,OA3BA,MAAM,QAAQ,IACZ,EAAQ,IAAI,MAAO,CAAC,EAAM,KAAW,CACnC,IAAM,EAAM,EAAM,QAAU,OACtB,EAAU,EAAM,SAAW,GAC3B,EAAM,EAAM,KAAO,QASnB,CAAE,KAAM,EAAe,QAAS,MAPtB,EAAM,EAAO,CAAC,OAAO,CACnC,MAAO,EAAM,MACb,OAAQ,EAAM,OACd,MACA,mBAAoB,GACrB,CAAC,CAEkD,GAAK,CAAE,UAAS,CAAC,CAAC,SAAS,CAC7E,kBAAmB,GACpB,CAAC,CAEF,EAAS,IAAI,EAAM,CACjB,OAAQ,EACR,OAAQ,EACR,SAAU,SAAS,IACnB,MAAO,EAAK,MACZ,OAAQ,EAAK,OACd,CAAC,EACF,CACH,CAEM,CAAE,QAAO,SAAQ,WAAU,CAOpC,eAAsB,EACpB,EAC4C,CAC5C,IAAM,EAAO,MAAM,EAAM,EAAO,CAAC,UAAU,CAC3C,MAAO,CAAE,MAAO,EAAK,OAAS,EAAG,OAAQ,EAAK,QAAU,EAAG,CC7E7D,SAAgB,EAAiB,EAAqB,EAAmB,EAAS,OAAgB,CAChG,IAAM,EAAY,EAAY,YAAY,IAAI,CAI9C,MAAO,GAHK,EAAY,UAAU,EAAG,EAAU,CAGjC,GAAG,EAAU,GAFV,EAAY,UAAU,EAAY,EAAE,CAC3B,QAAQ,WAAY,GAAG,CACV,GAAG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/media",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -39,31 +39,40 @@
|
|
|
39
39
|
"./image-styles": {
|
|
40
40
|
"types": "./dist/image-styles.d.mts",
|
|
41
41
|
"import": "./dist/image-styles.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./processing": {
|
|
44
|
+
"types": "./dist/processing.d.mts",
|
|
45
|
+
"import": "./dist/processing.mjs"
|
|
42
46
|
}
|
|
43
47
|
},
|
|
44
48
|
"files": [
|
|
45
49
|
"dist"
|
|
46
50
|
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"dev": "tsdown --watch",
|
|
54
|
+
"test": "vitest"
|
|
55
|
+
},
|
|
47
56
|
"dependencies": {
|
|
57
|
+
"@murumets-ee/core": "workspace:*",
|
|
58
|
+
"@murumets-ee/db": "workspace:*",
|
|
59
|
+
"@murumets-ee/entity": "workspace:*",
|
|
60
|
+
"@murumets-ee/logging": "workspace:*",
|
|
61
|
+
"@murumets-ee/settings": "workspace:*",
|
|
62
|
+
"@murumets-ee/storage": "workspace:*",
|
|
48
63
|
"@radix-ui/react-dialog": "^1.1.0",
|
|
49
64
|
"@radix-ui/react-visually-hidden": "^1.1.0",
|
|
50
65
|
"clsx": "^2.1.0",
|
|
51
66
|
"drizzle-orm": "^0.45.1",
|
|
52
67
|
"server-only": "^0.0.1",
|
|
53
68
|
"sharp": "^0.34.5",
|
|
54
|
-
"tailwind-merge": "^2.6.0"
|
|
55
|
-
"@murumets-ee/core": "0.2.0",
|
|
56
|
-
"@murumets-ee/entity": "0.2.1",
|
|
57
|
-
"@murumets-ee/db": "0.2.0",
|
|
58
|
-
"@murumets-ee/logging": "0.2.0",
|
|
59
|
-
"@murumets-ee/storage": "0.2.0",
|
|
60
|
-
"@murumets-ee/settings": "0.2.0"
|
|
69
|
+
"tailwind-merge": "^2.6.0"
|
|
61
70
|
},
|
|
62
71
|
"peerDependencies": {
|
|
72
|
+
"@murumets-ee/ui": "workspace:*",
|
|
63
73
|
"lucide-react": ">=0.400.0",
|
|
64
74
|
"react": ">=19.0.0",
|
|
65
|
-
"react-dom": ">=19.0.0"
|
|
66
|
-
"@murumets-ee/ui": "0.1.5"
|
|
75
|
+
"react-dom": ">=19.0.0"
|
|
67
76
|
},
|
|
68
77
|
"peerDependenciesMeta": {
|
|
69
78
|
"@murumets-ee/ui": {
|
|
@@ -81,10 +90,5 @@
|
|
|
81
90
|
"tsdown": "^0.21.7",
|
|
82
91
|
"typescript": "^5.7.3",
|
|
83
92
|
"vitest": "^2.1.8"
|
|
84
|
-
},
|
|
85
|
-
"scripts": {
|
|
86
|
-
"build": "tsdown",
|
|
87
|
-
"dev": "tsdown --watch",
|
|
88
|
-
"test": "vitest"
|
|
89
93
|
}
|
|
90
|
-
}
|
|
94
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
Elastic License 2.0 (ELv2)
|
|
2
|
-
|
|
3
|
-
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
-
|
|
5
|
-
## Acceptance
|
|
6
|
-
|
|
7
|
-
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
-
|
|
9
|
-
## Copyright License
|
|
10
|
-
|
|
11
|
-
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
-
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
-
available, and prepare derivative works of the software, in each case subject
|
|
14
|
-
to the limitations and conditions below.
|
|
15
|
-
|
|
16
|
-
## Limitations
|
|
17
|
-
|
|
18
|
-
You may not provide the software to third parties as a hosted or managed
|
|
19
|
-
service, where the service provides users with access to any substantial set
|
|
20
|
-
of the features or functionality of the software.
|
|
21
|
-
|
|
22
|
-
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
-
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
-
software that is protected by the license key.
|
|
25
|
-
|
|
26
|
-
You may not alter, remove, or obscure any licensing, copyright, or other
|
|
27
|
-
notices of the licensor in the software. Any use of the licensor's trademarks
|
|
28
|
-
is subject to applicable law.
|
|
29
|
-
|
|
30
|
-
## Patents
|
|
31
|
-
|
|
32
|
-
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
-
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
-
sale, import and have imported the software, in each case subject to the
|
|
35
|
-
limitations and conditions in this license. This license does not cover any
|
|
36
|
-
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
-
the software. If you or your company make any written claim that the software
|
|
38
|
-
infringes or contributes to infringement of any patent, your patent license
|
|
39
|
-
for the software granted under these terms ends immediately. If your company
|
|
40
|
-
makes such a claim, your patent license ends immediately for work on behalf
|
|
41
|
-
of your company.
|
|
42
|
-
|
|
43
|
-
## Notices
|
|
44
|
-
|
|
45
|
-
You must ensure that anyone who gets a copy of any part of the software from
|
|
46
|
-
you also gets a copy of these terms.
|
|
47
|
-
|
|
48
|
-
If you modify the software, you must include in any modified copies of the
|
|
49
|
-
software prominent notices stating that you have modified the software.
|
|
50
|
-
|
|
51
|
-
## No Other Rights
|
|
52
|
-
|
|
53
|
-
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
-
these terms.
|
|
55
|
-
|
|
56
|
-
## Termination
|
|
57
|
-
|
|
58
|
-
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
-
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
-
with a notice of your violation, and you cease all violation of this license
|
|
61
|
-
no later than 30 days after you receive that notice, your licenses will be
|
|
62
|
-
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
-
reinstatement, any additional violation of these terms will cause your
|
|
64
|
-
licenses to terminate automatically and permanently.
|
|
65
|
-
|
|
66
|
-
## No Liability
|
|
67
|
-
|
|
68
|
-
As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
-
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
-
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
-
legal claim.
|
|
72
|
-
|
|
73
|
-
## Definitions
|
|
74
|
-
|
|
75
|
-
The **licensor** is the entity offering these terms, and the **software** is
|
|
76
|
-
the software the licensor makes available under these terms, including any
|
|
77
|
-
portion of it.
|
|
78
|
-
|
|
79
|
-
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
-
|
|
81
|
-
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
-
organization that you work for, plus all organizations that have control over,
|
|
83
|
-
are under the control of, or are under common control with that organization.
|
|
84
|
-
**control** means ownership of substantially all the assets of an entity, or
|
|
85
|
-
the power to direct the management and policies of an entity (for example, by
|
|
86
|
-
voting right, contract, or otherwise). Control can be direct or indirect.
|
|
87
|
-
|
|
88
|
-
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
-
these terms.
|
|
90
|
-
|
|
91
|
-
**use** means anything you do with the software requiring one of your
|
|
92
|
-
licenses.
|
|
93
|
-
|
|
94
|
-
**trademark** means trademarks, service marks, and similar rights.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client-DNTvmvYS.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * MediaClient — wraps AdminClient + StorageClient for media management.\n *\n * Usage:\n * import { createMediaClient } from '@murumets-ee/media/client'\n * const media = await createMediaClient(storageClient)\n * const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { FieldConfig, FieldToTS } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { Media } from './entity.js'\nimport { isProcessableImage, processImage } from './process-image.js'\nimport type {\n ImageStyle,\n MediaListOptions,\n MediaListResult,\n MediaRecord,\n MediaType,\n MediaUploadOptions,\n MediaUploadResult,\n} from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\n/** Value type that any entity field can hold */\ntype EntityFieldValue = FieldToTS<FieldConfig> | null | undefined\n\nexport interface MediaClientConfig {\n db: PostgresJsDatabase\n storage: StorageClient\n logger?: Logger\n /** Image styles to generate on upload. Loaded from plugin config if not provided. */\n imageStyles?: Record<string, ImageStyle>\n}\n\nexport class MediaClient {\n private db: PostgresJsDatabase\n private storage: StorageClient\n private logger?: Logger\n private admin: AdminClient | null = null\n private imageStyles: Record<string, ImageStyle> | null\n\n constructor(config: MediaClientConfig) {\n this.db = config.db\n this.storage = config.storage\n this.logger = config.logger\n this.imageStyles = config.imageStyles ?? null\n }\n\n private async getAdmin(): Promise<AdminClient> {\n if (!this.admin) {\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n this.admin = new AdminClient({\n entity: Media,\n db: this.db,\n logger: this.logger,\n })\n }\n return this.admin\n }\n\n /**\n * Resolve image styles: settings DB → plugin config → hardcoded defaults.\n * Result is cached; call `invalidateImageStylesCache()` after settings update.\n */\n private async resolveImageStyles(): Promise<Record<string, ImageStyle>> {\n if (this.imageStyles) return this.imageStyles\n\n // 1. Try settings DB (source of truth after first boot)\n try {\n const { createSettingsClient } = await import('@murumets-ee/settings')\n const { imageStylesSettings } = await import('./image-styles-settings.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const settingsClient = createSettingsClient(imageStylesSettings, { app })\n const stored = await settingsClient.get('imageStyles')\n if (stored && Object.keys(stored).length > 0) {\n this.imageStyles = stored\n return stored\n }\n } catch {\n // Settings not available — fall through to plugin config\n }\n\n // 2. Fall back to plugin config singleton\n try {\n const { getMediaConfig } = await import('./plugin.js')\n const config = getMediaConfig()\n this.imageStyles = config.imageStyles\n return this.imageStyles\n } catch {\n // Plugin not initialized — hardcoded defaults\n const defaults: Record<string, ImageStyle> = {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n }\n this.imageStyles = defaults\n return defaults\n }\n }\n\n /** Clear cached styles so next access re-reads from settings DB. */\n invalidateImageStylesCache(): void {\n this.imageStyles = null\n }\n\n // ---------------------------------------------------------------\n // Upload — the key convenience method\n // ---------------------------------------------------------------\n\n /**\n * Upload a file and create a media entity record in one step.\n * 1. Uploads original to storage (StorageClient)\n * 2. If image: extracts dimensions via Sharp + generates variants\n * 3. Creates media entity record (AdminClient)\n * 4. Returns the media record + URL\n *\n * Rolls back storage upload if entity creation fails.\n * Variant generation failures are logged but don't fail the upload.\n */\n async upload(\n data: Buffer | ReadableStream<Uint8Array>,\n options: MediaUploadOptions,\n ): Promise<MediaUploadResult> {\n // 1. Upload original file to storage\n const fileRecord = await this.storage.upload(data, {\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n visibility: options.visibility,\n uploadedBy: options.uploadedBy,\n })\n\n // 2. Image processing — extract dimensions + generate variants\n let width = options.width ?? null\n let height = options.height ?? null\n const variantKeys: Record<string, string> = {}\n\n if (data instanceof Buffer && isProcessableImage(options.mimeType)) {\n try {\n const styles = await this.resolveImageStyles()\n const processed = await processImage(data, styles)\n\n // Set dimensions from Sharp metadata\n width = processed.width\n height = processed.height\n\n // Upload each variant to storage\n const visibility = fileRecord.visibility\n await Promise.all(\n [...processed.variants.entries()].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(fileRecord.key, styleName, variant.format)\n try {\n await this.storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${options.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: fileRecord.key, style: styleName },\n uploadedBy: options.uploadedBy,\n })\n variantKeys[styleName] = vKey\n this.logger?.debug(\n { style: styleName, key: vKey, width: variant.width, height: variant.height },\n 'Variant uploaded',\n )\n } catch (variantErr: unknown) {\n this.logger?.warn(\n { style: styleName, key: vKey, error: variantErr },\n 'Failed to upload variant (non-fatal)',\n )\n }\n }),\n )\n\n // Store variant keys in original file's metadata\n if (Object.keys(variantKeys).length > 0) {\n await this.storage\n .updateMetadata(fileRecord.key, {\n metadata: {\n ...(fileRecord.metadata ?? {}),\n variants: variantKeys,\n },\n })\n .catch((metaErr: unknown) => {\n this.logger?.warn(\n { key: fileRecord.key, error: metaErr },\n 'Failed to update original file metadata with variant keys (non-fatal)',\n )\n })\n }\n\n this.logger?.info({ width, height, variants: Object.keys(variantKeys) }, 'Image processed')\n } catch (processErr: unknown) {\n this.logger?.warn(\n { filename: options.filename, error: processErr },\n 'Image processing failed (non-fatal, original saved)',\n )\n }\n }\n\n // 3. Create media entity record\n const mediaType = deriveMediaType(options.mimeType)\n const admin = await this.getAdmin()\n\n try {\n const entityRecord = await admin.create({\n title: options.title ?? deriveTitle(options.filename),\n alt: options.alt ?? null,\n description: options.description ?? null,\n fileKey: fileRecord.key,\n filename: options.filename,\n mimeType: options.mimeType,\n size: options.size,\n width,\n height,\n mediaType,\n } as Record<string, EntityFieldValue>)\n\n // 4. Get URL\n const url = await this.storage.getUrl(fileRecord.key)\n\n this.logger?.info(\n { id: entityRecord.id, fileKey: fileRecord.key, mediaType },\n 'Media uploaded',\n )\n\n return {\n media: entityRecord as unknown as MediaRecord,\n url,\n }\n } catch (error) {\n // Rollback: delete variants + original if entity creation fails\n this.logger?.error(\n { fileKey: fileRecord.key, error },\n 'Media entity creation failed, rolling back storage uploads',\n )\n\n // Delete variants first (best-effort)\n for (const vKey of Object.values(variantKeys)) {\n await this.storage.delete(vKey).catch(() => {})\n }\n\n // Delete original\n await this.storage.delete(fileRecord.key).catch((rollbackErr: unknown) => {\n this.logger?.error(\n { fileKey: fileRecord.key, error: rollbackErr },\n 'Storage rollback also failed',\n )\n })\n throw error\n }\n }\n\n // ---------------------------------------------------------------\n // CRUD delegation\n // ---------------------------------------------------------------\n\n async findById(id: string, options?: { locale?: string }): Promise<MediaRecord | null> {\n const admin = await this.getAdmin()\n const result = await admin.findById(id, options)\n return result as unknown as MediaRecord | null\n }\n\n async findMany(options?: MediaListOptions): Promise<MediaListResult> {\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { and, asc, desc, eq, ilike, sql } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered. Is the media() plugin loaded?')\n\n // Build where conditions\n const conditions = []\n\n if (options?.mediaType) {\n conditions.push(eq(table.mediaType, options.mediaType))\n }\n if (options?.mimeTypePrefix) {\n conditions.push(ilike(table.mimeType, `${options.mimeTypePrefix}%`))\n }\n if (options?.search) {\n const pattern = `%${options.search}%`\n conditions.push(\n sql`(${ilike(table.filename, pattern)} OR ${table.fields} ->> 'title' ILIKE ${pattern})`,\n )\n }\n\n const limit = options?.limit ?? 50\n const offset = options?.offset ?? 0\n const whereClause = conditions.length > 0 ? and(...conditions) : undefined\n\n // Count total\n const [countResult] = await this.db\n .select({ count: sql<number>`count(*)::int` })\n .from(table)\n .where(whereClause)\n\n // Fetch items via AdminClient for proper DTO shaping\n const orderField = options?.orderBy === 'filename' ? table.filename : table.createdAt\n const orderFn = (options?.orderDirection ?? 'desc') === 'asc' ? asc : desc\n\n const items = await admin.findMany({\n where: whereClause,\n limit,\n offset,\n orderBy: orderFn(orderField),\n })\n\n return {\n items: items as unknown as MediaRecord[],\n total: countResult?.count ?? 0,\n limit,\n offset,\n }\n }\n\n async update(\n id: string,\n data: { title?: string; alt?: string; description?: string },\n ): Promise<MediaRecord> {\n const admin = await this.getAdmin()\n const result = await admin.update(id, data as Record<string, EntityFieldValue>)\n return result as unknown as MediaRecord\n }\n\n /**\n * Delete a media entity, its variants, and its original file in storage.\n */\n async delete(id: string): Promise<void> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // 1. Delete entity first (ref checking via entity_refs happens here)\n await admin.delete(id)\n\n // 2. Look up original file record for variant metadata\n const fileRecord = await this.storage.getMetadata(fileKey)\n const variants = (fileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n\n // 3. Delete variants (best-effort)\n if (variants) {\n for (const vKey of Object.values(variants)) {\n await this.storage.delete(vKey).catch((err: unknown) => {\n this.logger?.warn({ variantKey: vKey, error: err }, 'Failed to delete variant file')\n })\n }\n }\n\n // 4. Delete original file (best-effort)\n await this.storage.delete(fileKey).catch((err: unknown) => {\n this.logger?.error(\n { id, fileKey, error: err },\n 'Failed to delete file from storage after entity deletion',\n )\n })\n\n this.logger?.info(\n { id, fileKey, deletedVariants: variants ? Object.keys(variants).length : 0 },\n 'Media deleted',\n )\n }\n\n // ---------------------------------------------------------------\n // URL resolution\n // ---------------------------------------------------------------\n\n /**\n * Get URL for a media entity by its ID.\n * Resolves entity -> fileKey -> storage URL.\n */\n async getUrl(id: string): Promise<string> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) throw new Error(`Media not found: ${id}`)\n\n return this.storage.getUrl((record as unknown as MediaRecord).fileKey)\n }\n\n /**\n * Get URLs for multiple media entities (batch).\n * Returns a Map of mediaId -> url.\n */\n async getUrls(ids: string[]): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n }),\n )\n\n return urlMap\n }\n\n /**\n * Get variant URL for a specific image style.\n * Falls back to original URL if the variant doesn't exist.\n *\n * @param id - Media entity ID\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns The variant URL, or original URL as fallback, or null if media not found\n */\n async getVariantUrl(id: string, styleName: string): Promise<string | null> {\n const admin = await this.getAdmin()\n const record = await admin.findById(id)\n if (!record) return null\n\n const fileKey = (record as unknown as MediaRecord).fileKey\n\n // Try variant key first\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n if (style) {\n const vKey = deriveVariantKey(fileKey, styleName, style.format ?? 'webp')\n try {\n return await this.storage.getUrl(vKey)\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n return await this.storage.getUrl(fileKey)\n } catch {\n return null\n }\n }\n\n /**\n * Get variant URLs for multiple media entities (batch).\n * Falls back to original URL per item if the variant doesn't exist.\n *\n * @param ids - Media entity IDs\n * @param styleName - Image style name (e.g., 'thumbnail')\n * @returns Map of mediaId -> variant URL (or original URL as fallback)\n */\n async getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>> {\n if (ids.length === 0) return new Map()\n\n const admin = await this.getAdmin()\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { inArray } = await import('drizzle-orm')\n\n const table = schemaRegistry.get('media')\n if (!table) return new Map()\n\n const records = await admin.findMany({\n where: inArray(table.id, ids),\n limit: ids.length,\n })\n\n const styles = await this.resolveImageStyles()\n const style = styles[styleName]\n const urlMap = new Map<string, string>()\n\n await Promise.all(\n (records as unknown as MediaRecord[]).map(async (record) => {\n // Try variant URL\n if (style) {\n const vKey = deriveVariantKey(record.fileKey, styleName, style.format ?? 'webp')\n try {\n const url = await this.storage.getUrl(vKey)\n urlMap.set(record.id, url)\n return\n } catch {\n // Variant doesn't exist — fall back to original\n }\n }\n\n // Fallback to original\n try {\n const url = await this.storage.getUrl(record.fileKey)\n urlMap.set(record.id, url)\n } catch {\n // Skip — no URL available\n }\n }),\n )\n\n return urlMap\n }\n}\n\n// ---------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------\n\nfunction deriveMediaType(mimeType: string): MediaType {\n if (mimeType.startsWith('image/')) return 'image'\n if (mimeType.startsWith('video/')) return 'video'\n if (mimeType.startsWith('audio/')) return 'audio'\n if (\n mimeType === 'application/pdf' ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.')\n ) {\n return 'document'\n }\n return 'other'\n}\n\nfunction deriveTitle(filename: string): string {\n const withoutExt = filename.replace(/\\.[^.]+$/, '')\n return withoutExt.replace(/[-_]/g, ' ')\n}\n\n/**\n * Factory — creates a MediaClient with an explicit StorageClient.\n * Must be called after createApp().\n */\nexport async function createMediaClient(storage: StorageClient): Promise<MediaClient> {\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n}\n\n// ---------------------------------------------------------------------------\n// Lazy singleton — auto-discovers storage config\n// ---------------------------------------------------------------------------\n\nlet _clientPromise: Promise<MediaClient> | null = null\n\n/**\n * Lazy singleton — returns a shared MediaClient instance.\n * Auto-discovers storage configuration from the storage plugin.\n * Must be called after createApp().\n */\nexport function getMediaClient(): Promise<MediaClient> {\n if (!_clientPromise) {\n _clientPromise = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const config = getStorageConfig()\n const storage = createStorageClient(config, { app })\n\n return new MediaClient({\n db: app.db.readWrite,\n storage,\n logger: app.logger.child({ media: true }),\n })\n })()\n }\n return _clientPromise\n}\n"],"mappings":"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"}
|