create-nextblock 0.16.4 → 0.17.1

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 (203) hide show
  1. package/CLAUDE.md +22 -0
  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/package.json +1 -1
  8. package/templates/nextblock-template/.browserslistrc +11 -11
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/CLAUDE.md +26 -0
  11. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
  12. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
  13. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  14. package/templates/nextblock-template/app/actions/consent.ts +57 -57
  15. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  16. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  17. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  18. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  19. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  20. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  21. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  22. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  23. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +272 -205
  24. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +10473 -8467
  25. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  26. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  27. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  28. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  29. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  30. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  31. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  32. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +442 -441
  33. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +4 -1
  34. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  35. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  36. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  37. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  38. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  39. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  40. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +3 -3
  41. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  42. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -136
  43. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  44. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  45. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  46. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  47. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  48. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  49. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  50. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  51. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  52. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  53. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  54. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  55. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  57. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  58. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -273
  59. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -251
  60. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  62. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  63. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  64. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  65. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  66. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  67. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  68. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
  69. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  70. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  71. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
  72. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  73. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  74. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  75. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  76. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  77. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  78. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  79. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  80. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  81. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  82. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  83. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  84. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  85. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  86. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  87. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  88. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  89. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  90. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  91. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  92. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  93. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  94. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  95. package/templates/nextblock-template/components/Header.tsx +38 -38
  96. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  97. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  98. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  99. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  100. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  101. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  102. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  103. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +11 -0
  104. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  105. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  106. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  107. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  108. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
  109. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
  110. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  111. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  112. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  113. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  114. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
  115. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  116. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  117. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  118. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  119. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  120. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  121. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +1 -1
  122. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +402 -282
  123. package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +15 -8
  124. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  125. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +23 -4
  126. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  127. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +4 -1
  128. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +8 -0
  129. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +2 -2
  130. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +95 -104
  131. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  132. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  133. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  134. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  135. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  136. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  137. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -135
  138. package/templates/nextblock-template/lib/cms/payments-reminder.ts +105 -105
  139. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  140. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  141. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  142. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  143. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  144. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  145. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  146. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  147. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  148. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  149. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  150. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  151. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  152. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  153. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  154. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  155. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  156. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  157. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +56 -54
  158. package/templates/nextblock-template/lib/media/youtube.ts +99 -99
  159. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  160. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  161. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  162. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  163. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  164. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  165. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  166. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  167. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  168. package/templates/nextblock-template/lib/seo/page-document.ts +4 -4
  169. package/templates/nextblock-template/lib/seo/redirect-store.ts +3 -2
  170. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  171. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  172. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  173. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +15 -175
  174. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  175. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  176. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  177. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  178. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  179. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  180. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  181. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  182. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  183. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  184. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  185. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  186. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  187. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  188. package/templates/nextblock-template/next-env.d.ts +1 -0
  189. package/templates/nextblock-template/package.json +1 -1
  190. package/templates/nextblock-template/public/images/cortex_post.webp +0 -0
  191. package/templates/nextblock-template/public/images/update_nextblock.webp +0 -0
  192. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  193. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  194. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  195. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  196. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  197. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  198. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  199. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  200. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  201. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  202. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  203. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
@@ -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
+ }