@morphika/andami 0.2.9 → 0.2.11

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.
@@ -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
- fetch(`/api/custom-sections/${instance.custom_section_id}`)
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
- fetch(`/api/custom-sections/${instance.custom_section_id}`)
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
- fetch(`/api/custom-sections/${instance.custom_section_id}`)
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
+ }