@fonderie/storage 0.1.0 → 0.1.1

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/dist/index.cjs CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/providers/db-blob.ts
29
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
30
  var DbBlobProvider = class {
30
31
  constructor(store) {
31
32
  this.store = store;
@@ -40,6 +41,7 @@ var DbBlobProvider = class {
40
41
  return { ref: rows[0].id };
41
42
  }
42
43
  async get(ref) {
44
+ if (!UUID_RE.test(ref)) return null;
43
45
  const rows = await this.store.query(
44
46
  "SELECT bytes FROM fonderie_storage_blobs WHERE id = $1",
45
47
  [ref]
@@ -48,6 +50,7 @@ var DbBlobProvider = class {
48
50
  return row ? { kind: "bytes", bytes: row.bytes } : null;
49
51
  }
50
52
  async delete(ref) {
53
+ if (!UUID_RE.test(ref)) return;
51
54
  await this.store.query("DELETE FROM fonderie_storage_blobs WHERE id = $1", [ref]);
52
55
  }
53
56
  };
@@ -64,7 +67,12 @@ var LocalFsProvider = class {
64
67
  this.dir = (0, import_node_path.resolve)(dir);
65
68
  }
66
69
  ensureDir() {
67
- if (!this.ready) this.ready = (0, import_promises.mkdir)(this.dir, { recursive: true }).then(() => void 0);
70
+ if (!this.ready) {
71
+ this.ready = (0, import_promises.mkdir)(this.dir, { recursive: true }).then(() => void 0).catch((err) => {
72
+ this.ready = null;
73
+ throw err;
74
+ });
75
+ }
68
76
  return this.ready;
69
77
  }
70
78
  // Reject any ref that isn't a bare id, so a ref can never escape `dir`
@@ -1 +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":[]}
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// Refs we mint are UUIDs. Guard reads/deletes so a foreign or malformed ref\n// resolves to \"not found\" (null / no-op) instead of a Postgres\n// \"invalid input syntax for type uuid\" error — the interface contract is\n// null-on-missing, and a foundation provider shouldn't throw on a bad ref.\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\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\tif (!UUID_RE.test(ref)) return 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\tif (!UUID_RE.test(ref)) return; // nothing to delete for a ref we never minted\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\t// Clear the cached promise on failure so a transient mkdir error (e.g. a\n\t\t// briefly-unmounted volume) doesn't stick a rejected promise that fails\n\t\t// every later put; the next call retries.\n\t\tif (!this.ready) {\n\t\t\tthis.ready = mkdir(this.dir, { recursive: true })\n\t\t\t\t.then(() => undefined)\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tthis.ready = null;\n\t\t\t\t\tthrow err;\n\t\t\t\t});\n\t\t}\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;;;ACQA,IAAM,UAAU;AAUT,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,QAAI,CAAC,QAAQ,KAAK,GAAG,EAAG,QAAO;AAC/B,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,QAAI,CAAC,QAAQ,KAAK,GAAG,EAAG;AACxB,UAAM,KAAK,MAAM,MAAM,oDAAoD,CAAC,GAAG,CAAC;AAAA,EACjF;AACD;;;AC7CA,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;AAIlC,QAAI,CAAC,KAAK,OAAO;AAChB,WAAK,YAAQ,uBAAM,KAAK,KAAK,EAAE,WAAW,KAAK,CAAC,EAC9C,KAAK,MAAM,MAAS,EACpB,MAAM,CAAC,QAAQ;AACf,aAAK,QAAQ;AACb,cAAM;AAAA,MACP,CAAC;AAAA,IACH;AACA,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 CHANGED
@@ -1,4 +1,4 @@
1
- import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.cjs';
1
+ import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-CR0cffCy.cjs';
2
2
  import { IStoreAdapter } from '@fonderie/store';
3
3
 
4
4
  /**
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.js';
1
+ import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-CR0cffCy.js';
2
2
  import { IStoreAdapter } from '@fonderie/store';
3
3
 
4
4
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/providers/db-blob.ts
2
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
3
  var DbBlobProvider = class {
3
4
  constructor(store) {
4
5
  this.store = store;
@@ -13,6 +14,7 @@ var DbBlobProvider = class {
13
14
  return { ref: rows[0].id };
14
15
  }
15
16
  async get(ref) {
17
+ if (!UUID_RE.test(ref)) return null;
16
18
  const rows = await this.store.query(
17
19
  "SELECT bytes FROM fonderie_storage_blobs WHERE id = $1",
18
20
  [ref]
@@ -21,6 +23,7 @@ var DbBlobProvider = class {
21
23
  return row ? { kind: "bytes", bytes: row.bytes } : null;
22
24
  }
23
25
  async delete(ref) {
26
+ if (!UUID_RE.test(ref)) return;
24
27
  await this.store.query("DELETE FROM fonderie_storage_blobs WHERE id = $1", [ref]);
25
28
  }
26
29
  };
@@ -37,7 +40,12 @@ var LocalFsProvider = class {
37
40
  this.dir = resolve(dir);
38
41
  }
39
42
  ensureDir() {
40
- if (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => void 0);
43
+ if (!this.ready) {
44
+ this.ready = mkdir(this.dir, { recursive: true }).then(() => void 0).catch((err) => {
45
+ this.ready = null;
46
+ throw err;
47
+ });
48
+ }
41
49
  return this.ready;
42
50
  }
43
51
  // Reject any ref that isn't a bare id, so a ref can never escape `dir`
package/dist/index.js.map CHANGED
@@ -1 +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":[]}
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// Refs we mint are UUIDs. Guard reads/deletes so a foreign or malformed ref\n// resolves to \"not found\" (null / no-op) instead of a Postgres\n// \"invalid input syntax for type uuid\" error — the interface contract is\n// null-on-missing, and a foundation provider shouldn't throw on a bad ref.\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\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\tif (!UUID_RE.test(ref)) return 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\tif (!UUID_RE.test(ref)) return; // nothing to delete for a ref we never minted\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\t// Clear the cached promise on failure so a transient mkdir error (e.g. a\n\t\t// briefly-unmounted volume) doesn't stick a rejected promise that fails\n\t\t// every later put; the next call retries.\n\t\tif (!this.ready) {\n\t\t\tthis.ready = mkdir(this.dir, { recursive: true })\n\t\t\t\t.then(() => undefined)\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tthis.ready = null;\n\t\t\t\t\tthrow err;\n\t\t\t\t});\n\t\t}\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":";AAQA,IAAM,UAAU;AAUT,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,QAAI,CAAC,QAAQ,KAAK,GAAG,EAAG,QAAO;AAC/B,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,QAAI,CAAC,QAAQ,KAAK,GAAG,EAAG;AACxB,UAAM,KAAK,MAAM,MAAM,oDAAoD,CAAC,GAAG,CAAC;AAAA,EACjF;AACD;;;AC7CA,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;AAIlC,QAAI,CAAC,KAAK,OAAO;AAChB,WAAK,QAAQ,MAAM,KAAK,KAAK,EAAE,WAAW,KAAK,CAAC,EAC9C,KAAK,MAAM,MAAS,EACpB,MAAM,CAAC,QAAQ;AACf,aAAK,QAAQ;AACb,cAAM;AAAA,MACP,CAAC;AAAA,IACH;AACA,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":[]}
package/dist/s3.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.cjs';
1
+ import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-CR0cffCy.cjs';
2
2
 
3
3
  interface IS3ProviderOptions {
4
4
  /** Target bucket. Must already exist. */
package/dist/s3.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-Cb480R4u.js';
1
+ import { I as IStorageProvider, a as IStoredRef, b as IFetched } from './types-CR0cffCy.js';
2
2
 
3
3
  interface IS3ProviderOptions {
4
4
  /** Target bucket. Must already exist. */
@@ -37,7 +37,16 @@ interface IStorageProvider {
37
37
  bytes: Uint8Array;
38
38
  contentType: string;
39
39
  }): Promise<IStoredRef>;
40
- /** Resolve a ref to bytes or a redirect URL, or null if it's gone. */
40
+ /**
41
+ * Resolve a ref to bytes or a redirect URL, or null if it's gone.
42
+ *
43
+ * NOTE on `null`: DbBlob/LocalFs verify existence and return `null` for a
44
+ * missing ref. `S3Provider` returns a presigned `redirect` WITHOUT a
45
+ * round-trip to check existence, so a missing key yields a redirect URL that
46
+ * 404s when followed rather than `null`. Consumers that must detect "gone"
47
+ * without following the URL should keep their own metadata (as
48
+ * `@fonderie/media` does with its asset row) rather than rely on `null`.
49
+ */
41
50
  get(ref: string): Promise<IFetched | null>;
42
51
  /** Remove the stored object. Idempotent — deleting a missing ref must not throw. */
43
52
  delete(ref: string): Promise<void>;
@@ -37,7 +37,16 @@ interface IStorageProvider {
37
37
  bytes: Uint8Array;
38
38
  contentType: string;
39
39
  }): Promise<IStoredRef>;
40
- /** Resolve a ref to bytes or a redirect URL, or null if it's gone. */
40
+ /**
41
+ * Resolve a ref to bytes or a redirect URL, or null if it's gone.
42
+ *
43
+ * NOTE on `null`: DbBlob/LocalFs verify existence and return `null` for a
44
+ * missing ref. `S3Provider` returns a presigned `redirect` WITHOUT a
45
+ * round-trip to check existence, so a missing key yields a redirect URL that
46
+ * 404s when followed rather than `null`. Consumers that must detect "gone"
47
+ * without following the URL should keep their own metadata (as
48
+ * `@fonderie/media` does with its asset row) rather than rely on `null`.
49
+ */
41
50
  get(ref: string): Promise<IFetched | null>;
42
51
  /** Remove the stored object. Idempotent — deleting a missing ref must not throw. */
43
52
  delete(ref: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/storage",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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
5
  "keywords": [
6
6
  "fonderiejs",