@chidchanun/bcp 0.1.26 → 0.1.27
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/README.md +176 -518
- package/docs/README.md +71 -59
- package/docs/releases/0.1.27.md +350 -0
- package/docs/s3-storage.md +212 -55
- package/docs/storage-ecosystem.md +434 -0
- package/package.json +2 -1
- package/packages/client/src/server.ts +33 -2
- package/packages/server/src/storage-ecosystem.ts +1326 -0
- package/packages/server/src/storage-s3-ecosystem.ts +947 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# Storage Ecosystem
|
|
2
|
+
|
|
3
|
+
BCP Framework `0.1.27` expands the `0.1.25` storage abstraction and `0.1.26` streaming/cloud work into a broader object-storage ecosystem.
|
|
4
|
+
|
|
5
|
+
> Version target: `0.1.27` unreleased development target until RC validation and publication complete.
|
|
6
|
+
|
|
7
|
+
The public APIs are available through `bcp/server`.
|
|
8
|
+
|
|
9
|
+
## Capabilities
|
|
10
|
+
|
|
11
|
+
Use `getStorageEcosystemCapabilities()` when code needs to select an optimized path:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
getStorageEcosystemCapabilities,
|
|
16
|
+
} from "bcp/server";
|
|
17
|
+
|
|
18
|
+
const capabilities =
|
|
19
|
+
getStorageEcosystemCapabilities(
|
|
20
|
+
storage
|
|
21
|
+
);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The result includes the original storage capabilities plus the `0.1.27` ecosystem surface:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
{
|
|
28
|
+
streamingRead: boolean;
|
|
29
|
+
streamingWrite: boolean;
|
|
30
|
+
ranges: boolean;
|
|
31
|
+
signedUrls: boolean;
|
|
32
|
+
listing: boolean;
|
|
33
|
+
signedReadUrls: boolean;
|
|
34
|
+
signedWriteUrls: boolean;
|
|
35
|
+
copy: boolean;
|
|
36
|
+
move: boolean;
|
|
37
|
+
metadata: boolean;
|
|
38
|
+
bulkDelete: boolean;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Built-in local storage supports listing, copy, move, metadata and bulk deletion. It intentionally does not provide signed URLs.
|
|
43
|
+
|
|
44
|
+
Built-in S3-compatible storage supports the complete capability set.
|
|
45
|
+
|
|
46
|
+
## List objects
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import {
|
|
50
|
+
listStorageObjects,
|
|
51
|
+
} from "bcp/server";
|
|
52
|
+
|
|
53
|
+
const page =
|
|
54
|
+
await listStorageObjects(
|
|
55
|
+
storage,
|
|
56
|
+
{
|
|
57
|
+
prefix:
|
|
58
|
+
"documents/",
|
|
59
|
+
limit:
|
|
60
|
+
50,
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Result:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
{
|
|
69
|
+
objects: StorageObjectMetadata[];
|
|
70
|
+
cursor?: string;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Use the cursor returned by the previous page:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const next =
|
|
78
|
+
await listStorageObjects(
|
|
79
|
+
storage,
|
|
80
|
+
{
|
|
81
|
+
prefix:
|
|
82
|
+
"documents/",
|
|
83
|
+
limit:
|
|
84
|
+
50,
|
|
85
|
+
cursor:
|
|
86
|
+
page.cursor,
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Cursors are opaque provider/application tokens. Do not decode them or persist assumptions about their internal format.
|
|
92
|
+
|
|
93
|
+
`limit` defaults to `100` and accepts values from `1` through `1000`.
|
|
94
|
+
|
|
95
|
+
### Prefix behavior
|
|
96
|
+
|
|
97
|
+
Both of these are valid:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
prefix: "documents"
|
|
101
|
+
prefix: "documents/"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A trailing slash is preserved when supplied, which is useful when application keys are organized as logical folders.
|
|
105
|
+
|
|
106
|
+
Storage listing is not a filesystem directory API. Keys remain object keys, and consumers should not infer authorization from prefixes.
|
|
107
|
+
|
|
108
|
+
## Copy objects
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import {
|
|
112
|
+
copyStorageObject,
|
|
113
|
+
} from "bcp/server";
|
|
114
|
+
|
|
115
|
+
const copied =
|
|
116
|
+
await copyStorageObject(
|
|
117
|
+
storage,
|
|
118
|
+
"incoming/report.pdf",
|
|
119
|
+
"archive/report.pdf"
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
By default an existing destination is rejected with `StorageError` code `OBJECT_EXISTS`.
|
|
124
|
+
|
|
125
|
+
Intentional replacement must opt in:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
await copyStorageObject(
|
|
129
|
+
storage,
|
|
130
|
+
source,
|
|
131
|
+
destination,
|
|
132
|
+
{
|
|
133
|
+
overwrite:
|
|
134
|
+
true,
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
S3-compatible storage uses native `CopyObject`.
|
|
140
|
+
|
|
141
|
+
When a custom adapter does not expose a native `copy()` method, the helper can fall back to streamed read/write copy using the existing `StorageAdapter` APIs.
|
|
142
|
+
|
|
143
|
+
If the adapter supports user metadata, the compatibility copy path preserves it.
|
|
144
|
+
|
|
145
|
+
## Move objects
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import {
|
|
149
|
+
moveStorageObject,
|
|
150
|
+
} from "bcp/server";
|
|
151
|
+
|
|
152
|
+
await moveStorageObject(
|
|
153
|
+
storage,
|
|
154
|
+
"tmp/avatar.webp",
|
|
155
|
+
"users/42/avatar.webp"
|
|
156
|
+
);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A move is implemented as a successful copy followed by deletion of the source.
|
|
160
|
+
|
|
161
|
+
This has an important distributed-storage implication: copy and delete are not one atomic transaction across generic object stores. If the delete fails after the copy succeeds, both source and destination can temporarily exist.
|
|
162
|
+
|
|
163
|
+
Application workflows that need stronger business-level atomicity should coordinate the move with database state rather than assuming the storage operation is transactional.
|
|
164
|
+
|
|
165
|
+
## User metadata
|
|
166
|
+
|
|
167
|
+
Portable application metadata is represented as:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
Record<string, string>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Store metadata with an object:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const stored =
|
|
177
|
+
await storage.put(
|
|
178
|
+
"documents/report.pdf",
|
|
179
|
+
bytes,
|
|
180
|
+
{
|
|
181
|
+
contentType:
|
|
182
|
+
"application/pdf",
|
|
183
|
+
metadata: {
|
|
184
|
+
owner:
|
|
185
|
+
"user-42",
|
|
186
|
+
category:
|
|
187
|
+
"report",
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The same option is available on the ecosystem `putStream()` contract.
|
|
194
|
+
|
|
195
|
+
Read metadata independently:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import {
|
|
199
|
+
getStorageMetadata,
|
|
200
|
+
} from "bcp/server";
|
|
201
|
+
|
|
202
|
+
const metadata =
|
|
203
|
+
await getStorageMetadata(
|
|
204
|
+
storage,
|
|
205
|
+
stored.key
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Replace it:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import {
|
|
213
|
+
setStorageMetadata,
|
|
214
|
+
} from "bcp/server";
|
|
215
|
+
|
|
216
|
+
await setStorageMetadata(
|
|
217
|
+
storage,
|
|
218
|
+
stored.key,
|
|
219
|
+
{
|
|
220
|
+
owner:
|
|
221
|
+
"user-42",
|
|
222
|
+
status:
|
|
223
|
+
"approved",
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`setStorageMetadata()` replaces the application metadata map rather than merging it.
|
|
229
|
+
|
|
230
|
+
### Metadata rules
|
|
231
|
+
|
|
232
|
+
BCP normalizes metadata keys to lowercase.
|
|
233
|
+
|
|
234
|
+
Keys must use a portable subset:
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
letters
|
|
238
|
+
numbers
|
|
239
|
+
underscore
|
|
240
|
+
hyphen
|
|
241
|
+
dot
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The framework reserves:
|
|
245
|
+
|
|
246
|
+
```text
|
|
247
|
+
bcp_sha256
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
for internal checksum metadata.
|
|
251
|
+
|
|
252
|
+
Metadata values must be strings and cannot contain CR/LF or NUL characters.
|
|
253
|
+
|
|
254
|
+
Metadata is useful for lightweight object context, but it should not replace application authorization or relational data that needs querying/transactions.
|
|
255
|
+
|
|
256
|
+
## Local metadata implementation
|
|
257
|
+
|
|
258
|
+
Local storage keeps BCP-internal and application metadata under the existing reserved metadata directory rather than mixing it with user-visible object keys.
|
|
259
|
+
|
|
260
|
+
The reserved directory is excluded from object listings.
|
|
261
|
+
|
|
262
|
+
Deleting or replacing an object also clears or replaces its application metadata as appropriate.
|
|
263
|
+
|
|
264
|
+
## S3 metadata implementation
|
|
265
|
+
|
|
266
|
+
S3-compatible storage maps application metadata to S3 user metadata.
|
|
267
|
+
|
|
268
|
+
BCP preserves its internal `bcp_sha256` metadata when application metadata is replaced.
|
|
269
|
+
|
|
270
|
+
Updating S3 object metadata uses S3's copy-to-self metadata replacement behavior. This means metadata mutation is a provider object operation, not merely a local in-memory change.
|
|
271
|
+
|
|
272
|
+
## Bulk deletion
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import {
|
|
276
|
+
deleteStorageObjects,
|
|
277
|
+
} from "bcp/server";
|
|
278
|
+
|
|
279
|
+
const result =
|
|
280
|
+
await deleteStorageObjects(
|
|
281
|
+
storage,
|
|
282
|
+
[
|
|
283
|
+
"tmp/a.bin",
|
|
284
|
+
"tmp/b.bin",
|
|
285
|
+
"tmp/c.bin",
|
|
286
|
+
]
|
|
287
|
+
);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Result:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
{
|
|
294
|
+
deleted: string[];
|
|
295
|
+
missing: string[];
|
|
296
|
+
failed: Array<{
|
|
297
|
+
key: string;
|
|
298
|
+
message: string;
|
|
299
|
+
}>;
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Duplicate input keys are normalized to one deletion request.
|
|
304
|
+
|
|
305
|
+
Local/custom fallback deletion can distinguish missing keys.
|
|
306
|
+
|
|
307
|
+
S3's delete-object semantics treat a delete for a missing key as successful, so S3-compatible bulk deletion normally reports those keys as `deleted` rather than `missing`.
|
|
308
|
+
|
|
309
|
+
S3 requests are batched to the provider's 1000-object multi-delete limit.
|
|
310
|
+
|
|
311
|
+
## Signed read URLs
|
|
312
|
+
|
|
313
|
+
Signed URLs are intended primarily for object-storage adapters such as S3/R2/MinIO.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import {
|
|
317
|
+
createStorageSignedReadUrl,
|
|
318
|
+
} from "bcp/server";
|
|
319
|
+
|
|
320
|
+
const url =
|
|
321
|
+
await createStorageSignedReadUrl(
|
|
322
|
+
storage,
|
|
323
|
+
"videos/demo.mp4",
|
|
324
|
+
{
|
|
325
|
+
expiresIn:
|
|
326
|
+
5 * 60,
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The URL lets a client fetch the object directly from the object-storage endpoint for the configured lifetime.
|
|
332
|
+
|
|
333
|
+
BCP defaults to 15 minutes and accepts an expiry from 1 second through 7 days.
|
|
334
|
+
|
|
335
|
+
## Signed write URLs
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
import {
|
|
339
|
+
createStorageSignedWriteUrl,
|
|
340
|
+
} from "bcp/server";
|
|
341
|
+
|
|
342
|
+
const url =
|
|
343
|
+
await createStorageSignedWriteUrl(
|
|
344
|
+
storage,
|
|
345
|
+
"uploads/video.mp4",
|
|
346
|
+
{
|
|
347
|
+
expiresIn:
|
|
348
|
+
5 * 60,
|
|
349
|
+
contentType:
|
|
350
|
+
"video/mp4",
|
|
351
|
+
metadata: {
|
|
352
|
+
owner:
|
|
353
|
+
"user-42",
|
|
354
|
+
},
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
A common direct-upload flow is:
|
|
360
|
+
|
|
361
|
+
```text
|
|
362
|
+
Browser
|
|
363
|
+
↓ request upload authorization
|
|
364
|
+
BCP application
|
|
365
|
+
↓ returns short-lived signed URL
|
|
366
|
+
Browser
|
|
367
|
+
↓ PUT bytes directly
|
|
368
|
+
S3 / R2 / MinIO
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
This avoids proxying very large file bytes through the BCP application server.
|
|
372
|
+
|
|
373
|
+
If the signed request includes content type or metadata headers, the direct uploader must send the corresponding headers required by the generated signature/provider behavior.
|
|
374
|
+
|
|
375
|
+
## Signed URL security
|
|
376
|
+
|
|
377
|
+
A signed URL is a temporary credential. Anyone who obtains a valid URL can normally perform the signed operation until it expires.
|
|
378
|
+
|
|
379
|
+
Recommended practices:
|
|
380
|
+
|
|
381
|
+
- generate signed URLs only after authorization,
|
|
382
|
+
- use short expirations,
|
|
383
|
+
- choose server-generated object keys instead of trusting arbitrary client paths,
|
|
384
|
+
- limit the signed operation to one object/action,
|
|
385
|
+
- do not log full signed URLs when query strings contain credentials,
|
|
386
|
+
- keep buckets private by default,
|
|
387
|
+
- configure bucket CORS only for intended browser origins/methods,
|
|
388
|
+
- persist upload ownership in application data before or immediately after issuing upload authorization.
|
|
389
|
+
|
|
390
|
+
## Unsupported operations
|
|
391
|
+
|
|
392
|
+
A helper that requires an adapter capability which cannot be provided generically throws `StorageEcosystemError`:
|
|
393
|
+
|
|
394
|
+
```text
|
|
395
|
+
code: UNSUPPORTED_OPERATION
|
|
396
|
+
status: 501
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
For example, local filesystem storage does not fabricate a signed URL API.
|
|
400
|
+
|
|
401
|
+
Other ecosystem validation errors include:
|
|
402
|
+
|
|
403
|
+
```text
|
|
404
|
+
INVALID_CURSOR
|
|
405
|
+
INVALID_METADATA
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
## Compatibility model
|
|
409
|
+
|
|
410
|
+
`StorageAdapter` itself remains the stable minimum contract from earlier releases.
|
|
411
|
+
|
|
412
|
+
`StorageEcosystemAdapter` layers optional capabilities on top of it. This allows older custom adapters to continue compiling while applications gradually adopt richer operations.
|
|
413
|
+
|
|
414
|
+
Prefer the public helper functions when code should work across adapters:
|
|
415
|
+
|
|
416
|
+
```text
|
|
417
|
+
listStorageObjects
|
|
418
|
+
copyStorageObject
|
|
419
|
+
moveStorageObject
|
|
420
|
+
deleteStorageObjects
|
|
421
|
+
getStorageMetadata
|
|
422
|
+
setStorageMetadata
|
|
423
|
+
createStorageSignedReadUrl
|
|
424
|
+
createStorageSignedWriteUrl
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Using the helpers also gives the framework a place to provide compatibility fallbacks when an operation can be implemented safely through the base adapter contract.
|
|
428
|
+
|
|
429
|
+
## Related documentation
|
|
430
|
+
|
|
431
|
+
- [Storage and File Delivery](storage.md)
|
|
432
|
+
- [S3-Compatible Storage](s3-storage.md)
|
|
433
|
+
- [File Upload](file-upload.md)
|
|
434
|
+
- [BCP Framework 0.1.27](releases/0.1.27.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chidchanun/bcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"@aws-sdk/client-s3": "^3.1119.0",
|
|
83
83
|
"@aws-sdk/lib-storage": "^3.1119.0",
|
|
84
|
+
"@aws-sdk/s3-request-presigner": "^3.1121.0",
|
|
84
85
|
"@babel/core": "^8.0.1",
|
|
85
86
|
"@babel/preset-react": "^8.0.1",
|
|
86
87
|
"@babel/preset-typescript": "^8.0.1",
|
|
@@ -55,7 +55,6 @@ export {
|
|
|
55
55
|
} from "../../server/src/upload-stream.js";
|
|
56
56
|
|
|
57
57
|
export {
|
|
58
|
-
createLocalStorage,
|
|
59
58
|
getStorageCapabilities,
|
|
60
59
|
normalizeStorageKey,
|
|
61
60
|
putStorageStream,
|
|
@@ -78,13 +77,45 @@ export {
|
|
|
78
77
|
} from "../../server/src/storage.js";
|
|
79
78
|
|
|
80
79
|
export {
|
|
81
|
-
|
|
80
|
+
copyStorageObject,
|
|
81
|
+
createLocalStorage,
|
|
82
|
+
createStorageSignedReadUrl,
|
|
83
|
+
createStorageSignedWriteUrl,
|
|
84
|
+
deleteStorageObjects,
|
|
85
|
+
getStorageEcosystemCapabilities,
|
|
86
|
+
getStorageMetadata,
|
|
87
|
+
listStorageObjects,
|
|
88
|
+
moveStorageObject,
|
|
89
|
+
setStorageMetadata,
|
|
90
|
+
StorageEcosystemError,
|
|
91
|
+
|
|
92
|
+
type StorageCopyOptions,
|
|
93
|
+
type StorageDeleteManyFailure,
|
|
94
|
+
type StorageDeleteManyResult,
|
|
95
|
+
type StorageEcosystemAdapter,
|
|
96
|
+
type StorageEcosystemCapabilities,
|
|
97
|
+
type StorageEcosystemErrorCode,
|
|
98
|
+
type StorageEcosystemPutOptions,
|
|
99
|
+
type StorageEcosystemPutStreamOptions,
|
|
100
|
+
type StorageListOptions,
|
|
101
|
+
type StorageListResult,
|
|
102
|
+
type StorageSignedUrlOptions,
|
|
103
|
+
type StorageSignedWriteUrlOptions,
|
|
104
|
+
type StorageUserMetadata,
|
|
105
|
+
} from "../../server/src/storage-ecosystem.js";
|
|
82
106
|
|
|
107
|
+
export {
|
|
83
108
|
type S3StorageAdapter,
|
|
84
109
|
type S3StorageMultipartOptions,
|
|
85
110
|
type S3StorageOptions,
|
|
86
111
|
} from "../../server/src/storage-s3.js";
|
|
87
112
|
|
|
113
|
+
export {
|
|
114
|
+
createS3Storage,
|
|
115
|
+
|
|
116
|
+
type S3StorageEcosystemAdapter,
|
|
117
|
+
} from "../../server/src/storage-s3-ecosystem.js";
|
|
118
|
+
|
|
88
119
|
export {
|
|
89
120
|
createStorageResponse,
|
|
90
121
|
isStorageRangeError,
|