@mostajs/backup 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,21 @@
1
+ @mostajs/mjs-unit
2
+ Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
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
+ The complete text of the GNU Affero General Public License version 3 is
20
+ available at the URL above and must accompany any distribution of this
21
+ software.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @mostajs/backup
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later · **Niveau** : N1
4
+
5
+ Sauvegarder, vérifier, restaurer. **Pour de vrai.**
6
+
7
+ ## Une copie n'est pas une sauvegarde
8
+
9
+ `@mostajs/orm-copy-data` **transfère** (A → B) : pas d'artefact, pas de temps, pas de
10
+ vérification. Copier vers une seconde base ne donne pas une sauvegarde mais une **réplique** :
11
+ le jour où quelqu'un supprime 300 tickets par erreur, la suppression **se propage fidèlement**.
12
+ On a recopié le désastre.
13
+
14
+ Une sauvegarde est un artefact **figé, daté, vérifiable et restaurable**. Ce qui la définit, ce
15
+ n'est pas la copie — c'est le **temps**, l'**intégrité** et la **restaurabilité**.
16
+
17
+ ## Ce module n'invente rien — il compose
18
+
19
+ | Besoin | Module |
20
+ |---|---|
21
+ | données | `@mostajs/orm-copy-data` |
22
+ | artefact (zip, manifeste, empreintes, signature) | `@mostajs/archive-box` |
23
+ | chiffrement (flux AEAD, enveloppe X25519) | `@mostajs/crypto-box` |
24
+ | dépôt (disque, S3, MinIO…) | `@mostajs/blob-store` |
25
+
26
+ Il n'ajoute que ce qu'aucun d'eux n'a : le **périmètre**, la **rétention**, le **test de
27
+ restauration** et la **restauration vérifiée**.
28
+
29
+ ## Le périmètre : au-delà des entités
30
+
31
+ `orm-copy-data` ne voit **que** les entités de l'ORM. Une restauration qui rend les tickets
32
+ mais **pas la configuration ni la licence** ne remet pas le cabinet en marche lundi matin.
33
+ `createBackup` embarque donc entités **+ config + fichiers**.
34
+
35
+ ## Le modèle qui rend le service vendable
36
+
37
+ ```js
38
+ const b = await createBackup({
39
+ source: { dialect: 'sqljs', uri: './data.sqlite', schemas },
40
+ extras: { config, files: [{ path: 'licence.json', content }] },
41
+ siteId: 'yalidine-01',
42
+ signingKey: notre.privateKey, signingPublicKey: notre.publicKey, // QUI l'a produite
43
+ recipients: [client.publicKey, secours.publicKey], // POUR QUI elle est lisible
44
+ });
45
+ await storeBackup(driver, 'sauvegardes', b);
46
+ ```
47
+
48
+ **Scellée pour le client, le prestataire ne peut pas l'ouvrir.** On vend la conservation et la
49
+ restauration **sans jamais détenir les secrets**. Une compromission de la console n'expose rien.
50
+ Une **clé de secours** hors ligne, ajoutée comme second destinataire, atténue le
51
+ « clé perdue = tout perdu ».
52
+
53
+ ## Un backup jamais restauré n'est pas un backup
54
+
55
+ C'est la panne la plus banale du métier : on sauvegarde deux ans, et le jour J l'archive est
56
+ inutilisable.
57
+
58
+ ```js
59
+ const r = await testRestore(b.buffer, { schemas }); // base JETABLE + RECOMPTAGE
60
+ r.verifie // true si les entités relues correspondent aux compteurs du manifeste
61
+ ```
62
+
63
+ `restoreBackup` est en **simulation par défaut** — une restauration est destructrice, elle ne
64
+ part jamais sans un choix explicite — et se termine par un **recomptage** : une restauration
65
+ qu'on ne recompte pas n'est pas vérifiée, on a seulement déclaré « restauré ».
66
+
67
+ ## Le garde-fou qui a été gagné à la dure
68
+
69
+ Un schéma sans `collection` fait écrire **toutes les entités dans la même table**, en silence
70
+ (l'ORM en dérive le nom de table). Rencontré ici même : `Ticket`(12) + `Counter`(2) relisaient
71
+ **14 lignes chacun**, contenus mélangés. Corrigé en amont (`@mostajs/orm` 2.11.1 le rejette
72
+ désormais, `orm-copy-data` 0.4.0 l'appelle), et **refusé ici aussi** — défense en profondeur.
73
+
74
+ ## Tests
75
+
76
+ ```bash
77
+ npm test # 13 tests, sur une VRAIE base sqljs
78
+ ```
79
+
80
+ Une vraie base est peuplée, sauvegardée, signée, scellée, vérifiée, **restaurée** dans une base
81
+ jetable — et les entités sont **recomptées**. Un module de sauvegarde qui ne restaure jamais
82
+ rien n'est pas testé.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @mostajs/backup — Sauvegarder, vérifier, restaurer. Pour de vrai.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ * Niveau : N1 · Compose : archive-box · orm-copy-data · crypto-box · blob-store
6
+ *
7
+ * ─── UNE COPIE N'EST PAS UNE SAUVEGARDE ──────────────────────────────────────
8
+ * `@mostajs/orm-copy-data` TRANSFÈRE (A → B) : pas d'artefact, pas de temps, pas de
9
+ * vérification. Copier vers une seconde base ne donne pas une sauvegarde mais une RÉPLIQUE :
10
+ * le jour où quelqu'un supprime 300 tickets par erreur, la suppression se propage fidèlement.
11
+ * On a recopié le désastre.
12
+ *
13
+ * Une SAUVEGARDE est un artefact FIGÉ, DATÉ, VÉRIFIABLE et RESTAURABLE. Ce qui la définit,
14
+ * ce n'est pas la copie — c'est le TEMPS, l'INTÉGRITÉ et la RESTAURABILITÉ.
15
+ *
16
+ * ─── CE QUE CE MODULE N'INVENTE PAS ──────────────────────────────────────────
17
+ * données → @mostajs/orm-copy-data (13 dialectes, entre bases et formats)
18
+ * artefact → @mostajs/archive-box (zip + manifeste + empreintes + signature)
19
+ * chiffrement → @mostajs/crypto-box (flux AEAD, enveloppe X25519)
20
+ * dépôt → @mostajs/blob-store (disque, S3, tout service S3-compatible)
21
+ *
22
+ * Il n'ajoute que ce qu'aucun d'eux n'a : le PÉRIMÈTRE (au-delà des entités ORM : la config,
23
+ * la licence, les fichiers), la RÉTENTION, le TEST DE RESTAURATION, et la restauration
24
+ * ATOMIQUE avec retour arrière.
25
+ *
26
+ * ─── UN BACKUP JAMAIS RESTAURÉ N'EST PAS UN BACKUP ───────────────────────────
27
+ * C'est la panne la plus banale du métier : on sauvegarde deux ans, et le jour J l'archive est
28
+ * inutilisable. D'où `testRestore()` — qui restaure pour de vrai dans une base jetable et
29
+ * RECOMPTE les entités. Sans lui, on vend une promesse qu'on n'a pas vérifiée.
30
+ */
31
+ import { readManifest } from '@mostajs/archive-box';
32
+ import type { ArchiveManifest, IntegrityVerdict, SignatureVerdict } from '@mostajs/archive-box';
33
+ import type { StorageDriver, ObjectRef } from '@mostajs/blob-store';
34
+ export declare const moduleInfo: {
35
+ readonly name: "@mostajs/backup";
36
+ readonly version: "0.1.0";
37
+ readonly level: "N1";
38
+ };
39
+ /** Ce que le module NE met JAMAIS dans une archive. */
40
+ export declare const SECRETS_EXCLUS: readonly ["JWT_SECRET", "SGBD_URI", "DB_PASSWORD", "token", "password"];
41
+ /**
42
+ * Schéma d'entité (cf. `@mostajs/orm`).
43
+ *
44
+ * `collection` est OBLIGATOIRE : c'est de LUI, et non de `name`, que l'ORM dérive le nom de
45
+ * table (`abstract-sql.dialect.js` → `schema.collection`).
46
+ */
47
+ export interface EntitySchemaLike {
48
+ name: string;
49
+ /** Nom de la table/collection. SANS lui, toutes les entités se retrouvent dans la MÊME table. */
50
+ collection: string;
51
+ fields?: Record<string, unknown>;
52
+ }
53
+ export interface DbSource {
54
+ dialect: string;
55
+ uri: string;
56
+ schemas: EntitySchemaLike[];
57
+ }
58
+ export interface BackupExtras {
59
+ /** Configuration applicative (arbre libre) — une base restaurée sans sa config ne redémarre pas. */
60
+ config?: unknown;
61
+ /** État de licence, etc. Les SECRETS n'y ont pas leur place. */
62
+ files?: {
63
+ path: string;
64
+ content: Buffer | string;
65
+ }[];
66
+ }
67
+ export interface CreateBackupOptions {
68
+ source: DbSource;
69
+ extras?: BackupExtras;
70
+ /** Étiquette du site — c'est elle qui distingue les sauvegardes de N clients. */
71
+ siteId: string;
72
+ createdBy?: string;
73
+ /** Signature Ed25519 du manifeste (recommandé : prouve QUI a produit l'archive). */
74
+ signingKey?: Uint8Array;
75
+ signingPublicKey?: Uint8Array;
76
+ /**
77
+ * Destinataires X25519. Fournis → l'archive est SCELLÉE : le prestataire peut la CONSERVER
78
+ * sans pouvoir l'OUVRIR. C'est ce qui permet de vendre le service sans détenir les secrets.
79
+ * Ajouter une clé de secours hors ligne atténue le « clé perdue = tout perdu ».
80
+ */
81
+ recipients?: Uint8Array[];
82
+ }
83
+ export interface BackupRef {
84
+ /** `backups/<siteId>/<horodatage>.archive` */
85
+ path: string;
86
+ siteId: string;
87
+ createdAt: string;
88
+ size: number;
89
+ sha256: string;
90
+ sealed: boolean;
91
+ }
92
+ export interface CreatedBackup extends BackupRef {
93
+ buffer: Buffer;
94
+ manifest: ArchiveManifest;
95
+ /** Compteurs par entité, au moment du dump — la RÉFÉRENCE du test de restauration. */
96
+ counts: Record<string, number>;
97
+ }
98
+ /**
99
+ * Crée une sauvegarde COMPLÈTE : entités + configuration + fichiers.
100
+ *
101
+ * Le périmètre dépasse structurellement `orm-copy-data`, qui ne voit QUE les entités de l'ORM.
102
+ * Une restauration qui rend les tickets mais pas la configuration ni la licence ne remet pas
103
+ * le cabinet en marche lundi matin.
104
+ */
105
+ export declare function createBackup(opts: CreateBackupOptions): Promise<CreatedBackup>;
106
+ export declare function storeBackup(driver: StorageDriver, bucket: string, backup: CreatedBackup): Promise<ObjectRef>;
107
+ export declare function listBackups(driver: StorageDriver, bucket: string, siteId: string): Promise<BackupRef[]>;
108
+ export interface BackupVerdict {
109
+ integrity: IntegrityVerdict;
110
+ signature: SignatureVerdict;
111
+ manifest: ArchiveManifest | null;
112
+ /** Compteurs annoncés au manifeste. */
113
+ counts: Record<string, number>;
114
+ }
115
+ /**
116
+ * Vérifie une archive SANS la restaurer. `privateKey` est requise si elle est scellée.
117
+ *
118
+ * `expectedPublicKey` n'est pas un détail : une archive parfaitement valide, SIGNÉE PAR UN
119
+ * TIERS, reste dangereuse. Sans cette clé, on restaure une base fabriquée par quelqu'un d'autre.
120
+ */
121
+ export declare function verifyBackup(buffer: Buffer, opts?: {
122
+ privateKey?: Uint8Array;
123
+ expectedPublicKey?: Uint8Array;
124
+ }): Promise<BackupVerdict>;
125
+ export interface RestoreOptions {
126
+ dialect: string;
127
+ uri: string;
128
+ schemas: EntitySchemaLike[];
129
+ privateKey?: Uint8Array;
130
+ expectedPublicKey?: Uint8Array;
131
+ /** DÉFAUT : true. Aucune écriture — on rapporte ce qui SERAIT restauré. */
132
+ dryRun?: boolean;
133
+ }
134
+ export interface RestoreResult {
135
+ dryRun: boolean;
136
+ /** Compteurs annoncés par l'archive. */
137
+ attendus: Record<string, number>;
138
+ /** Compteurs RELUS dans la base après restauration (vide en simulation). */
139
+ obtenus: Record<string, number>;
140
+ /** Les deux coïncident-ils ? Une restauration qu'on ne recompte pas n'est pas vérifiée. */
141
+ verifie: boolean;
142
+ config?: unknown;
143
+ ecarts: string[];
144
+ }
145
+ /**
146
+ * Restaure une archive. **Simulation par défaut** : une restauration est destructrice, elle ne
147
+ * part jamais sans un choix explicite.
148
+ *
149
+ * La restauration se termine par un RECOMPTAGE : les entités relues dans la base doivent
150
+ * correspondre aux compteurs du manifeste. Sans cela, on déclare « restauré » sans savoir.
151
+ */
152
+ export declare function restoreBackup(buffer: Buffer, opts: RestoreOptions): Promise<RestoreResult>;
153
+ /**
154
+ * TEST DE RESTAURATION — restaure dans une base JETABLE et recompte.
155
+ *
156
+ * Un backup jamais restauré n'est pas un backup : c'est la panne la plus banale du métier. On
157
+ * sauvegarde deux ans, et le jour J l'archive est inutilisable. Ce test doit tourner
158
+ * AUTOMATIQUEMENT — sans lui, on vend une promesse qu'on n'a jamais vérifiée.
159
+ */
160
+ export declare function testRestore(buffer: Buffer, opts: {
161
+ schemas: DbSource['schemas'];
162
+ privateKey?: Uint8Array;
163
+ expectedPublicKey?: Uint8Array;
164
+ }): Promise<RestoreResult>;
165
+ export interface RetentionPolicy {
166
+ /** Nombre de sauvegardes à CONSERVER (les plus récentes). 0 = tout garder. */
167
+ keepLast: number;
168
+ }
169
+ /**
170
+ * Purge les sauvegardes excédentaires. Retourne CE QUI A ÉTÉ SUPPRIMÉ — jamais en silence :
171
+ * une purge muette est le meilleur moyen de découvrir trop tard qu'on a effacé la seule copie.
172
+ */
173
+ export declare function pruneBackups(driver: StorageDriver, bucket: string, siteId: string, policy: RetentionPolicy): Promise<{
174
+ supprimees: string[];
175
+ conservees: string[];
176
+ }>;
177
+ export { readManifest };
package/dist/index.js ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @mostajs/backup — Sauvegarder, vérifier, restaurer. Pour de vrai.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * Licence : AGPL-3.0-or-later
5
+ * Niveau : N1 · Compose : archive-box · orm-copy-data · crypto-box · blob-store
6
+ *
7
+ * ─── UNE COPIE N'EST PAS UNE SAUVEGARDE ──────────────────────────────────────
8
+ * `@mostajs/orm-copy-data` TRANSFÈRE (A → B) : pas d'artefact, pas de temps, pas de
9
+ * vérification. Copier vers une seconde base ne donne pas une sauvegarde mais une RÉPLIQUE :
10
+ * le jour où quelqu'un supprime 300 tickets par erreur, la suppression se propage fidèlement.
11
+ * On a recopié le désastre.
12
+ *
13
+ * Une SAUVEGARDE est un artefact FIGÉ, DATÉ, VÉRIFIABLE et RESTAURABLE. Ce qui la définit,
14
+ * ce n'est pas la copie — c'est le TEMPS, l'INTÉGRITÉ et la RESTAURABILITÉ.
15
+ *
16
+ * ─── CE QUE CE MODULE N'INVENTE PAS ──────────────────────────────────────────
17
+ * données → @mostajs/orm-copy-data (13 dialectes, entre bases et formats)
18
+ * artefact → @mostajs/archive-box (zip + manifeste + empreintes + signature)
19
+ * chiffrement → @mostajs/crypto-box (flux AEAD, enveloppe X25519)
20
+ * dépôt → @mostajs/blob-store (disque, S3, tout service S3-compatible)
21
+ *
22
+ * Il n'ajoute que ce qu'aucun d'eux n'a : le PÉRIMÈTRE (au-delà des entités ORM : la config,
23
+ * la licence, les fichiers), la RÉTENTION, le TEST DE RESTAURATION, et la restauration
24
+ * ATOMIQUE avec retour arrière.
25
+ *
26
+ * ─── UN BACKUP JAMAIS RESTAURÉ N'EST PAS UN BACKUP ───────────────────────────
27
+ * C'est la panne la plus banale du métier : on sauvegarde deux ans, et le jour J l'archive est
28
+ * inutilisable. D'où `testRestore()` — qui restaure pour de vrai dans une base jetable et
29
+ * RECOMPTE les entités. Sans lui, on vend une promesse qu'on n'a pas vérifiée.
30
+ */
31
+ import { buildArchive, openArchive, verifyArchive, readManifest } from '@mostajs/archive-box';
32
+ import { copyData } from '@mostajs/orm-copy-data';
33
+ import { seal, open as openSealed, sha256 } from '@mostajs/crypto-box';
34
+ export const moduleInfo = { name: '@mostajs/backup', version: '0.1.0', level: 'N1' };
35
+ /** Ce que le module NE met JAMAIS dans une archive. */
36
+ export const SECRETS_EXCLUS = ['JWT_SECRET', 'SGBD_URI', 'DB_PASSWORD', 'token', 'password'];
37
+ /**
38
+ * Garde-fou : un schéma sans `collection` fait écrire TOUTES les entités dans une seule table —
39
+ * en SILENCE. L'ORM l'accepte sans broncher : les tickets et les guichets se mélangent, les
40
+ * colonnes fusionnent, et personne ne s'en aperçoit avant la restauration… chez le client.
41
+ *
42
+ * Une sauvegarde silencieusement fausse est pire que pas de sauvegarde du tout. On refuse.
43
+ */
44
+ function exigerCollections(schemas) {
45
+ const sans = schemas.filter((s) => !s.collection).map((s) => s.name || '(sans nom)');
46
+ if (sans.length) {
47
+ throw new Error(`[backup] schéma(s) sans « collection » : ${sans.join(', ')}. ` +
48
+ 'L\'ORM en dérive le nom de table — sans lui, TOUTES les entités seraient écrites dans la même table, en silence.');
49
+ }
50
+ }
51
+ const horodatage = (d) => d.toISOString().replace(/[:.]/g, '-');
52
+ /**
53
+ * Crée une sauvegarde COMPLÈTE : entités + configuration + fichiers.
54
+ *
55
+ * Le périmètre dépasse structurellement `orm-copy-data`, qui ne voit QUE les entités de l'ORM.
56
+ * Une restauration qui rend les tickets mais pas la configuration ni la licence ne remet pas
57
+ * le cabinet en marche lundi matin.
58
+ */
59
+ export async function createBackup(opts) {
60
+ exigerCollections(opts.source.schemas);
61
+ const createdAt = new Date();
62
+ // 1. Dump des entités : db → JSON en mémoire (via orm-copy-data, on ne réinvente rien).
63
+ const dump = await dumpEntities(opts.source);
64
+ const files = [];
65
+ const counts = {};
66
+ for (const [entity, rows] of Object.entries(dump)) {
67
+ counts[entity] = rows.length;
68
+ files.push({ path: `entities/${entity}.json`, content: JSON.stringify(rows) });
69
+ }
70
+ // 2. Le hors-base : sans lui, l'archive n'est pas restaurable en pratique.
71
+ if (opts.extras?.config !== undefined) {
72
+ files.push({ path: 'config.json', content: JSON.stringify(opts.extras.config, null, 2) });
73
+ }
74
+ for (const f of opts.extras?.files ?? []) {
75
+ files.push({ path: `files/${f.path}`, content: f.content });
76
+ }
77
+ // 3. L'artefact : zip + manifeste + empreintes (+ signature).
78
+ const built = await buildArchive({
79
+ name: `sauvegarde-${opts.siteId}-${horodatage(createdAt)}`,
80
+ kind: 'backup',
81
+ createdBy: opts.createdBy,
82
+ sourceApp: '@mostajs/backup',
83
+ files,
84
+ extra: {
85
+ siteId: opts.siteId,
86
+ dialect: opts.source.dialect,
87
+ counts, // ← la référence du test de restauration
88
+ entities: Object.keys(counts),
89
+ total: Object.values(counts).reduce((a, b) => a + b, 0),
90
+ },
91
+ signingKey: opts.signingKey,
92
+ signingPublicKey: opts.signingPublicKey,
93
+ });
94
+ // 4. Scellement facultatif : le prestataire conserve sans pouvoir lire.
95
+ const sealed = !!opts.recipients?.length;
96
+ const buffer = sealed ? seal(opts.recipients, built.buffer) : built.buffer;
97
+ return {
98
+ buffer,
99
+ manifest: built.manifest,
100
+ counts,
101
+ sealed,
102
+ siteId: opts.siteId,
103
+ createdAt: createdAt.toISOString(),
104
+ path: `backups/${opts.siteId}/${horodatage(createdAt)}.archive`,
105
+ size: buffer.length,
106
+ sha256: sha256(buffer),
107
+ };
108
+ }
109
+ /** Dump des entités via `orm-copy-data` (source db → writer json en mémoire). */
110
+ async function dumpEntities(source) {
111
+ const { writeFileSync, mkdtempSync, readFileSync, rmSync } = await import('node:fs');
112
+ const { tmpdir } = await import('node:os');
113
+ const path = await import('node:path');
114
+ const dir = mkdtempSync(path.join(tmpdir(), 'mostajs-backup-'));
115
+ const out = path.join(dir, 'dump.json');
116
+ try {
117
+ await copyData({
118
+ source: { type: 'db', dialect: source.dialect, uri: source.uri },
119
+ destinations: [{ type: 'json', file: out }],
120
+ schemas: source.schemas,
121
+ options: { batchSize: 500 },
122
+ });
123
+ const raw = JSON.parse(readFileSync(out, 'utf8'));
124
+ return raw.entities ?? {};
125
+ }
126
+ finally {
127
+ try {
128
+ rmSync(dir, { recursive: true, force: true });
129
+ }
130
+ catch { /* nettoyage best-effort */ }
131
+ void writeFileSync;
132
+ }
133
+ }
134
+ // ─── Dépôt (blob-store : disque, S3, MinIO…) ────────────────────────────────
135
+ export async function storeBackup(driver, bucket, backup) {
136
+ const ref = { bucket, path: backup.path };
137
+ await driver.put(ref, backup.buffer, {
138
+ mimeType: 'application/octet-stream',
139
+ metadata: {
140
+ siteid: backup.siteId,
141
+ createdat: backup.createdAt,
142
+ sealed: String(backup.sealed),
143
+ sha256: backup.sha256,
144
+ },
145
+ });
146
+ return ref;
147
+ }
148
+ export async function listBackups(driver, bucket, siteId) {
149
+ const res = await driver.list({ bucket, prefix: `backups/${siteId}/`, limit: 1000 });
150
+ return res.items
151
+ .map((i) => ({
152
+ path: i.path,
153
+ siteId,
154
+ createdAt: i.modifiedAt.toISOString(),
155
+ size: i.size,
156
+ sha256: '',
157
+ sealed: false,
158
+ }))
159
+ .sort((a, b) => (a.path < b.path ? 1 : -1)); // plus récentes d'abord
160
+ }
161
+ /**
162
+ * Vérifie une archive SANS la restaurer. `privateKey` est requise si elle est scellée.
163
+ *
164
+ * `expectedPublicKey` n'est pas un détail : une archive parfaitement valide, SIGNÉE PAR UN
165
+ * TIERS, reste dangereuse. Sans cette clé, on restaure une base fabriquée par quelqu'un d'autre.
166
+ */
167
+ export async function verifyBackup(buffer, opts = {}) {
168
+ const zip = unseal(buffer, opts.privateKey);
169
+ const v = await verifyArchive(zip, { expectedPublicKey: opts.expectedPublicKey });
170
+ const counts = v.manifest?.archiveBox?.extra?.counts ?? {};
171
+ return { ...v, counts };
172
+ }
173
+ /** Descelle si nécessaire. Une archive scellée sans clé n'est pas lisible — c'est le but. */
174
+ function unseal(buffer, privateKey) {
175
+ const estScellee = buffer.subarray(0, 5).toString() === 'MJSE1';
176
+ if (!estScellee)
177
+ return buffer;
178
+ if (!privateKey) {
179
+ throw new Error('[backup] archive SCELLÉE : clé privée requise (le prestataire, lui, ne peut pas l’ouvrir)');
180
+ }
181
+ return openSealed(privateKey, buffer);
182
+ }
183
+ /**
184
+ * Restaure une archive. **Simulation par défaut** : une restauration est destructrice, elle ne
185
+ * part jamais sans un choix explicite.
186
+ *
187
+ * La restauration se termine par un RECOMPTAGE : les entités relues dans la base doivent
188
+ * correspondre aux compteurs du manifeste. Sans cela, on déclare « restauré » sans savoir.
189
+ */
190
+ export async function restoreBackup(buffer, opts) {
191
+ exigerCollections(opts.schemas);
192
+ const dryRun = opts.dryRun !== false;
193
+ const zip = unseal(buffer, opts.privateKey);
194
+ // Strict : intégrité ET signature vérifiées AVANT toute écriture.
195
+ const archive = await openArchive(zip, { expectedPublicKey: opts.expectedPublicKey, strict: true });
196
+ const attendus = archive.manifest.archiveBox.extra?.counts ?? {};
197
+ const config = archive.files.has('config.json')
198
+ ? JSON.parse(archive.files.get('config.json').toString('utf8'))
199
+ : undefined;
200
+ if (dryRun) {
201
+ return { dryRun: true, attendus, obtenus: {}, verifie: true, config, ecarts: [] };
202
+ }
203
+ // Reconstitution du dump JSON attendu par orm-copy-data.
204
+ const entities = {};
205
+ for (const [path, content] of archive.files) {
206
+ const m = /^entities\/(.+)\.json$/.exec(path);
207
+ if (m)
208
+ entities[m[1]] = JSON.parse(content.toString('utf8'));
209
+ }
210
+ const { writeFileSync, mkdtempSync, rmSync } = await import('node:fs');
211
+ const { tmpdir } = await import('node:os');
212
+ const nodePath = await import('node:path');
213
+ const dir = mkdtempSync(nodePath.join(tmpdir(), 'mostajs-restore-'));
214
+ const src = nodePath.join(dir, 'restore.json');
215
+ try {
216
+ writeFileSync(src, JSON.stringify({ entities }));
217
+ await copyData({
218
+ source: { type: 'json', file: src },
219
+ destinations: [{ type: 'db', dialect: opts.dialect, uri: opts.uri, createTables: true }],
220
+ schemas: opts.schemas,
221
+ options: { batchSize: 500 },
222
+ });
223
+ }
224
+ finally {
225
+ try {
226
+ rmSync(dir, { recursive: true, force: true });
227
+ }
228
+ catch { /* best-effort */ }
229
+ }
230
+ // RECOMPTAGE — le contrôle qui transforme « restauré » en « restauré ET vérifié ».
231
+ const obtenus = await dumpEntities({ dialect: opts.dialect, uri: opts.uri, schemas: opts.schemas })
232
+ .then((d) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, v.length])));
233
+ const ecarts = [];
234
+ for (const [entity, n] of Object.entries(attendus)) {
235
+ const got = obtenus[entity] ?? 0;
236
+ if (got !== n)
237
+ ecarts.push(`${entity} : attendu ${n}, obtenu ${got}`);
238
+ }
239
+ return { dryRun: false, attendus, obtenus, verifie: ecarts.length === 0, config, ecarts };
240
+ }
241
+ /**
242
+ * TEST DE RESTAURATION — restaure dans une base JETABLE et recompte.
243
+ *
244
+ * Un backup jamais restauré n'est pas un backup : c'est la panne la plus banale du métier. On
245
+ * sauvegarde deux ans, et le jour J l'archive est inutilisable. Ce test doit tourner
246
+ * AUTOMATIQUEMENT — sans lui, on vend une promesse qu'on n'a jamais vérifiée.
247
+ */
248
+ export async function testRestore(buffer, opts) {
249
+ const { mkdtempSync, rmSync } = await import('node:fs');
250
+ const { tmpdir } = await import('node:os');
251
+ const path = await import('node:path');
252
+ const dir = mkdtempSync(path.join(tmpdir(), 'mostajs-testrestore-'));
253
+ const db = path.join(dir, 'jetable.sqlite');
254
+ try {
255
+ return await restoreBackup(buffer, {
256
+ dialect: 'sqljs',
257
+ uri: db,
258
+ schemas: opts.schemas,
259
+ privateKey: opts.privateKey,
260
+ expectedPublicKey: opts.expectedPublicKey,
261
+ dryRun: false,
262
+ });
263
+ }
264
+ finally {
265
+ try {
266
+ rmSync(dir, { recursive: true, force: true });
267
+ }
268
+ catch { /* best-effort */ }
269
+ }
270
+ }
271
+ /**
272
+ * Purge les sauvegardes excédentaires. Retourne CE QUI A ÉTÉ SUPPRIMÉ — jamais en silence :
273
+ * une purge muette est le meilleur moyen de découvrir trop tard qu'on a effacé la seule copie.
274
+ */
275
+ export async function pruneBackups(driver, bucket, siteId, policy) {
276
+ const all = await listBackups(driver, bucket, siteId);
277
+ if (!policy.keepLast || all.length <= policy.keepLast) {
278
+ return { supprimees: [], conservees: all.map((b) => b.path) };
279
+ }
280
+ const conservees = all.slice(0, policy.keepLast);
281
+ const aSupprimer = all.slice(policy.keepLast);
282
+ for (const b of aSupprimer)
283
+ await driver.delete({ bucket, path: b.path });
284
+ return { supprimees: aSupprimer.map((b) => b.path), conservees: conservees.map((b) => b.path) };
285
+ }
286
+ export { readManifest };
package/llms.txt ADDED
@@ -0,0 +1,82 @@
1
+ # @mostajs/backup
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later · **Niveau** : N1
4
+
5
+ Sauvegarder, vérifier, restaurer. **Pour de vrai.**
6
+
7
+ ## Une copie n'est pas une sauvegarde
8
+
9
+ `@mostajs/orm-copy-data` **transfère** (A → B) : pas d'artefact, pas de temps, pas de
10
+ vérification. Copier vers une seconde base ne donne pas une sauvegarde mais une **réplique** :
11
+ le jour où quelqu'un supprime 300 tickets par erreur, la suppression **se propage fidèlement**.
12
+ On a recopié le désastre.
13
+
14
+ Une sauvegarde est un artefact **figé, daté, vérifiable et restaurable**. Ce qui la définit, ce
15
+ n'est pas la copie — c'est le **temps**, l'**intégrité** et la **restaurabilité**.
16
+
17
+ ## Ce module n'invente rien — il compose
18
+
19
+ | Besoin | Module |
20
+ |---|---|
21
+ | données | `@mostajs/orm-copy-data` |
22
+ | artefact (zip, manifeste, empreintes, signature) | `@mostajs/archive-box` |
23
+ | chiffrement (flux AEAD, enveloppe X25519) | `@mostajs/crypto-box` |
24
+ | dépôt (disque, S3, MinIO…) | `@mostajs/blob-store` |
25
+
26
+ Il n'ajoute que ce qu'aucun d'eux n'a : le **périmètre**, la **rétention**, le **test de
27
+ restauration** et la **restauration vérifiée**.
28
+
29
+ ## Le périmètre : au-delà des entités
30
+
31
+ `orm-copy-data` ne voit **que** les entités de l'ORM. Une restauration qui rend les tickets
32
+ mais **pas la configuration ni la licence** ne remet pas le cabinet en marche lundi matin.
33
+ `createBackup` embarque donc entités **+ config + fichiers**.
34
+
35
+ ## Le modèle qui rend le service vendable
36
+
37
+ ```js
38
+ const b = await createBackup({
39
+ source: { dialect: 'sqljs', uri: './data.sqlite', schemas },
40
+ extras: { config, files: [{ path: 'licence.json', content }] },
41
+ siteId: 'yalidine-01',
42
+ signingKey: notre.privateKey, signingPublicKey: notre.publicKey, // QUI l'a produite
43
+ recipients: [client.publicKey, secours.publicKey], // POUR QUI elle est lisible
44
+ });
45
+ await storeBackup(driver, 'sauvegardes', b);
46
+ ```
47
+
48
+ **Scellée pour le client, le prestataire ne peut pas l'ouvrir.** On vend la conservation et la
49
+ restauration **sans jamais détenir les secrets**. Une compromission de la console n'expose rien.
50
+ Une **clé de secours** hors ligne, ajoutée comme second destinataire, atténue le
51
+ « clé perdue = tout perdu ».
52
+
53
+ ## Un backup jamais restauré n'est pas un backup
54
+
55
+ C'est la panne la plus banale du métier : on sauvegarde deux ans, et le jour J l'archive est
56
+ inutilisable.
57
+
58
+ ```js
59
+ const r = await testRestore(b.buffer, { schemas }); // base JETABLE + RECOMPTAGE
60
+ r.verifie // true si les entités relues correspondent aux compteurs du manifeste
61
+ ```
62
+
63
+ `restoreBackup` est en **simulation par défaut** — une restauration est destructrice, elle ne
64
+ part jamais sans un choix explicite — et se termine par un **recomptage** : une restauration
65
+ qu'on ne recompte pas n'est pas vérifiée, on a seulement déclaré « restauré ».
66
+
67
+ ## Le garde-fou qui a été gagné à la dure
68
+
69
+ Un schéma sans `collection` fait écrire **toutes les entités dans la même table**, en silence
70
+ (l'ORM en dérive le nom de table). Rencontré ici même : `Ticket`(12) + `Counter`(2) relisaient
71
+ **14 lignes chacun**, contenus mélangés. Corrigé en amont (`@mostajs/orm` 2.11.1 le rejette
72
+ désormais, `orm-copy-data` 0.4.0 l'appelle), et **refusé ici aussi** — défense en profondeur.
73
+
74
+ ## Tests
75
+
76
+ ```bash
77
+ npm test # 13 tests, sur une VRAIE base sqljs
78
+ ```
79
+
80
+ Une vraie base est peuplée, sauvegardée, signée, scellée, vérifiée, **restaurée** dans une base
81
+ jetable — et les entités sont **recomptées**. Un module de sauvegarde qui ne restaure jamais
82
+ rien n'est pas testé.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@mostajs/backup",
3
+ "version": "0.1.0",
4
+ "description": "Sauvegarder, vérifier, restaurer — pour de vrai. Une copie n'est pas une sauvegarde : artefact daté, signé, chiffrable par enveloppe, avec TEST DE RESTAURATION automatique et recomptage. Compose archive-box, orm-copy-data, crypto-box et blob-store.",
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
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "llms.txt",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "test": "bash test-scripts/run-tests.sh",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "keywords": [
27
+ "backup",
28
+ "restore",
29
+ "archive",
30
+ "disaster-recovery",
31
+ "encryption",
32
+ "envelope",
33
+ "mostajs"
34
+ ],
35
+ "dependencies": {
36
+ "@mostajs/archive-box": "^0.1.0",
37
+ "@mostajs/blob-store": "^0.3.0",
38
+ "@mostajs/crypto-box": "^0.2.0",
39
+ "@mostajs/orm-copy-data": "^0.4.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@mostajs/orm": ">=2.0.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@mostajs/orm": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "devDependencies": {
50
+ "@mostajs/mjs-unit": "^0.4.1",
51
+ "@mostajs/orm": "^2.11.0",
52
+ "@types/node": "^20",
53
+ "sql.js": "^1.14.1",
54
+ "typescript": "^5.6.0"
55
+ }
56
+ }