@fonderie/storage 0.0.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 +21 -0
- package/README.md +52 -0
- package/brain/outcomes.md +20 -0
- package/brain/signatures.md +43 -0
- package/dist/index.cjs +99 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_storage.sql +19 -0
- package/dist/s3.cjs +70 -0
- package/dist/s3.cjs.map +1 -0
- package/dist/s3.d.cts +49 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.js +48 -0
- package/dist/s3.js.map +1 -0
- package/dist/types-Cb480R4u.d.cts +46 -0
- package/dist/types-Cb480R4u.d.ts +46 -0
- package/package.json +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fonderie, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @fonderie/storage
|
|
2
|
+
|
|
3
|
+
Content-agnostic, provider-abstracted **object storage** — store any bytes
|
|
4
|
+
behind one interface, on Postgres, disk, or any S3-compatible service
|
|
5
|
+
(S3 / MinIO / R2 / B2 / Spaces). The low-dependency foundation other bricks
|
|
6
|
+
build on: `@fonderie/media` (uploads/serving), DB archives, datasets, anything.
|
|
7
|
+
|
|
8
|
+
Start on Postgres for free; flip one config line to real object storage when
|
|
9
|
+
scale (or a paying customer) justifies it — no consumer code changes.
|
|
10
|
+
|
|
11
|
+
## The interface
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
interface IStorageProvider {
|
|
15
|
+
put(input: { bytes: Uint8Array; contentType: string }): Promise<{ ref: string }>;
|
|
16
|
+
get(ref: string): Promise<{ kind: 'bytes'; bytes: Uint8Array } | { kind: 'redirect'; url: string } | null>;
|
|
17
|
+
delete(ref: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`get` returns **bytes** (you serve/write/parse them) or a **redirect URL** (the
|
|
22
|
+
backend serves it, e.g. an S3 presigned URL) — so consumers never branch on
|
|
23
|
+
which backend is wired.
|
|
24
|
+
|
|
25
|
+
## Providers
|
|
26
|
+
|
|
27
|
+
- **`DbBlobProvider`** — bytes in Postgres (`fonderie_storage_blobs`). Zero infra;
|
|
28
|
+
one process + one database; atomic `pg_dump` backups. The default.
|
|
29
|
+
- **`LocalFsProvider`** — bytes on the server filesystem. Single-box, no DB bloat.
|
|
30
|
+
- **`S3Provider`** — any S3-compatible store. `get` returns a presigned redirect
|
|
31
|
+
so the app never proxies bytes.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { DbBlobProvider, LocalFsProvider } from '@fonderie/storage';
|
|
35
|
+
import { S3Provider } from '@fonderie/storage/s3'; // opt-in; pulls the AWS SDK peers
|
|
36
|
+
|
|
37
|
+
const dev = new DbBlobProvider(store);
|
|
38
|
+
const prod = new S3Provider({ bucket: 'assets', endpoint: 'http://minio:9000', accessKeyId, secretAccessKey });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`S3Provider` needs the optional peers `@aws-sdk/client-s3` and
|
|
42
|
+
`@aws-sdk/s3-request-presigner`; they're only loaded when you import
|
|
43
|
+
`@fonderie/storage/s3`, so DbBlob/LocalFs users stay dependency-free.
|
|
44
|
+
|
|
45
|
+
## Migration
|
|
46
|
+
|
|
47
|
+
`DbBlobProvider` needs its table — run the migration alongside your others:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { getMigrationsPath } from '@fonderie/storage/migrations';
|
|
51
|
+
await new InternalMigrationRunner(store, getMigrationsPath()).run();
|
|
52
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
|
|
2
|
+
|
|
3
|
+
# @fonderie/storage — outcomes
|
|
4
|
+
|
|
5
|
+
What this package does to a running app: tables its migrations create,
|
|
6
|
+
rows it seeds, routes it registers. Generated from the migration SQL and
|
|
7
|
+
route tables in source — trust this file instead of reading `dist/` or
|
|
8
|
+
downloading tarballs.
|
|
9
|
+
|
|
10
|
+
## Database tables (after all migrations)
|
|
11
|
+
|
|
12
|
+
### `fonderie_storage_blobs`
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
16
|
+
bytes BYTEA NOT NULL
|
|
17
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Raw SQL ships in `node_modules/@fonderie/storage/dist/migrations/sql/` — read it there if you must; never download tarballs.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
|
|
2
|
+
|
|
3
|
+
# @fonderie/storage — signatures
|
|
4
|
+
|
|
5
|
+
## @fonderie/storage
|
|
6
|
+
|
|
7
|
+
Subpath exports: `@fonderie/storage/s3`, `@fonderie/storage/migrations`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
interface IStorageProvider {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
put(input: {
|
|
13
|
+
bytes: Uint8Array;
|
|
14
|
+
contentType: string;
|
|
15
|
+
}): Promise<IStoredRef>;
|
|
16
|
+
get(ref: string): Promise<IFetched | null>;
|
|
17
|
+
delete(ref: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type IFetched = {
|
|
21
|
+
kind: 'bytes';
|
|
22
|
+
bytes: Uint8Array;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'redirect';
|
|
25
|
+
url: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface IStoredRef {
|
|
29
|
+
ref: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
new DbBlobProvider(store: IStoreAdapter): DbBlobProvider
|
|
33
|
+
.name: "db-blob"
|
|
34
|
+
.put({ bytes }: { bytes: Uint8Array<ArrayBufferLike>; contentType: string; }): Promise<IStoredRef>
|
|
35
|
+
.get(ref: string): Promise<IFetched | null>
|
|
36
|
+
.delete(ref: string): Promise<void>
|
|
37
|
+
|
|
38
|
+
new LocalFsProvider(dir: string): LocalFsProvider
|
|
39
|
+
.name: "local-fs"
|
|
40
|
+
.put({ bytes }: { bytes: Uint8Array<ArrayBufferLike>; contentType: string; }): Promise<IStoredRef>
|
|
41
|
+
.get(ref: string): Promise<IFetched | null>
|
|
42
|
+
.delete(ref: string): Promise<void>
|
|
43
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
DbBlobProvider: () => DbBlobProvider,
|
|
24
|
+
LocalFsProvider: () => LocalFsProvider
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/providers/db-blob.ts
|
|
29
|
+
var DbBlobProvider = class {
|
|
30
|
+
constructor(store) {
|
|
31
|
+
this.store = store;
|
|
32
|
+
}
|
|
33
|
+
store;
|
|
34
|
+
name = "db-blob";
|
|
35
|
+
async put({ bytes }) {
|
|
36
|
+
const rows = await this.store.query(
|
|
37
|
+
"INSERT INTO fonderie_storage_blobs (bytes) VALUES ($1) RETURNING id",
|
|
38
|
+
[Buffer.from(bytes)]
|
|
39
|
+
);
|
|
40
|
+
return { ref: rows[0].id };
|
|
41
|
+
}
|
|
42
|
+
async get(ref) {
|
|
43
|
+
const rows = await this.store.query(
|
|
44
|
+
"SELECT bytes FROM fonderie_storage_blobs WHERE id = $1",
|
|
45
|
+
[ref]
|
|
46
|
+
);
|
|
47
|
+
const row = rows[0];
|
|
48
|
+
return row ? { kind: "bytes", bytes: row.bytes } : null;
|
|
49
|
+
}
|
|
50
|
+
async delete(ref) {
|
|
51
|
+
await this.store.query("DELETE FROM fonderie_storage_blobs WHERE id = $1", [ref]);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/providers/local-fs.ts
|
|
56
|
+
var import_promises = require("fs/promises");
|
|
57
|
+
var import_node_crypto = require("crypto");
|
|
58
|
+
var import_node_path = require("path");
|
|
59
|
+
var LocalFsProvider = class {
|
|
60
|
+
name = "local-fs";
|
|
61
|
+
dir;
|
|
62
|
+
ready = null;
|
|
63
|
+
constructor(dir) {
|
|
64
|
+
this.dir = (0, import_node_path.resolve)(dir);
|
|
65
|
+
}
|
|
66
|
+
ensureDir() {
|
|
67
|
+
if (!this.ready) this.ready = (0, import_promises.mkdir)(this.dir, { recursive: true }).then(() => void 0);
|
|
68
|
+
return this.ready;
|
|
69
|
+
}
|
|
70
|
+
// Reject any ref that isn't a bare id, so a ref can never escape `dir`
|
|
71
|
+
// (path traversal). Ids we mint are UUIDs.
|
|
72
|
+
pathFor(ref) {
|
|
73
|
+
if (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error("invalid storage ref");
|
|
74
|
+
return (0, import_node_path.join)(this.dir, ref);
|
|
75
|
+
}
|
|
76
|
+
async put({ bytes }) {
|
|
77
|
+
await this.ensureDir();
|
|
78
|
+
const ref = (0, import_node_crypto.randomUUID)();
|
|
79
|
+
await (0, import_promises.writeFile)(this.pathFor(ref), bytes);
|
|
80
|
+
return { ref };
|
|
81
|
+
}
|
|
82
|
+
async get(ref) {
|
|
83
|
+
try {
|
|
84
|
+
const bytes = await (0, import_promises.readFile)(this.pathFor(ref));
|
|
85
|
+
return { kind: "bytes", bytes };
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async delete(ref) {
|
|
91
|
+
await (0, import_promises.rm)(this.pathFor(ref), { force: true });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
95
|
+
0 && (module.exports = {
|
|
96
|
+
DbBlobProvider,
|
|
97
|
+
LocalFsProvider
|
|
98
|
+
});
|
|
99
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/providers/db-blob.ts","../src/providers/local-fs.ts"],"sourcesContent":["// @fonderie/storage — content-agnostic, provider-abstracted object storage.\n// The low-dependency foundation other bricks build on (media uploads, DB\n// archives). Store any bytes; swap DbBlob → S3/MinIO with one config line.\n//\n// S3Provider is intentionally NOT exported here — import it from\n// '@fonderie/storage/s3' so the AWS SDK is pulled in only when you use it.\nexport type { IStorageProvider, IFetched, IStoredRef } from './providers/types';\nexport { DbBlobProvider } from './providers/db-blob';\nexport { LocalFsProvider } from './providers/local-fs';\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live in Postgres (`fonderie_storage_blobs`, created\n * by this package's migration). Great for getting started and self-hosting — the\n * whole app is one Node process + one database, and a `pg_dump` captures the\n * objects atomically with the rows that reference them. Swap to `S3Provider`\n * when bandwidth or table size make object storage worth the extra moving part;\n * no consumer code changes, only the provider passed in config.\n */\nexport class DbBlobProvider implements IStorageProvider {\n\treadonly name = 'db-blob';\n\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t'INSERT INTO fonderie_storage_blobs (bytes) VALUES ($1) RETURNING id',\n\t\t\t[Buffer.from(bytes)],\n\t\t);\n\t\treturn { ref: rows[0]!.id };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\tconst rows = await this.store.query<{ bytes: Buffer }>(\n\t\t\t'SELECT bytes FROM fonderie_storage_blobs WHERE id = $1',\n\t\t\t[ref],\n\t\t);\n\t\tconst row = rows[0];\n\t\treturn row ? { kind: 'bytes', bytes: row.bytes } : null;\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_storage_blobs WHERE id = $1', [ref]);\n\t}\n}\n","import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport { randomUUID } from 'node:crypto';\nimport { join, resolve } from 'node:path';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful\n * for a single-box deployment that wants objects off the database without\n * standing up object storage. The `ref` is an opaque filename; content type is\n * the consumer's concern, so nothing about the bytes-on-disk needs to encode it.\n *\n * Serves inline through the app like `DbBlobProvider` (no CDN in front), so at\n * real scale prefer `S3Provider` — same interface, one config line.\n */\nexport class LocalFsProvider implements IStorageProvider {\n\treadonly name = 'local-fs';\n\tprivate readonly dir: string;\n\tprivate ready: Promise<void> | null = null;\n\n\tconstructor(dir: string) {\n\t\tthis.dir = resolve(dir);\n\t}\n\n\tprivate ensureDir(): Promise<void> {\n\t\tif (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => undefined);\n\t\treturn this.ready;\n\t}\n\n\t// Reject any ref that isn't a bare id, so a ref can never escape `dir`\n\t// (path traversal). Ids we mint are UUIDs.\n\tprivate pathFor(ref: string): string {\n\t\tif (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error('invalid storage ref');\n\t\treturn join(this.dir, ref);\n\t}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tawait this.ensureDir();\n\t\tconst ref = randomUUID();\n\t\tawait writeFile(this.pathFor(ref), bytes);\n\t\treturn { ref };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\ttry {\n\t\t\tconst bytes = await readFile(this.pathFor(ref));\n\t\t\treturn { kind: 'bytes', bytes };\n\t\t} catch {\n\t\t\treturn null; // ENOENT (or an invalid ref) → treated as not found\n\t\t}\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait rm(this.pathFor(ref), { force: true }); // force → no throw when already gone\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACYO,IAAM,iBAAN,MAAiD;AAAA,EAGvD,YAA6B,OAAsB;AAAtB;AAAA,EAAuB;AAAA,EAAvB;AAAA,EAFpB,OAAO;AAAA,EAIhB,MAAM,IAAI,EAAE,MAAM,GAAoE;AACrF,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,OAAO,KAAK,KAAK,CAAC;AAAA,IACpB;AACA,WAAO,EAAE,KAAK,KAAK,CAAC,EAAG,GAAG;AAAA,EAC3B;AAAA,EAEA,MAAM,IAAI,KAAuC;AAChD,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,GAAG;AAAA,IACL;AACA,UAAM,MAAM,KAAK,CAAC;AAClB,WAAO,MAAM,EAAE,MAAM,SAAS,OAAO,IAAI,MAAM,IAAI;AAAA,EACpD;AAAA,EAEA,MAAM,OAAO,KAA4B;AACxC,UAAM,KAAK,MAAM,MAAM,oDAAoD,CAAC,GAAG,CAAC;AAAA,EACjF;AACD;;;ACrCA,sBAA+C;AAC/C,yBAA2B;AAC3B,uBAA8B;AAavB,IAAM,kBAAN,MAAkD;AAAA,EAC/C,OAAO;AAAA,EACC;AAAA,EACT,QAA8B;AAAA,EAEtC,YAAY,KAAa;AACxB,SAAK,UAAM,0BAAQ,GAAG;AAAA,EACvB;AAAA,EAEQ,YAA2B;AAClC,QAAI,CAAC,KAAK,MAAO,MAAK,YAAQ,uBAAM,KAAK,KAAK,EAAE,WAAW,KAAK,CAAC,EAAE,KAAK,MAAM,MAAS;AACvF,WAAO,KAAK;AAAA,EACb;AAAA;AAAA;AAAA,EAIQ,QAAQ,KAAqB;AACpC,QAAI,CAAC,mBAAmB,KAAK,GAAG,EAAG,OAAM,IAAI,MAAM,qBAAqB;AACxE,eAAO,uBAAK,KAAK,KAAK,GAAG;AAAA,EAC1B;AAAA,EAEA,MAAM,IAAI,EAAE,MAAM,GAAoE;AACrF,UAAM,KAAK,UAAU;AACrB,UAAM,UAAM,+BAAW;AACvB,cAAM,2BAAU,KAAK,QAAQ,GAAG,GAAG,KAAK;AACxC,WAAO,EAAE,IAAI;AAAA,EACd;AAAA,EAEA,MAAM,IAAI,KAAuC;AAChD,QAAI;AACH,YAAM,QAAQ,UAAM,0BAAS,KAAK,QAAQ,GAAG,CAAC;AAC9C,aAAO,EAAE,MAAM,SAAS,MAAM;AAAA,IAC/B,QAAQ;AACP,aAAO;AAAA,IACR;AAAA,EACD;AAAA,EAEA,MAAM,OAAO,KAA4B;AACxC,cAAM,oBAAG,KAAK,QAAQ,GAAG,GAAG,EAAE,OAAO,KAAK,CAAC;AAAA,EAC5C;AACD;","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.cjs';
|
|
2
|
+
import { IStoreAdapter } from '@fonderie/store';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Zero-infra provider: bytes live in Postgres (`fonderie_storage_blobs`, created
|
|
6
|
+
* by this package's migration). Great for getting started and self-hosting — the
|
|
7
|
+
* whole app is one Node process + one database, and a `pg_dump` captures the
|
|
8
|
+
* objects atomically with the rows that reference them. Swap to `S3Provider`
|
|
9
|
+
* when bandwidth or table size make object storage worth the extra moving part;
|
|
10
|
+
* no consumer code changes, only the provider passed in config.
|
|
11
|
+
*/
|
|
12
|
+
declare class DbBlobProvider implements IStorageProvider {
|
|
13
|
+
private readonly store;
|
|
14
|
+
readonly name = "db-blob";
|
|
15
|
+
constructor(store: IStoreAdapter);
|
|
16
|
+
put({ bytes }: {
|
|
17
|
+
bytes: Uint8Array;
|
|
18
|
+
contentType: string;
|
|
19
|
+
}): Promise<IStoredRef>;
|
|
20
|
+
get(ref: string): Promise<IFetched | null>;
|
|
21
|
+
delete(ref: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful
|
|
26
|
+
* for a single-box deployment that wants objects off the database without
|
|
27
|
+
* standing up object storage. The `ref` is an opaque filename; content type is
|
|
28
|
+
* the consumer's concern, so nothing about the bytes-on-disk needs to encode it.
|
|
29
|
+
*
|
|
30
|
+
* Serves inline through the app like `DbBlobProvider` (no CDN in front), so at
|
|
31
|
+
* real scale prefer `S3Provider` — same interface, one config line.
|
|
32
|
+
*/
|
|
33
|
+
declare class LocalFsProvider implements IStorageProvider {
|
|
34
|
+
readonly name = "local-fs";
|
|
35
|
+
private readonly dir;
|
|
36
|
+
private ready;
|
|
37
|
+
constructor(dir: string);
|
|
38
|
+
private ensureDir;
|
|
39
|
+
private pathFor;
|
|
40
|
+
put({ bytes }: {
|
|
41
|
+
bytes: Uint8Array;
|
|
42
|
+
contentType: string;
|
|
43
|
+
}): Promise<IStoredRef>;
|
|
44
|
+
get(ref: string): Promise<IFetched | null>;
|
|
45
|
+
delete(ref: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { DbBlobProvider, IFetched, IStorageProvider, IStoredRef, LocalFsProvider };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.js';
|
|
2
|
+
import { IStoreAdapter } from '@fonderie/store';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Zero-infra provider: bytes live in Postgres (`fonderie_storage_blobs`, created
|
|
6
|
+
* by this package's migration). Great for getting started and self-hosting — the
|
|
7
|
+
* whole app is one Node process + one database, and a `pg_dump` captures the
|
|
8
|
+
* objects atomically with the rows that reference them. Swap to `S3Provider`
|
|
9
|
+
* when bandwidth or table size make object storage worth the extra moving part;
|
|
10
|
+
* no consumer code changes, only the provider passed in config.
|
|
11
|
+
*/
|
|
12
|
+
declare class DbBlobProvider implements IStorageProvider {
|
|
13
|
+
private readonly store;
|
|
14
|
+
readonly name = "db-blob";
|
|
15
|
+
constructor(store: IStoreAdapter);
|
|
16
|
+
put({ bytes }: {
|
|
17
|
+
bytes: Uint8Array;
|
|
18
|
+
contentType: string;
|
|
19
|
+
}): Promise<IStoredRef>;
|
|
20
|
+
get(ref: string): Promise<IFetched | null>;
|
|
21
|
+
delete(ref: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful
|
|
26
|
+
* for a single-box deployment that wants objects off the database without
|
|
27
|
+
* standing up object storage. The `ref` is an opaque filename; content type is
|
|
28
|
+
* the consumer's concern, so nothing about the bytes-on-disk needs to encode it.
|
|
29
|
+
*
|
|
30
|
+
* Serves inline through the app like `DbBlobProvider` (no CDN in front), so at
|
|
31
|
+
* real scale prefer `S3Provider` — same interface, one config line.
|
|
32
|
+
*/
|
|
33
|
+
declare class LocalFsProvider implements IStorageProvider {
|
|
34
|
+
readonly name = "local-fs";
|
|
35
|
+
private readonly dir;
|
|
36
|
+
private ready;
|
|
37
|
+
constructor(dir: string);
|
|
38
|
+
private ensureDir;
|
|
39
|
+
private pathFor;
|
|
40
|
+
put({ bytes }: {
|
|
41
|
+
bytes: Uint8Array;
|
|
42
|
+
contentType: string;
|
|
43
|
+
}): Promise<IStoredRef>;
|
|
44
|
+
get(ref: string): Promise<IFetched | null>;
|
|
45
|
+
delete(ref: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { DbBlobProvider, IFetched, IStorageProvider, IStoredRef, LocalFsProvider };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/providers/db-blob.ts
|
|
2
|
+
var DbBlobProvider = class {
|
|
3
|
+
constructor(store) {
|
|
4
|
+
this.store = store;
|
|
5
|
+
}
|
|
6
|
+
store;
|
|
7
|
+
name = "db-blob";
|
|
8
|
+
async put({ bytes }) {
|
|
9
|
+
const rows = await this.store.query(
|
|
10
|
+
"INSERT INTO fonderie_storage_blobs (bytes) VALUES ($1) RETURNING id",
|
|
11
|
+
[Buffer.from(bytes)]
|
|
12
|
+
);
|
|
13
|
+
return { ref: rows[0].id };
|
|
14
|
+
}
|
|
15
|
+
async get(ref) {
|
|
16
|
+
const rows = await this.store.query(
|
|
17
|
+
"SELECT bytes FROM fonderie_storage_blobs WHERE id = $1",
|
|
18
|
+
[ref]
|
|
19
|
+
);
|
|
20
|
+
const row = rows[0];
|
|
21
|
+
return row ? { kind: "bytes", bytes: row.bytes } : null;
|
|
22
|
+
}
|
|
23
|
+
async delete(ref) {
|
|
24
|
+
await this.store.query("DELETE FROM fonderie_storage_blobs WHERE id = $1", [ref]);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/providers/local-fs.ts
|
|
29
|
+
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
30
|
+
import { randomUUID } from "crypto";
|
|
31
|
+
import { join, resolve } from "path";
|
|
32
|
+
var LocalFsProvider = class {
|
|
33
|
+
name = "local-fs";
|
|
34
|
+
dir;
|
|
35
|
+
ready = null;
|
|
36
|
+
constructor(dir) {
|
|
37
|
+
this.dir = resolve(dir);
|
|
38
|
+
}
|
|
39
|
+
ensureDir() {
|
|
40
|
+
if (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => void 0);
|
|
41
|
+
return this.ready;
|
|
42
|
+
}
|
|
43
|
+
// Reject any ref that isn't a bare id, so a ref can never escape `dir`
|
|
44
|
+
// (path traversal). Ids we mint are UUIDs.
|
|
45
|
+
pathFor(ref) {
|
|
46
|
+
if (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error("invalid storage ref");
|
|
47
|
+
return join(this.dir, ref);
|
|
48
|
+
}
|
|
49
|
+
async put({ bytes }) {
|
|
50
|
+
await this.ensureDir();
|
|
51
|
+
const ref = randomUUID();
|
|
52
|
+
await writeFile(this.pathFor(ref), bytes);
|
|
53
|
+
return { ref };
|
|
54
|
+
}
|
|
55
|
+
async get(ref) {
|
|
56
|
+
try {
|
|
57
|
+
const bytes = await readFile(this.pathFor(ref));
|
|
58
|
+
return { kind: "bytes", bytes };
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async delete(ref) {
|
|
64
|
+
await rm(this.pathFor(ref), { force: true });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
export {
|
|
68
|
+
DbBlobProvider,
|
|
69
|
+
LocalFsProvider
|
|
70
|
+
};
|
|
71
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/providers/db-blob.ts","../src/providers/local-fs.ts"],"sourcesContent":["import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live in Postgres (`fonderie_storage_blobs`, created\n * by this package's migration). Great for getting started and self-hosting — the\n * whole app is one Node process + one database, and a `pg_dump` captures the\n * objects atomically with the rows that reference them. Swap to `S3Provider`\n * when bandwidth or table size make object storage worth the extra moving part;\n * no consumer code changes, only the provider passed in config.\n */\nexport class DbBlobProvider implements IStorageProvider {\n\treadonly name = 'db-blob';\n\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t'INSERT INTO fonderie_storage_blobs (bytes) VALUES ($1) RETURNING id',\n\t\t\t[Buffer.from(bytes)],\n\t\t);\n\t\treturn { ref: rows[0]!.id };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\tconst rows = await this.store.query<{ bytes: Buffer }>(\n\t\t\t'SELECT bytes FROM fonderie_storage_blobs WHERE id = $1',\n\t\t\t[ref],\n\t\t);\n\t\tconst row = rows[0];\n\t\treturn row ? { kind: 'bytes', bytes: row.bytes } : null;\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_storage_blobs WHERE id = $1', [ref]);\n\t}\n}\n","import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport { randomUUID } from 'node:crypto';\nimport { join, resolve } from 'node:path';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful\n * for a single-box deployment that wants objects off the database without\n * standing up object storage. The `ref` is an opaque filename; content type is\n * the consumer's concern, so nothing about the bytes-on-disk needs to encode it.\n *\n * Serves inline through the app like `DbBlobProvider` (no CDN in front), so at\n * real scale prefer `S3Provider` — same interface, one config line.\n */\nexport class LocalFsProvider implements IStorageProvider {\n\treadonly name = 'local-fs';\n\tprivate readonly dir: string;\n\tprivate ready: Promise<void> | null = null;\n\n\tconstructor(dir: string) {\n\t\tthis.dir = resolve(dir);\n\t}\n\n\tprivate ensureDir(): Promise<void> {\n\t\tif (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => undefined);\n\t\treturn this.ready;\n\t}\n\n\t// Reject any ref that isn't a bare id, so a ref can never escape `dir`\n\t// (path traversal). Ids we mint are UUIDs.\n\tprivate pathFor(ref: string): string {\n\t\tif (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error('invalid storage ref');\n\t\treturn join(this.dir, ref);\n\t}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tawait this.ensureDir();\n\t\tconst ref = randomUUID();\n\t\tawait writeFile(this.pathFor(ref), bytes);\n\t\treturn { ref };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\ttry {\n\t\t\tconst bytes = await readFile(this.pathFor(ref));\n\t\t\treturn { kind: 'bytes', bytes };\n\t\t} catch {\n\t\t\treturn null; // ENOENT (or an invalid ref) → treated as not found\n\t\t}\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait rm(this.pathFor(ref), { force: true }); // force → no throw when already gone\n\t}\n}\n"],"mappings":";AAYO,IAAM,iBAAN,MAAiD;AAAA,EAGvD,YAA6B,OAAsB;AAAtB;AAAA,EAAuB;AAAA,EAAvB;AAAA,EAFpB,OAAO;AAAA,EAIhB,MAAM,IAAI,EAAE,MAAM,GAAoE;AACrF,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,OAAO,KAAK,KAAK,CAAC;AAAA,IACpB;AACA,WAAO,EAAE,KAAK,KAAK,CAAC,EAAG,GAAG;AAAA,EAC3B;AAAA,EAEA,MAAM,IAAI,KAAuC;AAChD,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,GAAG;AAAA,IACL;AACA,UAAM,MAAM,KAAK,CAAC;AAClB,WAAO,MAAM,EAAE,MAAM,SAAS,OAAO,IAAI,MAAM,IAAI;AAAA,EACpD;AAAA,EAEA,MAAM,OAAO,KAA4B;AACxC,UAAM,KAAK,MAAM,MAAM,oDAAoD,CAAC,GAAG,CAAC;AAAA,EACjF;AACD;;;ACrCA,SAAS,OAAO,UAAU,IAAI,iBAAiB;AAC/C,SAAS,kBAAkB;AAC3B,SAAS,MAAM,eAAe;AAavB,IAAM,kBAAN,MAAkD;AAAA,EAC/C,OAAO;AAAA,EACC;AAAA,EACT,QAA8B;AAAA,EAEtC,YAAY,KAAa;AACxB,SAAK,MAAM,QAAQ,GAAG;AAAA,EACvB;AAAA,EAEQ,YAA2B;AAClC,QAAI,CAAC,KAAK,MAAO,MAAK,QAAQ,MAAM,KAAK,KAAK,EAAE,WAAW,KAAK,CAAC,EAAE,KAAK,MAAM,MAAS;AACvF,WAAO,KAAK;AAAA,EACb;AAAA;AAAA;AAAA,EAIQ,QAAQ,KAAqB;AACpC,QAAI,CAAC,mBAAmB,KAAK,GAAG,EAAG,OAAM,IAAI,MAAM,qBAAqB;AACxE,WAAO,KAAK,KAAK,KAAK,GAAG;AAAA,EAC1B;AAAA,EAEA,MAAM,IAAI,EAAE,MAAM,GAAoE;AACrF,UAAM,KAAK,UAAU;AACrB,UAAM,MAAM,WAAW;AACvB,UAAM,UAAU,KAAK,QAAQ,GAAG,GAAG,KAAK;AACxC,WAAO,EAAE,IAAI;AAAA,EACd;AAAA,EAEA,MAAM,IAAI,KAAuC;AAChD,QAAI;AACH,YAAM,QAAQ,MAAM,SAAS,KAAK,QAAQ,GAAG,CAAC;AAC9C,aAAO,EAAE,MAAM,SAAS,MAAM;AAAA,IAC/B,QAAQ;AACP,aAAO;AAAA,IACR;AAAA,EACD;AAAA,EAEA,MAAM,OAAO,KAA4B;AACxC,UAAM,GAAG,KAAK,QAAQ,GAAG,GAAG,EAAE,OAAO,KAAK,CAAC;AAAA,EAC5C;AACD;","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/migrations/index.ts"],"sourcesContent":["import { createMigrationsPath } from '@fonderie/store';\n\nexport const getMigrationsPath = (): string => createMigrationsPath(import.meta.url);\n"],"mappings":";AAAA,SAAS,4BAA4B;AAE9B,IAAM,oBAAoB,MAAc,qBAAqB,YAAY,GAAG;","names":[]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- ----------------------------------------------------------------------------
|
|
2
|
+
-- 001_storage
|
|
3
|
+
-- ----------------------------------------------------------------------------
|
|
4
|
+
-- Byte storage for the built-in DbBlobProvider (zero-infra object storage in
|
|
5
|
+
-- Postgres). `bytes` holds an arbitrary object; the id is the opaque `ref` the
|
|
6
|
+
-- provider hands back and consumers persist. Untouched when an external
|
|
7
|
+
-- provider (local-fs, S3/MinIO) is wired — the ref then points into that
|
|
8
|
+
-- backend instead.
|
|
9
|
+
--
|
|
10
|
+
-- Deliberately content-agnostic: no content_type, no owner, no purpose. What an
|
|
11
|
+
-- object *is* and who owns it is the consumer's concern (e.g. @fonderie/media
|
|
12
|
+
-- keeps that in its own fonderie_media_assets table).
|
|
13
|
+
-- ----------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS fonderie_storage_blobs (
|
|
16
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
17
|
+
bytes BYTEA NOT NULL,
|
|
18
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
19
|
+
);
|
package/dist/s3.cjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/s3.ts
|
|
21
|
+
var s3_exports = {};
|
|
22
|
+
__export(s3_exports, {
|
|
23
|
+
S3Provider: () => S3Provider
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(s3_exports);
|
|
26
|
+
|
|
27
|
+
// src/providers/s3.ts
|
|
28
|
+
var import_node_crypto = require("crypto");
|
|
29
|
+
var import_client_s3 = require("@aws-sdk/client-s3");
|
|
30
|
+
var import_s3_request_presigner = require("@aws-sdk/s3-request-presigner");
|
|
31
|
+
var S3Provider = class {
|
|
32
|
+
name = "s3";
|
|
33
|
+
client;
|
|
34
|
+
bucket;
|
|
35
|
+
ttl;
|
|
36
|
+
prefix;
|
|
37
|
+
constructor(opts) {
|
|
38
|
+
this.bucket = opts.bucket;
|
|
39
|
+
this.ttl = opts.presignTtlSeconds ?? 300;
|
|
40
|
+
this.prefix = opts.keyPrefix ?? "";
|
|
41
|
+
this.client = new import_client_s3.S3Client({
|
|
42
|
+
region: opts.region ?? "us-east-1",
|
|
43
|
+
...opts.endpoint ? { endpoint: opts.endpoint, forcePathStyle: opts.forcePathStyle ?? true } : {},
|
|
44
|
+
...opts.accessKeyId && opts.secretAccessKey ? { credentials: { accessKeyId: opts.accessKeyId, secretAccessKey: opts.secretAccessKey } } : {}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async put({ bytes, contentType }) {
|
|
48
|
+
const ref = `${this.prefix}${(0, import_node_crypto.randomUUID)()}`;
|
|
49
|
+
await this.client.send(
|
|
50
|
+
new import_client_s3.PutObjectCommand({ Bucket: this.bucket, Key: ref, Body: bytes, ContentType: contentType })
|
|
51
|
+
);
|
|
52
|
+
return { ref };
|
|
53
|
+
}
|
|
54
|
+
async get(ref) {
|
|
55
|
+
const url = await (0, import_s3_request_presigner.getSignedUrl)(
|
|
56
|
+
this.client,
|
|
57
|
+
new import_client_s3.GetObjectCommand({ Bucket: this.bucket, Key: ref }),
|
|
58
|
+
{ expiresIn: this.ttl }
|
|
59
|
+
);
|
|
60
|
+
return { kind: "redirect", url };
|
|
61
|
+
}
|
|
62
|
+
async delete(ref) {
|
|
63
|
+
await this.client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.bucket, Key: ref }));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
67
|
+
0 && (module.exports = {
|
|
68
|
+
S3Provider
|
|
69
|
+
});
|
|
70
|
+
//# sourceMappingURL=s3.cjs.map
|
package/dist/s3.cjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/s3.ts","../src/providers/s3.ts"],"sourcesContent":["// Opt-in S3-compatible provider (AWS S3 / MinIO / R2 / B2 / Spaces). Imported\n// separately from the main entry so the AWS SDK peer is only loaded when used:\n// import { S3Provider } from '@fonderie/storage/s3'\nexport { S3Provider } from './providers/s3';\nexport type { IS3ProviderOptions } from './providers/s3';\n","import { randomUUID } from 'node:crypto';\n\nimport {\n\tDeleteObjectCommand,\n\tGetObjectCommand,\n\tPutObjectCommand,\n\tS3Client,\n} from '@aws-sdk/client-s3';\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\nexport interface IS3ProviderOptions {\n\t/** Target bucket. Must already exist. */\n\tbucket: string;\n\tregion?: string;\n\t/**\n\t * Custom endpoint for S3-compatible services. Set this for MinIO / R2 / B2 /\n\t * Spaces (e.g. `http://localhost:9000`); omit for AWS S3. When set,\n\t * path-style addressing is enabled by default (what MinIO expects).\n\t */\n\tendpoint?: string;\n\tforcePathStyle?: boolean;\n\taccessKeyId?: string;\n\tsecretAccessKey?: string;\n\t/** How long a presigned GET URL stays valid, in seconds. Default 300. */\n\tpresignTtlSeconds?: number;\n\t/** Optional key prefix, e.g. 'avatars/' or 'archive/'. */\n\tkeyPrefix?: string;\n}\n\n/**\n * Object-storage provider for any S3-compatible service — AWS S3, **MinIO**,\n * Cloudflare R2, Backblaze B2, DigitalOcean Spaces. They're one provider\n * parameterized by `endpoint`; MinIO is just `endpoint: 'http://minio:9000'`.\n *\n * `get` returns a **presigned redirect URL** rather than bytes, so the app\n * hands the client straight to the object store / CDN and never proxies the\n * payload — the point of moving off DB blobs at scale.\n *\n * Requires the `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` optional\n * peers; import from `@fonderie/storage/s3` only when you actually use it, so\n * DbBlob / LocalFs consumers never pull the AWS SDK.\n */\nexport class S3Provider implements IStorageProvider {\n\treadonly name = 's3';\n\tprivate readonly client: S3Client;\n\tprivate readonly bucket: string;\n\tprivate readonly ttl: number;\n\tprivate readonly prefix: string;\n\n\tconstructor(opts: IS3ProviderOptions) {\n\t\tthis.bucket = opts.bucket;\n\t\tthis.ttl = opts.presignTtlSeconds ?? 300;\n\t\tthis.prefix = opts.keyPrefix ?? '';\n\t\tthis.client = new S3Client({\n\t\t\tregion: opts.region ?? 'us-east-1',\n\t\t\t...(opts.endpoint\n\t\t\t\t? { endpoint: opts.endpoint, forcePathStyle: opts.forcePathStyle ?? true }\n\t\t\t\t: {}),\n\t\t\t...(opts.accessKeyId && opts.secretAccessKey\n\t\t\t\t? { credentials: { accessKeyId: opts.accessKeyId, secretAccessKey: opts.secretAccessKey } }\n\t\t\t\t: {}),\n\t\t});\n\t}\n\n\tasync put({ bytes, contentType }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tconst ref = `${this.prefix}${randomUUID()}`;\n\t\tawait this.client.send(\n\t\t\tnew PutObjectCommand({ Bucket: this.bucket, Key: ref, Body: bytes, ContentType: contentType }),\n\t\t);\n\t\treturn { ref };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\t// A presigned URL doesn't verify existence — a deleted key simply 404s at\n\t\t// the store when the client follows the redirect. Callers that hold their\n\t\t// own metadata row (e.g. media) have already confirmed the asset exists.\n\t\tconst url = await getSignedUrl(\n\t\t\tthis.client,\n\t\t\tnew GetObjectCommand({ Bucket: this.bucket, Key: ref }),\n\t\t\t{ expiresIn: this.ttl },\n\t\t);\n\t\treturn { kind: 'redirect', url };\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: ref }));\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,yBAA2B;AAE3B,uBAKO;AACP,kCAA6B;AAoCtB,IAAM,aAAN,MAA6C;AAAA,EAC1C,OAAO;AAAA,EACC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAA0B;AACrC,SAAK,SAAS,KAAK;AACnB,SAAK,MAAM,KAAK,qBAAqB;AACrC,SAAK,SAAS,KAAK,aAAa;AAChC,SAAK,SAAS,IAAI,0BAAS;AAAA,MAC1B,QAAQ,KAAK,UAAU;AAAA,MACvB,GAAI,KAAK,WACN,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,kBAAkB,KAAK,IACvE,CAAC;AAAA,MACJ,GAAI,KAAK,eAAe,KAAK,kBAC1B,EAAE,aAAa,EAAE,aAAa,KAAK,aAAa,iBAAiB,KAAK,gBAAgB,EAAE,IACxF,CAAC;AAAA,IACL,CAAC;AAAA,EACF;AAAA,EAEA,MAAM,IAAI,EAAE,OAAO,YAAY,GAAoE;AAClG,UAAM,MAAM,GAAG,KAAK,MAAM,OAAG,+BAAW,CAAC;AACzC,UAAM,KAAK,OAAO;AAAA,MACjB,IAAI,kCAAiB,EAAE,QAAQ,KAAK,QAAQ,KAAK,KAAK,MAAM,OAAO,aAAa,YAAY,CAAC;AAAA,IAC9F;AACA,WAAO,EAAE,IAAI;AAAA,EACd;AAAA,EAEA,MAAM,IAAI,KAAuC;AAIhD,UAAM,MAAM,UAAM;AAAA,MACjB,KAAK;AAAA,MACL,IAAI,kCAAiB,EAAE,QAAQ,KAAK,QAAQ,KAAK,IAAI,CAAC;AAAA,MACtD,EAAE,WAAW,KAAK,IAAI;AAAA,IACvB;AACA,WAAO,EAAE,MAAM,YAAY,IAAI;AAAA,EAChC;AAAA,EAEA,MAAM,OAAO,KAA4B;AACxC,UAAM,KAAK,OAAO,KAAK,IAAI,qCAAoB,EAAE,QAAQ,KAAK,QAAQ,KAAK,IAAI,CAAC,CAAC;AAAA,EAClF;AACD;","names":[]}
|
package/dist/s3.d.cts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.cjs';
|
|
2
|
+
|
|
3
|
+
interface IS3ProviderOptions {
|
|
4
|
+
/** Target bucket. Must already exist. */
|
|
5
|
+
bucket: string;
|
|
6
|
+
region?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Custom endpoint for S3-compatible services. Set this for MinIO / R2 / B2 /
|
|
9
|
+
* Spaces (e.g. `http://localhost:9000`); omit for AWS S3. When set,
|
|
10
|
+
* path-style addressing is enabled by default (what MinIO expects).
|
|
11
|
+
*/
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
forcePathStyle?: boolean;
|
|
14
|
+
accessKeyId?: string;
|
|
15
|
+
secretAccessKey?: string;
|
|
16
|
+
/** How long a presigned GET URL stays valid, in seconds. Default 300. */
|
|
17
|
+
presignTtlSeconds?: number;
|
|
18
|
+
/** Optional key prefix, e.g. 'avatars/' or 'archive/'. */
|
|
19
|
+
keyPrefix?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Object-storage provider for any S3-compatible service — AWS S3, **MinIO**,
|
|
23
|
+
* Cloudflare R2, Backblaze B2, DigitalOcean Spaces. They're one provider
|
|
24
|
+
* parameterized by `endpoint`; MinIO is just `endpoint: 'http://minio:9000'`.
|
|
25
|
+
*
|
|
26
|
+
* `get` returns a **presigned redirect URL** rather than bytes, so the app
|
|
27
|
+
* hands the client straight to the object store / CDN and never proxies the
|
|
28
|
+
* payload — the point of moving off DB blobs at scale.
|
|
29
|
+
*
|
|
30
|
+
* Requires the `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` optional
|
|
31
|
+
* peers; import from `@fonderie/storage/s3` only when you actually use it, so
|
|
32
|
+
* DbBlob / LocalFs consumers never pull the AWS SDK.
|
|
33
|
+
*/
|
|
34
|
+
declare class S3Provider implements IStorageProvider {
|
|
35
|
+
readonly name = "s3";
|
|
36
|
+
private readonly client;
|
|
37
|
+
private readonly bucket;
|
|
38
|
+
private readonly ttl;
|
|
39
|
+
private readonly prefix;
|
|
40
|
+
constructor(opts: IS3ProviderOptions);
|
|
41
|
+
put({ bytes, contentType }: {
|
|
42
|
+
bytes: Uint8Array;
|
|
43
|
+
contentType: string;
|
|
44
|
+
}): Promise<IStoredRef>;
|
|
45
|
+
get(ref: string): Promise<IFetched | null>;
|
|
46
|
+
delete(ref: string): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { type IS3ProviderOptions, S3Provider };
|
package/dist/s3.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.js';
|
|
2
|
+
|
|
3
|
+
interface IS3ProviderOptions {
|
|
4
|
+
/** Target bucket. Must already exist. */
|
|
5
|
+
bucket: string;
|
|
6
|
+
region?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Custom endpoint for S3-compatible services. Set this for MinIO / R2 / B2 /
|
|
9
|
+
* Spaces (e.g. `http://localhost:9000`); omit for AWS S3. When set,
|
|
10
|
+
* path-style addressing is enabled by default (what MinIO expects).
|
|
11
|
+
*/
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
forcePathStyle?: boolean;
|
|
14
|
+
accessKeyId?: string;
|
|
15
|
+
secretAccessKey?: string;
|
|
16
|
+
/** How long a presigned GET URL stays valid, in seconds. Default 300. */
|
|
17
|
+
presignTtlSeconds?: number;
|
|
18
|
+
/** Optional key prefix, e.g. 'avatars/' or 'archive/'. */
|
|
19
|
+
keyPrefix?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Object-storage provider for any S3-compatible service — AWS S3, **MinIO**,
|
|
23
|
+
* Cloudflare R2, Backblaze B2, DigitalOcean Spaces. They're one provider
|
|
24
|
+
* parameterized by `endpoint`; MinIO is just `endpoint: 'http://minio:9000'`.
|
|
25
|
+
*
|
|
26
|
+
* `get` returns a **presigned redirect URL** rather than bytes, so the app
|
|
27
|
+
* hands the client straight to the object store / CDN and never proxies the
|
|
28
|
+
* payload — the point of moving off DB blobs at scale.
|
|
29
|
+
*
|
|
30
|
+
* Requires the `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` optional
|
|
31
|
+
* peers; import from `@fonderie/storage/s3` only when you actually use it, so
|
|
32
|
+
* DbBlob / LocalFs consumers never pull the AWS SDK.
|
|
33
|
+
*/
|
|
34
|
+
declare class S3Provider implements IStorageProvider {
|
|
35
|
+
readonly name = "s3";
|
|
36
|
+
private readonly client;
|
|
37
|
+
private readonly bucket;
|
|
38
|
+
private readonly ttl;
|
|
39
|
+
private readonly prefix;
|
|
40
|
+
constructor(opts: IS3ProviderOptions);
|
|
41
|
+
put({ bytes, contentType }: {
|
|
42
|
+
bytes: Uint8Array;
|
|
43
|
+
contentType: string;
|
|
44
|
+
}): Promise<IStoredRef>;
|
|
45
|
+
get(ref: string): Promise<IFetched | null>;
|
|
46
|
+
delete(ref: string): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { type IS3ProviderOptions, S3Provider };
|
package/dist/s3.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/providers/s3.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import {
|
|
4
|
+
DeleteObjectCommand,
|
|
5
|
+
GetObjectCommand,
|
|
6
|
+
PutObjectCommand,
|
|
7
|
+
S3Client
|
|
8
|
+
} from "@aws-sdk/client-s3";
|
|
9
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
10
|
+
var S3Provider = class {
|
|
11
|
+
name = "s3";
|
|
12
|
+
client;
|
|
13
|
+
bucket;
|
|
14
|
+
ttl;
|
|
15
|
+
prefix;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.bucket = opts.bucket;
|
|
18
|
+
this.ttl = opts.presignTtlSeconds ?? 300;
|
|
19
|
+
this.prefix = opts.keyPrefix ?? "";
|
|
20
|
+
this.client = new S3Client({
|
|
21
|
+
region: opts.region ?? "us-east-1",
|
|
22
|
+
...opts.endpoint ? { endpoint: opts.endpoint, forcePathStyle: opts.forcePathStyle ?? true } : {},
|
|
23
|
+
...opts.accessKeyId && opts.secretAccessKey ? { credentials: { accessKeyId: opts.accessKeyId, secretAccessKey: opts.secretAccessKey } } : {}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async put({ bytes, contentType }) {
|
|
27
|
+
const ref = `${this.prefix}${randomUUID()}`;
|
|
28
|
+
await this.client.send(
|
|
29
|
+
new PutObjectCommand({ Bucket: this.bucket, Key: ref, Body: bytes, ContentType: contentType })
|
|
30
|
+
);
|
|
31
|
+
return { ref };
|
|
32
|
+
}
|
|
33
|
+
async get(ref) {
|
|
34
|
+
const url = await getSignedUrl(
|
|
35
|
+
this.client,
|
|
36
|
+
new GetObjectCommand({ Bucket: this.bucket, Key: ref }),
|
|
37
|
+
{ expiresIn: this.ttl }
|
|
38
|
+
);
|
|
39
|
+
return { kind: "redirect", url };
|
|
40
|
+
}
|
|
41
|
+
async delete(ref) {
|
|
42
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: ref }));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
export {
|
|
46
|
+
S3Provider
|
|
47
|
+
};
|
|
48
|
+
//# sourceMappingURL=s3.js.map
|
package/dist/s3.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/providers/s3.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport {\n\tDeleteObjectCommand,\n\tGetObjectCommand,\n\tPutObjectCommand,\n\tS3Client,\n} from '@aws-sdk/client-s3';\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\nexport interface IS3ProviderOptions {\n\t/** Target bucket. Must already exist. */\n\tbucket: string;\n\tregion?: string;\n\t/**\n\t * Custom endpoint for S3-compatible services. Set this for MinIO / R2 / B2 /\n\t * Spaces (e.g. `http://localhost:9000`); omit for AWS S3. When set,\n\t * path-style addressing is enabled by default (what MinIO expects).\n\t */\n\tendpoint?: string;\n\tforcePathStyle?: boolean;\n\taccessKeyId?: string;\n\tsecretAccessKey?: string;\n\t/** How long a presigned GET URL stays valid, in seconds. Default 300. */\n\tpresignTtlSeconds?: number;\n\t/** Optional key prefix, e.g. 'avatars/' or 'archive/'. */\n\tkeyPrefix?: string;\n}\n\n/**\n * Object-storage provider for any S3-compatible service — AWS S3, **MinIO**,\n * Cloudflare R2, Backblaze B2, DigitalOcean Spaces. They're one provider\n * parameterized by `endpoint`; MinIO is just `endpoint: 'http://minio:9000'`.\n *\n * `get` returns a **presigned redirect URL** rather than bytes, so the app\n * hands the client straight to the object store / CDN and never proxies the\n * payload — the point of moving off DB blobs at scale.\n *\n * Requires the `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` optional\n * peers; import from `@fonderie/storage/s3` only when you actually use it, so\n * DbBlob / LocalFs consumers never pull the AWS SDK.\n */\nexport class S3Provider implements IStorageProvider {\n\treadonly name = 's3';\n\tprivate readonly client: S3Client;\n\tprivate readonly bucket: string;\n\tprivate readonly ttl: number;\n\tprivate readonly prefix: string;\n\n\tconstructor(opts: IS3ProviderOptions) {\n\t\tthis.bucket = opts.bucket;\n\t\tthis.ttl = opts.presignTtlSeconds ?? 300;\n\t\tthis.prefix = opts.keyPrefix ?? '';\n\t\tthis.client = new S3Client({\n\t\t\tregion: opts.region ?? 'us-east-1',\n\t\t\t...(opts.endpoint\n\t\t\t\t? { endpoint: opts.endpoint, forcePathStyle: opts.forcePathStyle ?? true }\n\t\t\t\t: {}),\n\t\t\t...(opts.accessKeyId && opts.secretAccessKey\n\t\t\t\t? { credentials: { accessKeyId: opts.accessKeyId, secretAccessKey: opts.secretAccessKey } }\n\t\t\t\t: {}),\n\t\t});\n\t}\n\n\tasync put({ bytes, contentType }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tconst ref = `${this.prefix}${randomUUID()}`;\n\t\tawait this.client.send(\n\t\t\tnew PutObjectCommand({ Bucket: this.bucket, Key: ref, Body: bytes, ContentType: contentType }),\n\t\t);\n\t\treturn { ref };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\t// A presigned URL doesn't verify existence — a deleted key simply 404s at\n\t\t// the store when the client follows the redirect. Callers that hold their\n\t\t// own metadata row (e.g. media) have already confirmed the asset exists.\n\t\tconst url = await getSignedUrl(\n\t\t\tthis.client,\n\t\t\tnew GetObjectCommand({ Bucket: this.bucket, Key: ref }),\n\t\t\t{ expiresIn: this.ttl },\n\t\t);\n\t\treturn { kind: 'redirect', url };\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: ref }));\n\t}\n}\n"],"mappings":";AAAA,SAAS,kBAAkB;AAE3B;AAAA,EACC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACM;AACP,SAAS,oBAAoB;AAoCtB,IAAM,aAAN,MAA6C;AAAA,EAC1C,OAAO;AAAA,EACC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAA0B;AACrC,SAAK,SAAS,KAAK;AACnB,SAAK,MAAM,KAAK,qBAAqB;AACrC,SAAK,SAAS,KAAK,aAAa;AAChC,SAAK,SAAS,IAAI,SAAS;AAAA,MAC1B,QAAQ,KAAK,UAAU;AAAA,MACvB,GAAI,KAAK,WACN,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,kBAAkB,KAAK,IACvE,CAAC;AAAA,MACJ,GAAI,KAAK,eAAe,KAAK,kBAC1B,EAAE,aAAa,EAAE,aAAa,KAAK,aAAa,iBAAiB,KAAK,gBAAgB,EAAE,IACxF,CAAC;AAAA,IACL,CAAC;AAAA,EACF;AAAA,EAEA,MAAM,IAAI,EAAE,OAAO,YAAY,GAAoE;AAClG,UAAM,MAAM,GAAG,KAAK,MAAM,GAAG,WAAW,CAAC;AACzC,UAAM,KAAK,OAAO;AAAA,MACjB,IAAI,iBAAiB,EAAE,QAAQ,KAAK,QAAQ,KAAK,KAAK,MAAM,OAAO,aAAa,YAAY,CAAC;AAAA,IAC9F;AACA,WAAO,EAAE,IAAI;AAAA,EACd;AAAA,EAEA,MAAM,IAAI,KAAuC;AAIhD,UAAM,MAAM,MAAM;AAAA,MACjB,KAAK;AAAA,MACL,IAAI,iBAAiB,EAAE,QAAQ,KAAK,QAAQ,KAAK,IAAI,CAAC;AAAA,MACtD,EAAE,WAAW,KAAK,IAAI;AAAA,IACvB;AACA,WAAO,EAAE,MAAM,YAAY,IAAI;AAAA,EAChC;AAAA,EAEA,MAAM,OAAO,KAA4B;AACxC,UAAM,KAAK,OAAO,KAAK,IAAI,oBAAoB,EAAE,QAAQ,KAAK,QAAQ,KAAK,IAAI,CAAC,CAAC;AAAA,EAClF;AACD;","names":[]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage seam every backend implements — the same provider pattern
|
|
3
|
+
* @fonderie/billing uses for payment providers. Product code (media uploads, DB
|
|
4
|
+
* archives, anything) depends on this interface, never on a concrete backend,
|
|
5
|
+
* so `DbBlobProvider` (zero infra) → `S3Provider` (object storage / MinIO) is a
|
|
6
|
+
* one-line swap in the consumer's config.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately content-agnostic and least-common-denominator: `put` / `get` /
|
|
9
|
+
* `delete` of raw bytes, nothing backend-specific and nothing about *what* the
|
|
10
|
+
* bytes are. The one real divergence between backends — "can you hand the client
|
|
11
|
+
* a URL, or must the app stream the bytes?" — is modelled by the discriminated
|
|
12
|
+
* result of `get`, so an S3 provider can return a presigned URL while a DB /
|
|
13
|
+
* filesystem provider returns bytes, and the caller never branches on which.
|
|
14
|
+
*/
|
|
15
|
+
/** A backend-opaque handle to a stored object; the consumer persists it verbatim. */
|
|
16
|
+
interface IStoredRef {
|
|
17
|
+
ref: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The outcome of resolving a ref. `bytes` → the caller has the raw bytes (serve
|
|
21
|
+
* them / write them to a file / parse them). `redirect` → a URL the backend can
|
|
22
|
+
* serve directly (e.g. an S3 presigned URL) — hand it to the client. `null` →
|
|
23
|
+
* not found.
|
|
24
|
+
*/
|
|
25
|
+
type IFetched = {
|
|
26
|
+
kind: 'bytes';
|
|
27
|
+
bytes: Uint8Array;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'redirect';
|
|
30
|
+
url: string;
|
|
31
|
+
};
|
|
32
|
+
interface IStorageProvider {
|
|
33
|
+
/** Stable id for logs/diagnostics (e.g. 'db-blob', 'local-fs', 's3'). */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** Persist bytes; `contentType` is passed for backends that store it natively (S3). */
|
|
36
|
+
put(input: {
|
|
37
|
+
bytes: Uint8Array;
|
|
38
|
+
contentType: string;
|
|
39
|
+
}): Promise<IStoredRef>;
|
|
40
|
+
/** Resolve a ref to bytes or a redirect URL, or null if it's gone. */
|
|
41
|
+
get(ref: string): Promise<IFetched | null>;
|
|
42
|
+
/** Remove the stored object. Idempotent — deleting a missing ref must not throw. */
|
|
43
|
+
delete(ref: string): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type { IStorageProvider as I, IStoredRef as a, IFetched as b };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage seam every backend implements — the same provider pattern
|
|
3
|
+
* @fonderie/billing uses for payment providers. Product code (media uploads, DB
|
|
4
|
+
* archives, anything) depends on this interface, never on a concrete backend,
|
|
5
|
+
* so `DbBlobProvider` (zero infra) → `S3Provider` (object storage / MinIO) is a
|
|
6
|
+
* one-line swap in the consumer's config.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately content-agnostic and least-common-denominator: `put` / `get` /
|
|
9
|
+
* `delete` of raw bytes, nothing backend-specific and nothing about *what* the
|
|
10
|
+
* bytes are. The one real divergence between backends — "can you hand the client
|
|
11
|
+
* a URL, or must the app stream the bytes?" — is modelled by the discriminated
|
|
12
|
+
* result of `get`, so an S3 provider can return a presigned URL while a DB /
|
|
13
|
+
* filesystem provider returns bytes, and the caller never branches on which.
|
|
14
|
+
*/
|
|
15
|
+
/** A backend-opaque handle to a stored object; the consumer persists it verbatim. */
|
|
16
|
+
interface IStoredRef {
|
|
17
|
+
ref: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The outcome of resolving a ref. `bytes` → the caller has the raw bytes (serve
|
|
21
|
+
* them / write them to a file / parse them). `redirect` → a URL the backend can
|
|
22
|
+
* serve directly (e.g. an S3 presigned URL) — hand it to the client. `null` →
|
|
23
|
+
* not found.
|
|
24
|
+
*/
|
|
25
|
+
type IFetched = {
|
|
26
|
+
kind: 'bytes';
|
|
27
|
+
bytes: Uint8Array;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'redirect';
|
|
30
|
+
url: string;
|
|
31
|
+
};
|
|
32
|
+
interface IStorageProvider {
|
|
33
|
+
/** Stable id for logs/diagnostics (e.g. 'db-blob', 'local-fs', 's3'). */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** Persist bytes; `contentType` is passed for backends that store it natively (S3). */
|
|
36
|
+
put(input: {
|
|
37
|
+
bytes: Uint8Array;
|
|
38
|
+
contentType: string;
|
|
39
|
+
}): Promise<IStoredRef>;
|
|
40
|
+
/** Resolve a ref to bytes or a redirect URL, or null if it's gone. */
|
|
41
|
+
get(ref: string): Promise<IFetched | null>;
|
|
42
|
+
/** Remove the stored object. Idempotent — deleting a missing ref must not throw. */
|
|
43
|
+
delete(ref: string): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type { IStorageProvider as I, IStoredRef as a, IFetched as b };
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fonderie/storage",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Content-agnostic, provider-abstracted object storage — store any bytes on Postgres, disk, or S3-compatible object storage (S3/MinIO/R2) behind one interface. The low-dependency foundation for media, archives, and more.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"fonderiejs",
|
|
7
|
+
"storage",
|
|
8
|
+
"object-storage",
|
|
9
|
+
"s3",
|
|
10
|
+
"minio",
|
|
11
|
+
"blob",
|
|
12
|
+
"saas",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"require": "./dist/index.cjs"
|
|
25
|
+
},
|
|
26
|
+
"./s3": {
|
|
27
|
+
"types": "./dist/s3.d.ts",
|
|
28
|
+
"import": "./dist/s3.js",
|
|
29
|
+
"require": "./dist/s3.cjs"
|
|
30
|
+
},
|
|
31
|
+
"./migrations": {
|
|
32
|
+
"types": "./dist/migrations/index.d.ts",
|
|
33
|
+
"import": "./dist/migrations/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"main": "./dist/index.cjs",
|
|
37
|
+
"module": "./dist/index.js",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup && tsup --config tsup.migrations.ts",
|
|
41
|
+
"dev": "tsup --watch",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "tsx --test src/__tests__/*.test.ts",
|
|
44
|
+
"lint": "biome lint src",
|
|
45
|
+
"format": "biome format --write src",
|
|
46
|
+
"check": "biome check --write src"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@fonderie/core": "^0.8.0",
|
|
50
|
+
"@fonderie/store": "^0.2.0",
|
|
51
|
+
"@aws-sdk/client-s3": "^3.0.0",
|
|
52
|
+
"@aws-sdk/s3-request-presigner": "^3.0.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"@aws-sdk/client-s3": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@aws-sdk/s3-request-presigner": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@aws-sdk/client-s3": "^3.0.0",
|
|
64
|
+
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
65
|
+
"@fonderie/core": "../core",
|
|
66
|
+
"@fonderie/store": "../store",
|
|
67
|
+
"@types/node": "^26.4.1",
|
|
68
|
+
"tsup": "^8.5.1",
|
|
69
|
+
"tsx": "^4.23.13",
|
|
70
|
+
"typescript": "^6.0.3"
|
|
71
|
+
},
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
},
|
|
75
|
+
"files": [
|
|
76
|
+
"dist",
|
|
77
|
+
"brain",
|
|
78
|
+
"LICENSE",
|
|
79
|
+
"README.md"
|
|
80
|
+
],
|
|
81
|
+
"repository": {
|
|
82
|
+
"type": "git",
|
|
83
|
+
"url": "git+https://github.com/fonderiejs/fonderie.git",
|
|
84
|
+
"directory": "packages/storage"
|
|
85
|
+
},
|
|
86
|
+
"homepage": "https://github.com/fonderiejs/fonderie/tree/main/packages/storage#readme",
|
|
87
|
+
"bugs": {
|
|
88
|
+
"url": "https://github.com/fonderiejs/fonderie/issues"
|
|
89
|
+
}
|
|
90
|
+
}
|