create-nextblock 0.15.10 → 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 (266) 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 -0
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  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 -2900
  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 -552
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
  46. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  47. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  48. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
  51. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  52. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  53. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  54. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  55. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  56. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  58. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  59. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  60. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  63. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  64. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  65. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  74. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
  77. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  80. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  81. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  82. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  83. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  86. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  98. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  99. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  103. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  104. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  105. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  106. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  107. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  108. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  109. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  110. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  114. package/templates/nextblock-template/app/layout.tsx +671 -671
  115. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  116. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  117. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  118. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  119. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  120. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  121. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  122. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  123. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  124. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  125. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  126. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  127. package/templates/nextblock-template/app/providers.tsx +96 -96
  128. package/templates/nextblock-template/app/robots.ts +123 -0
  129. package/templates/nextblock-template/app/sitemap.ts +1 -1
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  231. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +0 -1
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/proxy.ts +240 -20
  256. package/templates/nextblock-template/scripts/backup.js +115 -115
  257. package/templates/nextblock-template/scripts/restore.js +385 -385
  258. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  259. package/templates/nextblock-template/tailwind.config.js +25 -25
  260. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  261. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  262. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  263. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  264. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  265. package/tsconfig.base.json +3 -3
  266. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -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
+ }