Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/runtime-provider-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@nestm/storage': minor
---

Add runtime provider selection and a package-owned filesystem driver.

`@nestm/storage/files-sdk/provider` builds a driver from a provider slug carried
as data — `createProviderStorageDriver({ provider: 's3' | 'gcs' | 'azure' | 'r2'
| 'fs' | … })` — importing that provider's adapter, and only that one, on
demand. A deployment now selects its store with an environment variable and
installs a single native SDK instead of the application hard-coding a driver per
backend. The same entry point exposes the provider catalog (`listStorageProviders`,
`getStorageProvider`, `listStorageProviderEnvVars`,
`listStorageProviderSecretEnvVars`, `isStorageProvider`) as pure data, so config
validation and health checks can read a provider's env contract without loading
an adapter. An unknown slug fails closed with `INVALID_ARGUMENT` before anything
is imported.

`@nestm/storage/files-sdk/fs` adds `createFsStorageDriver` for local filesystem
storage, mirroring the S3 factory. Its adapter reaches only `node:fs`, so it adds
no native SDK to an install.

`@nestm/storage/files-sdk/s3` additionally exports `withS3Capabilities`, which
applies S3's conditional-copy promotion and signed-policy declarations to an
adapter built by `s3(...)`. The provider factory uses it so the `s3` slug keeps
those capabilities without re-deriving `S3AdapterOptions` from flat config.
`EnhancedS3Adapter` is now `S3StorageAdapter`; the type was not previously
exported.
87 changes: 85 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,71 @@ modules retain their own DI scope rather than mutating an application-wide
registry. Names are case-sensitive and cannot contain leading or trailing
whitespace.

### Select the provider at runtime

An application that ships to more than one environment usually cannot name its
provider at build time. `createProviderStorageDriver` takes the slug as data and
imports that provider's adapter — and only that one — on demand, so a deployment
picks its store with an environment variable and installs one native SDK:

```ts
import { createProviderStorageDriver } from '@nestm/storage/files-sdk/provider';

StorageModule.forRootAsync({
imports: [ConfigModule],
stores: [
{
name: 'media',
inject: [ConfigService],
useFactory: (config: ConfigService) =>
createProviderStorageDriver({
provider: config.getOrThrow('STORAGE_PROVIDER'),
prefix: config.get('STORAGE_PREFIX'),
config: {
bucket: config.get('STORAGE_BUCKET'),
region: config.get('STORAGE_REGION'),
root: config.get('STORAGE_ROOT'),
},
}),
},
],
});
```

`config` is one flat bag of provider settings — `bucket` and `region` for an
object store, `root` for the filesystem, `accountName` and `container` for
Azure. Each provider reads what it needs and ignores the rest, so the same shape
survives a provider change. Credentials may be omitted wherever the provider's
SDK resolves its own chain (an IAM role, Application Default Credentials, a
shared profile).

An unknown slug fails closed with `INVALID_ARGUMENT` before anything is
imported. Validate untrusted input up front with `isStorageProvider`, and drive
config validation from the catalog rather than a hand-kept list:

```ts
import {
getStorageProvider,
isStorageProvider,
listStorageProviders,
listStorageProviderSecretEnvVars,
} from '@nestm/storage/files-sdk/provider';

listStorageProviders().map((provider) => provider.slug); // 'akamai', 'alibaba', …
getStorageProvider('gcs')?.peerDeps; // ['@google-cloud/storage', …]
listStorageProviderSecretEnvVars('s3').map((variable) => variable.key);
```

The catalog is pure data and pulls in no adapter, so it is safe in config UIs,
health checks, and startup validation.

The `s3` slug additionally carries the conditional-promotion and signed-policy
capabilities described under
[Race-free staged-object promotion](#race-free-staged-object-promotion); every
other provider exposes exactly what its adapter declares. When the provider _is_
known at build time, import `@nestm/storage/files-sdk/s3` or
`@nestm/storage/files-sdk/fs` directly and skip the indirection.

## Storage API

`StorageClient` exposes:
Expand Down Expand Up @@ -510,8 +575,26 @@ StorageModule.forRoot({
});
```

For local filesystem storage, import `fs` from `files-sdk/fs` and pass it to
`createFilesSdkDriver` exactly like a cloud adapter.
For local filesystem storage, use the package-owned factory. The adapter reaches
only `node:fs`, so it needs no native SDK:

```ts
import { createFsStorageDriver } from '@nestm/storage/files-sdk/fs';

StorageModule.forRoot({
stores: [
{
name: 'artifacts',
driver: createFsStorageDriver({ adapter: { root: './var/artifacts' } }),
},
],
});
```

Bodies are written verbatim at `<root>/<key>`. A `<key>.meta.json` sidecar beside
each one carries the content type, ETag, and custom metadata a filesystem has
nowhere else to put; sidecars never surface as keys, and uploading a key ending
in `.meta.json` fails closed rather than colliding with one.

## License

Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
"types": "./dist/files-sdk/index.d.ts",
"import": "./dist/files-sdk/index.js"
},
"./files-sdk/fs": {
"types": "./dist/files-sdk/fs/index.d.ts",
"import": "./dist/files-sdk/fs/index.js"
},
"./files-sdk/provider": {
"types": "./dist/files-sdk/provider/index.d.ts",
"import": "./dist/files-sdk/provider/index.js"
},
"./files-sdk/s3": {
"types": "./dist/files-sdk/s3/index.d.ts",
"import": "./dist/files-sdk/s3/index.js"
Expand Down
78 changes: 78 additions & 0 deletions src/files-sdk/fs/fs.driver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { StorageClient } from '../../storage.client.js';
import { StorageErrorCode } from '../../storage.error.js';
import { createFsStorageDriver } from './index.js';

describe('createFsStorageDriver', () => {
let root = '';

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'nestm-storage-fs-'));
});

afterEach(() => {
rmSync(root, { force: true, recursive: true });
});

it('stores the body verbatim at the key path under the root', async () => {
const client = new StorageClient(
'artifacts',
createFsStorageDriver({ adapter: { root } }),
);

await client.upload('nested/page.html', '<html>hi</html>', {
contentType: 'text/html; charset=utf-8',
});

expect(readFileSync(join(root, 'nested/page.html'), 'utf8')).toBe(
'<html>hi</html>',
);
const head = await client.head('nested/page.html');
expect(head.contentType).toBe('text/html; charset=utf-8');
});

it('keeps sidecars out of listings', async () => {
const client = new StorageClient(
'artifacts',
createFsStorageDriver({ adapter: { root } }),
);
await client.upload('a.txt', 'a');
await client.upload('b.txt', 'b');

const listed = await client.list();

expect(listed.items.map((item) => item.key).sort()).toEqual([
'a.txt',
'b.txt',
]);
});

it('scopes every key under the configured prefix', async () => {
const client = new StorageClient(
'artifacts',
createFsStorageDriver({ adapter: { root }, prefix: 'tenant-a' }),
);

await client.upload('report.txt', 'scoped');

expect(readFileSync(join(root, 'tenant-a/report.txt'), 'utf8')).toBe(
'scoped',
);
const listed = await client.list();
expect(listed.items.map((item) => item.key)).toEqual(['report.txt']);
});

it('reports a missing object as NOT_FOUND', async () => {
const client = new StorageClient(
'artifacts',
createFsStorageDriver({ adapter: { root } }),
);

await expect(client.downloadBytes('absent.bin')).rejects.toMatchObject({
code: StorageErrorCode.NOT_FOUND,
});
});
});
36 changes: 36 additions & 0 deletions src/files-sdk/fs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fs, type FsAdapter, type FsAdapterOptions } from 'files-sdk/fs';

import {
createFilesSdkDriver,
type FilesSdkDriverOptions,
type FilesSdkStorageDriver,
} from '../files-sdk.driver.js';

export interface FsStorageDriverOptions extends Omit<
FilesSdkDriverOptions<FsAdapter>,
'adapter'
> {
adapter: FsAdapterOptions;
}

/**
* Creates the files-sdk filesystem driver from the storage package's own
* dependency context. The adapter reaches only `node:fs`, so this entry point
* adds no native SDK to the install — unlike every object-store provider, it is
* always available.
*
* The adapter keeps a `<key>.meta.json` sidecar beside each body to carry the
* content type, ETag, and custom metadata that a filesystem has nowhere else to
* put. Sidecars never surface as keys: `list` and `search` skip them, and
* uploading a key that ends in `.meta.json` fails closed rather than colliding
* with one.
*/
export function createFsStorageDriver(
options: FsStorageDriverOptions,
): FilesSdkStorageDriver<FsAdapter> {
const { adapter: adapterOptions, ...filesOptions } = options;
return createFilesSdkDriver({ ...filesOptions, adapter: fs(adapterOptions) });
}

export { fs, mapFsError } from 'files-sdk/fs';
export type { FsAdapter, FsAdapterOptions } from 'files-sdk/fs';
Loading