create-nextblock 0.14.0 → 0.14.2

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