npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 failed

Read 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
    end

Look 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-storage

The 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 step

An 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 maxFileSize lands the attachment FAILED and 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 uploadStatus column with the caller. completeUpload() accepts READY without a file size, and failUpload() accepts FAILED with a storage id.
  • The PENDING marker and the queue message of a processable attachment commit with the READY upload status, or neither commits.
  • The enqueue passes a dedupeKey and 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.