@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.
- package/dist/cjs/index.js +395 -0
- package/dist/cjs/package.json +1 -0
- package/dist/index.d.ts +64 -1
- package/dist/index.js +66 -1
- package/package.json +12 -8
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Sauvegarder, vérifier, restaurer — pour de vrai.
|
|
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.
|
|
37
|
-
"@mostajs/blob-store": "^0.
|
|
38
|
-
"@mostajs/crypto-box": "^0.2.
|
|
39
|
-
"@mostajs/orm-copy-data": "^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"
|