@ballistix.digital/attachment-storage
v0.5.0
Published
NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine.
Downloads
308
Keywords
Readme
@ballistix.digital/attachment-storage
A NestJS module that gives an entity row an uploaded file behind it, with a pluggable storage engine. An attachment is a database row with an upload status, and a storage engine holds the file itself under the row's storage id. Two engines ship, Azure and GCP, each behind its own entry, so an application installs the SDK of the engine it uses.
This file is the consumer guide. The pages that describe how the package is built live in the Ballistix wiki.
The boundary
The library owns the storage engines, the entity mixin and the upload
lifecycle service. It owns no HTTP route, no DTO, no Context and no CRUD
logic, and it holds no state machine. Your application keeps its controllers,
its CrudService, its DTOs, its Context and its xstate upload machine, and
the entry actions of that machine call the management service you extend from
this package.
The model
The uploadStatus column of the attachment is the lifecycle.
stateDiagram-v2
classDef waiting fill:#FFD700,stroke:#333,color:#000
classDef done fill:#90EE90,stroke:#333,color:#006400
classDef failed fill:#FFB6C1,stroke:#DC143C,color:#000
[*] --> UPLOADING: the consumer saves the attachment
UPLOADING --> READY: completeUpload(), the file is within the maximum
UPLOADING --> FAILED: failUpload(), or no file, or over the maximum
READY --> [*]
FAILED --> [*]
class UPLOADING waiting
class READY done
class FAILED failedRead the two exits of UPLOADING. completeUpload() reads the file and lands
READY with its size and its media type. Every other outcome lands FAILED
with no storage id.
The storage id is always <id>/<name>. The library derives it from the
attachment, so no caller and no subclass chooses another layout.
One upload, step by step
sequenceDiagram
actor Client
box rgb(230,230,250) Consumer
participant Ctrl as Controller and CrudService
participant Machine as Upload machine
end
box rgb(144,238,144) Library
participant Mgmt as Management service
end
box rgb(255,228,181) Task scheduler
participant Queue as TaskRuntime
end
box rgb(211,211,211) Infrastructure
participant DB as Postgres
participant Storage as Storage
end
rect rgb(255,250,205)
Note over Ctrl,Storage: 1. create: the attachment and the presigned write URL
Client->>Ctrl: POST the attachment
Ctrl->>DB: INSERT the attachment, upload status UPLOADING
Ctrl->>Mgmt: prepareUpload(attachment, transaction)
Mgmt->>DB: UPDATE storageId to id/name
Mgmt->>Storage: generatePresignedWriteUrl(storageId, ttl)
Mgmt-->>Ctrl: the presigned write URL
Ctrl-->>Client: the attachment and the presigned write URL
end
rect rgb(224,255,224)
Note over Client,Storage: 2. the client writes the file itself
Client->>Storage: PUT the file to the presigned write URL
end
rect rgb(255,228,225)
Note over Ctrl,Storage: 3. complete: one transaction settles everything
Client->>Ctrl: PATCH upload status READY
Ctrl->>DB: UPDATE uploadStatus to READY
Ctrl->>Machine: transition UPLOADING to READY
Machine->>Mgmt: completeUpload(attachment, transaction)
Mgmt->>Storage: getFileProperties(storageId)
Mgmt->>DB: UPDATE fileSize, mimeType and uploadStatus READY
Mgmt->>DB: UPDATE processingStatus to PENDING
Mgmt->>Queue: enqueue(queue, id, transaction, dedupeKey)
Note over Mgmt,Queue: the READY row, the PENDING marker and the message commit together
endLook at the third block. The file never travels through your API, and the two writes of a processable attachment ride the transaction the request opened.
Install
npm install @ballistix.digital/attachment-storageThe package needs Node 22.12 or later. Every peer dependency is optional, so npm installs none of them on its own: install what the entries you import need.
| Package | Range | Needed by |
| --- | --- | --- |
| @nestjs/common | ^11 || ^12, optional | every entry but ./types |
| @nestjs/core | ^11 || ^12, optional | every entry but ./types |
| typeorm | >=0.3.0 <2.0.0, optional | every entry but ./types |
| reflect-metadata | ^0.2, optional | every entry but ./types |
| @ballistix.digital/exception-types | >=0.4.0 <1.0.0, optional | every entry but ./types |
| @ballistix.digital/task-scheduler | >=0.9.0 <1.0.0, optional | ./processable |
| @azure/storage-blob | ^12.33, optional | ./azure |
| @google-cloud/storage | ^7, optional | ./gcp |
The root entry needs Nest, TypeORM, reflect-metadata and the exception types.
The three SDK and scheduler peers sit behind their own entry, so an application
on Azure installs @azure/storage-blob alone, and an application that
processes no attachment installs no task scheduler. The ./types entry loads
no peer at all, so a package that shares DTO types with a browser application
depends on this package and pulls in no framework. The package has no
dependencies of its own. The two Ballistix ranges stay open up to 1.0.0:
every later 0.x release of a peer installs without a change here.
Register the module
import {
AttachmentStorageModule,
AttachmentStorageModuleOptions,
StorageEngine,
} from '@ballistix.digital/attachment-storage';
import { AzureBlobStorageEngine } from '@ballistix.digital/attachment-storage/azure';
import { GcpCloudStorageEngine } from '@ballistix.digital/attachment-storage/gcp';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
const buildEngine = (config: ConfigService): StorageEngine =>
config.get('STORAGE_ENGINE') === 'gcp'
? new GcpCloudStorageEngine({ bucketName: config.get('GCS_BUCKET') })
: new AzureBlobStorageEngine({
connectionString: config.get('AZURE_STORAGE_CONNECTION_STRING'),
containerName: config.get('AZURE_STORAGE_CONTAINER'),
});
@Module({
imports: [
AttachmentStorageModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService): AttachmentStorageModuleOptions => ({
engine: buildEngine(config),
maxFileSize: Number(config.get('ATTACHMENT_MAX_FILE_SIZE')),
presignedUrlTtlSeconds: 900,
}),
}),
],
})
export class AppModule {}One config value picks the engine, and the application constructs it. Import
the engine you need from its own entry.
AttachmentStorageModule.forRoot(options) takes the same options when nothing
needs injection.
| Option | Purpose |
| --- | --- |
| engine | The storage engine instance every upload and download travels through. Required |
| maxFileSize | The largest file an upload accepts, in bytes. Required |
| presignedUrlTtlSeconds | How long a presigned URL stays valid, in seconds. Defaults to 900 |
The module reads no environment variable and no ConfigService of its own.
Every value arrives through these options.
The module is global. A management service in any feature module injects
AttachmentStorageRuntime without a further import.
AttachmentStorageRuntime is the one injectable of the package, and it carries
no members: the engine and the two limits sit behind it, and the management
service alone reads them. The engine of the options gets a provider of its own
under the public STORAGE_ENGINE token, which a test overrides to put another
engine in front of every management service.
The module starts nothing and stops nothing. An engine opens no connection of its own, and it never creates the container or the bucket it writes into. That location is infrastructure that exists before the application runs.
Give the entity the mixin
Compose AttachmentMixin onto the entity base class of the application:
import { AttachmentMixin } from '@ballistix.digital/attachment-storage';
import { BaseEntity, Column, Entity } from 'typeorm';
@Entity({ name: 'invoice_attachment' })
export class InvoiceAttachment extends AttachmentMixin(BaseEntity) {
@Column({ type: 'uuid', nullable: false })
invoiceId: string;
}The mixin contributes the id and five upload columns:
| Column | Type | Default |
| --- | --- | --- |
| id | uuid, primary key | generated |
| name | varchar | — |
| fileSize | float, nullable, bytes | null |
| mimeType | varchar, nullable | null |
| uploadStatus | enum attachment_upload_status, indexed | UPLOADING |
| storageId | varchar, nullable | null |
The mixin adds nothing else. Your timestamps, your audit columns, your
description and your application status stay on your own base class. Every
entity on the mixin shares the one Postgres enum type
attachment_upload_status. Generate a migration for the new table.
Write a management service
Extend AttachmentManagementService once per attachment type:
import { AttachmentManagementService, AttachmentStorageRuntime } from '@ballistix.digital/attachment-storage';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InvoiceAttachment } from './invoiceAttachment.entity';
@Injectable()
export class InvoiceAttachmentManagementService extends AttachmentManagementService<InvoiceAttachment> {
constructor(
attachmentStorageRuntime: AttachmentStorageRuntime,
@InjectRepository(InvoiceAttachment) invoiceAttachmentRepository: Repository<InvoiceAttachment>,
) {
super(attachmentStorageRuntime, invoiceAttachmentRepository);
}
}Register the class as a provider of the owning module. super() takes two
arguments. The first is the AttachmentStorageRuntime the module provides.
Pass it up and forget it: the type carries no member, the class behind it is
not exported, and a new dependency of the base changes that class and no
consumer. The second is the repository. Name the parameter after the entity.
Write a processable management service
A processable attachment hands its ready file to a queue of
@ballistix.digital/task-scheduler. Its entity composes both mixins:
import { AttachmentMixin } from '@ballistix.digital/attachment-storage';
import { ProcessableTaskMixin } from '@ballistix.digital/task-scheduler';
import { BaseEntity, Column, Entity } from 'typeorm';
@Entity({ name: 'report_attachment' })
export class ReportAttachment extends ProcessableTaskMixin(AttachmentMixin(BaseEntity)) {
@Column({ type: 'uuid', nullable: false })
reportId: string;
}The service extends ProcessableAttachmentManagementService and names the
queue its processor subscribes to:
import { AttachmentStorageRuntime } from '@ballistix.digital/attachment-storage';
import { ProcessableAttachmentManagementService } from '@ballistix.digital/attachment-storage/processable';
import { TaskRuntime } from '@ballistix.digital/task-scheduler';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ReportAttachment } from './reportAttachment.entity';
@Injectable()
export class ReportAttachmentManagementService extends ProcessableAttachmentManagementService<ReportAttachment> {
protected readonly queue = 'report-attachment';
constructor(
attachmentStorageRuntime: AttachmentStorageRuntime,
@InjectRepository(ReportAttachment) reportAttachmentRepository: Repository<ReportAttachment>,
taskRuntime: TaskRuntime,
) {
super(attachmentStorageRuntime, reportAttachmentRepository, taskRuntime);
}
}Register TaskModule and ExceptionModule of those packages next to
AttachmentStorageModule. Write the processor of that queue with
AbstractTaskProcessor of the task scheduler.
A completed upload then writes two more things on the transaction of the
caller: the PENDING processing status of the row, and one message on the
queue keyed on the id of the attachment. A rollback leaves neither. The
enqueue passes a dedupeKey and no retry option, because the queue of the
processor owns the retry policy.
An upload that lands FAILED enqueues nothing.
Drive the lifecycle from the consumer
Your controller, your CrudService and your upload machine call these seven
methods. Every one of them takes an attachment that already exists and carries
its id.
| Method | What it does |
| --- | --- |
| prepareUpload(attachment, transaction) | Writes the storage id and returns the presigned write URL |
| upload(attachment, data, contentType, transaction) | Writes the file from the server, then completes the upload |
| completeUpload(attachment, transaction) | Reads the file and lands READY, or lands FAILED |
| failUpload(attachment, transaction) | Lands FAILED, clears the storage id and deletes the orphan file |
| readUrl(attachment) | Returns the presigned read URL of a READY attachment |
| download(attachment) | Reads the whole file of a READY attachment into memory |
| deleteOrphanFile(storageId, transaction) | Deletes the file when no attachment references it |
Every write rides the transaction you pass. The service opens no transaction of its own, so one commit lands your row and the storage writes together.
readUrl() and download() need a stored file, which only a READY
attachment has. Any other upload status is refused with
PreconditionFailedException, code PRECONDITION_FAILED, status 409, detail
{ condition: 'ATTACHMENT_NOT_READY', args: { uploadStatus } }. The
condition is AttachmentPreconditionEnum.ATTACHMENT_NOT_READY, exported from
the root entry and from ./types, so a browser client keys its translation
on it. A transition that reads an attachment that no longer exists throws
ResourceNotFoundException, status 404, with the entity name of your
repository.
The shared-write rule
You and the library both write the uploadStatus column. A consumer that
drives that column through its own state machine writes the new upload status
from the request body first, and calls the transition afterwards, on the same
transaction. Both transitions accept that.
| Transition | Accepted upload status | Refused |
| --- | --- | --- |
| completeUpload() | UPLOADING, or READY with no file size yet | every other state, with GenericBadRequestException |
| failUpload() | UPLOADING, or FAILED that still carries a storage id | every other state, with GenericBadRequestException |
The file size is the marker of a finished completion. A second
completeUpload() of an attachment that already carries its size therefore
fails, and a repeated failUpload() of an attachment with no storage id fails
the same way.
Create the attachment and hand out the write URL
public async createAttachment(name: string, invoiceId: string): Promise<AttachmentWithUrl> {
return this.dataSource.transaction(async (transaction) => {
const repository = transaction.getRepository(InvoiceAttachment);
const attachment = await repository.save(repository.create({ name, invoiceId }));
const url = await this.managementService.prepareUpload(attachment, transaction);
return { attachment, url };
});
}The attachment starts UPLOADING, because that is the default of the column.
The client writes the file straight to the returned URL.
Complete the upload from the machine
public async markReady(id: string): Promise<InvoiceAttachment> {
return this.dataSource.transaction(async (transaction) => {
const repository = transaction.getRepository(InvoiceAttachment);
await repository.update({ id }, { uploadStatus: AttachmentUploadStatusEnum.READY });
return this.managementService.completeUpload(await repository.findOneByOrFail({ id }), transaction);
});
}completeUpload() reads the attachment back on your transaction, so it sees
the upload status you just wrote. It then reads the file and decides:
flowchart TD
C["completeUpload(attachment, transaction)"] --> R["read the attachment on the transaction"]
R --> G{"UPLOADING, or READY with no file size?"}
G -- no --> B["GenericBadRequestException"]
G -- yes --> P["engine.getFileProperties(storageId)"]
P --> M{"does storage hold the file?"}
M -- no --> F["FAILED, storageId null, no hook"]
M -- yes --> S{"contentLength within maxFileSize?"}
S -- no --> D["FAILED with the size, storageId null,<br/>deleteOrphanFile on the same transaction"]
S -- yes --> K["READY with the size and the media type,<br/>then onUploadReady()"]
classDef decision fill:#FFD700,stroke:#333,stroke-width:2px,color:#000
classDef terminal fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000
classDef done fill:#90EE90,stroke:#333,stroke-width:2px,color:#006400
classDef step fill:#F5F5F5,stroke:#333,stroke-width:1px,color:#000
class G,M,S decision
class B,F,D terminal
class K done
class C,R,P stepAn oversize file lands FAILED and the file leaves storage on the same
transaction, so the attachment and its file settle together.
Write the file from the server
await this.managementService.upload(attachment, buffer, 'application/pdf', transaction);upload() runs the same READY transition a client upload runs, so a
processable attachment enqueues its message the same way.
Delete an attachment
Two attachments can carry one storage id. Remove your own reference first, and call the delete afterwards, on the same transaction:
await this.dataSource.transaction(async (transaction) => {
const storageId = attachment.storageId;
await transaction.getRepository(InvoiceAttachment).delete({ id: attachment.id });
await this.managementService.deleteOrphanFile(storageId, transaction);
});deleteOrphanFile() counts the attachments that still reference the storage
id. It deletes the file only when none does, and it never throws: a storage
that refuses the delete leaves an orphan file behind, which the service logs
and your transaction survives.
Test with the mock
The ./testing entry gives StorageEngineMock. It keeps its files in an
in-memory map, records every call, and needs no emulator. Its two presigned
URLs are real: the mock serves them from an HTTP server on 127.0.0.1, so a
client writes and reads the file through them with fetch, axios or
supertest.
import { AttachmentStorageModule, AttachmentUploadStatusEnum } from '@ballistix.digital/attachment-storage';
import { StorageEngineMock } from '@ballistix.digital/attachment-storage/testing';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
it('lands READY with the size and the media type of the file', async () => {
const engine = new StorageEngineMock();
const repository = testDataSource.getRepository(InvoiceAttachment);
const moduleRef = await Test.createTestingModule({
imports: [AttachmentStorageModule.forRoot({ engine, maxFileSize: 1024 })],
providers: [
InvoiceAttachmentManagementService,
{ provide: getRepositoryToken(InvoiceAttachment), useValue: repository },
],
}).compile();
await moduleRef.init();
const managementService = moduleRef.get(InvoiceAttachmentManagementService);
const attachment = await repository.save(repository.create({ name: 'invoice.pdf' }));
await testDataSource.transaction(async (transaction) => {
await managementService.prepareUpload(attachment, transaction);
});
engine.seed(attachment.storageId as string, Buffer.from('1234'), 'application/pdf');
await testDataSource.transaction(async (transaction) => {
await managementService.completeUpload(attachment, transaction);
});
const stored = await repository.findOneByOrFail({ id: attachment.id });
expect(stored.uploadStatus).toBe(AttachmentUploadStatusEnum.READY);
expect(stored.fileSize).toBe(4);
expect(stored.mimeType).toBe('application/pdf');
expect(engine.writeUrls[0].storageId).toBe(attachment.storageId);
});seed() puts a file in place the way a client PUT through a presigned write
URL would. A spec that wants that PUT itself writes through the URL:
const url = await managementService.prepareUpload(attachment, transaction);
await fetch(url, { method: 'PUT', headers: { 'content-type': 'application/pdf' }, body: fileBuffer });Read these members of the mock:
| Member | Holds |
| --- | --- |
| files | every file the mock holds, keyed on the storage id |
| writeUrls | every generatePresignedWriteUrl call: storageId, expiresInSeconds |
| readUrls | every generatePresignedReadUrl call, in the same shape |
| uploaded | the storage id of every upload call |
| deleted | the storage id of every delete call |
| seed(storageId, data, contentType) | puts a file in place before the code under test runs |
| reset() | empties the map and every recorded list, and closes the server |
writeUrls and readUrls hold RecordedUrl values, which the same entry
exports, so a spec asserts the storage id and the lifetime the service asked
for. The two URL methods answer
http://127.0.0.1:<port>/write/<storage id>?expires=<unix seconds> and the
same under /read/. The first of the two starts one server on a free port. A
PUT to a write URL stores the body under the storage id with the
content-type header it carries, and answers 201. A GET of a read URL
answers the bytes and that media type, or 404 when the mock holds no file. A
URL past its expires answers 403, and any other method answers 405. The
server is unreferenced, so it never holds a Jest process open, and reset()
closes it.
To drive a processable attachment, add TaskModule with TaskEngineMock of
the task scheduler, and read the enqueued message from that mock.
Swap the engine in tests
The module provides the engine of its options under STORAGE_ENGINE, and the
package exports that token. A test of the application overrides that one
provider, and every management service reads the engine it puts there:
import { AttachmentStorageModule, STORAGE_ENGINE } from '@ballistix.digital/attachment-storage';
import { AzureBlobStorageEngine } from '@ballistix.digital/attachment-storage/azure';
import { StorageEngineMock } from '@ballistix.digital/attachment-storage/testing';
// AppModule
AttachmentStorageModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
engine: new AzureBlobStorageEngine({ connectionString: config.get('...'), containerName: config.get('...') }),
maxFileSize: 10 * 1024 * 1024,
}),
});
// A test
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(STORAGE_ENGINE)
.useClass(StorageEngineMock)
.compile();useValue(engine) takes an instance the spec already holds, so it reads
writeUrls and the other recordings of that instance afterwards.
No provider carries the StorageEngine class itself, so
overrideProvider(StorageEngine) finds nothing to replace and a consumer
injects no engine outside a test.
API reference
Six entries. The root loads no storage SDK and no task scheduler, and ./types
loads no framework.
@ballistix.digital/attachment-storage
| Export | Purpose |
| --- | --- |
| AttachmentStorageModule.forRoot(options) | Registers the module with options that are already known |
| AttachmentStorageModule.forRootAsync(options) | The same, with imports, inject and a useFactory |
| AttachmentStorageModuleOptions | { engine, maxFileSize, presignedUrlTtlSeconds? } |
| AttachmentStorageModuleAsyncOptions | { imports?, inject?, useFactory } |
| AttachmentStorageRuntime | The one injectable: the first argument of super() in a management service |
| AttachmentManagementService<T> | The base class a consumer extends per attachment type |
| AttachmentMixin(Base) | Adds the id and the five upload columns to an entity class |
| Attachment | The interface of a row with those columns |
| AttachmentUploadStatusEnum | UPLOADING, READY, FAILED |
| AttachmentPreconditionEnum | ATTACHMENT_NOT_READY, the condition of the PreconditionFailedException a read path throws |
| FileProperties | { contentLength, contentType }, what storage reports about a file |
| FileNotFoundError | Storage holds no file under this storage id. Carries storageId |
| StorageEngine | The engine contract the application constructs. A type, not something you inject |
| STORAGE_ENGINE | The token the module provides the engine under. A test overrides it to swap the engine |
@ballistix.digital/attachment-storage/processable
| Export | Purpose |
| --- | --- |
| ProcessableAttachmentManagementService<T> | The management service that marks PENDING and enqueues on READY |
| ProcessableAttachment | Attachment and ProcessableTask on one row |
@ballistix.digital/attachment-storage/azure
| Export | Purpose |
| --- | --- |
| AzureBlobStorageEngine | The engine on @azure/storage-blob, constructed with AzureBlobStorageOptions |
| AzureBlobStorageOptions | { connectionString, containerName } |
@ballistix.digital/attachment-storage/gcp
| Export | Purpose |
| --- | --- |
| GcpCloudStorageEngine | The engine on @google-cloud/storage, constructed with GcpCloudStorageOptions |
| GcpCloudStorageOptions | { bucketName, projectId?, keyFilename?, credentials?, apiEndpoint? } |
@ballistix.digital/attachment-storage/types
| Export | Purpose |
| --- | --- |
| AttachmentUploadStatusEnum | The same enum object the root entry exports, with no Nest and no TypeORM underneath it |
| AttachmentPreconditionEnum | The same enum object the root entry exports, so a browser client translates the condition without a copied string |
| Attachment | The interface of a row with the upload columns |
| FileProperties | { contentLength, contentType } |
A package that shares DTO types with a browser application re-exports the
enums from this entry instead of declaring a second copy. Every emitted module
is also reachable by its path under dist/, which the Nest Swagger CLI plugin
needs when it rebuilds an enum-typed DTO property as a require() of the
declaration file the enum came from. Import through an entry in code you
write.
@ballistix.digital/attachment-storage/testing
| Export | Purpose |
| --- | --- |
| StorageEngineMock | The in-memory engine a test drives, with seed() and reset() |
| RecordedUrl | { storageId, expiresInSeconds }, one recorded URL call |
The engine contract
StorageEngine is abstract. An application constructs one implementation and
hands it to AttachmentStorageModule. The module and the management service
call it, and no consumer injects it.
| Method | Purpose |
| --- | --- |
| generatePresignedWriteUrl(storageId, expiresInSeconds) | A URL a client writes one file through. The file need not exist yet |
| generatePresignedReadUrl(storageId, expiresInSeconds) | A URL a client reads the file through |
| upload(storageId, data, contentType) | Writes the bytes and records the media type. Replaces an existing file |
| download(storageId) | Reads the whole file into memory. Rejects with FileNotFoundError |
| delete(storageId) | Removes the file. Resolves for a file storage does not hold |
| getFileProperties(storageId) | The size and the media type. Rejects with FileNotFoundError |
| exists(storageId) | Whether storage holds a file under this storage id |
Two rules hold for every implementation. A read of a file that storage does not
hold rejects with FileNotFoundError carrying the storage id. A delete of such
a file resolves, so deleting twice is safe.
Guarantees
- Every storage write rides the transaction of the caller. The service opens no transaction of its own.
- The storage id is always
<id>/<name>, derived from the attachment. No caller and no subclass chooses it. - A file over
maxFileSizelands the attachmentFAILEDand leaves storage on the same transaction. deleteOrphanFile()never throws. A refused delete leaves an orphan file, which the service logs.deleteOrphanFile()counts references first, so a storage id two attachments share keeps its file.- The transitions share the
uploadStatuscolumn with the caller.completeUpload()acceptsREADYwithout a file size, andfailUpload()acceptsFAILEDwith a storage id. - The
PENDINGmarker and the queue message of a processable attachment commit with theREADYupload status, or neither commits. - The enqueue passes a
dedupeKeyand no retry option. The queue of the processor owns the retry policy. - The module reads no environment variable. Every value arrives through its options.
- An engine creates no container and no bucket. That location is infrastructure.
