create-nextblock 0.16.1 → 0.16.3

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 (264) 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/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  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 +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  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/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  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 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  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 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  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 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  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 +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. 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
+ }