@plutocms/supabase 0.2.1 → 0.3.0
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.
- package/CHANGELOG.md +19 -0
- package/FEATURES.md +3 -0
- package/app/components/EnvPersistWarning.vue +82 -0
- package/app/components/migrations/MigrationsBanner.vue +26 -0
- package/app/composables/migrations.ts +74 -0
- package/app/middleware/setup-check.ts +8 -1
- package/app/pages/admin/migrations.vue +320 -0
- package/app/pages/admin/setup.vue +107 -4
- package/app/pages/admin.vue +13 -0
- package/package.json +2 -2
- package/server/api/migrations/run.post.ts +119 -0
- package/server/api/migrations/status.get.ts +16 -0
- package/server/api/setup/create.post.ts +61 -23
- package/server/utils/admin-guard.ts +46 -0
- package/server/utils/env-file.ts +53 -0
- package/server/utils/pending-migrations.ts +52 -0
- package/server/utils/scrub-connection-string.ts +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/plutocms/supabase/compare/v0.2.2...v0.3.0) (2026-09-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **migrations:** detect and apply pending layer schemas ([b7f0785](https://github.com/plutocms/supabase/commit/b7f0785838f65dcbf9cb22fb3d21307c0819f70b))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **setup:** apply layer schemas when the wizard completes ([f29b2aa](https://github.com/plutocms/supabase/commit/f29b2aae4f8b6c5211f157ad349e0566df57c5cc))
|
|
14
|
+
|
|
15
|
+
## [0.2.2](https://github.com/plutocms/supabase/compare/v0.2.1...v0.2.2) (2026-09-10)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **deps:** require pluto 0.3.2 ([6a6382a](https://github.com/plutocms/supabase/commit/6a6382aed8103a58f8b586df82e8e36e6e7c1f02))
|
|
21
|
+
|
|
3
22
|
## [0.2.1](https://github.com/plutocms/supabase/compare/v0.2.0...v0.2.1) (2026-09-09)
|
|
4
23
|
|
|
5
24
|
|
package/FEATURES.md
CHANGED
|
@@ -5,3 +5,6 @@ with full detail, so this file stays short.
|
|
|
5
5
|
|
|
6
6
|
See the workspace-level `CLAUDE.md` (one directory up) for the delegation, writing, and git
|
|
7
7
|
policies shared by every project here.
|
|
8
|
+
|
|
9
|
+
- Layer migrations: apply pending layer database migrations from the admin UI, after initial
|
|
10
|
+
setup. @.claude/skills/layer-migrations/SKILL.md
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shown when a migration (or the setup wizard) applied successfully but
|
|
3
|
+
// could not write DATABASE_URL to .env — see server/utils/env-file.ts for
|
|
4
|
+
// why this can happen (process.cwd() may not be the project root in a
|
|
5
|
+
// production build).
|
|
6
|
+
//
|
|
7
|
+
// `connectionString` is never sent here from the server — it is the value
|
|
8
|
+
// the admin already typed into this page's own form. Showing it back to
|
|
9
|
+
// the same browser that just typed it does not violate the "never send it
|
|
10
|
+
// back" rule that applies to the server's response; it is only saying it
|
|
11
|
+
// out loud so the admin can finish the one manual step themselves.
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
connectionString: string
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const toast = useToast()
|
|
17
|
+
|
|
18
|
+
const envLine = computed(() => `DATABASE_URL="${props.connectionString}"`)
|
|
19
|
+
|
|
20
|
+
async function copyEnvLine() {
|
|
21
|
+
try {
|
|
22
|
+
await navigator.clipboard.writeText(envLine.value)
|
|
23
|
+
|
|
24
|
+
toast.add({
|
|
25
|
+
title: 'Copied',
|
|
26
|
+
description: 'The DATABASE_URL line is on your clipboard.',
|
|
27
|
+
icon: 'lucide:check-circle',
|
|
28
|
+
color: 'success',
|
|
29
|
+
})
|
|
30
|
+
} catch {
|
|
31
|
+
toast.add({
|
|
32
|
+
title: 'Could not copy',
|
|
33
|
+
description: 'Select and copy the line by hand instead.',
|
|
34
|
+
icon: 'lucide:circle-x',
|
|
35
|
+
color: 'error',
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<UAlert
|
|
43
|
+
color="warning"
|
|
44
|
+
variant="outline"
|
|
45
|
+
icon="lucide:triangle-alert"
|
|
46
|
+
title="Connection string not saved automatically"
|
|
47
|
+
>
|
|
48
|
+
<template #description>
|
|
49
|
+
<div class="flex flex-col gap-y-3">
|
|
50
|
+
<p>
|
|
51
|
+
The database change already succeeded. Pluto could not write
|
|
52
|
+
<code>DATABASE_URL</code> to your project's
|
|
53
|
+
<code>.env</code> file, so it will ask again next time unless you
|
|
54
|
+
add it by hand:
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
<ol class="list-inside list-decimal">
|
|
58
|
+
<li>Open (or create) <code>.env</code> in your project's root folder.</li>
|
|
59
|
+
<li>Add this line. Replace any existing <code>DATABASE_URL</code> line with it.</li>
|
|
60
|
+
<li>Restart the server.</li>
|
|
61
|
+
</ol>
|
|
62
|
+
|
|
63
|
+
<div class="flex items-center gap-x-2">
|
|
64
|
+
<code
|
|
65
|
+
class="bg-muted grow overflow-x-auto rounded-md px-2 py-1.5 text-xs whitespace-nowrap"
|
|
66
|
+
>
|
|
67
|
+
{{ envLine }}
|
|
68
|
+
</code>
|
|
69
|
+
|
|
70
|
+
<UButton
|
|
71
|
+
icon="lucide:copy"
|
|
72
|
+
variant="soft"
|
|
73
|
+
color="neutral"
|
|
74
|
+
size="sm"
|
|
75
|
+
aria-label="Copy the DATABASE_URL line"
|
|
76
|
+
@click="copyEnvLine"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
</UAlert>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Renders nothing when there is no logged-in admin (status reads as null)
|
|
3
|
+
// or when no layer is pending. See `useMigrations` for the 401/403 handling.
|
|
4
|
+
const { status, pendingCount } = useMigrations()
|
|
5
|
+
|
|
6
|
+
const title = computed(() =>
|
|
7
|
+
pendingCount.value === 1
|
|
8
|
+
? '1 layer needs a database migration'
|
|
9
|
+
: `${pendingCount.value} layers need a database migration`
|
|
10
|
+
)
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<UAlert
|
|
15
|
+
v-if="status && pendingCount > 0"
|
|
16
|
+
:title="title"
|
|
17
|
+
color="warning"
|
|
18
|
+
variant="subtle"
|
|
19
|
+
icon="lucide:triangle-alert"
|
|
20
|
+
>
|
|
21
|
+
<template #description>
|
|
22
|
+
<ULink to="/admin/migrations" class="underline"> Go to Migrations </ULink
|
|
23
|
+
>.
|
|
24
|
+
</template>
|
|
25
|
+
</UAlert>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface LayerMigrationResult {
|
|
2
|
+
layerName: string
|
|
3
|
+
status: 'applied' | 'skipped' | 'failed'
|
|
4
|
+
error?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface MigrationsStatusResponse {
|
|
8
|
+
success: boolean
|
|
9
|
+
pending: string[]
|
|
10
|
+
applied: string[]
|
|
11
|
+
hasConnection: boolean
|
|
12
|
+
needsConnectionString: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RunMigrationsResponse {
|
|
16
|
+
success: boolean
|
|
17
|
+
needsConnectionString?: boolean
|
|
18
|
+
results?: LayerMigrationResult[]
|
|
19
|
+
persisted?: boolean
|
|
20
|
+
message?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads and applies pending layer migrations, for admin-only pages and the
|
|
25
|
+
* admin-shell banner.
|
|
26
|
+
*
|
|
27
|
+
* `useFetch` keys the request as `pluto-migrations-status`, so every
|
|
28
|
+
* caller in the app shares the same request and state instead of each
|
|
29
|
+
* firing its own.
|
|
30
|
+
*
|
|
31
|
+
* A 401 or 403 from the status endpoint means the visitor is not a
|
|
32
|
+
* logged-in admin. `status` reads as `null` in that case, so a caller
|
|
33
|
+
* should show nothing — an unauthenticated visitor must see no trace of
|
|
34
|
+
* this feature. Only call this composable from admin pages/components.
|
|
35
|
+
*/
|
|
36
|
+
export function useMigrations() {
|
|
37
|
+
const {
|
|
38
|
+
data,
|
|
39
|
+
error,
|
|
40
|
+
status: fetchStatus,
|
|
41
|
+
refresh,
|
|
42
|
+
} = useFetch<MigrationsStatusResponse>('/api/migrations/status', {
|
|
43
|
+
key: 'pluto-migrations-status',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const status = computed(() => {
|
|
47
|
+
if (error.value) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return data.value ?? null
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const pendingCount = computed(() => status.value?.pending.length ?? 0)
|
|
55
|
+
|
|
56
|
+
async function runMigrations(connectionString?: string) {
|
|
57
|
+
const result = await $fetch<RunMigrationsResponse>('/api/migrations/run', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
body: connectionString ? { connectionString } : undefined,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
await refresh()
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
status,
|
|
69
|
+
fetchStatus,
|
|
70
|
+
pendingCount,
|
|
71
|
+
refresh,
|
|
72
|
+
runMigrations,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -20,7 +20,14 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
|
|
20
20
|
return navigateTo('/admin/setup')
|
|
21
21
|
}
|
|
22
22
|
} else {
|
|
23
|
-
// Setup already done — block access to setup page
|
|
23
|
+
// Setup already done — block access to setup page.
|
|
24
|
+
//
|
|
25
|
+
// Keep this redirect even after layer migrations move to
|
|
26
|
+
// /admin/migrations. The setup page prompts an unauthenticated
|
|
27
|
+
// visitor for a raw database connection string, and it is safe only
|
|
28
|
+
// because it is unreachable once setup is done. Reopening it for a
|
|
29
|
+
// routine layer addition would let an anonymous visitor submit a
|
|
30
|
+
// connection string to the server.
|
|
24
31
|
if (to.path === '/admin/setup') {
|
|
25
32
|
return navigateTo('/admin/login')
|
|
26
33
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
useHead({
|
|
3
|
+
title: 'Migrations',
|
|
4
|
+
})
|
|
5
|
+
|
|
6
|
+
const config = useRuntimeConfig()
|
|
7
|
+
const projectId = config.public.supabase.url
|
|
8
|
+
?.split('https://')[1]
|
|
9
|
+
?.split('.')[0]
|
|
10
|
+
|
|
11
|
+
const toast = useToast()
|
|
12
|
+
|
|
13
|
+
const { status, fetchStatus, pendingCount, refresh, runMigrations } =
|
|
14
|
+
useMigrations()
|
|
15
|
+
|
|
16
|
+
const isDev = import.meta.dev
|
|
17
|
+
|
|
18
|
+
const connectionForm = ref({
|
|
19
|
+
connectionString: '',
|
|
20
|
+
password: '',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const isRunning = ref(false)
|
|
24
|
+
const lastRun = ref<Awaited<ReturnType<typeof runMigrations>> | null>(null)
|
|
25
|
+
// Kept only in this page's own memory, only to show the manual .env step if
|
|
26
|
+
// persisting fails — see EnvPersistWarning.vue. Never sent anywhere new.
|
|
27
|
+
const lastConnectionString = ref('')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Polls the status endpoint until it responds again, or gives up.
|
|
31
|
+
*
|
|
32
|
+
* Used only to wait out a dev-server restart (see the comment in the
|
|
33
|
+
* `catch` block of `applyMigrations`) — the server is expected to come
|
|
34
|
+
* back within a few seconds, not to be actually down.
|
|
35
|
+
*/
|
|
36
|
+
async function waitForServerAndRefresh(): Promise<boolean> {
|
|
37
|
+
const maxAttempts = 15
|
|
38
|
+
const delayMs = 2000
|
|
39
|
+
|
|
40
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
42
|
+
await refresh()
|
|
43
|
+
|
|
44
|
+
if (status.value) {
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function applyMigrations(useForm: boolean) {
|
|
53
|
+
isRunning.value = true
|
|
54
|
+
lastRun.value = null
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const connectionString = useForm
|
|
58
|
+
? connectionForm.value.connectionString.replace(
|
|
59
|
+
'[YOUR-PASSWORD]',
|
|
60
|
+
encodeURIComponent(connectionForm.value.password)
|
|
61
|
+
)
|
|
62
|
+
: undefined
|
|
63
|
+
|
|
64
|
+
if (connectionString) {
|
|
65
|
+
lastConnectionString.value = connectionString
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = await runMigrations(connectionString)
|
|
69
|
+
lastRun.value = result
|
|
70
|
+
|
|
71
|
+
if (result.success === false) {
|
|
72
|
+
toast.add({
|
|
73
|
+
title: 'Could not apply migrations',
|
|
74
|
+
description: result.needsConnectionString
|
|
75
|
+
? 'A database connection string is required.'
|
|
76
|
+
: undefined,
|
|
77
|
+
icon: 'lucide:circle-x',
|
|
78
|
+
color: 'error',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toast.add({
|
|
85
|
+
title: 'Migrations applied',
|
|
86
|
+
icon: 'lucide:check-circle',
|
|
87
|
+
color: 'success',
|
|
88
|
+
})
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// Saving a first-time connection string writes DATABASE_URL to .env
|
|
91
|
+
// (server/utils/env-file.ts). In dev, Nuxt restarts the whole dev
|
|
92
|
+
// server whenever .env changes, so this request's connection can drop
|
|
93
|
+
// before its response arrives, even though the migration itself
|
|
94
|
+
// already succeeded server-side. A dropped connection has no HTTP
|
|
95
|
+
// status code — a real error response would — so use that to tell the
|
|
96
|
+
// two apart, and only in dev, where the restart is expected at all.
|
|
97
|
+
const hasStatusCode =
|
|
98
|
+
typeof error === 'object' &&
|
|
99
|
+
error !== null &&
|
|
100
|
+
'statusCode' in error &&
|
|
101
|
+
(error as { statusCode?: unknown }).statusCode !== undefined
|
|
102
|
+
|
|
103
|
+
if (isDev && useForm && !hasStatusCode) {
|
|
104
|
+
toast.add({
|
|
105
|
+
title: 'Dev server restarting',
|
|
106
|
+
description:
|
|
107
|
+
'Saving a new connection string restarts the dev server. Reconnecting…',
|
|
108
|
+
icon: 'lucide:refresh-cw',
|
|
109
|
+
color: 'info',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const recovered = await waitForServerAndRefresh()
|
|
113
|
+
|
|
114
|
+
toast.add(
|
|
115
|
+
recovered
|
|
116
|
+
? {
|
|
117
|
+
title: 'Reconnected',
|
|
118
|
+
description:
|
|
119
|
+
pendingCount.value === 0
|
|
120
|
+
? 'The server is back. All migrations were applied.'
|
|
121
|
+
: `The server is back. ${pendingCount.value} layer(s) still pending — check above.`,
|
|
122
|
+
icon: 'lucide:check-circle',
|
|
123
|
+
color: 'success',
|
|
124
|
+
}
|
|
125
|
+
: {
|
|
126
|
+
title: 'Still reconnecting',
|
|
127
|
+
description:
|
|
128
|
+
'The dev server is taking longer than expected. Reload this page in a moment.',
|
|
129
|
+
icon: 'lucide:triangle-alert',
|
|
130
|
+
color: 'warning',
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (import.meta.dev) {
|
|
138
|
+
console.error('Error applying migrations:', error)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
toast.add({
|
|
142
|
+
title: 'Error applying migrations',
|
|
143
|
+
description: 'Please check the console for more details.',
|
|
144
|
+
icon: 'lucide:circle-x',
|
|
145
|
+
color: 'error',
|
|
146
|
+
})
|
|
147
|
+
} finally {
|
|
148
|
+
isRunning.value = false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<template>
|
|
154
|
+
<AdminView>
|
|
155
|
+
<h1 class="text-3xl font-bold lg:text-4xl">Migrations</h1>
|
|
156
|
+
|
|
157
|
+
<div v-if="fetchStatus === 'pending'" class="flex items-center gap-x-2">
|
|
158
|
+
<Icon name="svg-spinners:ring-resize" />
|
|
159
|
+
<span>Loading migration status…</span>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<template v-else-if="status">
|
|
163
|
+
<UCard>
|
|
164
|
+
<div class="flex flex-col gap-y-6">
|
|
165
|
+
<UAlert
|
|
166
|
+
v-if="pendingCount === 0"
|
|
167
|
+
color="success"
|
|
168
|
+
variant="outline"
|
|
169
|
+
icon="lucide:check-circle"
|
|
170
|
+
title="Everything is up to date"
|
|
171
|
+
description="No layer needs a database migration."
|
|
172
|
+
/>
|
|
173
|
+
|
|
174
|
+
<div v-else class="flex flex-col gap-y-2">
|
|
175
|
+
<h2 class="font-semibold">
|
|
176
|
+
Pending layers ({{ status.pending.length }})
|
|
177
|
+
</h2>
|
|
178
|
+
<ul class="list-inside list-disc text-sm">
|
|
179
|
+
<li v-for="layer in status.pending" :key="layer">
|
|
180
|
+
{{ layer }}
|
|
181
|
+
</li>
|
|
182
|
+
</ul>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div v-if="status.applied.length" class="flex flex-col gap-y-2">
|
|
186
|
+
<h2 class="font-semibold">
|
|
187
|
+
Applied layers ({{ status.applied.length }})
|
|
188
|
+
</h2>
|
|
189
|
+
<ul class="list-inside list-disc text-sm">
|
|
190
|
+
<li v-for="layer in status.applied" :key="layer">
|
|
191
|
+
{{ layer }}
|
|
192
|
+
</li>
|
|
193
|
+
</ul>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<template v-if="pendingCount > 0">
|
|
197
|
+
<UForm
|
|
198
|
+
v-if="status.needsConnectionString"
|
|
199
|
+
class="flex flex-col gap-y-4"
|
|
200
|
+
@submit="applyMigrations(true)"
|
|
201
|
+
>
|
|
202
|
+
<UFormField label="Supabase connection string" required>
|
|
203
|
+
<UInput
|
|
204
|
+
v-model="connectionForm.connectionString"
|
|
205
|
+
:disabled="isRunning"
|
|
206
|
+
placeholder="postgresql://"
|
|
207
|
+
required
|
|
208
|
+
/>
|
|
209
|
+
|
|
210
|
+
<template #help>
|
|
211
|
+
You can find your connection string
|
|
212
|
+
<ULink
|
|
213
|
+
:to="`https://supabase.com/dashboard/project/${projectId}/database/settings?showConnect=true&connectTab=direct&method=transaction`"
|
|
214
|
+
target="_blank"
|
|
215
|
+
class="underline"
|
|
216
|
+
external
|
|
217
|
+
>
|
|
218
|
+
clicking here </ULink
|
|
219
|
+
>.
|
|
220
|
+
</template>
|
|
221
|
+
</UFormField>
|
|
222
|
+
|
|
223
|
+
<UFormField label="Database password" required>
|
|
224
|
+
<UInput
|
|
225
|
+
v-model="connectionForm.password"
|
|
226
|
+
:disabled="isRunning"
|
|
227
|
+
type="password"
|
|
228
|
+
placeholder="• • • • •"
|
|
229
|
+
required
|
|
230
|
+
/>
|
|
231
|
+
</UFormField>
|
|
232
|
+
|
|
233
|
+
<UAlert
|
|
234
|
+
color="secondary"
|
|
235
|
+
icon="lucide:badge-info"
|
|
236
|
+
variant="outline"
|
|
237
|
+
>
|
|
238
|
+
<template #description>
|
|
239
|
+
Your password is created when you create your Supabase
|
|
240
|
+
project. You can change it in the
|
|
241
|
+
<ULink
|
|
242
|
+
to="https://supabase.com/dashboard/project/_/database/settings"
|
|
243
|
+
target="_blank"
|
|
244
|
+
>
|
|
245
|
+
Supabase Database Settings </ULink
|
|
246
|
+
>.
|
|
247
|
+
</template>
|
|
248
|
+
</UAlert>
|
|
249
|
+
|
|
250
|
+
<UAlert
|
|
251
|
+
color="warning"
|
|
252
|
+
variant="outline"
|
|
253
|
+
title="Why do I need to provide a database password?"
|
|
254
|
+
icon="lucide:info"
|
|
255
|
+
description="It is used once to open a direct PostgreSQL connection and run the pending layer schemas. It is then written to .env on the server, so future migrations need no prompt. It is never stored anywhere else, and never sent back to your browser."
|
|
256
|
+
/>
|
|
257
|
+
|
|
258
|
+
<UAlert
|
|
259
|
+
v-if="isDev"
|
|
260
|
+
color="info"
|
|
261
|
+
variant="outline"
|
|
262
|
+
title="This restarts the dev server"
|
|
263
|
+
icon="lucide:refresh-cw"
|
|
264
|
+
description="Saving a new connection string writes it to .env. In development, that restarts the server automatically. The page will briefly show a reconnecting message — this is expected, not an error."
|
|
265
|
+
/>
|
|
266
|
+
|
|
267
|
+
<div class="flex">
|
|
268
|
+
<UButton
|
|
269
|
+
:loading="isRunning"
|
|
270
|
+
:disabled="isRunning"
|
|
271
|
+
type="submit"
|
|
272
|
+
icon="lucide:database"
|
|
273
|
+
class="w-full justify-center sm:w-auto"
|
|
274
|
+
>
|
|
275
|
+
Apply pending migrations
|
|
276
|
+
</UButton>
|
|
277
|
+
</div>
|
|
278
|
+
</UForm>
|
|
279
|
+
|
|
280
|
+
<div v-else class="flex">
|
|
281
|
+
<UButton
|
|
282
|
+
:loading="isRunning"
|
|
283
|
+
:disabled="isRunning"
|
|
284
|
+
icon="lucide:database"
|
|
285
|
+
class="w-full justify-center sm:w-auto"
|
|
286
|
+
@click="applyMigrations(false)"
|
|
287
|
+
>
|
|
288
|
+
Apply pending migrations
|
|
289
|
+
</UButton>
|
|
290
|
+
</div>
|
|
291
|
+
</template>
|
|
292
|
+
</div>
|
|
293
|
+
</UCard>
|
|
294
|
+
|
|
295
|
+
<UCard v-if="lastRun">
|
|
296
|
+
<div class="flex flex-col gap-y-4">
|
|
297
|
+
<h2 class="font-semibold">Last run</h2>
|
|
298
|
+
|
|
299
|
+
<EnvPersistWarning
|
|
300
|
+
v-if="lastRun.success && lastRun.persisted === false"
|
|
301
|
+
:connection-string="lastConnectionString"
|
|
302
|
+
/>
|
|
303
|
+
|
|
304
|
+
<ul
|
|
305
|
+
v-if="lastRun.results?.length"
|
|
306
|
+
class="flex flex-col gap-y-1 text-sm"
|
|
307
|
+
>
|
|
308
|
+
<li v-for="result in lastRun.results" :key="result.layerName">
|
|
309
|
+
<span class="font-mono">{{ result.layerName }}</span>
|
|
310
|
+
— {{ result.status }}
|
|
311
|
+
<span v-if="result.error" class="text-error">
|
|
312
|
+
: {{ result.error }}</span
|
|
313
|
+
>
|
|
314
|
+
</li>
|
|
315
|
+
</ul>
|
|
316
|
+
</div>
|
|
317
|
+
</UCard>
|
|
318
|
+
</template>
|
|
319
|
+
</AdminView>
|
|
320
|
+
</template>
|
|
@@ -17,6 +17,8 @@ const projectId = config.public.supabase.url
|
|
|
17
17
|
|
|
18
18
|
const toast = useToast()
|
|
19
19
|
|
|
20
|
+
const isDev = import.meta.dev
|
|
21
|
+
|
|
20
22
|
const items = ref<StepperItem[]>([
|
|
21
23
|
{
|
|
22
24
|
title: 'Database Setup',
|
|
@@ -40,6 +42,12 @@ const databaseForm = ref({
|
|
|
40
42
|
password: '',
|
|
41
43
|
})
|
|
42
44
|
|
|
45
|
+
// Set only when the server reports it could not write DATABASE_URL to
|
|
46
|
+
// .env — see EnvPersistWarning.vue. Holds the connection string this page
|
|
47
|
+
// already sent once; never re-sent, only shown back on this same page.
|
|
48
|
+
const envNotPersisted = ref(false)
|
|
49
|
+
const submittedConnectionString = ref('')
|
|
50
|
+
|
|
43
51
|
const currentLoading = computed(() => {
|
|
44
52
|
if (currentStep.value === 0) {
|
|
45
53
|
return isSettingUpDatabase.value
|
|
@@ -64,18 +72,56 @@ const currentStepSubmitLabel = computed(() => {
|
|
|
64
72
|
return 'Submit'
|
|
65
73
|
})
|
|
66
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Polls /api/settings/first_setup until the dev server responds again, and
|
|
77
|
+
* advances the stepper once it does.
|
|
78
|
+
*
|
|
79
|
+
* Used only to wait out the restart described in the `catch` block of
|
|
80
|
+
* `completeDatabaseSetup` — the server is expected to come back within a
|
|
81
|
+
* few seconds, not to be actually down.
|
|
82
|
+
*/
|
|
83
|
+
async function waitForServerAndAdvance(): Promise<boolean> {
|
|
84
|
+
const maxAttempts = 15
|
|
85
|
+
const delayMs = 2000
|
|
86
|
+
|
|
87
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await $fetch('/api/settings/first_setup')
|
|
92
|
+
|
|
93
|
+
toast.add({
|
|
94
|
+
title: 'Reconnected',
|
|
95
|
+
description: 'The server is back. Your database has been set up.',
|
|
96
|
+
icon: 'lucide:check-circle',
|
|
97
|
+
color: 'success',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
stepper.value?.next()
|
|
101
|
+
|
|
102
|
+
return true
|
|
103
|
+
} catch {
|
|
104
|
+
// Still restarting — try again.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
67
111
|
async function completeDatabaseSetup() {
|
|
68
112
|
try {
|
|
69
113
|
isSettingUpDatabase.value = true
|
|
70
114
|
|
|
115
|
+
const connectionString = databaseForm.value.connectionString.replace(
|
|
116
|
+
'[YOUR-PASSWORD]',
|
|
117
|
+
encodeURIComponent(databaseForm.value.password)
|
|
118
|
+
)
|
|
119
|
+
|
|
71
120
|
const data = await $fetch<any>('/api/setup/create', {
|
|
72
121
|
method: 'POST',
|
|
73
122
|
body: {
|
|
74
123
|
baseUrl: window.location.origin,
|
|
75
|
-
connectionString
|
|
76
|
-
'[YOUR-PASSWORD]',
|
|
77
|
-
encodeURIComponent(databaseForm.value.password)
|
|
78
|
-
),
|
|
124
|
+
connectionString,
|
|
79
125
|
},
|
|
80
126
|
})
|
|
81
127
|
|
|
@@ -90,6 +136,11 @@ async function completeDatabaseSetup() {
|
|
|
90
136
|
return
|
|
91
137
|
}
|
|
92
138
|
|
|
139
|
+
if (data.persisted === false) {
|
|
140
|
+
envNotPersisted.value = true
|
|
141
|
+
submittedConnectionString.value = connectionString
|
|
142
|
+
}
|
|
143
|
+
|
|
93
144
|
toast.add({
|
|
94
145
|
title: 'Database setup complete',
|
|
95
146
|
description: 'Your database has been set up successfully.',
|
|
@@ -99,6 +150,44 @@ async function completeDatabaseSetup() {
|
|
|
99
150
|
|
|
100
151
|
stepper.value?.next()
|
|
101
152
|
} catch (error) {
|
|
153
|
+
// Writing DATABASE_URL to .env (the last step of /api/setup/create,
|
|
154
|
+
// after the schema and every layer migration already succeeded)
|
|
155
|
+
// restarts the dev server. The response can be cut off by that
|
|
156
|
+
// restart even though the setup itself worked. A dropped connection
|
|
157
|
+
// has no HTTP status code — a real error response would — so use
|
|
158
|
+
// that, and only in dev, to tell "still finishing" apart from a real
|
|
159
|
+
// failure. See the matching comment in app/pages/admin/migrations.vue.
|
|
160
|
+
const hasStatusCode =
|
|
161
|
+
typeof error === 'object' &&
|
|
162
|
+
error !== null &&
|
|
163
|
+
'statusCode' in error &&
|
|
164
|
+
(error as { statusCode?: unknown }).statusCode !== undefined
|
|
165
|
+
|
|
166
|
+
if (import.meta.dev && !hasStatusCode) {
|
|
167
|
+
toast.add({
|
|
168
|
+
title: 'Dev server restarting',
|
|
169
|
+
description: 'Setup is finishing. Reconnecting…',
|
|
170
|
+
icon: 'lucide:refresh-cw',
|
|
171
|
+
color: 'info',
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const recovered = await waitForServerAndAdvance()
|
|
175
|
+
|
|
176
|
+
if (recovered) {
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
toast.add({
|
|
181
|
+
title: 'Still reconnecting',
|
|
182
|
+
description:
|
|
183
|
+
'The dev server is taking longer than expected. Reload this page in a moment.',
|
|
184
|
+
icon: 'lucide:triangle-alert',
|
|
185
|
+
color: 'warning',
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
102
191
|
if (import.meta.dev) {
|
|
103
192
|
console.error('Error setting up the database:', error)
|
|
104
193
|
}
|
|
@@ -253,6 +342,15 @@ function handleStepChange(step: number) {
|
|
|
253
342
|
</div>
|
|
254
343
|
</template>
|
|
255
344
|
</UAlert>
|
|
345
|
+
|
|
346
|
+
<UAlert
|
|
347
|
+
v-if="isDev"
|
|
348
|
+
color="info"
|
|
349
|
+
variant="outline"
|
|
350
|
+
title="This restarts the dev server"
|
|
351
|
+
icon="lucide:refresh-cw"
|
|
352
|
+
description="Saving your connection string writes it to .env. In development, that restarts the server automatically. The page will briefly show a reconnecting message — this is expected, not an error."
|
|
353
|
+
/>
|
|
256
354
|
</UForm>
|
|
257
355
|
</section>
|
|
258
356
|
|
|
@@ -269,6 +367,11 @@ function handleStepChange(step: number) {
|
|
|
269
367
|
up.
|
|
270
368
|
</p>
|
|
271
369
|
|
|
370
|
+
<EnvPersistWarning
|
|
371
|
+
v-if="envNotPersisted"
|
|
372
|
+
:connection-string="submittedConnectionString"
|
|
373
|
+
/>
|
|
374
|
+
|
|
272
375
|
<p class="text-center">
|
|
273
376
|
<UButton
|
|
274
377
|
icon="lucide:arrow-right"
|
package/app/pages/admin.vue
CHANGED
|
@@ -32,6 +32,19 @@ watch(visibility, async (current, previous) => {
|
|
|
32
32
|
|
|
33
33
|
<template>
|
|
34
34
|
<div class="light:text-zinc-800 h-full dark:text-white">
|
|
35
|
+
<ClientOnly>
|
|
36
|
+
<Container
|
|
37
|
+
v-if="
|
|
38
|
+
isLoggedIn
|
|
39
|
+
&& !route.path.startsWith('/admin/setup')
|
|
40
|
+
&& !route.path.startsWith('/admin/migrations')
|
|
41
|
+
"
|
|
42
|
+
class="pt-4"
|
|
43
|
+
>
|
|
44
|
+
<MigrationsBanner />
|
|
45
|
+
</Container>
|
|
46
|
+
</ClientOnly>
|
|
47
|
+
|
|
35
48
|
<NuxtPage />
|
|
36
49
|
</div>
|
|
37
50
|
</template>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@nuxt/ui": "^4.9.0",
|
|
40
40
|
"@nuxtjs/supabase": "^2.0.9",
|
|
41
|
-
"@plutocms/pluto": "^0.3.
|
|
41
|
+
"@plutocms/pluto": "^0.3.2",
|
|
42
42
|
"@plutocms/utils": "^0.2.1",
|
|
43
43
|
"postgres": "^3.4.9",
|
|
44
44
|
"supabase": "^2.109.1",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
2
|
+
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
3
|
+
import { runLayerMigration } from '../../utils/migrations'
|
|
4
|
+
import { getMigrationStatus } from '../../utils/pending-migrations'
|
|
5
|
+
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
6
|
+
import { regenerateSupabaseTypes } from '../../utils/typegen'
|
|
7
|
+
|
|
8
|
+
interface Payload {
|
|
9
|
+
connectionString?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface LayerResult {
|
|
13
|
+
layerName: string
|
|
14
|
+
status: 'applied' | 'skipped' | 'failed'
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
await requireAdmin(event)
|
|
20
|
+
|
|
21
|
+
const body = await readBody<Payload | undefined>(event)
|
|
22
|
+
|
|
23
|
+
// Connection string precedence: an already-configured DATABASE_URL always
|
|
24
|
+
// wins, and a body-supplied string is ignored completely in that case.
|
|
25
|
+
// This is the whole security model for this endpoint — without it, any
|
|
26
|
+
// admin could point the server at an arbitrary database host.
|
|
27
|
+
const envConnectionString = process.env.DATABASE_URL
|
|
28
|
+
const bodyConnectionString = body?.connectionString
|
|
29
|
+
const usingBodyConnectionString = !envConnectionString
|
|
30
|
+
|
|
31
|
+
const connStr = envConnectionString || bodyConnectionString
|
|
32
|
+
|
|
33
|
+
if (!connStr) {
|
|
34
|
+
return {
|
|
35
|
+
success: false as const,
|
|
36
|
+
needsConnectionString: true,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const config = useRuntimeConfig()
|
|
41
|
+
const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
|
|
42
|
+
|
|
43
|
+
const { pending } = await getMigrationStatus(event)
|
|
44
|
+
|
|
45
|
+
const results: LayerResult[] = []
|
|
46
|
+
let anyApplied = false
|
|
47
|
+
// Tracks whether we've already committed a body-supplied connection
|
|
48
|
+
// string to process.env for this request (done once, on first success).
|
|
49
|
+
let connStrCommitted = false
|
|
50
|
+
|
|
51
|
+
for (const layerName of pending) {
|
|
52
|
+
const schemaSql = layerSchemas[layerName]
|
|
53
|
+
|
|
54
|
+
if (!schemaSql) {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await runLayerMigration({
|
|
59
|
+
layerName,
|
|
60
|
+
schemaSql,
|
|
61
|
+
connectionString: connStr,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (result.success) {
|
|
65
|
+
results.push({
|
|
66
|
+
layerName,
|
|
67
|
+
status: result.skipped ? 'skipped' : 'applied',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (!result.skipped) {
|
|
71
|
+
anyApplied = true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (usingBodyConnectionString && !connStrCommitted) {
|
|
75
|
+
// Deliberate runtime mutation: a valid connection string just
|
|
76
|
+
// proved itself against the database, so make it available to
|
|
77
|
+
// getConnectionString() for the rest of this process life. No
|
|
78
|
+
// server restart is needed for later requests to pick it up.
|
|
79
|
+
process.env.DATABASE_URL = connStr
|
|
80
|
+
connStrCommitted = true
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const message = scrubConnectionString(
|
|
84
|
+
result.error ?? 'Unknown migration error.',
|
|
85
|
+
connStr
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
console.error(`Migration failed [${layerName}]:`, message)
|
|
89
|
+
|
|
90
|
+
results.push({ layerName, status: 'failed', error: message })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Nothing to persist when DATABASE_URL was already configured. Otherwise
|
|
95
|
+
// only persist once a body-supplied string has proven itself valid.
|
|
96
|
+
let persisted = !usingBodyConnectionString
|
|
97
|
+
let message: string | undefined
|
|
98
|
+
|
|
99
|
+
if (usingBodyConnectionString && connStrCommitted) {
|
|
100
|
+
persisted = await persistDatabaseUrl(connStr)
|
|
101
|
+
|
|
102
|
+
if (!persisted) {
|
|
103
|
+
message =
|
|
104
|
+
'Migrations were applied, but the connection string could not be ' +
|
|
105
|
+
'saved to .env. Add DATABASE_URL to .env by hand.'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (import.meta.dev && anyApplied && config.plutoRootDir) {
|
|
110
|
+
await regenerateSupabaseTypes(config.plutoRootDir, connStr)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
success: true as const,
|
|
115
|
+
results,
|
|
116
|
+
persisted,
|
|
117
|
+
message,
|
|
118
|
+
}
|
|
119
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
2
|
+
import { getMigrationStatus } from '../../utils/pending-migrations'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireAdmin(event)
|
|
6
|
+
|
|
7
|
+
const { pending, applied, hasConnection } = await getMigrationStatus(event)
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
success: true as const,
|
|
11
|
+
pending,
|
|
12
|
+
applied,
|
|
13
|
+
hasConnection,
|
|
14
|
+
needsConnectionString: !hasConnection && pending.length > 0,
|
|
15
|
+
}
|
|
16
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { resolve } from 'node:path'
|
|
3
1
|
import postgres from 'postgres'
|
|
2
|
+
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
3
|
+
import { runLayerMigration } from '../../utils/migrations'
|
|
4
|
+
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
4
5
|
import { splitStatements } from '../../utils/sql'
|
|
5
6
|
|
|
6
7
|
interface Payload {
|
|
@@ -8,6 +9,12 @@ interface Payload {
|
|
|
8
9
|
connectionString: string
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
interface LayerResult {
|
|
13
|
+
layerName: string
|
|
14
|
+
status: 'applied' | 'skipped' | 'failed'
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export default defineEventHandler(async (event) => {
|
|
12
19
|
const body = await readBody<Payload>(event)
|
|
13
20
|
|
|
@@ -51,40 +58,71 @@ export default defineEventHandler(async (event) => {
|
|
|
51
58
|
ON CONFLICT (layer_name) DO NOTHING`
|
|
52
59
|
)
|
|
53
60
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
61
|
+
// Apply every extra layer's schema too. The core schema must run first
|
|
62
|
+
// (a layer schema may depend on a core object), so this loop stays
|
|
63
|
+
// after the core-schema block above. A single failing layer must not
|
|
64
|
+
// fail the whole wizard — the core schema already succeeded and
|
|
65
|
+
// first_setup is already 'false', so this loop reports per-layer
|
|
66
|
+
// failures instead of throwing.
|
|
67
|
+
const config = useRuntimeConfig()
|
|
68
|
+
const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
|
|
69
|
+
|
|
70
|
+
const layers: LayerResult[] = []
|
|
62
71
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
for (const [layerName, schemaSql] of Object.entries(layerSchemas)) {
|
|
73
|
+
if (!schemaSql) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await runLayerMigration({
|
|
78
|
+
layerName,
|
|
79
|
+
schemaSql,
|
|
80
|
+
connectionString,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (result.success) {
|
|
84
|
+
layers.push({
|
|
85
|
+
layerName,
|
|
86
|
+
status: result.skipped ? 'skipped' : 'applied',
|
|
87
|
+
})
|
|
88
|
+
} else {
|
|
89
|
+
const message = scrubConnectionString(
|
|
90
|
+
result.error ?? 'Unknown migration error.',
|
|
91
|
+
connectionString
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
console.error(`Layer migration failed [${layerName}]:`, message)
|
|
95
|
+
|
|
96
|
+
layers.push({ layerName, status: 'failed', error: message })
|
|
72
97
|
}
|
|
73
|
-
envContent += `DATABASE_URL="${connectionString}"\n`
|
|
74
98
|
}
|
|
75
99
|
|
|
76
|
-
|
|
100
|
+
// Deliberate runtime mutation: the connection string just proved
|
|
101
|
+
// itself against the database, so make it available to
|
|
102
|
+
// getConnectionString() for the rest of this process life, with no
|
|
103
|
+
// server restart needed.
|
|
104
|
+
process.env.DATABASE_URL = connectionString
|
|
105
|
+
|
|
106
|
+
// Persist connection string to .env for future layer migrations
|
|
107
|
+
const persisted = await persistDatabaseUrl(connectionString)
|
|
77
108
|
|
|
78
109
|
return {
|
|
79
110
|
success: true,
|
|
80
111
|
message: 'Database setup completed successfully.',
|
|
112
|
+
layers,
|
|
113
|
+
persisted,
|
|
81
114
|
}
|
|
82
115
|
} catch (error: any) {
|
|
83
|
-
|
|
116
|
+
const message = scrubConnectionString(
|
|
117
|
+
error.message ?? 'Unknown error.',
|
|
118
|
+
connectionString
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
console.error('Error setting up the database:', message)
|
|
84
122
|
|
|
85
123
|
return {
|
|
86
124
|
success: false,
|
|
87
|
-
error:
|
|
125
|
+
error: message,
|
|
88
126
|
}
|
|
89
127
|
} finally {
|
|
90
128
|
sql.end()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guards a server route so only a logged-in admin can call it.
|
|
6
|
+
*
|
|
7
|
+
* `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
|
|
8
|
+
* `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
|
|
9
|
+
* `User` row. The claims object has no `id` field — the user's id is the
|
|
10
|
+
* `sub` claim. Using `user.id` here silently queries `eq('id', undefined)`,
|
|
11
|
+
* which matches zero rows and looks exactly like "not an admin" even for a
|
|
12
|
+
* real admin. Always read `user.sub`, never `user.id`.
|
|
13
|
+
*
|
|
14
|
+
* Reads `is_admin` from `public.profiles`, never from `user_metadata`. The
|
|
15
|
+
* `handle_new_user` trigger never writes `is_admin` back to
|
|
16
|
+
* `auth.users.raw_user_meta_data`, so `user_metadata.is_admin` can be unset
|
|
17
|
+
* even for the first admin. `public.profiles.is_admin` is the source of
|
|
18
|
+
* truth.
|
|
19
|
+
*
|
|
20
|
+
* Throws a 401 if there is no logged-in user, or a 403 if the user is not
|
|
21
|
+
* an admin. Returns the claims on success.
|
|
22
|
+
*/
|
|
23
|
+
export async function requireAdmin(event: H3Event) {
|
|
24
|
+
const user = await serverSupabaseUser(event)
|
|
25
|
+
|
|
26
|
+
if (!user) {
|
|
27
|
+
throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
31
|
+
|
|
32
|
+
const { data: profile, error } = await client
|
|
33
|
+
.from('profiles')
|
|
34
|
+
.select('is_admin')
|
|
35
|
+
.eq('id', user.sub)
|
|
36
|
+
.single()
|
|
37
|
+
|
|
38
|
+
if (error || !profile?.is_admin) {
|
|
39
|
+
throw createError({
|
|
40
|
+
statusCode: 403,
|
|
41
|
+
statusMessage: 'Your account is not an admin.',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return user
|
|
46
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writes (or updates) `DATABASE_URL` in the project's `.env` file.
|
|
6
|
+
*
|
|
7
|
+
* Reads the current file, replaces an existing `DATABASE_URL` line or
|
|
8
|
+
* appends a new one, then writes it back. Never throws: a failure to read
|
|
9
|
+
* or write is swallowed and reported as `false`, so a caller can still
|
|
10
|
+
* report success for work that is already durably recorded elsewhere (for
|
|
11
|
+
* example, an applied migration row) even when `.env` could not be
|
|
12
|
+
* updated.
|
|
13
|
+
*
|
|
14
|
+
* Caveat: `process.cwd()` may not point at the project root in every
|
|
15
|
+
* deployment (a production build can start from a different working
|
|
16
|
+
* directory). This best-effort write can silently target the wrong file in
|
|
17
|
+
* that case.
|
|
18
|
+
*/
|
|
19
|
+
export async function persistDatabaseUrl(
|
|
20
|
+
connectionString: string
|
|
21
|
+
): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
const envPath = resolve(process.cwd(), '.env')
|
|
24
|
+
let envContent = ''
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
envContent = await readFile(envPath, 'utf-8')
|
|
28
|
+
} catch {
|
|
29
|
+
// .env doesn't exist yet.
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (envContent.includes('DATABASE_URL=')) {
|
|
33
|
+
envContent = envContent.replace(
|
|
34
|
+
/^DATABASE_URL=.*$/m,
|
|
35
|
+
`DATABASE_URL="${connectionString}"`
|
|
36
|
+
)
|
|
37
|
+
} else {
|
|
38
|
+
// Ensure there's a trailing newline before appending.
|
|
39
|
+
if (envContent.length > 0 && !envContent.endsWith('\n')) {
|
|
40
|
+
envContent += '\n'
|
|
41
|
+
}
|
|
42
|
+
envContent += `DATABASE_URL="${connectionString}"\n`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await writeFile(envPath, envContent, 'utf-8')
|
|
46
|
+
|
|
47
|
+
return true
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Error persisting DATABASE_URL to .env:', error)
|
|
50
|
+
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { serverSupabaseClient } from '#supabase/server'
|
|
3
|
+
|
|
4
|
+
export interface MigrationStatus {
|
|
5
|
+
discovered: string[]
|
|
6
|
+
applied: string[]
|
|
7
|
+
pending: string[]
|
|
8
|
+
hasConnection: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Computes which layer schemas are discovered, applied, and pending.
|
|
13
|
+
*
|
|
14
|
+
* This reads `pluto_migrations` through RLS with the caller's own session
|
|
15
|
+
* (the table policy limits reads to `authenticated`). Call this only from a
|
|
16
|
+
* route already gated by `requireAdmin` — an anonymous caller would see zero
|
|
17
|
+
* rows (not an error), which would make every discovered layer look
|
|
18
|
+
* pending and leak layer names to an unauthenticated visitor.
|
|
19
|
+
*
|
|
20
|
+
* Never returns the SQL content of a layer schema, only its name.
|
|
21
|
+
*/
|
|
22
|
+
export async function getMigrationStatus(
|
|
23
|
+
event: H3Event
|
|
24
|
+
): Promise<MigrationStatus> {
|
|
25
|
+
const config = useRuntimeConfig()
|
|
26
|
+
const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
|
|
27
|
+
const discovered = Object.keys(layerSchemas)
|
|
28
|
+
|
|
29
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
30
|
+
|
|
31
|
+
const { data, error } = await client
|
|
32
|
+
.from('pluto_migrations')
|
|
33
|
+
.select('layer_name')
|
|
34
|
+
|
|
35
|
+
if (error) {
|
|
36
|
+
throw createError({
|
|
37
|
+
statusCode: 500,
|
|
38
|
+
statusMessage: `Error reading migration status: ${error.message}`,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const applied = (data ?? []).map((row) => row.layer_name)
|
|
43
|
+
const appliedSet = new Set(applied)
|
|
44
|
+
const pending = discovered.filter((name) => !appliedSet.has(name))
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
discovered,
|
|
48
|
+
applied,
|
|
49
|
+
pending,
|
|
50
|
+
hasConnection: Boolean(process.env.DATABASE_URL),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes a connection string, and its password segment, from an error
|
|
3
|
+
* message before it is logged or returned to a client.
|
|
4
|
+
*
|
|
5
|
+
* A `postgres` error can embed the full connection string (host, user,
|
|
6
|
+
* password) in its message. Call this on every error message that might
|
|
7
|
+
* have touched a connection string, before it is logged or sent in a
|
|
8
|
+
* response.
|
|
9
|
+
*/
|
|
10
|
+
export function scrubConnectionString(
|
|
11
|
+
message: string,
|
|
12
|
+
connStr: string
|
|
13
|
+
): string {
|
|
14
|
+
if (!message || !connStr) {
|
|
15
|
+
return message
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let scrubbed = message.split(connStr).join('[redacted]')
|
|
19
|
+
|
|
20
|
+
const passwordMatch = connStr.match(/:\/\/[^:@/]+:([^@]+)@/)
|
|
21
|
+
const password = passwordMatch?.[1]
|
|
22
|
+
|
|
23
|
+
if (password) {
|
|
24
|
+
scrubbed = scrubbed.split(password).join('[redacted]')
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const decodedPassword = decodeURIComponent(password)
|
|
28
|
+
if (decodedPassword !== password) {
|
|
29
|
+
scrubbed = scrubbed.split(decodedPassword).join('[redacted]')
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Password wasn't URI-encoded — nothing more to decode.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return scrubbed
|
|
37
|
+
}
|