@nestm/storage 0.1.0-alpha.1 → 0.1.0-alpha.11
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/CHANGELOG.md +271 -0
- package/README.md +877 -25
- package/SECURITY.md +128 -0
- package/dist/ai-sdk/ai-sdk-file-workflow-tools.d.ts +37 -0
- package/dist/ai-sdk/ai-sdk-file-workflow-tools.d.ts.map +1 -0
- package/dist/ai-sdk/ai-sdk-file-workflow-tools.js +258 -0
- package/dist/ai-sdk/ai-sdk-file-workflow-tools.js.map +1 -0
- package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts +91 -0
- package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts.map +1 -0
- package/dist/ai-sdk/ai-sdk-workspace-tools.js +491 -0
- package/dist/ai-sdk/ai-sdk-workspace-tools.js.map +1 -0
- package/dist/ai-sdk/index.d.ts +3 -0
- package/dist/ai-sdk/index.d.ts.map +1 -0
- package/dist/ai-sdk/index.js +3 -0
- package/dist/ai-sdk/index.js.map +1 -0
- package/dist/bytes/index.d.ts +30 -0
- package/dist/bytes/index.d.ts.map +1 -0
- package/dist/bytes/index.js +96 -0
- package/dist/bytes/index.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/storage-staged-content.d.ts +37 -0
- package/dist/core/storage-staged-content.d.ts.map +1 -0
- package/dist/core/storage-staged-content.js +94 -0
- package/dist/core/storage-staged-content.js.map +1 -0
- package/dist/core/storage-streams.d.ts +19 -0
- package/dist/core/storage-streams.d.ts.map +1 -0
- package/dist/core/storage-streams.js +95 -0
- package/dist/core/storage-streams.js.map +1 -0
- package/dist/core/storage-text.d.ts +33 -0
- package/dist/core/storage-text.d.ts.map +1 -0
- package/dist/core/storage-text.js +98 -0
- package/dist/core/storage-text.js.map +1 -0
- package/dist/files-sdk/files-sdk.driver.d.ts +85 -3
- package/dist/files-sdk/files-sdk.driver.d.ts.map +1 -1
- package/dist/files-sdk/files-sdk.driver.js +1437 -37
- package/dist/files-sdk/files-sdk.driver.js.map +1 -1
- package/dist/files-sdk/fs/index.d.ts +42 -0
- package/dist/files-sdk/fs/index.d.ts.map +1 -0
- package/dist/files-sdk/fs/index.js +1117 -0
- package/dist/files-sdk/fs/index.js.map +1 -0
- package/dist/files-sdk/index.d.ts +1 -1
- package/dist/files-sdk/index.d.ts.map +1 -1
- package/dist/files-sdk/index.js.map +1 -1
- package/dist/files-sdk/memory.d.ts +7 -0
- package/dist/files-sdk/memory.d.ts.map +1 -0
- package/dist/files-sdk/memory.js +72 -0
- package/dist/files-sdk/memory.js.map +1 -0
- package/dist/files-sdk/provider/index.d.ts +58 -0
- package/dist/files-sdk/provider/index.d.ts.map +1 -0
- package/dist/files-sdk/provider/index.js +151 -0
- package/dist/files-sdk/provider/index.js.map +1 -0
- package/dist/files-sdk/s3/construction-metadata.d.ts +9 -0
- package/dist/files-sdk/s3/construction-metadata.d.ts.map +1 -0
- package/dist/files-sdk/s3/construction-metadata.js +24 -0
- package/dist/files-sdk/s3/construction-metadata.js.map +1 -0
- package/dist/files-sdk/s3/index.d.ts +54 -0
- package/dist/files-sdk/s3/index.d.ts.map +1 -0
- package/dist/files-sdk/s3/index.js +1505 -0
- package/dist/files-sdk/s3/index.js.map +1 -0
- package/dist/gateway/index.d.ts +1 -1
- package/dist/gateway/index.d.ts.map +1 -1
- package/dist/gateway/index.js.map +1 -1
- package/dist/gateway/storage-gateway-fastify-parser.d.ts.map +1 -1
- package/dist/gateway/storage-gateway-fastify-parser.js.map +1 -1
- package/dist/gateway/storage-gateway.controller.d.ts +12 -11
- package/dist/gateway/storage-gateway.controller.d.ts.map +1 -1
- package/dist/gateway/storage-gateway.controller.js +240 -70
- package/dist/gateway/storage-gateway.controller.js.map +1 -1
- package/dist/gateway/storage-gateway.guard.d.ts.map +1 -1
- package/dist/gateway/storage-gateway.guard.js.map +1 -1
- package/dist/gateway/storage-gateway.module.d.ts.map +1 -1
- package/dist/gateway/storage-gateway.module.js +60 -1
- package/dist/gateway/storage-gateway.module.js.map +1 -1
- package/dist/gateway/storage-gateway.tokens.d.ts +1 -0
- package/dist/gateway/storage-gateway.tokens.d.ts.map +1 -1
- package/dist/gateway/storage-gateway.tokens.js +1 -0
- package/dist/gateway/storage-gateway.tokens.js.map +1 -1
- package/dist/gateway/storage-gateway.types.d.ts +53 -10
- package/dist/gateway/storage-gateway.types.d.ts.map +1 -1
- package/dist/gateway/storage-gateway.types.js.map +1 -1
- package/dist/index.d.ts +1 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/inject-storage.decorator.js.map +1 -1
- package/dist/storage-etag.d.ts +13 -0
- package/dist/storage-etag.d.ts.map +1 -0
- package/dist/storage-etag.js +31 -0
- package/dist/storage-etag.js.map +1 -0
- package/dist/storage-upload-control.d.ts.map +1 -1
- package/dist/storage.client.d.ts +15 -3
- package/dist/storage.client.d.ts.map +1 -1
- package/dist/storage.client.js +204 -2
- package/dist/storage.client.js.map +1 -1
- package/dist/storage.driver.d.ts +28 -1
- package/dist/storage.driver.d.ts.map +1 -1
- package/dist/storage.driver.js.map +1 -1
- package/dist/storage.error.d.ts +17 -10
- package/dist/storage.error.d.ts.map +1 -1
- package/dist/storage.error.js +47 -1
- package/dist/storage.error.js.map +1 -1
- package/dist/storage.module.d.ts.map +1 -1
- package/dist/storage.module.js.map +1 -1
- package/dist/storage.service.d.ts.map +1 -1
- package/dist/storage.service.js +28 -3
- package/dist/storage.service.js.map +1 -1
- package/dist/storage.tokens.js.map +1 -1
- package/dist/storage.types.d.ts +150 -1
- package/dist/storage.types.d.ts.map +1 -1
- package/dist/storage.types.js.map +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +3 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/provider-conformance.d.ts +65 -0
- package/dist/testing/provider-conformance.d.ts.map +1 -0
- package/dist/testing/provider-conformance.js +869 -0
- package/dist/testing/provider-conformance.js.map +1 -0
- package/dist/workspace/index.d.ts +10 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +8 -0
- package/dist/workspace/index.js.map +1 -0
- package/dist/workspace/storage-file-catalog.types.d.ts +65 -0
- package/dist/workspace/storage-file-catalog.types.d.ts.map +1 -0
- package/dist/workspace/storage-file-catalog.types.js +2 -0
- package/dist/workspace/storage-file-catalog.types.js.map +1 -0
- package/dist/workspace/storage-file-workflow.d.ts +7 -0
- package/dist/workspace/storage-file-workflow.d.ts.map +1 -0
- package/dist/workspace/storage-file-workflow.js +440 -0
- package/dist/workspace/storage-file-workflow.js.map +1 -0
- package/dist/workspace/storage-file-workflow.protection.d.ts +31 -0
- package/dist/workspace/storage-file-workflow.protection.d.ts.map +1 -0
- package/dist/workspace/storage-file-workflow.protection.js +258 -0
- package/dist/workspace/storage-file-workflow.protection.js.map +1 -0
- package/dist/workspace/storage-file-workflow.types.d.ts +128 -0
- package/dist/workspace/storage-file-workflow.types.d.ts.map +1 -0
- package/dist/workspace/storage-file-workflow.types.js +8 -0
- package/dist/workspace/storage-file-workflow.types.js.map +1 -0
- package/dist/workspace/storage-workspace.cursor.d.ts +71 -0
- package/dist/workspace/storage-workspace.cursor.d.ts.map +1 -0
- package/dist/workspace/storage-workspace.cursor.js +408 -0
- package/dist/workspace/storage-workspace.cursor.js.map +1 -0
- package/dist/workspace/storage-workspace.d.ts +6 -0
- package/dist/workspace/storage-workspace.d.ts.map +1 -0
- package/dist/workspace/storage-workspace.error.d.ts +32 -0
- package/dist/workspace/storage-workspace.error.d.ts.map +1 -0
- package/dist/workspace/storage-workspace.error.js +75 -0
- package/dist/workspace/storage-workspace.error.js.map +1 -0
- package/dist/workspace/storage-workspace.js +849 -0
- package/dist/workspace/storage-workspace.js.map +1 -0
- package/dist/workspace/storage-workspace.path.d.ts +9 -0
- package/dist/workspace/storage-workspace.path.d.ts.map +1 -0
- package/dist/workspace/storage-workspace.path.js +68 -0
- package/dist/workspace/storage-workspace.path.js.map +1 -0
- package/dist/workspace/storage-workspace.types.d.ts +123 -0
- package/dist/workspace/storage-workspace.types.d.ts.map +1 -0
- package/dist/workspace/storage-workspace.types.js +22 -0
- package/dist/workspace/storage-workspace.types.js.map +1 -0
- package/package.json +94 -20
package/README.md
CHANGED
|
@@ -1,27 +1,48 @@
|
|
|
1
1
|
# @nestm/storage
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Framework-neutral storage clients with NestJS 12 integration, named stores,
|
|
4
|
+
explicit streaming I/O, capability-scoped agent workspaces, cross-store
|
|
5
|
+
workflows, and an optional guarded HTTP gateway.
|
|
5
6
|
|
|
6
7
|
The package uses [`files-sdk`](https://github.com/haydenbleasel/files-sdk) as
|
|
7
8
|
its provider engine, but owns the API injected into Nest applications. Provider
|
|
8
9
|
SDK types, errors, and `files.raw` do not leak through the root package.
|
|
9
10
|
|
|
10
|
-
> This package targets
|
|
11
|
-
>
|
|
11
|
+
> This package targets stable NestJS 12 and is itself published on the `alpha`
|
|
12
|
+
> dist-tag.
|
|
12
13
|
|
|
13
14
|
## Requirements
|
|
14
15
|
|
|
15
16
|
- Node.js 22.12 or newer
|
|
16
|
-
- NestJS `12.0.0-alpha.5` or newer in the Nest 12 prerelease line
|
|
17
17
|
- ESM
|
|
18
18
|
|
|
19
|
+
The framework-neutral `@nestm/storage/core` entry point does not require
|
|
20
|
+
NestJS. The root entry point and HTTP gateway additionally require NestJS 12,
|
|
21
|
+
`reflect-metadata`, and RxJS. Those framework peers are optional at installation
|
|
22
|
+
time so core-only consumers do not download NestJS.
|
|
23
|
+
|
|
19
24
|
## Install
|
|
20
25
|
|
|
21
26
|
```sh
|
|
22
|
-
pnpm add @nestm/storage@alpha
|
|
27
|
+
pnpm add @nestm/storage@alpha
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The package-owned S3 factory needs no direct `files-sdk` import. Install the
|
|
31
|
+
pinned engine explicitly only when the application imports another adapter
|
|
32
|
+
such as `files-sdk/gcs`:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pnpm add files-sdk@2.3.0
|
|
23
36
|
```
|
|
24
37
|
|
|
38
|
+
Pass AWS-SDK-backed S3 adapters through the package-owned `s3()` and
|
|
39
|
+
`withS3Capabilities()` helpers (or use `createS3StorageDriver()`).
|
|
40
|
+
`createFilesSdkDriver()` rejects a structurally S3-backed raw adapter that has
|
|
41
|
+
not crossed this provenance boundary, even when a wrapper renames or proxies
|
|
42
|
+
the adapter. This prevents callers from bypassing endpoint and provider-profile
|
|
43
|
+
checks by omitting the capability decorator or replacing the public `raw`
|
|
44
|
+
property while retaining methods closed over the original client.
|
|
45
|
+
|
|
25
46
|
Install only the native SDKs required by the chosen provider. For example:
|
|
26
47
|
|
|
27
48
|
```sh
|
|
@@ -36,26 +57,488 @@ pnpm add @google-cloud/storage google-auth-library
|
|
|
36
57
|
pnpm add @azure/storage-blob @azure/core-auth @azure/identity
|
|
37
58
|
```
|
|
38
59
|
|
|
60
|
+
`@aws-sdk/client-s3` 3.1079.0 or newer is required, matching the Files SDK 2.3
|
|
61
|
+
peer floor. The supported range includes destination `If-Match` and
|
|
62
|
+
`If-None-Match` serialization for `CopyObject`; the package peer range and
|
|
63
|
+
packed minimum-peer smoke test enforce this floor.
|
|
64
|
+
|
|
39
65
|
`files-sdk` currently declares its optional Nest peer for Nest 10 and 11. This
|
|
40
66
|
library does not import `files-sdk/nestjs`; the Nest 12 integration is entirely
|
|
41
67
|
owned here. A package manager may nevertheless report that temporary optional
|
|
42
|
-
peer mismatch
|
|
68
|
+
peer mismatch until Files SDK widens its declaration to include Nest 12.
|
|
69
|
+
|
|
70
|
+
## Framework-neutral core
|
|
71
|
+
|
|
72
|
+
Import storage primitives from `@nestm/storage/core` in workers, scripts, and
|
|
73
|
+
applications that do not use NestJS:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import {
|
|
77
|
+
StorageClient,
|
|
78
|
+
type StorageDriver,
|
|
79
|
+
type StorageUploadOptions,
|
|
80
|
+
} from '@nestm/storage/core';
|
|
81
|
+
|
|
82
|
+
declare const driver: StorageDriver;
|
|
83
|
+
|
|
84
|
+
const media = new StorageClient('media', driver);
|
|
85
|
+
|
|
86
|
+
await media.upload('avatars/user.png', image, {
|
|
87
|
+
contentType: 'image/png',
|
|
88
|
+
} satisfies StorageUploadOptions);
|
|
89
|
+
|
|
90
|
+
await media.onApplicationShutdown();
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The core entry point exports `StorageClient`, the `StorageDriver` contract,
|
|
94
|
+
storage errors and operation types, and `StorageUploadControl`. It has no NestJS
|
|
95
|
+
runtime or declaration imports. Provider adapters remain available through
|
|
96
|
+
`@nestm/storage/files-sdk`.
|
|
97
|
+
|
|
98
|
+
## Mounted agent workspaces
|
|
99
|
+
|
|
100
|
+
`@nestm/storage/workspace` turns a `StorageClient` into a narrow capability for
|
|
101
|
+
one logical directory. The mount is virtual: the same API works over S3, a
|
|
102
|
+
filesystem driver, or another storage backend without exposing the provider,
|
|
103
|
+
bucket, filesystem root, raw cursor, or internal prefix to its caller.
|
|
104
|
+
|
|
105
|
+
```mermaid
|
|
106
|
+
flowchart LR
|
|
107
|
+
A["Trusted application context"] -->|"store + opaque prefix + policy"| W["StorageWorkspace"]
|
|
108
|
+
W --> C["StorageClient"]
|
|
109
|
+
C --> D["S3 / filesystem / other driver"]
|
|
110
|
+
W --> T["AI SDK workspace tools"]
|
|
111
|
+
T --> G["ToolLoopAgent"]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Only trusted application code chooses the mount prefix. Every path accepted by
|
|
115
|
+
the workspace is a canonical, relative POSIX path. Absolute paths, backslashes,
|
|
116
|
+
control characters, repeated separators, and `.` or `..` segments are rejected
|
|
117
|
+
rather than normalized. Keys and provider cursors returned by a driver are also
|
|
118
|
+
checked before they are converted back to logical paths.
|
|
119
|
+
|
|
120
|
+
Pagination requires a server-owned cursor configuration. The built-in
|
|
121
|
+
`Aes256GcmStorageWorkspaceCursorCodec` produces versioned, authenticated,
|
|
122
|
+
encrypted tokens that can resume on another request, process, or replica when
|
|
123
|
+
every replica constructs an equivalent codec from the same key ring and uses
|
|
124
|
+
the same stable store identity, physical prefix, mount ID, trusted scope, and
|
|
125
|
+
effective limits. Use one codec instance per process, use a dedicated 32-byte
|
|
126
|
+
key, retain rotated decryption keys for at least one cursor TTL, and derive
|
|
127
|
+
`mountId` and `scope` only from authenticated server context.
|
|
128
|
+
|
|
129
|
+
The underlying driver must also implement the universal replayable list-cursor
|
|
130
|
+
contract against the same logical backend namespace: its cursor cannot be
|
|
131
|
+
consumed or tied to one driver instance. While the provider cursor remains
|
|
132
|
+
valid and available, an outer cursor can be retried until its authenticated
|
|
133
|
+
expiry. That expiry is only an authorization ceiling: it does not extend a
|
|
134
|
+
provider token's lifetime or promise snapshot isolation, provider availability,
|
|
135
|
+
network access, or valid credentials. Provider invalidation is an operational
|
|
136
|
+
list failure, and concurrent object changes remain subject to provider
|
|
137
|
+
continuation semantics. Without a codec, single-page operations still work but
|
|
138
|
+
a continuation fails closed.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import {
|
|
142
|
+
Aes256GcmStorageWorkspaceCursorCodec,
|
|
143
|
+
mountStorageWorkspace,
|
|
144
|
+
} from '@nestm/storage/workspace';
|
|
145
|
+
|
|
146
|
+
// cursorKey is a separately validated 32-byte secret from deployment config.
|
|
147
|
+
const cursorCodec = new Aes256GcmStorageWorkspaceCursorCodec({
|
|
148
|
+
activeKeyId: 'v1',
|
|
149
|
+
keys: { v1: cursorKey },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const workspace = mountStorageWorkspace(agentFiles, {
|
|
153
|
+
// Use an opaque server-derived run id, never a value selected by the model.
|
|
154
|
+
prefix: `workspaces/${runId}`,
|
|
155
|
+
cursor: {
|
|
156
|
+
codec: cursorCodec,
|
|
157
|
+
mountId: `agent-workspace:${runId}`,
|
|
158
|
+
scope: `organization:${organizationId}/workspace:${workspaceId}`,
|
|
159
|
+
},
|
|
160
|
+
permissions: [
|
|
161
|
+
'list',
|
|
162
|
+
'read',
|
|
163
|
+
'search',
|
|
164
|
+
'write',
|
|
165
|
+
'create',
|
|
166
|
+
'replace',
|
|
167
|
+
'copy',
|
|
168
|
+
'move',
|
|
169
|
+
'delete',
|
|
170
|
+
],
|
|
171
|
+
limits: {
|
|
172
|
+
cursorTtlMs: 5 * 60 * 1000,
|
|
173
|
+
maxCursorBytes: 4096,
|
|
174
|
+
maxReadBytes: 1024 * 1024,
|
|
175
|
+
maxWriteBytes: 1024 * 1024,
|
|
176
|
+
maxPageSize: 100,
|
|
177
|
+
maxSearchResults: 100,
|
|
178
|
+
maxSearchScan: 1000,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const created = await workspace.writeFile(
|
|
183
|
+
'src/main.ts',
|
|
184
|
+
'export const ready = true;\n',
|
|
185
|
+
{ mode: 'create', contentType: 'text/typescript' },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (created.etag === undefined) {
|
|
189
|
+
throw new Error('This backend cannot safely replace the object.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await workspace.writeFile('src/main.ts', 'export const ready = false;\n', {
|
|
193
|
+
mode: 'replace',
|
|
194
|
+
etag: created.etag,
|
|
195
|
+
contentType: 'text/typescript',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const image = await workspace.readBytes('assets/logo.png');
|
|
199
|
+
console.log(image.bytes.byteLength);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`readBytes` is now a required member of the exported `StorageWorkspace`
|
|
203
|
+
interface. Workspaces returned by `mountStorageWorkspace` provide it
|
|
204
|
+
automatically; custom implementations and typed test doubles must add the
|
|
205
|
+
method when adopting this alpha minor.
|
|
206
|
+
|
|
207
|
+
Create, replace, and delete are conditional operations. A driver that cannot
|
|
208
|
+
enforce the requested not-exists or ETag precondition fails with
|
|
209
|
+
`NOT_SUPPORTED`; the workspace never substitutes an `exists()`/`head()` check
|
|
210
|
+
followed by an unconditional mutation. Reads enforce their byte ceiling while
|
|
211
|
+
consuming the stream, and list/search results are bounded. Search supports
|
|
212
|
+
exact, substring, and workspace-coordinate glob matching, but no caller-supplied
|
|
213
|
+
regular expressions.
|
|
214
|
+
|
|
215
|
+
Move is implemented as create-only copy followed by ETag-conditional source
|
|
216
|
+
delete. If source deletion cannot be confirmed, the destination is retained and
|
|
217
|
+
the call returns `CONFLICT`; inspect both logical paths before retrying. This
|
|
218
|
+
preserves at least one copy across provider timeouts and post-operation hook
|
|
219
|
+
failures, but does not pretend a multi-object move is transactionally atomic.
|
|
220
|
+
|
|
221
|
+
Callers that prefer the ordinary Files pipeline can opt into explicit
|
|
222
|
+
last-write-wins variants. The `write` permission is separate from conditional
|
|
223
|
+
`create` and `replace` authority:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
await workspace.writeFile('notes.txt', 'latest contents', {
|
|
227
|
+
mode: 'overwrite',
|
|
228
|
+
});
|
|
229
|
+
await workspace.copyFile('notes.txt', 'backup.txt', { mode: 'overwrite' });
|
|
230
|
+
await workspace.deleteFile('backup.txt', { mode: 'unconditional' });
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Overwrite copy reads the latest source through the ordinary download pipeline,
|
|
234
|
+
enforces `maxWriteBytes` while collecting it, and uploads it through the
|
|
235
|
+
ordinary upload pipeline. It never substitutes the provider's server-side
|
|
236
|
+
copy. These paths compose with Files SDK plugins, hooks, and receipts, including
|
|
237
|
+
the built-in `encryption()` plugin. That plugin is useful compatibility
|
|
238
|
+
evidence, not an Artifact-specific security policy: strict encrypted-only
|
|
239
|
+
reads, tenant/path-bound AAD, key custody and rotation, and copy/move rules
|
|
240
|
+
remain application-owned.
|
|
241
|
+
|
|
242
|
+
Move remains conditional-only. A last-write-wins download/upload/delete
|
|
243
|
+
sequence could copy one source generation and then delete a newer generation
|
|
244
|
+
written during the transfer. Use the ETag-conditional `moveFile` variant when a
|
|
245
|
+
move is required.
|
|
246
|
+
|
|
247
|
+
A child mount may further restrict a directory, permissions, or limits, but it
|
|
248
|
+
cannot widen any of them:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const readOnlySource = workspace.mount('src', {
|
|
252
|
+
permissions: ['list', 'read', 'search'],
|
|
253
|
+
limits: { maxReadBytes: 256 * 1024 },
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### AI SDK and NestJS composition
|
|
258
|
+
|
|
259
|
+
Install AI SDK 7 and Zod only in applications that use the optional adapter:
|
|
260
|
+
|
|
261
|
+
```sh
|
|
262
|
+
pnpm add ai@^7 zod@^4
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`files-sdk` 2.3.x still declares an optional `ai@^6` peer for its own adapter,
|
|
266
|
+
so some package managers may print a peer warning when AI SDK 7 is installed.
|
|
267
|
+
This package does not import that adapter; `@nestm/storage/ai-sdk` targets AI
|
|
268
|
+
SDK 7 directly.
|
|
269
|
+
|
|
270
|
+
`@nestm/storage/ai-sdk` converts an already-mounted workspace to an ordinary
|
|
271
|
+
upstream `ToolSet`. It does not import NestJS or `@nestm/ai-sdk`; the application
|
|
272
|
+
composes the tool set through the AI module's existing named-toolset factory.
|
|
273
|
+
For a tenant or run selected per request, make both factories request-scoped and
|
|
274
|
+
derive the mount coordinate from authenticated host context:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { Module, Scope } from '@nestjs/common';
|
|
278
|
+
import { AiSdkModule, AiSdkService, getAiToolsetToken } from '@nestm/ai-sdk';
|
|
279
|
+
import { getStorageToken, type StorageClient } from '@nestm/storage';
|
|
280
|
+
import { createAiSdkWorkspaceTools } from '@nestm/storage/ai-sdk';
|
|
281
|
+
import { mountStorageWorkspace } from '@nestm/storage/workspace';
|
|
282
|
+
import type { ToolSet } from 'ai';
|
|
283
|
+
|
|
284
|
+
@Module({
|
|
285
|
+
imports: [
|
|
286
|
+
AppStorageModule,
|
|
287
|
+
WorkspaceContextModule,
|
|
288
|
+
AiSdkModule.forFeature({
|
|
289
|
+
imports: [AppStorageModule, WorkspaceContextModule],
|
|
290
|
+
toolsets: [
|
|
291
|
+
{
|
|
292
|
+
name: 'workspace',
|
|
293
|
+
scope: Scope.REQUEST,
|
|
294
|
+
inject: [getStorageToken('agent-files'), WorkspaceContext],
|
|
295
|
+
useFactory: (storage: StorageClient, context: WorkspaceContext) =>
|
|
296
|
+
createAiSdkWorkspaceTools({
|
|
297
|
+
workspace: mountStorageWorkspace(storage, {
|
|
298
|
+
// A validated, opaque coordinate from trusted auth/run state.
|
|
299
|
+
// It is never accepted from a prompt or tool input.
|
|
300
|
+
prefix: context.storagePrefix,
|
|
301
|
+
// Includes the singleton codec plus stable mountId and scope.
|
|
302
|
+
cursor: context.cursorConfiguration,
|
|
303
|
+
permissions: [
|
|
304
|
+
'list',
|
|
305
|
+
'read',
|
|
306
|
+
'search',
|
|
307
|
+
'write',
|
|
308
|
+
'create',
|
|
309
|
+
'replace',
|
|
310
|
+
'copy',
|
|
311
|
+
'move',
|
|
312
|
+
'delete',
|
|
313
|
+
],
|
|
314
|
+
}),
|
|
315
|
+
}),
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
agents: [
|
|
319
|
+
{
|
|
320
|
+
name: 'workspace-agent',
|
|
321
|
+
scope: Scope.REQUEST,
|
|
322
|
+
inject: [AiSdkService, getAiToolsetToken('workspace')],
|
|
323
|
+
useFactory: (ai: AiSdkService, tools: ToolSet) => ({
|
|
324
|
+
model: ai.languageModel(),
|
|
325
|
+
instructions:
|
|
326
|
+
'Use only the mounted workspace tools for file operations.',
|
|
327
|
+
tools,
|
|
328
|
+
}),
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
}),
|
|
332
|
+
],
|
|
333
|
+
})
|
|
334
|
+
export class WorkspaceAgentModule {}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
The generated set contains only tools allowed by the workspace permissions:
|
|
338
|
+
bounded list, stat, UTF-8 read, and search tools plus conditional create,
|
|
339
|
+
replace, copy, move, and delete tools when granted. Mutation tools require AI
|
|
340
|
+
SDK user approval by default; approval can be configured per tool, but the
|
|
341
|
+
workspace capability remains the authorization boundary even when approval is
|
|
342
|
+
disabled. The module's `AiSdkService.files()` API is the model provider's file
|
|
343
|
+
upload facility and is unrelated to storage workspaces.
|
|
344
|
+
|
|
345
|
+
`mutationMode` defaults to `'conditional'`. A trusted composition can instead
|
|
346
|
+
select `{ mutationMode: 'last-write-wins' }`; generated mutation schemas then
|
|
347
|
+
omit ETags and modes, hardcode the explicit overwrite/unconditional workspace
|
|
348
|
+
variants, and require `write` permission for destination mutations.
|
|
349
|
+
Unconditional delete requires both `write` and `delete`. The move tool is
|
|
350
|
+
omitted in last-write-wins mode because Workspace move remains conditional-only.
|
|
351
|
+
|
|
352
|
+
Atomic create collisions remain sanitized tool errors by default. Applications
|
|
353
|
+
that model an existing destination as a normal tool result can map that one
|
|
354
|
+
case while preserving replace/ETag conflicts as failures:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
const tools = createAiSdkWorkspaceTools({
|
|
358
|
+
workspace,
|
|
359
|
+
mapCreateConflict: ({ path }) => ({
|
|
360
|
+
kind: 'artifact-conflict' as const,
|
|
361
|
+
path,
|
|
362
|
+
status: 'already-exists' as const,
|
|
363
|
+
}),
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The mapper receives only the logical workspace path; provider errors, object
|
|
368
|
+
keys, and mount coordinates are never exposed. `mapCreateConflict` is valid
|
|
369
|
+
only in conditional mode and is rejected with last-write-wins mode.
|
|
370
|
+
|
|
371
|
+
This logical confinement is sufficient for a `ToolLoopAgent` whose only file
|
|
372
|
+
capabilities are these tools. It cannot constrain a coding harness that already
|
|
373
|
+
has shell, `node:fs`, or subprocess access. For Codex/Claude-style harnesses,
|
|
374
|
+
materialize the workspace into a per-session container or VM, mount only that
|
|
375
|
+
directory, run the harness there, and synchronize reviewed changes back through
|
|
376
|
+
`StorageWorkspace`. A working directory alone is not a sandbox.
|
|
377
|
+
|
|
378
|
+
## Durable file workflows and catalog tools
|
|
379
|
+
|
|
380
|
+
`@nestm/storage` owns byte mechanics and the draft state machine. The host owns
|
|
381
|
+
logical file heads, authorization, persistence and transaction atomicity. No
|
|
382
|
+
database, NestJS, tenant model or runtime lease is required by these contracts.
|
|
43
383
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
384
|
+
| Entry point | Exports |
|
|
385
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
386
|
+
| `@nestm/storage/core` | `StorageStagedContentStore`, `collectStorageBytes`, `storageBytesStream`, `readStorageTextWindow`, `searchStorageText`, `applyStorageTextEdit` and their types |
|
|
387
|
+
| `@nestm/storage/workspace` | `StorageFileWorkflow`, draft/persistence/catalog contracts, `protectStorageFileWorkflowWorkspace`, `protectStorageFileWorkflowOperation`, `getStorageFileWorkflow`, `getStorageFileCatalog` |
|
|
388
|
+
| `@nestm/storage/ai-sdk` | `createAiSdkFileWorkflowTools`, `createAiSdkCatalogFileTools`, their options and tool-name constants; existing `createAiSdkWorkspaceTools` is unchanged |
|
|
389
|
+
| `@nestm/storage/bytes` | Browser-safe `sha256StorageBytes`, `verifyStorageChunkReceipt`, `trimStorageUtf8Chunk`, `encodeStorageBase64`, `isStorageUtf8Blob` |
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
import { StorageStagedContentStore } from '@nestm/storage/core';
|
|
393
|
+
import {
|
|
394
|
+
StorageFileWorkflow,
|
|
395
|
+
protectStorageFileWorkflowWorkspace,
|
|
396
|
+
} from '@nestm/storage/workspace';
|
|
397
|
+
import {
|
|
398
|
+
createAiSdkCatalogFileTools,
|
|
399
|
+
createAiSdkFileWorkflowTools,
|
|
400
|
+
} from '@nestm/storage/ai-sdk';
|
|
401
|
+
|
|
402
|
+
// These values are supplied by the host, never by the model.
|
|
403
|
+
const content = new StorageStagedContentStore({
|
|
404
|
+
client,
|
|
405
|
+
key: (scope: HostScope, payloadId) => hostBodyKey(scope, payloadId),
|
|
406
|
+
});
|
|
407
|
+
const workflows = new StorageFileWorkflow({ content, persistence });
|
|
408
|
+
const workspace = protectStorageFileWorkflowWorkspace({
|
|
409
|
+
workspace: hostCatalogWorkspace,
|
|
410
|
+
catalog: hostCatalog,
|
|
411
|
+
workflows: workflows.mount(trustedScope, {
|
|
412
|
+
permissions: ['read', 'write', 'commit'],
|
|
413
|
+
signal: executionSignal,
|
|
414
|
+
}),
|
|
415
|
+
signal: executionSignal,
|
|
416
|
+
authorize: ({ permission, signal }) =>
|
|
417
|
+
authorizeCurrentHostScope(trustedScope, permission, signal),
|
|
418
|
+
});
|
|
419
|
+
const tools = {
|
|
420
|
+
...createAiSdkCatalogFileTools({
|
|
421
|
+
catalog: workspace.catalog!,
|
|
422
|
+
commandId: (toolCallId) => hostCommandId(toolCallId),
|
|
423
|
+
}),
|
|
424
|
+
...createAiSdkFileWorkflowTools({
|
|
425
|
+
workflow: workspace.workflows,
|
|
426
|
+
idempotencyKey: (toolCallId) => hostCommandId(toolCallId),
|
|
427
|
+
}),
|
|
428
|
+
};
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
The catalog delegate implements the existing `StorageWorkspace` against the
|
|
432
|
+
host's logical catalog; do not pass a raw provider-prefix workspace alongside
|
|
433
|
+
an unrelated file service. `StorageFileCatalogCapability` adds opaque file IDs,
|
|
434
|
+
byte windows, literal content search and idempotent small write/edit commands.
|
|
435
|
+
Its `commandId` is host-scoped, stable across retries, and compared against a
|
|
436
|
+
canonical request fingerprint in host persistence. The host preserves its
|
|
437
|
+
additional policy when a generic edit targets a specialized file.
|
|
438
|
+
|
|
439
|
+
`createAiSdkCatalogFileEditSchemas(maxWriteBytes)` exposes typed append/edit
|
|
440
|
+
Zod objects used by the generic factory itself. A host can `.extend()` these
|
|
441
|
+
with an optional product-only metadata field while retaining the same UTF-8
|
|
442
|
+
byte rules. Delegate ordinary input to the generic tool; dispatch specialized
|
|
443
|
+
input through the same leased product capability and command identity. Do not
|
|
444
|
+
cast an opaque tool schema or rebuild the generic schema locally.
|
|
445
|
+
|
|
446
|
+
The protector retains the lease/caller signal and reauthorizes every call.
|
|
447
|
+
Create-only base grants cannot replace or edit a catalog file. Workflow
|
|
448
|
+
`restrict({ permissions, mutations, limits, signal })` intersects existing
|
|
449
|
+
grants/ceilings and retains parent cancellation; it never changes scope. The
|
|
450
|
+
protector restricts persisted draft mutations to the base create/replace grants,
|
|
451
|
+
including resumed sealed drafts and replay, without requiring a public read
|
|
452
|
+
grant to perform an otherwise authorized commit. Child mounts do
|
|
453
|
+
not inherit broader catalog/workflow handles. Configure both delegates with the
|
|
454
|
+
same scope and compatible limits; the host catalog must enforce scan budgets
|
|
455
|
+
inside its implementation. Structured extensions on the returned workspace
|
|
456
|
+
are supported: owning features can attach protected product closures while
|
|
457
|
+
retaining the same `.catalog` and `.workflows` members. The structural getters
|
|
458
|
+
are contract checks for trusted handles, not authentication of arbitrary objects.
|
|
459
|
+
These static file operations acquire no lease themselves.
|
|
460
|
+
|
|
461
|
+
### Host persistence transaction
|
|
462
|
+
|
|
463
|
+
Implement `StorageFileWorkflowPersistence<Scope, Receipt>.transaction(scope,
|
|
464
|
+
{ permission, signal }, work)`. On **every** call (including replay and body
|
|
465
|
+
preparation reads), reauthorize that intent and serialize the scope's draft and
|
|
466
|
+
idempotency records across replicas. `read` admits readers; `write` and `commit`
|
|
467
|
+
must enforce the host's mutation authority. The callback receives detached
|
|
468
|
+
records through `findDraftByKey`, `getDraft`, `saveDraft`, `listDrafts`,
|
|
469
|
+
`listParts`, `putPart` and `commitHeads`.
|
|
470
|
+
|
|
471
|
+
`commitHeads` receives sorted `{ draft, body }` changes. It must compare every
|
|
472
|
+
create/expected-ETag predicate and change all logical heads **in the same host
|
|
473
|
+
transaction** as the subsequent committed draft records and receipts. Return
|
|
474
|
+
one non-null receipt per change in the supplied order. A rejection rolls back
|
|
475
|
+
all callback effects. Serial object writes or independently committed database
|
|
476
|
+
calls do not satisfy this port. The package does not claim to implement database
|
|
477
|
+
atomicity. See the packed consumer fixture and filesystem e2e test for executable
|
|
478
|
+
minimal hosts; production hosts need their own cross-replica transaction/locking
|
|
479
|
+
implementation and rollback tests.
|
|
480
|
+
|
|
481
|
+
Drafts transition `open → sealed → committed` or `open/sealed → cancelled`.
|
|
482
|
+
Appends accept a bounded snapshot of bytes at the exact current byte offset.
|
|
483
|
+
Identical offset/size/SHA-256 replay succeeds; changed bytes or gaps conflict.
|
|
484
|
+
Text chunks must contain complete valid UTF-8. Public summaries and part receipts
|
|
485
|
+
omit staged body IDs and provider keys. A commit seals all requested drafts,
|
|
486
|
+
streams verified chunks into immutable bodies, then commits all heads and draft
|
|
487
|
+
receipts in one host transaction. Failed preparation or stale heads leave sealed
|
|
488
|
+
drafts retryable/cancellable. Mixed committed/uncommitted batches conflict;
|
|
489
|
+
replaying an entirely committed batch returns its stored receipts after checking
|
|
490
|
+
the requested size and optional digest. Cancellation cannot undo committed heads.
|
|
491
|
+
|
|
492
|
+
Check the signal before durable transaction commit. After commit, return the
|
|
493
|
+
receipt even if cancellation races the response. Do not automatically retry the
|
|
494
|
+
transaction callback. A lost response requires replay/reconciliation, not an
|
|
495
|
+
assumption of rollback. Provider writes happen before the metadata transaction;
|
|
496
|
+
failures, losing concurrent preparations, cancellations and superseded files can
|
|
497
|
+
leave unreferenced bodies. Retention eligibility, reference tracking and cleanup
|
|
498
|
+
scheduling remain host-owned. `StorageStagedContentStore.remove` performs an
|
|
499
|
+
ETag-conditional deletion only after the host proves eligibility; it is not a
|
|
500
|
+
collector and is never called automatically by the workflow.
|
|
501
|
+
|
|
502
|
+
### Byte and provider guarantees
|
|
503
|
+
|
|
504
|
+
Staging requires native create-only writes that return an ETag and native exact
|
|
505
|
+
ETag reads; byte ranges additionally require `rangeRead`. Missing primitives
|
|
506
|
+
fail with `NOT_SUPPORTED`, without an existence-check/unconditional-write or
|
|
507
|
+
full-download fallback. Files SDK remains the provider, streaming, range and
|
|
508
|
+
conditional-policy engine. Its multipart upload controls remain distinct from
|
|
509
|
+
the host-persisted draft protocol; it does not provide atomic catalog commits.
|
|
510
|
+
The bundled filesystem and memory drivers and verified native S3 profiles
|
|
511
|
+
support staging. Memory stores are process-local and volatile; their conditional
|
|
512
|
+
comparison/publication is synchronous after asynchronous body consumption.
|
|
513
|
+
Memory publications use canonical SHA-256 ETags. Direct raw-map mutation remains
|
|
514
|
+
a trusted testing escape hatch, outside protected workspace authority.
|
|
515
|
+
|
|
516
|
+
Windows count UTF-8 bytes, use inclusive provider range ends and preserve BOMs.
|
|
517
|
+
Offsets splitting a character and malformed/truncated UTF-8 at EOF fail. Search
|
|
518
|
+
budgets include the actual requested bytes; follow `nextOffset` even on an empty
|
|
519
|
+
match page. `applyStorageTextEdit` works on an already buffered string, requires a
|
|
520
|
+
result byte ceiling and rejects non-unique replacement targets; it is not a
|
|
521
|
+
streaming document editor. Model text appends default to 8192 bytes per call,
|
|
522
|
+
clamped to the capability. Mutation tools require approval by default; hosts may
|
|
523
|
+
apply an existing approval policy explicitly.
|
|
524
|
+
|
|
525
|
+
The browser bytes entrypoint imports no Node, provider, NestJS or AI modules.
|
|
526
|
+
`verifyStorageChunkReceipt(blob, receipt, { offset, maxBytes, signal })` verifies
|
|
527
|
+
ordering, bounds and the actual local SHA-256 before returning the next offset.
|
|
528
|
+
Filename/size/mtime are insufficient proof of resumed content identity. Filename
|
|
529
|
+
classification, browser session storage and HTTP transport remain host policy.
|
|
48
530
|
|
|
49
531
|
## Configure named stores
|
|
50
532
|
|
|
51
|
-
|
|
52
|
-
bridge, and register the
|
|
533
|
+
Use the package-owned S3 factory when applicable. For other providers, create a
|
|
534
|
+
`files-sdk` adapter, wrap it through the explicit bridge, and register the
|
|
535
|
+
resulting driver:
|
|
53
536
|
|
|
54
537
|
```ts
|
|
55
538
|
import { Module } from '@nestjs/common';
|
|
56
|
-
import { s3 } from 'files-sdk/s3';
|
|
57
539
|
import { gcs } from 'files-sdk/gcs';
|
|
58
540
|
import { createFilesSdkDriver } from '@nestm/storage/files-sdk';
|
|
541
|
+
import { createS3StorageDriver } from '@nestm/storage/files-sdk/s3';
|
|
59
542
|
import { StorageModule } from '@nestm/storage';
|
|
60
543
|
|
|
61
544
|
export const StorageKey = {
|
|
@@ -70,11 +553,11 @@ export const StorageKey = {
|
|
|
70
553
|
stores: [
|
|
71
554
|
{
|
|
72
555
|
name: StorageKey.MEDIA,
|
|
73
|
-
driver:
|
|
74
|
-
adapter:
|
|
556
|
+
driver: createS3StorageDriver({
|
|
557
|
+
adapter: {
|
|
75
558
|
bucket: 'media',
|
|
76
559
|
region: 'us-east-1',
|
|
77
|
-
}
|
|
560
|
+
},
|
|
78
561
|
}),
|
|
79
562
|
},
|
|
80
563
|
{
|
|
@@ -160,11 +643,11 @@ StorageModule.forRootAsync({
|
|
|
160
643
|
name: 'media',
|
|
161
644
|
inject: [ConfigService],
|
|
162
645
|
useFactory: (config: ConfigService) =>
|
|
163
|
-
|
|
164
|
-
adapter:
|
|
646
|
+
createS3StorageDriver({
|
|
647
|
+
adapter: {
|
|
165
648
|
bucket: config.getOrThrow('MEDIA_BUCKET'),
|
|
166
649
|
region: config.getOrThrow('AWS_REGION'),
|
|
167
|
-
}
|
|
650
|
+
},
|
|
168
651
|
}),
|
|
169
652
|
},
|
|
170
653
|
],
|
|
@@ -182,11 +665,136 @@ modules retain their own DI scope rather than mutating an application-wide
|
|
|
182
665
|
registry. Names are case-sensitive and cannot contain leading or trailing
|
|
183
666
|
whitespace.
|
|
184
667
|
|
|
668
|
+
### Select the provider at runtime
|
|
669
|
+
|
|
670
|
+
An application that ships to more than one environment usually cannot name its
|
|
671
|
+
provider at build time. `createProviderStorageDriver` takes the slug as data and
|
|
672
|
+
imports that provider's adapter — and only that one — on demand, so a deployment
|
|
673
|
+
picks its store with an environment variable and installs one native SDK:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
import { createProviderStorageDriver } from '@nestm/storage/files-sdk/provider';
|
|
677
|
+
|
|
678
|
+
StorageModule.forRootAsync({
|
|
679
|
+
imports: [ConfigModule],
|
|
680
|
+
stores: [
|
|
681
|
+
{
|
|
682
|
+
name: 'media',
|
|
683
|
+
inject: [ConfigService],
|
|
684
|
+
useFactory: (config: ConfigService) =>
|
|
685
|
+
createProviderStorageDriver({
|
|
686
|
+
provider: config.getOrThrow('STORAGE_PROVIDER'),
|
|
687
|
+
prefix: config.get('STORAGE_PREFIX'),
|
|
688
|
+
config: {
|
|
689
|
+
bucket: config.get('STORAGE_BUCKET'),
|
|
690
|
+
region: config.get('STORAGE_REGION'),
|
|
691
|
+
root: config.get('STORAGE_ROOT'),
|
|
692
|
+
},
|
|
693
|
+
}),
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
});
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
`config` is one flat bag of provider settings — `bucket` and `region` for an
|
|
700
|
+
object store, `root` for the filesystem, `accountName` and `container` for
|
|
701
|
+
Azure. Each provider reads what it needs and ignores the rest, so the same shape
|
|
702
|
+
survives a provider change. Credentials may be omitted wherever the provider's
|
|
703
|
+
SDK resolves its own chain (an IAM role, Application Default Credentials, a
|
|
704
|
+
shared profile).
|
|
705
|
+
|
|
706
|
+
An unknown slug fails closed with `INVALID_ARGUMENT` before anything is
|
|
707
|
+
imported. Validate untrusted input up front with `isStorageProvider`, and drive
|
|
708
|
+
config validation from the catalog rather than a hand-kept list:
|
|
709
|
+
|
|
710
|
+
```ts
|
|
711
|
+
import {
|
|
712
|
+
getStorageProvider,
|
|
713
|
+
isStorageProvider,
|
|
714
|
+
listStorageProviders,
|
|
715
|
+
listStorageProviderSecretEnvVars,
|
|
716
|
+
} from '@nestm/storage/files-sdk/provider';
|
|
717
|
+
|
|
718
|
+
listStorageProviders().map((provider) => provider.slug); // 'akamai', 'alibaba', …
|
|
719
|
+
getStorageProvider('gcs')?.peerDeps; // ['@google-cloud/storage', …]
|
|
720
|
+
listStorageProviderSecretEnvVars('s3').map((variable) => variable.key);
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
The catalog is pure data and pulls in no adapter, so it is safe in config UIs,
|
|
724
|
+
health checks, and startup validation.
|
|
725
|
+
|
|
726
|
+
The `s3` slug additionally carries the verified per-operation profile and
|
|
727
|
+
signed-policy capabilities described under
|
|
728
|
+
[Exact provider conditions and staged-object promotion](#exact-provider-conditions-and-staged-object-promotion);
|
|
729
|
+
every provider not backed by the AWS S3 SDK exposes what its adapter declares.
|
|
730
|
+
When the provider _is_ known at build time, import
|
|
731
|
+
`@nestm/storage/files-sdk/s3` or `@nestm/storage/files-sdk/fs` directly and skip
|
|
732
|
+
the indirection.
|
|
733
|
+
|
|
734
|
+
S3 endpoint and public-URL provenance is resolved from the adapter that
|
|
735
|
+
`files-sdk` actually constructs, including values merged from `configJson`.
|
|
736
|
+
An unaudited endpoint forces the driver read-only, and a `publicBaseUrl` removes
|
|
737
|
+
the signed-download TTL guarantee because the resulting public URL does not
|
|
738
|
+
expire. AWS-SDK-backed noncanonical provider slugs (for example an S3-compatible
|
|
739
|
+
provider wrapper) also default to unverified/read-only; only the canonical
|
|
740
|
+
`s3` provider may infer the native AWS profile, and a custom endpoint becomes
|
|
741
|
+
writable only with an explicit branded `S3ProviderProfile`.
|
|
742
|
+
|
|
743
|
+
Before enabling conditional operations for a custom S3-compatible endpoint,
|
|
744
|
+
run the reusable
|
|
745
|
+
[provider conformance contract](https://github.com/nestm-dev/storage/blob/main/docs/provider-conformance.md)
|
|
746
|
+
against dedicated test credentials. Unknown endpoints are forced read-only and
|
|
747
|
+
receive no inferred conditional capabilities.
|
|
748
|
+
|
|
749
|
+
## Files SDK responsibility boundary
|
|
750
|
+
|
|
751
|
+
Files SDK is the upstream authority for the generic storage data plane:
|
|
752
|
+
provider adapters, generic CRUD, bulk and list operations, retries, transfers
|
|
753
|
+
and sync, its plugin pipeline, and framework-neutral gateway mechanics.
|
|
754
|
+
`@nestm/storage` retains the guarantees that Files SDK does not currently
|
|
755
|
+
provide: NestJS 12 named stores, exact native conditional/CAS capabilities,
|
|
756
|
+
`StorageWorkspace` permissions and limits, bounded storage errors, and
|
|
757
|
+
capability-scoped AI tools.
|
|
758
|
+
|
|
759
|
+
Files SDK 2.3 provides native conditional operations through the same
|
|
760
|
+
interception boundary as ordinary CRUD. NestM adapters expose their exact
|
|
761
|
+
create, replace, ETag read, delete, and paired conditional-copy primitives to
|
|
762
|
+
that boundary. These operations now pass through caller-configured Files
|
|
763
|
+
plugins, hooks, retries, and receipts; a body transform, veto, retry observer,
|
|
764
|
+
or audit policy therefore sees the conditional operation instead of being
|
|
765
|
+
bypassed.
|
|
766
|
+
|
|
767
|
+
NestM retains a narrow direct fallback only for conditional shapes Files SDK
|
|
768
|
+
2.3 cannot represent: immutable version predicates, conditional
|
|
769
|
+
multipart/resumable completion, and a copy with only its source or only its
|
|
770
|
+
destination conditioned. Because those fallbacks cannot traverse the Files
|
|
771
|
+
operation pipeline, they remain fail-closed when caller Files policy is active:
|
|
772
|
+
|
|
773
|
+
| Operation shape | Execution path | With Files plugins, active hooks, or receipts |
|
|
774
|
+
| ----------------------------------------------------------- | --------------------- | --------------------------------------------- |
|
|
775
|
+
| Ordinary operations | Files pipeline | Available |
|
|
776
|
+
| Create/replace/ETag read/delete/paired conditional copy | Files 2.3 pipeline | Available |
|
|
777
|
+
| Version, conditional multipart/resumable, or one-sided copy | NestM direct fallback | Hidden; invocation returns `NOT_SUPPORTED` |
|
|
778
|
+
|
|
779
|
+
An empty plugin list, an empty hooks object, and `receipts: false` do not count
|
|
780
|
+
as caller policy. When available, a direct fallback still applies prefixing,
|
|
781
|
+
the physical-key budget, read-only restrictions, default retry/signal/timeout
|
|
782
|
+
options, and bounded error mapping. `StoragePlugin` remains a separate
|
|
783
|
+
veto/observation boundary; it is not a substitute for Files body or result
|
|
784
|
+
transforms.
|
|
785
|
+
|
|
786
|
+
`StorageWorkspace` uses the Files pipeline whenever its conditional operation
|
|
787
|
+
has an upstream representation. Lower-level conditional client and driver APIs
|
|
788
|
+
remain available for applications that intentionally use the policy-free
|
|
789
|
+
NestM-only fallback shapes.
|
|
790
|
+
|
|
185
791
|
## Storage API
|
|
186
792
|
|
|
187
793
|
`StorageClient` exposes:
|
|
188
794
|
|
|
189
795
|
- `upload`, `downloadStream`, `head`, `exists`, `delete`, `copy`, and `move`;
|
|
796
|
+
- exact `uploadConditional`, `downloadConditional`, `deleteConditional`, and
|
|
797
|
+
staged-object `promote` operations when the driver advertises each primitive;
|
|
190
798
|
- `list`, cursor-aware `listAll`, and lazy `search`;
|
|
191
799
|
- `signDownload` and discriminated PUT/POST `signUpload`;
|
|
192
800
|
- `uploadMany`, `downloadMany`, `headMany`, `existsMany`, and `deleteMany`;
|
|
@@ -194,6 +802,24 @@ whitespace.
|
|
|
194
802
|
- provider capability inspection; and
|
|
195
803
|
- pause/resume/abort through `StorageUploadControl`.
|
|
196
804
|
|
|
805
|
+
Provider list cursors are opaque, non-consuming continuation tokens. Replaying
|
|
806
|
+
the same cursor and page limit against unchanged provider-visible state must
|
|
807
|
+
return an equivalent page and continuation position, even after a descendant
|
|
808
|
+
cursor has been used. A cursor is bound to the logical store, `prefix`, and
|
|
809
|
+
`delimiter`, but not to `limit`, retries, timeout, or abort signal; callers may
|
|
810
|
+
change those transport/page-size options while resuming the same position.
|
|
811
|
+
|
|
812
|
+
The cursor must work through a newly constructed compatible driver targeting
|
|
813
|
+
the same backend namespace while the provider token remains valid and
|
|
814
|
+
available; it cannot depend on process-, client-, or session-local state. An
|
|
815
|
+
adapter for a consuming or instance-bound provider token must materialize a
|
|
816
|
+
stable continuation before it can provide conforming paginated
|
|
817
|
+
`StorageDriver.list` results. This contract lets a caller safely retry, replay,
|
|
818
|
+
or resume pagination on another replica. It does not promise a provider-token
|
|
819
|
+
lifetime, snapshot isolation across concurrent mutations, or provider,
|
|
820
|
+
network, credential, or authorization availability. Provider invalidation is
|
|
821
|
+
an ordinary list-operation failure.
|
|
822
|
+
|
|
197
823
|
Downloads are streaming by default:
|
|
198
824
|
|
|
199
825
|
```ts
|
|
@@ -216,6 +842,138 @@ Node `Readable` uploads are accepted and converted to Web streams without
|
|
|
216
842
|
buffering. Provider capability gaps fail closed with `StorageError` rather than
|
|
217
843
|
silently discarding a range, metadata, or cache-control request.
|
|
218
844
|
|
|
845
|
+
### Exact provider conditions and staged-object promotion
|
|
846
|
+
|
|
847
|
+
Capabilities distinguish conditional create, replace, delete, read, source
|
|
848
|
+
copy, destination copy, atomic source-and-destination promotion, and multipart
|
|
849
|
+
completion. They also declare the complete physical-key byte budget. Callers
|
|
850
|
+
must check the exact primitive they need; a missing field is unsupported and is
|
|
851
|
+
never widened from another operation.
|
|
852
|
+
|
|
853
|
+
Some adapters expose conditional copy only as a paired source-and-destination
|
|
854
|
+
operation. In that case,
|
|
855
|
+
`capabilities.conditionalCopySource.requiresDestinationPredicate` and
|
|
856
|
+
`capabilities.conditionalCopyDestination.requiresSourcePredicate` are `true`.
|
|
857
|
+
`StorageClient.promote` rejects a request missing the required counterpart
|
|
858
|
+
before provider I/O. A paired request must also satisfy the advertised
|
|
859
|
+
create/replace bit and
|
|
860
|
+
`capabilities.conditionalCopyDestination.atomicWithSource`.
|
|
861
|
+
|
|
862
|
+
The physical-key budget applies to the exact key sent to the adapter. It
|
|
863
|
+
therefore counts leading slashes for unprefixed drivers, the separator added to
|
|
864
|
+
a configured driver prefix, and provider prefixes derived for `list` or
|
|
865
|
+
`search`. Over-budget object keys and explicit list/search prefixes fail before
|
|
866
|
+
provider dispatch rather than being normalized into a shorter key. A glob's
|
|
867
|
+
provider prefix is the exact prefix inferred by files-sdk itself, and that
|
|
868
|
+
derived list operation passes through the same final guard. A non-positive
|
|
869
|
+
`maxResults` performs no provider walk and therefore dispatches no prefix.
|
|
870
|
+
|
|
871
|
+
Adapters and plugins execute as trusted in-process code; this package does not
|
|
872
|
+
attempt to sandbox a plugin that performs its own network or filesystem I/O.
|
|
873
|
+
For supported plugin pipelines that forward operations through `next`, the
|
|
874
|
+
physical-key guard is the innermost wrapper, after caller plugins and before
|
|
875
|
+
the adapter call. A plugin therefore cannot widen an upload key or list/search
|
|
876
|
+
prefix past the declared byte ceiling while still using the normal dispatch
|
|
877
|
+
pipeline.
|
|
878
|
+
|
|
879
|
+
Storage-facing ETags have one canonical representation: a bare, case-sensitive
|
|
880
|
+
opaque token with no HTTP quotes. Canonical values contain 1–1024 visible
|
|
881
|
+
ASCII bytes and exclude commas, backslashes, whitespace, control characters,
|
|
882
|
+
`DEL`, non-ASCII text, the `*` wildcard, and case-insensitive `W/` weak-tag
|
|
883
|
+
prefixes. Treat the value as opaque: preserve the exact string returned by
|
|
884
|
+
`head`, reads, or writes and pass it back unchanged to a conditional operation.
|
|
885
|
+
Do not add or remove quotes in application code. S3-compatible drivers remove
|
|
886
|
+
exactly one valid provider-owned quote pair on ingress and add exactly one pair
|
|
887
|
+
when serializing the HTTP header.
|
|
888
|
+
|
|
889
|
+
This tightens the precondition boundary. Quoted or otherwise non-canonical
|
|
890
|
+
ETags that older versions happened to accept now fail with `INVALID_ARGUMENT`,
|
|
891
|
+
and an unsafe provider result fails with a sanitized `PROVIDER` error instead
|
|
892
|
+
of being exposed as a usable validator. Applications that persisted quoted
|
|
893
|
+
ETags must refresh them with `head` rather than trimming them heuristically.
|
|
894
|
+
Strict normalization prevents a caller-controlled wildcard, entity-tag list,
|
|
895
|
+
weak validator, or malformed header value from widening an operation that
|
|
896
|
+
promises one exact strong match.
|
|
897
|
+
|
|
898
|
+
The native AWS S3 profile advertises ETag- and version-conditioned server-side
|
|
899
|
+
copy. This lets an application validate a staged object and copy that exact
|
|
900
|
+
source to its final key instead of re-reading whichever bytes occupy the
|
|
901
|
+
staging key later:
|
|
902
|
+
|
|
903
|
+
```ts
|
|
904
|
+
import { StorageError, StorageErrorCode } from '@nestm/storage';
|
|
905
|
+
|
|
906
|
+
const staged = await media.head(stagingKey);
|
|
907
|
+
if (staged.etag === undefined) {
|
|
908
|
+
throw new StorageError('Provider did not return a source ETag.', {
|
|
909
|
+
code: StorageErrorCode.NOT_SUPPORTED,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Validate size, declared MIME, and magic bytes before this call.
|
|
914
|
+
await media.file(stagingKey).promoteTo(finalKey, {
|
|
915
|
+
sourceEtag: staged.etag,
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// Commit ready metadata first. Promotion deliberately retains the staged
|
|
919
|
+
// object so a failed database commit remains recoverable.
|
|
920
|
+
await media.delete(stagingKey);
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
`sourceVersion` can select an immutable S3 version and may be combined with
|
|
924
|
+
`sourceEtag`. A destination condition can independently require create-only or
|
|
925
|
+
replacement of an exact ETag. Combining source and destination predicates also
|
|
926
|
+
requires `capabilities.conditionalCopyDestination.atomicWithSource`; otherwise
|
|
927
|
+
the request fails with `NOT_SUPPORTED`. A promotion must contain at least one
|
|
928
|
+
source or destination predicate.
|
|
929
|
+
|
|
930
|
+
Cloudflare R2 has a separate stable profile: create, replace, ETag-conditioned
|
|
931
|
+
read, and ETag-conditioned source copy are enabled, while conditional delete,
|
|
932
|
+
destination copy, atomic promotion, version predicates, and conditional
|
|
933
|
+
multipart completion remain absent. R2 proves content-type binding for
|
|
934
|
+
presigned PUT requests but not POST-form size ranges, so its signed-upload
|
|
935
|
+
policy is `{ contentType: true, sizeRange: false }` and the gateway refuses to
|
|
936
|
+
mint its POST upload form. Cloudflare documents that presigned `POST` form
|
|
937
|
+
uploads are not supported in its
|
|
938
|
+
[presigned URL contract](https://developers.cloudflare.com/r2/api/s3/presigned-urls/).
|
|
939
|
+
Direct R2 signed-upload calls that request a size bound likewise fail with
|
|
940
|
+
`NOT_SUPPORTED` before signing.
|
|
941
|
+
Custom S3-compatible endpoints start with no conditional operations and the
|
|
942
|
+
entire driver is forced read-only until an explicit conformance-verified
|
|
943
|
+
`S3ProviderProfile` is supplied. Omitting `signedUploadPolicy` while defining a
|
|
944
|
+
custom profile normalizes both policy claims to `false`; providers may opt in
|
|
945
|
+
only to constraints their conformance evidence proves.
|
|
946
|
+
|
|
947
|
+
Successful S3 signed uploads enforce every requested constraint. A request
|
|
948
|
+
with `contentType` and no `maxSize` uses a presigned PUT whose signature
|
|
949
|
+
includes the `content-type` header. A request with `maxSize` uses a POST policy
|
|
950
|
+
with `content-length-range` and, when present, an exact `Content-Type`
|
|
951
|
+
condition. S3 cannot express a lower-only `minSize` through this contract, so
|
|
952
|
+
that shape fails with `NOT_SUPPORTED`; an unclaimed profile constraint also
|
|
953
|
+
fails before credentials are resolved or a URL is minted. Literal physical
|
|
954
|
+
keys ending in AWS's `${filename}` POST template are rejected for bounded
|
|
955
|
+
uploads because the SDK otherwise widens the exact key condition to a prefix.
|
|
956
|
+
|
|
957
|
+
`withS3Capabilities()` decorates a raw S3 adapter in place and may be applied
|
|
958
|
+
only once. Construct a fresh raw adapter when selecting a different profile;
|
|
959
|
+
reapplying the helper is rejected so a previous broader profile cannot survive
|
|
960
|
+
a later narrower declaration. The selected profile is bound to the exact
|
|
961
|
+
reserved capability and operation members installed by that decoration;
|
|
962
|
+
same-client aliases may change display metadata but cannot add or replace those
|
|
963
|
+
members to widen the profile.
|
|
964
|
+
|
|
965
|
+
An explicit profile applied to a native AWS SDK endpoint may only narrow the
|
|
966
|
+
immutable `AWS_S3_PROVIDER_PROFILE`. It cannot raise the complete-key budget
|
|
967
|
+
above 1,024 bytes or claim an operation/policy bit absent from the built-in
|
|
968
|
+
profile. This containment follows actual SDK endpoint provenance even when an
|
|
969
|
+
adapter alias changes its display name.
|
|
970
|
+
|
|
971
|
+
The package-owned `s3()` factory also retains whether `publicBaseUrl` was
|
|
972
|
+
configured even when the second `withS3Capabilities()` options object is
|
|
973
|
+
omitted. In that case `signedDownloadPolicy.expiresIn` is false. Foreign S3
|
|
974
|
+
adapters whose construction metadata is unavailable receive the same
|
|
975
|
+
conservative false value instead of claiming an enforceable TTL.
|
|
976
|
+
|
|
219
977
|
### Resumable uploads
|
|
220
978
|
|
|
221
979
|
```ts
|
|
@@ -267,20 +1025,42 @@ The gateway lives at `@nestm/storage/gateway` and is never mounted by
|
|
|
267
1025
|
`StorageModule`.
|
|
268
1026
|
|
|
269
1027
|
```ts
|
|
270
|
-
import { Module } from '@nestjs/common';
|
|
1028
|
+
import { Injectable, Module } from '@nestjs/common';
|
|
271
1029
|
import {
|
|
272
1030
|
StorageGatewayModule,
|
|
273
1031
|
StorageGatewayOperation,
|
|
1032
|
+
type StorageGatewayKeyPolicy,
|
|
274
1033
|
} from '@nestm/storage/gateway';
|
|
275
1034
|
|
|
1035
|
+
@Injectable()
|
|
1036
|
+
class TenantStorageKeyPolicy implements StorageGatewayKeyPolicy {
|
|
1037
|
+
resolve({ input, request, target }) {
|
|
1038
|
+
const tenantId = tenantIdFromAuthenticatedRequest(request);
|
|
1039
|
+
const root = `tenants/${base64url(tenantId)}`;
|
|
1040
|
+
if (target === 'pattern') {
|
|
1041
|
+
// Search is already constrained by the separately resolved prefix.
|
|
1042
|
+
return input?.value ?? '*';
|
|
1043
|
+
}
|
|
1044
|
+
return `${root}/${input?.value ?? ''}`;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
@Module({
|
|
1049
|
+
providers: [TenantStorageKeyPolicy],
|
|
1050
|
+
exports: [TenantStorageKeyPolicy],
|
|
1051
|
+
})
|
|
1052
|
+
class StoragePolicyModule {}
|
|
1053
|
+
|
|
276
1054
|
@Module({
|
|
277
1055
|
imports: [
|
|
278
1056
|
AppStorageModule,
|
|
279
1057
|
AuthModule,
|
|
1058
|
+
StoragePolicyModule,
|
|
280
1059
|
StorageGatewayModule.register({
|
|
281
|
-
imports: [AppStorageModule, AuthModule],
|
|
1060
|
+
imports: [AppStorageModule, AuthModule, StoragePolicyModule],
|
|
282
1061
|
store: 'media',
|
|
283
1062
|
guards: [JwtAuthGuard],
|
|
1063
|
+
keyPolicy: TenantStorageKeyPolicy,
|
|
284
1064
|
mode: 'hybrid',
|
|
285
1065
|
operations: [
|
|
286
1066
|
StorageGatewayOperation.DOWNLOAD,
|
|
@@ -291,6 +1071,9 @@ import {
|
|
|
291
1071
|
StorageGatewayOperation.SIGN_UPLOAD,
|
|
292
1072
|
],
|
|
293
1073
|
maxUploadBytes: 100 * 1024 * 1024,
|
|
1074
|
+
maxSignedUploadBytes: 10 * 1024 * 1024,
|
|
1075
|
+
signedUploadContentTypes: ['image/jpeg', 'image/png'],
|
|
1076
|
+
maxSignedUrlExpiresIn: 900,
|
|
294
1077
|
maxListResults: 1000,
|
|
295
1078
|
maxSearchResults: 1000,
|
|
296
1079
|
proxyInlineContentTypes: ['image/jpeg', 'image/png'],
|
|
@@ -304,12 +1087,42 @@ Registration fails without at least one existing Nest guard. The only bypass is
|
|
|
304
1087
|
the explicit `allowUnauthenticated: true` development escape hatch. Operations
|
|
305
1088
|
are deny-by-default and must be allowlisted individually.
|
|
306
1089
|
|
|
1090
|
+
Registration also fails without a `keyPolicy`. Guards answer whether a request
|
|
1091
|
+
may reach the gateway; the key policy independently resolves every parsed
|
|
1092
|
+
`key`, `prefix`, search `pattern`, `from`, and `to` value to the exact provider
|
|
1093
|
+
path. It runs even when a list/search prefix was omitted, so the policy can
|
|
1094
|
+
always impose a tenant root. Key-policy providers may be request scoped.
|
|
1095
|
+
Returned paths are parsed again and reject absolute paths, backslashes, control
|
|
1096
|
+
characters, empty segments, and dot/parent segments.
|
|
1097
|
+
|
|
1098
|
+
Existing single-tenant applications can temporarily set
|
|
1099
|
+
`unsafeAllowUnscopedKeys: true` instead of `keyPolicy`. The name is intentional:
|
|
1100
|
+
it preserves caller-controlled provider keys and must not be used on an exposed
|
|
1101
|
+
or multi-tenant gateway. It cannot be combined with `keyPolicy`.
|
|
1102
|
+
|
|
307
1103
|
Proxy downloads default to `Content-Disposition: attachment` and always send
|
|
308
1104
|
`X-Content-Type-Options: nosniff`. Add only trusted, non-active MIME types to
|
|
309
1105
|
`proxyInlineContentTypes` when browser rendering is required. Search responses
|
|
310
1106
|
are capped by `maxSearchResults`, and list pages by `maxListResults` (both
|
|
311
1107
|
1,000 by default).
|
|
312
1108
|
|
|
1109
|
+
Every signed URL is capped by `maxSignedUrlExpiresIn` (3,600 seconds by
|
|
1110
|
+
default). Signed uploads always carry a provider-enforced maximum size, capped
|
|
1111
|
+
by `maxSignedUploadBytes`, and require an exact lowercase MIME type from
|
|
1112
|
+
`signedUploadContentTypes`. The default direct-upload allowlist contains only
|
|
1113
|
+
`application/octet-stream`. Gateway callers may request only the literal
|
|
1114
|
+
`attachment` or `inline` response disposition; arbitrary response-header text
|
|
1115
|
+
and filenames are rejected. A driver must also advertise
|
|
1116
|
+
`signedUploadPolicy.contentType` and `signedUploadPolicy.sizeRange`; otherwise
|
|
1117
|
+
the gateway refuses to mint the URL. Native AWS advertises both and uses S3
|
|
1118
|
+
POST policy conditions. R2 advertises content-type enforcement but not a POST
|
|
1119
|
+
size range, while omitted custom declarations normalize both claims to false;
|
|
1120
|
+
the gateway therefore fails closed for those profiles. Signed downloads
|
|
1121
|
+
similarly require
|
|
1122
|
+
`signedDownloadPolicy.expiresIn`. The S3 factory advertises it only when no
|
|
1123
|
+
permanent `publicBaseUrl` was configured, preventing a configured TTL from
|
|
1124
|
+
silently returning a non-expiring public link.
|
|
1125
|
+
|
|
313
1126
|
The fixed gateway prefix is `/storage`:
|
|
314
1127
|
|
|
315
1128
|
| Method | Path | Operation |
|
|
@@ -360,9 +1173,26 @@ try {
|
|
|
360
1173
|
}
|
|
361
1174
|
```
|
|
362
1175
|
|
|
1176
|
+
`files-sdk` `NotFound` failures retain `StorageErrorCode.NOT_FOUND`, including
|
|
1177
|
+
when a provider adapter and this driver resolve separate copies of `files-sdk`.
|
|
1178
|
+
`isStorageError()` likewise recognizes branded and exact legacy structural
|
|
1179
|
+
errors produced by a duplicated `@nestm/storage` package copy.
|
|
1180
|
+
|
|
1181
|
+
For a conditional mutation, `error.applied === true` means the provider commit
|
|
1182
|
+
succeeded but acknowledgement failed afterward, for example in an awaited
|
|
1183
|
+
post-operation plugin. Conditional uploads also expose `error.appliedEtag` when
|
|
1184
|
+
the committed generation is known. Do not blindly retry the original
|
|
1185
|
+
predicate: reconcile the logical destination first, using an exact ETag read
|
|
1186
|
+
when `appliedEtag` is present. This is a one-way signal: `applied === false`
|
|
1187
|
+
does not prove that a remote mutation did not commit. A timeout, connection
|
|
1188
|
+
loss, or exhausted retry can lose the provider's success response, so reconcile
|
|
1189
|
+
ambiguous transport/provider failures before repeating a conditional mutation.
|
|
1190
|
+
The sanitized workspace, AI-tool, and gateway error boundaries retain only
|
|
1191
|
+
this bounded reconciliation metadata.
|
|
1192
|
+
|
|
363
1193
|
Capability flags cover range reads, native byte-level upload progress,
|
|
364
1194
|
delimiter listing, metadata, cache control, resumable uploads, server-side
|
|
365
|
-
copy, and signed transfers.
|
|
1195
|
+
copy, conditional promotion, and signed transfers.
|
|
366
1196
|
Provider-specific native clients are intentionally not exposed from the root
|
|
367
1197
|
package.
|
|
368
1198
|
|
|
@@ -378,8 +1208,30 @@ StorageModule.forRoot({
|
|
|
378
1208
|
});
|
|
379
1209
|
```
|
|
380
1210
|
|
|
381
|
-
For local filesystem storage,
|
|
382
|
-
`
|
|
1211
|
+
For local filesystem storage, use the package-owned factory. The adapter reaches
|
|
1212
|
+
only `node:fs`, so it needs no native SDK:
|
|
1213
|
+
|
|
1214
|
+
```ts
|
|
1215
|
+
import { createFsStorageDriver } from '@nestm/storage/files-sdk/fs';
|
|
1216
|
+
|
|
1217
|
+
StorageModule.forRoot({
|
|
1218
|
+
stores: [
|
|
1219
|
+
{
|
|
1220
|
+
name: 'artifacts',
|
|
1221
|
+
driver: createFsStorageDriver({ adapter: { root: './var/artifacts' } }),
|
|
1222
|
+
},
|
|
1223
|
+
],
|
|
1224
|
+
});
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
Bodies are written verbatim at `<root>/<key>`. A `<key>.meta.json` sidecar beside
|
|
1228
|
+
each one carries the content type, ETag, and custom metadata a filesystem has
|
|
1229
|
+
nowhere else to put; sidecars never surface as keys, and uploading a key ending
|
|
1230
|
+
in `.meta.json` fails closed rather than colliding with one. Ordinary and
|
|
1231
|
+
conditional mutations made through the decorated adapter share one
|
|
1232
|
+
process-local lock domain. Conditional guarantees therefore require a
|
|
1233
|
+
dedicated root: do not mutate it through another process, an unwrapped adapter,
|
|
1234
|
+
or direct filesystem calls.
|
|
383
1235
|
|
|
384
1236
|
## License
|
|
385
1237
|
|