{"_id":"@ballistix.digital/attachment-storage","_rev":"6-2b497be22646822ff14d1146a86d5772","name":"@ballistix.digital/attachment-storage","dist-tags":{"latest":"0.5.0"},"versions":{"0.1.0":{"name":"@ballistix.digital/attachment-storage","version":"0.1.0","license":"MIT","_id":"@ballistix.digital/attachment-storage@0.1.0","maintainers":[{"name":"brecht-p7s-ballistix","email":"brecht.pallemans@ballistix.digital"},{"name":"maartenraes","email":"maarten@ballistix.digital"},{"name":"jmostaer","email":"jens@ballistix.digital"},{"name":"aaron-ballistix","email":"aaron@ballistix.digital"},{"name":"aude-ballistix","email":"aude@ballistix.digital"}],"homepage":"https://github.com/layeredprints/attachment-storage#readme","bugs":{"url":"https://github.com/layeredprints/attachment-storage/issues"},"dist":{"shasum":"b35cb8a2e42d496199a7b9dcf91be24916e9fe33","tarball":"https://registry.npmjs.org/@ballistix.digital/attachment-storage/-/attachment-storage-0.1.0.tgz","fileCount":53,"integrity":"sha512-FQzgOVBNZ8ZYGtiTcQktnbYHsNUN6i/VoortwAGMwiq9giI+H+lYIjx2S70zNT1tEf7FppWOTujwqkv+V/+qfg==","signatures":[{"sig":"MEYCIQDGxyd8yojQaLhF9d++xDve/nAbTQaJ8DxLyHdNlOyVCgIhAJJk5x4NptQWZRTUegmxCPwwX9kXEufgG18g73gLCSL3","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":76612},"main":"dist/index.js","_from":"file:/Users/jens/Documents/Projects/BallistiX/attachment-storage/dist-tarballs/ballistix.digital-attachment-storage-0.1.0.tgz","types":"dist/index.d.ts","engines":{"node":">=22.12"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./gcp":{"types":"./dist/gcp/index.d.ts","default":"./dist/gcp/index.js"},"./azure":{"types":"./dist/azure/index.d.ts","default":"./dist/azure/index.js"},"./testing":{"types":"./dist/testing/index.d.ts","default":"./dist/testing/index.js"},"./processable":{"types":"./dist/processable/index.d.ts","default":"./dist/processable/index.js"}},"scripts":{"lint":"eslint \"src/**/*.ts\" \"test/**/*.ts\"","test":"jest --selectProjects unit integration --runInBand","build":"rm -rf dist && tsc -p tsconfig.build.json","format":"prettier --write \"src/**/*.ts\" \"test/**/*.ts\"","test:unit":"jest --selectProjects unit","test:watch":"jest --selectProjects unit --watch","test:coverage":"jest --selectProjects unit integration --runInBand --coverage","test:packaging":"npm run build && jest --selectProjects packaging","test:integration":"jest --selectProjects integration --runInBand"},"_npmUser":{"name":"jmostaer","email":"jens@ballistix.digital"},"_resolved":"/Users/jens/Documents/Projects/BallistiX/attachment-storage/dist-tarballs/ballistix.digital-attachment-storage-0.1.0.tgz","_integrity":"sha512-FQzgOVBNZ8ZYGtiTcQktnbYHsNUN6i/VoortwAGMwiq9giI+H+lYIjx2S70zNT1tEf7FppWOTujwqkv+V/+qfg==","repository":{"url":"git+https://github.com/layeredprints/attachment-storage.git","type":"git"},"_npmVersion":"11.12.1","description":"NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine.","directories":{},"_nodeVersion":"24.15.0","publishConfig":{"access":"public"},"typesVersions":{"*":{"gcp":["dist/gcp/index.d.ts"],"azure":["dist/azure/index.d.ts"],"testing":["dist/testing/index.d.ts"],"processable":["dist/processable/index.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"pg":"^8.16.0","jest":"^30.0.0","eslint":"^8.57.1","ts-jest":"^29.4.0","typeorm":"^1.1.0","prettier":"^3.6.0","@types/pg":"^8.15.0","typescript":"^5.9.0","@types/jest":"^30.0.0","@types/node":"^22.15.0","@nestjs/core":"^11.1.6","@nestjs/common":"^11.1.6","@nestjs/swagger":"^11.4.7","@nestjs/testing":"^11.1.6","reflect-metadata":"^0.2.2","@azure/storage-blob":"^12.33.0","@google-cloud/storage":"^7.22.0","eslint-config-prettier":"^9.1.0","eslint-plugin-prettier":"^5.5.0","@typescript-eslint/parser":"^8.44.0","@typescript-eslint/eslint-plugin":"^8.44.0","@ballistix.digital/task-scheduler":"^0.4.0","@ballistix.digital/exception-types":"^0.1.0","@ballistix.digital/exception-mapper":"^0.1.0"},"peerDependencies":{"typeorm":">=0.3.0 <2.0.0","@nestjs/core":"^11","@nestjs/common":"^11","reflect-metadata":"^0.2","@azure/storage-blob":"^12.33","@google-cloud/storage":"^7","@ballistix.digital/task-scheduler":"^0.4.0","@ballistix.digital/exception-types":"^0.1.0"},"peerDependenciesMeta":{"@azure/storage-blob":{"optional":true},"@google-cloud/storage":{"optional":true},"@ballistix.digital/task-scheduler":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/attachment-storage_0.1.0_1789134802039_0.5799037397710969","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@ballistix.digital/attachment-storage","version":"0.2.0","license":"MIT","_id":"@ballistix.digital/attachment-storage@0.2.0","maintainers":[{"name":"brecht-p7s-ballistix","email":"brecht.pallemans@ballistix.digital"},{"name":"maartenraes","email":"maarten@ballistix.digital"},{"name":"jmostaer","email":"jens@ballistix.digital"},{"name":"aaron-ballistix","email":"aaron@ballistix.digital"},{"name":"aude-ballistix","email":"aude@ballistix.digital"}],"homepage":"https://github.com/layeredprints/attachment-storage#readme","bugs":{"url":"https://github.com/layeredprints/attachment-storage/issues"},"dist":{"shasum":"062e89aed5a5ec7668037b957ad9f403e053831d","tarball":"https://registry.npmjs.org/@ballistix.digital/attachment-storage/-/attachment-storage-0.2.0.tgz","fileCount":53,"integrity":"sha512-fqh3P52YVRqnKyGfYC1LhFry5hobD0djei3rwkk6MbNMNVSnIYdEAtbu/5OCK66eLhgB1HMn8XhMAeP41L9M8Q==","signatures":[{"sig":"MEUCIQChsZsnT4GE81r7WtmjQfSCvZdt1F7zz31Z2odVC36oYwIgA9yhn9AmWWc2o3tT6zY3U5TL+dEFYB9wv71xwsO08qw=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEYCIQDHnmbm4ep/szAnVU337pKunITj0QyCN6r30pPxe3XyQgIhAKPMxYS6bZuTDgy2a2tylYFqxNc5itMl3cAZdFKPhyzg","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":76612},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=22.12"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./gcp":{"types":"./dist/gcp/index.d.ts","default":"./dist/gcp/index.js"},"./azure":{"types":"./dist/azure/index.d.ts","default":"./dist/azure/index.js"},"./testing":{"types":"./dist/testing/index.d.ts","default":"./dist/testing/index.js"},"./processable":{"types":"./dist/processable/index.d.ts","default":"./dist/processable/index.js"}},"gitHead":"abbafe602169f5769f08e6b3a67752d8034c0fd4","scripts":{"lint":"eslint \"src/**/*.ts\" \"test/**/*.ts\"","test":"jest --selectProjects unit integration --runInBand","build":"rm -rf dist && tsc -p tsconfig.build.json","format":"prettier --write \"src/**/*.ts\" \"test/**/*.ts\"","test:unit":"jest --selectProjects unit","test:watch":"jest --selectProjects unit --watch","test:coverage":"jest --selectProjects unit integration --runInBand --coverage","test:packaging":"npm run build && jest --selectProjects packaging","test:integration":"jest --selectProjects integration --runInBand"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1ea9ecef-5a03-4656-9a18-e1d0ca47c1bf"}},"repository":{"url":"git+https://github.com/layeredprints/attachment-storage.git","type":"git"},"_npmVersion":"12.0.2","description":"NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"typesVersions":{"*":{"gcp":["dist/gcp/index.d.ts"],"azure":["dist/azure/index.d.ts"],"testing":["dist/testing/index.d.ts"],"processable":["dist/processable/index.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"pg":"^8.16.0","jest":"^30.0.0","eslint":"^8.57.1","ts-jest":"^29.4.0","typeorm":"^1.1.0","prettier":"^3.6.0","@types/pg":"^8.15.0","typescript":"^5.9.0","@types/jest":"^30.0.0","@types/node":"^22.15.0","@nestjs/core":"^11.1.6","@nestjs/common":"^11.1.6","@nestjs/swagger":"^11.4.7","@nestjs/testing":"^11.1.6","reflect-metadata":"^0.2.2","@azure/storage-blob":"^12.33.0","@google-cloud/storage":"^7.22.0","eslint-config-prettier":"^9.1.0","eslint-plugin-prettier":"^5.5.0","@typescript-eslint/parser":"^8.44.0","@typescript-eslint/eslint-plugin":"^8.44.0","@ballistix.digital/task-scheduler":"^0.4.0","@ballistix.digital/exception-types":"^0.1.0","@ballistix.digital/exception-mapper":"^0.1.0"},"peerDependencies":{"typeorm":">=0.3.0 <2.0.0","@nestjs/core":"^11","@nestjs/common":"^11","reflect-metadata":"^0.2","@azure/storage-blob":"^12.33","@google-cloud/storage":"^7","@ballistix.digital/task-scheduler":"^0.4.0","@ballistix.digital/exception-types":"^0.1.0"},"peerDependenciesMeta":{"@azure/storage-blob":{"optional":true},"@google-cloud/storage":{"optional":true},"@ballistix.digital/task-scheduler":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/attachment-storage_0.2.0_1789135233980_0.23346288205506482","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@ballistix.digital/attachment-storage","version":"0.3.0","license":"MIT","_id":"@ballistix.digital/attachment-storage@0.3.0","maintainers":[{"name":"brecht-p7s-ballistix","email":"brecht.pallemans@ballistix.digital"},{"name":"maartenraes","email":"maarten@ballistix.digital"},{"name":"jmostaer","email":"jens@ballistix.digital"},{"name":"aaron-ballistix","email":"aaron@ballistix.digital"},{"name":"aude-ballistix","email":"aude@ballistix.digital"}],"homepage":"https://github.com/layeredprints/attachment-storage#readme","bugs":{"url":"https://github.com/layeredprints/attachment-storage/issues"},"dist":{"shasum":"e516d3a9a8b6506dfc367e42dbc5eb4e947a0c2c","tarball":"https://registry.npmjs.org/@ballistix.digital/attachment-storage/-/attachment-storage-0.3.0.tgz","fileCount":55,"integrity":"sha512-G9rDtrFRBuft99jrCLgMLIejzwMlJ6gHzetHzlbhqk1Is/BXqw5/LGHduN88MjAizTyeRZ0wsen3mzlXCEv89Q==","signatures":[{"sig":"MEUCIQDVyAazsxs+a97xG7z9i5J0k4ietJL9sxwvDqSY+JBmpQIgBv4FOXF9svjfFlHBFOUReWByEQwDYyrJO2c4EbxyJZk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEYCIQDXzavLYEWn/7CeHuzpOcCRTeuFOzFs/kL0jb/Bui5EDAIhAMmvYNSkX+5EzNce5l1kbYJwp9NaZ0vufX9x9KLVqQI8","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":79442},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=22.12"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./gcp":{"types":"./dist/gcp/index.d.ts","default":"./dist/gcp/index.js"},"./azure":{"types":"./dist/azure/index.d.ts","default":"./dist/azure/index.js"},"./types":{"types":"./dist/types/index.d.ts","default":"./dist/types/index.js"},"./dist/*":{"types":"./dist/*.d.ts","default":"./dist/*.js"},"./testing":{"types":"./dist/testing/index.d.ts","default":"./dist/testing/index.js"},"./processable":{"types":"./dist/processable/index.d.ts","default":"./dist/processable/index.js"}},"gitHead":"c6c957d71fd9a919ff4e15ae1de2fb057126f4b2","scripts":{"lint":"eslint \"src/**/*.ts\" \"test/**/*.ts\"","test":"jest --selectProjects unit integration --runInBand","build":"rm -rf dist && tsc -p tsconfig.build.json","format":"prettier --write \"src/**/*.ts\" \"test/**/*.ts\"","test:unit":"jest --selectProjects unit","test:watch":"jest --selectProjects unit --watch","test:coverage":"jest --selectProjects unit integration --runInBand --coverage","test:packaging":"npm run build && jest --selectProjects packaging","test:integration":"jest --selectProjects integration --runInBand"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1ea9ecef-5a03-4656-9a18-e1d0ca47c1bf"}},"repository":{"url":"git+https://github.com/layeredprints/attachment-storage.git","type":"git"},"_npmVersion":"12.0.2","description":"NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"typesVersions":{"*":{"gcp":["dist/gcp/index.d.ts"],"azure":["dist/azure/index.d.ts"],"types":["dist/types/index.d.ts"],"testing":["dist/testing/index.d.ts"],"processable":["dist/processable/index.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"pg":"^8.16.0","jest":"^30.0.0","eslint":"^8.57.1","ts-jest":"^29.4.0","typeorm":"^1.1.0","prettier":"^3.6.0","@types/pg":"^8.15.0","typescript":"^5.9.0","@types/jest":"^30.0.0","@types/node":"^22.15.0","@nestjs/core":"^11.1.6","@nestjs/common":"^11.1.6","@nestjs/swagger":"^11.4.7","@nestjs/testing":"^11.1.6","reflect-metadata":"^0.2.2","@azure/storage-blob":"^12.33.0","@google-cloud/storage":"^7.22.0","eslint-config-prettier":"^9.1.0","eslint-plugin-prettier":"^5.5.0","@typescript-eslint/parser":"^8.44.0","@typescript-eslint/eslint-plugin":"^8.44.0","@ballistix.digital/task-scheduler":"^0.4.0","@ballistix.digital/exception-types":"^0.1.0","@ballistix.digital/exception-mapper":"^0.1.0"},"peerDependencies":{"typeorm":">=0.3.0 <2.0.0","@nestjs/core":"^11 || ^12","@nestjs/common":"^11 || ^12","reflect-metadata":"^0.2","@azure/storage-blob":"^12.33","@google-cloud/storage":"^7","@ballistix.digital/task-scheduler":"^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0","@ballistix.digital/exception-types":"^0.1.0 || ^0.2.0"},"peerDependenciesMeta":{"@azure/storage-blob":{"optional":true},"@google-cloud/storage":{"optional":true},"@ballistix.digital/task-scheduler":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/attachment-storage_0.3.0_1789481688033_0.42991616596724747","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"name":"@ballistix.digital/attachment-storage","version":"0.4.0","license":"MIT","_id":"@ballistix.digital/attachment-storage@0.4.0","maintainers":[{"name":"brecht-p7s-ballistix","email":"brecht.pallemans@ballistix.digital"},{"name":"maartenraes","email":"maarten@ballistix.digital"},{"name":"jmostaer","email":"jens@ballistix.digital"},{"name":"aaron-ballistix","email":"aaron@ballistix.digital"},{"name":"aude-ballistix","email":"aude@ballistix.digital"}],"homepage":"https://github.com/layeredprints/attachment-storage#readme","bugs":{"url":"https://github.com/layeredprints/attachment-storage/issues"},"dist":{"shasum":"82b94d17710fd0e7310e930acd76c36327fbada1","tarball":"https://registry.npmjs.org/@ballistix.digital/attachment-storage/-/attachment-storage-0.4.0.tgz","fileCount":55,"integrity":"sha512-ccp2DOPC2HM23n2Pf8qPWDEwwfu+V3I8Ur779ozw/WeUkFJkTQfEd8hjtaujw5iFM4LpPm+yuhHEHDv61JHJ3g==","signatures":[{"sig":"MEQCIG2Wmb7YDXxYpyXNfN16XlQBRIbN6TszDU5QKzFlvJulAiARy/9F8kRzvTKf54mg7pjftP6HHPcaZ8kzy18aUgd3Iw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEYCIQD1z7w+eO8iOv2G4yGTsDfJfNCPqjtJWZXeZWDQl5/eYAIhAOvJCVxbQvVUO40cvAhwvbFuIlVNXR7bwfEekEVMr6sk","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":80738},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=22.12"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./gcp":{"types":"./dist/gcp/index.d.ts","default":"./dist/gcp/index.js"},"./azure":{"types":"./dist/azure/index.d.ts","default":"./dist/azure/index.js"},"./types":{"types":"./dist/types/index.d.ts","default":"./dist/types/index.js"},"./dist/*":{"types":"./dist/*.d.ts","default":"./dist/*.js"},"./testing":{"types":"./dist/testing/index.d.ts","default":"./dist/testing/index.js"},"./processable":{"types":"./dist/processable/index.d.ts","default":"./dist/processable/index.js"}},"gitHead":"dc06bc4274d334dd41de49256125e574c8b1b131","scripts":{"lint":"eslint \"src/**/*.ts\" \"test/**/*.ts\"","test":"jest --selectProjects unit integration --runInBand","build":"rm -rf dist && tsc -p tsconfig.build.json","format":"prettier --write \"src/**/*.ts\" \"test/**/*.ts\"","test:unit":"jest --selectProjects unit","test:watch":"jest --selectProjects unit --watch","test:coverage":"jest --selectProjects unit integration --runInBand --coverage","test:packaging":"npm run build && jest --selectProjects packaging","test:integration":"jest --selectProjects integration --runInBand"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1ea9ecef-5a03-4656-9a18-e1d0ca47c1bf"}},"repository":{"url":"git+https://github.com/layeredprints/attachment-storage.git","type":"git"},"_npmVersion":"12.0.2","description":"NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"typesVersions":{"*":{"gcp":["dist/gcp/index.d.ts"],"azure":["dist/azure/index.d.ts"],"types":["dist/types/index.d.ts"],"testing":["dist/testing/index.d.ts"],"processable":["dist/processable/index.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"pg":"^8.16.0","jest":"^30.0.0","eslint":"^8.57.1","ts-jest":"^29.4.0","typeorm":"^1.1.0","prettier":"^3.6.0","@types/pg":"^8.15.0","typescript":"^5.9.0","@types/jest":"^30.0.0","@types/node":"^22.15.0","@nestjs/core":"^11.1.6","@nestjs/common":"^11.1.6","@nestjs/swagger":"^11.4.7","@nestjs/testing":"^11.1.6","reflect-metadata":"^0.2.2","@azure/storage-blob":"^12.33.0","@google-cloud/storage":"^7.22.0","eslint-config-prettier":"^9.1.0","eslint-plugin-prettier":"^5.5.0","@typescript-eslint/parser":"^8.44.0","@typescript-eslint/eslint-plugin":"^8.44.0","@ballistix.digital/task-scheduler":"^0.4.0","@ballistix.digital/exception-types":"^0.1.0","@ballistix.digital/exception-mapper":"^0.1.0"},"peerDependencies":{"typeorm":">=0.3.0 <2.0.0","@nestjs/core":"^11 || ^12","@nestjs/common":"^11 || ^12","reflect-metadata":"^0.2","@azure/storage-blob":"^12.33","@google-cloud/storage":"^7","@ballistix.digital/task-scheduler":"^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0","@ballistix.digital/exception-types":"^0.1.0 || ^0.2.0"},"peerDependenciesMeta":{"typeorm":{"optional":true},"@nestjs/core":{"optional":true},"@nestjs/common":{"optional":true},"reflect-metadata":{"optional":true},"@azure/storage-blob":{"optional":true},"@google-cloud/storage":{"optional":true},"@ballistix.digital/task-scheduler":{"optional":true},"@ballistix.digital/exception-types":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/attachment-storage_0.4.0_1789651583111_0.730990917692075","host":"s3://npm-registry-packages-npm-production"}},"0.5.0":{"name":"@ballistix.digital/attachment-storage","version":"0.5.0","license":"MIT","_id":"@ballistix.digital/attachment-storage@0.5.0","maintainers":[{"name":"brecht-p7s-ballistix","email":"brecht.pallemans@ballistix.digital"},{"name":"maartenraes","email":"maarten@ballistix.digital"},{"name":"jmostaer","email":"jens@ballistix.digital"},{"name":"aaron-ballistix","email":"aaron@ballistix.digital"},{"name":"aude-ballistix","email":"aude@ballistix.digital"}],"homepage":"https://github.com/layeredprints/attachment-storage#readme","bugs":{"url":"https://github.com/layeredprints/attachment-storage/issues"},"dist":{"shasum":"36bf73147be1edd00cf264097497c0fa8e00ad06","tarball":"https://registry.npmjs.org/@ballistix.digital/attachment-storage/-/attachment-storage-0.5.0.tgz","fileCount":57,"integrity":"sha512-j4fQnM+uwtRQjQ4JAdPjz/JidTybjKPW4+uGOEl1XUiS6tH3gSf9nkRxqePMA0DTuluHV0CYQww8ntXq01GXUQ==","signatures":[{"sig":"MEUCIQD+VwQv2PJvxA8yd1uKUWGQiq7MdfLslnVRcULy5BqougIgGGZPXQ+HtkyENJ2DZXu9Z3rO6EtG5GwaGQWTpjwrgMk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEQCIAFMofEB43msRnyk2A27fbpaPxvpl6n+88n3T/bZ//LnAiAKofFVk2l4HX4HmrYGp78MNRyyVrtJvIDATxGCQm2kvA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":84331},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">=22.12"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./gcp":{"types":"./dist/gcp/index.d.ts","default":"./dist/gcp/index.js"},"./azure":{"types":"./dist/azure/index.d.ts","default":"./dist/azure/index.js"},"./types":{"types":"./dist/types/index.d.ts","default":"./dist/types/index.js"},"./dist/*":{"types":"./dist/*.d.ts","default":"./dist/*.js"},"./testing":{"types":"./dist/testing/index.d.ts","default":"./dist/testing/index.js"},"./processable":{"types":"./dist/processable/index.d.ts","default":"./dist/processable/index.js"}},"gitHead":"ad73c930c3ffb86d7d1418e1e34fb91875f4c943","scripts":{"lint":"eslint \"src/**/*.ts\" \"test/**/*.ts\"","test":"jest --selectProjects unit integration --runInBand","build":"rm -rf dist && tsc -p tsconfig.build.json","format":"prettier --write \"src/**/*.ts\" \"test/**/*.ts\"","test:unit":"jest --selectProjects unit","test:watch":"jest --selectProjects unit --watch","test:coverage":"jest --selectProjects unit integration --runInBand --coverage","test:packaging":"npm run build && jest --selectProjects packaging","test:integration":"jest --selectProjects integration --runInBand"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1ea9ecef-5a03-4656-9a18-e1d0ca47c1bf"}},"repository":{"url":"git+https://github.com/layeredprints/attachment-storage.git","type":"git"},"_npmVersion":"12.0.2","description":"NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"typesVersions":{"*":{"gcp":["dist/gcp/index.d.ts"],"azure":["dist/azure/index.d.ts"],"types":["dist/types/index.d.ts"],"testing":["dist/testing/index.d.ts"],"processable":["dist/processable/index.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"pg":"^8.16.0","jest":"^30.0.0","eslint":"^8.57.1","ts-jest":"^29.4.0","typeorm":"^1.1.0","prettier":"^3.6.0","@types/pg":"^8.15.0","typescript":"^5.9.0","@types/jest":"^30.0.0","@types/node":"^22.15.0","@nestjs/core":"^11.1.6","@nestjs/common":"^11.1.6","@nestjs/swagger":"^11.4.7","@nestjs/testing":"^11.1.6","reflect-metadata":"^0.2.2","@azure/storage-blob":"^12.33.0","@google-cloud/storage":"^7.22.0","eslint-config-prettier":"^9.1.0","eslint-plugin-prettier":"^5.5.0","@typescript-eslint/parser":"^8.44.0","@typescript-eslint/eslint-plugin":"^8.44.0","@ballistix.digital/task-scheduler":"^0.9.0","@ballistix.digital/exception-types":"^0.4.0","@ballistix.digital/exception-mapper":"^0.4.0"},"peerDependencies":{"typeorm":">=0.3.0 <2.0.0","@nestjs/core":"^11 || ^12","@nestjs/common":"^11 || ^12","reflect-metadata":"^0.2","@azure/storage-blob":"^12.33","@google-cloud/storage":"^7","@ballistix.digital/task-scheduler":">=0.9.0 <1.0.0","@ballistix.digital/exception-types":">=0.4.0 <1.0.0"},"peerDependenciesMeta":{"typeorm":{"optional":true},"@nestjs/core":{"optional":true},"@nestjs/common":{"optional":true},"reflect-metadata":{"optional":true},"@azure/storage-blob":{"optional":true},"@google-cloud/storage":{"optional":true},"@ballistix.digital/task-scheduler":{"optional":true},"@ballistix.digital/exception-types":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/attachment-storage_0.5.0_1789718158339_0.06415274175382679","host":"s3://npm-registry-packages-npm-production"}}},"time":{"created":"2026-09-11T13:53:21.623Z","modified":"2026-10-01T12:25:23.186Z","0.1.0":"2026-09-11T13:53:22.172Z","0.2.0":"2026-09-11T14:00:34.086Z","0.3.0":"2026-09-15T14:14:48.120Z","0.4.0":"2026-09-17T13:26:23.195Z","0.5.0":"2026-09-18T07:55:58.446Z"},"bugs":{"url":"https://github.com/layeredprints/attachment-storage/issues"},"license":"MIT","homepage":"https://github.com/layeredprints/attachment-storage#readme","repository":{"url":"git+https://github.com/layeredprints/attachment-storage.git","type":"git"},"description":"NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine.","maintainers":[{"email":"brecht.pallemans@ballistix.digital","name":"brecht-p7s-ballistix"},{"email":"maarten@ballistix.digital","name":"maartenraes"},{"email":"jens@ballistix.digital","name":"jmostaer"},{"email":"aaron@ballistix.digital","name":"aaron-ballistix"},{"email":"aude@ballistix.digital","name":"aude-ballistix"},{"email":"lukas@ballistix.digital","name":"lukaskindt"}],"readme":"# @ballistix.digital/attachment-storage\n\nA NestJS module that gives an entity row an uploaded file behind it, with a\npluggable storage engine. An attachment is a database row with an upload\nstatus, and a storage engine holds the file itself under the row's storage\nid. Two engines ship, Azure and GCP, each behind its own entry, so an\napplication installs the SDK of the engine it uses.\n\nThis file is the consumer guide. The pages that describe how the package is\nbuilt live in the [Ballistix wiki](https://github.com/layeredprints/ballistix-okf-wiki/blob/main/general/libraries/attachment-storage/).\n\n## The boundary\n\nThe library owns the storage engines, the entity mixin and the upload\nlifecycle service. It owns no HTTP route, no DTO, no Context and no CRUD\nlogic, and it holds no state machine. Your application keeps its controllers,\nits `CrudService`, its DTOs, its Context and its xstate upload machine, and\nthe entry actions of that machine call the management service you extend from\nthis package.\n\n## The model\n\nThe `uploadStatus` column of the attachment is the lifecycle.\n\n```mermaid\nstateDiagram-v2\n    classDef waiting fill:#FFD700,stroke:#333,color:#000\n    classDef done fill:#90EE90,stroke:#333,color:#006400\n    classDef failed fill:#FFB6C1,stroke:#DC143C,color:#000\n\n    [*] --> UPLOADING: the consumer saves the attachment\n    UPLOADING --> READY: completeUpload(), the file is within the maximum\n    UPLOADING --> FAILED: failUpload(), or no file, or over the maximum\n    READY --> [*]\n    FAILED --> [*]\n\n    class UPLOADING waiting\n    class READY done\n    class FAILED failed\n```\n\nRead the two exits of `UPLOADING`. `completeUpload()` reads the file and lands\n`READY` with its size and its media type. Every other outcome lands `FAILED`\nwith no storage id.\n\nThe storage id is always `<id>/<name>`. The library derives it from the\nattachment, so no caller and no subclass chooses another layout.\n\n### One upload, step by step\n\n```mermaid\nsequenceDiagram\n    actor Client\n    box rgb(230,230,250) Consumer\n        participant Ctrl as Controller and CrudService\n        participant Machine as Upload machine\n    end\n    box rgb(144,238,144) Library\n        participant Mgmt as Management service\n    end\n    box rgb(255,228,181) Task scheduler\n        participant Queue as TaskRuntime\n    end\n    box rgb(211,211,211) Infrastructure\n        participant DB as Postgres\n        participant Storage as Storage\n    end\n\n    rect rgb(255,250,205)\n        Note over Ctrl,Storage: 1. create: the attachment and the presigned write URL\n        Client->>Ctrl: POST the attachment\n        Ctrl->>DB: INSERT the attachment, upload status UPLOADING\n        Ctrl->>Mgmt: prepareUpload(attachment, transaction)\n        Mgmt->>DB: UPDATE storageId to id/name\n        Mgmt->>Storage: generatePresignedWriteUrl(storageId, ttl)\n        Mgmt-->>Ctrl: the presigned write URL\n        Ctrl-->>Client: the attachment and the presigned write URL\n    end\n\n    rect rgb(224,255,224)\n        Note over Client,Storage: 2. the client writes the file itself\n        Client->>Storage: PUT the file to the presigned write URL\n    end\n\n    rect rgb(255,228,225)\n        Note over Ctrl,Storage: 3. complete: one transaction settles everything\n        Client->>Ctrl: PATCH upload status READY\n        Ctrl->>DB: UPDATE uploadStatus to READY\n        Ctrl->>Machine: transition UPLOADING to READY\n        Machine->>Mgmt: completeUpload(attachment, transaction)\n        Mgmt->>Storage: getFileProperties(storageId)\n        Mgmt->>DB: UPDATE fileSize, mimeType and uploadStatus READY\n        Mgmt->>DB: UPDATE processingStatus to PENDING\n        Mgmt->>Queue: enqueue(queue, id, transaction, dedupeKey)\n        Note over Mgmt,Queue: the READY row, the PENDING marker and the message commit together\n    end\n```\n\nLook at the third block. The file never travels through your API, and the two\nwrites of a processable attachment ride the transaction the request opened.\n\n## Install\n\n```bash\nnpm install @ballistix.digital/attachment-storage\n```\n\nThe package needs Node 22.12 or later. Every peer dependency is optional, so\nnpm installs none of them on its own: install what the entries you import need.\n\n| Package | Range | Needed by |\n| --- | --- | --- |\n| `@nestjs/common` | `^11 || ^12`, optional | every entry but `./types` |\n| `@nestjs/core` | `^11 || ^12`, optional | every entry but `./types` |\n| `typeorm` | `>=0.3.0 <2.0.0`, optional | every entry but `./types` |\n| `reflect-metadata` | `^0.2`, optional | every entry but `./types` |\n| `@ballistix.digital/exception-types` | `>=0.4.0 <1.0.0`, optional | every entry but `./types` |\n| `@ballistix.digital/task-scheduler` | `>=0.9.0 <1.0.0`, optional | `./processable` |\n| `@azure/storage-blob` | `^12.33`, optional | `./azure` |\n| `@google-cloud/storage` | `^7`, optional | `./gcp` |\n\nThe root entry needs Nest, TypeORM, `reflect-metadata` and the exception types.\nThe three SDK and scheduler peers sit behind their own entry, so an application\non Azure installs `@azure/storage-blob` alone, and an application that\nprocesses no attachment installs no task scheduler. The `./types` entry loads\nno peer at all, so a package that shares DTO types with a browser application\ndepends on this package and pulls in no framework. The package has no\ndependencies of its own. The two Ballistix ranges stay open up to `1.0.0`:\nevery later `0.x` release of a peer installs without a change here.\n\n## Register the module\n\n```ts\nimport {\n\tAttachmentStorageModule,\n\tAttachmentStorageModuleOptions,\n\tStorageEngine,\n} from '@ballistix.digital/attachment-storage';\nimport { AzureBlobStorageEngine } from '@ballistix.digital/attachment-storage/azure';\nimport { GcpCloudStorageEngine } from '@ballistix.digital/attachment-storage/gcp';\nimport { Module } from '@nestjs/common';\nimport { ConfigModule, ConfigService } from '@nestjs/config';\n\nconst buildEngine = (config: ConfigService): StorageEngine =>\n\tconfig.get('STORAGE_ENGINE') === 'gcp'\n\t\t? new GcpCloudStorageEngine({ bucketName: config.get('GCS_BUCKET') })\n\t\t: new AzureBlobStorageEngine({\n\t\t\t\tconnectionString: config.get('AZURE_STORAGE_CONNECTION_STRING'),\n\t\t\t\tcontainerName: config.get('AZURE_STORAGE_CONTAINER'),\n\t\t\t});\n\n@Module({\n\timports: [\n\t\tAttachmentStorageModule.forRootAsync({\n\t\t\timports: [ConfigModule],\n\t\t\tinject: [ConfigService],\n\t\t\tuseFactory: (config: ConfigService): AttachmentStorageModuleOptions => ({\n\t\t\t\tengine: buildEngine(config),\n\t\t\t\tmaxFileSize: Number(config.get('ATTACHMENT_MAX_FILE_SIZE')),\n\t\t\t\tpresignedUrlTtlSeconds: 900,\n\t\t\t}),\n\t\t}),\n\t],\n})\nexport class AppModule {}\n```\n\nOne config value picks the engine, and the application constructs it. Import\nthe engine you need from its own entry.\n`AttachmentStorageModule.forRoot(options)` takes the same options when nothing\nneeds injection.\n\n| Option | Purpose |\n| --- | --- |\n| `engine` | The storage engine instance every upload and download travels through. Required |\n| `maxFileSize` | The largest file an upload accepts, in bytes. Required |\n| `presignedUrlTtlSeconds` | How long a presigned URL stays valid, in seconds. Defaults to 900 |\n\nThe module reads no environment variable and no `ConfigService` of its own.\nEvery value arrives through these options.\n\nThe module is global. A management service in any feature module injects\n`AttachmentStorageRuntime` without a further import.\n`AttachmentStorageRuntime` is the one injectable of the package, and it carries\nno members: the engine and the two limits sit behind it, and the management\nservice alone reads them. The engine of the options gets a provider of its own\nunder the public `STORAGE_ENGINE` token, which a test overrides to put another\nengine in front of every management service.\n\nThe module starts nothing and stops nothing. An engine opens no connection of\nits own, and it never creates the container or the bucket it writes into. That\nlocation is infrastructure that exists before the application runs.\n\n## Give the entity the mixin\n\nCompose `AttachmentMixin` onto the entity base class of the application:\n\n```ts\nimport { AttachmentMixin } from '@ballistix.digital/attachment-storage';\nimport { BaseEntity, Column, Entity } from 'typeorm';\n\n@Entity({ name: 'invoice_attachment' })\nexport class InvoiceAttachment extends AttachmentMixin(BaseEntity) {\n\t@Column({ type: 'uuid', nullable: false })\n\tinvoiceId: string;\n}\n```\n\nThe mixin contributes the id and five upload columns:\n\n| Column | Type | Default |\n| --- | --- | --- |\n| `id` | `uuid`, primary key | generated |\n| `name` | `varchar` | — |\n| `fileSize` | `float`, nullable, bytes | `null` |\n| `mimeType` | `varchar`, nullable | `null` |\n| `uploadStatus` | enum `attachment_upload_status`, indexed | `UPLOADING` |\n| `storageId` | `varchar`, nullable | `null` |\n\nThe mixin adds nothing else. Your timestamps, your audit columns, your\ndescription and your application status stay on your own base class. Every\nentity on the mixin shares the one Postgres enum type\n`attachment_upload_status`. Generate a migration for the new table.\n\n## Write a management service\n\nExtend `AttachmentManagementService` once per attachment type:\n\n```ts\nimport { AttachmentManagementService, AttachmentStorageRuntime } from '@ballistix.digital/attachment-storage';\nimport { Injectable } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { Repository } from 'typeorm';\nimport { InvoiceAttachment } from './invoiceAttachment.entity';\n\n@Injectable()\nexport class InvoiceAttachmentManagementService extends AttachmentManagementService<InvoiceAttachment> {\n\tconstructor(\n\t\tattachmentStorageRuntime: AttachmentStorageRuntime,\n\t\t@InjectRepository(InvoiceAttachment) invoiceAttachmentRepository: Repository<InvoiceAttachment>,\n\t) {\n\t\tsuper(attachmentStorageRuntime, invoiceAttachmentRepository);\n\t}\n}\n```\n\nRegister the class as a provider of the owning module. `super()` takes two\narguments. The first is the `AttachmentStorageRuntime` the module provides.\nPass it up and forget it: the type carries no member, the class behind it is\nnot exported, and a new dependency of the base changes that class and no\nconsumer. The second is the repository. Name the parameter after the entity.\n\n## Write a processable management service\n\nA processable attachment hands its ready file to a queue of\n`@ballistix.digital/task-scheduler`. Its entity composes both mixins:\n\n```ts\nimport { AttachmentMixin } from '@ballistix.digital/attachment-storage';\nimport { ProcessableTaskMixin } from '@ballistix.digital/task-scheduler';\nimport { BaseEntity, Column, Entity } from 'typeorm';\n\n@Entity({ name: 'report_attachment' })\nexport class ReportAttachment extends ProcessableTaskMixin(AttachmentMixin(BaseEntity)) {\n\t@Column({ type: 'uuid', nullable: false })\n\treportId: string;\n}\n```\n\nThe service extends `ProcessableAttachmentManagementService` and names the\nqueue its processor subscribes to:\n\n```ts\nimport { AttachmentStorageRuntime } from '@ballistix.digital/attachment-storage';\nimport { ProcessableAttachmentManagementService } from '@ballistix.digital/attachment-storage/processable';\nimport { TaskRuntime } from '@ballistix.digital/task-scheduler';\nimport { Injectable } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { Repository } from 'typeorm';\nimport { ReportAttachment } from './reportAttachment.entity';\n\n@Injectable()\nexport class ReportAttachmentManagementService extends ProcessableAttachmentManagementService<ReportAttachment> {\n\tprotected readonly queue = 'report-attachment';\n\n\tconstructor(\n\t\tattachmentStorageRuntime: AttachmentStorageRuntime,\n\t\t@InjectRepository(ReportAttachment) reportAttachmentRepository: Repository<ReportAttachment>,\n\t\ttaskRuntime: TaskRuntime,\n\t) {\n\t\tsuper(attachmentStorageRuntime, reportAttachmentRepository, taskRuntime);\n\t}\n}\n```\n\nRegister `TaskModule` and `ExceptionModule` of those packages next to\n`AttachmentStorageModule`. Write the processor of that queue with\n`AbstractTaskProcessor` of the task scheduler.\n\nA completed upload then writes two more things on the transaction of the\ncaller: the `PENDING` processing status of the row, and one message on the\nqueue keyed on the id of the attachment. A rollback leaves neither. The\nenqueue passes a `dedupeKey` and no retry option, because the queue of the\nprocessor owns the retry policy.\n\nAn upload that lands `FAILED` enqueues nothing.\n\n## Drive the lifecycle from the consumer\n\nYour controller, your `CrudService` and your upload machine call these seven\nmethods. Every one of them takes an attachment that already exists and carries\nits id.\n\n| Method | What it does |\n| --- | --- |\n| `prepareUpload(attachment, transaction)` | Writes the storage id and returns the presigned write URL |\n| `upload(attachment, data, contentType, transaction)` | Writes the file from the server, then completes the upload |\n| `completeUpload(attachment, transaction)` | Reads the file and lands `READY`, or lands `FAILED` |\n| `failUpload(attachment, transaction)` | Lands `FAILED`, clears the storage id and deletes the orphan file |\n| `readUrl(attachment)` | Returns the presigned read URL of a `READY` attachment |\n| `download(attachment)` | Reads the whole file of a `READY` attachment into memory |\n| `deleteOrphanFile(storageId, transaction)` | Deletes the file when no attachment references it |\n\nEvery write rides the transaction you pass. The service opens no transaction\nof its own, so one commit lands your row and the storage writes together.\n\n`readUrl()` and `download()` need a stored file, which only a `READY`\nattachment has. Any other upload status is refused with\n`PreconditionFailedException`, code `PRECONDITION_FAILED`, status 409, detail\n`{ condition: 'ATTACHMENT_NOT_READY', args: { uploadStatus } }`. The\ncondition is `AttachmentPreconditionEnum.ATTACHMENT_NOT_READY`, exported from\nthe root entry and from `./types`, so a browser client keys its translation\non it. A transition that reads an attachment that no longer exists throws\n`ResourceNotFoundException`, status 404, with the entity name of your\nrepository.\n\n### The shared-write rule\n\nYou and the library both write the `uploadStatus` column. A consumer that\ndrives that column through its own state machine writes the new upload status\nfrom the request body first, and calls the transition afterwards, on the same\ntransaction. Both transitions accept that.\n\n| Transition | Accepted upload status | Refused |\n| --- | --- | --- |\n| `completeUpload()` | `UPLOADING`, or `READY` with no file size yet | every other state, with `GenericBadRequestException` |\n| `failUpload()` | `UPLOADING`, or `FAILED` that still carries a storage id | every other state, with `GenericBadRequestException` |\n\nThe file size is the marker of a finished completion. A second\n`completeUpload()` of an attachment that already carries its size therefore\nfails, and a repeated `failUpload()` of an attachment with no storage id fails\nthe same way.\n\n### Create the attachment and hand out the write URL\n\n```ts\npublic async createAttachment(name: string, invoiceId: string): Promise<AttachmentWithUrl> {\n\treturn this.dataSource.transaction(async (transaction) => {\n\t\tconst repository = transaction.getRepository(InvoiceAttachment);\n\t\tconst attachment = await repository.save(repository.create({ name, invoiceId }));\n\t\tconst url = await this.managementService.prepareUpload(attachment, transaction);\n\n\t\treturn { attachment, url };\n\t});\n}\n```\n\nThe attachment starts `UPLOADING`, because that is the default of the column.\nThe client writes the file straight to the returned URL.\n\n### Complete the upload from the machine\n\n```ts\npublic async markReady(id: string): Promise<InvoiceAttachment> {\n\treturn this.dataSource.transaction(async (transaction) => {\n\t\tconst repository = transaction.getRepository(InvoiceAttachment);\n\t\tawait repository.update({ id }, { uploadStatus: AttachmentUploadStatusEnum.READY });\n\n\t\treturn this.managementService.completeUpload(await repository.findOneByOrFail({ id }), transaction);\n\t});\n}\n```\n\n`completeUpload()` reads the attachment back on your transaction, so it sees\nthe upload status you just wrote. It then reads the file and decides:\n\n```mermaid\nflowchart TD\n    C[\"completeUpload(attachment, transaction)\"] --> R[\"read the attachment on the transaction\"]\n    R --> G{\"UPLOADING, or READY with no file size?\"}\n    G -- no --> B[\"GenericBadRequestException\"]\n    G -- yes --> P[\"engine.getFileProperties(storageId)\"]\n    P --> M{\"does storage hold the file?\"}\n    M -- no --> F[\"FAILED, storageId null, no hook\"]\n    M -- yes --> S{\"contentLength within maxFileSize?\"}\n    S -- no --> D[\"FAILED with the size, storageId null,<br/>deleteOrphanFile on the same transaction\"]\n    S -- yes --> K[\"READY with the size and the media type,<br/>then onUploadReady()\"]\n\n    classDef decision fill:#FFD700,stroke:#333,stroke-width:2px,color:#000\n    classDef terminal fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000\n    classDef done fill:#90EE90,stroke:#333,stroke-width:2px,color:#006400\n    classDef step fill:#F5F5F5,stroke:#333,stroke-width:1px,color:#000\n    class G,M,S decision\n    class B,F,D terminal\n    class K done\n    class C,R,P step\n```\n\nAn oversize file lands `FAILED` and the file leaves storage on the same\ntransaction, so the attachment and its file settle together.\n\n### Write the file from the server\n\n```ts\nawait this.managementService.upload(attachment, buffer, 'application/pdf', transaction);\n```\n\n`upload()` runs the same `READY` transition a client upload runs, so a\nprocessable attachment enqueues its message the same way.\n\n### Delete an attachment\n\nTwo attachments can carry one storage id. Remove your own reference first, and\ncall the delete afterwards, on the same transaction:\n\n```ts\nawait this.dataSource.transaction(async (transaction) => {\n\tconst storageId = attachment.storageId;\n\tawait transaction.getRepository(InvoiceAttachment).delete({ id: attachment.id });\n\tawait this.managementService.deleteOrphanFile(storageId, transaction);\n});\n```\n\n`deleteOrphanFile()` counts the attachments that still reference the storage\nid. It deletes the file only when none does, and it never throws: a storage\nthat refuses the delete leaves an orphan file behind, which the service logs\nand your transaction survives.\n\n## Test with the mock\n\nThe `./testing` entry gives `StorageEngineMock`. It keeps its files in an\nin-memory map, records every call, and needs no emulator. Its two presigned\nURLs are real: the mock serves them from an HTTP server on `127.0.0.1`, so a\nclient writes and reads the file through them with `fetch`, axios or\nsupertest.\n\n```ts\nimport { AttachmentStorageModule, AttachmentUploadStatusEnum } from '@ballistix.digital/attachment-storage';\nimport { StorageEngineMock } from '@ballistix.digital/attachment-storage/testing';\nimport { Test } from '@nestjs/testing';\nimport { getRepositoryToken } from '@nestjs/typeorm';\n\nit('lands READY with the size and the media type of the file', async () => {\n\tconst engine = new StorageEngineMock();\n\tconst repository = testDataSource.getRepository(InvoiceAttachment);\n\n\tconst moduleRef = await Test.createTestingModule({\n\t\timports: [AttachmentStorageModule.forRoot({ engine, maxFileSize: 1024 })],\n\t\tproviders: [\n\t\t\tInvoiceAttachmentManagementService,\n\t\t\t{ provide: getRepositoryToken(InvoiceAttachment), useValue: repository },\n\t\t],\n\t}).compile();\n\tawait moduleRef.init();\n\n\tconst managementService = moduleRef.get(InvoiceAttachmentManagementService);\n\tconst attachment = await repository.save(repository.create({ name: 'invoice.pdf' }));\n\n\tawait testDataSource.transaction(async (transaction) => {\n\t\tawait managementService.prepareUpload(attachment, transaction);\n\t});\n\tengine.seed(attachment.storageId as string, Buffer.from('1234'), 'application/pdf');\n\tawait testDataSource.transaction(async (transaction) => {\n\t\tawait managementService.completeUpload(attachment, transaction);\n\t});\n\n\tconst stored = await repository.findOneByOrFail({ id: attachment.id });\n\texpect(stored.uploadStatus).toBe(AttachmentUploadStatusEnum.READY);\n\texpect(stored.fileSize).toBe(4);\n\texpect(stored.mimeType).toBe('application/pdf');\n\texpect(engine.writeUrls[0].storageId).toBe(attachment.storageId);\n});\n```\n\n`seed()` puts a file in place the way a client PUT through a presigned write\nURL would. A spec that wants that PUT itself writes through the URL:\n\n```ts\nconst url = await managementService.prepareUpload(attachment, transaction);\nawait fetch(url, { method: 'PUT', headers: { 'content-type': 'application/pdf' }, body: fileBuffer });\n```\n\nRead these members of the mock:\n\n| Member | Holds |\n| --- | --- |\n| `files` | every file the mock holds, keyed on the storage id |\n| `writeUrls` | every `generatePresignedWriteUrl` call: `storageId`, `expiresInSeconds` |\n| `readUrls` | every `generatePresignedReadUrl` call, in the same shape |\n| `uploaded` | the storage id of every `upload` call |\n| `deleted` | the storage id of every `delete` call |\n| `seed(storageId, data, contentType)` | puts a file in place before the code under test runs |\n| `reset()` | empties the map and every recorded list, and closes the server |\n\n`writeUrls` and `readUrls` hold `RecordedUrl` values, which the same entry\nexports, so a spec asserts the storage id and the lifetime the service asked\nfor. The two URL methods answer\n`http://127.0.0.1:<port>/write/<storage id>?expires=<unix seconds>` and the\nsame under `/read/`. The first of the two starts one server on a free port. A\n`PUT` to a write URL stores the body under the storage id with the\n`content-type` header it carries, and answers 201. A `GET` of a read URL\nanswers the bytes and that media type, or 404 when the mock holds no file. A\nURL past its `expires` answers 403, and any other method answers 405. The\nserver is unreferenced, so it never holds a Jest process open, and `reset()`\ncloses it.\n\nTo drive a processable attachment, add `TaskModule` with `TaskEngineMock` of\nthe task scheduler, and read the enqueued message from that mock.\n\n### Swap the engine in tests\n\nThe module provides the engine of its options under `STORAGE_ENGINE`, and the\npackage exports that token. A test of the application overrides that one\nprovider, and every management service reads the engine it puts there:\n\n```ts\nimport { AttachmentStorageModule, STORAGE_ENGINE } from '@ballistix.digital/attachment-storage';\nimport { AzureBlobStorageEngine } from '@ballistix.digital/attachment-storage/azure';\nimport { StorageEngineMock } from '@ballistix.digital/attachment-storage/testing';\n\n// AppModule\nAttachmentStorageModule.forRootAsync({\n\tinject: [ConfigService],\n\tuseFactory: (config: ConfigService) => ({\n\t\tengine: new AzureBlobStorageEngine({ connectionString: config.get('...'), containerName: config.get('...') }),\n\t\tmaxFileSize: 10 * 1024 * 1024,\n\t}),\n});\n\n// A test\nconst moduleRef = await Test.createTestingModule({ imports: [AppModule] })\n\t.overrideProvider(STORAGE_ENGINE)\n\t.useClass(StorageEngineMock)\n\t.compile();\n```\n\n`useValue(engine)` takes an instance the spec already holds, so it reads\n`writeUrls` and the other recordings of that instance afterwards.\n\nNo provider carries the `StorageEngine` class itself, so\n`overrideProvider(StorageEngine)` finds nothing to replace and a consumer\ninjects no engine outside a test.\n\n## API reference\n\nSix entries. The root loads no storage SDK and no task scheduler, and `./types`\nloads no framework.\n\n### `@ballistix.digital/attachment-storage`\n\n| Export | Purpose |\n| --- | --- |\n| `AttachmentStorageModule.forRoot(options)` | Registers the module with options that are already known |\n| `AttachmentStorageModule.forRootAsync(options)` | The same, with `imports`, `inject` and a `useFactory` |\n| `AttachmentStorageModuleOptions` | `{ engine, maxFileSize, presignedUrlTtlSeconds? }` |\n| `AttachmentStorageModuleAsyncOptions` | `{ imports?, inject?, useFactory }` |\n| `AttachmentStorageRuntime` | The one injectable: the first argument of `super()` in a management service |\n| `AttachmentManagementService<T>` | The base class a consumer extends per attachment type |\n| `AttachmentMixin(Base)` | Adds the id and the five upload columns to an entity class |\n| `Attachment` | The interface of a row with those columns |\n| `AttachmentUploadStatusEnum` | `UPLOADING`, `READY`, `FAILED` |\n| `AttachmentPreconditionEnum` | `ATTACHMENT_NOT_READY`, the condition of the `PreconditionFailedException` a read path throws |\n| `FileProperties` | `{ contentLength, contentType }`, what storage reports about a file |\n| `FileNotFoundError` | Storage holds no file under this storage id. Carries `storageId` |\n| `StorageEngine` | The engine contract the application constructs. A type, not something you inject |\n| `STORAGE_ENGINE` | The token the module provides the engine under. A test overrides it to swap the engine |\n\n### `@ballistix.digital/attachment-storage/processable`\n\n| Export | Purpose |\n| --- | --- |\n| `ProcessableAttachmentManagementService<T>` | The management service that marks `PENDING` and enqueues on `READY` |\n| `ProcessableAttachment` | `Attachment` and `ProcessableTask` on one row |\n\n### `@ballistix.digital/attachment-storage/azure`\n\n| Export | Purpose |\n| --- | --- |\n| `AzureBlobStorageEngine` | The engine on `@azure/storage-blob`, constructed with `AzureBlobStorageOptions` |\n| `AzureBlobStorageOptions` | `{ connectionString, containerName }` |\n\n### `@ballistix.digital/attachment-storage/gcp`\n\n| Export | Purpose |\n| --- | --- |\n| `GcpCloudStorageEngine` | The engine on `@google-cloud/storage`, constructed with `GcpCloudStorageOptions` |\n| `GcpCloudStorageOptions` | `{ bucketName, projectId?, keyFilename?, credentials?, apiEndpoint? }` |\n\n### `@ballistix.digital/attachment-storage/types`\n\n| Export | Purpose |\n| --- | --- |\n| `AttachmentUploadStatusEnum` | The same enum object the root entry exports, with no Nest and no TypeORM underneath it |\n| `AttachmentPreconditionEnum` | The same enum object the root entry exports, so a browser client translates the condition without a copied string |\n| `Attachment` | The interface of a row with the upload columns |\n| `FileProperties` | `{ contentLength, contentType }` |\n\nA package that shares DTO types with a browser application re-exports the\nenums from this entry instead of declaring a second copy. Every emitted module\nis also reachable by its path under `dist/`, which the Nest Swagger CLI plugin\nneeds when it rebuilds an enum-typed DTO property as a `require()` of the\ndeclaration file the enum came from. Import through an entry in code you\nwrite.\n\n### `@ballistix.digital/attachment-storage/testing`\n\n| Export | Purpose |\n| --- | --- |\n| `StorageEngineMock` | The in-memory engine a test drives, with `seed()` and `reset()` |\n| `RecordedUrl` | `{ storageId, expiresInSeconds }`, one recorded URL call |\n\n### The engine contract\n\n`StorageEngine` is abstract. An application constructs one implementation and\nhands it to `AttachmentStorageModule`. The module and the management service\ncall it, and no consumer injects it.\n\n| Method | Purpose |\n| --- | --- |\n| `generatePresignedWriteUrl(storageId, expiresInSeconds)` | A URL a client writes one file through. The file need not exist yet |\n| `generatePresignedReadUrl(storageId, expiresInSeconds)` | A URL a client reads the file through |\n| `upload(storageId, data, contentType)` | Writes the bytes and records the media type. Replaces an existing file |\n| `download(storageId)` | Reads the whole file into memory. Rejects with `FileNotFoundError` |\n| `delete(storageId)` | Removes the file. Resolves for a file storage does not hold |\n| `getFileProperties(storageId)` | The size and the media type. Rejects with `FileNotFoundError` |\n| `exists(storageId)` | Whether storage holds a file under this storage id |\n\nTwo rules hold for every implementation. A read of a file that storage does not\nhold rejects with `FileNotFoundError` carrying the storage id. A delete of such\na file resolves, so deleting twice is safe.\n\n## Guarantees\n\n- Every storage write rides the transaction of the caller. The service opens\n  no transaction of its own.\n- The storage id is always `<id>/<name>`, derived from the attachment. No\n  caller and no subclass chooses it.\n- A file over `maxFileSize` lands the attachment `FAILED` and leaves storage\n  on the same transaction.\n- `deleteOrphanFile()` never throws. A refused delete leaves an orphan file,\n  which the service logs.\n- `deleteOrphanFile()` counts references first, so a storage id two\n  attachments share keeps its file.\n- The transitions share the `uploadStatus` column with the caller.\n  `completeUpload()` accepts `READY` without a file size, and `failUpload()`\n  accepts `FAILED` with a storage id.\n- The `PENDING` marker and the queue message of a processable attachment\n  commit with the `READY` upload status, or neither commits.\n- The enqueue passes a `dedupeKey` and no retry option. The queue of the\n  processor owns the retry policy.\n- The module reads no environment variable. Every value arrives through its\n  options.\n- An engine creates no container and no bucket. That location is\n  infrastructure.\n","readmeFilename":"README.md"}