@trieb.work/payload-plugin-backup-mongodb 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -0
- package/dist/backup-dashboard.css +1132 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.js +3 -0
- package/dist/client.js.map +1 -0
- package/dist/collections/BackupSettings.d.ts +6 -0
- package/dist/collections/BackupSettings.js +91 -0
- package/dist/collections/BackupSettings.js.map +1 -0
- package/dist/collections/BackupTasks.d.ts +2 -0
- package/dist/collections/BackupTasks.js +56 -0
- package/dist/collections/BackupTasks.js.map +1 -0
- package/dist/components/BackupDashboard/BackupItemActions.client.d.ts +7 -0
- package/dist/components/BackupDashboard/BackupItemActions.client.js +90 -0
- package/dist/components/BackupDashboard/BackupItemActions.client.js.map +1 -0
- package/dist/components/BackupDashboard/BackupListCollapsible.client.d.ts +16 -0
- package/dist/components/BackupDashboard/BackupListCollapsible.client.js +597 -0
- package/dist/components/BackupDashboard/BackupListCollapsible.client.js.map +1 -0
- package/dist/components/BackupDashboard/BackupSettingsModal.client.d.ts +2 -0
- package/dist/components/BackupDashboard/BackupSettingsModal.client.js +939 -0
- package/dist/components/BackupDashboard/BackupSettingsModal.client.js.map +1 -0
- package/dist/components/BackupDashboard/CollectionBackupPreviewBody.client.d.ts +18 -0
- package/dist/components/BackupDashboard/CollectionBackupPreviewBody.client.js +267 -0
- package/dist/components/BackupDashboard/CollectionBackupPreviewBody.client.js.map +1 -0
- package/dist/components/BackupDashboard/ManualBackupDialog.client.d.ts +1 -0
- package/dist/components/BackupDashboard/ManualBackupDialog.client.js +276 -0
- package/dist/components/BackupDashboard/ManualBackupDialog.client.js.map +1 -0
- package/dist/components/BackupDashboard/RestoreBackupDialog.client.d.ts +6 -0
- package/dist/components/BackupDashboard/RestoreBackupDialog.client.js +551 -0
- package/dist/components/BackupDashboard/RestoreBackupDialog.client.js.map +1 -0
- package/dist/components/BackupDashboard/TaskActionButton.client.d.ts +29 -0
- package/dist/components/BackupDashboard/TaskActionButton.client.js +240 -0
- package/dist/components/BackupDashboard/TaskActionButton.client.js.map +1 -0
- package/dist/components/BackupDashboard/backupDashboardInlineCss.d.ts +1 -0
- package/dist/components/BackupDashboard/backupDashboardInlineCss.js +3 -0
- package/dist/components/BackupDashboard/backupDashboardInlineCss.js.map +1 -0
- package/dist/components/BackupDashboard/index.client.d.ts +8 -0
- package/dist/components/BackupDashboard/index.client.js +9 -0
- package/dist/components/BackupDashboard/index.client.js.map +1 -0
- package/dist/components/BackupDashboard/index.d.ts +8 -0
- package/dist/components/BackupDashboard/index.js +196 -0
- package/dist/components/BackupDashboard/index.js.map +1 -0
- package/dist/core/archive.d.ts +8 -0
- package/dist/core/archive.js +47 -0
- package/dist/core/archive.js.map +1 -0
- package/dist/core/backup.d.ts +41 -0
- package/dist/core/backup.js +188 -0
- package/dist/core/backup.js.map +1 -0
- package/dist/core/backupBlobIO.d.ts +54 -0
- package/dist/core/backupBlobIO.js +179 -0
- package/dist/core/backupBlobIO.js.map +1 -0
- package/dist/core/backupBlobTransfer.d.ts +29 -0
- package/dist/core/backupBlobTransfer.js +85 -0
- package/dist/core/backupBlobTransfer.js.map +1 -0
- package/dist/core/backupSettings.d.ts +35 -0
- package/dist/core/backupSettings.js +95 -0
- package/dist/core/backupSettings.js.map +1 -0
- package/dist/core/backupSourcePreview.d.ts +10 -0
- package/dist/core/backupSourcePreview.js +33 -0
- package/dist/core/backupSourcePreview.js.map +1 -0
- package/dist/core/blobTokenValidate.d.ts +14 -0
- package/dist/core/blobTokenValidate.js +75 -0
- package/dist/core/blobTokenValidate.js.map +1 -0
- package/dist/core/db.d.ts +24 -0
- package/dist/core/db.js +12 -0
- package/dist/core/db.js.map +1 -0
- package/dist/core/restore.d.ts +27 -0
- package/dist/core/restore.js +199 -0
- package/dist/core/restore.js.map +1 -0
- package/dist/core/restorePreview.d.ts +64 -0
- package/dist/core/restorePreview.js +266 -0
- package/dist/core/restorePreview.js.map +1 -0
- package/dist/core/taskProgress.d.ts +26 -0
- package/dist/core/taskProgress.js +73 -0
- package/dist/core/taskProgress.js.map +1 -0
- package/dist/core/vercelBackupCron.d.ts +8 -0
- package/dist/core/vercelBackupCron.js +38 -0
- package/dist/core/vercelBackupCron.js.map +1 -0
- package/dist/endpoints/createBackupEndpoints.d.ts +1 -0
- package/dist/endpoints/createBackupEndpoints.js +3 -0
- package/dist/endpoints/createBackupEndpoints.js.map +1 -0
- package/dist/endpoints/index.d.ts +3 -0
- package/dist/endpoints/index.js +34 -0
- package/dist/endpoints/index.js.map +1 -0
- package/dist/endpoints/paths/admin-backup-download.d.ts +8 -0
- package/dist/endpoints/paths/admin-backup-download.js +72 -0
- package/dist/endpoints/paths/admin-backup-download.js.map +1 -0
- package/dist/endpoints/paths/admin-delete.d.ts +3 -0
- package/dist/endpoints/paths/admin-delete.js +55 -0
- package/dist/endpoints/paths/admin-delete.js.map +1 -0
- package/dist/endpoints/paths/admin-manual.d.ts +3 -0
- package/dist/endpoints/paths/admin-manual.js +61 -0
- package/dist/endpoints/paths/admin-manual.js.map +1 -0
- package/dist/endpoints/paths/admin-preview.d.ts +6 -0
- package/dist/endpoints/paths/admin-preview.js +102 -0
- package/dist/endpoints/paths/admin-preview.js.map +1 -0
- package/dist/endpoints/paths/admin-restore.d.ts +3 -0
- package/dist/endpoints/paths/admin-restore.js +74 -0
- package/dist/endpoints/paths/admin-restore.js.map +1 -0
- package/dist/endpoints/paths/admin-seed.d.ts +3 -0
- package/dist/endpoints/paths/admin-seed.js +47 -0
- package/dist/endpoints/paths/admin-seed.js.map +1 -0
- package/dist/endpoints/paths/admin-settings.d.ts +3 -0
- package/dist/endpoints/paths/admin-settings.js +230 -0
- package/dist/endpoints/paths/admin-settings.js.map +1 -0
- package/dist/endpoints/paths/admin-task.d.ts +3 -0
- package/dist/endpoints/paths/admin-task.js +34 -0
- package/dist/endpoints/paths/admin-task.js.map +1 -0
- package/dist/endpoints/paths/admin-validate-blob-token.d.ts +8 -0
- package/dist/endpoints/paths/admin-validate-blob-token.js +26 -0
- package/dist/endpoints/paths/admin-validate-blob-token.js.map +1 -0
- package/dist/endpoints/paths/cron-list.d.ts +2 -0
- package/dist/endpoints/paths/cron-list.js +33 -0
- package/dist/endpoints/paths/cron-list.js.map +1 -0
- package/dist/endpoints/paths/cron-restore.d.ts +2 -0
- package/dist/endpoints/paths/cron-restore.js +61 -0
- package/dist/endpoints/paths/cron-restore.js.map +1 -0
- package/dist/endpoints/paths/cron-run.d.ts +3 -0
- package/dist/endpoints/paths/cron-run.js +45 -0
- package/dist/endpoints/paths/cron-run.js.map +1 -0
- package/dist/endpoints/shared.d.ts +13 -0
- package/dist/endpoints/shared.js +58 -0
- package/dist/endpoints/shared.js.map +1 -0
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.js +3 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/exports/rsc.js +3 -0
- package/dist/exports/rsc.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +82 -0
- package/dist/plugin.js.map +1 -0
- package/dist/publicApiPaths.d.ts +19 -0
- package/dist/publicApiPaths.js +20 -0
- package/dist/publicApiPaths.js.map +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/backupSelection.d.ts +6 -0
- package/dist/utils/backupSelection.js +16 -0
- package/dist/utils/backupSelection.js.map +1 -0
- package/dist/utils/blobName.d.ts +32 -0
- package/dist/utils/blobName.js +99 -0
- package/dist/utils/blobName.js.map +1 -0
- package/dist/utils/dashboardRoleAccess.d.ts +23 -0
- package/dist/utils/dashboardRoleAccess.js +64 -0
- package/dist/utils/dashboardRoleAccess.js.map +1 -0
- package/dist/utils/dialogBackdrop.d.ts +9 -0
- package/dist/utils/dialogBackdrop.js +17 -0
- package/dist/utils/dialogBackdrop.js.map +1 -0
- package/dist/utils/formatBytes.d.ts +2 -0
- package/dist/utils/formatBytes.js +20 -0
- package/dist/utils/formatBytes.js.map +1 -0
- package/dist/utils/hostname.d.ts +2 -0
- package/dist/utils/hostname.js +17 -0
- package/dist/utils/hostname.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/maskBlobToken.d.ts +11 -0
- package/dist/utils/maskBlobToken.js +42 -0
- package/dist/utils/maskBlobToken.js.map +1 -0
- package/package.json +140 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
export type MongoDb = {
|
|
3
|
+
collection: (name: string) => {
|
|
4
|
+
bulkWrite: (operations: unknown[]) => Promise<{
|
|
5
|
+
modifiedCount: number;
|
|
6
|
+
upsertedCount: number;
|
|
7
|
+
}>;
|
|
8
|
+
countDocuments: (query?: object) => Promise<number>;
|
|
9
|
+
deleteMany: (query: object) => Promise<unknown>;
|
|
10
|
+
find: (query: object) => {
|
|
11
|
+
toArray: () => Promise<unknown[]>;
|
|
12
|
+
};
|
|
13
|
+
indexes: () => Promise<{
|
|
14
|
+
key: Record<string, unknown>;
|
|
15
|
+
unique?: boolean;
|
|
16
|
+
}[]>;
|
|
17
|
+
};
|
|
18
|
+
listCollections: () => {
|
|
19
|
+
toArray: () => Promise<{
|
|
20
|
+
name: string;
|
|
21
|
+
}[]>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export declare function getDb(payload: Payload): MongoDb;
|
package/dist/core/db.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getDb(payload) {
|
|
2
|
+
if (payload.db.name !== 'mongoose') {
|
|
3
|
+
throw new Error('Backup failed: Not a mongoose database adapter');
|
|
4
|
+
}
|
|
5
|
+
const db = payload.db.connection?.db;
|
|
6
|
+
if (!db) {
|
|
7
|
+
throw new Error('Backup failed: Database not initialized');
|
|
8
|
+
}
|
|
9
|
+
return db;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/db.ts"],"sourcesContent":["import type { Payload } from 'payload'\n\ntype MongoosePayloadDb = {\n connection?: { db?: MongoDb }\n}\n\nexport type MongoDb = {\n collection: (name: string) => {\n bulkWrite: (operations: unknown[]) => Promise<{ modifiedCount: number; upsertedCount: number }>\n countDocuments: (query?: object) => Promise<number>\n deleteMany: (query: object) => Promise<unknown>\n find: (query: object) => { toArray: () => Promise<unknown[]> }\n indexes: () => Promise<{ key: Record<string, unknown>; unique?: boolean }[]>\n }\n listCollections: () => { toArray: () => Promise<{ name: string }[]> }\n}\n\nexport function getDb(payload: Payload): MongoDb {\n if (payload.db.name !== 'mongoose') {\n throw new Error('Backup failed: Not a mongoose database adapter')\n }\n const db = (payload.db as MongoosePayloadDb).connection?.db\n if (!db) {\n throw new Error('Backup failed: Database not initialized')\n }\n return db\n}\n"],"names":["getDb","payload","db","name","Error","connection"],"mappings":"AAiBA,OAAO,SAASA,MAAMC,OAAgB;IACpC,IAAIA,QAAQC,EAAE,CAACC,IAAI,KAAK,YAAY;QAClC,MAAM,IAAIC,MAAM;IAClB;IACA,MAAMF,KAAK,AAACD,QAAQC,EAAE,CAAuBG,UAAU,EAAEH;IACzD,IAAI,CAACA,IAAI;QACP,MAAM,IAAIE,MAAM;IAClB;IACA,OAAOF;AACT"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
import { type BackupBlobAccessLevel } from './backupBlobIO';
|
|
3
|
+
export interface RestoreBackupOptions {
|
|
4
|
+
/**
|
|
5
|
+
* When set, the archive is loaded with the backup token (fetch + SDK fallback; works for
|
|
6
|
+
* public and private blobs). Otherwise `downloadUrl` is fetched anonymously.
|
|
7
|
+
*/
|
|
8
|
+
backupRead?: {
|
|
9
|
+
pathname: string;
|
|
10
|
+
token: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Preferred Vercel Blob access for re-uploaded media files. When the store rejects the
|
|
14
|
+
* preferred level (e.g. a private-only store rejects `public`), the other level is tried
|
|
15
|
+
* automatically. Defaults to `public`.
|
|
16
|
+
*/
|
|
17
|
+
blobAccess?: BackupBlobAccessLevel;
|
|
18
|
+
/** Optional explicit Vercel Blob token used for archive media blob uploads. */
|
|
19
|
+
blobToken?: string;
|
|
20
|
+
/**
|
|
21
|
+
* When false, skip uploading media blobs from a tar.gz archive (Mongo `media` and other
|
|
22
|
+
* collections still restore). Default true.
|
|
23
|
+
*/
|
|
24
|
+
restoreArchiveMedia?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function restoreBackup(payload: Payload, downloadUrl: string, collectionBlacklist?: string[], mergeData?: boolean, taskId?: string, options?: RestoreBackupOptions): Promise<void>;
|
|
27
|
+
export declare function restoreSeedMedia(payload: Payload, taskId?: string): Promise<string[]>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { EJSON } from 'bson';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveTarGzip } from './archive';
|
|
5
|
+
import { COLLECTION_FILE_NAME } from './backup';
|
|
6
|
+
import { putBackupBlobContent, readBackupBlobContentFlexible } from './backupBlobIO';
|
|
7
|
+
import { getDb } from './db';
|
|
8
|
+
import { updateBackupTask } from './taskProgress';
|
|
9
|
+
export async function restoreBackup(payload, downloadUrl, collectionBlacklist = [], mergeData = false, taskId, options) {
|
|
10
|
+
const restoreArchiveMedia = options?.restoreArchiveMedia !== false;
|
|
11
|
+
const blobToken = options?.blobToken;
|
|
12
|
+
const blobAccess = options?.blobAccess ?? 'public';
|
|
13
|
+
const backupRead = options?.backupRead;
|
|
14
|
+
const t0 = Date.now();
|
|
15
|
+
const urlBase = downloadUrl.split('?')?.[0];
|
|
16
|
+
// Progress is stored in `backup-tasks`. Restoring that collection from the file
|
|
17
|
+
// would delete/replace the active task doc and break GET .../admin/task/:id polling.
|
|
18
|
+
const effectiveBlacklist = taskId ? Array.from(new Set([
|
|
19
|
+
'backup-tasks',
|
|
20
|
+
...collectionBlacklist
|
|
21
|
+
])) : [
|
|
22
|
+
...collectionBlacklist
|
|
23
|
+
];
|
|
24
|
+
payload.logger.info({
|
|
25
|
+
blacklist: effectiveBlacklist,
|
|
26
|
+
mergeData,
|
|
27
|
+
url: urlBase
|
|
28
|
+
}, '[restore] Starting restore');
|
|
29
|
+
if (taskId) {
|
|
30
|
+
await updateBackupTask(payload, taskId, {
|
|
31
|
+
message: 'Starting restore',
|
|
32
|
+
status: 'running'
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const db = getDb(payload);
|
|
36
|
+
const archiveBytes = backupRead ? await readBackupBlobContentFlexible(backupRead.pathname, downloadUrl, backupRead.token) : await (async ()=>{
|
|
37
|
+
const res = await fetch(downloadUrl);
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(`Failed to download backup (${res.status})`);
|
|
40
|
+
}
|
|
41
|
+
return Buffer.from(await res.arrayBuffer());
|
|
42
|
+
})();
|
|
43
|
+
let collections = {};
|
|
44
|
+
if (urlBase?.endsWith('.json')) {
|
|
45
|
+
payload.logger.info('[restore] Parsing JSON backup');
|
|
46
|
+
if (taskId) {
|
|
47
|
+
await updateBackupTask(payload, taskId, {
|
|
48
|
+
message: 'Parsing JSON backup'
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
collections = EJSON.parse(archiveBytes.toString('utf8'));
|
|
52
|
+
} else if (urlBase?.endsWith('.gz')) {
|
|
53
|
+
payload.logger.info('[restore] Extracting tar.gz backup');
|
|
54
|
+
if (taskId) {
|
|
55
|
+
await updateBackupTask(payload, taskId, {
|
|
56
|
+
message: 'Extracting tar.gz backup'
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const files = await resolveTarGzip(archiveBytes);
|
|
60
|
+
collections = EJSON.parse(files.find((file)=>file.name === COLLECTION_FILE_NAME)?.content?.toString() || '{}');
|
|
61
|
+
const medias = restoreArchiveMedia ? files.filter((file)=>file.name !== COLLECTION_FILE_NAME) : [];
|
|
62
|
+
if (!restoreArchiveMedia) {
|
|
63
|
+
payload.logger.info('[restore] Skipping archive media blob upload (restoreArchiveMedia=false)');
|
|
64
|
+
}
|
|
65
|
+
payload.logger.info({
|
|
66
|
+
count: medias.length
|
|
67
|
+
}, '[restore] Restoring media files to blob storage');
|
|
68
|
+
if (taskId) {
|
|
69
|
+
await updateBackupTask(payload, taskId, {
|
|
70
|
+
message: restoreArchiveMedia ? `Restoring ${medias.length} media file${medias.length === 1 ? '' : 's'}` : 'Skipped media files from archive'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const mediaResults = await Promise.all(medias.map((media)=>putBackupBlobContent(media.name, media.content, blobToken, blobAccess).then((effectiveAccess)=>({
|
|
74
|
+
name: media.name,
|
|
75
|
+
effectiveAccess
|
|
76
|
+
}))));
|
|
77
|
+
const mismatched = mediaResults.filter((r)=>r.effectiveAccess !== blobAccess);
|
|
78
|
+
if (mismatched.length > 0) {
|
|
79
|
+
payload.logger.warn({
|
|
80
|
+
count: mismatched.length,
|
|
81
|
+
preferredAccess: blobAccess
|
|
82
|
+
}, '[restore] Blob store rejected preferred access level for media; uploaded with fallback');
|
|
83
|
+
}
|
|
84
|
+
mediaResults.forEach((result)=>{
|
|
85
|
+
payload.logger.debug({
|
|
86
|
+
name: result.name,
|
|
87
|
+
access: result.effectiveAccess
|
|
88
|
+
}, '[restore] Media file uploaded');
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
throw new Error(`File type of backup ${downloadUrl} not supported`);
|
|
92
|
+
}
|
|
93
|
+
const collectionNames = Object.keys(collections);
|
|
94
|
+
payload.logger.info({
|
|
95
|
+
blacklisted: effectiveBlacklist.length,
|
|
96
|
+
total: collectionNames.length
|
|
97
|
+
}, '[restore] Restoring collections');
|
|
98
|
+
if (taskId) {
|
|
99
|
+
await updateBackupTask(payload, taskId, {
|
|
100
|
+
message: `Restoring ${collectionNames.length} collection${collectionNames.length === 1 ? '' : 's'}`
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
for (const collectionName of collectionNames){
|
|
104
|
+
if (effectiveBlacklist.includes(collectionName)) {
|
|
105
|
+
payload.logger.debug({
|
|
106
|
+
collection: collectionName
|
|
107
|
+
}, '[restore] Skipping blacklisted collection');
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const collectionData = collections[collectionName];
|
|
111
|
+
if (collectionData.length > 0) {
|
|
112
|
+
payload.logger.info({
|
|
113
|
+
collection: collectionName,
|
|
114
|
+
docs: collectionData.length,
|
|
115
|
+
mergeData
|
|
116
|
+
}, '[restore] Restoring collection');
|
|
117
|
+
const collection = db.collection(collectionName);
|
|
118
|
+
const indexes = await collection.indexes();
|
|
119
|
+
const uniqueIndexes = indexes.filter((idx)=>idx.unique).flatMap((idx)=>Object.keys(idx.key));
|
|
120
|
+
if (!mergeData) {
|
|
121
|
+
await collection.deleteMany({});
|
|
122
|
+
}
|
|
123
|
+
if (taskId) {
|
|
124
|
+
await updateBackupTask(payload, taskId, {
|
|
125
|
+
message: `Restoring collection ${collectionName} (${collectionData.length} docs)`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const res = await collection.bulkWrite(collectionData.map((doc)=>({
|
|
129
|
+
updateOne: {
|
|
130
|
+
filter: uniqueIndexes.length > 0 ? {
|
|
131
|
+
$or: [
|
|
132
|
+
{
|
|
133
|
+
_id: doc._id
|
|
134
|
+
},
|
|
135
|
+
...uniqueIndexes.map((field)=>({
|
|
136
|
+
[field]: doc[field]
|
|
137
|
+
}))
|
|
138
|
+
]
|
|
139
|
+
} : {
|
|
140
|
+
_id: doc._id
|
|
141
|
+
},
|
|
142
|
+
update: {
|
|
143
|
+
$set: doc
|
|
144
|
+
},
|
|
145
|
+
upsert: true
|
|
146
|
+
}
|
|
147
|
+
})));
|
|
148
|
+
payload.logger.debug({
|
|
149
|
+
collection: collectionName,
|
|
150
|
+
modified: res.modifiedCount,
|
|
151
|
+
upserted: res.upsertedCount
|
|
152
|
+
}, '[restore] Collection restored');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
payload.logger.info({
|
|
156
|
+
durationMs: Date.now() - t0
|
|
157
|
+
}, '[restore] Restore complete');
|
|
158
|
+
}
|
|
159
|
+
export async function restoreSeedMedia(payload, taskId) {
|
|
160
|
+
const files = await fs.readdir(path.join(process.cwd(), 'public/seed/media'));
|
|
161
|
+
if (taskId) {
|
|
162
|
+
await updateBackupTask(payload, taskId, {
|
|
163
|
+
message: `Restoring ${files.length} seed media file${files.length === 1 ? '' : 's'}`,
|
|
164
|
+
status: 'running'
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
for (const file of files){
|
|
168
|
+
const data = await fs.readFile(path.join(process.cwd(), 'public/seed/media', file));
|
|
169
|
+
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
|
170
|
+
const effectiveAccess = await putBackupBlobContent(file, data, process.env.BLOB_READ_WRITE_TOKEN, 'public');
|
|
171
|
+
payload.logger.info({
|
|
172
|
+
access: effectiveAccess,
|
|
173
|
+
file
|
|
174
|
+
}, '[restore] Restored seed media to Vercel Blob storage');
|
|
175
|
+
} else {
|
|
176
|
+
const folderPath = path.join(process.cwd(), 'public/media');
|
|
177
|
+
const publicPath = path.join(folderPath, file);
|
|
178
|
+
await fs.mkdir(folderPath, {
|
|
179
|
+
recursive: true
|
|
180
|
+
});
|
|
181
|
+
await fs.writeFile(publicPath, data);
|
|
182
|
+
payload.logger.info({
|
|
183
|
+
file,
|
|
184
|
+
publicPath
|
|
185
|
+
}, '[restore] Restored seed media to public directory');
|
|
186
|
+
}
|
|
187
|
+
if (taskId) {
|
|
188
|
+
await updateBackupTask(payload, taskId, {
|
|
189
|
+
message: `Restored seed media file ${file}`
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
payload.logger.info({
|
|
194
|
+
count: files.length
|
|
195
|
+
}, '[restore] Restored all seed media files');
|
|
196
|
+
return files;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
//# sourceMappingURL=restore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/restore.ts"],"sourcesContent":["import type { Payload } from 'payload'\n\nimport { EJSON } from 'bson'\nimport fs from 'node:fs/promises'\nimport path from 'node:path'\n\nimport { resolveTarGzip } from './archive'\nimport { COLLECTION_FILE_NAME } from './backup'\nimport {\n type BackupBlobAccessLevel,\n putBackupBlobContent,\n readBackupBlobContentFlexible,\n} from './backupBlobIO'\nimport { getDb } from './db'\nimport { updateBackupTask } from './taskProgress'\n\nexport interface RestoreBackupOptions {\n /**\n * When set, the archive is loaded with the backup token (fetch + SDK fallback; works for\n * public and private blobs). Otherwise `downloadUrl` is fetched anonymously.\n */\n backupRead?: { pathname: string; token: string }\n /**\n * Preferred Vercel Blob access for re-uploaded media files. When the store rejects the\n * preferred level (e.g. a private-only store rejects `public`), the other level is tried\n * automatically. Defaults to `public`.\n */\n blobAccess?: BackupBlobAccessLevel\n /** Optional explicit Vercel Blob token used for archive media blob uploads. */\n blobToken?: string\n /**\n * When false, skip uploading media blobs from a tar.gz archive (Mongo `media` and other\n * collections still restore). Default true.\n */\n restoreArchiveMedia?: boolean\n}\n\nexport async function restoreBackup(\n payload: Payload,\n downloadUrl: string,\n collectionBlacklist: string[] = [],\n mergeData = false,\n taskId?: string,\n options?: RestoreBackupOptions,\n): Promise<void> {\n const restoreArchiveMedia = options?.restoreArchiveMedia !== false\n const blobToken = options?.blobToken\n const blobAccess: BackupBlobAccessLevel = options?.blobAccess ?? 'public'\n const backupRead = options?.backupRead\n const t0 = Date.now()\n const urlBase = downloadUrl.split('?')?.[0]\n\n // Progress is stored in `backup-tasks`. Restoring that collection from the file\n // would delete/replace the active task doc and break GET .../admin/task/:id polling.\n const effectiveBlacklist = taskId\n ? Array.from(new Set(['backup-tasks', ...collectionBlacklist]))\n : [...collectionBlacklist]\n\n payload.logger.info(\n { blacklist: effectiveBlacklist, mergeData, url: urlBase },\n '[restore] Starting restore',\n )\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: 'Starting restore',\n status: 'running',\n })\n }\n\n const db = getDb(payload)\n const archiveBytes = backupRead\n ? await readBackupBlobContentFlexible(backupRead.pathname, downloadUrl, backupRead.token)\n : await (async () => {\n const res = await fetch(downloadUrl)\n if (!res.ok) {\n throw new Error(`Failed to download backup (${res.status})`)\n }\n return Buffer.from(await res.arrayBuffer())\n })()\n let collections: Record<string, Record<string, unknown>[]> = {}\n\n if (urlBase?.endsWith('.json')) {\n payload.logger.info('[restore] Parsing JSON backup')\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: 'Parsing JSON backup',\n })\n }\n collections = EJSON.parse(archiveBytes.toString('utf8'))\n } else if (urlBase?.endsWith('.gz')) {\n payload.logger.info('[restore] Extracting tar.gz backup')\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: 'Extracting tar.gz backup',\n })\n }\n const files = await resolveTarGzip(archiveBytes)\n collections = EJSON.parse(\n files.find((file) => file.name === COLLECTION_FILE_NAME)?.content?.toString() || '{}',\n )\n const medias = restoreArchiveMedia\n ? files.filter((file) => file.name !== COLLECTION_FILE_NAME)\n : []\n if (!restoreArchiveMedia) {\n payload.logger.info(\n '[restore] Skipping archive media blob upload (restoreArchiveMedia=false)',\n )\n }\n payload.logger.info({ count: medias.length }, '[restore] Restoring media files to blob storage')\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: restoreArchiveMedia\n ? `Restoring ${medias.length} media file${medias.length === 1 ? '' : 's'}`\n : 'Skipped media files from archive',\n })\n }\n const mediaResults = await Promise.all(\n medias.map((media) =>\n putBackupBlobContent(media.name, media.content, blobToken, blobAccess).then(\n (effectiveAccess) => ({ name: media.name, effectiveAccess }),\n ),\n ),\n )\n const mismatched = mediaResults.filter((r) => r.effectiveAccess !== blobAccess)\n if (mismatched.length > 0) {\n payload.logger.warn(\n { count: mismatched.length, preferredAccess: blobAccess },\n '[restore] Blob store rejected preferred access level for media; uploaded with fallback',\n )\n }\n mediaResults.forEach((result) => {\n payload.logger.debug(\n { name: result.name, access: result.effectiveAccess },\n '[restore] Media file uploaded',\n )\n })\n } else {\n throw new Error(`File type of backup ${downloadUrl} not supported`)\n }\n\n const collectionNames = Object.keys(collections)\n payload.logger.info(\n { blacklisted: effectiveBlacklist.length, total: collectionNames.length },\n '[restore] Restoring collections',\n )\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: `Restoring ${collectionNames.length} collection${collectionNames.length === 1 ? '' : 's'}`,\n })\n }\n\n for (const collectionName of collectionNames) {\n if (effectiveBlacklist.includes(collectionName)) {\n payload.logger.debug(\n { collection: collectionName },\n '[restore] Skipping blacklisted collection',\n )\n continue\n }\n const collectionData = collections[collectionName]\n if (collectionData.length > 0) {\n payload.logger.info(\n { collection: collectionName, docs: collectionData.length, mergeData },\n '[restore] Restoring collection',\n )\n const collection = db.collection(collectionName)\n const indexes = await collection.indexes()\n const uniqueIndexes = indexes\n .filter((idx) => idx.unique)\n .flatMap((idx) => Object.keys(idx.key))\n if (!mergeData) {\n await collection.deleteMany({})\n }\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: `Restoring collection ${collectionName} (${collectionData.length} docs)`,\n })\n }\n const res = await collection.bulkWrite(\n collectionData.map((doc) => ({\n updateOne: {\n filter:\n uniqueIndexes.length > 0\n ? {\n $or: [\n { _id: doc._id },\n ...uniqueIndexes.map((field) => ({ [field]: doc[field] })),\n ],\n }\n : { _id: doc._id },\n update: { $set: doc },\n upsert: true,\n },\n })),\n )\n payload.logger.debug(\n { collection: collectionName, modified: res.modifiedCount, upserted: res.upsertedCount },\n '[restore] Collection restored',\n )\n }\n }\n\n payload.logger.info({ durationMs: Date.now() - t0 }, '[restore] Restore complete')\n}\n\nexport async function restoreSeedMedia(payload: Payload, taskId?: string): Promise<string[]> {\n const files = await fs.readdir(path.join(process.cwd(), 'public/seed/media'))\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: `Restoring ${files.length} seed media file${files.length === 1 ? '' : 's'}`,\n status: 'running',\n })\n }\n for (const file of files) {\n const data = await fs.readFile(path.join(process.cwd(), 'public/seed/media', file))\n if (process.env.BLOB_READ_WRITE_TOKEN) {\n const effectiveAccess = await putBackupBlobContent(\n file,\n data,\n process.env.BLOB_READ_WRITE_TOKEN,\n 'public',\n )\n payload.logger.info(\n { access: effectiveAccess, file },\n '[restore] Restored seed media to Vercel Blob storage',\n )\n } else {\n const folderPath = path.join(process.cwd(), 'public/media')\n const publicPath = path.join(folderPath, file)\n await fs.mkdir(folderPath, { recursive: true })\n await fs.writeFile(publicPath, data)\n payload.logger.info({ file, publicPath }, '[restore] Restored seed media to public directory')\n }\n if (taskId) {\n await updateBackupTask(payload, taskId, {\n message: `Restored seed media file ${file}`,\n })\n }\n }\n payload.logger.info({ count: files.length }, '[restore] Restored all seed media files')\n return files\n}\n"],"names":["EJSON","fs","path","resolveTarGzip","COLLECTION_FILE_NAME","putBackupBlobContent","readBackupBlobContentFlexible","getDb","updateBackupTask","restoreBackup","payload","downloadUrl","collectionBlacklist","mergeData","taskId","options","restoreArchiveMedia","blobToken","blobAccess","backupRead","t0","Date","now","urlBase","split","effectiveBlacklist","Array","from","Set","logger","info","blacklist","url","message","status","db","archiveBytes","pathname","token","res","fetch","ok","Error","Buffer","arrayBuffer","collections","endsWith","parse","toString","files","find","file","name","content","medias","filter","count","length","mediaResults","Promise","all","map","media","then","effectiveAccess","mismatched","r","warn","preferredAccess","forEach","result","debug","access","collectionNames","Object","keys","blacklisted","total","collectionName","includes","collection","collectionData","docs","indexes","uniqueIndexes","idx","unique","flatMap","key","deleteMany","bulkWrite","doc","updateOne","$or","_id","field","update","$set","upsert","modified","modifiedCount","upserted","upsertedCount","durationMs","restoreSeedMedia","readdir","join","process","cwd","data","readFile","env","BLOB_READ_WRITE_TOKEN","folderPath","publicPath","mkdir","recursive","writeFile"],"mappings":"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"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
export type RestorePreviewFileKind = 'json' | 'tar-gzip';
|
|
3
|
+
export type RestorePreviewAdminHiddenReason = 'collection-config' | 'version-history';
|
|
4
|
+
export type RestorePreviewGroup = {
|
|
5
|
+
adminHidden: boolean;
|
|
6
|
+
adminHiddenReasons: RestorePreviewAdminHiddenReason[];
|
|
7
|
+
affectsAuthSession: boolean;
|
|
8
|
+
/** e.g. "Seiten (pages)" */
|
|
9
|
+
displayTitle: string;
|
|
10
|
+
/** Stable id for UI + skip payload (Payload collection slug when known, else mongo name). */
|
|
11
|
+
groupId: string;
|
|
12
|
+
main?: {
|
|
13
|
+
docCount: number;
|
|
14
|
+
mongoName: string;
|
|
15
|
+
};
|
|
16
|
+
/** All Mongo names restored when this group is checked. */
|
|
17
|
+
mongoNames: string[];
|
|
18
|
+
versions?: {
|
|
19
|
+
docCount: number;
|
|
20
|
+
mongoName: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type RestorePreviewResponse = {
|
|
24
|
+
fileKind: RestorePreviewFileKind;
|
|
25
|
+
groups: RestorePreviewGroup[];
|
|
26
|
+
mediaBlobCount: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Index in sidebar order, or `null` if the group is not a visible nav collection
|
|
30
|
+
* (hidden config, unknown mongo name, etc.).
|
|
31
|
+
*/
|
|
32
|
+
export declare function navVisibleCollectionOrderIndex(payload: Payload, groupId: string): null | number;
|
|
33
|
+
export declare function loadRestoreBackupIndex(downloadUrl: string, readAuth?: {
|
|
34
|
+
pathname: string;
|
|
35
|
+
token: string;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
byName: Record<string, unknown[]>;
|
|
38
|
+
fileKind: RestorePreviewFileKind;
|
|
39
|
+
mediaBlobCount: number;
|
|
40
|
+
}>;
|
|
41
|
+
/** Group Mongo collection names + doc counts (live DB or backup file) for admin UI lists. */
|
|
42
|
+
export declare function buildCollectionPreviewGroups(payload: Payload, countsByMongo: Record<string, number>, options?: {
|
|
43
|
+
excludeMongo?: Set<string>;
|
|
44
|
+
/** When true, list collections with 0 documents (e.g. manual backup UI). Default false. */
|
|
45
|
+
includeEmptyCollections?: boolean;
|
|
46
|
+
preferredLocales?: string[];
|
|
47
|
+
/** When true, order like Payload admin sidebar; unknown / hidden-only groups last by title. */
|
|
48
|
+
sortLikeAdminNav?: boolean;
|
|
49
|
+
}): RestorePreviewGroup[];
|
|
50
|
+
export declare function buildRestorePreviewGroups(payload: Payload, parsed: {
|
|
51
|
+
byName: Record<string, unknown[]>;
|
|
52
|
+
fileKind: RestorePreviewFileKind;
|
|
53
|
+
mediaBlobCount: number;
|
|
54
|
+
}, options?: {
|
|
55
|
+
excludeMongo?: Set<string>;
|
|
56
|
+
preferredLocales?: string[];
|
|
57
|
+
}): RestorePreviewResponse;
|
|
58
|
+
export declare function getRestorePreviewForAdminRestore(payload: Payload, downloadUrl: string, options?: {
|
|
59
|
+
backupRead?: {
|
|
60
|
+
pathname: string;
|
|
61
|
+
token: string;
|
|
62
|
+
};
|
|
63
|
+
preferredLocales?: string[];
|
|
64
|
+
}): Promise<RestorePreviewResponse>;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { EJSON } from 'bson';
|
|
2
|
+
import { resolveTarGzip } from './archive';
|
|
3
|
+
import { COLLECTION_FILE_NAME } from './backup';
|
|
4
|
+
import { readBackupBlobContentFlexible } from './backupBlobIO';
|
|
5
|
+
/** Mongo collection names that invalidate the current admin session when replaced. */ const AUTH_SESSION_MONGO_NAMES = new Set([
|
|
6
|
+
'payload-preferences',
|
|
7
|
+
'roles',
|
|
8
|
+
'sessions',
|
|
9
|
+
'users'
|
|
10
|
+
]);
|
|
11
|
+
const MONGO_VERSIONS = /^_(.+)_versions$/i;
|
|
12
|
+
function docCount(byName, name) {
|
|
13
|
+
const docs = byName[name];
|
|
14
|
+
return Array.isArray(docs) ? docs.length : 0;
|
|
15
|
+
}
|
|
16
|
+
function isVersionsMongoName(name) {
|
|
17
|
+
return MONGO_VERSIONS.test(name);
|
|
18
|
+
}
|
|
19
|
+
function resolveAdminHidden(payload, mongoName) {
|
|
20
|
+
const col = payload.config.collections.find((c)=>{
|
|
21
|
+
const dbName = c.dbName ?? c.slug;
|
|
22
|
+
return dbName === mongoName || c.slug === mongoName;
|
|
23
|
+
});
|
|
24
|
+
if (col?.admin?.hidden) {
|
|
25
|
+
return {
|
|
26
|
+
adminHidden: true,
|
|
27
|
+
adminHiddenReason: 'collection-config'
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (isVersionsMongoName(mongoName)) {
|
|
31
|
+
return {
|
|
32
|
+
adminHidden: true,
|
|
33
|
+
adminHiddenReason: 'version-history'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
adminHidden: false,
|
|
38
|
+
adminHiddenReason: null
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function pickLocalizedLabel(labels, preferredLocales) {
|
|
42
|
+
if (!labels || typeof labels !== 'object') {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const l = labels;
|
|
46
|
+
const pickField = (field)=>{
|
|
47
|
+
if (typeof field === 'string') {
|
|
48
|
+
return field;
|
|
49
|
+
}
|
|
50
|
+
if (field && typeof field === 'object') {
|
|
51
|
+
const map = field;
|
|
52
|
+
for (const loc of preferredLocales){
|
|
53
|
+
const v = map[loc];
|
|
54
|
+
if (v) {
|
|
55
|
+
return v;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const first = Object.values(map).find(Boolean);
|
|
59
|
+
return first;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
};
|
|
63
|
+
return pickField(l.plural) || pickField(l.singular);
|
|
64
|
+
}
|
|
65
|
+
function humanizeSlug(slug) {
|
|
66
|
+
return slug.split('-').map((w)=>w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
|
|
67
|
+
}
|
|
68
|
+
/** Collections shown in the default Payload admin sidebar: config order, skipping `admin.hidden`. */ function getNavVisibleCollections(payload) {
|
|
69
|
+
return payload.config.collections.filter((c)=>c.admin?.hidden !== true);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Index in sidebar order, or `null` if the group is not a visible nav collection
|
|
73
|
+
* (hidden config, unknown mongo name, etc.).
|
|
74
|
+
*/ export function navVisibleCollectionOrderIndex(payload, groupId) {
|
|
75
|
+
const visible = getNavVisibleCollections(payload);
|
|
76
|
+
const idx = visible.findIndex((c)=>{
|
|
77
|
+
const dbName = c.dbName ?? c.slug;
|
|
78
|
+
return c.slug === groupId || dbName === groupId;
|
|
79
|
+
});
|
|
80
|
+
return idx === -1 ? null : idx;
|
|
81
|
+
}
|
|
82
|
+
function sortPreviewGroupsLikeAdminNav(payload, groups, preferredLocales) {
|
|
83
|
+
const locale = preferredLocales[0] ?? 'en';
|
|
84
|
+
groups.sort((a, b)=>{
|
|
85
|
+
const ia = navVisibleCollectionOrderIndex(payload, a.groupId);
|
|
86
|
+
const ib = navVisibleCollectionOrderIndex(payload, b.groupId);
|
|
87
|
+
if (ia !== null && ib !== null && ia !== ib) {
|
|
88
|
+
return ia - ib;
|
|
89
|
+
}
|
|
90
|
+
if (ia !== null && ib === null) {
|
|
91
|
+
return -1;
|
|
92
|
+
}
|
|
93
|
+
if (ia === null && ib !== null) {
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
return a.displayTitle.localeCompare(b.displayTitle, locale);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function buildDisplayTitle(payload, groupSlug, preferredLocales) {
|
|
100
|
+
const col = payload.config.collections.find((c)=>c.slug === groupSlug);
|
|
101
|
+
const label = pickLocalizedLabel(col?.labels, preferredLocales) || humanizeSlug(groupSlug);
|
|
102
|
+
return `${label} (${groupSlug})`;
|
|
103
|
+
}
|
|
104
|
+
export async function loadRestoreBackupIndex(downloadUrl, readAuth) {
|
|
105
|
+
const urlBase = downloadUrl.split('?')?.[0];
|
|
106
|
+
const bytes = readAuth ? await readBackupBlobContentFlexible(readAuth.pathname, downloadUrl, readAuth.token) : await (async ()=>{
|
|
107
|
+
const res = await fetch(downloadUrl);
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
throw new Error(`Failed to download backup (${res.status})`);
|
|
110
|
+
}
|
|
111
|
+
return Buffer.from(await res.arrayBuffer());
|
|
112
|
+
})();
|
|
113
|
+
let byName = {};
|
|
114
|
+
let mediaBlobCount = 0;
|
|
115
|
+
let fileKind;
|
|
116
|
+
if (urlBase?.endsWith('.json')) {
|
|
117
|
+
fileKind = 'json';
|
|
118
|
+
byName = EJSON.parse(bytes.toString('utf8'));
|
|
119
|
+
} else if (urlBase?.endsWith('.gz')) {
|
|
120
|
+
fileKind = 'tar-gzip';
|
|
121
|
+
const files = await resolveTarGzip(bytes);
|
|
122
|
+
byName = EJSON.parse(files.find((f)=>f.name === COLLECTION_FILE_NAME)?.content?.toString() || '{}');
|
|
123
|
+
mediaBlobCount = files.filter((f)=>f.name !== COLLECTION_FILE_NAME).length;
|
|
124
|
+
} else {
|
|
125
|
+
throw new Error('Unsupported backup file type (expected .json or .gz)');
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
byName,
|
|
129
|
+
fileKind,
|
|
130
|
+
mediaBlobCount
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function rebuildMongoNames(g) {
|
|
134
|
+
return [
|
|
135
|
+
g.main?.mongoName,
|
|
136
|
+
g.versions?.mongoName
|
|
137
|
+
].filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
function countsFromByName(byName) {
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const k of Object.keys(byName)){
|
|
142
|
+
out[k] = docCount(byName, k);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Ensures every configured Payload collection appears (0 if absent from backup JSON), like live-DB preview. */ function mergeConfiguredCollectionZeros(payload, fileCounts, excludeMongo) {
|
|
147
|
+
const merged = {
|
|
148
|
+
...fileCounts
|
|
149
|
+
};
|
|
150
|
+
for (const col of payload.config.collections){
|
|
151
|
+
const mongo = col.dbName ?? col.slug;
|
|
152
|
+
if (excludeMongo.has(mongo)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!(mongo in merged)) {
|
|
156
|
+
merged[mongo] = 0;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return merged;
|
|
160
|
+
}
|
|
161
|
+
/** Group Mongo collection names + doc counts (live DB or backup file) for admin UI lists. */ export function buildCollectionPreviewGroups(payload, countsByMongo, options = {}) {
|
|
162
|
+
const preferredLocales = options.preferredLocales?.length ? options.preferredLocales : [
|
|
163
|
+
'de',
|
|
164
|
+
'en'
|
|
165
|
+
];
|
|
166
|
+
const excludeMongo = options.excludeMongo ?? new Set([
|
|
167
|
+
'backup-tasks'
|
|
168
|
+
]);
|
|
169
|
+
const includeEmptyCollections = options.includeEmptyCollections ?? false;
|
|
170
|
+
const sortLikeAdminNav = options.sortLikeAdminNav ?? false;
|
|
171
|
+
const aggs = new Map();
|
|
172
|
+
for (const [mongoName, count] of Object.entries(countsByMongo)){
|
|
173
|
+
if (excludeMongo.has(mongoName)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!includeEmptyCollections && count === 0) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const versionMatch = mongoName.match(MONGO_VERSIONS);
|
|
180
|
+
if (versionMatch) {
|
|
181
|
+
const groupId = versionMatch[1];
|
|
182
|
+
const prev = aggs.get(groupId) ?? {
|
|
183
|
+
groupId
|
|
184
|
+
};
|
|
185
|
+
aggs.set(groupId, {
|
|
186
|
+
...prev,
|
|
187
|
+
groupId,
|
|
188
|
+
versions: {
|
|
189
|
+
docCount: count,
|
|
190
|
+
mongoName
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
const groupId = mongoName;
|
|
195
|
+
const prev = aggs.get(groupId) ?? {
|
|
196
|
+
groupId
|
|
197
|
+
};
|
|
198
|
+
aggs.set(groupId, {
|
|
199
|
+
...prev,
|
|
200
|
+
groupId,
|
|
201
|
+
main: {
|
|
202
|
+
docCount: count,
|
|
203
|
+
mongoName
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const groups = [
|
|
209
|
+
...aggs.values()
|
|
210
|
+
].map((agg)=>{
|
|
211
|
+
const mongoNames = rebuildMongoNames(agg);
|
|
212
|
+
const reasons = new Set();
|
|
213
|
+
let adminHidden = false;
|
|
214
|
+
for (const mn of mongoNames){
|
|
215
|
+
const r = resolveAdminHidden(payload, mn);
|
|
216
|
+
if (r.adminHidden && r.adminHiddenReason) {
|
|
217
|
+
adminHidden = true;
|
|
218
|
+
reasons.add(r.adminHiddenReason);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const affectsAuthSession = mongoNames.some((n)=>AUTH_SESSION_MONGO_NAMES.has(n));
|
|
222
|
+
return {
|
|
223
|
+
adminHidden,
|
|
224
|
+
adminHiddenReasons: [
|
|
225
|
+
...reasons
|
|
226
|
+
],
|
|
227
|
+
affectsAuthSession,
|
|
228
|
+
displayTitle: buildDisplayTitle(payload, agg.groupId, preferredLocales),
|
|
229
|
+
groupId: agg.groupId,
|
|
230
|
+
main: agg.main,
|
|
231
|
+
mongoNames,
|
|
232
|
+
versions: agg.versions
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
if (sortLikeAdminNav) {
|
|
236
|
+
sortPreviewGroupsLikeAdminNav(payload, groups, preferredLocales);
|
|
237
|
+
} else {
|
|
238
|
+
const locale = preferredLocales[0] ?? 'en';
|
|
239
|
+
groups.sort((a, b)=>a.displayTitle.localeCompare(b.displayTitle, locale));
|
|
240
|
+
}
|
|
241
|
+
return groups;
|
|
242
|
+
}
|
|
243
|
+
export function buildRestorePreviewGroups(payload, parsed, options = {}) {
|
|
244
|
+
const excludeMongo = options.excludeMongo ?? new Set([
|
|
245
|
+
'backup-tasks'
|
|
246
|
+
]);
|
|
247
|
+
const rawCounts = countsFromByName(parsed.byName);
|
|
248
|
+
const counts = mergeConfiguredCollectionZeros(payload, rawCounts, excludeMongo);
|
|
249
|
+
const groups = buildCollectionPreviewGroups(payload, counts, {
|
|
250
|
+
excludeMongo,
|
|
251
|
+
includeEmptyCollections: true,
|
|
252
|
+
preferredLocales: options.preferredLocales,
|
|
253
|
+
sortLikeAdminNav: true
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
fileKind: parsed.fileKind,
|
|
257
|
+
groups,
|
|
258
|
+
mediaBlobCount: parsed.mediaBlobCount
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
export async function getRestorePreviewForAdminRestore(payload, downloadUrl, options = {}) {
|
|
262
|
+
const parsed = await loadRestoreBackupIndex(downloadUrl, options.backupRead);
|
|
263
|
+
return buildRestorePreviewGroups(payload, parsed, options);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
//# sourceMappingURL=restorePreview.js.map
|