@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/FEATURES.md +3 -0
  3. package/app/components/EnvPersistWarning.vue +82 -0
  4. package/app/components/migrations/MigrationsBanner.vue +26 -0
  5. package/app/components/navbar/NavbarAdminProvider.vue +4 -0
  6. package/app/composables/auth.ts +11 -9
  7. package/app/composables/migrations.ts +87 -0
  8. package/app/middleware/setup-check.ts +8 -1
  9. package/app/pages/admin/migrations.vue +344 -0
  10. package/app/pages/admin/setup.vue +109 -7
  11. package/app/pages/admin.vue +13 -0
  12. package/db/migrations/002_admin_hardening.sql +107 -0
  13. package/modules/pluto-migrations.ts +167 -32
  14. package/nuxt.config.ts +3 -1
  15. package/package.json +2 -2
  16. package/server/api/migrations/run.post.ts +101 -0
  17. package/server/api/migrations/status.get.ts +14 -0
  18. package/server/api/settings/update.post.ts +34 -6
  19. package/server/api/setup/create.post.ts +82 -54
  20. package/server/api/users/[id].get.ts +3 -0
  21. package/server/api/users/index.get.ts +3 -0
  22. package/server/plugins/migrations.ts +35 -38
  23. package/server/utils/admin-guard.ts +46 -0
  24. package/server/utils/env-file.ts +53 -0
  25. package/server/utils/ledger.ts +124 -0
  26. package/server/utils/migrations.ts +0 -0
  27. package/server/utils/pending-migrations.ts +0 -0
  28. package/server/utils/scrub-connection-string.ts +37 -0
  29. package/server/utils/sql.ts +86 -11
  30. package/shared/types/migrations.d.ts +28 -0
  31. package/shared/types/runtime-config.d.ts +10 -8
  32. package/shared/types/supabase.ts +7 -12
  33. /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)
@@ -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>