@plutocms/supabase 0.2.2 → 0.4.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 +28 -0
- package/FEATURES.md +3 -0
- package/app/components/EnvPersistWarning.vue +82 -0
- package/app/components/migrations/MigrationsBanner.vue +26 -0
- package/app/components/navbar/NavbarAdminProvider.vue +4 -0
- package/app/composables/auth.ts +11 -9
- package/app/composables/migrations.ts +87 -0
- package/app/middleware/setup-check.ts +8 -1
- package/app/pages/admin/migrations.vue +344 -0
- package/app/pages/admin/setup.vue +109 -7
- package/app/pages/admin.vue +13 -0
- package/db/migrations/002_admin_hardening.sql +107 -0
- package/modules/pluto-migrations.ts +167 -32
- package/nuxt.config.ts +3 -1
- package/package.json +2 -2
- package/server/api/migrations/run.post.ts +101 -0
- package/server/api/migrations/status.get.ts +14 -0
- package/server/api/settings/update.post.ts +34 -6
- package/server/api/setup/create.post.ts +82 -54
- package/server/api/users/[id].get.ts +3 -0
- package/server/api/users/index.get.ts +3 -0
- package/server/plugins/migrations.ts +35 -38
- package/server/utils/admin-guard.ts +46 -0
- package/server/utils/env-file.ts +53 -0
- package/server/utils/ledger.ts +124 -0
- package/server/utils/migrations.ts +0 -0
- package/server/utils/pending-migrations.ts +0 -0
- package/server/utils/scrub-connection-string.ts +37 -0
- package/server/utils/sql.ts +86 -11
- package/shared/types/migrations.d.ts +28 -0
- package/shared/types/runtime-config.d.ts +10 -8
- package/shared/types/supabase.ts +7 -12
- /package/{public/schema.sql → db/migrations/001_baseline.sql} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/plutocms/supabase/compare/v0.3.0...v0.4.0) (2026-09-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **migrations:** harden SQL splitter and add a versioned ledger shape ([#45](https://github.com/plutocms/supabase/issues/45)) ([f6e54a6](https://github.com/plutocms/supabase/commit/f6e54a6d87ae12c197e467c6d271793ce0ae4b7f))
|
|
9
|
+
* **migrations:** versioned per-file migration engine ([#46](https://github.com/plutocms/supabase/issues/46)) ([2e39d12](https://github.com/plutocms/supabase/commit/2e39d1255ada616ae55894bc2d363eb710d1a8dd))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* close setup-wizard RCE, signup privilege escalation, and unguarded admin routes ([#43](https://github.com/plutocms/supabase/issues/43)) ([81c3c7e](https://github.com/plutocms/supabase/commit/81c3c7eb7c2dfa80b426d91d0330176298316be4))
|
|
15
|
+
* **deps:** move @nuxt/eslint to dependencies ([#42](https://github.com/plutocms/supabase/issues/42)) ([11e5b1e](https://github.com/plutocms/supabase/commit/11e5b1eac80436af85a70252d25e22bda8bc3be2))
|
|
16
|
+
* **navbar:** hide admin navbar on public auth pages ([f9b2a9e](https://github.com/plutocms/supabase/commit/f9b2a9e8db7c83f89c842565e359f794fb700143))
|
|
17
|
+
* **types:** drop media.product_id from the committed type snapshot ([#44](https://github.com/plutocms/supabase/issues/44)) ([6b21506](https://github.com/plutocms/supabase/commit/6b215064c4f8538af097b71b4b5f1567de1d585e))
|
|
18
|
+
|
|
19
|
+
## [0.3.0](https://github.com/plutocms/supabase/compare/v0.2.2...v0.3.0) (2026-09-10)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* **migrations:** detect and apply pending layer schemas ([b7f0785](https://github.com/plutocms/supabase/commit/b7f0785838f65dcbf9cb22fb3d21307c0819f70b))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **setup:** apply layer schemas when the wizard completes ([f29b2aa](https://github.com/plutocms/supabase/commit/f29b2aae4f8b6c5211f157ad349e0566df57c5cc))
|
|
30
|
+
|
|
3
31
|
## [0.2.2](https://github.com/plutocms/supabase/compare/v0.2.1...v0.2.2) (2026-09-10)
|
|
4
32
|
|
|
5
33
|
|
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, versioned, per-file database migrations from the admin UI,
|
|
10
|
+
after initial 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>
|
|
@@ -3,6 +3,10 @@ const route = useRoute()
|
|
|
3
3
|
const session = useSupabaseSession()
|
|
4
4
|
|
|
5
5
|
const shouldShowNavbar = computed(() => {
|
|
6
|
+
if (allowedUnauthenticatedPaths.includes(route.path)) {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
return route.path === '/admin'
|
|
7
11
|
|| route.path.startsWith('/admin/')
|
|
8
12
|
|| Boolean(session.value)
|
package/app/composables/auth.ts
CHANGED
|
@@ -16,6 +16,17 @@ interface PlutoUserMetadata {
|
|
|
16
16
|
[key: string]: unknown
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Admin paths that a signed-out visitor may load. Keep this list in sync
|
|
20
|
+
// with every public `/admin/*` page.
|
|
21
|
+
export const allowedUnauthenticatedPaths: RouteLocationRaw[] = [
|
|
22
|
+
'/admin/login',
|
|
23
|
+
'/admin/signup',
|
|
24
|
+
'/admin/confirm',
|
|
25
|
+
'/admin/setup',
|
|
26
|
+
'/admin/forgot-password',
|
|
27
|
+
'/admin/update-password',
|
|
28
|
+
]
|
|
29
|
+
|
|
19
30
|
export async function useAuth(authOptions?: PlutoSupabaseAuthOptions) {
|
|
20
31
|
const toast = useToast()
|
|
21
32
|
|
|
@@ -50,15 +61,6 @@ export async function useAuth(authOptions?: PlutoSupabaseAuthOptions) {
|
|
|
50
61
|
const isLoggedIn = computed<boolean>(() => !!supabaseSession.value)
|
|
51
62
|
const isSubmitting = ref<boolean>(false)
|
|
52
63
|
|
|
53
|
-
const allowedUnauthenticatedPaths: RouteLocationRaw[] = [
|
|
54
|
-
'/admin/login',
|
|
55
|
-
'/admin/signup',
|
|
56
|
-
'/admin/confirm',
|
|
57
|
-
'/admin/setup',
|
|
58
|
-
'/admin/forgot-password',
|
|
59
|
-
'/admin/update-password',
|
|
60
|
-
]
|
|
61
|
-
|
|
62
64
|
interface LoginForm {
|
|
63
65
|
email: string
|
|
64
66
|
password: string
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export interface MigrationFileResult {
|
|
2
|
+
layerName: string
|
|
3
|
+
migrationName: string
|
|
4
|
+
status: 'applied' | 'skipped' | 'failed'
|
|
5
|
+
error?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LayerStatus {
|
|
9
|
+
layerName: string
|
|
10
|
+
applied: string[]
|
|
11
|
+
pending: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MigrationsStatusResponse {
|
|
15
|
+
success: boolean
|
|
16
|
+
layers: LayerStatus[]
|
|
17
|
+
pending: string[]
|
|
18
|
+
applied: string[]
|
|
19
|
+
discovered: string[]
|
|
20
|
+
pendingFileCount: number
|
|
21
|
+
hasConnection: boolean
|
|
22
|
+
needsConnectionString: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RunMigrationsResponse {
|
|
26
|
+
success: boolean
|
|
27
|
+
needsConnectionString?: boolean
|
|
28
|
+
results?: MigrationFileResult[]
|
|
29
|
+
persisted?: boolean
|
|
30
|
+
message?: string
|
|
31
|
+
error?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads and applies pending migrations, for admin-only pages and the
|
|
36
|
+
* admin-shell banner.
|
|
37
|
+
*
|
|
38
|
+
* `useFetch` keys the request as `pluto-migrations-status`, so every
|
|
39
|
+
* caller in the app shares the same request and state instead of each
|
|
40
|
+
* firing its own.
|
|
41
|
+
*
|
|
42
|
+
* A 401 or 403 from the status endpoint means the visitor is not a
|
|
43
|
+
* logged-in admin. `status` reads as `null` in that case, so a caller
|
|
44
|
+
* should show nothing — an unauthenticated visitor must see no trace of
|
|
45
|
+
* this feature. Only call this composable from admin pages/components.
|
|
46
|
+
*/
|
|
47
|
+
export function useMigrations() {
|
|
48
|
+
const {
|
|
49
|
+
data,
|
|
50
|
+
error,
|
|
51
|
+
status: fetchStatus,
|
|
52
|
+
refresh,
|
|
53
|
+
} = useFetch<MigrationsStatusResponse>('/api/migrations/status', {
|
|
54
|
+
key: 'pluto-migrations-status',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const status = computed(() => {
|
|
58
|
+
if (error.value) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return data.value ?? null
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Layer-level count — a layer with any pending file counts once here.
|
|
66
|
+
// See `status.pendingFileCount` for the per-file count.
|
|
67
|
+
const pendingCount = computed(() => status.value?.pending.length ?? 0)
|
|
68
|
+
|
|
69
|
+
async function runMigrations(connectionString?: string) {
|
|
70
|
+
const result = await $fetch<RunMigrationsResponse>('/api/migrations/run', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
body: connectionString ? { connectionString } : undefined,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
await refresh()
|
|
76
|
+
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
status,
|
|
82
|
+
fetchStatus,
|
|
83
|
+
pendingCount,
|
|
84
|
+
refresh,
|
|
85
|
+
runMigrations,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -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,344 @@
|
|
|
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
|
+
// Only the layers that actually have a pending, or applied, file — used to
|
|
19
|
+
// render per-file detail under each layer below.
|
|
20
|
+
const pendingLayers = computed(
|
|
21
|
+
() => status.value?.layers.filter((layer) => layer.pending.length > 0) ?? []
|
|
22
|
+
)
|
|
23
|
+
const appliedLayers = computed(
|
|
24
|
+
() => status.value?.layers.filter((layer) => layer.applied.length > 0) ?? []
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const connectionForm = ref({
|
|
28
|
+
connectionString: '',
|
|
29
|
+
password: '',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const isRunning = ref(false)
|
|
33
|
+
const lastRun = ref<Awaited<ReturnType<typeof runMigrations>> | null>(null)
|
|
34
|
+
// Kept only in this page's own memory, only to show the manual .env step if
|
|
35
|
+
// persisting fails — see EnvPersistWarning.vue. Never sent anywhere new.
|
|
36
|
+
const lastConnectionString = ref('')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Polls the status endpoint until it responds again, or gives up.
|
|
40
|
+
*
|
|
41
|
+
* Used only to wait out a dev-server restart (see the comment in the
|
|
42
|
+
* `catch` block of `applyMigrations`) — the server is expected to come
|
|
43
|
+
* back within a few seconds, not to be actually down.
|
|
44
|
+
*/
|
|
45
|
+
async function waitForServerAndRefresh(): Promise<boolean> {
|
|
46
|
+
const maxAttempts = 15
|
|
47
|
+
const delayMs = 2000
|
|
48
|
+
|
|
49
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
51
|
+
await refresh()
|
|
52
|
+
|
|
53
|
+
if (status.value) {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function applyMigrations(useForm: boolean) {
|
|
62
|
+
isRunning.value = true
|
|
63
|
+
lastRun.value = null
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const connectionString = useForm
|
|
67
|
+
? connectionForm.value.connectionString.replace(
|
|
68
|
+
'[YOUR-PASSWORD]',
|
|
69
|
+
encodeURIComponent(connectionForm.value.password)
|
|
70
|
+
)
|
|
71
|
+
: undefined
|
|
72
|
+
|
|
73
|
+
if (connectionString) {
|
|
74
|
+
lastConnectionString.value = connectionString
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await runMigrations(connectionString)
|
|
78
|
+
lastRun.value = result
|
|
79
|
+
|
|
80
|
+
if (result.success === false) {
|
|
81
|
+
toast.add({
|
|
82
|
+
title: 'Could not apply migrations',
|
|
83
|
+
description: result.needsConnectionString
|
|
84
|
+
? 'A database connection string is required.'
|
|
85
|
+
: undefined,
|
|
86
|
+
icon: 'lucide:circle-x',
|
|
87
|
+
color: 'error',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toast.add({
|
|
94
|
+
title: 'Migrations applied',
|
|
95
|
+
icon: 'lucide:check-circle',
|
|
96
|
+
color: 'success',
|
|
97
|
+
})
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Saving a first-time connection string writes DATABASE_URL to .env
|
|
100
|
+
// (server/utils/env-file.ts). In dev, Nuxt restarts the whole dev
|
|
101
|
+
// server whenever .env changes, so this request's connection can drop
|
|
102
|
+
// before its response arrives, even though the migration itself
|
|
103
|
+
// already succeeded server-side. A dropped connection has no HTTP
|
|
104
|
+
// status code — a real error response would — so use that to tell the
|
|
105
|
+
// two apart, and only in dev, where the restart is expected at all.
|
|
106
|
+
const hasStatusCode =
|
|
107
|
+
typeof error === 'object' &&
|
|
108
|
+
error !== null &&
|
|
109
|
+
'statusCode' in error &&
|
|
110
|
+
(error as { statusCode?: unknown }).statusCode !== undefined
|
|
111
|
+
|
|
112
|
+
if (isDev && useForm && !hasStatusCode) {
|
|
113
|
+
toast.add({
|
|
114
|
+
title: 'Dev server restarting',
|
|
115
|
+
description:
|
|
116
|
+
'Saving a new connection string restarts the dev server. Reconnecting…',
|
|
117
|
+
icon: 'lucide:refresh-cw',
|
|
118
|
+
color: 'info',
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const recovered = await waitForServerAndRefresh()
|
|
122
|
+
|
|
123
|
+
toast.add(
|
|
124
|
+
recovered
|
|
125
|
+
? {
|
|
126
|
+
title: 'Reconnected',
|
|
127
|
+
description:
|
|
128
|
+
pendingCount.value === 0
|
|
129
|
+
? 'The server is back. All migrations were applied.'
|
|
130
|
+
: `The server is back. ${pendingCount.value} layer(s) still pending — check above.`,
|
|
131
|
+
icon: 'lucide:check-circle',
|
|
132
|
+
color: 'success',
|
|
133
|
+
}
|
|
134
|
+
: {
|
|
135
|
+
title: 'Still reconnecting',
|
|
136
|
+
description:
|
|
137
|
+
'The dev server is taking longer than expected. Reload this page in a moment.',
|
|
138
|
+
icon: 'lucide:triangle-alert',
|
|
139
|
+
color: 'warning',
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (import.meta.dev) {
|
|
147
|
+
console.error('Error applying migrations:', error)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
toast.add({
|
|
151
|
+
title: 'Error applying migrations',
|
|
152
|
+
description: 'Please check the console for more details.',
|
|
153
|
+
icon: 'lucide:circle-x',
|
|
154
|
+
color: 'error',
|
|
155
|
+
})
|
|
156
|
+
} finally {
|
|
157
|
+
isRunning.value = false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<template>
|
|
163
|
+
<AdminView>
|
|
164
|
+
<h1 class="text-3xl font-bold lg:text-4xl">Migrations</h1>
|
|
165
|
+
|
|
166
|
+
<div v-if="fetchStatus === 'pending'" class="flex items-center gap-x-2">
|
|
167
|
+
<Icon name="svg-spinners:ring-resize" />
|
|
168
|
+
<span>Loading migration status…</span>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<template v-else-if="status">
|
|
172
|
+
<UCard>
|
|
173
|
+
<div class="flex flex-col gap-y-6">
|
|
174
|
+
<UAlert
|
|
175
|
+
v-if="pendingCount === 0"
|
|
176
|
+
color="success"
|
|
177
|
+
variant="outline"
|
|
178
|
+
icon="lucide:check-circle"
|
|
179
|
+
title="Everything is up to date"
|
|
180
|
+
description="No layer needs a database migration."
|
|
181
|
+
/>
|
|
182
|
+
|
|
183
|
+
<div v-else class="flex flex-col gap-y-2">
|
|
184
|
+
<h2 class="font-semibold">
|
|
185
|
+
Pending layers ({{ status.pending.length }})
|
|
186
|
+
</h2>
|
|
187
|
+
<ul class="flex flex-col gap-y-2 text-sm">
|
|
188
|
+
<li v-for="layer in pendingLayers" :key="layer.layerName">
|
|
189
|
+
<span class="font-mono">{{ layer.layerName }}</span>
|
|
190
|
+
<ul class="list-inside list-disc pl-4">
|
|
191
|
+
<li v-for="fileName in layer.pending" :key="fileName">
|
|
192
|
+
{{ fileName }}
|
|
193
|
+
</li>
|
|
194
|
+
</ul>
|
|
195
|
+
</li>
|
|
196
|
+
</ul>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div v-if="status.applied.length" class="flex flex-col gap-y-2">
|
|
200
|
+
<h2 class="font-semibold">
|
|
201
|
+
Applied layers ({{ status.applied.length }})
|
|
202
|
+
</h2>
|
|
203
|
+
<ul class="flex flex-col gap-y-2 text-sm">
|
|
204
|
+
<li v-for="layer in appliedLayers" :key="layer.layerName">
|
|
205
|
+
<span class="font-mono">{{ layer.layerName }}</span>
|
|
206
|
+
<ul class="list-inside list-disc pl-4">
|
|
207
|
+
<li v-for="fileName in layer.applied" :key="fileName">
|
|
208
|
+
{{ fileName }}
|
|
209
|
+
</li>
|
|
210
|
+
</ul>
|
|
211
|
+
</li>
|
|
212
|
+
</ul>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<template v-if="pendingCount > 0">
|
|
216
|
+
<UForm
|
|
217
|
+
v-if="status.needsConnectionString"
|
|
218
|
+
class="flex flex-col gap-y-4"
|
|
219
|
+
@submit="applyMigrations(true)"
|
|
220
|
+
>
|
|
221
|
+
<UFormField label="Supabase connection string" required>
|
|
222
|
+
<UInput
|
|
223
|
+
v-model="connectionForm.connectionString"
|
|
224
|
+
:disabled="isRunning"
|
|
225
|
+
placeholder="postgresql://"
|
|
226
|
+
required
|
|
227
|
+
/>
|
|
228
|
+
|
|
229
|
+
<template #help>
|
|
230
|
+
You can find your connection string
|
|
231
|
+
<ULink
|
|
232
|
+
:to="`https://supabase.com/dashboard/project/${projectId}/database/settings?showConnect=true&connectTab=direct&method=transaction`"
|
|
233
|
+
target="_blank"
|
|
234
|
+
class="underline"
|
|
235
|
+
external
|
|
236
|
+
>
|
|
237
|
+
clicking here </ULink
|
|
238
|
+
>.
|
|
239
|
+
</template>
|
|
240
|
+
</UFormField>
|
|
241
|
+
|
|
242
|
+
<UFormField label="Database password" required>
|
|
243
|
+
<UInput
|
|
244
|
+
v-model="connectionForm.password"
|
|
245
|
+
:disabled="isRunning"
|
|
246
|
+
type="password"
|
|
247
|
+
placeholder="• • • • •"
|
|
248
|
+
required
|
|
249
|
+
/>
|
|
250
|
+
</UFormField>
|
|
251
|
+
|
|
252
|
+
<UAlert
|
|
253
|
+
color="secondary"
|
|
254
|
+
icon="lucide:badge-info"
|
|
255
|
+
variant="outline"
|
|
256
|
+
>
|
|
257
|
+
<template #description>
|
|
258
|
+
Your password is created when you create your Supabase
|
|
259
|
+
project. You can change it in the
|
|
260
|
+
<ULink
|
|
261
|
+
to="https://supabase.com/dashboard/project/_/database/settings"
|
|
262
|
+
target="_blank"
|
|
263
|
+
>
|
|
264
|
+
Supabase Database Settings </ULink
|
|
265
|
+
>.
|
|
266
|
+
</template>
|
|
267
|
+
</UAlert>
|
|
268
|
+
|
|
269
|
+
<UAlert
|
|
270
|
+
color="warning"
|
|
271
|
+
variant="outline"
|
|
272
|
+
title="Why do I need to provide a database password?"
|
|
273
|
+
icon="lucide:info"
|
|
274
|
+
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."
|
|
275
|
+
/>
|
|
276
|
+
|
|
277
|
+
<UAlert
|
|
278
|
+
v-if="isDev"
|
|
279
|
+
color="info"
|
|
280
|
+
variant="outline"
|
|
281
|
+
title="This restarts the dev server"
|
|
282
|
+
icon="lucide:refresh-cw"
|
|
283
|
+
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."
|
|
284
|
+
/>
|
|
285
|
+
|
|
286
|
+
<div class="flex">
|
|
287
|
+
<UButton
|
|
288
|
+
:loading="isRunning"
|
|
289
|
+
:disabled="isRunning"
|
|
290
|
+
type="submit"
|
|
291
|
+
icon="lucide:database"
|
|
292
|
+
class="w-full justify-center sm:w-auto"
|
|
293
|
+
>
|
|
294
|
+
Apply pending migrations
|
|
295
|
+
</UButton>
|
|
296
|
+
</div>
|
|
297
|
+
</UForm>
|
|
298
|
+
|
|
299
|
+
<div v-else class="flex">
|
|
300
|
+
<UButton
|
|
301
|
+
:loading="isRunning"
|
|
302
|
+
:disabled="isRunning"
|
|
303
|
+
icon="lucide:database"
|
|
304
|
+
class="w-full justify-center sm:w-auto"
|
|
305
|
+
@click="applyMigrations(false)"
|
|
306
|
+
>
|
|
307
|
+
Apply pending migrations
|
|
308
|
+
</UButton>
|
|
309
|
+
</div>
|
|
310
|
+
</template>
|
|
311
|
+
</div>
|
|
312
|
+
</UCard>
|
|
313
|
+
|
|
314
|
+
<UCard v-if="lastRun">
|
|
315
|
+
<div class="flex flex-col gap-y-4">
|
|
316
|
+
<h2 class="font-semibold">Last run</h2>
|
|
317
|
+
|
|
318
|
+
<EnvPersistWarning
|
|
319
|
+
v-if="lastRun.success && lastRun.persisted === false"
|
|
320
|
+
:connection-string="lastConnectionString"
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
<ul
|
|
324
|
+
v-if="lastRun.results?.length"
|
|
325
|
+
class="flex flex-col gap-y-1 text-sm"
|
|
326
|
+
>
|
|
327
|
+
<li
|
|
328
|
+
v-for="result in lastRun.results"
|
|
329
|
+
:key="`${result.layerName}/${result.migrationName}`"
|
|
330
|
+
>
|
|
331
|
+
<span class="font-mono"
|
|
332
|
+
>{{ result.layerName }}/{{ result.migrationName }}</span
|
|
333
|
+
>
|
|
334
|
+
— {{ result.status }}
|
|
335
|
+
<span v-if="result.error" class="text-error">
|
|
336
|
+
: {{ result.error }}</span
|
|
337
|
+
>
|
|
338
|
+
</li>
|
|
339
|
+
</ul>
|
|
340
|
+
</div>
|
|
341
|
+
</UCard>
|
|
342
|
+
</template>
|
|
343
|
+
</AdminView>
|
|
344
|
+
</template>
|