create-nextblock 0.13.9 → 0.13.11

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 (260) 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/BlockEditorArea.tsx +10 -1
  46. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +5 -1
  47. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  48. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +55 -24
  49. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  50. package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +263 -251
  51. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  52. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
  53. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  54. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  55. package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -27
  56. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  57. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  58. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  59. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  60. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  61. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  62. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  63. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  64. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  65. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  66. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  67. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  68. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  69. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  70. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  71. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -189
  72. package/templates/nextblock-template/app/cms/layout.tsx +73 -73
  73. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  74. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  75. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  76. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  77. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/categories/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/new/page.tsx +135 -135
  82. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  83. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  84. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  85. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  86. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  87. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
  88. package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
  89. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  90. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  91. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  92. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +497 -497
  93. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  94. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  95. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  97. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  98. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  99. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -188
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
  103. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +20 -20
  104. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  105. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  106. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  107. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
  108. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  109. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  110. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -217
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -44
  116. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  117. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  118. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  119. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  120. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  121. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  122. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  123. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  124. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  125. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  126. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  127. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  128. package/templates/nextblock-template/app/sitemap.ts +130 -130
  129. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  130. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  131. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  132. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  133. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  134. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  135. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  136. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  137. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  138. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  139. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  140. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  141. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  142. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  143. package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -114
  144. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  145. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +8 -1
  146. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  147. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +29 -23
  148. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  149. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  150. package/templates/nextblock-template/components/form-message.tsx +32 -32
  151. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +69 -0
  152. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -0
  153. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  154. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  155. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  156. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  157. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -464
  158. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  159. package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
  160. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  161. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  162. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  163. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  164. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  165. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  166. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  167. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  168. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
  169. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  170. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  171. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  172. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  173. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  174. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  175. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  176. package/templates/nextblock-template/docs/README.md +39 -39
  177. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  178. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  179. package/templates/nextblock-template/index.d.ts +7 -7
  180. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  181. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  182. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  183. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  184. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  185. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -78
  186. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  187. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +21 -1
  188. package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -210
  189. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  190. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  191. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  192. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  193. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  194. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  195. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  196. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  197. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  198. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  199. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  200. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  201. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  202. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  203. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  204. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  205. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  206. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  207. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  208. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  209. package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -247
  210. package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -197
  211. package/templates/nextblock-template/lib/i18n/detection.ts +192 -192
  212. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  213. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  214. package/templates/nextblock-template/lib/media/youtube.ts +81 -0
  215. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  216. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  217. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  218. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  219. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  220. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  221. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  222. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  223. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  224. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  225. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  226. package/templates/nextblock-template/lib/search/types.ts +27 -27
  227. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  228. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  229. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  230. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +87 -82
  231. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  232. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  233. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  234. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  235. package/templates/nextblock-template/lib/site-url.ts +48 -48
  236. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  237. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  238. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  239. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  240. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  241. package/templates/nextblock-template/next-env.d.ts +1 -1
  242. package/templates/nextblock-template/package.json +1 -1
  243. package/templates/nextblock-template/postcss.config.js +6 -6
  244. package/templates/nextblock-template/scripts/backup.js +115 -115
  245. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  246. package/templates/nextblock-template/scripts/restore.js +385 -385
  247. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  248. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  249. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  250. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  251. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  252. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  253. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  254. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  255. package/templates/nextblock-template/tailwind.config.js +25 -25
  256. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  257. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  258. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  259. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  260. 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
+ }