@mostajs/blob-store 0.1.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 ADDED
@@ -0,0 +1,29 @@
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (c) 2026 Dr Hamid MADANI <drmdh@msn.com>
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ COMMERCIAL LICENSE
20
+
21
+ For organizations that cannot comply with the AGPL open-source requirements,
22
+ a commercial license is available. Contact: drmdh@msn.com
23
+
24
+ The commercial license allows you to:
25
+ - Use the software in proprietary/closed-source projects
26
+ - Modify without publishing your source code
27
+ - Get priority support and SLA
28
+
29
+ Contact: Dr Hamid MADANI <drmdh@msn.com>
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @mostajs/blob-store
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
4
+ **Niveau** : N0 (primitif) · **Zéro dépendance**
5
+
6
+ Ranger des octets. Rien d'autre.
7
+
8
+ Contrat `StorageDriver` — `put` / `get` / `delete` / `exists` / `stat` / `list` — avec flux,
9
+ métadonnées, `etag`, checksum SHA-256 et création atomique (`ifNotExists`).
10
+
11
+ ## Pourquoi ce module existe
12
+
13
+ Ce code vivait dans `@mostajs/storage`. Il y était **déjà pur** — aucun import d'ORM, de RBAC
14
+ ni de data-plug — mais **prisonnier** : `@mostajs/storage` déclare `@mostajs/orm`,
15
+ `@mostajs/data-plug` et `@mostajs/config` en dépendances. Importer le seul driver traînait donc
16
+ tout l'ORM derrière. Impossible d'obtenir « juste des octets ».
17
+
18
+ Extrait ici, il devient une brique réellement réutilisable :
19
+
20
+ | Consommateur | Ce qu'il en fait |
21
+ |---|---|
22
+ | `@mostajs/storage` | octets **+** métadonnées en base **+** RBAC (ré-exporte d'ici — aucune rupture) |
23
+ | `@mostajs/archive-box` | les octets d'une archive |
24
+ | `@mostajs/backup` | les octets d'une sauvegarde, locale ou externalisée |
25
+
26
+ Le module ignore délibérément ce que les octets représentent. C'est ce qui le rend malléable.
27
+
28
+ ## Usage
29
+
30
+ ```js
31
+ import { FilesystemDriver, StorageError } from '@mostajs/blob-store';
32
+
33
+ const driver = new FilesystemDriver({ rootDir: '/var/data/blobs' });
34
+
35
+ const { size, checksum } = await driver.put(
36
+ { bucket: 'backups', path: '2026-07-13.zip' },
37
+ bytes,
38
+ { mimeType: 'application/zip' },
39
+ );
40
+
41
+ const obj = await driver.get({ bucket: 'backups', path: '2026-07-13.zip' });
42
+ // obj.stream, obj.size, obj.mimeType, obj.etag
43
+ ```
44
+
45
+ Erreurs **typées** (jamais nues) : `StorageError` avec un `code` (`not_found`, `invalid_path`,
46
+ `access_denied`…) et `STORAGE_ERROR_HTTP` pour le mapping HTTP.
47
+
48
+ ## Pilotes
49
+
50
+ - **`FilesystemDriver`** — disque local. Sanitise les chemins (le `..` est refusé : un driver
51
+ de stockage est une surface d'attaque).
52
+ - **`CachingDriver`** — décorateur de cache devant un driver d'origine.
53
+ - **S3** — à venir (voir la feuille de route ; `@mostajs/storage` promettait « FS / S3 » sans
54
+ jamais livrer S3 : la dette est soldée **ici**, pour que tous les consommateurs en profitent).
55
+
56
+ ## Tests
57
+
58
+ ```bash
59
+ npm test # 10 tests mjs-unit, sur un VRAI répertoire temporaire
60
+ ```
61
+
62
+ Un test de stockage qui n'écrit rien ne prouve rien : les tests exercent le disque réel, avec
63
+ un vecteur SHA-256 **vérifié indépendamment** (et non recopié depuis le pilote lui-même).
@@ -0,0 +1,58 @@
1
+ import type { ObjectRef, PutObjectResult } from './types.js';
2
+ /** Source de bytes acceptée par `put` — Buffer, Uint8Array ou Web ReadableStream. */
3
+ export type PutBody = Uint8Array | Buffer | ReadableStream<Uint8Array>;
4
+ export interface PutOptions {
5
+ /** Type MIME stocké côté driver (ex: en S3 metadata, en xattr FS). */
6
+ mimeType: string;
7
+ /** Métadonnées libres (clé/valeur, strings) — propagées au driver. */
8
+ metadata?: Record<string, string>;
9
+ /** Taille connue d'avance (utile pour S3 multipart). */
10
+ size?: number;
11
+ /** Si true, refuse de remplacer un objet existant (atomic create). */
12
+ ifNotExists?: boolean;
13
+ }
14
+ export interface GetObjectResult {
15
+ /** Stream Web standard pour pipeline efficace. */
16
+ stream: ReadableStream<Uint8Array>;
17
+ size: number;
18
+ mimeType: string;
19
+ etag: string;
20
+ metadata?: Record<string, string>;
21
+ }
22
+ export interface StatResult {
23
+ size: number;
24
+ mimeType: string;
25
+ etag: string;
26
+ metadata?: Record<string, string>;
27
+ /** Date de dernière modification côté driver. */
28
+ modifiedAt: Date;
29
+ }
30
+ /**
31
+ * Contrat driver. Tous les chemins sont des paths logiques `<bucket>/<path>` ;
32
+ * le driver est libre de les traduire (FS = arborescence sur disque, S3 = key).
33
+ */
34
+ export interface StorageDriver {
35
+ readonly id: string;
36
+ put(ref: ObjectRef, body: PutBody, opts: PutOptions): Promise<PutObjectResult>;
37
+ get(ref: ObjectRef): Promise<GetObjectResult>;
38
+ delete(ref: ObjectRef): Promise<void>;
39
+ exists(ref: ObjectRef): Promise<boolean>;
40
+ stat(ref: ObjectRef): Promise<StatResult>;
41
+ /**
42
+ * Liste paginée des objets d'un bucket sous un préfixe donné. Retourne `nextCursor`
43
+ * si plus de résultats sont disponibles.
44
+ */
45
+ list(args: {
46
+ bucket: string;
47
+ prefix?: string;
48
+ limit?: number;
49
+ cursor?: string;
50
+ }): Promise<{
51
+ items: {
52
+ path: string;
53
+ size: number;
54
+ modifiedAt: Date;
55
+ }[];
56
+ nextCursor?: string;
57
+ }>;
58
+ }
package/dist/driver.js ADDED
@@ -0,0 +1,7 @@
1
+ // @mostajs/storage — Driver interface (FS / S3-compat / R2 / B2 / …)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Surface minimale qu'un driver doit implémenter. Volontairement étroite — on garde
5
+ // la même signature pour FS local et S3-compat distant, ce qui permet à l'app cliente
6
+ // de swap d'un backend à l'autre sans changer le code de @mostajs/storage core.
7
+ export {};
@@ -0,0 +1,71 @@
1
+ import type { StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult } from '../driver.js';
2
+ import type { ObjectRef, PutObjectResult } from '../types.js';
3
+ /** Traduit un objet en URL d'origine. `null`/`undefined` ⇒ objet purement local (pas d'origine). */
4
+ export interface OriginResolver {
5
+ (ref: ObjectRef): string | URL | null | undefined;
6
+ }
7
+ export interface CachingDriverConfig {
8
+ /** Driver de persistance sous-jacent (FilesystemDriver, S3Driver…). */
9
+ base: StorageDriver;
10
+ /** Résolution ref → URL d'origine. */
11
+ origin: OriginResolver;
12
+ /** Âge max (ms) avant re-pull. 0 = ne périme jamais. Défaut 0. */
13
+ ttlMs?: number;
14
+ /** Ne jamais sortir sur le réseau. Défaut false. */
15
+ offline?: boolean;
16
+ /** Origine KO + copie présente (même périmée) ⇒ servir la copie. Défaut true. */
17
+ staleOnError?: boolean;
18
+ /** MIME par défaut si l'origine n'expose pas de Content-Type exploitable. Défaut 'application/octet-stream'. */
19
+ defaultMimeType?: string;
20
+ /** En-têtes ajoutés à chaque requête d'origine (ex. User-Agent conforme à la policy d'origine). */
21
+ headers?: Record<string, string>;
22
+ /** Injection de fetch (tests / proxy). Défaut: fetch global. */
23
+ fetchImpl?: typeof fetch;
24
+ /** Timeout (ms) d'une requête d'origine. Défaut 15000. */
25
+ timeoutMs?: number;
26
+ }
27
+ /** État d'une lecture vis-à-vis du cache (exposé via `GetObjectResult.metadata['x-cache']`). */
28
+ export type CacheStatus = 'HIT' | 'MISS' | 'REFRESH' | 'FORCED' | 'STALE-OFFLINE' | 'STALE-FALLBACK' | 'SKIP';
29
+ export declare class CachingDriver implements StorageDriver {
30
+ readonly id: string;
31
+ private base;
32
+ private origin;
33
+ private ttlMs;
34
+ private offline;
35
+ private staleOnError;
36
+ private defaultMimeType;
37
+ private headers;
38
+ private fetchImpl;
39
+ private timeoutMs;
40
+ /** Déduplication intra-process des pulls en vol (best-effort). */
41
+ private inflight;
42
+ constructor(config: CachingDriverConfig);
43
+ put(ref: ObjectRef, body: PutBody, opts: PutOptions): Promise<PutObjectResult>;
44
+ delete(ref: ObjectRef): Promise<void>;
45
+ exists(ref: ObjectRef): Promise<boolean>;
46
+ stat(ref: ObjectRef): Promise<StatResult>;
47
+ list(args: {
48
+ bucket: string;
49
+ prefix?: string;
50
+ limit?: number;
51
+ cursor?: string;
52
+ }): Promise<{
53
+ items: {
54
+ path: string;
55
+ size: number;
56
+ modifiedAt: Date;
57
+ }[];
58
+ nextCursor?: string;
59
+ }>;
60
+ get(ref: ObjectRef): Promise<GetObjectResult>;
61
+ /** Re-pull inconditionnel depuis l'origine + re-store. Sert la « mise à jour forcée ». */
62
+ refresh(ref: ObjectRef): Promise<PutObjectResult>;
63
+ /** Garantit présence/fraîcheur sans renvoyer les bytes (warmers / pré-téléchargement en masse). */
64
+ prefetch(ref: ObjectRef): Promise<CacheStatus>;
65
+ private statOrNull;
66
+ /** Pull origine + put base, avec déduplication intra-process des requêtes concomitantes. */
67
+ private pull;
68
+ private fetchOrigin;
69
+ private withStatus;
70
+ private asStorageError;
71
+ }
@@ -0,0 +1,178 @@
1
+ // @mostajs/storage — CachingDriver (cache read-through / origin-pull)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ // License: AGPL-3.0-or-later
4
+ //
5
+ // Driver DÉCORATEUR : enrobe n'importe quel `StorageDriver` de base (FS, S3…) et lui
6
+ // ajoute une sémantique de cache read-through sur une ORIGINE HTTP résolue par
7
+ // l'appelant. La persistance reste 100 % déléguée au base (DI, swappable FS→S3) ;
8
+ // la connaissance métier (ce que vaut une URL d'origine) est injectée via `origin`.
9
+ // storage ne dépend donc d'AUCUN domaine appelant.
10
+ //
11
+ // Conception : docs/DESIGN-CACHING-DRIVER.md.
12
+ //
13
+ // Politique de fraîcheur :
14
+ // - miss → pull origine + store (MISS)
15
+ // - périmé/TTL → re-pull + re-store (REFRESH)
16
+ // - refresh() → re-pull inconditionnel (FORCED)
17
+ // - offline → jamais de réseau : sert le cache même périmé (STALE-OFFLINE) ou not_found
18
+ // - staleOnError→ origine KO mais copie présente : sert la copie (STALE-FALLBACK)
19
+ import { StorageError } from '../types.js';
20
+ const refKey = (ref) => `${ref.bucket}/${ref.path}`;
21
+ export class CachingDriver {
22
+ id;
23
+ base;
24
+ origin;
25
+ ttlMs;
26
+ offline;
27
+ staleOnError;
28
+ defaultMimeType;
29
+ headers;
30
+ fetchImpl;
31
+ timeoutMs;
32
+ /** Déduplication intra-process des pulls en vol (best-effort). */
33
+ inflight = new Map();
34
+ constructor(config) {
35
+ if (!config?.base)
36
+ throw new StorageError('driver_error', 'CachingDriver: `base` driver requis');
37
+ if (typeof config.origin !== 'function')
38
+ throw new StorageError('driver_error', 'CachingDriver: `origin` resolver requis');
39
+ this.base = config.base;
40
+ this.origin = config.origin;
41
+ this.ttlMs = config.ttlMs ?? 0;
42
+ this.offline = config.offline ?? false;
43
+ this.staleOnError = config.staleOnError ?? true;
44
+ this.defaultMimeType = config.defaultMimeType ?? 'application/octet-stream';
45
+ this.headers = config.headers ?? {};
46
+ this.fetchImpl = config.fetchImpl ?? fetch;
47
+ this.timeoutMs = config.timeoutMs ?? 15_000;
48
+ this.id = `caching(${this.base.id})`;
49
+ }
50
+ // ── Délégation pure au base ────────────────────────────────────────────────
51
+ put(ref, body, opts) { return this.base.put(ref, body, opts); }
52
+ delete(ref) { return this.base.delete(ref); }
53
+ exists(ref) { return this.base.exists(ref); }
54
+ stat(ref) { return this.base.stat(ref); }
55
+ list(args) { return this.base.list(args); }
56
+ // ── Lecture read-through ───────────────────────────────────────────────────
57
+ async get(ref) {
58
+ const present = await this.statOrNull(ref);
59
+ const fresh = present !== null && (this.ttlMs === 0 || (Date.now() - present.modifiedAt.getTime()) < this.ttlMs);
60
+ // Frais, ou hors-ligne avec une copie : on sert le cache.
61
+ // En offline on n'a PAS pu vérifier l'origine → toujours STALE-OFFLINE (même si « frais »
62
+ // par TTL). HIT n'est légitime qu'en ligne ET frais.
63
+ if (present !== null && (fresh || this.offline)) {
64
+ return this.withStatus(await this.base.get(ref), (fresh && !this.offline) ? 'HIT' : 'STALE-OFFLINE');
65
+ }
66
+ // Hors-ligne sans copie : rien à servir.
67
+ if (this.offline)
68
+ throw new StorageError('not_found', `Objet non caché (offline): ${refKey(ref)}`);
69
+ // En ligne, absent ou périmé → pull origine.
70
+ const url = this.origin(ref);
71
+ if (url == null) {
72
+ if (present !== null)
73
+ return this.withStatus(await this.base.get(ref), 'STALE-OFFLINE');
74
+ throw new StorageError('not_found', `Pas d'origine pour ${refKey(ref)}`);
75
+ }
76
+ try {
77
+ await this.pull(ref, url);
78
+ return this.withStatus(await this.base.get(ref), present !== null ? 'REFRESH' : 'MISS');
79
+ }
80
+ catch (e) {
81
+ if (this.staleOnError && present !== null) {
82
+ return this.withStatus(await this.base.get(ref), 'STALE-FALLBACK');
83
+ }
84
+ throw this.asStorageError(e, ref);
85
+ }
86
+ }
87
+ // ── Hors-contrat : mise à jour & pré-chauffe ───────────────────────────────
88
+ /** Re-pull inconditionnel depuis l'origine + re-store. Sert la « mise à jour forcée ». */
89
+ async refresh(ref) {
90
+ if (this.offline)
91
+ throw new StorageError('driver_error', `refresh impossible en mode offline: ${refKey(ref)}`);
92
+ const url = this.origin(ref);
93
+ if (url == null)
94
+ throw new StorageError('not_found', `Pas d'origine pour ${refKey(ref)}`);
95
+ return this.pull(ref, url, /*force*/ true);
96
+ }
97
+ /** Garantit présence/fraîcheur sans renvoyer les bytes (warmers / pré-téléchargement en masse). */
98
+ async prefetch(ref) {
99
+ const present = await this.statOrNull(ref);
100
+ const fresh = present !== null && (this.ttlMs === 0 || (Date.now() - present.modifiedAt.getTime()) < this.ttlMs);
101
+ if (fresh)
102
+ return 'SKIP';
103
+ if (this.offline) {
104
+ if (present !== null)
105
+ return 'STALE-OFFLINE';
106
+ throw new StorageError('not_found', `Objet non caché (offline): ${refKey(ref)}`);
107
+ }
108
+ const url = this.origin(ref);
109
+ if (url == null) {
110
+ if (present !== null)
111
+ return 'STALE-OFFLINE';
112
+ throw new StorageError('not_found', `Pas d'origine pour ${refKey(ref)}`);
113
+ }
114
+ try {
115
+ await this.pull(ref, url);
116
+ return present !== null ? 'REFRESH' : 'MISS';
117
+ }
118
+ catch (e) {
119
+ if (this.staleOnError && present !== null)
120
+ return 'STALE-FALLBACK';
121
+ throw this.asStorageError(e, ref);
122
+ }
123
+ }
124
+ // ── Internes ───────────────────────────────────────────────────────────────
125
+ async statOrNull(ref) {
126
+ try {
127
+ return await this.base.stat(ref);
128
+ }
129
+ catch (e) {
130
+ if (e instanceof StorageError && e.code === 'not_found')
131
+ return null;
132
+ // exists() en repli si le base lève autrement
133
+ if (!(await this.base.exists(ref)))
134
+ return null;
135
+ throw e;
136
+ }
137
+ }
138
+ /** Pull origine + put base, avec déduplication intra-process des requêtes concomitantes. */
139
+ pull(ref, url, force = false) {
140
+ const k = refKey(ref);
141
+ if (!force) {
142
+ const running = this.inflight.get(k);
143
+ if (running)
144
+ return running;
145
+ }
146
+ const job = (async () => {
147
+ const { bytes, mimeType } = await this.fetchOrigin(url);
148
+ return this.base.put(ref, bytes, { mimeType });
149
+ })();
150
+ this.inflight.set(k, job);
151
+ return job.finally(() => { if (this.inflight.get(k) === job)
152
+ this.inflight.delete(k); });
153
+ }
154
+ async fetchOrigin(url) {
155
+ const ac = new AbortController();
156
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
157
+ try {
158
+ const res = await this.fetchImpl(url, { headers: this.headers, signal: ac.signal });
159
+ if (!res.ok) {
160
+ throw new StorageError(res.status === 404 ? 'not_found' : 'driver_error', `Origine ${res.status} pour ${url}`, { status: res.status });
161
+ }
162
+ const mimeType = (res.headers.get('content-type') || '').split(';')[0].trim() || this.defaultMimeType;
163
+ const bytes = Buffer.from(await res.arrayBuffer());
164
+ return { bytes, mimeType };
165
+ }
166
+ finally {
167
+ clearTimeout(timer);
168
+ }
169
+ }
170
+ withStatus(r, status) {
171
+ return { ...r, metadata: { ...(r.metadata ?? {}), 'x-cache': status } };
172
+ }
173
+ asStorageError(e, ref) {
174
+ if (e instanceof StorageError)
175
+ return e;
176
+ return new StorageError('driver_error', `Pull origine échoué pour ${refKey(ref)}: ${e?.message ?? e}`);
177
+ }
178
+ }
@@ -0,0 +1,33 @@
1
+ import type { StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult } from '../driver.js';
2
+ import type { ObjectRef, PutObjectResult } from '../types.js';
3
+ export interface FilesystemDriverConfig {
4
+ /** Répertoire racine où sont stockés tous les buckets. Doit exister & être writable. */
5
+ rootDir: string;
6
+ }
7
+ export declare class FilesystemDriver implements StorageDriver {
8
+ private config;
9
+ readonly id = "filesystem";
10
+ private rootResolved;
11
+ constructor(config: FilesystemDriverConfig);
12
+ /** Resolve un (bucket, path) en absolute fs path, en garantissant qu'on reste sous rootDir. */
13
+ private resolveSafe;
14
+ put(ref: ObjectRef, body: PutBody, opts: PutOptions): Promise<PutObjectResult>;
15
+ get(ref: ObjectRef): Promise<GetObjectResult>;
16
+ delete(ref: ObjectRef): Promise<void>;
17
+ exists(ref: ObjectRef): Promise<boolean>;
18
+ stat(ref: ObjectRef): Promise<StatResult>;
19
+ list(args: {
20
+ bucket: string;
21
+ prefix?: string;
22
+ limit?: number;
23
+ cursor?: string;
24
+ }): Promise<{
25
+ items: {
26
+ path: string;
27
+ size: number;
28
+ modifiedAt: Date;
29
+ }[];
30
+ nextCursor?: string;
31
+ }>;
32
+ private readMeta;
33
+ }
@@ -0,0 +1,211 @@
1
+ // @mostajs/storage — Filesystem driver (self-host)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Driver local : `<rootDir>/<bucket>/<path>`. Les métadonnées (mimeType, etag, custom)
5
+ // sont stockées dans un sidecar JSON `<...>.meta.json` à côté du fichier — simple,
6
+ // portable, lisible. Le checksum sha256 est calculé au stream-write (pas de re-read).
7
+ //
8
+ // Sécurité du path :
9
+ // - Refuse `..` dans bucket ou path → StorageError('invalid_path').
10
+ // - Refuse les paths absolus côté input.
11
+ // - Vérifie après resolve que le résultat est sous rootDir (anti symlink escape).
12
+ import * as fs from 'node:fs/promises';
13
+ import * as fsSync from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as crypto from 'node:crypto';
16
+ import { Readable } from 'node:stream';
17
+ import { StorageError } from '../types.js';
18
+ const META_SUFFIX = '.meta.json';
19
+ function sanitizeSegment(seg, kind) {
20
+ if (!seg || seg === '.' || seg === '..') {
21
+ throw new StorageError('invalid_path', `Empty or dotted ${kind}: '${seg}'`);
22
+ }
23
+ if (seg.includes('\0')) {
24
+ throw new StorageError('invalid_path', `Null byte in ${kind}`);
25
+ }
26
+ if (path.isAbsolute(seg)) {
27
+ throw new StorageError('invalid_path', `${kind} must not be absolute`);
28
+ }
29
+ // Empêche tout '..' segment dans le chemin (même au milieu).
30
+ for (const part of seg.split(/[\\/]/)) {
31
+ if (part === '..') {
32
+ throw new StorageError('invalid_path', `${kind} contains traversal '..': '${seg}'`);
33
+ }
34
+ }
35
+ return seg;
36
+ }
37
+ export class FilesystemDriver {
38
+ config;
39
+ id = 'filesystem';
40
+ rootResolved;
41
+ constructor(config) {
42
+ this.config = config;
43
+ this.rootResolved = path.resolve(config.rootDir);
44
+ }
45
+ /** Resolve un (bucket, path) en absolute fs path, en garantissant qu'on reste sous rootDir. */
46
+ resolveSafe(ref) {
47
+ sanitizeSegment(ref.bucket, 'bucket');
48
+ sanitizeSegment(ref.path, 'path');
49
+ const candidate = path.resolve(this.rootResolved, ref.bucket, ref.path);
50
+ const rel = path.relative(this.rootResolved, candidate);
51
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
52
+ throw new StorageError('invalid_path', 'Resolved path escapes rootDir', {
53
+ rootDir: this.rootResolved,
54
+ bucket: ref.bucket,
55
+ path: ref.path,
56
+ });
57
+ }
58
+ return candidate;
59
+ }
60
+ async put(ref, body, opts) {
61
+ const abs = this.resolveSafe(ref);
62
+ if (opts.ifNotExists && fsSync.existsSync(abs)) {
63
+ throw new StorageError('access_denied', `Object already exists at ${ref.bucket}/${ref.path}`);
64
+ }
65
+ await fs.mkdir(path.dirname(abs), { recursive: true });
66
+ const hash = crypto.createHash('sha256');
67
+ let size = 0;
68
+ // Écriture atomique : fichier temporaire .tmp puis rename.
69
+ const tmpPath = `${abs}.tmp-${process.pid}-${Date.now()}`;
70
+ const out = fsSync.createWriteStream(tmpPath);
71
+ const writeBuffer = (chunk) => {
72
+ hash.update(chunk);
73
+ size += chunk.byteLength;
74
+ return new Promise((res, rej) => {
75
+ out.write(chunk, (err) => (err ? rej(err) : res()));
76
+ });
77
+ };
78
+ try {
79
+ if (body instanceof Uint8Array || Buffer.isBuffer(body)) {
80
+ await writeBuffer(body);
81
+ }
82
+ else {
83
+ // Web ReadableStream → async iteration
84
+ for await (const chunk of body) {
85
+ await writeBuffer(chunk);
86
+ }
87
+ }
88
+ await new Promise((res, rej) => out.end((err) => (err ? rej(err) : res())));
89
+ }
90
+ catch (e) {
91
+ await fs.unlink(tmpPath).catch(() => { });
92
+ throw new StorageError('driver_error', `FS write failed: ${e.message}`);
93
+ }
94
+ await fs.rename(tmpPath, abs);
95
+ const checksum = hash.digest('hex');
96
+ const etag = checksum.slice(0, 32);
97
+ // Sidecar metadata
98
+ const meta = {
99
+ mimeType: opts.mimeType,
100
+ etag,
101
+ checksum,
102
+ size,
103
+ metadata: opts.metadata ?? {},
104
+ modifiedAt: new Date().toISOString(),
105
+ };
106
+ await fs.writeFile(abs + META_SUFFIX, JSON.stringify(meta, null, 2), 'utf8');
107
+ return { ref, size, etag, checksum };
108
+ }
109
+ async get(ref) {
110
+ const abs = this.resolveSafe(ref);
111
+ const meta = await this.readMeta(abs).catch((e) => {
112
+ if (e.code === 'ENOENT') {
113
+ throw new StorageError('not_found', `Object not found: ${ref.bucket}/${ref.path}`);
114
+ }
115
+ throw e;
116
+ });
117
+ const nodeStream = fsSync.createReadStream(abs);
118
+ // Conversion Node Readable → Web ReadableStream
119
+ const stream = Readable.toWeb(nodeStream);
120
+ return {
121
+ stream,
122
+ size: meta.size,
123
+ mimeType: meta.mimeType,
124
+ etag: meta.etag,
125
+ metadata: meta.metadata,
126
+ };
127
+ }
128
+ async delete(ref) {
129
+ const abs = this.resolveSafe(ref);
130
+ await fs.unlink(abs).catch((e) => {
131
+ const code = e.code;
132
+ if (code === 'ENOENT')
133
+ return; // idempotent
134
+ throw new StorageError('driver_error', `FS unlink failed: ${e.message}`);
135
+ });
136
+ await fs.unlink(abs + META_SUFFIX).catch(() => { });
137
+ }
138
+ async exists(ref) {
139
+ const abs = this.resolveSafe(ref);
140
+ try {
141
+ await fs.access(abs);
142
+ return true;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ async stat(ref) {
149
+ const abs = this.resolveSafe(ref);
150
+ let st;
151
+ try {
152
+ st = await fs.stat(abs);
153
+ }
154
+ catch (e) {
155
+ if (e.code === 'ENOENT') {
156
+ throw new StorageError('not_found', `Object not found: ${ref.bucket}/${ref.path}`);
157
+ }
158
+ throw e;
159
+ }
160
+ const meta = await this.readMeta(abs).catch(() => null);
161
+ return {
162
+ size: st.size,
163
+ mimeType: meta?.mimeType ?? 'application/octet-stream',
164
+ etag: meta?.etag ?? '',
165
+ metadata: meta?.metadata,
166
+ modifiedAt: st.mtime,
167
+ };
168
+ }
169
+ async list(args) {
170
+ sanitizeSegment(args.bucket, 'bucket');
171
+ const bucketDir = path.resolve(this.rootResolved, args.bucket);
172
+ const limit = Math.max(1, Math.min(args.limit ?? 100, 1000));
173
+ const items = [];
174
+ async function walk(dir, relPath = '') {
175
+ let entries;
176
+ try {
177
+ entries = await fs.readdir(dir, { withFileTypes: true });
178
+ }
179
+ catch (e) {
180
+ if (e.code === 'ENOENT')
181
+ return;
182
+ throw e;
183
+ }
184
+ entries.sort((a, b) => a.name.localeCompare(b.name));
185
+ for (const entry of entries) {
186
+ const full = path.join(dir, entry.name);
187
+ const rel = relPath ? path.join(relPath, entry.name) : entry.name;
188
+ if (entry.isDirectory()) {
189
+ await walk(full, rel);
190
+ }
191
+ else if (entry.isFile() && !entry.name.endsWith(META_SUFFIX)) {
192
+ if (args.prefix && !rel.startsWith(args.prefix))
193
+ continue;
194
+ if (args.cursor && rel <= args.cursor)
195
+ continue;
196
+ if (items.length >= limit)
197
+ return;
198
+ const st = await fs.stat(full);
199
+ items.push({ path: rel, size: st.size, modifiedAt: st.mtime });
200
+ }
201
+ }
202
+ }
203
+ await walk(bucketDir);
204
+ const nextCursor = items.length === limit ? items[items.length - 1].path : undefined;
205
+ return { items, nextCursor };
206
+ }
207
+ async readMeta(abs) {
208
+ const raw = await fs.readFile(abs + META_SUFFIX, 'utf8');
209
+ return JSON.parse(raw);
210
+ }
211
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @mostajs/blob-store — Ranger des octets. Rien d'autre.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ * Niveau : N0 (primitif) · ZÉRO dépendance
6
+ *
7
+ * Contrat `StorageDriver` (put/get/delete/exists/stat/list) + pilotes.
8
+ *
9
+ * POURQUOI CE MODULE EXISTE
10
+ * Ce code vivait dans `@mostajs/storage`. Il y était déjà pur — aucun import d'ORM, de RBAC
11
+ * ni de data-plug — mais PRISONNIER : `@mostajs/storage` déclare `@mostajs/orm`,
12
+ * `@mostajs/data-plug` et `@mostajs/config` en dépendances. Importer le seul driver traînait
13
+ * donc tout l'ORM derrière. Impossible d'obtenir « juste des octets ».
14
+ *
15
+ * Extrait ici, il devient une brique réellement réutilisable :
16
+ * - `@mostajs/storage` → octets + métadonnées en base + RBAC (ré-exporte d'ici)
17
+ * - `@mostajs/archive-box` → octets d'une archive
18
+ * - `@mostajs/backup` → octets d'une sauvegarde (locale ou externalisée)
19
+ *
20
+ * Le module ignore délibérément ce que les octets représentent. C'est ce qui le rend malléable.
21
+ */
22
+ export type { ObjectRef, PutObjectResult } from './types.js';
23
+ export { StorageError, STORAGE_ERROR_HTTP } from './types.js';
24
+ export type { StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult, } from './driver.js';
25
+ export { FilesystemDriver } from './drivers/filesystem.js';
26
+ export { CachingDriver } from './drivers/caching.js';
27
+ export declare const moduleInfo: {
28
+ readonly name: "@mostajs/blob-store";
29
+ readonly version: "0.1.0";
30
+ readonly level: "N0";
31
+ };
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @mostajs/blob-store — Ranger des octets. Rien d'autre.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ * Niveau : N0 (primitif) · ZÉRO dépendance
6
+ *
7
+ * Contrat `StorageDriver` (put/get/delete/exists/stat/list) + pilotes.
8
+ *
9
+ * POURQUOI CE MODULE EXISTE
10
+ * Ce code vivait dans `@mostajs/storage`. Il y était déjà pur — aucun import d'ORM, de RBAC
11
+ * ni de data-plug — mais PRISONNIER : `@mostajs/storage` déclare `@mostajs/orm`,
12
+ * `@mostajs/data-plug` et `@mostajs/config` en dépendances. Importer le seul driver traînait
13
+ * donc tout l'ORM derrière. Impossible d'obtenir « juste des octets ».
14
+ *
15
+ * Extrait ici, il devient une brique réellement réutilisable :
16
+ * - `@mostajs/storage` → octets + métadonnées en base + RBAC (ré-exporte d'ici)
17
+ * - `@mostajs/archive-box` → octets d'une archive
18
+ * - `@mostajs/backup` → octets d'une sauvegarde (locale ou externalisée)
19
+ *
20
+ * Le module ignore délibérément ce que les octets représentent. C'est ce qui le rend malléable.
21
+ */
22
+ export { StorageError, STORAGE_ERROR_HTTP } from './types.js';
23
+ export { FilesystemDriver } from './drivers/filesystem.js';
24
+ export { CachingDriver } from './drivers/caching.js';
25
+ export const moduleInfo = { name: '@mostajs/blob-store', version: '0.1.0', level: 'N0' };
@@ -0,0 +1,29 @@
1
+ /** Pointeur vers un objet stocké. */
2
+ export interface ObjectRef {
3
+ bucket: string;
4
+ path: string;
5
+ }
6
+ /** Forme retournée à l'upload : taille + ETag/checksum côté driver. */
7
+ export interface PutObjectResult {
8
+ ref: ObjectRef;
9
+ size: number;
10
+ /** ETag tel que retourné par le driver (FS = sha256 hex tronqué, S3 = vrai ETag). */
11
+ etag: string;
12
+ /** sha256 complet hex. Calculé par défaut côté driver FS ; configurable côté S3. */
13
+ checksum: string;
14
+ }
15
+ /**
16
+ * Erreur typée du module (jamais nues — facilite le mapping HTTP côté appelant).
17
+ *
18
+ * Le nom `StorageError` et la liste complète des codes sont CONSERVÉS tels quels : c'est
19
+ * ce qui permet à @mostajs/storage de les ré-exporter sans casser un seul consommateur.
20
+ * Deux codes (`tenant_violation`, `signature_*`) relèvent de la couche au-dessus et non de
21
+ * l'octet ; les garder ici est le prix — assumé — d'une extraction NON CASSANTE.
22
+ */
23
+ export declare class StorageError extends Error {
24
+ code: 'not_found' | 'access_denied' | 'invalid_path' | 'invalid_mime' | 'too_large' | 'tenant_violation' | 'signature_invalid' | 'signature_expired' | 'driver_error';
25
+ details?: Record<string, unknown> | undefined;
26
+ constructor(code: 'not_found' | 'access_denied' | 'invalid_path' | 'invalid_mime' | 'too_large' | 'tenant_violation' | 'signature_invalid' | 'signature_expired' | 'driver_error', message: string, details?: Record<string, unknown> | undefined);
27
+ }
28
+ /** Mapping HTTP des codes `StorageError`. */
29
+ export declare const STORAGE_ERROR_HTTP: Record<StorageError['code'], number>;
package/dist/types.js ADDED
@@ -0,0 +1,44 @@
1
+ // @mostajs/blob-store — Types niveau OCTET
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ // Licence : AGPL-3.0-or-later
4
+ //
5
+ // EXTRAIT de @mostajs/storage (0.2.0) — types transplantés À L'IDENTIQUE, sans retouche.
6
+ //
7
+ // Ce module ne connaît QUE des octets : ranger, relire, lister, supprimer. Il ignore
8
+ // délibérément ce qu'ils représentent. C'est ce qui le rend réutilisable partout —
9
+ // @mostajs/storage (métadonnées en base + RBAC), @mostajs/archive-box (archives) et
10
+ // @mostajs/backup s'en servent sans qu'aucun n'impose ses dépendances aux autres.
11
+ //
12
+ // NB : `FileMeta` (la row File en base, avec son `accountId` d'isolation multi-tenant)
13
+ // N'EST PAS ici. C'est une métadonnée applicative, pas un octet : elle reste dans
14
+ // @mostajs/storage, avec l'ORM et le RBAC qui vont avec.
15
+ /**
16
+ * Erreur typée du module (jamais nues — facilite le mapping HTTP côté appelant).
17
+ *
18
+ * Le nom `StorageError` et la liste complète des codes sont CONSERVÉS tels quels : c'est
19
+ * ce qui permet à @mostajs/storage de les ré-exporter sans casser un seul consommateur.
20
+ * Deux codes (`tenant_violation`, `signature_*`) relèvent de la couche au-dessus et non de
21
+ * l'octet ; les garder ici est le prix — assumé — d'une extraction NON CASSANTE.
22
+ */
23
+ export class StorageError extends Error {
24
+ code;
25
+ details;
26
+ constructor(code, message, details) {
27
+ super(message);
28
+ this.code = code;
29
+ this.details = details;
30
+ this.name = 'StorageError';
31
+ }
32
+ }
33
+ /** Mapping HTTP des codes `StorageError`. */
34
+ export const STORAGE_ERROR_HTTP = {
35
+ not_found: 404,
36
+ access_denied: 403,
37
+ invalid_path: 400,
38
+ invalid_mime: 415,
39
+ too_large: 413,
40
+ tenant_violation: 403,
41
+ signature_invalid: 401,
42
+ signature_expired: 410,
43
+ driver_error: 502,
44
+ };
package/llms.txt ADDED
@@ -0,0 +1,35 @@
1
+ # @mostajs/blob-store — 0.1.0
2
+ Auteur : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later · Niveau N0 · ZÉRO dépendance
3
+
4
+ RÔLE
5
+ Ranger des octets. Contrat StorageDriver + pilotes. Ignore ce que les octets représentent.
6
+ EXTRAIT de @mostajs/storage (qui le ré-exporte — extraction non cassante, 110/110 tests verts).
7
+
8
+ API
9
+ import { FilesystemDriver, CachingDriver, StorageError, STORAGE_ERROR_HTTP } from '@mostajs/blob-store'
10
+ sous-chemins : /driver · /types · /drivers/filesystem · /drivers/caching
11
+
12
+ StorageDriver
13
+ put(ref, body, opts) -> { ref, size, etag, checksum } opts: { mimeType, metadata?, size?, ifNotExists? }
14
+ get(ref) -> { stream, size, mimeType, etag, metadata? }
15
+ delete(ref) -> void
16
+ exists(ref) -> boolean
17
+ stat(ref) -> { size, mimeType, etag, metadata? }
18
+ list({bucket,prefix?,limit?,cursor?}) -> { items: [{path,size,modifiedAt}], nextCursor? }
19
+
20
+ ObjectRef { bucket, path }
21
+ PutObjectResult { ref, size, etag, checksum } checksum = sha256 hex
22
+ StorageError code: not_found | access_denied | invalid_path | invalid_mime | too_large
23
+ | tenant_violation | signature_invalid | signature_expired | driver_error
24
+ FilesystemDriver new FilesystemDriver({ rootDir }) — refuse '..' (path traversal)
25
+ CachingDriver décorateur de cache devant un driver d'origine
26
+
27
+ CE QUI N'EST PAS ICI
28
+ FileMeta (row File en base, accountId multi-tenant) reste dans @mostajs/storage : c'est une
29
+ métadonnée applicative, pas un octet. La séparation est le point du module.
30
+
31
+ CONSOMMATEURS
32
+ @mostajs/storage (octets + ORM + RBAC) · @mostajs/archive-box · @mostajs/backup
33
+
34
+ TESTS
35
+ npm test 10 tests mjs-unit sur un vrai répertoire temporaire (sha256 vérifié indépendamment)
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@mostajs/blob-store",
3
+ "version": "0.1.0",
4
+ "description": "Ranger des octets — contrat StorageDriver (put/get/delete/exists/stat/list) + pilotes filesystem et caching. Primitive N0, zéro dépendance. Extrait de @mostajs/storage.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./driver": {
15
+ "types": "./dist/driver.d.ts",
16
+ "import": "./dist/driver.js",
17
+ "default": "./dist/driver.js"
18
+ },
19
+ "./types": {
20
+ "types": "./dist/types.d.ts",
21
+ "import": "./dist/types.js",
22
+ "default": "./dist/types.js"
23
+ },
24
+ "./drivers/filesystem": {
25
+ "types": "./dist/drivers/filesystem.d.ts",
26
+ "import": "./dist/drivers/filesystem.js",
27
+ "default": "./dist/drivers/filesystem.js"
28
+ },
29
+ "./drivers/caching": {
30
+ "types": "./dist/drivers/caching.d.ts",
31
+ "import": "./dist/drivers/caching.js",
32
+ "default": "./dist/drivers/caching.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "llms.txt",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "build": "tsc",
43
+ "test": "bash test-scripts/run-tests.sh",
44
+ "prepublishOnly": "npm run build"
45
+ },
46
+ "keywords": [
47
+ "storage",
48
+ "object-storage",
49
+ "blob",
50
+ "driver",
51
+ "filesystem",
52
+ "s3",
53
+ "zero-dep",
54
+ "mostajs"
55
+ ],
56
+ "devDependencies": {
57
+ "@mostajs/mjs-unit": "^0.3.1",
58
+ "@types/node": "^20.19.43",
59
+ "typescript": "^5.6.0"
60
+ }
61
+ }