@agentback/files 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +33 -0
- package/README.md +62 -0
- package/dist/__tests__/unit/fs-file-store.unit.d.ts +2 -0
- package/dist/__tests__/unit/fs-file-store.unit.d.ts.map +1 -0
- package/dist/__tests__/unit/fs-file-store.unit.js +38 -0
- package/dist/__tests__/unit/fs-file-store.unit.js.map +1 -0
- package/dist/__tests__/unit/in-memory-file-store.unit.d.ts +2 -0
- package/dist/__tests__/unit/in-memory-file-store.unit.d.ts.map +1 -0
- package/dist/__tests__/unit/in-memory-file-store.unit.js +65 -0
- package/dist/__tests__/unit/in-memory-file-store.unit.js.map +1 -0
- package/dist/fs/fs-file-store.d.ts +31 -0
- package/dist/fs/fs-file-store.d.ts.map +1 -0
- package/dist/fs/fs-file-store.js +92 -0
- package/dist/fs/fs-file-store.js.map +1 -0
- package/dist/in-memory/in-memory-file-store.d.ts +18 -0
- package/dist/in-memory/in-memory-file-store.d.ts.map +1 -0
- package/dist/in-memory/in-memory-file-store.js +60 -0
- package/dist/in-memory/in-memory-file-store.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +5 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +7 -0
- package/dist/keys.js.map +1 -0
- package/dist/ports.d.ts +64 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +17 -0
- package/dist/ports.js.map +1 -0
- package/dist/testing/conformance.d.ts +9 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +62 -0
- package/dist/testing/conformance.js.map +1 -0
- package/package.json +40 -0
- package/src/__tests__/unit/fs-file-store.unit.ts +45 -0
- package/src/__tests__/unit/in-memory-file-store.unit.ts +74 -0
- package/src/fs/fs-file-store.ts +123 -0
- package/src/in-memory/in-memory-file-store.ts +80 -0
- package/src/index.ts +8 -0
- package/src/keys.ts +9 -0
- package/src/ports.ts +79 -0
- package/src/testing/conformance.ts +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Copyright (c) Ninemind.ai 2026.
|
|
2
|
+
Node module: AgentBack
|
|
3
|
+
This project is licensed under the MIT License, full text below.
|
|
4
|
+
|
|
5
|
+
--------
|
|
6
|
+
|
|
7
|
+
MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) Ninemind.ai 2026
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
12
|
+
this software and
|
|
13
|
+
associated documentation files (the "Software"), to deal in the Software without
|
|
14
|
+
restriction, including
|
|
15
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
16
|
+
sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is furnished
|
|
18
|
+
to do so, subject to the
|
|
19
|
+
following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial
|
|
23
|
+
portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT
|
|
27
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
|
28
|
+
AND NONINFRINGEMENT. IN NO
|
|
29
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
30
|
+
OTHER LIABILITY, WHETHER
|
|
31
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
32
|
+
CONNECTION WITH THE SOFTWARE OR THE
|
|
33
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @agentback/files
|
|
2
|
+
|
|
3
|
+
> Transport-agnostic file storage: a `FileStore` port + an in-memory adapter.
|
|
4
|
+
|
|
5
|
+
The storage seam behind AgentBack's first-class file upload/download. REST
|
|
6
|
+
handlers never touch a storage SDK — they declare a `fileField()` on a route's
|
|
7
|
+
body schema (uploads stream straight to the bound `FileStore`) and `return
|
|
8
|
+
fileDownload(...)` (downloads stream back out). Bind any `FileStore`
|
|
9
|
+
implementation at `FILE_STORE`; the [`@agentback/files-s3`](../files-s3) adapter
|
|
10
|
+
is the production one.
|
|
11
|
+
|
|
12
|
+
## The port
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import {FILE_STORE, type FileStore} from '@agentback/files';
|
|
16
|
+
|
|
17
|
+
interface FileStore {
|
|
18
|
+
put(key, body: Readable | Buffer, opts?): Promise<StoredFile>;
|
|
19
|
+
get(key): Promise<RetrievedFile>; // throws FileNotFoundError if absent
|
|
20
|
+
exists(key): Promise<boolean>;
|
|
21
|
+
delete(key): Promise<void>;
|
|
22
|
+
presignedPut?(key, opts?): Promise<string>; // optional (direct-to-storage)
|
|
23
|
+
presignedGet?(key, opts?): Promise<string>; // optional
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Keys are **opaque and server-generated** — callers pass a UUID, never a
|
|
28
|
+
client-controlled path. `get` throws `FileNotFoundError` (HTTP-free; the REST
|
|
29
|
+
layer maps it to 404).
|
|
30
|
+
|
|
31
|
+
## Flow
|
|
32
|
+
|
|
33
|
+
```mermaid
|
|
34
|
+
flowchart LR
|
|
35
|
+
C[client] -- multipart/form-data --> R[RestServer route]
|
|
36
|
+
R -- "fileField() → multipart parser" --> S[(FileStore.put<br/>UUID key)]
|
|
37
|
+
R --> H[handler: persist metadata]
|
|
38
|
+
C2[client] -- GET /files/id --> H2[handler]
|
|
39
|
+
H2 -- ownership check --> G[(FileStore.get)]
|
|
40
|
+
G -- fileDownload → pipe(res) --> C2
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Adapters
|
|
44
|
+
|
|
45
|
+
- **`InMemoryFileStore`** (here) — buffers in a `Map`; tests/dev only.
|
|
46
|
+
- **`FsFileStore`** (here) — local filesystem; streams to/from `<baseDir>/<key>`
|
|
47
|
+
with a metadata sidecar. Single-node / self-hosted / dev-with-persistence.
|
|
48
|
+
- **`S3FileStore`** (`@agentback/files-s3`) — streams to S3 via AWS SDK v3.
|
|
49
|
+
|
|
50
|
+
## Testing
|
|
51
|
+
|
|
52
|
+
`@agentback/files/testing` exports `runFileStoreConformance(label, makeStore)`
|
|
53
|
+
— the port contract as a reusable suite. Every adapter runs it:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import {runFileStoreConformance} from '@agentback/files/testing';
|
|
57
|
+
runFileStoreConformance('MyStore', () => new MyStore());
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See [`examples/hello-uploads`](../../examples/hello-uploads) for the end-to-end
|
|
61
|
+
recipe (upload, list, owner-scoped download) and `@agentback/rest`'s
|
|
62
|
+
`fileField` / `fileResponse` for the REST integration.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fs-file-store.unit.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/fs-file-store.unit.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
5
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { FsFileStore } from '../../index.js';
|
|
9
|
+
import { runFileStoreConformance } from '../../testing/conformance.js';
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'agentback-fs-'));
|
|
11
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
12
|
+
// Runs unconditionally (no external service needed) — unlike the S3 adapter.
|
|
13
|
+
runFileStoreConformance('FsFileStore', () => new FsFileStore({ baseDir: root }));
|
|
14
|
+
describe('FsFileStore specifics', () => {
|
|
15
|
+
it('writes the bytes to disk under baseDir', async () => {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
|
|
17
|
+
const store = new FsFileStore({ baseDir: dir });
|
|
18
|
+
await store.put('k1', Buffer.from('on disk'), { contentType: 'text/plain' });
|
|
19
|
+
expect(existsSync(join(dir, 'k1'))).toBe(true);
|
|
20
|
+
expect(readFileSync(join(dir, 'k1'), 'utf8')).toBe('on disk');
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
it('delete removes both the data and its metadata sidecar', async () => {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
|
|
25
|
+
const store = new FsFileStore({ baseDir: dir });
|
|
26
|
+
await store.put('k2', Buffer.from('x'), { filename: 'x.bin' });
|
|
27
|
+
expect(existsSync(join(dir, 'k2.meta.json'))).toBe(true);
|
|
28
|
+
await store.delete('k2');
|
|
29
|
+
expect(existsSync(join(dir, 'k2'))).toBe(false);
|
|
30
|
+
expect(existsSync(join(dir, 'k2.meta.json'))).toBe(false);
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
it('rejects a key that escapes the base directory', async () => {
|
|
34
|
+
const store = new FsFileStore({ baseDir: root });
|
|
35
|
+
await expect(store.put('../escape', Buffer.from('no'))).rejects.toThrow(/escapes the base directory/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=fs-file-store.unit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fs-file-store.unit.js","sourceRoot":"","sources":["../../../src/__tests__/unit/fs-file-store.unit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-memory-file-store.unit.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/in-memory-file-store.unit.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { InMemoryFileStore, FileNotFoundError } from '../../index.js';
|
|
7
|
+
import { runFileStoreConformance } from '../../testing/conformance.js';
|
|
8
|
+
runFileStoreConformance('InMemoryFileStore', () => new InMemoryFileStore());
|
|
9
|
+
/** Drain a RetrievedFile's stream back to a Buffer for assertions. */
|
|
10
|
+
async function drain(stream) {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
for await (const c of stream)
|
|
13
|
+
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
14
|
+
return Buffer.concat(chunks);
|
|
15
|
+
}
|
|
16
|
+
describe('InMemoryFileStore', () => {
|
|
17
|
+
let store;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
store = new InMemoryFileStore();
|
|
20
|
+
});
|
|
21
|
+
it('round-trips a Buffer with its metadata', async () => {
|
|
22
|
+
const put = await store.put('k1', Buffer.from('hello'), {
|
|
23
|
+
contentType: 'text/plain',
|
|
24
|
+
filename: 'greeting.txt',
|
|
25
|
+
metadata: { owner: 'u1' },
|
|
26
|
+
});
|
|
27
|
+
expect(put).toEqual({ key: 'k1', size: 5, contentType: 'text/plain' });
|
|
28
|
+
const got = await store.get('k1');
|
|
29
|
+
expect(got.size).toBe(5);
|
|
30
|
+
expect(got.contentType).toBe('text/plain');
|
|
31
|
+
expect(got.filename).toBe('greeting.txt');
|
|
32
|
+
expect(got.metadata).toEqual({ owner: 'u1' });
|
|
33
|
+
expect((await drain(got.stream)).toString()).toBe('hello');
|
|
34
|
+
});
|
|
35
|
+
it('accepts a Readable body and reports the streamed size', async () => {
|
|
36
|
+
const body = Readable.from([Buffer.from('ab'), Buffer.from('cde')]);
|
|
37
|
+
const put = await store.put('k2', body);
|
|
38
|
+
expect(put.size).toBe(5);
|
|
39
|
+
expect((await drain((await store.get('k2')).stream)).toString()).toBe('abcde');
|
|
40
|
+
});
|
|
41
|
+
it('get() throws FileNotFoundError for a missing key', async () => {
|
|
42
|
+
await expect(store.get('nope')).rejects.toBeInstanceOf(FileNotFoundError);
|
|
43
|
+
await expect(store.get('nope')).rejects.toMatchObject({
|
|
44
|
+
code: 'file_not_found',
|
|
45
|
+
key: 'nope',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
it('exists() reflects presence; delete() removes', async () => {
|
|
49
|
+
expect(await store.exists('k3')).toBe(false);
|
|
50
|
+
await store.put('k3', Buffer.from('x'));
|
|
51
|
+
expect(await store.exists('k3')).toBe(true);
|
|
52
|
+
expect(store.count).toBe(1);
|
|
53
|
+
await store.delete('k3');
|
|
54
|
+
expect(await store.exists('k3')).toBe(false);
|
|
55
|
+
expect(store.count).toBe(0);
|
|
56
|
+
// delete of a missing key is a no-op (idempotent)
|
|
57
|
+
await expect(store.delete('k3')).resolves.toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
it('does not implement the optional presigned hooks', () => {
|
|
60
|
+
const fs = store;
|
|
61
|
+
expect(fs.presignedPut).toBeUndefined();
|
|
62
|
+
expect(fs.presignedGet).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
//# sourceMappingURL=in-memory-file-store.unit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-memory-file-store.unit.js","sourceRoot":"","sources":["../../../src/__tests__/unit/in-memory-file-store.unit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { type FileStore, type PutOptions, type RetrievedFile, type StoredFile } from '../ports.js';
|
|
3
|
+
export interface FsFileStoreOptions {
|
|
4
|
+
/** Directory under which all objects live. Created on demand. */
|
|
5
|
+
baseDir: string;
|
|
6
|
+
/** Optional key prefix (a subdirectory) namespacing every object. */
|
|
7
|
+
keyPrefix?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Local-filesystem {@link FileStore}. Streams object bytes to/from
|
|
11
|
+
* `<baseDir>/<key>` and keeps a small `<key>.meta.json` sidecar for
|
|
12
|
+
* contentType/filename/metadata. Good for single-node deploys, self-hosting,
|
|
13
|
+
* and dev-with-persistence — between {@link InMemoryFileStore} and an
|
|
14
|
+
* `S3FileStore`.
|
|
15
|
+
*
|
|
16
|
+
* Every key is resolved under `baseDir` and rejected if it escapes (defense in
|
|
17
|
+
* depth — REST keys are already server-generated UUIDs, never client paths).
|
|
18
|
+
*/
|
|
19
|
+
export declare class FsFileStore implements FileStore {
|
|
20
|
+
private readonly baseDir;
|
|
21
|
+
private readonly prefix;
|
|
22
|
+
constructor(opts: FsFileStoreOptions);
|
|
23
|
+
/** Absolute data path for a key, guaranteed to stay within `baseDir`. */
|
|
24
|
+
private pathFor;
|
|
25
|
+
put(key: string, body: Readable | Buffer, opts?: PutOptions): Promise<StoredFile>;
|
|
26
|
+
get(key: string): Promise<RetrievedFile>;
|
|
27
|
+
exists(key: string): Promise<boolean>;
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
|
+
private readSidecar;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=fs-file-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fs-file-store.d.ts","sourceRoot":"","sources":["../../src/fs/fs-file-store.ts"],"names":[],"mappings":"AAOA,OAAO,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AAErC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AASrB,MAAM,WAAW,kBAAkB;IACjC,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAY,YAAW,SAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,IAAI,EAAE,kBAAkB;IAKpC,yEAAyE;IACzE,OAAO,CAAC,OAAO;IAQT,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,MAAM,EACvB,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,UAAU,CAAC;IAehB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAsBxC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IASrC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAM1B,WAAW;CAO1B"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
5
|
+
import { access, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { FileNotFoundError, } from '../ports.js';
|
|
10
|
+
/**
|
|
11
|
+
* Local-filesystem {@link FileStore}. Streams object bytes to/from
|
|
12
|
+
* `<baseDir>/<key>` and keeps a small `<key>.meta.json` sidecar for
|
|
13
|
+
* contentType/filename/metadata. Good for single-node deploys, self-hosting,
|
|
14
|
+
* and dev-with-persistence — between {@link InMemoryFileStore} and an
|
|
15
|
+
* `S3FileStore`.
|
|
16
|
+
*
|
|
17
|
+
* Every key is resolved under `baseDir` and rejected if it escapes (defense in
|
|
18
|
+
* depth — REST keys are already server-generated UUIDs, never client paths).
|
|
19
|
+
*/
|
|
20
|
+
export class FsFileStore {
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.baseDir = resolve(opts.baseDir);
|
|
23
|
+
this.prefix = opts.keyPrefix ?? '';
|
|
24
|
+
}
|
|
25
|
+
/** Absolute data path for a key, guaranteed to stay within `baseDir`. */
|
|
26
|
+
pathFor(key) {
|
|
27
|
+
const full = resolve(this.baseDir, this.prefix + key);
|
|
28
|
+
if (full !== this.baseDir && !full.startsWith(this.baseDir + sep)) {
|
|
29
|
+
throw new Error(`Refusing a key that escapes the base directory: ${key}`);
|
|
30
|
+
}
|
|
31
|
+
return full;
|
|
32
|
+
}
|
|
33
|
+
async put(key, body, opts = {}) {
|
|
34
|
+
const p = this.pathFor(key);
|
|
35
|
+
await mkdir(dirname(p), { recursive: true });
|
|
36
|
+
const source = Buffer.isBuffer(body) ? Readable.from(body) : body;
|
|
37
|
+
await pipeline(source, createWriteStream(p));
|
|
38
|
+
const sidecar = {
|
|
39
|
+
...(opts.contentType ? { contentType: opts.contentType } : {}),
|
|
40
|
+
...(opts.filename ? { filename: opts.filename } : {}),
|
|
41
|
+
...(opts.metadata ? { metadata: opts.metadata } : {}),
|
|
42
|
+
};
|
|
43
|
+
await writeFile(`${p}.meta.json`, JSON.stringify(sidecar));
|
|
44
|
+
const { size } = await stat(p);
|
|
45
|
+
return { key, size, contentType: opts.contentType };
|
|
46
|
+
}
|
|
47
|
+
async get(key) {
|
|
48
|
+
const p = this.pathFor(key);
|
|
49
|
+
let size;
|
|
50
|
+
try {
|
|
51
|
+
({ size } = await stat(p));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err.code === 'ENOENT') {
|
|
55
|
+
throw new FileNotFoundError(key);
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
const sidecar = await this.readSidecar(p);
|
|
60
|
+
return {
|
|
61
|
+
key,
|
|
62
|
+
stream: createReadStream(p),
|
|
63
|
+
size,
|
|
64
|
+
contentType: sidecar.contentType,
|
|
65
|
+
filename: sidecar.filename,
|
|
66
|
+
metadata: sidecar.metadata,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async exists(key) {
|
|
70
|
+
try {
|
|
71
|
+
await access(this.pathFor(key));
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async delete(key) {
|
|
79
|
+
const p = this.pathFor(key);
|
|
80
|
+
await rm(p, { force: true });
|
|
81
|
+
await rm(`${p}.meta.json`, { force: true });
|
|
82
|
+
}
|
|
83
|
+
async readSidecar(p) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(await readFile(`${p}.meta.json`, 'utf8'));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=fs-file-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fs-file-store.js","sourceRoot":"","sources":["../../src/fs/fs-file-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { type FileStore, type PutOptions, type RetrievedFile, type StoredFile } from '../ports.js';
|
|
3
|
+
/**
|
|
4
|
+
* In-memory {@link FileStore} for tests and local dev. Each object is buffered
|
|
5
|
+
* whole in a `Map`, so it is NOT for production — no streaming-to-disk and no
|
|
6
|
+
* size cap (a large upload is held in memory). Use an `S3FileStore` (or similar
|
|
7
|
+
* streaming adapter) in production.
|
|
8
|
+
*/
|
|
9
|
+
export declare class InMemoryFileStore implements FileStore {
|
|
10
|
+
private readonly store;
|
|
11
|
+
put(key: string, body: Readable | Buffer, opts?: PutOptions): Promise<StoredFile>;
|
|
12
|
+
get(key: string): Promise<RetrievedFile>;
|
|
13
|
+
exists(key: string): Promise<boolean>;
|
|
14
|
+
delete(key: string): Promise<void>;
|
|
15
|
+
/** Test helper: number of stored objects. */
|
|
16
|
+
get count(): number;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=in-memory-file-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-memory-file-store.d.ts","sourceRoot":"","sources":["../../src/in-memory/in-memory-file-store.ts"],"names":[],"mappings":"AAIA,OAAO,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AACrC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AASrB;;;;;GAKG;AACH,qBAAa,iBAAkB,YAAW,SAAS;IACjD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAE5C,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,MAAM,EACvB,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,UAAU,CAAC;IAWhB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAaxC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIrC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC,6CAA6C;IAC7C,IAAI,KAAK,IAAI,MAAM,CAElB;CACF"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { FileNotFoundError, } from '../ports.js';
|
|
6
|
+
/**
|
|
7
|
+
* In-memory {@link FileStore} for tests and local dev. Each object is buffered
|
|
8
|
+
* whole in a `Map`, so it is NOT for production — no streaming-to-disk and no
|
|
9
|
+
* size cap (a large upload is held in memory). Use an `S3FileStore` (or similar
|
|
10
|
+
* streaming adapter) in production.
|
|
11
|
+
*/
|
|
12
|
+
export class InMemoryFileStore {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.store = new Map();
|
|
15
|
+
}
|
|
16
|
+
async put(key, body, opts = {}) {
|
|
17
|
+
const buffer = await toBuffer(body);
|
|
18
|
+
this.store.set(key, {
|
|
19
|
+
buffer,
|
|
20
|
+
contentType: opts.contentType,
|
|
21
|
+
filename: opts.filename,
|
|
22
|
+
metadata: opts.metadata,
|
|
23
|
+
});
|
|
24
|
+
return { key, size: buffer.byteLength, contentType: opts.contentType };
|
|
25
|
+
}
|
|
26
|
+
async get(key) {
|
|
27
|
+
const e = this.store.get(key);
|
|
28
|
+
if (!e)
|
|
29
|
+
throw new FileNotFoundError(key);
|
|
30
|
+
return {
|
|
31
|
+
key,
|
|
32
|
+
stream: Readable.from(e.buffer),
|
|
33
|
+
size: e.buffer.byteLength,
|
|
34
|
+
contentType: e.contentType,
|
|
35
|
+
filename: e.filename,
|
|
36
|
+
metadata: e.metadata,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async exists(key) {
|
|
40
|
+
return this.store.has(key);
|
|
41
|
+
}
|
|
42
|
+
async delete(key) {
|
|
43
|
+
this.store.delete(key);
|
|
44
|
+
}
|
|
45
|
+
/** Test helper: number of stored objects. */
|
|
46
|
+
get count() {
|
|
47
|
+
return this.store.size;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Collect a Buffer or a Readable into a single Buffer. */
|
|
51
|
+
async function toBuffer(body) {
|
|
52
|
+
if (Buffer.isBuffer(body))
|
|
53
|
+
return body;
|
|
54
|
+
const chunks = [];
|
|
55
|
+
for await (const chunk of body) {
|
|
56
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
57
|
+
}
|
|
58
|
+
return Buffer.concat(chunks);
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=in-memory-file-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-memory-file-store.js","sourceRoot":"","sources":["../../src/in-memory/in-memory-file-store.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAEhE,OAAO,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AACrC,OAAO,EACL,iBAAiB,GAKlB,MAAM,aAAa,CAAC;AASrB;;;;;GAKG;AACH,MAAM,OAAO,iBAAiB;IAA9B;QACmB,UAAK,GAAG,IAAI,GAAG,EAAiB,CAAC;IA0CpD,CAAC;IAxCC,KAAK,CAAC,GAAG,CACP,GAAW,EACX,IAAuB,EACvB,OAAmB,EAAE;QAErB,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE;YAClB,MAAM;YACN,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,QAAQ,EAAE,IAAI,CAAC,QAAQ;SACxB,CAAC,CAAC;QACH,OAAO,EAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,UAAU,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAC,CAAC;IACvE,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,CAAC,CAAC;YAAE,MAAM,IAAI,iBAAiB,CAAC,GAAG,CAAC,CAAC;QACzC,OAAO;YACL,GAAG;YACH,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC;YAC/B,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,UAAU;YACzB,WAAW,EAAE,CAAC,CAAC,WAAW;YAC1B,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,QAAQ,EAAE,CAAC,CAAC,QAAQ;SACrB,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAED,6CAA6C;IAC7C,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC;IACzB,CAAC;CACF;AAED,2DAA2D;AAC3D,KAAK,UAAU,QAAQ,CAAC,IAAuB;IAC7C,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACvC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QAC/B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAmB,CAAC,CAAC,CAAC;IACjF,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAC,iBAAiB,EAAC,MAAM,qCAAqC,CAAC;AACtE,OAAO,EAAC,WAAW,EAAE,KAAK,kBAAkB,EAAC,MAAM,uBAAuB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
export * from './ports.js';
|
|
5
|
+
export * from './keys.js';
|
|
6
|
+
export { InMemoryFileStore } from './in-memory/in-memory-file-store.js';
|
|
7
|
+
export { FsFileStore } from './fs/fs-file-store.js';
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAEhE,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAC,iBAAiB,EAAC,MAAM,qCAAqC,CAAC;AACtE,OAAO,EAAC,WAAW,EAA0B,MAAM,uBAAuB,CAAC"}
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAIA,OAAO,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAC3C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAE1C,+EAA+E;AAC/E,eAAO,MAAM,UAAU,uBAA8C,CAAC"}
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
import { BindingKey } from '@agentback/core';
|
|
5
|
+
/** DI key for the active {@link FileStore}. Bind an adapter; inject to use. */
|
|
6
|
+
export const FILE_STORE = BindingKey.create('files.store');
|
|
7
|
+
//# sourceMappingURL=keys.js.map
|
package/dist/keys.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAEhE,OAAO,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAG3C,+EAA+E;AAC/E,MAAM,CAAC,MAAM,UAAU,GAAG,UAAU,CAAC,MAAM,CAAY,aAAa,CAAC,CAAC"}
|
package/dist/ports.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream';
|
|
2
|
+
/** Options accepted when storing a file. */
|
|
3
|
+
export interface PutOptions {
|
|
4
|
+
/** MIME type recorded alongside the bytes (echoed on `get`). */
|
|
5
|
+
contentType?: string;
|
|
6
|
+
/** Original filename, surfaced on download as `Content-Disposition`. */
|
|
7
|
+
filename?: string;
|
|
8
|
+
/** Arbitrary string metadata persisted with the object. */
|
|
9
|
+
metadata?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
/** Result of a successful {@link FileStore.put}. */
|
|
12
|
+
export interface StoredFile {
|
|
13
|
+
key: string;
|
|
14
|
+
size: number;
|
|
15
|
+
contentType?: string;
|
|
16
|
+
/** Backend entity tag (e.g. S3 ETag), when the adapter provides one. */
|
|
17
|
+
etag?: string;
|
|
18
|
+
}
|
|
19
|
+
/** A retrieved file: a readable byte stream plus the metadata stored with it. */
|
|
20
|
+
export interface RetrievedFile {
|
|
21
|
+
key: string;
|
|
22
|
+
stream: Readable;
|
|
23
|
+
size: number;
|
|
24
|
+
contentType?: string;
|
|
25
|
+
filename?: string;
|
|
26
|
+
metadata?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
/** Options for the optional presigned-URL hooks. */
|
|
29
|
+
export interface PresignOptions {
|
|
30
|
+
/** URL lifetime in seconds. Adapter chooses a default when omitted. */
|
|
31
|
+
expiresInSec?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Transport-agnostic file storage seam. Adapters (in-memory, S3, …) implement
|
|
35
|
+
* it; REST handlers stream uploads in via {@link put} and downloads out via
|
|
36
|
+
* {@link get}. Bind an implementation at {@link FILE_STORE} and inject it.
|
|
37
|
+
*
|
|
38
|
+
* Keys are opaque to the store — callers MUST generate them server-side (e.g. a
|
|
39
|
+
* UUID); never pass a client-controlled path, or an S3 adapter is open to
|
|
40
|
+
* key-traversal. `get` throws {@link FileNotFoundError} for a missing key.
|
|
41
|
+
*
|
|
42
|
+
* `presignedPut`/`presignedGet` are optional: a server-proxied adapter omits
|
|
43
|
+
* them; a direct-to-storage adapter implements them. They are declared here so
|
|
44
|
+
* a future presigned flow is an additive capability, not a breaking change.
|
|
45
|
+
*/
|
|
46
|
+
export interface FileStore {
|
|
47
|
+
put(key: string, body: Readable | Buffer, opts?: PutOptions): Promise<StoredFile>;
|
|
48
|
+
get(key: string): Promise<RetrievedFile>;
|
|
49
|
+
exists(key: string): Promise<boolean>;
|
|
50
|
+
delete(key: string): Promise<void>;
|
|
51
|
+
presignedPut?(key: string, opts?: PutOptions & PresignOptions): Promise<string>;
|
|
52
|
+
presignedGet?(key: string, opts?: PresignOptions): Promise<string>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Thrown by {@link FileStore.get} when no object exists at the key. The REST
|
|
56
|
+
* layer maps this to a 404 (it stays here so the port carries no HTTP/openapi
|
|
57
|
+
* dependency).
|
|
58
|
+
*/
|
|
59
|
+
export declare class FileNotFoundError extends Error {
|
|
60
|
+
readonly key: string;
|
|
61
|
+
readonly code = "file_not_found";
|
|
62
|
+
constructor(key: string);
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ports.d.ts","sourceRoot":"","sources":["../src/ports.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AAE1C,4CAA4C;AAC5C,MAAM,WAAW,UAAU;IACzB,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED,oDAAoD;AACpD,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,QAAQ,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED,oDAAoD;AACpD,MAAM,WAAW,cAAc;IAC7B,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,SAAS;IACxB,GAAG,CACD,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,MAAM,EACvB,IAAI,CAAC,EAAE,UAAU,GAChB,OAAO,CAAC,UAAU,CAAC,CAAC;IACvB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,YAAY,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChF,YAAY,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACpE;AAED;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;IAE9B,QAAQ,CAAC,GAAG,EAAE,MAAM;IADhC,QAAQ,CAAC,IAAI,oBAAoB;gBACZ,GAAG,EAAE,MAAM;CAIjC"}
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
/**
|
|
5
|
+
* Thrown by {@link FileStore.get} when no object exists at the key. The REST
|
|
6
|
+
* layer maps this to a 404 (it stays here so the port carries no HTTP/openapi
|
|
7
|
+
* dependency).
|
|
8
|
+
*/
|
|
9
|
+
export class FileNotFoundError extends Error {
|
|
10
|
+
constructor(key) {
|
|
11
|
+
super(`No file stored at key '${key}'.`);
|
|
12
|
+
this.key = key;
|
|
13
|
+
this.code = 'file_not_found';
|
|
14
|
+
this.name = 'FileNotFoundError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=ports.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ports.js","sourceRoot":"","sources":["../src/ports.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,+CAA+C;AAC/C,gEAAgE;AAiEhE;;;;GAIG;AACH,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IAE1C,YAAqB,GAAW;QAC9B,KAAK,CAAC,0BAA0B,GAAG,IAAI,CAAC,CAAC;QADtB,QAAG,GAAH,GAAG,CAAQ;QADvB,SAAI,GAAG,gBAAgB,CAAC;QAG/B,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IAClC,CAAC;CACF"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type FileStore } from '../ports.js';
|
|
2
|
+
/**
|
|
3
|
+
* The {@link FileStore} contract, as a reusable suite. Every adapter
|
|
4
|
+
* (in-memory, S3, …) runs it to prove it honors the port. `makeStore` returns
|
|
5
|
+
* a fresh (or shared) store; keys are unique per assertion so a real bucket is
|
|
6
|
+
* safe.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runFileStoreConformance(label: string, makeStore: () => FileStore | Promise<FileStore>): void;
|
|
9
|
+
//# sourceMappingURL=conformance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conformance.d.ts","sourceRoot":"","sources":["../../src/testing/conformance.ts"],"names":[],"mappings":"AAMA,OAAO,EAAoB,KAAK,SAAS,EAAC,MAAM,aAAa,CAAC;AAc9D;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,GAC9C,IAAI,CA0CN"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { FileNotFoundError } from '../ports.js';
|
|
7
|
+
async function drain(stream) {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
for await (const c of stream)
|
|
10
|
+
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
11
|
+
return Buffer.concat(chunks);
|
|
12
|
+
}
|
|
13
|
+
let n = 0;
|
|
14
|
+
/** A unique key per call so a shared backing bucket stays collision-free. */
|
|
15
|
+
function uniqueKey(tag) {
|
|
16
|
+
return `agentback-conformance/${tag}-${process.pid}-${++n}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The {@link FileStore} contract, as a reusable suite. Every adapter
|
|
20
|
+
* (in-memory, S3, …) runs it to prove it honors the port. `makeStore` returns
|
|
21
|
+
* a fresh (or shared) store; keys are unique per assertion so a real bucket is
|
|
22
|
+
* safe.
|
|
23
|
+
*/
|
|
24
|
+
export function runFileStoreConformance(label, makeStore) {
|
|
25
|
+
describe(`FileStore conformance: ${label}`, () => {
|
|
26
|
+
it('put → get round-trips bytes, contentType, size', async () => {
|
|
27
|
+
const store = await makeStore();
|
|
28
|
+
const key = uniqueKey('roundtrip');
|
|
29
|
+
const put = await store.put(key, Buffer.from('payload'), {
|
|
30
|
+
contentType: 'application/octet-stream',
|
|
31
|
+
filename: 'p.bin',
|
|
32
|
+
});
|
|
33
|
+
expect(put.size).toBe(7);
|
|
34
|
+
const got = await store.get(key);
|
|
35
|
+
expect((await drain(got.stream)).toString()).toBe('payload');
|
|
36
|
+
expect(got.contentType).toBe('application/octet-stream');
|
|
37
|
+
expect(got.size).toBe(7);
|
|
38
|
+
await store.delete(key);
|
|
39
|
+
});
|
|
40
|
+
it('accepts a Readable body', async () => {
|
|
41
|
+
const store = await makeStore();
|
|
42
|
+
const key = uniqueKey('stream');
|
|
43
|
+
await store.put(key, Readable.from([Buffer.from('ab'), Buffer.from('c')]));
|
|
44
|
+
expect((await drain((await store.get(key)).stream)).toString()).toBe('abc');
|
|
45
|
+
await store.delete(key);
|
|
46
|
+
});
|
|
47
|
+
it('exists reflects presence; delete removes', async () => {
|
|
48
|
+
const store = await makeStore();
|
|
49
|
+
const key = uniqueKey('lifecycle');
|
|
50
|
+
expect(await store.exists(key)).toBe(false);
|
|
51
|
+
await store.put(key, Buffer.from('x'));
|
|
52
|
+
expect(await store.exists(key)).toBe(true);
|
|
53
|
+
await store.delete(key);
|
|
54
|
+
expect(await store.exists(key)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
it('get of a missing key throws FileNotFoundError', async () => {
|
|
57
|
+
const store = await makeStore();
|
|
58
|
+
await expect(store.get(uniqueKey('missing'))).rejects.toBeInstanceOf(FileNotFoundError);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=conformance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conformance.js","sourceRoot":"","sources":["../../src/testing/conformance.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentback/files",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Transport-agnostic file storage port (FileStore) with an in-memory adapter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./testing": {
|
|
14
|
+
"types": "./dist/testing/conformance.d.ts",
|
|
15
|
+
"import": "./dist/testing/conformance.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"tslib": "^2.8.1",
|
|
24
|
+
"@agentback/core": "~0.3.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"vitest": "~4.1.8"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.13"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -b",
|
|
38
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
import {afterAll, describe, expect, it} from 'vitest';
|
|
6
|
+
import {existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs';
|
|
7
|
+
import {tmpdir} from 'node:os';
|
|
8
|
+
import {join} from 'node:path';
|
|
9
|
+
import {FsFileStore} from '../../index.js';
|
|
10
|
+
import {runFileStoreConformance} from '../../testing/conformance.js';
|
|
11
|
+
|
|
12
|
+
const root = mkdtempSync(join(tmpdir(), 'agentback-fs-'));
|
|
13
|
+
afterAll(() => rmSync(root, {recursive: true, force: true}));
|
|
14
|
+
|
|
15
|
+
// Runs unconditionally (no external service needed) — unlike the S3 adapter.
|
|
16
|
+
runFileStoreConformance('FsFileStore', () => new FsFileStore({baseDir: root}));
|
|
17
|
+
|
|
18
|
+
describe('FsFileStore specifics', () => {
|
|
19
|
+
it('writes the bytes to disk under baseDir', async () => {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
|
|
21
|
+
const store = new FsFileStore({baseDir: dir});
|
|
22
|
+
await store.put('k1', Buffer.from('on disk'), {contentType: 'text/plain'});
|
|
23
|
+
expect(existsSync(join(dir, 'k1'))).toBe(true);
|
|
24
|
+
expect(readFileSync(join(dir, 'k1'), 'utf8')).toBe('on disk');
|
|
25
|
+
rmSync(dir, {recursive: true, force: true});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('delete removes both the data and its metadata sidecar', async () => {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'agentback-fs-spec-'));
|
|
30
|
+
const store = new FsFileStore({baseDir: dir});
|
|
31
|
+
await store.put('k2', Buffer.from('x'), {filename: 'x.bin'});
|
|
32
|
+
expect(existsSync(join(dir, 'k2.meta.json'))).toBe(true);
|
|
33
|
+
await store.delete('k2');
|
|
34
|
+
expect(existsSync(join(dir, 'k2'))).toBe(false);
|
|
35
|
+
expect(existsSync(join(dir, 'k2.meta.json'))).toBe(false);
|
|
36
|
+
rmSync(dir, {recursive: true, force: true});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects a key that escapes the base directory', async () => {
|
|
40
|
+
const store = new FsFileStore({baseDir: root});
|
|
41
|
+
await expect(store.put('../escape', Buffer.from('no'))).rejects.toThrow(
|
|
42
|
+
/escapes the base directory/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
import {Readable} from 'node:stream';
|
|
6
|
+
import {describe, it, expect, beforeEach} from 'vitest';
|
|
7
|
+
import {InMemoryFileStore, FileNotFoundError, type FileStore} from '../../index.js';
|
|
8
|
+
import {runFileStoreConformance} from '../../testing/conformance.js';
|
|
9
|
+
|
|
10
|
+
runFileStoreConformance('InMemoryFileStore', () => new InMemoryFileStore());
|
|
11
|
+
|
|
12
|
+
/** Drain a RetrievedFile's stream back to a Buffer for assertions. */
|
|
13
|
+
async function drain(stream: Readable): Promise<Buffer> {
|
|
14
|
+
const chunks: Buffer[] = [];
|
|
15
|
+
for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
16
|
+
return Buffer.concat(chunks);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('InMemoryFileStore', () => {
|
|
20
|
+
let store: InMemoryFileStore;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
store = new InMemoryFileStore();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('round-trips a Buffer with its metadata', async () => {
|
|
26
|
+
const put = await store.put('k1', Buffer.from('hello'), {
|
|
27
|
+
contentType: 'text/plain',
|
|
28
|
+
filename: 'greeting.txt',
|
|
29
|
+
metadata: {owner: 'u1'},
|
|
30
|
+
});
|
|
31
|
+
expect(put).toEqual({key: 'k1', size: 5, contentType: 'text/plain'});
|
|
32
|
+
|
|
33
|
+
const got = await store.get('k1');
|
|
34
|
+
expect(got.size).toBe(5);
|
|
35
|
+
expect(got.contentType).toBe('text/plain');
|
|
36
|
+
expect(got.filename).toBe('greeting.txt');
|
|
37
|
+
expect(got.metadata).toEqual({owner: 'u1'});
|
|
38
|
+
expect((await drain(got.stream)).toString()).toBe('hello');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts a Readable body and reports the streamed size', async () => {
|
|
42
|
+
const body = Readable.from([Buffer.from('ab'), Buffer.from('cde')]);
|
|
43
|
+
const put = await store.put('k2', body);
|
|
44
|
+
expect(put.size).toBe(5);
|
|
45
|
+
expect((await drain((await store.get('k2')).stream)).toString()).toBe('abcde');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('get() throws FileNotFoundError for a missing key', async () => {
|
|
49
|
+
await expect(store.get('nope')).rejects.toBeInstanceOf(FileNotFoundError);
|
|
50
|
+
await expect(store.get('nope')).rejects.toMatchObject({
|
|
51
|
+
code: 'file_not_found',
|
|
52
|
+
key: 'nope',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('exists() reflects presence; delete() removes', async () => {
|
|
57
|
+
expect(await store.exists('k3')).toBe(false);
|
|
58
|
+
await store.put('k3', Buffer.from('x'));
|
|
59
|
+
expect(await store.exists('k3')).toBe(true);
|
|
60
|
+
expect(store.count).toBe(1);
|
|
61
|
+
|
|
62
|
+
await store.delete('k3');
|
|
63
|
+
expect(await store.exists('k3')).toBe(false);
|
|
64
|
+
expect(store.count).toBe(0);
|
|
65
|
+
// delete of a missing key is a no-op (idempotent)
|
|
66
|
+
await expect(store.delete('k3')).resolves.toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not implement the optional presigned hooks', () => {
|
|
70
|
+
const fs: FileStore = store;
|
|
71
|
+
expect(fs.presignedPut).toBeUndefined();
|
|
72
|
+
expect(fs.presignedGet).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
import {createReadStream, createWriteStream} from 'node:fs';
|
|
6
|
+
import {access, mkdir, readFile, rm, stat, writeFile} from 'node:fs/promises';
|
|
7
|
+
import {dirname, resolve, sep} from 'node:path';
|
|
8
|
+
import {Readable} from 'node:stream';
|
|
9
|
+
import {pipeline} from 'node:stream/promises';
|
|
10
|
+
import {
|
|
11
|
+
FileNotFoundError,
|
|
12
|
+
type FileStore,
|
|
13
|
+
type PutOptions,
|
|
14
|
+
type RetrievedFile,
|
|
15
|
+
type StoredFile,
|
|
16
|
+
} from '../ports.js';
|
|
17
|
+
|
|
18
|
+
/** Per-object metadata persisted next to the bytes as `<key>.meta.json`. */
|
|
19
|
+
interface Sidecar {
|
|
20
|
+
contentType?: string;
|
|
21
|
+
filename?: string;
|
|
22
|
+
metadata?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FsFileStoreOptions {
|
|
26
|
+
/** Directory under which all objects live. Created on demand. */
|
|
27
|
+
baseDir: string;
|
|
28
|
+
/** Optional key prefix (a subdirectory) namespacing every object. */
|
|
29
|
+
keyPrefix?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Local-filesystem {@link FileStore}. Streams object bytes to/from
|
|
34
|
+
* `<baseDir>/<key>` and keeps a small `<key>.meta.json` sidecar for
|
|
35
|
+
* contentType/filename/metadata. Good for single-node deploys, self-hosting,
|
|
36
|
+
* and dev-with-persistence — between {@link InMemoryFileStore} and an
|
|
37
|
+
* `S3FileStore`.
|
|
38
|
+
*
|
|
39
|
+
* Every key is resolved under `baseDir` and rejected if it escapes (defense in
|
|
40
|
+
* depth — REST keys are already server-generated UUIDs, never client paths).
|
|
41
|
+
*/
|
|
42
|
+
export class FsFileStore implements FileStore {
|
|
43
|
+
private readonly baseDir: string;
|
|
44
|
+
private readonly prefix: string;
|
|
45
|
+
|
|
46
|
+
constructor(opts: FsFileStoreOptions) {
|
|
47
|
+
this.baseDir = resolve(opts.baseDir);
|
|
48
|
+
this.prefix = opts.keyPrefix ?? '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Absolute data path for a key, guaranteed to stay within `baseDir`. */
|
|
52
|
+
private pathFor(key: string): string {
|
|
53
|
+
const full = resolve(this.baseDir, this.prefix + key);
|
|
54
|
+
if (full !== this.baseDir && !full.startsWith(this.baseDir + sep)) {
|
|
55
|
+
throw new Error(`Refusing a key that escapes the base directory: ${key}`);
|
|
56
|
+
}
|
|
57
|
+
return full;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async put(
|
|
61
|
+
key: string,
|
|
62
|
+
body: Readable | Buffer,
|
|
63
|
+
opts: PutOptions = {},
|
|
64
|
+
): Promise<StoredFile> {
|
|
65
|
+
const p = this.pathFor(key);
|
|
66
|
+
await mkdir(dirname(p), {recursive: true});
|
|
67
|
+
const source = Buffer.isBuffer(body) ? Readable.from(body) : body;
|
|
68
|
+
await pipeline(source, createWriteStream(p));
|
|
69
|
+
const sidecar: Sidecar = {
|
|
70
|
+
...(opts.contentType ? {contentType: opts.contentType} : {}),
|
|
71
|
+
...(opts.filename ? {filename: opts.filename} : {}),
|
|
72
|
+
...(opts.metadata ? {metadata: opts.metadata} : {}),
|
|
73
|
+
};
|
|
74
|
+
await writeFile(`${p}.meta.json`, JSON.stringify(sidecar));
|
|
75
|
+
const {size} = await stat(p);
|
|
76
|
+
return {key, size, contentType: opts.contentType};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async get(key: string): Promise<RetrievedFile> {
|
|
80
|
+
const p = this.pathFor(key);
|
|
81
|
+
let size: number;
|
|
82
|
+
try {
|
|
83
|
+
({size} = await stat(p));
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
86
|
+
throw new FileNotFoundError(key);
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
const sidecar = await this.readSidecar(p);
|
|
91
|
+
return {
|
|
92
|
+
key,
|
|
93
|
+
stream: createReadStream(p),
|
|
94
|
+
size,
|
|
95
|
+
contentType: sidecar.contentType,
|
|
96
|
+
filename: sidecar.filename,
|
|
97
|
+
metadata: sidecar.metadata,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async exists(key: string): Promise<boolean> {
|
|
102
|
+
try {
|
|
103
|
+
await access(this.pathFor(key));
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async delete(key: string): Promise<void> {
|
|
111
|
+
const p = this.pathFor(key);
|
|
112
|
+
await rm(p, {force: true});
|
|
113
|
+
await rm(`${p}.meta.json`, {force: true});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async readSidecar(p: string): Promise<Sidecar> {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(await readFile(`${p}.meta.json`, 'utf8')) as Sidecar;
|
|
119
|
+
} catch {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
import {Readable} from 'node:stream';
|
|
6
|
+
import {
|
|
7
|
+
FileNotFoundError,
|
|
8
|
+
type FileStore,
|
|
9
|
+
type PutOptions,
|
|
10
|
+
type RetrievedFile,
|
|
11
|
+
type StoredFile,
|
|
12
|
+
} from '../ports.js';
|
|
13
|
+
|
|
14
|
+
interface Entry {
|
|
15
|
+
buffer: Buffer;
|
|
16
|
+
contentType?: string;
|
|
17
|
+
filename?: string;
|
|
18
|
+
metadata?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* In-memory {@link FileStore} for tests and local dev. Each object is buffered
|
|
23
|
+
* whole in a `Map`, so it is NOT for production — no streaming-to-disk and no
|
|
24
|
+
* size cap (a large upload is held in memory). Use an `S3FileStore` (or similar
|
|
25
|
+
* streaming adapter) in production.
|
|
26
|
+
*/
|
|
27
|
+
export class InMemoryFileStore implements FileStore {
|
|
28
|
+
private readonly store = new Map<string, Entry>();
|
|
29
|
+
|
|
30
|
+
async put(
|
|
31
|
+
key: string,
|
|
32
|
+
body: Readable | Buffer,
|
|
33
|
+
opts: PutOptions = {},
|
|
34
|
+
): Promise<StoredFile> {
|
|
35
|
+
const buffer = await toBuffer(body);
|
|
36
|
+
this.store.set(key, {
|
|
37
|
+
buffer,
|
|
38
|
+
contentType: opts.contentType,
|
|
39
|
+
filename: opts.filename,
|
|
40
|
+
metadata: opts.metadata,
|
|
41
|
+
});
|
|
42
|
+
return {key, size: buffer.byteLength, contentType: opts.contentType};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(key: string): Promise<RetrievedFile> {
|
|
46
|
+
const e = this.store.get(key);
|
|
47
|
+
if (!e) throw new FileNotFoundError(key);
|
|
48
|
+
return {
|
|
49
|
+
key,
|
|
50
|
+
stream: Readable.from(e.buffer),
|
|
51
|
+
size: e.buffer.byteLength,
|
|
52
|
+
contentType: e.contentType,
|
|
53
|
+
filename: e.filename,
|
|
54
|
+
metadata: e.metadata,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async exists(key: string): Promise<boolean> {
|
|
59
|
+
return this.store.has(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async delete(key: string): Promise<void> {
|
|
63
|
+
this.store.delete(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Test helper: number of stored objects. */
|
|
67
|
+
get count(): number {
|
|
68
|
+
return this.store.size;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Collect a Buffer or a Readable into a single Buffer. */
|
|
73
|
+
async function toBuffer(body: Readable | Buffer): Promise<Buffer> {
|
|
74
|
+
if (Buffer.isBuffer(body)) return body;
|
|
75
|
+
const chunks: Buffer[] = [];
|
|
76
|
+
for await (const chunk of body) {
|
|
77
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
|
78
|
+
}
|
|
79
|
+
return Buffer.concat(chunks);
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
export * from './ports.js';
|
|
6
|
+
export * from './keys.js';
|
|
7
|
+
export {InMemoryFileStore} from './in-memory/in-memory-file-store.js';
|
|
8
|
+
export {FsFileStore, type FsFileStoreOptions} from './fs/fs-file-store.js';
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
import {BindingKey} from '@agentback/core';
|
|
6
|
+
import type {FileStore} from './ports.js';
|
|
7
|
+
|
|
8
|
+
/** DI key for the active {@link FileStore}. Bind an adapter; inject to use. */
|
|
9
|
+
export const FILE_STORE = BindingKey.create<FileStore>('files.store');
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
import type {Readable} from 'node:stream';
|
|
6
|
+
|
|
7
|
+
/** Options accepted when storing a file. */
|
|
8
|
+
export interface PutOptions {
|
|
9
|
+
/** MIME type recorded alongside the bytes (echoed on `get`). */
|
|
10
|
+
contentType?: string;
|
|
11
|
+
/** Original filename, surfaced on download as `Content-Disposition`. */
|
|
12
|
+
filename?: string;
|
|
13
|
+
/** Arbitrary string metadata persisted with the object. */
|
|
14
|
+
metadata?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Result of a successful {@link FileStore.put}. */
|
|
18
|
+
export interface StoredFile {
|
|
19
|
+
key: string;
|
|
20
|
+
size: number;
|
|
21
|
+
contentType?: string;
|
|
22
|
+
/** Backend entity tag (e.g. S3 ETag), when the adapter provides one. */
|
|
23
|
+
etag?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A retrieved file: a readable byte stream plus the metadata stored with it. */
|
|
27
|
+
export interface RetrievedFile {
|
|
28
|
+
key: string;
|
|
29
|
+
stream: Readable;
|
|
30
|
+
size: number;
|
|
31
|
+
contentType?: string;
|
|
32
|
+
filename?: string;
|
|
33
|
+
metadata?: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Options for the optional presigned-URL hooks. */
|
|
37
|
+
export interface PresignOptions {
|
|
38
|
+
/** URL lifetime in seconds. Adapter chooses a default when omitted. */
|
|
39
|
+
expiresInSec?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Transport-agnostic file storage seam. Adapters (in-memory, S3, …) implement
|
|
44
|
+
* it; REST handlers stream uploads in via {@link put} and downloads out via
|
|
45
|
+
* {@link get}. Bind an implementation at {@link FILE_STORE} and inject it.
|
|
46
|
+
*
|
|
47
|
+
* Keys are opaque to the store — callers MUST generate them server-side (e.g. a
|
|
48
|
+
* UUID); never pass a client-controlled path, or an S3 adapter is open to
|
|
49
|
+
* key-traversal. `get` throws {@link FileNotFoundError} for a missing key.
|
|
50
|
+
*
|
|
51
|
+
* `presignedPut`/`presignedGet` are optional: a server-proxied adapter omits
|
|
52
|
+
* them; a direct-to-storage adapter implements them. They are declared here so
|
|
53
|
+
* a future presigned flow is an additive capability, not a breaking change.
|
|
54
|
+
*/
|
|
55
|
+
export interface FileStore {
|
|
56
|
+
put(
|
|
57
|
+
key: string,
|
|
58
|
+
body: Readable | Buffer,
|
|
59
|
+
opts?: PutOptions,
|
|
60
|
+
): Promise<StoredFile>;
|
|
61
|
+
get(key: string): Promise<RetrievedFile>;
|
|
62
|
+
exists(key: string): Promise<boolean>;
|
|
63
|
+
delete(key: string): Promise<void>;
|
|
64
|
+
presignedPut?(key: string, opts?: PutOptions & PresignOptions): Promise<string>;
|
|
65
|
+
presignedGet?(key: string, opts?: PresignOptions): Promise<string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Thrown by {@link FileStore.get} when no object exists at the key. The REST
|
|
70
|
+
* layer maps this to a 404 (it stays here so the port carries no HTTP/openapi
|
|
71
|
+
* dependency).
|
|
72
|
+
*/
|
|
73
|
+
export class FileNotFoundError extends Error {
|
|
74
|
+
readonly code = 'file_not_found';
|
|
75
|
+
constructor(readonly key: string) {
|
|
76
|
+
super(`No file stored at key '${key}'.`);
|
|
77
|
+
this.name = 'FileNotFoundError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Copyright Ninemind.ai 2026. All Rights Reserved.
|
|
2
|
+
// This file is licensed under the MIT License.
|
|
3
|
+
// License text available at https://opensource.org/license/mit/
|
|
4
|
+
|
|
5
|
+
import {Readable} from 'node:stream';
|
|
6
|
+
import {describe, it, expect} from 'vitest';
|
|
7
|
+
import {FileNotFoundError, type FileStore} from '../ports.js';
|
|
8
|
+
|
|
9
|
+
async function drain(stream: Readable): Promise<Buffer> {
|
|
10
|
+
const chunks: Buffer[] = [];
|
|
11
|
+
for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
12
|
+
return Buffer.concat(chunks);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let n = 0;
|
|
16
|
+
/** A unique key per call so a shared backing bucket stays collision-free. */
|
|
17
|
+
function uniqueKey(tag: string): string {
|
|
18
|
+
return `agentback-conformance/${tag}-${process.pid}-${++n}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The {@link FileStore} contract, as a reusable suite. Every adapter
|
|
23
|
+
* (in-memory, S3, …) runs it to prove it honors the port. `makeStore` returns
|
|
24
|
+
* a fresh (or shared) store; keys are unique per assertion so a real bucket is
|
|
25
|
+
* safe.
|
|
26
|
+
*/
|
|
27
|
+
export function runFileStoreConformance(
|
|
28
|
+
label: string,
|
|
29
|
+
makeStore: () => FileStore | Promise<FileStore>,
|
|
30
|
+
): void {
|
|
31
|
+
describe(`FileStore conformance: ${label}`, () => {
|
|
32
|
+
it('put → get round-trips bytes, contentType, size', async () => {
|
|
33
|
+
const store = await makeStore();
|
|
34
|
+
const key = uniqueKey('roundtrip');
|
|
35
|
+
const put = await store.put(key, Buffer.from('payload'), {
|
|
36
|
+
contentType: 'application/octet-stream',
|
|
37
|
+
filename: 'p.bin',
|
|
38
|
+
});
|
|
39
|
+
expect(put.size).toBe(7);
|
|
40
|
+
const got = await store.get(key);
|
|
41
|
+
expect((await drain(got.stream)).toString()).toBe('payload');
|
|
42
|
+
expect(got.contentType).toBe('application/octet-stream');
|
|
43
|
+
expect(got.size).toBe(7);
|
|
44
|
+
await store.delete(key);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('accepts a Readable body', async () => {
|
|
48
|
+
const store = await makeStore();
|
|
49
|
+
const key = uniqueKey('stream');
|
|
50
|
+
await store.put(key, Readable.from([Buffer.from('ab'), Buffer.from('c')]));
|
|
51
|
+
expect((await drain((await store.get(key)).stream)).toString()).toBe('abc');
|
|
52
|
+
await store.delete(key);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('exists reflects presence; delete removes', async () => {
|
|
56
|
+
const store = await makeStore();
|
|
57
|
+
const key = uniqueKey('lifecycle');
|
|
58
|
+
expect(await store.exists(key)).toBe(false);
|
|
59
|
+
await store.put(key, Buffer.from('x'));
|
|
60
|
+
expect(await store.exists(key)).toBe(true);
|
|
61
|
+
await store.delete(key);
|
|
62
|
+
expect(await store.exists(key)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('get of a missing key throws FileNotFoundError', async () => {
|
|
66
|
+
const store = await makeStore();
|
|
67
|
+
await expect(store.get(uniqueKey('missing'))).rejects.toBeInstanceOf(
|
|
68
|
+
FileNotFoundError,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|