@morphika/andami 0.2.8 → 0.2.10
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 +8 -3
- package/admin/backups.ts +7 -0
- package/app/(site)/layout.tsx +0 -2
- package/app/admin/backups/page.tsx +1 -0
- package/app/admin/layout.tsx +327 -274
- package/app/api/admin/backups/export/route.ts +76 -0
- package/app/api/admin/backups/prepare-export/route.ts +56 -0
- package/app/api/admin/backups/restore/route.ts +131 -0
- package/app/api/admin/backups/restore-data/route.ts +424 -0
- package/app/api/admin/backups/status/route.ts +35 -0
- package/app/api/custom-sections/[id]/route.ts +9 -5
- package/components/admin/backups/BackupsPage.tsx +1581 -0
- package/components/builder/CustomSectionInstanceCard.tsx +7 -3
- package/components/builder/ReadOnlyFrame.tsx +4 -2
- package/components/builder/settings-panel/CustomSectionSettings.tsx +5 -3
- package/lib/backup/export.ts +377 -0
- package/lib/backup/manifest.ts +121 -0
- package/lib/backup/r2-helpers.ts +294 -0
- package/lib/backup/restore.ts +266 -0
- package/lib/backup/sanity-ops.ts +194 -0
- package/lib/builder/serializer/normalizers.ts +1 -0
- package/lib/builder/store-canvas.ts +4 -0
- package/lib/builder/store.ts +1 -0
- package/lib/builder/types.ts +4 -0
- package/lib/security.ts +30 -0
- package/lib/security.ts.new +27 -0
- package/lib/storage/types.ts +33 -1
- package/lib/version.ts +7 -4
- package/package.json +17 -1
- package/components/ui/PortfolioTracker.tsx +0 -87
|
@@ -30,17 +30,21 @@ export default function CustomSectionInstanceCard({
|
|
|
30
30
|
onSectionDataLoaded,
|
|
31
31
|
}: CustomSectionInstanceCardProps) {
|
|
32
32
|
const cacheSettings = useBuilderStore((s) => s.cacheCustomSectionSettings);
|
|
33
|
+
const refetchTick = useBuilderStore((s) => s._customSectionRefetchTick);
|
|
33
34
|
const [sectionData, setSectionData] = useState<PageSectionV2 | null>(null);
|
|
34
35
|
const [loading, setLoading] = useState(true);
|
|
35
36
|
const [error, setError] = useState<string | null>(null);
|
|
36
37
|
|
|
37
|
-
// Fetch the section data for preview
|
|
38
|
+
// Fetch the section data for preview.
|
|
39
|
+
// Re-runs when refetchTick changes (after saving a custom section in the editor).
|
|
38
40
|
useEffect(() => {
|
|
39
41
|
let cancelled = false;
|
|
40
42
|
setLoading(true);
|
|
41
43
|
setError(null);
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
// Cache-bust: append tick to bypass browser/CDN cached responses after save
|
|
46
|
+
const bustParam = refetchTick > 0 ? `?_t=${refetchTick}` : "";
|
|
47
|
+
fetch(`/api/custom-sections/${instance.custom_section_id}${bustParam}`)
|
|
44
48
|
.then((res) => {
|
|
45
49
|
if (!res.ok) throw new Error("Section not found");
|
|
46
50
|
return res.json();
|
|
@@ -64,7 +68,7 @@ export default function CustomSectionInstanceCard({
|
|
|
64
68
|
|
|
65
69
|
return () => { cancelled = true; };
|
|
66
70
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
-
}, [instance.custom_section_id]);
|
|
71
|
+
}, [instance.custom_section_id, refetchTick]);
|
|
68
72
|
|
|
69
73
|
return (
|
|
70
74
|
<div className="relative">
|
|
@@ -284,11 +284,13 @@ const ReadOnlyCustomSection = memo(function ReadOnlyCustomSection({
|
|
|
284
284
|
viewport,
|
|
285
285
|
}: ReadOnlyCustomSectionProps) {
|
|
286
286
|
const cacheSettings = useBuilderStore((s) => s.cacheCustomSectionSettings);
|
|
287
|
+
const refetchTick = useBuilderStore((s) => s._customSectionRefetchTick);
|
|
287
288
|
const [sectionData, setSectionData] = useState<PageSectionV2 | null>(null);
|
|
288
289
|
|
|
289
290
|
useEffect(() => {
|
|
290
291
|
let cancelled = false;
|
|
291
|
-
|
|
292
|
+
const bustParam = refetchTick > 0 ? `?_t=${refetchTick}` : "";
|
|
293
|
+
fetch(`/api/custom-sections/${instance.custom_section_id}${bustParam}`)
|
|
292
294
|
.then((res) => (res.ok ? res.json() : null))
|
|
293
295
|
.then((data) => {
|
|
294
296
|
if (!cancelled && data?.section) {
|
|
@@ -302,7 +304,7 @@ const ReadOnlyCustomSection = memo(function ReadOnlyCustomSection({
|
|
|
302
304
|
.catch(() => {});
|
|
303
305
|
return () => { cancelled = true; };
|
|
304
306
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
305
|
-
}, [instance.custom_section_id]);
|
|
307
|
+
}, [instance.custom_section_id, refetchTick]);
|
|
306
308
|
|
|
307
309
|
if (!sectionData) {
|
|
308
310
|
return (
|
|
@@ -13,21 +13,23 @@ import type { CustomSectionInstance, PageSectionV2 } from "../../../lib/sanity/t
|
|
|
13
13
|
|
|
14
14
|
export function CustomSectionSettings({ instance }: { instance: CustomSectionInstance }) {
|
|
15
15
|
const store = useBuilderStore();
|
|
16
|
+
const refetchTick = useBuilderStore((s) => s._customSectionRefetchTick);
|
|
16
17
|
const [showDetachConfirm, setShowDetachConfirm] = useState(false);
|
|
17
18
|
const [sectionData, setSectionData] = useState<PageSectionV2 | null>(null);
|
|
18
19
|
const [loadingEdit, setLoadingEdit] = useState(false);
|
|
19
20
|
|
|
20
|
-
// Fetch section data for detach
|
|
21
|
+
// Fetch section data for detach — re-runs after custom section saves
|
|
21
22
|
useEffect(() => {
|
|
22
23
|
let cancelled = false;
|
|
23
|
-
|
|
24
|
+
const bustParam = refetchTick > 0 ? `?_t=${refetchTick}` : "";
|
|
25
|
+
fetch(`/api/custom-sections/${instance.custom_section_id}${bustParam}`)
|
|
24
26
|
.then((res) => res.ok ? res.json() : null)
|
|
25
27
|
.then((data) => {
|
|
26
28
|
if (!cancelled && data?.section) setSectionData(data.section);
|
|
27
29
|
})
|
|
28
30
|
.catch(() => {});
|
|
29
31
|
return () => { cancelled = true; };
|
|
30
|
-
}, [instance.custom_section_id]);
|
|
32
|
+
}, [instance.custom_section_id, refetchTick]);
|
|
31
33
|
|
|
32
34
|
const handleEdit = useCallback(async () => {
|
|
33
35
|
setLoadingEdit(true);
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup Export — core logic.
|
|
3
|
+
*
|
|
4
|
+
* Fetches all Sanity documents and R2 assets, then streams them
|
|
5
|
+
* into a ZIP archive. Uses `archiver` for streaming ZIP creation
|
|
6
|
+
* and the AWS S3 SDK for R2 file downloads.
|
|
7
|
+
*
|
|
8
|
+
* The ZIP is streamed directly to the HTTP response — no temp files
|
|
9
|
+
* or full-memory buffering required.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import archiver from "archiver";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { adminClient } from "../sanity/client";
|
|
15
|
+
import { logger } from "../logger";
|
|
16
|
+
import { ANDAMI_VERSION } from "../version";
|
|
17
|
+
import { createManifest } from "./manifest";
|
|
18
|
+
import {
|
|
19
|
+
getR2Config,
|
|
20
|
+
createS3Client,
|
|
21
|
+
listAllR2Files,
|
|
22
|
+
downloadR2File,
|
|
23
|
+
type R2FileEntry,
|
|
24
|
+
} from "./r2-helpers";
|
|
25
|
+
|
|
26
|
+
// ============================================
|
|
27
|
+
// Types (V2 client-side export)
|
|
28
|
+
// ============================================
|
|
29
|
+
|
|
30
|
+
/** Shape returned by prepareExportData() for the V2 client-side export. */
|
|
31
|
+
export interface ExportData {
|
|
32
|
+
manifest: import("./manifest").BackupManifest;
|
|
33
|
+
documents: {
|
|
34
|
+
pages: Record<string, unknown>[];
|
|
35
|
+
siteSettings: Record<string, unknown> | null;
|
|
36
|
+
siteStyles: Record<string, unknown> | null;
|
|
37
|
+
assetRegistry: Record<string, unknown> | null;
|
|
38
|
+
customSections: Record<string, unknown>[];
|
|
39
|
+
};
|
|
40
|
+
assets: {
|
|
41
|
+
r2_connected: boolean;
|
|
42
|
+
public_url: string | null;
|
|
43
|
+
files: { key: string; size: number }[];
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================
|
|
48
|
+
// Sanity document export
|
|
49
|
+
// ============================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch ALL Sanity documents needed for a complete backup.
|
|
53
|
+
*
|
|
54
|
+
* IMPORTANT: queries MUST NOT dereference Sanity references (i.e. no `->`).
|
|
55
|
+
* Using `project_ref->` or `internal_page->` in a GROQ projection would
|
|
56
|
+
* replace the raw `{ _ref, _type: "reference" }` objects with expanded
|
|
57
|
+
* document snapshots — breaking referential integrity on restore.
|
|
58
|
+
*
|
|
59
|
+
* By using the bare spread (`...` / no projection), Sanity returns every
|
|
60
|
+
* field as-is, including nested references, system fields (_id, _type,
|
|
61
|
+
* _createdAt, etc.) and any fields added in future framework versions.
|
|
62
|
+
*/
|
|
63
|
+
export async function fetchAllDocuments() {
|
|
64
|
+
// Fetch all document types in parallel
|
|
65
|
+
const [pages, siteSettings, siteStyles, assetRegistry, customSections] =
|
|
66
|
+
await Promise.all([
|
|
67
|
+
// Pages — full documents with all nested content. No projection → all
|
|
68
|
+
// references remain in their raw `{ _ref, _type: "reference" }` form.
|
|
69
|
+
adminClient.fetch(`*[_type == "page"] | order(title asc)`),
|
|
70
|
+
|
|
71
|
+
// Site settings — single document (nav_items keep raw internal_page refs)
|
|
72
|
+
adminClient.fetch(`*[_id == "siteSettings"][0]`),
|
|
73
|
+
|
|
74
|
+
// Site styles — single document
|
|
75
|
+
adminClient.fetch(`*[_type == "siteStyles"][0]`),
|
|
76
|
+
|
|
77
|
+
// Asset registry — single document (strip R2 credentials!)
|
|
78
|
+
adminClient.fetch(`*[_type == "assetRegistry"][0] {
|
|
79
|
+
_id, _type, _createdAt, _updatedAt, _rev,
|
|
80
|
+
storage_provider,
|
|
81
|
+
r2_bucket_url,
|
|
82
|
+
r2_bucket_name,
|
|
83
|
+
r2_connected_at,
|
|
84
|
+
assets,
|
|
85
|
+
relink_log
|
|
86
|
+
}`),
|
|
87
|
+
|
|
88
|
+
// Custom sections — all documents, full data (no dereferencing)
|
|
89
|
+
adminClient.fetch(`*[_type == "customSection"] | order(title asc)`),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
pages: pages ?? [],
|
|
94
|
+
siteSettings: siteSettings ?? null,
|
|
95
|
+
siteStyles: siteStyles ?? null,
|
|
96
|
+
assetRegistry: assetRegistry ?? null,
|
|
97
|
+
customSections: customSections ?? [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================
|
|
102
|
+
// ZIP stream creation
|
|
103
|
+
// ============================================
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create a streaming ZIP backup of the entire site.
|
|
107
|
+
*
|
|
108
|
+
* Returns a Node.js Readable stream that can be piped to the HTTP
|
|
109
|
+
* response. The ZIP contains:
|
|
110
|
+
* - manifest.json (metadata)
|
|
111
|
+
* - sanity/pages.json
|
|
112
|
+
* - sanity/settings.json
|
|
113
|
+
* - sanity/styles.json
|
|
114
|
+
* - sanity/asset-registry.json
|
|
115
|
+
* - sanity/custom-sections.json
|
|
116
|
+
* - assets/... (all R2 files, preserving folder structure)
|
|
117
|
+
*/
|
|
118
|
+
export async function createBackupStream(): Promise<{
|
|
119
|
+
stream: Readable;
|
|
120
|
+
filename: string;
|
|
121
|
+
}> {
|
|
122
|
+
const archive = archiver("zip", {
|
|
123
|
+
zlib: { level: 5 }, // Balanced compression (0=none, 9=max)
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Error handling — log and destroy
|
|
127
|
+
archive.on("error", (err) => {
|
|
128
|
+
logger.error("[Backup:Export]", "Archive error", err);
|
|
129
|
+
archive.destroy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
archive.on("warning", (warn) => {
|
|
133
|
+
logger.warn("[Backup:Export]", "Archive warning", warn);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Phase 1: Fetch all Sanity documents
|
|
137
|
+
logger.info("[Backup:Export]", "Fetching Sanity documents...");
|
|
138
|
+
const docs = await fetchAllDocuments();
|
|
139
|
+
|
|
140
|
+
// Phase 2: Resolve R2 config and list assets
|
|
141
|
+
let r2Files: R2FileEntry[] = [];
|
|
142
|
+
let r2Connected = false;
|
|
143
|
+
let totalAssetSize = 0;
|
|
144
|
+
|
|
145
|
+
const r2Config = await getR2Config();
|
|
146
|
+
if (r2Config) {
|
|
147
|
+
r2Connected = true;
|
|
148
|
+
try {
|
|
149
|
+
const s3 = createS3Client(r2Config);
|
|
150
|
+
r2Files = await listAllR2Files(s3, r2Config.bucketName);
|
|
151
|
+
totalAssetSize = r2Files.reduce((sum, f) => sum + f.size, 0);
|
|
152
|
+
logger.info(
|
|
153
|
+
"[Backup:Export]",
|
|
154
|
+
`Found ${r2Files.length} assets (${(totalAssetSize / 1024 / 1024).toFixed(1)} MB)`
|
|
155
|
+
);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logger.error("[Backup:Export]", "Failed to list R2 files", err);
|
|
158
|
+
// Continue without assets — Sanity data backup is still valuable
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
logger.info("[Backup:Export]", "R2 not connected — skipping asset backup");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Phase 3: Build manifest
|
|
165
|
+
const manifest = createManifest({
|
|
166
|
+
frameworkVersion: ANDAMI_VERSION,
|
|
167
|
+
pages: docs.pages.length,
|
|
168
|
+
siteSettings: docs.siteSettings ? 1 : 0,
|
|
169
|
+
siteStyles: docs.siteStyles ? 1 : 0,
|
|
170
|
+
assetRegistry: docs.assetRegistry ? 1 : 0,
|
|
171
|
+
customSections: docs.customSections.length,
|
|
172
|
+
assetCount: r2Files.length,
|
|
173
|
+
assetSizeBytes: totalAssetSize,
|
|
174
|
+
r2Connected,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Phase 4: Write Sanity data to archive
|
|
178
|
+
archive.append(JSON.stringify(manifest, null, 2), {
|
|
179
|
+
name: "manifest.json",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
archive.append(JSON.stringify(docs.pages, null, 2), {
|
|
183
|
+
name: "sanity/pages.json",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (docs.siteSettings) {
|
|
187
|
+
archive.append(JSON.stringify(docs.siteSettings, null, 2), {
|
|
188
|
+
name: "sanity/settings.json",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (docs.siteStyles) {
|
|
193
|
+
archive.append(JSON.stringify(docs.siteStyles, null, 2), {
|
|
194
|
+
name: "sanity/styles.json",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (docs.assetRegistry) {
|
|
199
|
+
archive.append(JSON.stringify(docs.assetRegistry, null, 2), {
|
|
200
|
+
name: "sanity/asset-registry.json",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (docs.customSections.length > 0) {
|
|
205
|
+
archive.append(JSON.stringify(docs.customSections, null, 2), {
|
|
206
|
+
name: "sanity/custom-sections.json",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Phase 5: Stream R2 assets into archive
|
|
211
|
+
// Assets are streamed one at a time to avoid memory pressure.
|
|
212
|
+
// Each file is downloaded from R2 and piped directly into the ZIP.
|
|
213
|
+
if (r2Config && r2Files.length > 0) {
|
|
214
|
+
const s3 = createS3Client(r2Config);
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < r2Files.length; i++) {
|
|
217
|
+
const file = r2Files[i];
|
|
218
|
+
try {
|
|
219
|
+
const bodyStream = await downloadR2File(
|
|
220
|
+
s3,
|
|
221
|
+
r2Config.bucketName,
|
|
222
|
+
file.key
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (bodyStream) {
|
|
226
|
+
// Convert Web ReadableStream to Node.js Readable for archiver
|
|
227
|
+
const nodeStream = Readable.fromWeb(bodyStream as import("node:stream/web").ReadableStream);
|
|
228
|
+
archive.append(nodeStream, { name: `assets/${file.key}` });
|
|
229
|
+
} else {
|
|
230
|
+
logger.warn(
|
|
231
|
+
"[Backup:Export]",
|
|
232
|
+
`Skipping empty file: ${file.key}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
logger.error(
|
|
237
|
+
"[Backup:Export]",
|
|
238
|
+
`Failed to add asset ${file.key}, skipping`,
|
|
239
|
+
err
|
|
240
|
+
);
|
|
241
|
+
// Continue with remaining assets — partial backup is better than none
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Finalize
|
|
247
|
+
// archive.finalize() signals the end of the ZIP. The stream will
|
|
248
|
+
// emit "end" once all data has been flushed.
|
|
249
|
+
archive.finalize();
|
|
250
|
+
|
|
251
|
+
// Build filename with date
|
|
252
|
+
const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
|
253
|
+
const filename = `andami-backup-${date}.zip`;
|
|
254
|
+
|
|
255
|
+
return { stream: archive, filename };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ============================================
|
|
259
|
+
// V2: Client-side export data preparation
|
|
260
|
+
// ============================================
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Prepare all data needed for a client-side backup export.
|
|
264
|
+
*
|
|
265
|
+
* Returns a JSON-serializable object containing:
|
|
266
|
+
* - manifest: backup metadata
|
|
267
|
+
* - documents: all Sanity documents (R2 credentials stripped)
|
|
268
|
+
* - assets: R2 connection info + flat file list (browser downloads directly)
|
|
269
|
+
*
|
|
270
|
+
* The browser uses this to build the ZIP locally — no binary data flows
|
|
271
|
+
* through Vercel. Typically completes in <2 seconds.
|
|
272
|
+
*/
|
|
273
|
+
export async function prepareExportData(): Promise<ExportData> {
|
|
274
|
+
// Fetch all Sanity documents
|
|
275
|
+
logger.info("[Backup:PrepareExport]", "Fetching Sanity documents...");
|
|
276
|
+
const docs = await fetchAllDocuments();
|
|
277
|
+
|
|
278
|
+
// Resolve R2 config and list assets
|
|
279
|
+
let r2Files: R2FileEntry[] = [];
|
|
280
|
+
let r2Connected = false;
|
|
281
|
+
let totalAssetSize = 0;
|
|
282
|
+
let publicUrl: string | null = null;
|
|
283
|
+
|
|
284
|
+
const r2Config = await getR2Config();
|
|
285
|
+
if (r2Config) {
|
|
286
|
+
r2Connected = true;
|
|
287
|
+
publicUrl = r2Config.bucketUrl || null;
|
|
288
|
+
try {
|
|
289
|
+
const s3 = createS3Client(r2Config);
|
|
290
|
+
r2Files = await listAllR2Files(s3, r2Config.bucketName);
|
|
291
|
+
totalAssetSize = r2Files.reduce((sum, f) => sum + f.size, 0);
|
|
292
|
+
logger.info(
|
|
293
|
+
"[Backup:PrepareExport]",
|
|
294
|
+
`Found ${r2Files.length} assets (${(totalAssetSize / 1024 / 1024).toFixed(1)} MB)`
|
|
295
|
+
);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
logger.error("[Backup:PrepareExport]", "Failed to list R2 files", err);
|
|
298
|
+
// Continue without asset list — Sanity data is still returned
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
logger.info("[Backup:PrepareExport]", "R2 not connected — skipping asset listing");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Build manifest
|
|
305
|
+
const manifest = createManifest({
|
|
306
|
+
frameworkVersion: ANDAMI_VERSION,
|
|
307
|
+
pages: docs.pages.length,
|
|
308
|
+
siteSettings: docs.siteSettings ? 1 : 0,
|
|
309
|
+
siteStyles: docs.siteStyles ? 1 : 0,
|
|
310
|
+
assetRegistry: docs.assetRegistry ? 1 : 0,
|
|
311
|
+
customSections: docs.customSections.length,
|
|
312
|
+
assetCount: r2Files.length,
|
|
313
|
+
assetSizeBytes: totalAssetSize,
|
|
314
|
+
r2Connected,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
manifest,
|
|
319
|
+
documents: docs,
|
|
320
|
+
assets: {
|
|
321
|
+
r2_connected: r2Connected,
|
|
322
|
+
public_url: publicUrl,
|
|
323
|
+
files: r2Files.map((f) => ({ key: f.key, size: f.size })),
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================
|
|
329
|
+
// Status check (for the admin UI)
|
|
330
|
+
// ============================================
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Gather backup readiness info without creating a backup.
|
|
334
|
+
* Used by the status endpoint and the backup page UI.
|
|
335
|
+
*/
|
|
336
|
+
export async function getBackupStatus() {
|
|
337
|
+
// Fetch document counts
|
|
338
|
+
const [pageCount, customSectionCount, settingsExists, stylesExists, registryExists] =
|
|
339
|
+
await Promise.all([
|
|
340
|
+
adminClient.fetch(`count(*[_type == "page"])`),
|
|
341
|
+
adminClient.fetch(`count(*[_type == "customSection"])`),
|
|
342
|
+
adminClient.fetch(`defined(*[_id == "siteSettings"][0]._id)`),
|
|
343
|
+
adminClient.fetch(`defined(*[_type == "siteStyles"][0]._id)`),
|
|
344
|
+
adminClient.fetch(`defined(*[_type == "assetRegistry"][0]._id)`),
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
// Check R2 status
|
|
348
|
+
let r2Connected = false;
|
|
349
|
+
let assetCount = 0;
|
|
350
|
+
let assetSizeBytes = 0;
|
|
351
|
+
|
|
352
|
+
const r2Config = await getR2Config();
|
|
353
|
+
if (r2Config) {
|
|
354
|
+
r2Connected = true;
|
|
355
|
+
try {
|
|
356
|
+
const s3 = createS3Client(r2Config);
|
|
357
|
+
const files = await listAllR2Files(s3, r2Config.bucketName);
|
|
358
|
+
assetCount = files.length;
|
|
359
|
+
assetSizeBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
360
|
+
} catch {
|
|
361
|
+
// R2 connected but listing failed — report as connected with 0 assets
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
r2_connected: r2Connected,
|
|
367
|
+
asset_count: assetCount,
|
|
368
|
+
asset_size_bytes: assetSizeBytes,
|
|
369
|
+
document_counts: {
|
|
370
|
+
pages: pageCount ?? 0,
|
|
371
|
+
custom_sections: customSectionCount ?? 0,
|
|
372
|
+
site_settings: settingsExists ?? false,
|
|
373
|
+
site_styles: stylesExists ?? false,
|
|
374
|
+
asset_registry: registryExists ?? false,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup Manifest — shared types and validation.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is the first file in the backup ZIP and describes
|
|
5
|
+
* its contents: framework version, creation date, document counts,
|
|
6
|
+
* and asset inventory.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ============================================
|
|
10
|
+
// Types
|
|
11
|
+
// ============================================
|
|
12
|
+
|
|
13
|
+
export interface BackupManifest {
|
|
14
|
+
/** Manifest schema version (for forward-compat checks) */
|
|
15
|
+
version: 1;
|
|
16
|
+
/** @morphika/andami package version that created the backup */
|
|
17
|
+
framework_version: string;
|
|
18
|
+
/** ISO 8601 timestamp */
|
|
19
|
+
created_at: string;
|
|
20
|
+
/** Counts of each Sanity document type */
|
|
21
|
+
documents: {
|
|
22
|
+
pages: number;
|
|
23
|
+
site_settings: number;
|
|
24
|
+
site_styles: number;
|
|
25
|
+
asset_registry: number;
|
|
26
|
+
custom_sections: number;
|
|
27
|
+
};
|
|
28
|
+
/** R2 asset summary */
|
|
29
|
+
assets: {
|
|
30
|
+
total_files: number;
|
|
31
|
+
total_size_bytes: number;
|
|
32
|
+
/** Whether R2 was connected at backup time */
|
|
33
|
+
r2_connected: boolean;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Summary of what's inside a backup — used by the status endpoint
|
|
39
|
+
* and the pre-restore validation UI.
|
|
40
|
+
*/
|
|
41
|
+
export interface BackupStatus {
|
|
42
|
+
r2_connected: boolean;
|
|
43
|
+
asset_count: number;
|
|
44
|
+
asset_size_bytes: number;
|
|
45
|
+
document_counts: {
|
|
46
|
+
pages: number;
|
|
47
|
+
custom_sections: number;
|
|
48
|
+
site_settings: boolean;
|
|
49
|
+
site_styles: boolean;
|
|
50
|
+
asset_registry: boolean;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================
|
|
55
|
+
// Validation
|
|
56
|
+
// ============================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate that a parsed object is a valid BackupManifest.
|
|
60
|
+
* Returns the manifest if valid, throws if not.
|
|
61
|
+
*/
|
|
62
|
+
export function validateManifest(data: unknown): BackupManifest {
|
|
63
|
+
if (!data || typeof data !== "object") {
|
|
64
|
+
throw new Error("Invalid backup: manifest.json is missing or not an object");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const m = data as Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
if (m.version !== 1) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Unsupported backup version: ${m.version}. This version of Andami supports version 1.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof m.created_at !== "string") {
|
|
76
|
+
throw new Error("Invalid backup: manifest.json missing created_at");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!m.documents || typeof m.documents !== "object") {
|
|
80
|
+
throw new Error("Invalid backup: manifest.json missing documents section");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!m.assets || typeof m.assets !== "object") {
|
|
84
|
+
throw new Error("Invalid backup: manifest.json missing assets section");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return data as BackupManifest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a manifest object for a new backup.
|
|
92
|
+
*/
|
|
93
|
+
export function createManifest(params: {
|
|
94
|
+
frameworkVersion: string;
|
|
95
|
+
pages: number;
|
|
96
|
+
siteSettings: number;
|
|
97
|
+
siteStyles: number;
|
|
98
|
+
assetRegistry: number;
|
|
99
|
+
customSections: number;
|
|
100
|
+
assetCount: number;
|
|
101
|
+
assetSizeBytes: number;
|
|
102
|
+
r2Connected: boolean;
|
|
103
|
+
}): BackupManifest {
|
|
104
|
+
return {
|
|
105
|
+
version: 1,
|
|
106
|
+
framework_version: params.frameworkVersion,
|
|
107
|
+
created_at: new Date().toISOString(),
|
|
108
|
+
documents: {
|
|
109
|
+
pages: params.pages,
|
|
110
|
+
site_settings: params.siteSettings,
|
|
111
|
+
site_styles: params.siteStyles,
|
|
112
|
+
asset_registry: params.assetRegistry,
|
|
113
|
+
custom_sections: params.customSections,
|
|
114
|
+
},
|
|
115
|
+
assets: {
|
|
116
|
+
total_files: params.assetCount,
|
|
117
|
+
total_size_bytes: params.assetSizeBytes,
|
|
118
|
+
r2_connected: params.r2Connected,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|