create-nextblock 0.14.2 → 0.14.5

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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  3. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  4. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  5. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  6. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  7. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  8. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  9. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
  10. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  11. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  12. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  13. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  14. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  15. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  16. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  18. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  20. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  21. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  22. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  23. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  24. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  25. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  26. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  27. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  28. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  29. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  30. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  31. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  32. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  33. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  34. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  35. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  36. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  37. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  38. package/templates/nextblock-template/app/layout.tsx +49 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/app/providers.tsx +16 -4
  44. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  45. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  46. package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
  47. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  48. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  49. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
  50. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
  51. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  52. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  53. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  54. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  55. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  56. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  57. package/templates/nextblock-template/lib/search/server.ts +3 -0
  58. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
  59. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  60. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  61. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  62. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  63. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  64. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  65. package/templates/nextblock-template/next-env.d.ts +2 -2
  66. package/templates/nextblock-template/package.json +1 -1
  67. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  68. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -1,471 +1,549 @@
1
- // apps/nextblock/app/cms/revisions/service.ts
2
- "use server";
3
-
4
- import { createClient } from "@nextblock-cms/db/server";
5
- import type { Json } from "@nextblock-cms/db";
6
- import { compare, applyPatch } from 'fast-json-patch';
7
- import type { FullPageContent, FullPostContent } from './utils';
8
-
9
-
10
- function shouldCreateSnapshot(currentVersion: number): boolean {
11
- // Create a snapshot every 20 revisions
12
- return currentVersion % 20 === 0;
13
- }
14
-
15
- export async function createPageRevision(
16
- pageId: number,
17
- authorId: string,
18
- previousContent: FullPageContent,
19
- newContent: FullPageContent
20
- ) {
21
- const supabase = createClient();
22
-
23
- // Get current version
24
- const { data: page, error: pageError } = await supabase
25
- .from('pages')
26
- .select('version')
27
- .eq('id', pageId)
28
- .single();
29
- if (pageError || !page) return { error: 'Page not found' } as const;
30
-
31
- const currentVersion = page.version ?? 1;
32
- const nextVersion = currentVersion + 1;
33
-
34
- // If we are moving to version 2, it means Version 1 was never saved (it was the initial state).
35
- // We should save Version 1 now so we have a history base.
36
- if (nextVersion === 2) {
37
- await supabase.from('page_revisions').insert({
38
- page_id: pageId,
39
- author_id: authorId, // Can be current author or null
40
- version: 1,
41
- revision_type: 'snapshot',
42
- content: previousContent as unknown as Json,
43
- });
44
- }
45
-
46
- const makeSnapshot = shouldCreateSnapshot(currentVersion) || nextVersion === 2; // ensure early snapshot cadence
47
-
48
- const revisionType: 'snapshot' | 'diff' = makeSnapshot ? 'snapshot' : 'diff';
49
- const content: Json = makeSnapshot ? (newContent as unknown as Json) : (compare(previousContent, newContent) as unknown as Json);
50
-
51
- // If it's a diff and there are no changes, skip creating revision
52
- if (revisionType === 'diff' && Array.isArray(content) && content.length === 0) {
53
- return { success: true as const, version: currentVersion }; // Return current version as we didn't bump
54
- }
55
-
56
- const { error: insertError } = await supabase.from('page_revisions').insert({
57
- page_id: pageId,
58
- author_id: authorId,
59
- version: nextVersion,
60
- revision_type: revisionType,
61
- content,
62
- });
63
- if (insertError) return { error: `Failed to insert page revision: ${insertError.message}` } as const;
64
-
65
- const { error: updateVersionError } = await supabase
66
- .from('pages')
67
- .update({ version: nextVersion })
68
- .eq('id', pageId);
69
- if (updateVersionError) return { error: `Failed to bump page version: ${updateVersionError.message}` } as const;
70
-
71
- return { success: true as const, version: nextVersion };
72
- }
73
-
74
- export async function createPostRevision(
75
- postId: number,
76
- authorId: string,
77
- previousContent: FullPostContent,
78
- newContent: FullPostContent
79
- ) {
80
- const supabase = createClient();
81
-
82
- const { data: post, error: postError } = await supabase
83
- .from('posts')
84
- .select('version')
85
- .eq('id', postId)
86
- .single();
87
- if (postError || !post) return { error: 'Post not found' } as const;
88
-
89
- const currentVersion = post.version ?? 1;
90
- const nextVersion = currentVersion + 1;
91
-
92
- if (nextVersion === 2) {
93
- await supabase.from('post_revisions').insert({
94
- post_id: postId,
95
- author_id: authorId,
96
- version: 1,
97
- revision_type: 'snapshot',
98
- content: previousContent as unknown as Json,
99
- });
100
- }
101
-
102
- const makeSnapshot = shouldCreateSnapshot(currentVersion) || nextVersion === 2;
103
-
104
- const revisionType: 'snapshot' | 'diff' = makeSnapshot ? 'snapshot' : 'diff';
105
- const content: Json = makeSnapshot ? (newContent as unknown as Json) : (compare(previousContent, newContent) as unknown as Json);
106
-
107
- if (revisionType === 'diff' && Array.isArray(content) && content.length === 0) {
108
- return { success: true as const, version: currentVersion };
109
- }
110
-
111
- const { error: insertError } = await supabase.from('post_revisions').insert({
112
- post_id: postId,
113
- author_id: authorId,
114
- version: nextVersion,
115
- revision_type: revisionType,
116
- content,
117
- });
118
- if (insertError) return { error: `Failed to insert post revision: ${insertError.message}` } as const;
119
-
120
- const { error: updateVersionError } = await supabase
121
- .from('posts')
122
- .update({ version: nextVersion })
123
- .eq('id', postId);
124
- if (updateVersionError) return { error: `Failed to bump post version: ${updateVersionError.message}` } as const;
125
-
126
- return { success: true as const, version: nextVersion };
127
- }
128
-
129
- export async function restorePageToVersion(pageId: number, targetVersion: number, authorId: string) {
130
- const supabase = createClient();
131
-
132
- // 1. Find latest snapshot at or before target
133
- const { data: snapshot, error: snapshotError } = await supabase
134
- .from('page_revisions')
135
- .select('version, content, revision_type')
136
- .eq('page_id', pageId)
137
- .lte('version', targetVersion)
138
- .eq('revision_type', 'snapshot')
139
- .order('version', { ascending: false })
140
- .limit(1)
141
- .maybeSingle();
142
-
143
- let content: FullPageContent;
144
- let baseVersion = 0;
145
-
146
- if (snapshot) {
147
- content = snapshot.content as unknown as FullPageContent;
148
- baseVersion = snapshot.version;
149
- } else if (targetVersion === 1) {
150
- // Fallback for missing Version 1: use empty content with current meta
151
- const { data: pageMeta } = await supabase
152
- .from('pages')
153
- .select('title, slug, language_id, status, meta_title, meta_description, feature_image_id')
154
- .eq('id', pageId)
155
- .single();
156
- if (!pageMeta) return { error: 'Page not found.' } as const;
157
- content = {
158
- meta: pageMeta,
159
- blocks: [],
160
- };
161
- baseVersion = 1;
162
- } else {
163
- if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
164
- return { error: 'No snapshot found at or before target version.' } as const;
165
- }
166
-
167
- // 2. Fetch diffs up to target and apply (only if we are not already at target)
168
- if (baseVersion < targetVersion) {
169
- const { data: diffs, error: diffsError } = await supabase
170
- .from('page_revisions')
171
- .select('version, content, revision_type')
172
- .eq('page_id', pageId)
173
- .gt('version', baseVersion)
174
- .lte('version', targetVersion)
175
- .order('version', { ascending: true });
176
- if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
177
-
178
- for (const r of diffs || []) {
179
- if (r.revision_type === 'diff') {
180
- const ops = r.content as any[];
181
- const result = applyPatch(content as any, ops, /*validate*/ false, /*mutateDocument*/ true);
182
- content = result.newDocument as unknown as FullPageContent;
183
- } else {
184
- content = r.content as unknown as FullPageContent;
185
- }
186
- }
187
- }
188
-
189
- // Determine next version number (append a new revision for restored state)
190
- const { data: pageRow } = await supabase
191
- .from('pages')
192
- .select('version')
193
- .eq('id', pageId)
194
- .single();
195
- const newVersion = ((pageRow?.version as number | null) ?? 1) + 1;
196
-
197
- // 3. Apply to DB: update page meta and replace blocks; bump to newVersion
198
- const { error: updatePageError } = await supabase
199
- .from('pages')
200
- .update({
201
- title: content.meta.title,
202
- slug: content.meta.slug,
203
- language_id: content.meta.language_id,
204
- status: content.meta.status,
205
- meta_title: content.meta.meta_title,
206
- meta_description: content.meta.meta_description,
207
- feature_image_id: content.meta.feature_image_id,
208
- version: newVersion,
209
- })
210
- .eq('id', pageId);
211
- if (updatePageError) return { error: `Failed to update page: ${updatePageError.message}` } as const;
212
-
213
- // delete all existing blocks for this page then reinsert
214
- const { error: deleteError } = await supabase.from('blocks').delete().eq('page_id', pageId);
215
- if (deleteError) return { error: `Failed to clear blocks: ${deleteError.message}` } as const;
216
-
217
- if (content.blocks.length > 0) {
218
- const toInsert = content.blocks.map(b => ({
219
- page_id: pageId,
220
- post_id: null,
221
- language_id: b.language_id,
222
- block_type: b.block_type,
223
- content: b.content,
224
- order: b.order,
225
- }));
226
- const { error: insertError } = await supabase.from('blocks').insert(toInsert);
227
- if (insertError) return { error: `Failed to insert blocks: ${insertError.message}` } as const;
228
- }
229
-
230
- // 4. Record a new snapshot revision representing the restored state at newVersion
231
- const { error: revErr } = await supabase.from('page_revisions').insert({
232
- page_id: pageId,
233
- author_id: authorId,
234
- version: newVersion,
235
- revision_type: 'snapshot',
236
- content: content as unknown as Json,
237
- });
238
- if (revErr) return { error: `Failed to write restored revision: ${revErr.message}` } as const;
239
-
240
- return { success: true as const };
241
- }
242
-
243
- export async function restorePostToVersion(postId: number, targetVersion: number, authorId: string) {
244
- const supabase = createClient();
245
-
246
- const { data: snapshot, error: snapshotError } = await supabase
247
- .from('post_revisions')
248
- .select('version, content, revision_type')
249
- .eq('post_id', postId)
250
- .lte('version', targetVersion)
251
- .eq('revision_type', 'snapshot')
252
- .order('version', { ascending: false })
253
- .limit(1)
254
- .maybeSingle();
255
-
256
- let content: FullPostContent;
257
- let baseVersion = 0;
258
-
259
- if (snapshot) {
260
- content = snapshot.content as unknown as FullPostContent;
261
- baseVersion = snapshot.version;
262
- } else if (targetVersion === 1) {
263
- const { data: postMeta } = await supabase
264
- .from('posts')
265
- .select('title, slug, language_id, status, meta_title, meta_description, label, excerpt, subtitle, published_at, feature_image_id')
266
- .eq('id', postId)
267
- .single();
268
- if (!postMeta) return { error: 'Post not found.' } as const;
269
- content = {
270
- meta: postMeta,
271
- blocks: [],
272
- };
273
- baseVersion = 1;
274
- } else {
275
- if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
276
- return { error: 'No snapshot found at or before target version.' } as const;
277
- }
278
-
279
- if (baseVersion < targetVersion) {
280
- const { data: diffs, error: diffsError } = await supabase
281
- .from('post_revisions')
282
- .select('version, content, revision_type')
283
- .eq('post_id', postId)
284
- .gt('version', baseVersion)
285
- .lte('version', targetVersion)
286
- .order('version', { ascending: true });
287
- if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
288
-
289
- for (const r of diffs || []) {
290
- if (r.revision_type === 'diff') {
291
- const ops = r.content as any[];
292
- const result = applyPatch(content as any, ops, /*validate*/ false, /*mutateDocument*/ true);
293
- content = result.newDocument as unknown as FullPostContent;
294
- } else {
295
- content = r.content as unknown as FullPostContent;
296
- }
297
- }
298
- }
299
-
300
- // Determine next version for post
301
- const { data: postRow } = await supabase
302
- .from('posts')
303
- .select('version')
304
- .eq('id', postId)
305
- .single();
306
- const newVersion = ((postRow?.version as number | null) ?? 1) + 1;
307
-
308
- const { error: updatePostError } = await supabase
309
- .from('posts')
310
- .update({
311
- title: content.meta.title,
312
- slug: content.meta.slug,
313
- language_id: content.meta.language_id,
314
- status: content.meta.status,
315
- meta_title: content.meta.meta_title,
316
- meta_description: content.meta.meta_description,
317
- label: content.meta.label,
318
- excerpt: content.meta.excerpt,
319
- subtitle: content.meta.subtitle,
320
- published_at: content.meta.published_at,
321
- feature_image_id: content.meta.feature_image_id,
322
- version: newVersion,
323
- })
324
- .eq('id', postId);
325
- if (updatePostError) return { error: `Failed to update post: ${updatePostError.message}` } as const;
326
-
327
- const { error: deleteError } = await supabase.from('blocks').delete().eq('post_id', postId);
328
- if (deleteError) return { error: `Failed to clear blocks: ${deleteError.message}` } as const;
329
-
330
- if (content.blocks.length > 0) {
331
- const toInsert = content.blocks.map(b => ({
332
- page_id: null,
333
- post_id: postId,
334
- language_id: b.language_id,
335
- block_type: b.block_type,
336
- content: b.content,
337
- order: b.order,
338
- }));
339
- const { error: insertError } = await supabase.from('blocks').insert(toInsert);
340
- if (insertError) return { error: `Failed to insert blocks: ${insertError.message}` } as const;
341
- }
342
-
343
- const { error: revErr } = await supabase.from('post_revisions').insert({
344
- post_id: postId,
345
- author_id: authorId,
346
- version: newVersion,
347
- revision_type: 'snapshot',
348
- content: content as unknown as Json,
349
- });
350
- if (revErr) return { error: `Failed to write restored revision: ${revErr.message}` } as const;
351
-
352
- return { success: true as const };
353
- }
354
-
355
- export async function reconstructPageVersionContent(pageId: number, targetVersion: number) {
356
- const supabase = createClient();
357
-
358
- const { data: snapshot, error: snapshotError } = await supabase
359
- .from('page_revisions')
360
- .select('version, content, revision_type')
361
- .eq('page_id', pageId)
362
- .lte('version', targetVersion)
363
- .eq('revision_type', 'snapshot')
364
- .order('version', { ascending: false })
365
- .limit(1)
366
- .maybeSingle();
367
-
368
- let content: FullPageContent;
369
- let baseVersion = 0;
370
-
371
- if (snapshot) {
372
- content = snapshot.content as unknown as FullPageContent;
373
- baseVersion = snapshot.version;
374
- } else if (targetVersion === 1) {
375
- const { data: pageMeta } = await supabase
376
- .from('pages')
377
- .select('title, slug, language_id, status, meta_title, meta_description, feature_image_id')
378
- .eq('id', pageId)
379
- .single();
380
- if (!pageMeta) return { error: 'Page not found.' } as const;
381
- content = {
382
- meta: pageMeta,
383
- blocks: [],
384
- };
385
- baseVersion = 1;
386
- } else {
387
- if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
388
- return { error: 'No snapshot found at or before target version.' } as const;
389
- }
390
-
391
- if (baseVersion < targetVersion) {
392
- const { data: diffs, error: diffsError } = await supabase
393
- .from('page_revisions')
394
- .select('version, content, revision_type')
395
- .eq('page_id', pageId)
396
- .gt('version', baseVersion)
397
- .lte('version', targetVersion)
398
- .order('version', { ascending: true });
399
- if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
400
-
401
- for (const r of diffs || []) {
402
- if (r.revision_type === 'diff') {
403
- const ops = r.content as any[];
404
- const result = applyPatch(content as any, ops, false, true);
405
- content = result.newDocument as unknown as FullPageContent;
406
- } else {
407
- content = r.content as unknown as FullPageContent;
408
- }
409
- }
410
- }
411
- return { success: true as const, content };
412
- }
413
-
414
- export async function reconstructPostVersionContent(postId: number, targetVersion: number) {
415
- const supabase = createClient();
416
-
417
- const { data: snapshot, error: snapshotError } = await supabase
418
- .from('post_revisions')
419
- .select('version, content, revision_type')
420
- .eq('post_id', postId)
421
- .lte('version', targetVersion)
422
- .eq('revision_type', 'snapshot')
423
- .order('version', { ascending: false })
424
- .limit(1)
425
- .maybeSingle();
426
-
427
- let content: FullPostContent;
428
- let baseVersion = 0;
429
-
430
- if (snapshot) {
431
- content = snapshot.content as unknown as FullPostContent;
432
- baseVersion = snapshot.version;
433
- } else if (targetVersion === 1) {
434
- const { data: postMeta } = await supabase
435
- .from('posts')
436
- .select('title, slug, language_id, status, meta_title, meta_description, label, excerpt, subtitle, published_at, feature_image_id')
437
- .eq('id', postId)
438
- .single();
439
- if (!postMeta) return { error: 'Post not found.' } as const;
440
- content = {
441
- meta: postMeta,
442
- blocks: [],
443
- };
444
- baseVersion = 1;
445
- } else {
446
- if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
447
- return { error: 'No snapshot found at or before target version.' } as const;
448
- }
449
-
450
- if (baseVersion < targetVersion) {
451
- const { data: diffs, error: diffsError } = await supabase
452
- .from('post_revisions')
453
- .select('version, content, revision_type')
454
- .eq('post_id', postId)
455
- .gt('version', baseVersion)
456
- .lte('version', targetVersion)
457
- .order('version', { ascending: true });
458
- if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
459
-
460
- for (const r of diffs || []) {
461
- if (r.revision_type === 'diff') {
462
- const ops = r.content as any[];
463
- const result = applyPatch(content as any, ops, false, true);
464
- content = result.newDocument as unknown as FullPostContent;
465
- } else {
466
- content = r.content as unknown as FullPostContent;
467
- }
468
- }
469
- }
470
- return { success: true as const, content };
471
- }
1
+ // apps/nextblock/app/cms/revisions/service.ts
2
+ "use server";
3
+
4
+ import { createClient } from "@nextblock-cms/db/server";
5
+ import type { Json } from "@nextblock-cms/db";
6
+ import { compare, applyPatch } from 'fast-json-patch';
7
+ import {
8
+ PAGE_META_COLUMNS,
9
+ POST_META_COLUMNS,
10
+ PRODUCT_META_COLUMNS,
11
+ buildRestoreMetaUpdate,
12
+ getFullPageContent,
13
+ getFullPostContent,
14
+ getFullProductContent,
15
+ } from './utils';
16
+ import type {
17
+ AnyFullContent,
18
+ FullPageContent,
19
+ FullPostContent,
20
+ FullProductContent,
21
+ } from './utils';
22
+
23
+ /** A full snapshot is stored whenever this many versions have accumulated since the last one. */
24
+ const SNAPSHOT_INTERVAL = 20;
25
+
26
+ export type RevisionParentType = 'page' | 'post' | 'product';
27
+
28
+ export type RevisionResult =
29
+ | { success: true; version: number; recorded: boolean }
30
+ | { error: string };
31
+
32
+ export type RestoreResult =
33
+ | { success: true; version: number }
34
+ | { error: string };
35
+
36
+ export type ReconstructResult<T> =
37
+ | { success: true; content: T }
38
+ | { error: string };
39
+
40
+ /**
41
+ * Everything that differs between pages, posts and products. Keeping one implementation
42
+ * behind this table is deliberate: the previous copy-per-entity version had drifted so far
43
+ * that the page and post restore paths no longer agreed on which columns they wrote.
44
+ */
45
+ interface RevisionSpec {
46
+ parentTable: 'pages' | 'posts' | 'products';
47
+ revisionTable: 'page_revisions' | 'post_revisions' | 'product_revisions';
48
+ parentColumn: 'page_id' | 'post_id' | 'product_id';
49
+ metaColumns: readonly string[];
50
+ label: string;
51
+ draft: { table: 'content_drafts'; parentType: 'page' | 'post' } | { table: 'product_drafts' };
52
+ }
53
+
54
+ const PAGE_SPEC: RevisionSpec = {
55
+ parentTable: 'pages',
56
+ revisionTable: 'page_revisions',
57
+ parentColumn: 'page_id',
58
+ metaColumns: PAGE_META_COLUMNS,
59
+ label: 'Page',
60
+ draft: { table: 'content_drafts', parentType: 'page' },
61
+ };
62
+
63
+ const POST_SPEC: RevisionSpec = {
64
+ parentTable: 'posts',
65
+ revisionTable: 'post_revisions',
66
+ parentColumn: 'post_id',
67
+ metaColumns: POST_META_COLUMNS,
68
+ label: 'Post',
69
+ draft: { table: 'content_drafts', parentType: 'post' },
70
+ };
71
+
72
+ const PRODUCT_SPEC: RevisionSpec = {
73
+ parentTable: 'products',
74
+ revisionTable: 'product_revisions',
75
+ parentColumn: 'product_id',
76
+ metaColumns: PRODUCT_META_COLUMNS,
77
+ label: 'Product',
78
+ draft: { table: 'product_drafts' },
79
+ };
80
+
81
+ function specFor(parentType: RevisionParentType): RevisionSpec {
82
+ if (parentType === 'page') return PAGE_SPEC;
83
+ if (parentType === 'post') return POST_SPEC;
84
+ return PRODUCT_SPEC;
85
+ }
86
+
87
+ /** Postgres unique-violation. Concurrent saves race for the same version number. */
88
+ const UNIQUE_VIOLATION = '23505';
89
+
90
+ type Db = any;
91
+
92
+ async function readParentVersion(
93
+ supabase: Db,
94
+ spec: RevisionSpec,
95
+ parentId: number | string
96
+ ): Promise<number | null> {
97
+ const { data, error } = await supabase
98
+ .from(spec.parentTable)
99
+ .select('version')
100
+ .eq('id', parentId)
101
+ .single();
102
+ if (error || !data) return null;
103
+ return (data.version as number | null) ?? 1;
104
+ }
105
+
106
+ /** The newest stored snapshot at or below `maxVersion`, or null if the chain has no base. */
107
+ async function findBaseSnapshot(
108
+ supabase: Db,
109
+ spec: RevisionSpec,
110
+ parentId: number | string,
111
+ maxVersion: number
112
+ ): Promise<{ version: number; content: unknown } | null> {
113
+ const { data } = await supabase
114
+ .from(spec.revisionTable)
115
+ .select('version, content')
116
+ .eq(spec.parentColumn, parentId)
117
+ .lte('version', maxVersion)
118
+ .eq('revision_type', 'snapshot')
119
+ .order('version', { ascending: false })
120
+ .limit(1)
121
+ .maybeSingle();
122
+ return data ? { version: data.version as number, content: data.content } : null;
123
+ }
124
+
125
+ /**
126
+ * Guarantee the diff chain has a snapshot to replay onto.
127
+ *
128
+ * A diff is only meaningful relative to a base. The old implementation wrote that base
129
+ * exactly once on the 1 -> 2 transition — and discarded the insert's error, so any page
130
+ * that missed that single moment (RLS rejection, a version bumped by another code path,
131
+ * content seeded straight into the table) accumulated diffs forever with nothing beneath
132
+ * them. Checking the invariant on every write instead means such a chain repairs itself on
133
+ * the next save.
134
+ */
135
+ async function ensureBaseSnapshot(
136
+ supabase: Db,
137
+ spec: RevisionSpec,
138
+ parentId: number | string,
139
+ authorId: string | null,
140
+ currentVersion: number,
141
+ currentContent: AnyFullContent
142
+ ): Promise<{ version: number } | { error: string }> {
143
+ const existing = await findBaseSnapshot(supabase, spec, parentId, currentVersion);
144
+ if (existing) return { version: existing.version };
145
+
146
+ const { error } = await supabase.from(spec.revisionTable).insert({
147
+ [spec.parentColumn]: parentId,
148
+ author_id: authorId,
149
+ version: currentVersion,
150
+ revision_type: 'snapshot',
151
+ content: currentContent as unknown as Json,
152
+ });
153
+
154
+ if (error) {
155
+ // Another writer inserted this version first; theirs is just as valid a base.
156
+ if (error.code === UNIQUE_VIOLATION) return { version: currentVersion };
157
+ return { error: `Failed to record the baseline revision: ${error.message}` };
158
+ }
159
+
160
+ return { version: currentVersion };
161
+ }
162
+
163
+ async function lastSnapshotVersion(
164
+ supabase: Db,
165
+ spec: RevisionSpec,
166
+ parentId: number | string
167
+ ): Promise<number> {
168
+ const { data } = await supabase
169
+ .from(spec.revisionTable)
170
+ .select('version')
171
+ .eq(spec.parentColumn, parentId)
172
+ .eq('revision_type', 'snapshot')
173
+ .order('version', { ascending: false })
174
+ .limit(1)
175
+ .maybeSingle();
176
+ return (data?.version as number | undefined) ?? 0;
177
+ }
178
+
179
+ /**
180
+ * Record the transition from `previousContent` to `newContent` as a new revision.
181
+ *
182
+ * Returns `recorded: false` when the two states are identical — that is a successful
183
+ * no-op, not a failure, and it deliberately does not consume a version number.
184
+ */
185
+ async function createRevisionInternal(
186
+ spec: RevisionSpec,
187
+ parentId: number | string,
188
+ authorId: string | null,
189
+ previousContent: AnyFullContent,
190
+ newContent: AnyFullContent,
191
+ client?: Db
192
+ ): Promise<RevisionResult> {
193
+ const supabase: Db = client ?? createClient();
194
+
195
+ const currentVersion = await readParentVersion(supabase, spec, parentId);
196
+ if (currentVersion === null) return { error: `${spec.label} not found` };
197
+
198
+ // Nothing changed. Checked before the snapshot decision so a no-op publish can never
199
+ // burn a version number or write a redundant full snapshot.
200
+ const ops = compare(previousContent as object, newContent as object);
201
+ if (ops.length === 0) {
202
+ return { success: true, version: currentVersion, recorded: false };
203
+ }
204
+
205
+ const base = await ensureBaseSnapshot(
206
+ supabase, spec, parentId, authorId, currentVersion, previousContent
207
+ );
208
+ if ('error' in base) return { error: base.error };
209
+
210
+ const nextVersion = currentVersion + 1;
211
+ const lastSnapshot = await lastSnapshotVersion(supabase, spec, parentId);
212
+
213
+ // A diff is only meaningful if replaying the existing chain actually reproduces the
214
+ // document this diff was computed against. Counting version numbers is not enough:
215
+ // writes that bypass the revision engine (a live-mode import, a script) change content
216
+ // without consuming a version, leaving a chain that looks contiguous but no longer
217
+ // represents the live state. Replaying it here is the only honest check — and when it
218
+ // disagrees, a full snapshot silently repairs the chain from this point on.
219
+ let makeSnapshot = nextVersion - lastSnapshot >= SNAPSHOT_INTERVAL;
220
+ if (!makeSnapshot) {
221
+ const replay = await reconstructInternal(spec, parentId, currentVersion, supabase);
222
+ makeSnapshot =
223
+ 'error' in replay ||
224
+ compare(replay.content as object, previousContent as object).length > 0;
225
+ }
226
+
227
+ const insertAt = async (version: number, asSnapshot: boolean) =>
228
+ supabase.from(spec.revisionTable).insert({
229
+ [spec.parentColumn]: parentId,
230
+ author_id: authorId,
231
+ version,
232
+ revision_type: asSnapshot ? 'snapshot' : 'diff',
233
+ content: (asSnapshot ? (newContent as unknown as Json) : (ops as unknown as Json)),
234
+ });
235
+
236
+ let targetVersion = nextVersion;
237
+ let { error: insertError } = await insertAt(targetVersion, makeSnapshot);
238
+
239
+ // Whoever wins the UNIQUE(parent_id, version) insert owns that version number. If we
240
+ // lost the race, re-read and take the next one — but store the full document rather
241
+ // than the diff, because the winner has moved the chain past the base our ops assumed.
242
+ if (insertError?.code === UNIQUE_VIOLATION) {
243
+ const refreshed = await readParentVersion(supabase, spec, parentId);
244
+ targetVersion = (refreshed ?? currentVersion) + 1;
245
+ ({ error: insertError } = await insertAt(targetVersion, true));
246
+ }
247
+
248
+ if (insertError) {
249
+ return { error: `Failed to insert ${spec.label.toLowerCase()} revision: ${insertError.message}` };
250
+ }
251
+
252
+ // The revision row is written before the version bump on purpose: a crash between the
253
+ // two leaves a recoverable extra revision, whereas the old order left the parent
254
+ // pointing at a version that had no history behind it.
255
+ const { error: bumpError } = await supabase
256
+ .from(spec.parentTable)
257
+ .update({ version: targetVersion })
258
+ .eq('id', parentId);
259
+ if (bumpError) {
260
+ return { error: `Failed to bump ${spec.label.toLowerCase()} version: ${bumpError.message}` };
261
+ }
262
+
263
+ return { success: true, version: targetVersion, recorded: true };
264
+ }
265
+
266
+ /**
267
+ * Resolve the exact content stored at `targetVersion` by replaying diffs onto the nearest
268
+ * snapshot at or below it.
269
+ *
270
+ * There is deliberately no fallback for a missing baseline. The previous implementation
271
+ * fabricated version 1 as "current metadata plus zero blocks" whenever no snapshot existed,
272
+ * which made Restore delete every block on the page and report success. An honest error is
273
+ * the correct answer; migration 00000000000016 backfills a real baseline so the error is
274
+ * also rare.
275
+ */
276
+ async function reconstructInternal<T extends AnyFullContent>(
277
+ spec: RevisionSpec,
278
+ parentId: number | string,
279
+ targetVersion: number,
280
+ client?: Db
281
+ ): Promise<ReconstructResult<T>> {
282
+ const supabase: Db = client ?? createClient();
283
+
284
+ const snapshot = await findBaseSnapshot(supabase, spec, parentId, targetVersion);
285
+ if (!snapshot) {
286
+ return {
287
+ error: `No stored snapshot exists at or before version ${targetVersion}, so this version cannot be reconstructed.`,
288
+ };
289
+ }
290
+
291
+ let content = snapshot.content as T;
292
+
293
+ if (snapshot.version < targetVersion) {
294
+ const { data: diffs, error: diffsError } = await supabase
295
+ .from(spec.revisionTable)
296
+ .select('version, content, revision_type')
297
+ .eq(spec.parentColumn, parentId)
298
+ .gt('version', snapshot.version)
299
+ .lte('version', targetVersion)
300
+ .order('version', { ascending: true });
301
+ if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` };
302
+
303
+ for (const r of diffs || []) {
304
+ if (r.revision_type === 'diff') {
305
+ const patchOps = r.content as unknown[];
306
+ if (!Array.isArray(patchOps)) {
307
+ return { error: `Revision history is corrupted at version ${r.version}.` };
308
+ }
309
+ try {
310
+ const result = applyPatch(content as object, patchOps as any, /* validate */ false, /* mutateDocument */ true);
311
+ content = result.newDocument as unknown as T;
312
+ } catch (e) {
313
+ return {
314
+ error: `Revision history is corrupted at version ${r.version}: ${e instanceof Error ? e.message : String(e)}`,
315
+ };
316
+ }
317
+ } else {
318
+ content = r.content as T;
319
+ }
320
+ }
321
+ }
322
+
323
+ if (!content || typeof content !== 'object' || !(content as AnyFullContent).meta) {
324
+ return { error: `Version ${targetVersion} is missing its content payload.` };
325
+ }
326
+
327
+ return { success: true, content };
328
+ }
329
+
330
+ async function deleteDraftFor(supabase: Db, spec: RevisionSpec, parentId: number | string) {
331
+ // A surviving draft row would be overlaid on top of the restored content by the editor,
332
+ // making the restore look like it silently failed — and the next Publish would replay the
333
+ // stale draft straight back over it.
334
+ if (spec.draft.table === 'content_drafts') {
335
+ await supabase
336
+ .from('content_drafts')
337
+ .delete()
338
+ .eq('parent_type', spec.draft.parentType)
339
+ .eq('parent_id', parentId);
340
+ } else {
341
+ await supabase.from('product_drafts').delete().eq('product_id', parentId);
342
+ }
343
+ }
344
+
345
+ async function restoreInternal(
346
+ spec: RevisionSpec,
347
+ parentId: number | string,
348
+ targetVersion: number,
349
+ authorId: string | null,
350
+ client?: Db
351
+ ): Promise<RestoreResult> {
352
+ const supabase: Db = client ?? createClient();
353
+
354
+ const reconstructed = await reconstructInternal(spec, parentId, targetVersion, supabase);
355
+ if ('error' in reconstructed) return { error: reconstructed.error };
356
+ const content = reconstructed.content;
357
+
358
+ const blocks = Array.isArray(content.blocks) ? content.blocks : null;
359
+ if (!blocks) {
360
+ return { error: `Version ${targetVersion} has no block list and cannot be restored.` };
361
+ }
362
+
363
+ // Build and validate the whole block payload before touching anything, so a malformed
364
+ // revision cannot leave the page emptied out between the delete and the insert.
365
+ const blockPayload = blocks.map((b, index) => ({
366
+ page_id: spec.parentColumn === 'page_id' ? parentId : null,
367
+ post_id: spec.parentColumn === 'post_id' ? parentId : null,
368
+ product_id: spec.parentColumn === 'product_id' ? parentId : null,
369
+ language_id: b.language_id,
370
+ block_type: b.block_type,
371
+ content: b.content,
372
+ order: Number.isFinite(b.order) ? b.order : index,
373
+ }));
374
+
375
+ if (blockPayload.some(b => typeof b.block_type !== 'string' || typeof b.language_id !== 'number')) {
376
+ return { error: `Version ${targetVersion} contains malformed blocks and was not restored.` };
377
+ }
378
+
379
+ const metaUpdate = buildRestoreMetaUpdate(
380
+ content.meta as unknown as Record<string, unknown>,
381
+ spec.metaColumns
382
+ );
383
+ if (Object.keys(metaUpdate).length === 0) {
384
+ return { error: `Version ${targetVersion} has no restorable metadata.` };
385
+ }
386
+
387
+ const { error: updateError } = await supabase
388
+ .from(spec.parentTable)
389
+ .update(metaUpdate)
390
+ .eq('id', parentId);
391
+ if (updateError) {
392
+ return { error: `Failed to update ${spec.label.toLowerCase()}: ${updateError.message}` };
393
+ }
394
+
395
+ // Kept so the block swap can be rolled back if the reinsert fails.
396
+ const { data: previousBlocks } = await supabase
397
+ .from('blocks')
398
+ .select('page_id, post_id, product_id, language_id, block_type, content, order')
399
+ .eq(spec.parentColumn, parentId)
400
+ .order('order', { ascending: true })
401
+ .order('id', { ascending: true });
402
+
403
+ const { error: deleteError } = await supabase
404
+ .from('blocks')
405
+ .delete()
406
+ .eq(spec.parentColumn, parentId);
407
+ if (deleteError) return { error: `Failed to clear blocks: ${deleteError.message}` };
408
+
409
+ if (blockPayload.length > 0) {
410
+ const { error: insertError } = await supabase.from('blocks').insert(blockPayload);
411
+ if (insertError) {
412
+ if (previousBlocks && previousBlocks.length > 0) {
413
+ await supabase.from('blocks').insert(previousBlocks);
414
+ }
415
+ return { error: `Failed to insert blocks: ${insertError.message}` };
416
+ }
417
+ }
418
+
419
+ await deleteDraftFor(supabase, spec, parentId);
420
+
421
+ // Record the restored state as a new snapshot so history stays append-only: restoring is
422
+ // itself an edit, and the version you restored from remains reachable.
423
+ const currentVersion = (await readParentVersion(supabase, spec, parentId)) ?? 1;
424
+ let newVersion = currentVersion + 1;
425
+
426
+ const insertRestoreSnapshot = async (version: number) =>
427
+ supabase.from(spec.revisionTable).insert({
428
+ [spec.parentColumn]: parentId,
429
+ author_id: authorId,
430
+ version,
431
+ revision_type: 'snapshot',
432
+ content: content as unknown as Json,
433
+ });
434
+
435
+ let { error: revError } = await insertRestoreSnapshot(newVersion);
436
+
437
+ // A revision can already occupy that number — a previous publish whose version bump
438
+ // failed leaves exactly that shape. Take the next free one instead of swallowing the
439
+ // collision, which would leave the parent pointing at somebody else's document.
440
+ if (revError?.code === UNIQUE_VIOLATION) {
441
+ const refreshed = await readParentVersion(supabase, spec, parentId);
442
+ newVersion = Math.max(refreshed ?? newVersion, newVersion) + 1;
443
+ ({ error: revError } = await insertRestoreSnapshot(newVersion));
444
+ }
445
+
446
+ if (revError) {
447
+ return { error: `Restored, but failed to record the new revision: ${revError.message}` };
448
+ }
449
+
450
+ const { error: bumpError } = await supabase
451
+ .from(spec.parentTable)
452
+ .update({ version: newVersion })
453
+ .eq('id', parentId);
454
+ if (bumpError) {
455
+ return { error: `Restored, but failed to bump the version: ${bumpError.message}` };
456
+ }
457
+
458
+ return { success: true, version: newVersion };
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Public API
463
+ // ---------------------------------------------------------------------------
464
+
465
+ export async function createPageRevision(
466
+ pageId: number,
467
+ authorId: string | null,
468
+ previousContent: FullPageContent,
469
+ newContent: FullPageContent,
470
+ client?: unknown
471
+ ): Promise<RevisionResult> {
472
+ return createRevisionInternal(PAGE_SPEC, pageId, authorId, previousContent, newContent, client);
473
+ }
474
+
475
+ export async function createPostRevision(
476
+ postId: number,
477
+ authorId: string | null,
478
+ previousContent: FullPostContent,
479
+ newContent: FullPostContent,
480
+ client?: unknown
481
+ ): Promise<RevisionResult> {
482
+ return createRevisionInternal(POST_SPEC, postId, authorId, previousContent, newContent, client);
483
+ }
484
+
485
+ export async function createProductRevision(
486
+ productId: string,
487
+ authorId: string | null,
488
+ previousContent: FullProductContent,
489
+ newContent: FullProductContent,
490
+ client?: unknown
491
+ ): Promise<RevisionResult> {
492
+ return createRevisionInternal(PRODUCT_SPEC, productId, authorId, previousContent, newContent, client);
493
+ }
494
+
495
+ export async function restorePageToVersion(pageId: number, targetVersion: number, authorId: string | null) {
496
+ return restoreInternal(PAGE_SPEC, pageId, targetVersion, authorId);
497
+ }
498
+
499
+ export async function restorePostToVersion(postId: number, targetVersion: number, authorId: string | null) {
500
+ return restoreInternal(POST_SPEC, postId, targetVersion, authorId);
501
+ }
502
+
503
+ export async function restoreProductToVersion(productId: string, targetVersion: number, authorId: string | null) {
504
+ return restoreInternal(PRODUCT_SPEC, productId, targetVersion, authorId);
505
+ }
506
+
507
+ export async function reconstructPageVersionContent(pageId: number, targetVersion: number) {
508
+ return reconstructInternal<FullPageContent>(PAGE_SPEC, pageId, targetVersion);
509
+ }
510
+
511
+ export async function reconstructPostVersionContent(postId: number, targetVersion: number) {
512
+ return reconstructInternal<FullPostContent>(POST_SPEC, postId, targetVersion);
513
+ }
514
+
515
+ export async function reconstructProductVersionContent(productId: string, targetVersion: number) {
516
+ return reconstructInternal<FullProductContent>(PRODUCT_SPEC, productId, targetVersion);
517
+ }
518
+
519
+ /**
520
+ * Record a revision for a live write made outside the draft pipeline — Cortex AI tools,
521
+ * imports, scripted edits.
522
+ *
523
+ * Two-phase so callers never have to know the snapshot shape: capture before the write,
524
+ * commit after it. The captured state is returned opaquely and handed straight back.
525
+ */
526
+ export async function captureRevisionBaseline(
527
+ parentType: RevisionParentType,
528
+ parentId: number | string,
529
+ client?: unknown
530
+ ): Promise<AnyFullContent | null> {
531
+ const supabase = (client ?? createClient()) as any;
532
+ if (parentType === 'page') return getFullPageContent(Number(parentId), undefined, supabase);
533
+ if (parentType === 'post') return getFullPostContent(Number(parentId), undefined, supabase);
534
+ return getFullProductContent(String(parentId), undefined, supabase);
535
+ }
536
+
537
+ export async function commitRevisionFromBaseline(
538
+ parentType: RevisionParentType,
539
+ parentId: number | string,
540
+ authorId: string | null,
541
+ baseline: AnyFullContent | null,
542
+ client?: unknown
543
+ ): Promise<RevisionResult> {
544
+ if (!baseline) return { error: 'No baseline was captured before the write.' };
545
+ const supabase = (client ?? createClient()) as any;
546
+ const next = await captureRevisionBaseline(parentType, parentId, supabase);
547
+ if (!next) return { error: 'Failed to read the content back after the write.' };
548
+ return createRevisionInternal(specFor(parentType), parentId, authorId, baseline, next, supabase);
549
+ }