create-nextblock 0.13.10 → 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 (255) 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/CartDrawerLoader.tsx +7 -7
  127. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  128. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  129. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  130. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  131. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  132. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  133. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  134. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  135. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  136. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  137. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  138. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  139. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  140. package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -114
  141. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  142. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +8 -1
  143. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  144. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +29 -23
  145. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  146. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  147. package/templates/nextblock-template/components/form-message.tsx +32 -32
  148. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +69 -0
  149. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -0
  150. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  151. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  152. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  153. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  154. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -464
  155. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  156. package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
  157. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  158. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  159. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  160. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  161. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  162. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  163. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  164. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  165. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
  166. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  167. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  168. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  169. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  170. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  171. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  172. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  173. package/templates/nextblock-template/docs/README.md +39 -39
  174. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  175. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  176. package/templates/nextblock-template/index.d.ts +7 -7
  177. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  178. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  179. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  180. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  181. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  182. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -78
  183. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  184. package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -210
  185. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  186. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  187. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  188. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  189. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  190. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  191. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  192. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  193. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  194. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  195. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  196. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  197. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  198. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  199. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  200. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  201. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  202. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  203. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  204. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  205. package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -247
  206. package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -197
  207. package/templates/nextblock-template/lib/i18n/detection.ts +192 -192
  208. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  209. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  210. package/templates/nextblock-template/lib/media/youtube.ts +81 -0
  211. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  212. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  213. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  214. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  215. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  216. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  217. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  218. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  219. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  220. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  221. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  222. package/templates/nextblock-template/lib/search/types.ts +27 -27
  223. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  224. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  225. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  226. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +87 -82
  227. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  228. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  229. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  230. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  231. package/templates/nextblock-template/lib/site-url.ts +48 -48
  232. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  233. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  234. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  235. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  236. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  237. package/templates/nextblock-template/package.json +1 -1
  238. package/templates/nextblock-template/postcss.config.js +6 -6
  239. package/templates/nextblock-template/scripts/backup.js +115 -115
  240. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  241. package/templates/nextblock-template/scripts/restore.js +385 -385
  242. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  243. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  244. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  245. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  246. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  247. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  248. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  249. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  250. package/templates/nextblock-template/tailwind.config.js +25 -25
  251. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  252. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  253. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  254. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  255. 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
+ }