@mostajs/backup 0.1.0 → 0.3.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.
@@ -0,0 +1,395 @@
1
+ "use strict";
2
+ /**
3
+ * @mostajs/backup — Sauvegarder, vérifier, restaurer. Pour de vrai.
4
+ * @author Dr Hamid MADANI <drmdh@msn.com>
5
+ * Licence : AGPL-3.0-or-later
6
+ * Niveau : N1 · Compose : archive-box · orm-copy-data · crypto-box · blob-store
7
+ *
8
+ * ─── UNE COPIE N'EST PAS UNE SAUVEGARDE ──────────────────────────────────────
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,
15
+ * ce n'est pas la copie — c'est le TEMPS, l'INTÉGRITÉ et la RESTAURABILITÉ.
16
+ *
17
+ * ─── CE QUE CE MODULE N'INVENTE PAS ──────────────────────────────────────────
18
+ * données → @mostajs/orm-copy-data (13 dialectes, entre bases et formats)
19
+ * artefact → @mostajs/archive-box (zip + manifeste + empreintes + signature)
20
+ * chiffrement → @mostajs/crypto-box (flux AEAD, enveloppe X25519)
21
+ * dépôt → @mostajs/blob-store (disque, S3, tout service S3-compatible)
22
+ *
23
+ * Il n'ajoute que ce qu'aucun d'eux n'a : le PÉRIMÈTRE (au-delà des entités ORM : la config,
24
+ * la licence, les fichiers), la RÉTENTION, le TEST DE RESTAURATION, et la restauration
25
+ * ATOMIQUE avec retour arrière.
26
+ *
27
+ * ─── UN BACKUP JAMAIS RESTAURÉ N'EST PAS UN BACKUP ───────────────────────────
28
+ * C'est la panne la plus banale du métier : on sauvegarde deux ans, et le jour J l'archive est
29
+ * inutilisable. D'où `testRestore()` — qui restaure pour de vrai dans une base jetable et
30
+ * RECOMPTE les entités. Sans lui, on vend une promesse qu'on n'a pas vérifiée.
31
+ */
32
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
33
+ if (k2 === undefined) k2 = k;
34
+ var desc = Object.getOwnPropertyDescriptor(m, k);
35
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
36
+ desc = { enumerable: true, get: function() { return m[k]; } };
37
+ }
38
+ Object.defineProperty(o, k2, desc);
39
+ }) : (function(o, m, k, k2) {
40
+ if (k2 === undefined) k2 = k;
41
+ o[k2] = m[k];
42
+ }));
43
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
44
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
45
+ }) : function(o, v) {
46
+ o["default"] = v;
47
+ });
48
+ var __importStar = (this && this.__importStar) || (function () {
49
+ var ownKeys = function(o) {
50
+ ownKeys = Object.getOwnPropertyNames || function (o) {
51
+ var ar = [];
52
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
53
+ return ar;
54
+ };
55
+ return ownKeys(o);
56
+ };
57
+ return function (mod) {
58
+ if (mod && mod.__esModule) return mod;
59
+ var result = {};
60
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
61
+ __setModuleDefault(result, mod);
62
+ return result;
63
+ };
64
+ })();
65
+ Object.defineProperty(exports, "__esModule", { value: true });
66
+ exports.readManifest = exports.SECRETS_EXCLUS = exports.moduleInfo = void 0;
67
+ exports.createBackup = createBackup;
68
+ exports.storeBackup = storeBackup;
69
+ exports.listBackups = listBackups;
70
+ exports.verifyBackup = verifyBackup;
71
+ exports.restoreBackup = restoreBackup;
72
+ exports.testRestore = testRestore;
73
+ exports.pruneBackups = pruneBackups;
74
+ exports.scheduleBackups = scheduleBackups;
75
+ const archive_box_1 = require("@mostajs/archive-box");
76
+ Object.defineProperty(exports, "readManifest", { enumerable: true, get: function () { return archive_box_1.readManifest; } });
77
+ const orm_copy_data_1 = require("@mostajs/orm-copy-data");
78
+ const crypto_box_1 = require("@mostajs/crypto-box");
79
+ const scheduler_1 = require("@mostajs/scheduler");
80
+ exports.moduleInfo = { name: '@mostajs/backup', version: '0.3.0', level: 'N1' };
81
+ /** Ce que le module NE met JAMAIS dans une archive. */
82
+ exports.SECRETS_EXCLUS = ['JWT_SECRET', 'SGBD_URI', 'DB_PASSWORD', 'token', 'password'];
83
+ /**
84
+ * Garde-fou : un schéma sans `collection` fait écrire TOUTES les entités dans une seule table —
85
+ * en SILENCE. L'ORM l'accepte sans broncher : les tickets et les guichets se mélangent, les
86
+ * colonnes fusionnent, et personne ne s'en aperçoit avant la restauration… chez le client.
87
+ *
88
+ * Une sauvegarde silencieusement fausse est pire que pas de sauvegarde du tout. On refuse.
89
+ */
90
+ function exigerCollections(schemas) {
91
+ const sans = schemas.filter((s) => !s.collection).map((s) => s.name || '(sans nom)');
92
+ if (sans.length) {
93
+ throw new Error(`[backup] schéma(s) sans « collection » : ${sans.join(', ')}. ` +
94
+ 'L\'ORM en dérive le nom de table — sans lui, TOUTES les entités seraient écrites dans la même table, en silence.');
95
+ }
96
+ }
97
+ const horodatage = (d) => d.toISOString().replace(/[:.]/g, '-');
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
+ async function createBackup(opts) {
106
+ exigerCollections(opts.source.schemas);
107
+ const createdAt = new Date();
108
+ // 1. Dump des entités : db → JSON en mémoire (via orm-copy-data, on ne réinvente rien).
109
+ const dump = await dumpEntities(opts.source);
110
+ const files = [];
111
+ const counts = {};
112
+ for (const [entity, rows] of Object.entries(dump)) {
113
+ counts[entity] = rows.length;
114
+ files.push({ path: `entities/${entity}.json`, content: JSON.stringify(rows) });
115
+ }
116
+ // 2. Le hors-base : sans lui, l'archive n'est pas restaurable en pratique.
117
+ if (opts.extras?.config !== undefined) {
118
+ files.push({ path: 'config.json', content: JSON.stringify(opts.extras.config, null, 2) });
119
+ }
120
+ for (const f of opts.extras?.files ?? []) {
121
+ files.push({ path: `files/${f.path}`, content: f.content });
122
+ }
123
+ // 3. L'artefact : zip + manifeste + empreintes (+ signature).
124
+ const built = await (0, archive_box_1.buildArchive)({
125
+ name: `sauvegarde-${opts.siteId}-${horodatage(createdAt)}`,
126
+ kind: 'backup',
127
+ createdBy: opts.createdBy,
128
+ sourceApp: '@mostajs/backup',
129
+ files,
130
+ extra: {
131
+ siteId: opts.siteId,
132
+ dialect: opts.source.dialect,
133
+ counts, // ← la référence du test de restauration
134
+ entities: Object.keys(counts),
135
+ total: Object.values(counts).reduce((a, b) => a + b, 0),
136
+ },
137
+ signingKey: opts.signingKey,
138
+ signingPublicKey: opts.signingPublicKey,
139
+ });
140
+ // 4. Scellement facultatif : le prestataire conserve sans pouvoir lire.
141
+ const sealed = !!opts.recipients?.length;
142
+ const buffer = sealed ? (0, crypto_box_1.seal)(opts.recipients, built.buffer) : built.buffer;
143
+ return {
144
+ buffer,
145
+ plainBuffer: built.buffer, // jamais déposé — sert au test de restauration
146
+ manifest: built.manifest,
147
+ counts,
148
+ sealed,
149
+ siteId: opts.siteId,
150
+ createdAt: createdAt.toISOString(),
151
+ path: `backups/${opts.siteId}/${horodatage(createdAt)}.archive`,
152
+ size: buffer.length,
153
+ sha256: (0, crypto_box_1.sha256)(buffer),
154
+ };
155
+ }
156
+ /** Dump des entités via `orm-copy-data` (source db → writer json en mémoire). */
157
+ async function dumpEntities(source) {
158
+ const { writeFileSync, mkdtempSync, readFileSync, rmSync } = await Promise.resolve().then(() => __importStar(require('node:fs')));
159
+ const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
160
+ const path = await Promise.resolve().then(() => __importStar(require('node:path')));
161
+ const dir = mkdtempSync(path.join(tmpdir(), 'mostajs-backup-'));
162
+ const out = path.join(dir, 'dump.json');
163
+ try {
164
+ await (0, orm_copy_data_1.copyData)({
165
+ source: { type: 'db', dialect: source.dialect, uri: source.uri },
166
+ destinations: [{ type: 'json', file: out }],
167
+ schemas: source.schemas,
168
+ options: { batchSize: 500 },
169
+ });
170
+ const raw = JSON.parse(readFileSync(out, 'utf8'));
171
+ return raw.entities ?? {};
172
+ }
173
+ finally {
174
+ try {
175
+ rmSync(dir, { recursive: true, force: true });
176
+ }
177
+ catch { /* nettoyage best-effort */ }
178
+ void writeFileSync;
179
+ }
180
+ }
181
+ // ─── Dépôt (blob-store : disque, S3, MinIO…) ────────────────────────────────
182
+ async function storeBackup(driver, bucket, backup) {
183
+ const ref = { bucket, path: backup.path };
184
+ await driver.put(ref, backup.buffer, {
185
+ mimeType: 'application/octet-stream',
186
+ metadata: {
187
+ siteid: backup.siteId,
188
+ createdat: backup.createdAt,
189
+ sealed: String(backup.sealed),
190
+ sha256: backup.sha256,
191
+ },
192
+ });
193
+ return ref;
194
+ }
195
+ async function listBackups(driver, bucket, siteId) {
196
+ const res = await driver.list({ bucket, prefix: `backups/${siteId}/`, limit: 1000 });
197
+ return res.items
198
+ .map((i) => ({
199
+ path: i.path,
200
+ siteId,
201
+ createdAt: i.modifiedAt.toISOString(),
202
+ size: i.size,
203
+ sha256: '',
204
+ sealed: false,
205
+ }))
206
+ .sort((a, b) => (a.path < b.path ? 1 : -1)); // plus récentes d'abord
207
+ }
208
+ /**
209
+ * Vérifie une archive SANS la restaurer. `privateKey` est requise si elle est scellée.
210
+ *
211
+ * `expectedPublicKey` n'est pas un détail : une archive parfaitement valide, SIGNÉE PAR UN
212
+ * TIERS, reste dangereuse. Sans cette clé, on restaure une base fabriquée par quelqu'un d'autre.
213
+ */
214
+ async function verifyBackup(buffer, opts = {}) {
215
+ const zip = unseal(buffer, opts.privateKey);
216
+ const v = await (0, archive_box_1.verifyArchive)(zip, { expectedPublicKey: opts.expectedPublicKey });
217
+ const counts = v.manifest?.archiveBox?.extra?.counts ?? {};
218
+ return { ...v, counts };
219
+ }
220
+ /** Descelle si nécessaire. Une archive scellée sans clé n'est pas lisible — c'est le but. */
221
+ function unseal(buffer, privateKey) {
222
+ const estScellee = buffer.subarray(0, 5).toString() === 'MJSE1';
223
+ if (!estScellee)
224
+ return buffer;
225
+ if (!privateKey) {
226
+ throw new Error('[backup] archive SCELLÉE : clé privée requise (le prestataire, lui, ne peut pas l’ouvrir)');
227
+ }
228
+ return (0, crypto_box_1.open)(privateKey, buffer);
229
+ }
230
+ /**
231
+ * Restaure une archive. **Simulation par défaut** : une restauration est destructrice, elle ne
232
+ * part jamais sans un choix explicite.
233
+ *
234
+ * La restauration se termine par un RECOMPTAGE : les entités relues dans la base doivent
235
+ * correspondre aux compteurs du manifeste. Sans cela, on déclare « restauré » sans savoir.
236
+ */
237
+ async function restoreBackup(buffer, opts) {
238
+ exigerCollections(opts.schemas);
239
+ const dryRun = opts.dryRun !== false;
240
+ const zip = unseal(buffer, opts.privateKey);
241
+ // Strict : intégrité ET signature vérifiées AVANT toute écriture.
242
+ const archive = await (0, archive_box_1.openArchive)(zip, { expectedPublicKey: opts.expectedPublicKey, strict: true });
243
+ const attendus = archive.manifest.archiveBox.extra?.counts ?? {};
244
+ const config = archive.files.has('config.json')
245
+ ? JSON.parse(archive.files.get('config.json').toString('utf8'))
246
+ : undefined;
247
+ if (dryRun) {
248
+ return { dryRun: true, attendus, obtenus: {}, verifie: true, config, ecarts: [] };
249
+ }
250
+ // Reconstitution du dump JSON attendu par orm-copy-data.
251
+ const entities = {};
252
+ for (const [path, content] of archive.files) {
253
+ const m = /^entities\/(.+)\.json$/.exec(path);
254
+ if (m)
255
+ entities[m[1]] = JSON.parse(content.toString('utf8'));
256
+ }
257
+ const { writeFileSync, mkdtempSync, rmSync } = await Promise.resolve().then(() => __importStar(require('node:fs')));
258
+ const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
259
+ const nodePath = await Promise.resolve().then(() => __importStar(require('node:path')));
260
+ const dir = mkdtempSync(nodePath.join(tmpdir(), 'mostajs-restore-'));
261
+ const src = nodePath.join(dir, 'restore.json');
262
+ try {
263
+ writeFileSync(src, JSON.stringify({ entities }));
264
+ await (0, orm_copy_data_1.copyData)({
265
+ source: { type: 'json', file: src },
266
+ destinations: [{ type: 'db', dialect: opts.dialect, uri: opts.uri, createTables: true }],
267
+ schemas: opts.schemas,
268
+ options: { batchSize: 500 },
269
+ });
270
+ }
271
+ finally {
272
+ try {
273
+ rmSync(dir, { recursive: true, force: true });
274
+ }
275
+ catch { /* best-effort */ }
276
+ }
277
+ // RECOMPTAGE — le contrôle qui transforme « restauré » en « restauré ET vérifié ».
278
+ const obtenus = await dumpEntities({ dialect: opts.dialect, uri: opts.uri, schemas: opts.schemas })
279
+ .then((d) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, v.length])));
280
+ const ecarts = [];
281
+ for (const [entity, n] of Object.entries(attendus)) {
282
+ const got = obtenus[entity] ?? 0;
283
+ if (got !== n)
284
+ ecarts.push(`${entity} : attendu ${n}, obtenu ${got}`);
285
+ }
286
+ return { dryRun: false, attendus, obtenus, verifie: ecarts.length === 0, config, ecarts };
287
+ }
288
+ /**
289
+ * TEST DE RESTAURATION — restaure dans une base JETABLE et recompte.
290
+ *
291
+ * Un backup jamais restauré n'est pas un backup : c'est la panne la plus banale du métier. On
292
+ * sauvegarde deux ans, et le jour J l'archive est inutilisable. Ce test doit tourner
293
+ * AUTOMATIQUEMENT — sans lui, on vend une promesse qu'on n'a jamais vérifiée.
294
+ */
295
+ async function testRestore(buffer, opts) {
296
+ const { mkdtempSync, rmSync } = await Promise.resolve().then(() => __importStar(require('node:fs')));
297
+ const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
298
+ const path = await Promise.resolve().then(() => __importStar(require('node:path')));
299
+ const dir = mkdtempSync(path.join(tmpdir(), 'mostajs-testrestore-'));
300
+ const db = path.join(dir, 'jetable.sqlite');
301
+ try {
302
+ return await restoreBackup(buffer, {
303
+ dialect: 'sqljs',
304
+ uri: db,
305
+ schemas: opts.schemas,
306
+ privateKey: opts.privateKey,
307
+ expectedPublicKey: opts.expectedPublicKey,
308
+ dryRun: false,
309
+ });
310
+ }
311
+ finally {
312
+ try {
313
+ rmSync(dir, { recursive: true, force: true });
314
+ }
315
+ catch { /* best-effort */ }
316
+ }
317
+ }
318
+ /**
319
+ * Purge les sauvegardes excédentaires. Retourne CE QUI A ÉTÉ SUPPRIMÉ — jamais en silence :
320
+ * une purge muette est le meilleur moyen de découvrir trop tard qu'on a effacé la seule copie.
321
+ */
322
+ async function pruneBackups(driver, bucket, siteId, policy) {
323
+ const all = await listBackups(driver, bucket, siteId);
324
+ if (!policy.keepLast || all.length <= policy.keepLast) {
325
+ return { supprimees: [], conservees: all.map((b) => b.path) };
326
+ }
327
+ const conservees = all.slice(0, policy.keepLast);
328
+ const aSupprimer = all.slice(policy.keepLast);
329
+ for (const b of aSupprimer)
330
+ await driver.delete({ bucket, path: b.path });
331
+ return { supprimees: aSupprimer.map((b) => b.path), conservees: conservees.map((b) => b.path) };
332
+ }
333
+ /**
334
+ * Planifie les sauvegardes. Compose `@mostajs/scheduler` — on ne réinvente ni la cadence, ni
335
+ * l'idempotence, ni l'adoption de la période au démarrage.
336
+ *
337
+ * ─── DEUX PIÈGES MORTELS, TRAITÉS ICI ────────────────────────────────────────
338
+ *
339
+ * 1. **La rétention qui purge après un ÉCHEC.** C'est la façon classique de tout perdre : la
340
+ * sauvegarde de ce soir échoue (base verrouillée, disque plein), la rotation s'exécute quand
341
+ * même, et elle supprime la plus ancienne des N archives valides. Répétez l'opération N nuits,
342
+ * et il ne reste RIEN — alors que chaque nuit « la sauvegarde a tourné ».
343
+ * → La purge n'a lieu **QUE** si la nouvelle sauvegarde est créée ET vérifiée.
344
+ *
345
+ * 2. **L'échec silencieux.** Un planificateur qui n'a personne pour écouter ses erreurs a l'air
346
+ * de fonctionner. On ne s'aperçoit de rien jusqu'au jour où l'on cherche une archive qui
347
+ * n'existe pas.
348
+ * → `onBackup` est notifié à CHAQUE cycle, succès **comme** échec. C'est le point de
349
+ * supervision : branchez-y une alerte, sinon vous ne saurez jamais.
350
+ */
351
+ function scheduleBackups(opts) {
352
+ const verifyRestore = opts.verifyRestore !== false;
353
+ return (0, scheduler_1.createScheduler)({
354
+ getSchedule: opts.getSchedule,
355
+ intervalMs: opts.intervalMs,
356
+ now: opts.now,
357
+ onTrigger: async () => {
358
+ let backup;
359
+ try {
360
+ backup = await createBackup(opts);
361
+ await storeBackup(opts.driver, opts.bucket, backup);
362
+ // Le test de restauration fait partie de la sauvegarde, pas d'une corvée séparée :
363
+ // une archive non testée n'est pas une archive vérifiée.
364
+ let restoreTest;
365
+ if (verifyRestore) {
366
+ // On teste l'archive AVANT scellement. Une archive scellée pour le client ne peut pas
367
+ // être ouverte par la console — c'est le point du modèle. Tester sur `buffer` exigerait
368
+ // donc la clé PRIVÉE du client, exactement ce qu'on refuse de détenir.
369
+ restoreTest = await testRestore(backup.plainBuffer, {
370
+ schemas: opts.source.schemas,
371
+ expectedPublicKey: opts.signingPublicKey,
372
+ });
373
+ if (!restoreTest.verifie) {
374
+ // On NE PURGE PAS : les anciennes archives sont peut-être tout ce qui reste de bon.
375
+ throw new Error(`[backup] sauvegarde créée mais NON RESTAURABLE — rétention suspendue. ${restoreTest.ecarts.join(' · ')}`);
376
+ }
377
+ }
378
+ // Purge — uniquement maintenant, la nouvelle sauvegarde étant créée ET vérifiée.
379
+ let pruned;
380
+ if (opts.retention) {
381
+ const r = await pruneBackups(opts.driver, opts.bucket, opts.siteId, opts.retention);
382
+ pruned = r.supprimees;
383
+ }
384
+ opts.onBackup?.({ ok: true, backup, restoreTest, pruned });
385
+ }
386
+ catch (error) {
387
+ // La rétention n'a PAS tourné : c'est le point. Un échec ne doit jamais faire disparaître
388
+ // une archive valide.
389
+ opts.onBackup?.({ ok: false, backup, error });
390
+ throw error; // remonte aussi à `onError` du planificateur
391
+ }
392
+ },
393
+ onError: (e) => { void e; /* déjà remonté par onBackup */ },
394
+ });
395
+ }
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
package/dist/index.d.ts CHANGED
@@ -31,9 +31,10 @@
31
31
  import { readManifest } from '@mostajs/archive-box';
32
32
  import type { ArchiveManifest, IntegrityVerdict, SignatureVerdict } from '@mostajs/archive-box';
33
33
  import type { StorageDriver, ObjectRef } from '@mostajs/blob-store';
34
+ import type { Schedule, Scheduler } from '@mostajs/scheduler';
34
35
  export declare const moduleInfo: {
35
36
  readonly name: "@mostajs/backup";
36
- readonly version: "0.1.0";
37
+ readonly version: "0.3.0";
37
38
  readonly level: "N1";
38
39
  };
39
40
  /** Ce que le module NE met JAMAIS dans une archive. */
@@ -90,7 +91,20 @@ export interface BackupRef {
90
91
  sealed: boolean;
91
92
  }
92
93
  export interface CreatedBackup extends BackupRef {
94
+ /** Archive telle qu'elle sera DÉPOSÉE (scellée si des destinataires sont fournis). */
93
95
  buffer: Buffer;
96
+ /**
97
+ * Archive AVANT scellement — **en mémoire uniquement, jamais déposée**.
98
+ *
99
+ * Elle existe pour une seule raison : permettre de TESTER la restauration sans posséder la
100
+ * clé privée du client. Sans elle, on serait devant une contradiction : une archive scellée
101
+ * pour le client ne peut pas être ouverte par la console — c'est tout l'intérêt du modèle —
102
+ * donc la console ne pourrait jamais vérifier qu'elle est restaurable. On testerait à
103
+ * l'aveugle, ou pas du tout.
104
+ *
105
+ * Le processus de sauvegarde a le clair en mémoire de toute façon : on teste AVANT de sceller.
106
+ */
107
+ plainBuffer: Buffer;
94
108
  manifest: ArchiveManifest;
95
109
  /** Compteurs par entité, au moment du dump — la RÉFÉRENCE du test de restauration. */
96
110
  counts: Record<string, number>;
@@ -174,4 +188,53 @@ export declare function pruneBackups(driver: StorageDriver, bucket: string, site
174
188
  supprimees: string[];
175
189
  conservees: string[];
176
190
  }>;
191
+ export interface ScheduledBackupOptions extends Omit<CreateBackupOptions, 'source'> {
192
+ source: DbSource;
193
+ driver: StorageDriver;
194
+ bucket: string;
195
+ /** Planification — RELUE à chaque tick (changement d'heure sans redémarrage). */
196
+ getSchedule: () => Schedule | Promise<Schedule>;
197
+ /** Rétention. Appliquée SEULEMENT après une sauvegarde réussie (voir ci-dessous). */
198
+ retention?: RetentionPolicy;
199
+ /**
200
+ * Tester la restauration après chaque sauvegarde. DÉFAUT : **true**.
201
+ *
202
+ * Une sauvegarde planifiée qui n'est jamais restaurée est un rituel, pas une protection : on
203
+ * accumule deux ans d'archives, et le jour J elles sont inutilisables. Le test restaure dans
204
+ * une base JETABLE et RECOMPTE — il coûte du temps machine, il vaut ce qu'il coûte.
205
+ */
206
+ verifyRestore?: boolean;
207
+ /** Notifié à chaque cycle — c'est LE point de supervision. */
208
+ onBackup?: (info: {
209
+ ok: boolean;
210
+ backup?: CreatedBackup;
211
+ restoreTest?: RestoreResult;
212
+ pruned?: string[];
213
+ error?: unknown;
214
+ }) => void;
215
+ /** Cadence d'évaluation (ms). Défaut 60 000. */
216
+ intervalMs?: number;
217
+ /** Horloge injectable (tests). */
218
+ now?: () => Date;
219
+ }
220
+ /**
221
+ * Planifie les sauvegardes. Compose `@mostajs/scheduler` — on ne réinvente ni la cadence, ni
222
+ * l'idempotence, ni l'adoption de la période au démarrage.
223
+ *
224
+ * ─── DEUX PIÈGES MORTELS, TRAITÉS ICI ────────────────────────────────────────
225
+ *
226
+ * 1. **La rétention qui purge après un ÉCHEC.** C'est la façon classique de tout perdre : la
227
+ * sauvegarde de ce soir échoue (base verrouillée, disque plein), la rotation s'exécute quand
228
+ * même, et elle supprime la plus ancienne des N archives valides. Répétez l'opération N nuits,
229
+ * et il ne reste RIEN — alors que chaque nuit « la sauvegarde a tourné ».
230
+ * → La purge n'a lieu **QUE** si la nouvelle sauvegarde est créée ET vérifiée.
231
+ *
232
+ * 2. **L'échec silencieux.** Un planificateur qui n'a personne pour écouter ses erreurs a l'air
233
+ * de fonctionner. On ne s'aperçoit de rien jusqu'au jour où l'on cherche une archive qui
234
+ * n'existe pas.
235
+ * → `onBackup` est notifié à CHAQUE cycle, succès **comme** échec. C'est le point de
236
+ * supervision : branchez-y une alerte, sinon vous ne saurez jamais.
237
+ */
238
+ export declare function scheduleBackups(opts: ScheduledBackupOptions): Scheduler;
177
239
  export { readManifest };
240
+ export type { Schedule, Scheduler } from '@mostajs/scheduler';
package/dist/index.js CHANGED
@@ -31,7 +31,8 @@
31
31
  import { buildArchive, openArchive, verifyArchive, readManifest } from '@mostajs/archive-box';
32
32
  import { copyData } from '@mostajs/orm-copy-data';
33
33
  import { seal, open as openSealed, sha256 } from '@mostajs/crypto-box';
34
- export const moduleInfo = { name: '@mostajs/backup', version: '0.1.0', level: 'N1' };
34
+ import { createScheduler } from '@mostajs/scheduler';
35
+ export const moduleInfo = { name: '@mostajs/backup', version: '0.3.0', level: 'N1' };
35
36
  /** Ce que le module NE met JAMAIS dans une archive. */
36
37
  export const SECRETS_EXCLUS = ['JWT_SECRET', 'SGBD_URI', 'DB_PASSWORD', 'token', 'password'];
37
38
  /**
@@ -96,6 +97,7 @@ export async function createBackup(opts) {
96
97
  const buffer = sealed ? seal(opts.recipients, built.buffer) : built.buffer;
97
98
  return {
98
99
  buffer,
100
+ plainBuffer: built.buffer, // jamais déposé — sert au test de restauration
99
101
  manifest: built.manifest,
100
102
  counts,
101
103
  sealed,
@@ -283,4 +285,67 @@ export async function pruneBackups(driver, bucket, siteId, policy) {
283
285
  await driver.delete({ bucket, path: b.path });
284
286
  return { supprimees: aSupprimer.map((b) => b.path), conservees: conservees.map((b) => b.path) };
285
287
  }
288
+ /**
289
+ * Planifie les sauvegardes. Compose `@mostajs/scheduler` — on ne réinvente ni la cadence, ni
290
+ * l'idempotence, ni l'adoption de la période au démarrage.
291
+ *
292
+ * ─── DEUX PIÈGES MORTELS, TRAITÉS ICI ────────────────────────────────────────
293
+ *
294
+ * 1. **La rétention qui purge après un ÉCHEC.** C'est la façon classique de tout perdre : la
295
+ * sauvegarde de ce soir échoue (base verrouillée, disque plein), la rotation s'exécute quand
296
+ * même, et elle supprime la plus ancienne des N archives valides. Répétez l'opération N nuits,
297
+ * et il ne reste RIEN — alors que chaque nuit « la sauvegarde a tourné ».
298
+ * → La purge n'a lieu **QUE** si la nouvelle sauvegarde est créée ET vérifiée.
299
+ *
300
+ * 2. **L'échec silencieux.** Un planificateur qui n'a personne pour écouter ses erreurs a l'air
301
+ * de fonctionner. On ne s'aperçoit de rien jusqu'au jour où l'on cherche une archive qui
302
+ * n'existe pas.
303
+ * → `onBackup` est notifié à CHAQUE cycle, succès **comme** échec. C'est le point de
304
+ * supervision : branchez-y une alerte, sinon vous ne saurez jamais.
305
+ */
306
+ export function scheduleBackups(opts) {
307
+ const verifyRestore = opts.verifyRestore !== false;
308
+ return createScheduler({
309
+ getSchedule: opts.getSchedule,
310
+ intervalMs: opts.intervalMs,
311
+ now: opts.now,
312
+ onTrigger: async () => {
313
+ let backup;
314
+ try {
315
+ backup = await createBackup(opts);
316
+ await storeBackup(opts.driver, opts.bucket, backup);
317
+ // Le test de restauration fait partie de la sauvegarde, pas d'une corvée séparée :
318
+ // une archive non testée n'est pas une archive vérifiée.
319
+ let restoreTest;
320
+ if (verifyRestore) {
321
+ // On teste l'archive AVANT scellement. Une archive scellée pour le client ne peut pas
322
+ // être ouverte par la console — c'est le point du modèle. Tester sur `buffer` exigerait
323
+ // donc la clé PRIVÉE du client, exactement ce qu'on refuse de détenir.
324
+ restoreTest = await testRestore(backup.plainBuffer, {
325
+ schemas: opts.source.schemas,
326
+ expectedPublicKey: opts.signingPublicKey,
327
+ });
328
+ if (!restoreTest.verifie) {
329
+ // On NE PURGE PAS : les anciennes archives sont peut-être tout ce qui reste de bon.
330
+ throw new Error(`[backup] sauvegarde créée mais NON RESTAURABLE — rétention suspendue. ${restoreTest.ecarts.join(' · ')}`);
331
+ }
332
+ }
333
+ // Purge — uniquement maintenant, la nouvelle sauvegarde étant créée ET vérifiée.
334
+ let pruned;
335
+ if (opts.retention) {
336
+ const r = await pruneBackups(opts.driver, opts.bucket, opts.siteId, opts.retention);
337
+ pruned = r.supprimees;
338
+ }
339
+ opts.onBackup?.({ ok: true, backup, restoreTest, pruned });
340
+ }
341
+ catch (error) {
342
+ // La rétention n'a PAS tourné : c'est le point. Un échec ne doit jamais faire disparaître
343
+ // une archive valide.
344
+ opts.onBackup?.({ ok: false, backup, error });
345
+ throw error; // remonte aussi à `onError` du planificateur
346
+ }
347
+ },
348
+ onError: (e) => { void e; /* déjà remonté par onBackup */ },
349
+ });
350
+ }
286
351
  export { readManifest };
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
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.",
3
+ "version": "0.3.0",
4
+ "description": "Sauvegarder, vérifier, restaurer — pour de vrai. Artefact daté, signé, chiffrable par enveloppe. Sauvegardes PLANIFIÉES avec test de restauration automatique et rétention qui ne purge JAMAIS après un échec. Compose archive-box, orm-copy-data, crypto-box, blob-store et scheduler.",
5
+ "type": "module",
6
+ "main": "./dist/cjs/index.js",
7
+ "types": "./dist/index.d.ts",
5
8
  "license": "AGPL-3.0-or-later",
6
9
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
- "type": "module",
8
10
  "exports": {
9
11
  ".": {
10
12
  "types": "./dist/index.d.ts",
11
13
  "import": "./dist/index.js",
14
+ "require": "./dist/cjs/index.js",
12
15
  "default": "./dist/index.js"
13
16
  }
14
17
  },
@@ -19,7 +22,7 @@
19
22
  "LICENSE"
20
23
  ],
21
24
  "scripts": {
22
- "build": "tsc",
25
+ "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
23
26
  "test": "bash test-scripts/run-tests.sh",
24
27
  "prepublishOnly": "npm run build"
25
28
  },
@@ -33,10 +36,11 @@
33
36
  "mostajs"
34
37
  ],
35
38
  "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"
39
+ "@mostajs/archive-box": "^0.2.0",
40
+ "@mostajs/blob-store": "^0.4.0",
41
+ "@mostajs/crypto-box": "^0.2.1",
42
+ "@mostajs/orm-copy-data": "^0.5.0",
43
+ "@mostajs/scheduler": "^0.2.0"
40
44
  },
41
45
  "peerDependencies": {
42
46
  "@mostajs/orm": ">=2.0.0"