create-nextblock 0.16.1 → 0.16.2

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 (258) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  42. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  43. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  44. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  45. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  46. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  47. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  48. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  49. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  50. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  51. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  52. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  53. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  54. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  55. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  57. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  58. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  59. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  60. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  61. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  62. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  63. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  64. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  65. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  66. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  67. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  68. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  69. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  70. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  71. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
  72. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  73. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  74. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  75. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  76. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  77. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  78. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  79. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  80. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  81. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  82. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  83. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  84. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  85. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  86. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  87. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  88. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  89. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  90. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  91. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  92. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  93. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  94. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  95. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  96. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  97. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  98. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  99. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  100. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  101. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  102. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  103. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  104. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  105. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  106. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  107. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  108. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  109. package/templates/nextblock-template/app/layout.tsx +671 -671
  110. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  111. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  112. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  113. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  114. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  115. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  116. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  117. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  118. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  119. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  120. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  121. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  122. package/templates/nextblock-template/app/providers.tsx +96 -96
  123. package/templates/nextblock-template/app/robots.ts +123 -123
  124. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  125. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  126. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  127. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  128. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  129. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  130. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  131. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  132. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  133. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  134. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  135. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  136. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  137. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  138. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  139. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  140. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  141. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  142. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  143. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  144. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  145. package/templates/nextblock-template/components/Header.tsx +38 -38
  146. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  147. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  148. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  149. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  150. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  151. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  152. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  153. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  154. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  155. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  156. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  157. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  158. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  159. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  160. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  161. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  162. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  163. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  164. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  165. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  166. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  167. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  168. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  169. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  170. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  171. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  172. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  173. package/templates/nextblock-template/components/form-message.tsx +32 -32
  174. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  175. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
  176. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  177. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  178. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  179. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  180. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  181. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  182. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  183. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  184. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  185. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  186. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  187. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  188. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  189. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  190. package/templates/nextblock-template/docs/README.md +42 -42
  191. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  192. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  193. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  194. package/templates/nextblock-template/index.d.ts +7 -7
  195. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  196. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  197. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  198. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  199. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  200. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  201. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  202. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  203. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  204. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  205. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  206. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  207. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  208. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  209. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  210. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  211. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  212. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  213. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  214. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  217. package/templates/nextblock-template/lib/search/types.ts +27 -27
  218. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  219. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  220. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  221. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  222. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  223. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
  224. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
  225. package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
  226. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  227. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  228. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  229. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  230. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  231. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  232. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
  233. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  234. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  235. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  236. package/templates/nextblock-template/lib/site-url.ts +102 -102
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  240. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  241. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  242. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  243. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  244. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  245. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  246. package/templates/nextblock-template/next-env.d.ts +1 -2
  247. package/templates/nextblock-template/package.json +1 -1
  248. package/templates/nextblock-template/postcss.config.js +6 -6
  249. package/templates/nextblock-template/scripts/backup.js +115 -115
  250. package/templates/nextblock-template/scripts/restore.js +385 -385
  251. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  252. package/templates/nextblock-template/tailwind.config.js +25 -25
  253. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  254. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  255. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  256. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  257. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  258. package/tsconfig.base.json +3 -3
@@ -1,569 +1,569 @@
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
- // Carry contact-form routing across the restore.
404
- //
405
- // A snapshot taken before migration 27 stores `recipient_email` and no `form_key`.
406
- // Restoring it verbatim would strand the form: the renderer strips the legacy address
407
- // (so nothing leaks), but with no key the submission falls back to the site-wide
408
- // contact address, losing whatever per-form destination the owner had configured.
409
- // Reuse the key from the live block being replaced, matched positionally.
410
- const liveFormKeys = ((previousBlocks ?? []) as Array<{ block_type: string; content: unknown }>)
411
- .filter((block) => block.block_type === 'form')
412
- .map((block) => (block.content as Record<string, unknown> | null)?.['form_key'])
413
- .filter((key): key is string => typeof key === 'string' && key.length > 0);
414
-
415
- for (const block of blockPayload as Array<{ block_type?: string; content?: unknown }>) {
416
- if (block.block_type !== 'form') continue;
417
- const content = (block.content ?? {}) as Record<string, unknown>;
418
- if (typeof content['form_key'] === 'string' && content['form_key']) continue;
419
- const inherited = liveFormKeys.shift();
420
- if (inherited) block.content = { ...content, form_key: inherited };
421
- }
422
-
423
- const { error: deleteError } = await supabase
424
- .from('blocks')
425
- .delete()
426
- .eq(spec.parentColumn, parentId);
427
- if (deleteError) return { error: `Failed to clear blocks: ${deleteError.message}` };
428
-
429
- if (blockPayload.length > 0) {
430
- const { error: insertError } = await supabase.from('blocks').insert(blockPayload);
431
- if (insertError) {
432
- if (previousBlocks && previousBlocks.length > 0) {
433
- await supabase.from('blocks').insert(previousBlocks);
434
- }
435
- return { error: `Failed to insert blocks: ${insertError.message}` };
436
- }
437
- }
438
-
439
- await deleteDraftFor(supabase, spec, parentId);
440
-
441
- // Record the restored state as a new snapshot so history stays append-only: restoring is
442
- // itself an edit, and the version you restored from remains reachable.
443
- const currentVersion = (await readParentVersion(supabase, spec, parentId)) ?? 1;
444
- let newVersion = currentVersion + 1;
445
-
446
- const insertRestoreSnapshot = async (version: number) =>
447
- supabase.from(spec.revisionTable).insert({
448
- [spec.parentColumn]: parentId,
449
- author_id: authorId,
450
- version,
451
- revision_type: 'snapshot',
452
- content: content as unknown as Json,
453
- });
454
-
455
- let { error: revError } = await insertRestoreSnapshot(newVersion);
456
-
457
- // A revision can already occupy that number — a previous publish whose version bump
458
- // failed leaves exactly that shape. Take the next free one instead of swallowing the
459
- // collision, which would leave the parent pointing at somebody else's document.
460
- if (revError?.code === UNIQUE_VIOLATION) {
461
- const refreshed = await readParentVersion(supabase, spec, parentId);
462
- newVersion = Math.max(refreshed ?? newVersion, newVersion) + 1;
463
- ({ error: revError } = await insertRestoreSnapshot(newVersion));
464
- }
465
-
466
- if (revError) {
467
- return { error: `Restored, but failed to record the new revision: ${revError.message}` };
468
- }
469
-
470
- const { error: bumpError } = await supabase
471
- .from(spec.parentTable)
472
- .update({ version: newVersion })
473
- .eq('id', parentId);
474
- if (bumpError) {
475
- return { error: `Restored, but failed to bump the version: ${bumpError.message}` };
476
- }
477
-
478
- return { success: true, version: newVersion };
479
- }
480
-
481
- // ---------------------------------------------------------------------------
482
- // Public API
483
- // ---------------------------------------------------------------------------
484
-
485
- export async function createPageRevision(
486
- pageId: number,
487
- authorId: string | null,
488
- previousContent: FullPageContent,
489
- newContent: FullPageContent,
490
- client?: unknown
491
- ): Promise<RevisionResult> {
492
- return createRevisionInternal(PAGE_SPEC, pageId, authorId, previousContent, newContent, client);
493
- }
494
-
495
- export async function createPostRevision(
496
- postId: number,
497
- authorId: string | null,
498
- previousContent: FullPostContent,
499
- newContent: FullPostContent,
500
- client?: unknown
501
- ): Promise<RevisionResult> {
502
- return createRevisionInternal(POST_SPEC, postId, authorId, previousContent, newContent, client);
503
- }
504
-
505
- export async function createProductRevision(
506
- productId: string,
507
- authorId: string | null,
508
- previousContent: FullProductContent,
509
- newContent: FullProductContent,
510
- client?: unknown
511
- ): Promise<RevisionResult> {
512
- return createRevisionInternal(PRODUCT_SPEC, productId, authorId, previousContent, newContent, client);
513
- }
514
-
515
- export async function restorePageToVersion(pageId: number, targetVersion: number, authorId: string | null) {
516
- return restoreInternal(PAGE_SPEC, pageId, targetVersion, authorId);
517
- }
518
-
519
- export async function restorePostToVersion(postId: number, targetVersion: number, authorId: string | null) {
520
- return restoreInternal(POST_SPEC, postId, targetVersion, authorId);
521
- }
522
-
523
- export async function restoreProductToVersion(productId: string, targetVersion: number, authorId: string | null) {
524
- return restoreInternal(PRODUCT_SPEC, productId, targetVersion, authorId);
525
- }
526
-
527
- export async function reconstructPageVersionContent(pageId: number, targetVersion: number) {
528
- return reconstructInternal<FullPageContent>(PAGE_SPEC, pageId, targetVersion);
529
- }
530
-
531
- export async function reconstructPostVersionContent(postId: number, targetVersion: number) {
532
- return reconstructInternal<FullPostContent>(POST_SPEC, postId, targetVersion);
533
- }
534
-
535
- export async function reconstructProductVersionContent(productId: string, targetVersion: number) {
536
- return reconstructInternal<FullProductContent>(PRODUCT_SPEC, productId, targetVersion);
537
- }
538
-
539
- /**
540
- * Record a revision for a live write made outside the draft pipeline — Cortex AI tools,
541
- * imports, scripted edits.
542
- *
543
- * Two-phase so callers never have to know the snapshot shape: capture before the write,
544
- * commit after it. The captured state is returned opaquely and handed straight back.
545
- */
546
- export async function captureRevisionBaseline(
547
- parentType: RevisionParentType,
548
- parentId: number | string,
549
- client?: unknown
550
- ): Promise<AnyFullContent | null> {
551
- const supabase = (client ?? createClient()) as any;
552
- if (parentType === 'page') return getFullPageContent(Number(parentId), undefined, supabase);
553
- if (parentType === 'post') return getFullPostContent(Number(parentId), undefined, supabase);
554
- return getFullProductContent(String(parentId), undefined, supabase);
555
- }
556
-
557
- export async function commitRevisionFromBaseline(
558
- parentType: RevisionParentType,
559
- parentId: number | string,
560
- authorId: string | null,
561
- baseline: AnyFullContent | null,
562
- client?: unknown
563
- ): Promise<RevisionResult> {
564
- if (!baseline) return { error: 'No baseline was captured before the write.' };
565
- const supabase = (client ?? createClient()) as any;
566
- const next = await captureRevisionBaseline(parentType, parentId, supabase);
567
- if (!next) return { error: 'Failed to read the content back after the write.' };
568
- return createRevisionInternal(specFor(parentType), parentId, authorId, baseline, next, supabase);
569
- }
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
+ // Carry contact-form routing across the restore.
404
+ //
405
+ // A snapshot taken before migration 27 stores `recipient_email` and no `form_key`.
406
+ // Restoring it verbatim would strand the form: the renderer strips the legacy address
407
+ // (so nothing leaks), but with no key the submission falls back to the site-wide
408
+ // contact address, losing whatever per-form destination the owner had configured.
409
+ // Reuse the key from the live block being replaced, matched positionally.
410
+ const liveFormKeys = ((previousBlocks ?? []) as Array<{ block_type: string; content: unknown }>)
411
+ .filter((block) => block.block_type === 'form')
412
+ .map((block) => (block.content as Record<string, unknown> | null)?.['form_key'])
413
+ .filter((key): key is string => typeof key === 'string' && key.length > 0);
414
+
415
+ for (const block of blockPayload as Array<{ block_type?: string; content?: unknown }>) {
416
+ if (block.block_type !== 'form') continue;
417
+ const content = (block.content ?? {}) as Record<string, unknown>;
418
+ if (typeof content['form_key'] === 'string' && content['form_key']) continue;
419
+ const inherited = liveFormKeys.shift();
420
+ if (inherited) block.content = { ...content, form_key: inherited };
421
+ }
422
+
423
+ const { error: deleteError } = await supabase
424
+ .from('blocks')
425
+ .delete()
426
+ .eq(spec.parentColumn, parentId);
427
+ if (deleteError) return { error: `Failed to clear blocks: ${deleteError.message}` };
428
+
429
+ if (blockPayload.length > 0) {
430
+ const { error: insertError } = await supabase.from('blocks').insert(blockPayload);
431
+ if (insertError) {
432
+ if (previousBlocks && previousBlocks.length > 0) {
433
+ await supabase.from('blocks').insert(previousBlocks);
434
+ }
435
+ return { error: `Failed to insert blocks: ${insertError.message}` };
436
+ }
437
+ }
438
+
439
+ await deleteDraftFor(supabase, spec, parentId);
440
+
441
+ // Record the restored state as a new snapshot so history stays append-only: restoring is
442
+ // itself an edit, and the version you restored from remains reachable.
443
+ const currentVersion = (await readParentVersion(supabase, spec, parentId)) ?? 1;
444
+ let newVersion = currentVersion + 1;
445
+
446
+ const insertRestoreSnapshot = async (version: number) =>
447
+ supabase.from(spec.revisionTable).insert({
448
+ [spec.parentColumn]: parentId,
449
+ author_id: authorId,
450
+ version,
451
+ revision_type: 'snapshot',
452
+ content: content as unknown as Json,
453
+ });
454
+
455
+ let { error: revError } = await insertRestoreSnapshot(newVersion);
456
+
457
+ // A revision can already occupy that number — a previous publish whose version bump
458
+ // failed leaves exactly that shape. Take the next free one instead of swallowing the
459
+ // collision, which would leave the parent pointing at somebody else's document.
460
+ if (revError?.code === UNIQUE_VIOLATION) {
461
+ const refreshed = await readParentVersion(supabase, spec, parentId);
462
+ newVersion = Math.max(refreshed ?? newVersion, newVersion) + 1;
463
+ ({ error: revError } = await insertRestoreSnapshot(newVersion));
464
+ }
465
+
466
+ if (revError) {
467
+ return { error: `Restored, but failed to record the new revision: ${revError.message}` };
468
+ }
469
+
470
+ const { error: bumpError } = await supabase
471
+ .from(spec.parentTable)
472
+ .update({ version: newVersion })
473
+ .eq('id', parentId);
474
+ if (bumpError) {
475
+ return { error: `Restored, but failed to bump the version: ${bumpError.message}` };
476
+ }
477
+
478
+ return { success: true, version: newVersion };
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Public API
483
+ // ---------------------------------------------------------------------------
484
+
485
+ export async function createPageRevision(
486
+ pageId: number,
487
+ authorId: string | null,
488
+ previousContent: FullPageContent,
489
+ newContent: FullPageContent,
490
+ client?: unknown
491
+ ): Promise<RevisionResult> {
492
+ return createRevisionInternal(PAGE_SPEC, pageId, authorId, previousContent, newContent, client);
493
+ }
494
+
495
+ export async function createPostRevision(
496
+ postId: number,
497
+ authorId: string | null,
498
+ previousContent: FullPostContent,
499
+ newContent: FullPostContent,
500
+ client?: unknown
501
+ ): Promise<RevisionResult> {
502
+ return createRevisionInternal(POST_SPEC, postId, authorId, previousContent, newContent, client);
503
+ }
504
+
505
+ export async function createProductRevision(
506
+ productId: string,
507
+ authorId: string | null,
508
+ previousContent: FullProductContent,
509
+ newContent: FullProductContent,
510
+ client?: unknown
511
+ ): Promise<RevisionResult> {
512
+ return createRevisionInternal(PRODUCT_SPEC, productId, authorId, previousContent, newContent, client);
513
+ }
514
+
515
+ export async function restorePageToVersion(pageId: number, targetVersion: number, authorId: string | null) {
516
+ return restoreInternal(PAGE_SPEC, pageId, targetVersion, authorId);
517
+ }
518
+
519
+ export async function restorePostToVersion(postId: number, targetVersion: number, authorId: string | null) {
520
+ return restoreInternal(POST_SPEC, postId, targetVersion, authorId);
521
+ }
522
+
523
+ export async function restoreProductToVersion(productId: string, targetVersion: number, authorId: string | null) {
524
+ return restoreInternal(PRODUCT_SPEC, productId, targetVersion, authorId);
525
+ }
526
+
527
+ export async function reconstructPageVersionContent(pageId: number, targetVersion: number) {
528
+ return reconstructInternal<FullPageContent>(PAGE_SPEC, pageId, targetVersion);
529
+ }
530
+
531
+ export async function reconstructPostVersionContent(postId: number, targetVersion: number) {
532
+ return reconstructInternal<FullPostContent>(POST_SPEC, postId, targetVersion);
533
+ }
534
+
535
+ export async function reconstructProductVersionContent(productId: string, targetVersion: number) {
536
+ return reconstructInternal<FullProductContent>(PRODUCT_SPEC, productId, targetVersion);
537
+ }
538
+
539
+ /**
540
+ * Record a revision for a live write made outside the draft pipeline — Cortex AI tools,
541
+ * imports, scripted edits.
542
+ *
543
+ * Two-phase so callers never have to know the snapshot shape: capture before the write,
544
+ * commit after it. The captured state is returned opaquely and handed straight back.
545
+ */
546
+ export async function captureRevisionBaseline(
547
+ parentType: RevisionParentType,
548
+ parentId: number | string,
549
+ client?: unknown
550
+ ): Promise<AnyFullContent | null> {
551
+ const supabase = (client ?? createClient()) as any;
552
+ if (parentType === 'page') return getFullPageContent(Number(parentId), undefined, supabase);
553
+ if (parentType === 'post') return getFullPostContent(Number(parentId), undefined, supabase);
554
+ return getFullProductContent(String(parentId), undefined, supabase);
555
+ }
556
+
557
+ export async function commitRevisionFromBaseline(
558
+ parentType: RevisionParentType,
559
+ parentId: number | string,
560
+ authorId: string | null,
561
+ baseline: AnyFullContent | null,
562
+ client?: unknown
563
+ ): Promise<RevisionResult> {
564
+ if (!baseline) return { error: 'No baseline was captured before the write.' };
565
+ const supabase = (client ?? createClient()) as any;
566
+ const next = await captureRevisionBaseline(parentType, parentId, supabase);
567
+ if (!next) return { error: 'Failed to read the content back after the write.' };
568
+ return createRevisionInternal(specFor(parentType), parentId, authorId, baseline, next, supabase);
569
+ }