create-nextblock 0.13.10 → 0.13.12

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 (259) hide show
  1. package/docker-template/.dockerignore +23 -23
  2. package/docker-template/.env.docker.example +69 -69
  3. package/docker-template/docker/db/init/99-jwt.sql +6 -6
  4. package/docker-template/docker/db/init/99-roles.sql +25 -25
  5. package/docker-template/docker/kong/kong.yml +112 -112
  6. package/docker-template/scripts/docker-setup.mjs +310 -310
  7. package/libs/db/tsconfig.lib.json +3 -3
  8. package/libs/editor/tsconfig.lib.json +3 -3
  9. package/libs/ui/tsconfig.lib.json +3 -3
  10. package/libs/utils/tsconfig.json +3 -3
  11. package/package.json +1 -1
  12. package/project.json +19 -19
  13. package/templates/nextblock-template/.browserslistrc +11 -11
  14. package/templates/nextblock-template/.dockerignore +23 -23
  15. package/templates/nextblock-template/README.md +34 -34
  16. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  17. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  18. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
  19. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
  20. package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
  21. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  22. package/templates/nextblock-template/app/actions/consent.ts +57 -57
  23. package/templates/nextblock-template/app/actions/postActions.ts +146 -146
  24. package/templates/nextblock-template/app/actions/productGridActions.ts +40 -40
  25. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  26. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  27. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  28. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  29. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  30. package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -151
  31. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  32. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  33. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
  34. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5749 -5609
  35. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  36. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  37. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  38. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  39. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  40. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  41. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  42. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
  43. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  44. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  45. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  46. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  47. package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +263 -263
  48. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  49. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
  50. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  51. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -27
  53. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  54. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  55. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  56. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  57. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  58. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  59. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  60. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  61. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  62. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  63. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  64. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  65. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  66. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  67. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  68. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -189
  69. package/templates/nextblock-template/app/cms/layout.tsx +73 -73
  70. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  71. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  72. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  73. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  74. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  75. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  76. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  77. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  78. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  79. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  80. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  81. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  82. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  83. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
  86. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  87. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  88. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +497 -497
  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/page.tsx +28 -28
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  95. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  96. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -188
  97. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  98. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  99. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
  100. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +20 -20
  101. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  102. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
  105. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  106. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  107. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  108. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  109. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  110. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -217
  111. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  112. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -44
  113. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  114. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  115. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  116. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  117. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  118. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  119. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  120. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  121. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  122. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  123. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  124. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  125. package/templates/nextblock-template/app/sitemap.ts +130 -130
  126. package/templates/nextblock-template/components/BlockRenderer.tsx +4 -0
  127. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  128. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  129. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  130. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  131. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  132. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  133. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  134. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  135. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  136. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  137. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  138. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  139. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  140. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  141. package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -114
  142. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  143. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +8 -1
  144. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  145. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +3 -0
  146. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +33 -23
  147. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  148. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  149. package/templates/nextblock-template/components/form-message.tsx +32 -32
  150. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -0
  151. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -0
  152. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  153. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  154. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  155. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  156. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -464
  157. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  158. package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
  159. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  160. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  161. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  162. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  163. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  164. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  165. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  166. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  167. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
  168. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  169. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  170. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  171. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  172. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  173. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  174. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  175. package/templates/nextblock-template/docs/README.md +39 -39
  176. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  177. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  178. package/templates/nextblock-template/index.d.ts +7 -7
  179. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  180. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  181. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  182. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  183. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  184. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -78
  185. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  186. package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -210
  187. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  188. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  189. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  190. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  191. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  192. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  193. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  194. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  195. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  196. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  197. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  198. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  199. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  200. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  201. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  202. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  203. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  204. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  205. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  206. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  207. package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -247
  208. package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -197
  209. package/templates/nextblock-template/lib/i18n/detection.ts +192 -192
  210. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  211. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  212. package/templates/nextblock-template/lib/media/youtube.ts +99 -0
  213. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  214. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  217. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  218. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  219. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  220. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  221. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  222. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  223. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  224. package/templates/nextblock-template/lib/search/types.ts +27 -27
  225. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  226. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  227. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  228. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +87 -82
  229. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  230. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  231. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  232. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  233. package/templates/nextblock-template/lib/site-url.ts +48 -48
  234. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  235. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  236. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  237. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  238. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  239. package/templates/nextblock-template/next-env.d.ts +1 -1
  240. package/templates/nextblock-template/next.config.js +6 -0
  241. package/templates/nextblock-template/package.json +1 -1
  242. package/templates/nextblock-template/postcss.config.js +6 -6
  243. package/templates/nextblock-template/scripts/backup.js +115 -115
  244. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  245. package/templates/nextblock-template/scripts/restore.js +385 -385
  246. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  247. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  248. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  249. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  250. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  251. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  252. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  253. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  254. package/templates/nextblock-template/tailwind.config.js +25 -25
  255. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  256. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  257. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  258. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  259. package/tsconfig.base.json +3 -3
@@ -1,408 +1,408 @@
1
- import 'server-only';
2
- // Server-side schema applier for the /setup wizard. Two backends, preferred in order:
3
- //
4
- // 1. Supabase Management API (HTTPS) — used when a SUPABASE_ACCESS_TOKEN + project ref
5
- // are available. Robust on any network (IPv4, no direct-DB host to resolve) and
6
- // needs no Postgres connection string. This is the default for Supabase Cloud.
7
- // 2. Direct Postgres connection via POSTGRES_URL (the 'postgres' driver, same pattern
8
- // as app/api/cron/reset-sandbox/route.ts) — fallback for self-hosted / no-token.
9
- //
10
- // Either way, migrations run in version order; each file is applied AND recorded in
11
- // supabase_migrations.schema_migrations inside one transaction, so a failure rolls back
12
- // cleanly and a retry re-runs from a clean state (critical: some migrations aren't
13
- // idempotent). Applied versions are tracked exactly like the Supabase CLI.
14
- import { existsSync } from 'node:fs';
15
- import { readdir, readFile } from 'node:fs/promises';
16
- import path from 'node:path';
17
- import postgres from 'postgres';
18
- import { isLocalWritableEnv } from './env-status';
19
- import { MIGRATIONS_BUNDLE } from './migrations-bundle';
20
-
21
- export interface SchemaApplyResult {
22
- ok: boolean;
23
- applied: number;
24
- error?: string;
25
- }
26
-
27
- /**
28
- * Locate the migrations directory: the Nx monorepo keeps them at
29
- * <workspaceRoot>/libs/db/src/supabase/migrations; a standalone create-nextblock
30
- * project materializes them at <projectRoot>/supabase/migrations.
31
- */
32
- function resolveMigrationsDir(): string | null {
33
- // Monorepo first: find the nearest nx.json ancestor (the workspace root) and use its
34
- // libs/db migrations. Checking nx.json before any supabase/ dir avoids accidentally
35
- // picking up a stray app-level supabase/migrations folder.
36
- let dir = process.cwd();
37
- for (let i = 0; i < 8; i++) {
38
- if (existsSync(path.join(dir, 'nx.json'))) {
39
- const monorepo = path.join(dir, 'libs', 'db', 'src', 'supabase', 'migrations');
40
- if (existsSync(monorepo)) return monorepo;
41
- break;
42
- }
43
- const parent = path.dirname(dir);
44
- if (parent === dir) break;
45
- dir = parent;
46
- }
47
-
48
- // Standalone create-nextblock project: nearest supabase/migrations from cwd upward.
49
- dir = process.cwd();
50
- for (let i = 0; i < 8; i++) {
51
- const standalone = path.join(dir, 'supabase', 'migrations');
52
- if (existsSync(standalone)) return standalone;
53
- const parent = path.dirname(dir);
54
- if (parent === dir) break;
55
- dir = parent;
56
- }
57
-
58
- return null;
59
- }
60
-
61
- /** Supabase project ref, from SUPABASE_PROJECT_ID or the project URL host. */
62
- function resolveProjectRef(): string | null {
63
- const fromId = process.env.SUPABASE_PROJECT_ID?.trim();
64
- if (fromId) return fromId;
65
- try {
66
- const host = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').hostname;
67
- if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
68
- return host.split('.')[0];
69
- }
70
- } catch {
71
- // not a cloud URL
72
- }
73
- return null;
74
- }
75
-
76
- /** Turn a non-2xx Management API response into a short, non-HTML error message. */
77
- function summarizeApiError(status: number, body: string): string {
78
- const isHtml = /<!doctype|<html/i.test(body);
79
- const detail = isHtml ? 'gateway error (HTML response)' : body.slice(0, 200).trim();
80
- return `Supabase Management API ${status}${detail ? `: ${detail}` : ''}`;
81
- }
82
-
83
- /**
84
- * Run a SQL query via the Supabase Management API with retry + timeout. Transient
85
- * gateway/server errors (5xx) and rate limits (429) are retried with backoff — applying
86
- * ~31 migrations is many sequential calls, and the gateway occasionally returns a 502
87
- * HTML page. 4xx errors fail fast (they won't get better on retry). Returns the parsed
88
- * JSON body (or null).
89
- */
90
- async function managementApiQuery(ref: string, token: string, query: string): Promise<unknown> {
91
- const endpoint = `https://api.supabase.com/v1/projects/${ref}/database/query`;
92
- const maxAttempts = 4;
93
- let lastError = 'unknown error';
94
-
95
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
96
- if (attempt > 0) {
97
- await new Promise((resolve) => setTimeout(resolve, 700 * attempt));
98
- }
99
-
100
- let res: Response;
101
- try {
102
- res = await fetch(endpoint, {
103
- method: 'POST',
104
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
105
- body: JSON.stringify({ query }),
106
- signal: AbortSignal.timeout(45_000),
107
- });
108
- } catch (caught) {
109
- // Network error / timeout — retry.
110
- lastError = caught instanceof Error ? caught.message : 'network error';
111
- continue;
112
- }
113
-
114
- if (res.ok) {
115
- try {
116
- return await res.json();
117
- } catch {
118
- return null;
119
- }
120
- }
121
-
122
- let body = '';
123
- try {
124
- body = await res.text();
125
- } catch {
126
- /* ignore */
127
- }
128
- lastError = summarizeApiError(res.status, body);
129
-
130
- // Retry transient gateway/server errors and rate limits; fail fast on 4xx.
131
- if (res.status >= 500 || res.status === 429) {
132
- continue;
133
- }
134
- throw new Error(lastError);
135
- }
136
-
137
- throw new Error(`${lastError} (after ${maxAttempts} attempts)`);
138
- }
139
-
140
- const TRACKING_DDL =
141
- 'create schema if not exists supabase_migrations;' +
142
- 'create table if not exists supabase_migrations.schema_migrations ' +
143
- '(version text primary key, name text, statements text[]);';
144
-
145
- // Re-grant the Supabase API roles on everything in `public` after all migrations run.
146
- // Migration 06 grants ON ALL TABLES, but only covers tables that exist at that point;
147
- // later migrations (e.g. content_drafts in 14) rely on the schema's DEFAULT PRIVILEGES,
148
- // which a `DROP SCHEMA public CASCADE` reset wipes. This final, idempotent pass mirrors
149
- // migration 06's grants over the FINAL table set, so no table is left ungranted
150
- // ("permission denied"). RLS still governs row access on top of these base grants.
151
- const GRANTS_SQL =
152
- 'grant usage on schema public to anon, authenticated, service_role;' +
153
- 'grant select on all tables in schema public to anon;' +
154
- 'grant all on all tables in schema public to authenticated, service_role;' +
155
- 'grant all on all sequences in schema public to anon, authenticated, service_role;' +
156
- 'grant execute on all functions in schema public to anon, authenticated, service_role;';
157
-
158
- function recordSql(version: string, file: string): string {
159
- return (
160
- `insert into supabase_migrations.schema_migrations (version, name) ` +
161
- `values ('${version}', '${file.replace(/'/g, "''")}') on conflict (version) do nothing;`
162
- );
163
- }
164
-
165
- /**
166
- * Wrap a migration file + its version record in one explicit transaction. A mid-file
167
- * failure aborts the whole thing (nothing applied, nothing recorded) so a retry re-runs
168
- * from a clean state — essential for non-idempotent migrations (e.g. 25's ADD CONSTRAINT).
169
- */
170
- function transactionalMigration(version: string, file: string, sqlText: string): string {
171
- return `begin;\n${sqlText}\n;\n${recordSql(version, file)}\ncommit;`;
172
- }
173
-
174
- // Destructive reset: drop the public schema (all app tables + triggers) and the
175
- // migration history (separate schema — survives a plain DROP SCHEMA public). Wrapped in
176
- // one transaction so it's all-or-nothing. Auth users are NOT cleared here (SQL on the
177
- // auth schema can be permission-restricted via the Management API); the caller clears
178
- // them with the admin API instead. The ONLY caller (completeSetup) gates this on BOTH
179
- // assertNotProvisioned() (no admin exists — a live site is immune) AND isLocalWritableEnv()
180
- // (local dev only). Plus this function self-guards on isLocalWritableEnv(). Never unguarded.
181
- const RESET_SQL =
182
- 'begin;' +
183
- 'drop schema if exists public cascade;' +
184
- 'create schema public;' +
185
- 'grant all on schema public to postgres;' +
186
- 'grant all on schema public to anon;' +
187
- 'grant all on schema public to authenticated;' +
188
- 'grant all on schema public to service_role;' +
189
- 'drop schema if exists supabase_migrations cascade;' +
190
- 'commit;';
191
-
192
- export async function resetDatabase(): Promise<{ ok: boolean; error?: string }> {
193
- // Defense-in-depth: refuse to wipe anything outside local development, regardless of
194
- // the caller. This is the last line guarding an irreversible, destructive operation.
195
- if (!isLocalWritableEnv()) {
196
- return { ok: false, error: 'Database reset is only allowed in local development.' };
197
- }
198
-
199
- const ref = resolveProjectRef();
200
- const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
201
-
202
- if (ref && token) {
203
- try {
204
- await managementApiQuery(ref, token, RESET_SQL);
205
- return { ok: true };
206
- } catch (caught) {
207
- return {
208
- ok: false,
209
- error: caught instanceof Error ? caught.message : 'unknown error resetting the database',
210
- };
211
- }
212
- }
213
-
214
- const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
215
- if (dbUrl) {
216
- const db = postgres(dbUrl, { ssl: 'require', onnotice: () => undefined, max: 1 });
217
- try {
218
- await db.unsafe(RESET_SQL);
219
- return { ok: true };
220
- } catch (caught) {
221
- return {
222
- ok: false,
223
- error: caught instanceof Error ? caught.message : 'unknown error resetting the database',
224
- };
225
- } finally {
226
- try {
227
- await db.end({ timeout: 5 });
228
- } catch {
229
- /* ignore */
230
- }
231
- }
232
- }
233
-
234
- return {
235
- ok: false,
236
- error: 'No Supabase access token or Postgres connection string is available to reset.',
237
- };
238
- }
239
-
240
- export async function applyMigrations(): Promise<SchemaApplyResult> {
241
- const migrationsDir = resolveMigrationsDir();
242
-
243
- let files: string[];
244
- let readSql: (file: string) => Promise<string>;
245
-
246
- if (migrationsDir) {
247
- // Local dev / Docker: read the canonical .sql files from disk (always current).
248
- files = (await readdir(migrationsDir))
249
- .filter((name) => /^\d+_.*\.sql$/.test(name))
250
- .sort();
251
- readSql = (file: string) => readFile(path.join(migrationsDir, file), 'utf8');
252
- } else if (MIGRATIONS_BUNDLE.length > 0) {
253
- // Serverless (Vercel): libs/db isn't on the function filesystem, so fall back to the
254
- // build-time embedded bundle (npm run generate:migrations-bundle).
255
- files = MIGRATIONS_BUNDLE.map((m) => m.name).sort();
256
- const sqlByName = new Map(MIGRATIONS_BUNDLE.map((m) => [m.name, m.sql]));
257
- readSql = async (file: string) => sqlByName.get(file) ?? '';
258
- } else {
259
- return {
260
- ok: false,
261
- applied: 0,
262
- error: 'Could not locate the migrations (no directory and no embedded bundle).',
263
- };
264
- }
265
-
266
- const ref = resolveProjectRef();
267
- const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
268
- if (ref && token) {
269
- return applyViaManagementApi(ref, token, files, readSql);
270
- }
271
-
272
- const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
273
- if (dbUrl) {
274
- return applyViaPostgres(dbUrl, files, readSql);
275
- }
276
-
277
- return {
278
- ok: false,
279
- applied: 0,
280
- error:
281
- 'No Supabase access token or Postgres connection string is available to apply the schema.',
282
- };
283
- }
284
-
285
- /** Backend 1: Supabase Management API (HTTPS). */
286
- async function applyViaManagementApi(
287
- ref: string,
288
- token: string,
289
- files: string[],
290
- readSql: (file: string) => Promise<string>,
291
- ): Promise<SchemaApplyResult> {
292
- const run = (query: string) => managementApiQuery(ref, token, query);
293
-
294
- const toRows = (raw: unknown): Array<Record<string, unknown>> => {
295
- if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
296
- const obj = raw as { result?: unknown; data?: unknown } | null;
297
- if (Array.isArray(obj?.result)) return obj!.result as Array<Record<string, unknown>>;
298
- if (Array.isArray(obj?.data)) return obj!.data as Array<Record<string, unknown>>;
299
- return [];
300
- };
301
-
302
- let applied = 0;
303
- try {
304
- await run(TRACKING_DDL);
305
- const done = new Set(
306
- toRows(await run('select version from supabase_migrations.schema_migrations;'))
307
- .map((r) => r.version)
308
- .filter(Boolean) as string[],
309
- );
310
-
311
- // Self-heal stale history: if versions are recorded but a core table is missing
312
- // (e.g. someone ran DROP SCHEMA public CASCADE without clearing the history, which
313
- // lives in the separate supabase_migrations schema), those records are lying — clear
314
- // them and re-apply everything from scratch.
315
- if (done.size > 0) {
316
- const exists =
317
- toRows(await run("select to_regclass('public.site_settings') as t;"))[0]?.t != null;
318
- if (!exists) {
319
- await run('delete from supabase_migrations.schema_migrations;');
320
- done.clear();
321
- }
322
- }
323
-
324
- for (const file of files) {
325
- const version = file.split('_')[0];
326
- if (done.has(version)) continue;
327
- const sqlText = await readSql(file);
328
- await run(transactionalMigration(version, file, sqlText));
329
- applied += 1;
330
- }
331
-
332
- await run(GRANTS_SQL);
333
- await run("notify pgrst, 'reload schema';");
334
- return { ok: true, applied };
335
- } catch (caught) {
336
- return {
337
- ok: false,
338
- applied,
339
- error:
340
- caught instanceof Error
341
- ? caught.message
342
- : 'unknown error applying migrations via the Management API',
343
- };
344
- }
345
- }
346
-
347
- /** Backend 2: direct Postgres connection. */
348
- async function applyViaPostgres(
349
- dbUrl: string,
350
- files: string[],
351
- readSql: (file: string) => Promise<string>,
352
- ): Promise<SchemaApplyResult> {
353
- const db = postgres(dbUrl, { ssl: 'require', onnotice: () => undefined, max: 1 });
354
- let applied = 0;
355
- try {
356
- await db.unsafe(TRACKING_DDL);
357
- const rows = await db<{ version: string }[]>`
358
- select version from supabase_migrations.schema_migrations
359
- `;
360
- const done = new Set(rows.map((r) => r.version));
361
-
362
- // Self-heal stale history (see Management API backend): recorded versions but a core
363
- // table missing means the schema was wiped without clearing history — re-apply all.
364
- if (done.size > 0) {
365
- const sentinel = await db<{ t: string | null }[]>`
366
- select to_regclass('public.site_settings')::text as t
367
- `;
368
- if (!sentinel[0]?.t) {
369
- await db`delete from supabase_migrations.schema_migrations`;
370
- done.clear();
371
- }
372
- }
373
-
374
- for (const file of files) {
375
- const version = file.split('_')[0];
376
- if (done.has(version)) continue;
377
- const sqlText = await readSql(file);
378
- await db.unsafe(transactionalMigration(version, file, sqlText));
379
- applied += 1;
380
- }
381
-
382
- await db.unsafe(GRANTS_SQL);
383
- await db.unsafe("notify pgrst, 'reload schema';");
384
- return { ok: true, applied };
385
- } catch (caught) {
386
- const message =
387
- caught instanceof Error ? caught.message : 'unknown error applying migrations';
388
- // Newer Supabase projects expose no IPv4 address for the DIRECT db.<ref>.supabase.co
389
- // host, so on an IPv4-only network DNS fails (ENOTFOUND/ENOENT). The Session pooler
390
- // host is IPv4 — point users there.
391
- const isUnreachable = /ENOTFOUND|ENOENT|EAI_AGAIN|getaddrinfo|ECONNREFUSED|ETIMEDOUT/i.test(
392
- message,
393
- );
394
- return {
395
- ok: false,
396
- applied,
397
- error: isUnreachable
398
- ? `Could not reach the database host (${message}). Provide a Supabase access token (so the wizard can use the Management API over HTTPS), or use the Session pooler connection string (Supabase dashboard → Connect → Session pooler), which works on IPv4 networks.`
399
- : message,
400
- };
401
- } finally {
402
- try {
403
- await db.end({ timeout: 5 });
404
- } catch {
405
- // Closing a connection should never mask the real result.
406
- }
407
- }
408
- }
1
+ import 'server-only';
2
+ // Server-side schema applier for the /setup wizard. Two backends, preferred in order:
3
+ //
4
+ // 1. Supabase Management API (HTTPS) — used when a SUPABASE_ACCESS_TOKEN + project ref
5
+ // are available. Robust on any network (IPv4, no direct-DB host to resolve) and
6
+ // needs no Postgres connection string. This is the default for Supabase Cloud.
7
+ // 2. Direct Postgres connection via POSTGRES_URL (the 'postgres' driver, same pattern
8
+ // as app/api/cron/reset-sandbox/route.ts) — fallback for self-hosted / no-token.
9
+ //
10
+ // Either way, migrations run in version order; each file is applied AND recorded in
11
+ // supabase_migrations.schema_migrations inside one transaction, so a failure rolls back
12
+ // cleanly and a retry re-runs from a clean state (critical: some migrations aren't
13
+ // idempotent). Applied versions are tracked exactly like the Supabase CLI.
14
+ import { existsSync } from 'node:fs';
15
+ import { readdir, readFile } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import postgres from 'postgres';
18
+ import { isLocalWritableEnv } from './env-status';
19
+ import { MIGRATIONS_BUNDLE } from './migrations-bundle';
20
+
21
+ export interface SchemaApplyResult {
22
+ ok: boolean;
23
+ applied: number;
24
+ error?: string;
25
+ }
26
+
27
+ /**
28
+ * Locate the migrations directory: the Nx monorepo keeps them at
29
+ * <workspaceRoot>/libs/db/src/supabase/migrations; a standalone create-nextblock
30
+ * project materializes them at <projectRoot>/supabase/migrations.
31
+ */
32
+ function resolveMigrationsDir(): string | null {
33
+ // Monorepo first: find the nearest nx.json ancestor (the workspace root) and use its
34
+ // libs/db migrations. Checking nx.json before any supabase/ dir avoids accidentally
35
+ // picking up a stray app-level supabase/migrations folder.
36
+ let dir = process.cwd();
37
+ for (let i = 0; i < 8; i++) {
38
+ if (existsSync(path.join(dir, 'nx.json'))) {
39
+ const monorepo = path.join(dir, 'libs', 'db', 'src', 'supabase', 'migrations');
40
+ if (existsSync(monorepo)) return monorepo;
41
+ break;
42
+ }
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+
48
+ // Standalone create-nextblock project: nearest supabase/migrations from cwd upward.
49
+ dir = process.cwd();
50
+ for (let i = 0; i < 8; i++) {
51
+ const standalone = path.join(dir, 'supabase', 'migrations');
52
+ if (existsSync(standalone)) return standalone;
53
+ const parent = path.dirname(dir);
54
+ if (parent === dir) break;
55
+ dir = parent;
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ /** Supabase project ref, from SUPABASE_PROJECT_ID or the project URL host. */
62
+ function resolveProjectRef(): string | null {
63
+ const fromId = process.env.SUPABASE_PROJECT_ID?.trim();
64
+ if (fromId) return fromId;
65
+ try {
66
+ const host = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').hostname;
67
+ if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
68
+ return host.split('.')[0];
69
+ }
70
+ } catch {
71
+ // not a cloud URL
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** Turn a non-2xx Management API response into a short, non-HTML error message. */
77
+ function summarizeApiError(status: number, body: string): string {
78
+ const isHtml = /<!doctype|<html/i.test(body);
79
+ const detail = isHtml ? 'gateway error (HTML response)' : body.slice(0, 200).trim();
80
+ return `Supabase Management API ${status}${detail ? `: ${detail}` : ''}`;
81
+ }
82
+
83
+ /**
84
+ * Run a SQL query via the Supabase Management API with retry + timeout. Transient
85
+ * gateway/server errors (5xx) and rate limits (429) are retried with backoff — applying
86
+ * ~31 migrations is many sequential calls, and the gateway occasionally returns a 502
87
+ * HTML page. 4xx errors fail fast (they won't get better on retry). Returns the parsed
88
+ * JSON body (or null).
89
+ */
90
+ async function managementApiQuery(ref: string, token: string, query: string): Promise<unknown> {
91
+ const endpoint = `https://api.supabase.com/v1/projects/${ref}/database/query`;
92
+ const maxAttempts = 4;
93
+ let lastError = 'unknown error';
94
+
95
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
96
+ if (attempt > 0) {
97
+ await new Promise((resolve) => setTimeout(resolve, 700 * attempt));
98
+ }
99
+
100
+ let res: Response;
101
+ try {
102
+ res = await fetch(endpoint, {
103
+ method: 'POST',
104
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({ query }),
106
+ signal: AbortSignal.timeout(45_000),
107
+ });
108
+ } catch (caught) {
109
+ // Network error / timeout — retry.
110
+ lastError = caught instanceof Error ? caught.message : 'network error';
111
+ continue;
112
+ }
113
+
114
+ if (res.ok) {
115
+ try {
116
+ return await res.json();
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ let body = '';
123
+ try {
124
+ body = await res.text();
125
+ } catch {
126
+ /* ignore */
127
+ }
128
+ lastError = summarizeApiError(res.status, body);
129
+
130
+ // Retry transient gateway/server errors and rate limits; fail fast on 4xx.
131
+ if (res.status >= 500 || res.status === 429) {
132
+ continue;
133
+ }
134
+ throw new Error(lastError);
135
+ }
136
+
137
+ throw new Error(`${lastError} (after ${maxAttempts} attempts)`);
138
+ }
139
+
140
+ const TRACKING_DDL =
141
+ 'create schema if not exists supabase_migrations;' +
142
+ 'create table if not exists supabase_migrations.schema_migrations ' +
143
+ '(version text primary key, name text, statements text[]);';
144
+
145
+ // Re-grant the Supabase API roles on everything in `public` after all migrations run.
146
+ // Migration 06 grants ON ALL TABLES, but only covers tables that exist at that point;
147
+ // later migrations (e.g. content_drafts in 14) rely on the schema's DEFAULT PRIVILEGES,
148
+ // which a `DROP SCHEMA public CASCADE` reset wipes. This final, idempotent pass mirrors
149
+ // migration 06's grants over the FINAL table set, so no table is left ungranted
150
+ // ("permission denied"). RLS still governs row access on top of these base grants.
151
+ const GRANTS_SQL =
152
+ 'grant usage on schema public to anon, authenticated, service_role;' +
153
+ 'grant select on all tables in schema public to anon;' +
154
+ 'grant all on all tables in schema public to authenticated, service_role;' +
155
+ 'grant all on all sequences in schema public to anon, authenticated, service_role;' +
156
+ 'grant execute on all functions in schema public to anon, authenticated, service_role;';
157
+
158
+ function recordSql(version: string, file: string): string {
159
+ return (
160
+ `insert into supabase_migrations.schema_migrations (version, name) ` +
161
+ `values ('${version}', '${file.replace(/'/g, "''")}') on conflict (version) do nothing;`
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Wrap a migration file + its version record in one explicit transaction. A mid-file
167
+ * failure aborts the whole thing (nothing applied, nothing recorded) so a retry re-runs
168
+ * from a clean state — essential for non-idempotent migrations (e.g. 25's ADD CONSTRAINT).
169
+ */
170
+ function transactionalMigration(version: string, file: string, sqlText: string): string {
171
+ return `begin;\n${sqlText}\n;\n${recordSql(version, file)}\ncommit;`;
172
+ }
173
+
174
+ // Destructive reset: drop the public schema (all app tables + triggers) and the
175
+ // migration history (separate schema — survives a plain DROP SCHEMA public). Wrapped in
176
+ // one transaction so it's all-or-nothing. Auth users are NOT cleared here (SQL on the
177
+ // auth schema can be permission-restricted via the Management API); the caller clears
178
+ // them with the admin API instead. The ONLY caller (completeSetup) gates this on BOTH
179
+ // assertNotProvisioned() (no admin exists — a live site is immune) AND isLocalWritableEnv()
180
+ // (local dev only). Plus this function self-guards on isLocalWritableEnv(). Never unguarded.
181
+ const RESET_SQL =
182
+ 'begin;' +
183
+ 'drop schema if exists public cascade;' +
184
+ 'create schema public;' +
185
+ 'grant all on schema public to postgres;' +
186
+ 'grant all on schema public to anon;' +
187
+ 'grant all on schema public to authenticated;' +
188
+ 'grant all on schema public to service_role;' +
189
+ 'drop schema if exists supabase_migrations cascade;' +
190
+ 'commit;';
191
+
192
+ export async function resetDatabase(): Promise<{ ok: boolean; error?: string }> {
193
+ // Defense-in-depth: refuse to wipe anything outside local development, regardless of
194
+ // the caller. This is the last line guarding an irreversible, destructive operation.
195
+ if (!isLocalWritableEnv()) {
196
+ return { ok: false, error: 'Database reset is only allowed in local development.' };
197
+ }
198
+
199
+ const ref = resolveProjectRef();
200
+ const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
201
+
202
+ if (ref && token) {
203
+ try {
204
+ await managementApiQuery(ref, token, RESET_SQL);
205
+ return { ok: true };
206
+ } catch (caught) {
207
+ return {
208
+ ok: false,
209
+ error: caught instanceof Error ? caught.message : 'unknown error resetting the database',
210
+ };
211
+ }
212
+ }
213
+
214
+ const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
215
+ if (dbUrl) {
216
+ const db = postgres(dbUrl, { ssl: 'require', onnotice: () => undefined, max: 1 });
217
+ try {
218
+ await db.unsafe(RESET_SQL);
219
+ return { ok: true };
220
+ } catch (caught) {
221
+ return {
222
+ ok: false,
223
+ error: caught instanceof Error ? caught.message : 'unknown error resetting the database',
224
+ };
225
+ } finally {
226
+ try {
227
+ await db.end({ timeout: 5 });
228
+ } catch {
229
+ /* ignore */
230
+ }
231
+ }
232
+ }
233
+
234
+ return {
235
+ ok: false,
236
+ error: 'No Supabase access token or Postgres connection string is available to reset.',
237
+ };
238
+ }
239
+
240
+ export async function applyMigrations(): Promise<SchemaApplyResult> {
241
+ const migrationsDir = resolveMigrationsDir();
242
+
243
+ let files: string[];
244
+ let readSql: (file: string) => Promise<string>;
245
+
246
+ if (migrationsDir) {
247
+ // Local dev / Docker: read the canonical .sql files from disk (always current).
248
+ files = (await readdir(migrationsDir))
249
+ .filter((name) => /^\d+_.*\.sql$/.test(name))
250
+ .sort();
251
+ readSql = (file: string) => readFile(path.join(migrationsDir, file), 'utf8');
252
+ } else if (MIGRATIONS_BUNDLE.length > 0) {
253
+ // Serverless (Vercel): libs/db isn't on the function filesystem, so fall back to the
254
+ // build-time embedded bundle (npm run generate:migrations-bundle).
255
+ files = MIGRATIONS_BUNDLE.map((m) => m.name).sort();
256
+ const sqlByName = new Map(MIGRATIONS_BUNDLE.map((m) => [m.name, m.sql]));
257
+ readSql = async (file: string) => sqlByName.get(file) ?? '';
258
+ } else {
259
+ return {
260
+ ok: false,
261
+ applied: 0,
262
+ error: 'Could not locate the migrations (no directory and no embedded bundle).',
263
+ };
264
+ }
265
+
266
+ const ref = resolveProjectRef();
267
+ const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
268
+ if (ref && token) {
269
+ return applyViaManagementApi(ref, token, files, readSql);
270
+ }
271
+
272
+ const dbUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL;
273
+ if (dbUrl) {
274
+ return applyViaPostgres(dbUrl, files, readSql);
275
+ }
276
+
277
+ return {
278
+ ok: false,
279
+ applied: 0,
280
+ error:
281
+ 'No Supabase access token or Postgres connection string is available to apply the schema.',
282
+ };
283
+ }
284
+
285
+ /** Backend 1: Supabase Management API (HTTPS). */
286
+ async function applyViaManagementApi(
287
+ ref: string,
288
+ token: string,
289
+ files: string[],
290
+ readSql: (file: string) => Promise<string>,
291
+ ): Promise<SchemaApplyResult> {
292
+ const run = (query: string) => managementApiQuery(ref, token, query);
293
+
294
+ const toRows = (raw: unknown): Array<Record<string, unknown>> => {
295
+ if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
296
+ const obj = raw as { result?: unknown; data?: unknown } | null;
297
+ if (Array.isArray(obj?.result)) return obj!.result as Array<Record<string, unknown>>;
298
+ if (Array.isArray(obj?.data)) return obj!.data as Array<Record<string, unknown>>;
299
+ return [];
300
+ };
301
+
302
+ let applied = 0;
303
+ try {
304
+ await run(TRACKING_DDL);
305
+ const done = new Set(
306
+ toRows(await run('select version from supabase_migrations.schema_migrations;'))
307
+ .map((r) => r.version)
308
+ .filter(Boolean) as string[],
309
+ );
310
+
311
+ // Self-heal stale history: if versions are recorded but a core table is missing
312
+ // (e.g. someone ran DROP SCHEMA public CASCADE without clearing the history, which
313
+ // lives in the separate supabase_migrations schema), those records are lying — clear
314
+ // them and re-apply everything from scratch.
315
+ if (done.size > 0) {
316
+ const exists =
317
+ toRows(await run("select to_regclass('public.site_settings') as t;"))[0]?.t != null;
318
+ if (!exists) {
319
+ await run('delete from supabase_migrations.schema_migrations;');
320
+ done.clear();
321
+ }
322
+ }
323
+
324
+ for (const file of files) {
325
+ const version = file.split('_')[0];
326
+ if (done.has(version)) continue;
327
+ const sqlText = await readSql(file);
328
+ await run(transactionalMigration(version, file, sqlText));
329
+ applied += 1;
330
+ }
331
+
332
+ await run(GRANTS_SQL);
333
+ await run("notify pgrst, 'reload schema';");
334
+ return { ok: true, applied };
335
+ } catch (caught) {
336
+ return {
337
+ ok: false,
338
+ applied,
339
+ error:
340
+ caught instanceof Error
341
+ ? caught.message
342
+ : 'unknown error applying migrations via the Management API',
343
+ };
344
+ }
345
+ }
346
+
347
+ /** Backend 2: direct Postgres connection. */
348
+ async function applyViaPostgres(
349
+ dbUrl: string,
350
+ files: string[],
351
+ readSql: (file: string) => Promise<string>,
352
+ ): Promise<SchemaApplyResult> {
353
+ const db = postgres(dbUrl, { ssl: 'require', onnotice: () => undefined, max: 1 });
354
+ let applied = 0;
355
+ try {
356
+ await db.unsafe(TRACKING_DDL);
357
+ const rows = await db<{ version: string }[]>`
358
+ select version from supabase_migrations.schema_migrations
359
+ `;
360
+ const done = new Set(rows.map((r) => r.version));
361
+
362
+ // Self-heal stale history (see Management API backend): recorded versions but a core
363
+ // table missing means the schema was wiped without clearing history — re-apply all.
364
+ if (done.size > 0) {
365
+ const sentinel = await db<{ t: string | null }[]>`
366
+ select to_regclass('public.site_settings')::text as t
367
+ `;
368
+ if (!sentinel[0]?.t) {
369
+ await db`delete from supabase_migrations.schema_migrations`;
370
+ done.clear();
371
+ }
372
+ }
373
+
374
+ for (const file of files) {
375
+ const version = file.split('_')[0];
376
+ if (done.has(version)) continue;
377
+ const sqlText = await readSql(file);
378
+ await db.unsafe(transactionalMigration(version, file, sqlText));
379
+ applied += 1;
380
+ }
381
+
382
+ await db.unsafe(GRANTS_SQL);
383
+ await db.unsafe("notify pgrst, 'reload schema';");
384
+ return { ok: true, applied };
385
+ } catch (caught) {
386
+ const message =
387
+ caught instanceof Error ? caught.message : 'unknown error applying migrations';
388
+ // Newer Supabase projects expose no IPv4 address for the DIRECT db.<ref>.supabase.co
389
+ // host, so on an IPv4-only network DNS fails (ENOTFOUND/ENOENT). The Session pooler
390
+ // host is IPv4 — point users there.
391
+ const isUnreachable = /ENOTFOUND|ENOENT|EAI_AGAIN|getaddrinfo|ECONNREFUSED|ETIMEDOUT/i.test(
392
+ message,
393
+ );
394
+ return {
395
+ ok: false,
396
+ applied,
397
+ error: isUnreachable
398
+ ? `Could not reach the database host (${message}). Provide a Supabase access token (so the wizard can use the Management API over HTTPS), or use the Session pooler connection string (Supabase dashboard → Connect → Session pooler), which works on IPv4 networks.`
399
+ : message,
400
+ };
401
+ } finally {
402
+ try {
403
+ await db.end({ timeout: 5 });
404
+ } catch {
405
+ // Closing a connection should never mask the real result.
406
+ }
407
+ }
408
+ }