appos 0.3.2-0 → 0.3.4-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.
Files changed (158) hide show
  1. package/dist/bin/auth-schema-CcqAJY9P.mjs +2 -0
  2. package/dist/bin/better-sqlite3-CuQ3hsWl.mjs +2 -0
  3. package/dist/bin/bun-sql-DGeo-s_M.mjs +2 -0
  4. package/dist/bin/cache-3oO07miM.mjs +2 -0
  5. package/dist/bin/chunk-l9p7A9gZ.mjs +2 -0
  6. package/dist/bin/cockroach-BaICwY7N.mjs +2 -0
  7. package/dist/bin/database-CaysWPpa.mjs +2 -0
  8. package/dist/bin/esm-BvsccvmM.mjs +2 -0
  9. package/dist/bin/esm-CGKzJ7Am.mjs +3 -0
  10. package/dist/bin/event-DnSe3eh0.mjs +8 -0
  11. package/dist/bin/extract-blob-metadata-iqwTl2ft.mjs +170 -0
  12. package/dist/bin/generate-image-variant-Lyx0vhM6.mjs +2 -0
  13. package/dist/bin/generate-preview-0MrKxslA.mjs +2 -0
  14. package/dist/bin/libsql-DQJrZsU9.mjs +2 -0
  15. package/dist/bin/logger-BAGZLUzj.mjs +2 -0
  16. package/dist/bin/main.mjs +1201 -190
  17. package/dist/bin/migrator-B7iNKM8N.mjs +2 -0
  18. package/dist/bin/migrator-BKE1cSQQ.mjs +2 -0
  19. package/dist/bin/migrator-BXcbc9zs.mjs +2 -0
  20. package/dist/bin/migrator-B_XhRWZC.mjs +8 -0
  21. package/dist/bin/migrator-Bz52Gtr8.mjs +2 -0
  22. package/dist/bin/migrator-C7W-cZHB.mjs +2 -0
  23. package/dist/bin/migrator-CEnKyGSW.mjs +2 -0
  24. package/dist/bin/migrator-CHzIIl5X.mjs +2 -0
  25. package/dist/bin/migrator-CR-rjZdM.mjs +2 -0
  26. package/dist/bin/migrator-CjIr1ZCx.mjs +8 -0
  27. package/dist/bin/migrator-Cuubh2dg.mjs +2 -0
  28. package/dist/bin/migrator-D8m-ORbr.mjs +8 -0
  29. package/dist/bin/migrator-DBFwrhZH.mjs +2 -0
  30. package/dist/bin/migrator-DLmhW9u_.mjs +2 -0
  31. package/dist/bin/migrator-DLoHx807.mjs +4 -0
  32. package/dist/bin/migrator-DtN_iS87.mjs +2 -0
  33. package/dist/bin/migrator-Yc57lb3w.mjs +2 -0
  34. package/dist/bin/migrator-cEVXH3xC.mjs +2 -0
  35. package/dist/bin/migrator-hWi-sYIq.mjs +2 -0
  36. package/dist/bin/mysql2-DufFWkj4.mjs +2 -0
  37. package/dist/bin/neon-serverless-5a4h2VFz.mjs +2 -0
  38. package/dist/bin/node-CiOp4xrR.mjs +22 -0
  39. package/dist/bin/node-mssql-DvZGaUkB.mjs +322 -0
  40. package/dist/bin/node-postgres-BqbJVBQY.mjs +2 -0
  41. package/dist/bin/node-postgres-DnhRTTO8.mjs +2 -0
  42. package/dist/bin/open-0ksnL0S8.mjs +2 -0
  43. package/dist/bin/pdf-sUYeFPr4.mjs +14 -0
  44. package/dist/bin/pg-CaH8ptj-.mjs +2 -0
  45. package/dist/bin/pg-core-BLTZt9AH.mjs +8 -0
  46. package/dist/bin/pg-core-CGzidKaA.mjs +2 -0
  47. package/dist/bin/pglite-BJB9z7Ju.mjs +2 -0
  48. package/dist/bin/planetscale-serverless-H3RfLlMK.mjs +13 -0
  49. package/dist/bin/postgres-js-DuOf1eWm.mjs +2 -0
  50. package/dist/bin/purge-attachment-DQXpTtTx.mjs +2 -0
  51. package/dist/bin/purge-audit-logs-BEt2J2gD.mjs +2 -0
  52. package/dist/bin/{purge-unattached-blobs-Duvv8Izd.mjs → purge-unattached-blobs-DOmk4ddJ.mjs} +1 -1
  53. package/dist/bin/query-builder-DSRrR6X_.mjs +8 -0
  54. package/dist/bin/query-builder-V8-LDhvA.mjs +3 -0
  55. package/dist/bin/session-CdB1A-LB.mjs +14 -0
  56. package/dist/bin/session-Cl2e-_i8.mjs +8 -0
  57. package/dist/bin/singlestore-COft6TlR.mjs +8 -0
  58. package/dist/bin/sql-D-eKV1Dn.mjs +2 -0
  59. package/dist/bin/sqlite-cloud-Co9jOn5G.mjs +2 -0
  60. package/dist/bin/sqlite-proxy-Cpu78gJF.mjs +2 -0
  61. package/dist/bin/src-C-oXmCzx.mjs +6 -0
  62. package/dist/bin/table-3zUpWkMg.mjs +2 -0
  63. package/dist/bin/track-db-changes-DWyY5jXm.mjs +2 -0
  64. package/dist/bin/utils-CyoeCJlf.mjs +2 -0
  65. package/dist/bin/utils-EoqYQKy1.mjs +2 -0
  66. package/dist/bin/utils-bsypyqPl.mjs +2 -0
  67. package/dist/bin/vercel-postgres-HWL6xtqi.mjs +2 -0
  68. package/dist/bin/workflow-zxHDyfLq.mjs +2 -0
  69. package/dist/bin/youch-handler-DrYdbUhe.mjs +2 -0
  70. package/dist/bin/zod-MJjkEkRY.mjs +24 -0
  71. package/dist/exports/api/_virtual/rolldown_runtime.mjs +36 -1
  72. package/dist/exports/api/app-context.mjs +24 -1
  73. package/dist/exports/api/auth-schema.mjs +373 -1
  74. package/dist/exports/api/auth.d.mts +4 -0
  75. package/dist/exports/api/auth.mjs +188 -1
  76. package/dist/exports/api/cache.d.mts +2 -2
  77. package/dist/exports/api/cache.mjs +28 -1
  78. package/dist/exports/api/config.mjs +72 -1
  79. package/dist/exports/api/constants.mjs +92 -1
  80. package/dist/exports/api/container.mjs +49 -1
  81. package/dist/exports/api/database.mjs +218 -1
  82. package/dist/exports/api/event.mjs +236 -1
  83. package/dist/exports/api/i18n.mjs +45 -1
  84. package/dist/exports/api/index.mjs +20 -1
  85. package/dist/exports/api/instrumentation.mjs +40 -1
  86. package/dist/exports/api/logger.mjs +26 -1
  87. package/dist/exports/api/mailer.mjs +37 -1
  88. package/dist/exports/api/middleware.mjs +73 -1
  89. package/dist/exports/api/openapi.mjs +507 -1
  90. package/dist/exports/api/orm.mjs +43 -1
  91. package/dist/exports/api/otel.mjs +56 -1
  92. package/dist/exports/api/redis.mjs +41 -1
  93. package/dist/exports/api/storage-schema.mjs +72 -1
  94. package/dist/exports/api/storage.mjs +833 -1
  95. package/dist/exports/api/web/auth.mjs +17 -1
  96. package/dist/exports/api/workflow.mjs +196 -1
  97. package/dist/exports/api/workflows/_virtual/rolldown_runtime.mjs +36 -1
  98. package/dist/exports/api/workflows/api/auth-schema.mjs +373 -1
  99. package/dist/exports/api/workflows/api/auth.d.mts +4 -0
  100. package/dist/exports/api/workflows/api/cache.d.mts +2 -2
  101. package/dist/exports/api/workflows/api/event.mjs +126 -1
  102. package/dist/exports/api/workflows/api/redis.mjs +3 -1
  103. package/dist/exports/api/workflows/api/workflow.mjs +135 -1
  104. package/dist/exports/api/workflows/constants.mjs +23 -1
  105. package/dist/exports/api/workflows/extract-blob-metadata.mjs +132 -1
  106. package/dist/exports/api/workflows/generate-image-variant.d.mts +2 -2
  107. package/dist/exports/api/workflows/generate-image-variant.mjs +118 -1
  108. package/dist/exports/api/workflows/generate-preview.mjs +160 -1
  109. package/dist/exports/api/workflows/index.mjs +3 -1
  110. package/dist/exports/api/workflows/purge-attachment.mjs +34 -1
  111. package/dist/exports/api/workflows/purge-audit-logs.mjs +47 -1
  112. package/dist/exports/api/workflows/purge-unattached-blobs.mjs +46 -1
  113. package/dist/exports/api/workflows/track-db-changes.mjs +110 -1
  114. package/dist/exports/cli/_virtual/rolldown_runtime.mjs +36 -1
  115. package/dist/exports/cli/api/auth-schema.mjs +373 -1
  116. package/dist/exports/cli/api/auth.d.mts +4 -0
  117. package/dist/exports/cli/api/cache.d.mts +2 -2
  118. package/dist/exports/cli/api/event.mjs +126 -1
  119. package/dist/exports/cli/api/redis.mjs +3 -1
  120. package/dist/exports/cli/api/workflow.mjs +135 -1
  121. package/dist/exports/cli/api/workflows/extract-blob-metadata.mjs +132 -1
  122. package/dist/exports/cli/api/workflows/generate-image-variant.mjs +118 -1
  123. package/dist/exports/cli/api/workflows/generate-preview.mjs +160 -1
  124. package/dist/exports/cli/api/workflows/purge-attachment.mjs +34 -1
  125. package/dist/exports/cli/api/workflows/purge-audit-logs.mjs +47 -1
  126. package/dist/exports/cli/api/workflows/purge-unattached-blobs.mjs +46 -1
  127. package/dist/exports/cli/api/workflows/track-db-changes.mjs +110 -1
  128. package/dist/exports/cli/command.d.mts +2 -0
  129. package/dist/exports/cli/command.mjs +43 -1
  130. package/dist/exports/cli/constants.mjs +23 -1
  131. package/dist/exports/cli/index.mjs +3 -1
  132. package/dist/exports/devtools/index.js +4 -1
  133. package/dist/exports/tests/api/auth.d.mts +4 -0
  134. package/dist/exports/tests/api/cache.d.mts +2 -2
  135. package/dist/exports/tests/api/middleware/i18n.mjs +1 -1
  136. package/dist/exports/tests/api/middleware/youch-handler.mjs +1 -1
  137. package/dist/exports/tests/api/openapi.mjs +1 -1
  138. package/dist/exports/tests/api/server.mjs +1 -1
  139. package/dist/exports/tests/api/storage.d.mts +4 -4
  140. package/dist/exports/tests/constants.mjs +1 -1
  141. package/dist/exports/vendors/date.js +1 -1
  142. package/dist/exports/vendors/toolkit.js +1 -1
  143. package/dist/exports/vendors/zod.js +1 -1
  144. package/dist/exports/vitest/globals.mjs +1 -1
  145. package/dist/exports/web/auth.js +75 -1
  146. package/dist/exports/web/i18n.js +45 -1
  147. package/dist/exports/web/index.js +8 -1
  148. package/package.json +19 -18
  149. package/dist/bin/auth-schema-Va0CYicu.mjs +0 -2
  150. package/dist/bin/event-8JibGFH_.mjs +0 -2
  151. package/dist/bin/extract-blob-metadata-DjPfHtQ2.mjs +0 -2
  152. package/dist/bin/generate-image-variant-D5VDFyWj.mjs +0 -2
  153. package/dist/bin/generate-preview-Dssw7w5U.mjs +0 -2
  154. package/dist/bin/purge-attachment-BBPzIxwt.mjs +0 -2
  155. package/dist/bin/purge-audit-logs-BeZy3IFM.mjs +0 -2
  156. package/dist/bin/track-db-changes-CFykw_YO.mjs +0 -2
  157. package/dist/bin/workflow-BNUZrj4F.mjs +0 -2
  158. package/dist/bin/youch-handler-BadUgHb0.mjs +0 -2
@@ -1 +1,833 @@
1
- import{orm_exports as e}from"./orm.mjs";import{extractBlobMetadata as t}from"./workflows/extract-blob-metadata.mjs";import{generateImageVariant as n}from"./workflows/generate-image-variant.mjs";import{generatePreview as r}from"./workflows/generate-preview.mjs";import{purgeAttachment as i}from"./workflows/purge-attachment.mjs";import{join as a}from"node:path";import{mkdtemp as o,rm as s,writeFile as c}from"node:fs/promises";import{createHash as l,createHmac as u,randomBytes as d,timingSafeEqual as f}from"node:crypto";import{tmpdir as p}from"node:os";import{DriveManager as m}from"flydrive";import{FSDriver as h}from"flydrive/drivers/fs";import{S3Driver as g}from"flydrive/drivers/s3";function _(e){return{bucket:e.bucket,region:e.region,visibility:e.visibility??`private`,...e.endpoint&&{endpoint:e.endpoint,forcePathStyle:!0},...e.credentials&&{credentials:e.credentials}}}function v(e){return l(`md5`).update(e).digest(`hex`)}function y(e,t){return`variants/${e}/${l(`sha256`).update(JSON.stringify({blobId:e,transformations:t})).digest(`hex`)}`}function b(e){let t=`${Date.now()}-${d(16).toString(`hex`)}`;return e?`${e}/${t}`:t}function x(e){if(!e.default)throw Error(`Storage: 'default' disk must be specified`);if(!e.disks||Object.keys(e.disks).length===0)throw Error(`Storage: At least one disk must be configured`);let t=e.default,n={},r={};for(let[t,i]of Object.entries(e.disks)){let a=i;if(`driver`in a)if(a.driver===`fs`){let e=()=>new h(a);n[t]=e,r[t]=e}else a.driver===`s3`&&(n[t]=()=>new g(a),e.publicEndpoint?r[t]=()=>new g({...a,endpoint:e.publicEndpoint}):r[t]=()=>new g(a));else if(`location`in a){let e=()=>new h(a);n[t]=e,r[t]=e}else if(`bucket`in a)if(n[t]=()=>new g(a),e.publicEndpoint){let n={...a,endpoint:e.publicEndpoint};r[t]=()=>new g(n)}else r[t]=()=>new g(a)}let i=new m({default:t,services:n}),a=new m({default:t,services:r});return new S(i,e.database,t,a,e.purgeCron,e.secret)}var S=class{drive;db;defaultDisk;purgeCron;secret;signedUrlDrive;constructor(e,t,n,r,i,a){this.drive=e,this.db=t,this.defaultDisk=n,this.signedUrlDrive=r,this.purgeCron=i,this.secret=a}async createBlob(e,t){let n=b(t.prefix),r=Buffer.from(e),i=v(r),a=t.serviceName||this.defaultDisk;await(t.serviceName?this.drive.use(t.serviceName):this.drive.use()).put(n,r,{contentType:t.contentType});let[o]=await this.db.insert(this.db._.fullSchema.storageBlobs).values({key:n,filename:t.filename,contentType:t.contentType,metadata:t.metadata,serviceName:a,byteSize:r.byteLength,checksum:i}).returning();return o}async getBlob(t){let[n]=await this.db.select().from(this.db._.fullSchema.storageBlobs).where((0,e.eq)(this.db._.fullSchema.storageBlobs.id,t));return n||null}async downloadBlob(e){let t=await this.getBlob(e);if(!t)return null;let n=await this.drive.use(t.serviceName).getBytes(t.key);return Buffer.from(n)}async deleteBlob(t){let n=await this.getBlob(t);return n?(await this.drive.use(n.serviceName).delete(n.key),await this.db.delete(this.db._.fullSchema.storageBlobs).where((0,e.eq)(this.db._.fullSchema.storageBlobs.id,t)),!0):!1}async getSignedUrl(e,t={}){let n=await this.getBlob(e);if(!n)return null;if(n.serviceName===`public`)return this.getPublicUrl(e);let r=this.signedUrlDrive.use(n.serviceName);try{let e={expiresIn:t.expiresIn||3600};if(t.disposition){let r=t.filename||n.filename;e.contentDisposition=t.disposition===`attachment`?`attachment; filename="${r}"`:`inline; filename="${r}"`}return await r.getSignedUrl(n.key,e)}catch{let e=`/storage/${n.id}`;return t.disposition?`${e}?${new URLSearchParams({disposition:t.disposition,...t.filename&&{filename:t.filename}}).toString()}`:e}}async getPublicUrl(e){let t=await this.getBlob(e);if(!t)return null;let n=this.drive.use(t.serviceName);if(t.serviceName===`public`)try{if(`getUrl`in n&&typeof n.getUrl==`function`)return await n.getUrl(t.key)}catch{}return`/storage/${t.id}`}async createAttachment(t,n,r,i,a=!1){if(a){let a=`${t}:${n}:${i}`,o=Array.from(a).reduce((e,t)=>(e<<5)-e+t.charCodeAt(0)|0,0);return await this.db.transaction(async a=>{await a.execute(e.sql`SELECT pg_advisory_xact_lock(${o})`),await a.delete(this.db._.fullSchema.storageAttachments).where((0,e.and)((0,e.eq)(this.db._.fullSchema.storageAttachments.recordType,t),(0,e.eq)(this.db._.fullSchema.storageAttachments.recordId,n),(0,e.eq)(this.db._.fullSchema.storageAttachments.name,i)));let[s]=await a.insert(this.db._.fullSchema.storageAttachments).values({recordType:t,recordId:n,blobId:r,name:i}).returning();return s})}let[o]=await this.db.insert(this.db._.fullSchema.storageAttachments).values({recordType:t,recordId:n,blobId:r,name:i}).returning();return o}async getAttachments(e,t,n){return this.db.query.storageAttachments.findMany({where:{recordType:e,recordId:t,...n&&{name:n}},with:{blob:!0}})}async getAttachmentsByIds(e){return e.length===0?[]:this.db.query.storageAttachments.findMany({where:{id:{in:e}},with:{blob:!0}})}async deleteAttachment(t){return((await this.db.delete(this.db._.fullSchema.storageAttachments).where((0,e.eq)(this.db._.fullSchema.storageAttachments.id,t))).rowCount??0)>0}async deleteAttachments(t,n,r){let i=r?(0,e.and)((0,e.eq)(this.db._.fullSchema.storageAttachments.recordType,t),(0,e.eq)(this.db._.fullSchema.storageAttachments.recordId,n),(0,e.eq)(this.db._.fullSchema.storageAttachments.name,r)):(0,e.and)((0,e.eq)(this.db._.fullSchema.storageAttachments.recordType,t),(0,e.eq)(this.db._.fullSchema.storageAttachments.recordId,n));return(await this.db.delete(this.db._.fullSchema.storageAttachments).where(i)).rowCount??0}async getDirectUploadUrl(e){let t=e.serviceName||this.defaultDisk,n=this.drive.use(t);try{let r=b();if(`putSignedUrl`in n){let i=await n.putSignedUrl(r,{expiresIn:e.expiresIn||3600,contentType:e.contentType}),[a]=await this.db.insert(this.db._.fullSchema.storageBlobs).values({key:r,filename:e.filename,contentType:e.contentType,metadata:{...e.metadata,pending:!0},serviceName:t,byteSize:0,checksum:``}).returning();return{url:i,key:r,blobId:a.id,headers:{"Content-Type":e.contentType||`application/octet-stream`}}}}catch{}return null}async finalizeDirectUpload(t,n){let r=await this.getBlob(t);if(!r)throw Error(`Blob ${t} not found`);let i=r.metadata&&typeof r.metadata==`object`?{...r.metadata}:{};delete i.pending,await this.db.update(this.db._.fullSchema.storageBlobs).set({byteSize:n,metadata:i}).where((0,e.eq)(this.db._.fullSchema.storageBlobs.id,t))}async updateBlobMetadata(t,n){let r=await this.getBlob(t);if(!r)throw Error(`Blob ${t} not found`);let i={...r.metadata&&typeof r.metadata==`object`?r.metadata:{},...n};await this.db.update(this.db._.fullSchema.storageBlobs).set({metadata:i}).where((0,e.eq)(this.db._.fullSchema.storageBlobs.id,t))}async getVariant(e,t){let n=l(`sha256`).update(JSON.stringify(t)).digest(`hex`),[r]=await this.db.query.storageVariantRecords.findMany({where:{blobId:e,variationDigest:n},with:{blob:!0},limit:1});return r?.blob||null}async createVariant(e,t,n){let r=await this.getBlob(e);if(!r)throw Error(`Blob ${e} not found`);let i=y(e,t),a=v(n);await this.drive.use(r.serviceName).put(i,n,{contentType:r.contentType||`application/octet-stream`});let[o]=await this.db.insert(this.db._.fullSchema.storageBlobs).values({key:i,filename:r.filename,contentType:r.contentType,metadata:{sourceBlob:e,transformations:t},serviceName:r.serviceName,byteSize:n.byteLength,checksum:a}).returning(),s=l(`sha256`).update(JSON.stringify(t)).digest(`hex`);return await this.db.insert(this.db._.fullSchema.storageVariantRecords).values({blobId:e,variationDigest:s,id:o.id}),o}async getUnattachedBlobs(t={}){let n=t.olderThan||new Date(Date.now()-2880*60*1e3).toISOString(),r=t.limit||1e3;return(await this.db.select({blob:this.db._.fullSchema.storageBlobs}).from(this.db._.fullSchema.storageBlobs).leftJoin(this.db._.fullSchema.storageAttachments,(0,e.eq)(this.db._.fullSchema.storageBlobs.id,this.db._.fullSchema.storageAttachments.blobId)).where((0,e.and)((0,e.isNull)(this.db._.fullSchema.storageAttachments.id),(0,e.lt)(this.db._.fullSchema.storageBlobs.createdAt,n))).limit(r)).map(e=>e.blob)}async getPendingBlobs(t=new Date(Date.now()-1440*60*1e3).toISOString()){return await this.db.select().from(this.db._.fullSchema.storageBlobs).where((0,e.and)((0,e.lt)(this.db._.fullSchema.storageBlobs.createdAt,t),e.sql`${this.db._.fullSchema.storageBlobs.metadata}->>'pending' = 'true'`))}async purgeUnattached(e){let t=await this.getUnattachedBlobs({olderThan:e}),n=0;for(let e of t)try{await this.deleteBlob(e.id),n++}catch(t){console.error(`Failed to purge blob ${e.id}:`,t)}return n}signedId(e,t=3600){if(!this.secret)throw Error(`Storage secret not configured`);let n={blobId:e,exp:Math.floor(Date.now()/1e3)+t},r=JSON.stringify(n),i=u(`sha256`,this.secret).update(r).digest(`base64url`);return`${Buffer.from(r).toString(`base64url`)}.${i}`}async findSigned(e){if(!this.secret)throw Error(`Storage secret not configured`);try{let[t,n]=e.split(`.`);if(!t||!n)return null;let r=Buffer.from(t,`base64url`).toString(),i=u(`sha256`,this.secret).update(r).digest(`base64url`),a=Buffer.from(n,`base64url`),o=Buffer.from(i,`base64url`);if(a.length!==o.length||!f(a,o))return null;let s=JSON.parse(r);return s.exp&&s.exp<Math.floor(Date.now()/1e3)?null:this.getBlob(s.blobId)}catch{return null}}one(e,l,u){let d=this,f=u;return{async attach(n){if(!await d.getBlob(n))throw Error(`Blob ${n} not found`);let r=await d.createAttachment(e,l,n,f,!0);return await t.start({blobId:n}),r},async get(){return(await d.getAttachments(e,l,f))[0]?.blob??null},async attached(){return await this.get()!==null},async url(e){let t=await this.get();return t?d.getSignedUrl(t.id,e):null},async publicUrl(){let e=await this.get();return e?d.getPublicUrl(e.id):null},async metadata(){return(await this.get())?.metadata??null},async analyzed(){return(await this.metadata())?.analyzed===!0},async representable(){let e=await this.get();return e?.contentType?[`image/`,`video/`,`application/pdf`].some(t=>e.contentType?.startsWith(t)):!1},async variant(e,t=3600){let r=await this.get();if(!r)return null;let i=await d.getVariant(r.id,e);return i?d.getSignedUrl(i.id,{expiresIn:t}):(await n.start({blobId:r.id,transformations:e}),null)},async preview(e=3600,t=1){let n=await this.get();if(!n)return null;let i=await d.getVariant(n.id,{preview:!0});return i?d.getSignedUrl(i.id,{expiresIn:e}):(await r.start({blobId:n.id,timeInSeconds:t}),null)},async detach(){return await d.deleteAttachments(e,l,f)>0},async purge(){let t=await d.getAttachments(e,l,f);if(t.length===0)return!1;let n=t[0].blob;return n?(await d.deleteAttachments(e,l,f),await d.deleteBlob(n.id),!0):!1},async purgeLater(){let t=await d.getAttachments(e,l,f);return t.length===0?!1:(await i.start({attachmentIds:[t[0].id]}),!0)},async download(){let e=await this.get();return e?d.downloadBlob(e.id):null},async open(e){let t=await this.get();if(!t)return null;let n=await d.downloadBlob(t.id);if(!n)return null;let r=await o(a(p(),`attachment-`)),i=a(r,t.filename);try{return await c(i,n),await e(i)}finally{await s(r,{recursive:!0,force:!0})}},async representation(e,t=3600){let n=await this.get();return n?n.contentType?.startsWith(`image/`)?this.variant(e,t):this.preview(t):null},async byteSize(){return(await this.get())?.byteSize??null},async contentType(){return(await this.get())?.contentType??null},async filename(){return(await this.get())?.filename??null},async signedId(e=3600){let t=await this.get();return t?d.signedId(t.id,e):null}}}many(e,l,u){let d=this,f=u;return{async attach(n){let r=Array.isArray(n)?n:[n],i=await Promise.all(r.map(e=>d.getBlob(e))),a=r.filter((e,t)=>!i[t]);if(a.length>0)throw Error(`Blobs not found: ${a.join(`, `)}`);let o=await Promise.all(r.map(t=>d.createAttachment(e,l,t,f)));return await Promise.all(r.map(e=>t.start({blobId:e}))),o},async list(){return(await d.getAttachments(e,l,f)).map(e=>e.blob).filter(e=>e!==null)},async count(){return(await this.list()).length},async urls(e){let t=await this.list();return(await Promise.all(t.map(t=>d.getSignedUrl(t.id,e)))).filter(e=>e!==null)},async publicUrls(){let e=await this.list();return Promise.all(e.map(e=>d.getPublicUrl(e.id)))},async metadata(){return(await this.list()).map(e=>e.metadata??null)},async analyzed(){return(await this.metadata()).map(e=>e?.analyzed===!0)},async representable(){return(await this.list()).map(e=>e.contentType?[`image/`,`video/`,`application/pdf`].some(t=>e.contentType?.startsWith(t)):!1)},async variants(e,t=3600){let r=await this.list();return Promise.all(r.map(async r=>{let i=await d.getVariant(r.id,e);return i?d.getSignedUrl(i.id,{expiresIn:t}):(await n.start({blobId:r.id,transformations:e}),null)}))},async previews(e=3600,t=1){let n=await this.list();return Promise.all(n.map(async n=>{let i=await d.getVariant(n.id,{preview:!0});return i?d.getSignedUrl(i.id,{expiresIn:e}):(await r.start({blobId:n.id,timeInSeconds:t}),null)}))},async detach(t){if(t){let n=(await d.getAttachments(e,l,f)).find(e=>e.blob?.id===t);return n?(await d.deleteAttachment(n.id),1):0}return d.deleteAttachments(e,l,f)},async purge(t){let n=await d.getAttachments(e,l,f);if(t){let e=n.find(e=>e.blob?.id===t);return e?(await d.deleteAttachment(e.id),await d.deleteBlob(t),1):0}let r=0;for(let e of n)await d.deleteAttachment(e.id),e.blob&&(await d.deleteBlob(e.blob.id),r++);return r},async purgeLater(t){let n=await d.getAttachments(e,l,f);if(t){let e=n.find(e=>e.blob?.id===t);return e?(await i.start({attachmentIds:[e.id]}),1):0}return n.length>0&&await i.start({attachmentIds:n.map(e=>e.id)}),n.length},async download(){let e=await this.list(),t=[];for(let n of e){let e=await d.downloadBlob(n.id);e&&t.push(e)}return t},async open(e){let t=await this.list(),n=[];for(let r of t){let t=await d.downloadBlob(r.id);if(!t)continue;let i=await o(a(p(),`attachment-`)),l=a(i,r.filename);try{await c(l,t),n.push(await e(l,r))}finally{await s(i,{recursive:!0,force:!0})}}return n},async representations(e,t=3600){let i=await this.list();return Promise.all(i.map(async i=>{if(i.contentType?.startsWith(`image/`)){let r=await d.getVariant(i.id,e);return r?d.getSignedUrl(r.id,{expiresIn:t}):(await n.start({blobId:i.id,transformations:e}),null)}let a=await d.getVariant(i.id,{preview:!0});return a?d.getSignedUrl(a.id,{expiresIn:t}):(await r.start({blobId:i.id}),null)}))},async byteSizes(){return(await this.list()).map(e=>e.byteSize)},async contentTypes(){return(await this.list()).map(e=>e.contentType)},async filenames(){return(await this.list()).map(e=>e.filename)},async signedIds(e=3600){return(await this.list()).map(t=>d.signedId(t.id,e))}}}};export{S as StorageService,_ as defineS3Disk,x as defineStorage};
1
+ import { orm_exports } from "./orm.mjs";
2
+ import { extractBlobMetadata } from "./workflows/extract-blob-metadata.mjs";
3
+ import { generateImageVariant } from "./workflows/generate-image-variant.mjs";
4
+ import { generatePreview } from "./workflows/generate-preview.mjs";
5
+ import { purgeAttachment } from "./workflows/purge-attachment.mjs";
6
+ import { join } from "node:path";
7
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
8
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
9
+ import { tmpdir } from "node:os";
10
+ import { DriveManager } from "flydrive";
11
+ import { FSDriver } from "flydrive/drivers/fs";
12
+ import { S3Driver } from "flydrive/drivers/s3";
13
+
14
+ //#region src/api/storage.ts
15
+ /**
16
+ * Creates an S3 disk configuration with common boilerplate handled.
17
+ *
18
+ * Algorithm:
19
+ * 1. Build base config with bucket, region, visibility
20
+ * 2. If endpoint provided, add endpoint + forcePathStyle
21
+ * 3. If credentials provided, add credentials object
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * defineS3Disk({
26
+ * bucket: "my-bucket",
27
+ * region: "us-east-1",
28
+ * endpoint: "http://localhost:4566",
29
+ * credentials: {
30
+ * accessKeyId: "test",
31
+ * secretAccessKey: "test",
32
+ * },
33
+ * })
34
+ * ```
35
+ */
36
+ function defineS3Disk(opts) {
37
+ return {
38
+ bucket: opts.bucket,
39
+ region: opts.region,
40
+ visibility: opts.visibility ?? "private",
41
+ ...opts.endpoint && {
42
+ endpoint: opts.endpoint,
43
+ forcePathStyle: true
44
+ },
45
+ ...opts.credentials && { credentials: opts.credentials }
46
+ };
47
+ }
48
+ /**
49
+ * Calculate checksum for a buffer.
50
+ */
51
+ function calculateChecksum(buffer) {
52
+ return createHash("md5").update(buffer).digest("hex");
53
+ }
54
+ /**
55
+ * Generate deterministic variant key.
56
+ */
57
+ function generateVariantKey(blobId, transformations) {
58
+ return `variants/${blobId}/${createHash("sha256").update(JSON.stringify({
59
+ blobId,
60
+ transformations
61
+ })).digest("hex")}`;
62
+ }
63
+ /**
64
+ * Generate a secure key for blob storage.
65
+ */
66
+ function generateKey(prefix) {
67
+ const key = `${Date.now()}-${randomBytes(16).toString("hex")}`;
68
+ return prefix ? `${prefix}/${key}` : key;
69
+ }
70
+ /**
71
+ * Define storage service with FlyDrive.
72
+ * @template TDisks - Record of disk configurations keyed by disk name
73
+ * @template TDb - Database type for table name extraction
74
+ * @template TAttachments - Mapping of table names to valid attachment names
75
+ */
76
+ function defineStorage(opts) {
77
+ if (!opts.default) throw new Error("Storage: 'default' disk must be specified");
78
+ if (!opts.disks || Object.keys(opts.disks).length === 0) throw new Error("Storage: At least one disk must be configured");
79
+ const defaultDisk = opts.default;
80
+ const operationalServices = {};
81
+ const signedUrlServices = {};
82
+ for (const [name, diskConfig] of Object.entries(opts.disks)) {
83
+ const config = diskConfig;
84
+ if ("driver" in config) {
85
+ if (config.driver === "fs") {
86
+ const fsDriver = () => new FSDriver(config);
87
+ operationalServices[name] = fsDriver;
88
+ signedUrlServices[name] = fsDriver;
89
+ } else if (config.driver === "s3") {
90
+ operationalServices[name] = () => new S3Driver(config);
91
+ if (opts.publicEndpoint) signedUrlServices[name] = () => new S3Driver({
92
+ ...config,
93
+ endpoint: opts.publicEndpoint
94
+ });
95
+ else signedUrlServices[name] = () => new S3Driver(config);
96
+ }
97
+ } else if ("location" in config) {
98
+ const fsDriver = () => new FSDriver(config);
99
+ operationalServices[name] = fsDriver;
100
+ signedUrlServices[name] = fsDriver;
101
+ } else if ("bucket" in config) {
102
+ operationalServices[name] = () => new S3Driver(config);
103
+ if (opts.publicEndpoint) {
104
+ const signedUrlConfig = {
105
+ ...config,
106
+ endpoint: opts.publicEndpoint
107
+ };
108
+ signedUrlServices[name] = () => new S3Driver(signedUrlConfig);
109
+ } else signedUrlServices[name] = () => new S3Driver(config);
110
+ }
111
+ }
112
+ const operationalDrive = new DriveManager({
113
+ default: defaultDisk,
114
+ services: operationalServices
115
+ });
116
+ const signedUrlDrive = new DriveManager({
117
+ default: defaultDisk,
118
+ services: signedUrlServices
119
+ });
120
+ return new StorageService(operationalDrive, opts.database, defaultDisk, signedUrlDrive, opts.purgeCron, opts.secret);
121
+ }
122
+ /**
123
+ * Storage service for blob operations.
124
+ *
125
+ * This service uses a dual-drive architecture when configured:
126
+ * - `drive`: For actual file operations (upload, delete, read) using internal endpoints
127
+ * - `signedUrlDrive`: For generating browser-accessible signed URLs with public endpoints
128
+ *
129
+ * This allows containers to use fast internal network for operations while
130
+ * generating URLs that work in end-user browsers.
131
+ *
132
+ * @template TDiskNames - Union of disk names (e.g., "private" | "public")
133
+ * @template TTableNames - Union of table names for type-safe attachment methods
134
+ * @template TAttachments - Mapping of table names to valid attachment names
135
+ */
136
+ var StorageService = class {
137
+ /**
138
+ * The drive manager for file operations.
139
+ */
140
+ drive;
141
+ /**
142
+ * The database instance with storage schema and relations.
143
+ */
144
+ db;
145
+ /**
146
+ * The default disk name.
147
+ */
148
+ defaultDisk;
149
+ /**
150
+ * Cron expression for unattached blob purge schedule.
151
+ *
152
+ * @default "0 0 * * *"
153
+ */
154
+ purgeCron;
155
+ /**
156
+ * Secret key for signing blob IDs.
157
+ */
158
+ secret;
159
+ /**
160
+ * Drive manager for generating signed URLs (public endpoint).
161
+ */
162
+ signedUrlDrive;
163
+ constructor(drive, db, defaultDisk, signedUrlDrive, purgeCron, secret) {
164
+ this.drive = drive;
165
+ this.db = db;
166
+ this.defaultDisk = defaultDisk;
167
+ this.signedUrlDrive = signedUrlDrive;
168
+ this.purgeCron = purgeCron;
169
+ this.secret = secret;
170
+ }
171
+ /**
172
+ * Create a blob record and upload file.
173
+ */
174
+ async createBlob(file, options) {
175
+ const key = generateKey(options.prefix);
176
+ const buffer = Buffer.from(file);
177
+ const checksum = calculateChecksum(buffer);
178
+ const serviceName = options.serviceName || this.defaultDisk;
179
+ await (options.serviceName ? this.drive.use(options.serviceName) : this.drive.use()).put(key, buffer, { contentType: options.contentType });
180
+ const [blob] = await this.db.insert(this.db._.fullSchema.storageBlobs).values({
181
+ key,
182
+ filename: options.filename,
183
+ contentType: options.contentType,
184
+ metadata: options.metadata,
185
+ serviceName,
186
+ byteSize: buffer.byteLength,
187
+ checksum
188
+ }).returning();
189
+ return blob;
190
+ }
191
+ /**
192
+ * Get blob by ID.
193
+ */
194
+ async getBlob(id) {
195
+ const [blob] = await this.db.select().from(this.db._.fullSchema.storageBlobs).where((0, orm_exports.eq)(this.db._.fullSchema.storageBlobs.id, id));
196
+ return blob || null;
197
+ }
198
+ /**
199
+ * Download blob content.
200
+ */
201
+ async downloadBlob(id) {
202
+ const blob = await this.getBlob(id);
203
+ if (!blob) return null;
204
+ const content = await this.drive.use(blob.serviceName).getBytes(blob.key);
205
+ return Buffer.from(content);
206
+ }
207
+ /**
208
+ * Delete blob and its content.
209
+ */
210
+ async deleteBlob(id) {
211
+ const blob = await this.getBlob(id);
212
+ if (!blob) return false;
213
+ await this.drive.use(blob.serviceName).delete(blob.key);
214
+ await this.db.delete(this.db._.fullSchema.storageBlobs).where((0, orm_exports.eq)(this.db._.fullSchema.storageBlobs.id, id));
215
+ return true;
216
+ }
217
+ /**
218
+ * Get signed URL for blob. If blob is on public service, returns permanent
219
+ * URL instead.
220
+ */
221
+ async getSignedUrl(id, options = {}) {
222
+ const blob = await this.getBlob(id);
223
+ if (!blob) return null;
224
+ if (blob.serviceName === "public") return this.getPublicUrl(id);
225
+ const disk = this.signedUrlDrive.use(blob.serviceName);
226
+ try {
227
+ const signedUrlOptions = { expiresIn: options.expiresIn || 3600 };
228
+ if (options.disposition) {
229
+ const filename = options.filename || blob.filename;
230
+ signedUrlOptions.contentDisposition = options.disposition === "attachment" ? `attachment; filename="${filename}"` : `inline; filename="${filename}"`;
231
+ }
232
+ return await disk.getSignedUrl(blob.key, signedUrlOptions);
233
+ } catch (error) {
234
+ const baseUrl = `/storage/${blob.id}`;
235
+ if (options.disposition) return `${baseUrl}?${new URLSearchParams({
236
+ disposition: options.disposition,
237
+ ...options.filename && { filename: options.filename }
238
+ }).toString()}`;
239
+ return baseUrl;
240
+ }
241
+ }
242
+ /**
243
+ * Get permanent public URL (no expiration).
244
+ * Works for blobs on public storage service.
245
+ */
246
+ async getPublicUrl(id) {
247
+ const blob = await this.getBlob(id);
248
+ if (!blob) return null;
249
+ const disk = this.drive.use(blob.serviceName);
250
+ if (blob.serviceName === "public") try {
251
+ if ("getUrl" in disk && typeof disk.getUrl === "function") return await disk.getUrl(blob.key);
252
+ } catch {}
253
+ return `/storage/${blob.id}`;
254
+ }
255
+ /**
256
+ * Create attachment between a record and blob.
257
+ *
258
+ * @param recordType - The table name (e.g., 'users')
259
+ * @param recordId - The record ID
260
+ * @param blobId - The blob ID to attach
261
+ * @param name - The attachment name (e.g., 'avatar')
262
+ * @param replaceExisting - If true, replaces existing attachment with same name (for one-to-one)
263
+ */
264
+ async createAttachment(recordType, recordId, blobId, name, replaceExisting = false) {
265
+ if (replaceExisting) {
266
+ const lockKey = `${recordType}:${recordId}:${name}`;
267
+ const lockId = Array.from(lockKey).reduce((hash, char) => (hash << 5) - hash + char.charCodeAt(0) | 0, 0);
268
+ return await this.db.transaction(async (tx) => {
269
+ await tx.execute(orm_exports.sql`SELECT pg_advisory_xact_lock(${lockId})`);
270
+ await tx.delete(this.db._.fullSchema.storageAttachments).where((0, orm_exports.and)((0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.recordType, recordType), (0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.recordId, recordId), (0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.name, name)));
271
+ const [attachment$1] = await tx.insert(this.db._.fullSchema.storageAttachments).values({
272
+ recordType,
273
+ recordId,
274
+ blobId,
275
+ name
276
+ }).returning();
277
+ return attachment$1;
278
+ });
279
+ }
280
+ const [attachment] = await this.db.insert(this.db._.fullSchema.storageAttachments).values({
281
+ recordType,
282
+ recordId,
283
+ blobId,
284
+ name
285
+ }).returning();
286
+ return attachment;
287
+ }
288
+ /**
289
+ * Get attachments for a record with their associated blobs.
290
+ */
291
+ async getAttachments(recordType, recordId, name) {
292
+ return this.db.query.storageAttachments.findMany({
293
+ where: {
294
+ recordType,
295
+ recordId,
296
+ ...name && { name }
297
+ },
298
+ with: { blob: true }
299
+ });
300
+ }
301
+ /**
302
+ * Get attachments by their IDs with associated blobs.
303
+ *
304
+ * @param attachmentIds - Array of attachment IDs to fetch.
305
+ */
306
+ async getAttachmentsByIds(attachmentIds) {
307
+ if (attachmentIds.length === 0) return [];
308
+ return this.db.query.storageAttachments.findMany({
309
+ where: { id: { in: attachmentIds } },
310
+ with: { blob: true }
311
+ });
312
+ }
313
+ /**
314
+ * Delete a single attachment by ID.
315
+ */
316
+ async deleteAttachment(attachmentId) {
317
+ return ((await this.db.delete(this.db._.fullSchema.storageAttachments).where((0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.id, attachmentId))).rowCount ?? 0) > 0;
318
+ }
319
+ /**
320
+ * Delete attachments for a record (without deleting the blobs).
321
+ */
322
+ async deleteAttachments(recordType, recordId, name) {
323
+ const whereClause = name ? (0, orm_exports.and)((0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.recordType, recordType), (0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.recordId, recordId), (0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.name, name)) : (0, orm_exports.and)((0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.recordType, recordType), (0, orm_exports.eq)(this.db._.fullSchema.storageAttachments.recordId, recordId));
324
+ return (await this.db.delete(this.db._.fullSchema.storageAttachments).where(whereClause)).rowCount ?? 0;
325
+ }
326
+ /**
327
+ * Get direct upload credentials (for S3). Also creates a pending blob record
328
+ * that will be finalized after upload.
329
+ */
330
+ async getDirectUploadUrl(options) {
331
+ const serviceName = options.serviceName || this.defaultDisk;
332
+ const disk = this.drive.use(serviceName);
333
+ try {
334
+ const key = generateKey();
335
+ if ("putSignedUrl" in disk) {
336
+ const uploadUrl = await disk.putSignedUrl(key, {
337
+ expiresIn: options.expiresIn || 3600,
338
+ contentType: options.contentType
339
+ });
340
+ const [blob] = await this.db.insert(this.db._.fullSchema.storageBlobs).values({
341
+ key,
342
+ filename: options.filename,
343
+ contentType: options.contentType,
344
+ metadata: {
345
+ ...options.metadata,
346
+ pending: true
347
+ },
348
+ serviceName,
349
+ byteSize: 0,
350
+ checksum: ""
351
+ }).returning();
352
+ return {
353
+ url: uploadUrl,
354
+ key,
355
+ blobId: blob.id,
356
+ headers: { "Content-Type": options.contentType || "application/octet-stream" }
357
+ };
358
+ }
359
+ } catch {}
360
+ return null;
361
+ }
362
+ /**
363
+ * Finalize a direct upload by updating blob metadata. Call this after the
364
+ * client has uploaded to S3.
365
+ */
366
+ async finalizeDirectUpload(blobId, actualSize) {
367
+ const blob = await this.getBlob(blobId);
368
+ if (!blob) throw new Error(`Blob ${blobId} not found`);
369
+ const newMetadata = blob.metadata && typeof blob.metadata === "object" ? { ...blob.metadata } : {};
370
+ delete newMetadata.pending;
371
+ await this.db.update(this.db._.fullSchema.storageBlobs).set({
372
+ byteSize: actualSize,
373
+ metadata: newMetadata
374
+ }).where((0, orm_exports.eq)(this.db._.fullSchema.storageBlobs.id, blobId));
375
+ }
376
+ /**
377
+ * Update blob metadata (for automatic extraction).
378
+ */
379
+ async updateBlobMetadata(blobId, metadata) {
380
+ const blob = await this.getBlob(blobId);
381
+ if (!blob) throw new Error(`Blob ${blobId} not found`);
382
+ const newMetadata = {
383
+ ...blob.metadata && typeof blob.metadata === "object" ? blob.metadata : {},
384
+ ...metadata
385
+ };
386
+ await this.db.update(this.db._.fullSchema.storageBlobs).set({ metadata: newMetadata }).where((0, orm_exports.eq)(this.db._.fullSchema.storageBlobs.id, blobId));
387
+ }
388
+ /**
389
+ * Get existing variant or return null.
390
+ */
391
+ async getVariant(blobId, transformations) {
392
+ const variationDigest = createHash("sha256").update(JSON.stringify(transformations)).digest("hex");
393
+ const [variantRecord] = await this.db.query.storageVariantRecords.findMany({
394
+ where: {
395
+ blobId,
396
+ variationDigest
397
+ },
398
+ with: { blob: true },
399
+ limit: 1
400
+ });
401
+ return variantRecord?.blob || null;
402
+ }
403
+ /**
404
+ * Create variant blob and record.
405
+ */
406
+ async createVariant(blobId, transformations, variantBuffer) {
407
+ const blob = await this.getBlob(blobId);
408
+ if (!blob) throw new Error(`Blob ${blobId} not found`);
409
+ const key = generateVariantKey(blobId, transformations);
410
+ const checksum = calculateChecksum(variantBuffer);
411
+ await this.drive.use(blob.serviceName).put(key, variantBuffer, { contentType: blob.contentType || "application/octet-stream" });
412
+ const [variantBlob] = await this.db.insert(this.db._.fullSchema.storageBlobs).values({
413
+ key,
414
+ filename: blob.filename,
415
+ contentType: blob.contentType,
416
+ metadata: {
417
+ sourceBlob: blobId,
418
+ transformations
419
+ },
420
+ serviceName: blob.serviceName,
421
+ byteSize: variantBuffer.byteLength,
422
+ checksum
423
+ }).returning();
424
+ const variationDigest = createHash("sha256").update(JSON.stringify(transformations)).digest("hex");
425
+ await this.db.insert(this.db._.fullSchema.storageVariantRecords).values({
426
+ blobId,
427
+ variationDigest,
428
+ id: variantBlob.id
429
+ });
430
+ return variantBlob;
431
+ }
432
+ /**
433
+ * Get blobs that have no attachments (orphaned). Useful for cleanup jobs.
434
+ */
435
+ async getUnattachedBlobs(options = {}) {
436
+ const olderThan = options.olderThan || (/* @__PURE__ */ new Date(Date.now() - 2880 * 60 * 1e3)).toISOString();
437
+ const limit = options.limit || 1e3;
438
+ return (await this.db.select({ blob: this.db._.fullSchema.storageBlobs }).from(this.db._.fullSchema.storageBlobs).leftJoin(this.db._.fullSchema.storageAttachments, (0, orm_exports.eq)(this.db._.fullSchema.storageBlobs.id, this.db._.fullSchema.storageAttachments.blobId)).where((0, orm_exports.and)((0, orm_exports.isNull)(this.db._.fullSchema.storageAttachments.id), (0, orm_exports.lt)(this.db._.fullSchema.storageBlobs.createdAt, olderThan))).limit(limit)).map((row) => row.blob);
439
+ }
440
+ /**
441
+ * Get pending blobs that were never finalized (stuck direct uploads).
442
+ */
443
+ async getPendingBlobs(olderThan = (/* @__PURE__ */ new Date(Date.now() - 1440 * 60 * 1e3)).toISOString()) {
444
+ return await this.db.select().from(this.db._.fullSchema.storageBlobs).where((0, orm_exports.and)((0, orm_exports.lt)(this.db._.fullSchema.storageBlobs.createdAt, olderThan), orm_exports.sql`${this.db._.fullSchema.storageBlobs.metadata}->>'pending' = 'true'`));
445
+ }
446
+ /**
447
+ * Purge unattached blobs.
448
+ */
449
+ async purgeUnattached(olderThan) {
450
+ const blobs = await this.getUnattachedBlobs({ olderThan });
451
+ let purgedCount = 0;
452
+ for (const blob of blobs) try {
453
+ await this.deleteBlob(blob.id);
454
+ purgedCount++;
455
+ } catch (error) {
456
+ console.error(`Failed to purge blob ${blob.id}:`, error);
457
+ }
458
+ return purgedCount;
459
+ }
460
+ /**
461
+ * Create a signed, tamper-proof reference to a blob.
462
+ * Use findSigned() to resolve back to blob.
463
+ *
464
+ * Algorithm:
465
+ * 1. Create payload with blobId and expiration timestamp
466
+ * 2. JSON stringify and base64url encode the payload
467
+ * 3. Create HMAC-SHA256 signature of the encoded payload
468
+ * 4. Return payload.signature format
469
+ */
470
+ signedId(blobId, expiresIn = 3600) {
471
+ if (!this.secret) throw new Error("Storage secret not configured");
472
+ const payload = {
473
+ blobId,
474
+ exp: Math.floor(Date.now() / 1e3) + expiresIn
475
+ };
476
+ const data = JSON.stringify(payload);
477
+ const signature = createHmac("sha256", this.secret).update(data).digest("base64url");
478
+ return `${Buffer.from(data).toString("base64url")}.${signature}`;
479
+ }
480
+ /**
481
+ * Find blob by signed ID. Returns null if invalid or expired.
482
+ * Uses constant-time comparison to prevent timing attacks.
483
+ *
484
+ * Algorithm:
485
+ * 1. Split signed ID into payload and signature
486
+ * 2. Decode and recompute expected signature
487
+ * 3. Use timingSafeEqual for constant-time comparison
488
+ * 4. Check expiration timestamp
489
+ * 5. Return blob if valid, null otherwise
490
+ */
491
+ async findSigned(signedId) {
492
+ if (!this.secret) throw new Error("Storage secret not configured");
493
+ try {
494
+ const [encodedPayload, signature] = signedId.split(".");
495
+ if (!encodedPayload || !signature) return null;
496
+ const data = Buffer.from(encodedPayload, "base64url").toString();
497
+ const expectedSignature = createHmac("sha256", this.secret).update(data).digest("base64url");
498
+ const sigBuffer = Buffer.from(signature, "base64url");
499
+ const expectedBuffer = Buffer.from(expectedSignature, "base64url");
500
+ if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) return null;
501
+ const payload = JSON.parse(data);
502
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) return null;
503
+ return this.getBlob(payload.blobId);
504
+ } catch {
505
+ return null;
506
+ }
507
+ }
508
+ /**
509
+ * Get a single attachment handle (one-to-one relationship).
510
+ * Similar to Rails' `has_one_attached :avatar`.
511
+ *
512
+ * Algorithm:
513
+ * Returns an object with methods to manage a single attachment:
514
+ * - attach(): Replace existing attachment with new blob
515
+ * - get(): Get attached blob or null
516
+ * - url(): Get signed URL
517
+ * - variant(): Get/generate image variant
518
+ * - purge(): Delete attachment and blob
519
+ *
520
+ * @template TTable - The table name (must be in TTableNames)
521
+ */
522
+ one(recordType, recordId, name) {
523
+ const storage = this;
524
+ const attachmentName = name;
525
+ return {
526
+ async attach(blobId) {
527
+ if (!await storage.getBlob(blobId)) throw new Error(`Blob ${blobId} not found`);
528
+ const attachment = await storage.createAttachment(recordType, recordId, blobId, attachmentName, true);
529
+ await extractBlobMetadata.start({ blobId });
530
+ return attachment;
531
+ },
532
+ async get() {
533
+ return (await storage.getAttachments(recordType, recordId, attachmentName))[0]?.blob ?? null;
534
+ },
535
+ async attached() {
536
+ return await this.get() !== null;
537
+ },
538
+ async url(options) {
539
+ const blob = await this.get();
540
+ if (!blob) return null;
541
+ return storage.getSignedUrl(blob.id, options);
542
+ },
543
+ async publicUrl() {
544
+ const blob = await this.get();
545
+ if (!blob) return null;
546
+ return storage.getPublicUrl(blob.id);
547
+ },
548
+ async metadata() {
549
+ return (await this.get())?.metadata ?? null;
550
+ },
551
+ async analyzed() {
552
+ return (await this.metadata())?.analyzed === true;
553
+ },
554
+ async representable() {
555
+ const blob = await this.get();
556
+ if (!blob?.contentType) return false;
557
+ return [
558
+ "image/",
559
+ "video/",
560
+ "application/pdf"
561
+ ].some((t) => blob.contentType?.startsWith(t));
562
+ },
563
+ async variant(transformations, expiresIn = 3600) {
564
+ const blob = await this.get();
565
+ if (!blob) return null;
566
+ const existing = await storage.getVariant(blob.id, transformations);
567
+ if (existing) return storage.getSignedUrl(existing.id, { expiresIn });
568
+ await generateImageVariant.start({
569
+ blobId: blob.id,
570
+ transformations
571
+ });
572
+ return null;
573
+ },
574
+ async preview(expiresIn = 3600, timeInSeconds = 1) {
575
+ const blob = await this.get();
576
+ if (!blob) return null;
577
+ const existing = await storage.getVariant(blob.id, { preview: true });
578
+ if (existing) return storage.getSignedUrl(existing.id, { expiresIn });
579
+ await generatePreview.start({
580
+ blobId: blob.id,
581
+ timeInSeconds
582
+ });
583
+ return null;
584
+ },
585
+ async detach() {
586
+ return await storage.deleteAttachments(recordType, recordId, attachmentName) > 0;
587
+ },
588
+ async purge() {
589
+ const results = await storage.getAttachments(recordType, recordId, attachmentName);
590
+ if (results.length === 0) return false;
591
+ const blob = results[0].blob;
592
+ if (!blob) return false;
593
+ await storage.deleteAttachments(recordType, recordId, attachmentName);
594
+ await storage.deleteBlob(blob.id);
595
+ return true;
596
+ },
597
+ async purgeLater() {
598
+ const results = await storage.getAttachments(recordType, recordId, attachmentName);
599
+ if (results.length === 0) return false;
600
+ await purgeAttachment.start({ attachmentIds: [results[0].id] });
601
+ return true;
602
+ },
603
+ async download() {
604
+ const blob = await this.get();
605
+ if (!blob) return null;
606
+ return storage.downloadBlob(blob.id);
607
+ },
608
+ async open(callback) {
609
+ const blob = await this.get();
610
+ if (!blob) return null;
611
+ const buffer = await storage.downloadBlob(blob.id);
612
+ if (!buffer) return null;
613
+ const tempDir = await mkdtemp(join(tmpdir(), "attachment-"));
614
+ const tempPath = join(tempDir, blob.filename);
615
+ try {
616
+ await writeFile(tempPath, buffer);
617
+ return await callback(tempPath);
618
+ } finally {
619
+ await rm(tempDir, {
620
+ recursive: true,
621
+ force: true
622
+ });
623
+ }
624
+ },
625
+ async representation(transformations, expiresIn = 3600) {
626
+ const blob = await this.get();
627
+ if (!blob) return null;
628
+ if (blob.contentType?.startsWith("image/")) return this.variant(transformations, expiresIn);
629
+ return this.preview(expiresIn);
630
+ },
631
+ async byteSize() {
632
+ return (await this.get())?.byteSize ?? null;
633
+ },
634
+ async contentType() {
635
+ return (await this.get())?.contentType ?? null;
636
+ },
637
+ async filename() {
638
+ return (await this.get())?.filename ?? null;
639
+ },
640
+ async signedId(expiresIn = 3600) {
641
+ const blob = await this.get();
642
+ if (!blob) return null;
643
+ return storage.signedId(blob.id, expiresIn);
644
+ }
645
+ };
646
+ }
647
+ /**
648
+ * Get a multiple attachment handle (one-to-many relationship).
649
+ * Similar to Rails' `has_many_attached :images`.
650
+ *
651
+ * Algorithm:
652
+ * Returns an object with methods to manage multiple attachments:
653
+ * - attach(): Add blobs (doesn't replace existing)
654
+ * - list(): Get all attached blobs
655
+ * - urls(): Get signed URLs for all
656
+ * - variants(): Get/generate variants for all
657
+ * - purge(): Delete all or specific attachment
658
+ *
659
+ * @template TTable - The table name (must be in TTableNames)
660
+ */
661
+ many(recordType, recordId, name) {
662
+ const storage = this;
663
+ const attachmentName = name;
664
+ return {
665
+ async attach(blobIds) {
666
+ const ids = Array.isArray(blobIds) ? blobIds : [blobIds];
667
+ const blobs = await Promise.all(ids.map((id) => storage.getBlob(id)));
668
+ const missing = ids.filter((_, i) => !blobs[i]);
669
+ if (missing.length > 0) throw new Error(`Blobs not found: ${missing.join(", ")}`);
670
+ const attachments = await Promise.all(ids.map((id) => storage.createAttachment(recordType, recordId, id, attachmentName)));
671
+ await Promise.all(ids.map((id) => extractBlobMetadata.start({ blobId: id })));
672
+ return attachments;
673
+ },
674
+ async list() {
675
+ return (await storage.getAttachments(recordType, recordId, attachmentName)).map((r) => r.blob).filter((b) => b !== null);
676
+ },
677
+ async count() {
678
+ return (await this.list()).length;
679
+ },
680
+ async urls(options) {
681
+ const blobs = await this.list();
682
+ return (await Promise.all(blobs.map((b) => storage.getSignedUrl(b.id, options)))).filter((u) => u !== null);
683
+ },
684
+ async publicUrls() {
685
+ const blobs = await this.list();
686
+ return Promise.all(blobs.map((b) => storage.getPublicUrl(b.id)));
687
+ },
688
+ async metadata() {
689
+ return (await this.list()).map((b) => b.metadata ?? null);
690
+ },
691
+ async analyzed() {
692
+ return (await this.metadata()).map((m) => m?.analyzed === true);
693
+ },
694
+ async representable() {
695
+ return (await this.list()).map((b) => {
696
+ if (!b.contentType) return false;
697
+ return [
698
+ "image/",
699
+ "video/",
700
+ "application/pdf"
701
+ ].some((t) => b.contentType?.startsWith(t));
702
+ });
703
+ },
704
+ async variants(transformations, expiresIn = 3600) {
705
+ const blobs = await this.list();
706
+ return Promise.all(blobs.map(async (blob) => {
707
+ const existing = await storage.getVariant(blob.id, transformations);
708
+ if (existing) return storage.getSignedUrl(existing.id, { expiresIn });
709
+ await generateImageVariant.start({
710
+ blobId: blob.id,
711
+ transformations
712
+ });
713
+ return null;
714
+ }));
715
+ },
716
+ async previews(expiresIn = 3600, timeInSeconds = 1) {
717
+ const blobs = await this.list();
718
+ return Promise.all(blobs.map(async (blob) => {
719
+ const existing = await storage.getVariant(blob.id, { preview: true });
720
+ if (existing) return storage.getSignedUrl(existing.id, { expiresIn });
721
+ await generatePreview.start({
722
+ blobId: blob.id,
723
+ timeInSeconds
724
+ });
725
+ return null;
726
+ }));
727
+ },
728
+ async detach(blobId) {
729
+ if (blobId) {
730
+ const attachment = (await storage.getAttachments(recordType, recordId, attachmentName)).find((r) => r.blob?.id === blobId);
731
+ if (attachment) {
732
+ await storage.deleteAttachment(attachment.id);
733
+ return 1;
734
+ }
735
+ return 0;
736
+ }
737
+ return storage.deleteAttachments(recordType, recordId, attachmentName);
738
+ },
739
+ async purge(blobId) {
740
+ const results = await storage.getAttachments(recordType, recordId, attachmentName);
741
+ if (blobId) {
742
+ const attachment = results.find((r) => r.blob?.id === blobId);
743
+ if (!attachment) return 0;
744
+ await storage.deleteAttachment(attachment.id);
745
+ await storage.deleteBlob(blobId);
746
+ return 1;
747
+ }
748
+ let count = 0;
749
+ for (const r of results) {
750
+ await storage.deleteAttachment(r.id);
751
+ if (r.blob) {
752
+ await storage.deleteBlob(r.blob.id);
753
+ count++;
754
+ }
755
+ }
756
+ return count;
757
+ },
758
+ async purgeLater(blobId) {
759
+ const results = await storage.getAttachments(recordType, recordId, attachmentName);
760
+ if (blobId) {
761
+ const attachment = results.find((r) => r.blob?.id === blobId);
762
+ if (!attachment) return 0;
763
+ await purgeAttachment.start({ attachmentIds: [attachment.id] });
764
+ return 1;
765
+ }
766
+ if (results.length > 0) await purgeAttachment.start({ attachmentIds: results.map((r) => r.id) });
767
+ return results.length;
768
+ },
769
+ async download() {
770
+ const blobs = await this.list();
771
+ const buffers = [];
772
+ for (const blob of blobs) {
773
+ const buffer = await storage.downloadBlob(blob.id);
774
+ if (buffer) buffers.push(buffer);
775
+ }
776
+ return buffers;
777
+ },
778
+ async open(callback) {
779
+ const blobs = await this.list();
780
+ const results = [];
781
+ for (const blob of blobs) {
782
+ const buffer = await storage.downloadBlob(blob.id);
783
+ if (!buffer) continue;
784
+ const tempDir = await mkdtemp(join(tmpdir(), "attachment-"));
785
+ const tempPath = join(tempDir, blob.filename);
786
+ try {
787
+ await writeFile(tempPath, buffer);
788
+ results.push(await callback(tempPath, blob));
789
+ } finally {
790
+ await rm(tempDir, {
791
+ recursive: true,
792
+ force: true
793
+ });
794
+ }
795
+ }
796
+ return results;
797
+ },
798
+ async representations(transformations, expiresIn = 3600) {
799
+ const blobs = await this.list();
800
+ return Promise.all(blobs.map(async (blob) => {
801
+ if (blob.contentType?.startsWith("image/")) {
802
+ const existing = await storage.getVariant(blob.id, transformations);
803
+ if (existing) return storage.getSignedUrl(existing.id, { expiresIn });
804
+ await generateImageVariant.start({
805
+ blobId: blob.id,
806
+ transformations
807
+ });
808
+ return null;
809
+ }
810
+ const preview = await storage.getVariant(blob.id, { preview: true });
811
+ if (preview) return storage.getSignedUrl(preview.id, { expiresIn });
812
+ await generatePreview.start({ blobId: blob.id });
813
+ return null;
814
+ }));
815
+ },
816
+ async byteSizes() {
817
+ return (await this.list()).map((b) => b.byteSize);
818
+ },
819
+ async contentTypes() {
820
+ return (await this.list()).map((b) => b.contentType);
821
+ },
822
+ async filenames() {
823
+ return (await this.list()).map((b) => b.filename);
824
+ },
825
+ async signedIds(expiresIn = 3600) {
826
+ return (await this.list()).map((b) => storage.signedId(b.id, expiresIn));
827
+ }
828
+ };
829
+ }
830
+ };
831
+
832
+ //#endregion
833
+ export { StorageService, defineS3Disk, defineStorage };