@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
|
@@ -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,55 @@ 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
|
-
|
|
75
|
-
connectionString: databaseForm.value.connectionString.replace(
|
|
76
|
-
'[YOUR-PASSWORD]',
|
|
77
|
-
encodeURIComponent(databaseForm.value.password)
|
|
78
|
-
),
|
|
123
|
+
connectionString,
|
|
79
124
|
},
|
|
80
125
|
})
|
|
81
126
|
|
|
@@ -90,6 +135,11 @@ async function completeDatabaseSetup() {
|
|
|
90
135
|
return
|
|
91
136
|
}
|
|
92
137
|
|
|
138
|
+
if (data.persisted === false) {
|
|
139
|
+
envNotPersisted.value = true
|
|
140
|
+
submittedConnectionString.value = connectionString
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
toast.add({
|
|
94
144
|
title: 'Database setup complete',
|
|
95
145
|
description: 'Your database has been set up successfully.',
|
|
@@ -99,6 +149,44 @@ async function completeDatabaseSetup() {
|
|
|
99
149
|
|
|
100
150
|
stepper.value?.next()
|
|
101
151
|
} catch (error) {
|
|
152
|
+
// Writing DATABASE_URL to .env (the last step of /api/setup/create,
|
|
153
|
+
// after the schema and every layer migration already succeeded)
|
|
154
|
+
// restarts the dev server. The response can be cut off by that
|
|
155
|
+
// restart even though the setup itself worked. A dropped connection
|
|
156
|
+
// has no HTTP status code — a real error response would — so use
|
|
157
|
+
// that, and only in dev, to tell "still finishing" apart from a real
|
|
158
|
+
// failure. See the matching comment in app/pages/admin/migrations.vue.
|
|
159
|
+
const hasStatusCode =
|
|
160
|
+
typeof error === 'object' &&
|
|
161
|
+
error !== null &&
|
|
162
|
+
'statusCode' in error &&
|
|
163
|
+
(error as { statusCode?: unknown }).statusCode !== undefined
|
|
164
|
+
|
|
165
|
+
if (import.meta.dev && !hasStatusCode) {
|
|
166
|
+
toast.add({
|
|
167
|
+
title: 'Dev server restarting',
|
|
168
|
+
description: 'Setup is finishing. Reconnecting…',
|
|
169
|
+
icon: 'lucide:refresh-cw',
|
|
170
|
+
color: 'info',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const recovered = await waitForServerAndAdvance()
|
|
174
|
+
|
|
175
|
+
if (recovered) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
toast.add({
|
|
180
|
+
title: 'Still reconnecting',
|
|
181
|
+
description:
|
|
182
|
+
'The dev server is taking longer than expected. Reload this page in a moment.',
|
|
183
|
+
icon: 'lucide:triangle-alert',
|
|
184
|
+
color: 'warning',
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
102
190
|
if (import.meta.dev) {
|
|
103
191
|
console.error('Error setting up the database:', error)
|
|
104
192
|
}
|
|
@@ -234,11 +322,11 @@ function handleStepChange(step: number) {
|
|
|
234
322
|
Supabase database and run the SQL queries from
|
|
235
323
|
this
|
|
236
324
|
<ULink
|
|
237
|
-
to="https://github.com/plutocms/supabase/
|
|
325
|
+
to="https://github.com/plutocms/supabase/tree/main/db/migrations"
|
|
238
326
|
target="_blank"
|
|
239
327
|
class="underline"
|
|
240
328
|
>
|
|
241
|
-
SQL
|
|
329
|
+
SQL files</ULink
|
|
242
330
|
>.
|
|
243
331
|
</p>
|
|
244
332
|
|
|
@@ -253,6 +341,15 @@ function handleStepChange(step: number) {
|
|
|
253
341
|
</div>
|
|
254
342
|
</template>
|
|
255
343
|
</UAlert>
|
|
344
|
+
|
|
345
|
+
<UAlert
|
|
346
|
+
v-if="isDev"
|
|
347
|
+
color="info"
|
|
348
|
+
variant="outline"
|
|
349
|
+
title="This restarts the dev server"
|
|
350
|
+
icon="lucide:refresh-cw"
|
|
351
|
+
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."
|
|
352
|
+
/>
|
|
256
353
|
</UForm>
|
|
257
354
|
</section>
|
|
258
355
|
|
|
@@ -269,6 +366,11 @@ function handleStepChange(step: number) {
|
|
|
269
366
|
up.
|
|
270
367
|
</p>
|
|
271
368
|
|
|
369
|
+
<EnvPersistWarning
|
|
370
|
+
v-if="envNotPersisted"
|
|
371
|
+
:connection-string="submittedConnectionString"
|
|
372
|
+
/>
|
|
373
|
+
|
|
272
374
|
<p class="text-center">
|
|
273
375
|
<UButton
|
|
274
376
|
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>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
-- Admin hardening
|
|
2
|
+
--
|
|
3
|
+
-- Gates the writes that were previously open to any authenticated user
|
|
4
|
+
-- behind a real admin check, and closes a client-controlled admin
|
|
5
|
+
-- self-grant on signup. Standalone and self-sufficient: it does not
|
|
6
|
+
-- depend on running as a diff against 001_baseline.sql, only on that
|
|
7
|
+
-- file's tables (public.profiles, public.settings, public.healthcheck)
|
|
8
|
+
-- already existing.
|
|
9
|
+
|
|
10
|
+
-- Returns true when the calling user's own profile has is_admin = true.
|
|
11
|
+
-- SECURITY DEFINER so this lookup runs with the function owner's
|
|
12
|
+
-- privileges rather than the caller's — RLS policies call this to decide
|
|
13
|
+
-- whether to grant a *wider* read/write than a user's own row would
|
|
14
|
+
-- otherwise get, so the check itself must not be subject to those same
|
|
15
|
+
-- policies.
|
|
16
|
+
create or replace function public.is_admin()
|
|
17
|
+
returns boolean
|
|
18
|
+
language sql
|
|
19
|
+
security definer
|
|
20
|
+
set search_path = ''
|
|
21
|
+
stable
|
|
22
|
+
as $$
|
|
23
|
+
select coalesce(
|
|
24
|
+
(select is_admin from public.profiles where id = auth.uid()),
|
|
25
|
+
false
|
|
26
|
+
);
|
|
27
|
+
$$;
|
|
28
|
+
|
|
29
|
+
-- A user can always read their own profile; only admins can read
|
|
30
|
+
-- everyone's. Previously any authenticated user could read every row
|
|
31
|
+
-- (including every user's email) — this was the actual reason `is_admin`
|
|
32
|
+
-- existed on the table but no policy referenced it.
|
|
33
|
+
drop policy if exists "Enable read access for authenticated users" on public.profiles;
|
|
34
|
+
|
|
35
|
+
create policy "Enable read access for authenticated users"
|
|
36
|
+
on public.profiles
|
|
37
|
+
for select
|
|
38
|
+
to authenticated, dashboard_user
|
|
39
|
+
using (auth.uid() = id or public.is_admin());
|
|
40
|
+
|
|
41
|
+
-- Previously any authenticated user (not just an admin) could insert or
|
|
42
|
+
-- update settings — see server/api/settings/update.post.ts, which also
|
|
43
|
+
-- now requires an admin session.
|
|
44
|
+
drop policy if exists "Enable insert for authenticated users only" on public.settings;
|
|
45
|
+
|
|
46
|
+
create policy "Enable insert for authenticated users only"
|
|
47
|
+
on public.settings
|
|
48
|
+
for insert
|
|
49
|
+
to authenticated, dashboard_user
|
|
50
|
+
with check (public.is_admin());
|
|
51
|
+
|
|
52
|
+
drop policy if exists "Enable update for authenticated users on settings" on public.settings;
|
|
53
|
+
|
|
54
|
+
create policy "Enable update for authenticated users on settings"
|
|
55
|
+
on public.settings
|
|
56
|
+
for update
|
|
57
|
+
to authenticated, dashboard_user
|
|
58
|
+
using (public.is_admin())
|
|
59
|
+
with check (public.is_admin());
|
|
60
|
+
|
|
61
|
+
-- Previously any authenticated user (not just an admin) could flip
|
|
62
|
+
-- first_setup back to 'true', which would re-open the unauthenticated
|
|
63
|
+
-- setup wizard for every visitor.
|
|
64
|
+
drop policy if exists "Enable update for authenticated users on healthcheck" on public.healthcheck;
|
|
65
|
+
|
|
66
|
+
create policy "Enable update for authenticated users on healthcheck"
|
|
67
|
+
on public.healthcheck
|
|
68
|
+
for update
|
|
69
|
+
to authenticated, dashboard_user
|
|
70
|
+
using (public.is_admin())
|
|
71
|
+
with check (public.is_admin());
|
|
72
|
+
|
|
73
|
+
--- inserts a row into public.profiles
|
|
74
|
+
--- automatically grants admin to the first user if no profiles exist yet
|
|
75
|
+
create or replace function public.handle_new_user()
|
|
76
|
+
returns trigger
|
|
77
|
+
language plpgsql
|
|
78
|
+
security definer set search_path = ''
|
|
79
|
+
as $$
|
|
80
|
+
declare
|
|
81
|
+
profile_count int;
|
|
82
|
+
should_be_admin boolean;
|
|
83
|
+
begin
|
|
84
|
+
select count(*) into profile_count from public.profiles;
|
|
85
|
+
|
|
86
|
+
-- Only the very first account is auto-promoted. Never trust
|
|
87
|
+
-- raw_user_meta_data for this: it is fully client-controlled — anyone can
|
|
88
|
+
-- pass an arbitrary `options.data` straight to Supabase's own
|
|
89
|
+
-- /auth/v1/signup endpoint, bypassing this project's own /api/auth/signup
|
|
90
|
+
-- route entirely — so honoring an `is_admin` key there let any new
|
|
91
|
+
-- signup grant itself admin.
|
|
92
|
+
should_be_admin := profile_count = 0;
|
|
93
|
+
|
|
94
|
+
insert into public.profiles (id, email, username, display_name, is_admin)
|
|
95
|
+
values (
|
|
96
|
+
new.id,
|
|
97
|
+
new.email,
|
|
98
|
+
new.raw_user_meta_data ->> 'username',
|
|
99
|
+
new.raw_user_meta_data ->> 'display_name',
|
|
100
|
+
should_be_admin
|
|
101
|
+
);
|
|
102
|
+
return new;
|
|
103
|
+
end;
|
|
104
|
+
$$;
|
|
105
|
+
-- Not re-created here: on_auth_user_created (from 001_baseline.sql)
|
|
106
|
+
-- already points at public.handle_new_user() by name, and
|
|
107
|
+
-- `create or replace function` above is enough to update its behavior.
|
|
@@ -1,11 +1,87 @@
|
|
|
1
|
+
import type { PlutoMigrationFile } from '../shared/types/migrations'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
1
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { dirname, join, relative } from 'node:path'
|
|
4
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
3
5
|
import { createResolver, defineNuxtModule, getLayerDirectories } from 'nuxt/kit'
|
|
4
6
|
|
|
7
|
+
const schemaPattern = /^schema\.(.+)\.sql$/
|
|
8
|
+
const noTransactionPattern = /^--\s*pluto:no-transaction\b/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plain, deliberate string comparator for migration file names — ASCII
|
|
12
|
+
* lexicographic order, so `001_...` sorts before `002_...` and so on.
|
|
13
|
+
* Written out instead of relying on `Array.prototype.sort()`'s default
|
|
14
|
+
* (which happens to do the same thing for ASCII names, but isn't a
|
|
15
|
+
* documented guarantee to depend on).
|
|
16
|
+
*/
|
|
17
|
+
function compareFileNames(a: string, b: string): number {
|
|
18
|
+
if (a < b) {
|
|
19
|
+
return -1
|
|
20
|
+
}
|
|
21
|
+
if (a > b) {
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derives a layer's key from its `package.json` `name` field, stripping
|
|
29
|
+
* any `@scope/` prefix. `@plutocms/supabase` always maps to the literal
|
|
30
|
+
* key `core` — every deployed site's ledger already has rows with
|
|
31
|
+
* `layer_name = 'core'`. Falls back to the layer directory's own name
|
|
32
|
+
* when `package.json` is missing or unreadable.
|
|
33
|
+
*/
|
|
34
|
+
function deriveLayerKey(layerRoot: string): string {
|
|
35
|
+
try {
|
|
36
|
+
const pkgRaw = readFileSync(join(layerRoot, 'package.json'), 'utf-8')
|
|
37
|
+
const pkg = JSON.parse(pkgRaw) as { name?: string }
|
|
38
|
+
|
|
39
|
+
if (typeof pkg.name === 'string' && pkg.name.length > 0) {
|
|
40
|
+
if (pkg.name === '@plutocms/supabase') {
|
|
41
|
+
return 'core'
|
|
42
|
+
}
|
|
43
|
+
return pkg.name.replace(/^@[^/]+\//, '')
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// package.json missing or unreadable — fall back to the directory name.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return basename(layerRoot)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function computeChecksum(content: string): string {
|
|
53
|
+
return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16)
|
|
54
|
+
}
|
|
55
|
+
|
|
5
56
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
57
|
+
* True when the file's first non-blank line is a `-- pluto:no-transaction`
|
|
58
|
+
* directive.
|
|
59
|
+
*/
|
|
60
|
+
function detectNoTransaction(content: string): boolean {
|
|
61
|
+
const firstNonBlankLine = content
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((line) => line.trim())
|
|
64
|
+
.find((line) => line.length > 0)
|
|
65
|
+
|
|
66
|
+
return firstNonBlankLine ? noTransactionPattern.test(firstNonBlankLine) : false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readMigrationFile(dir: string, fileName: string, name: string): PlutoMigrationFile {
|
|
70
|
+
const content = readFileSync(join(dir, fileName), 'utf-8')
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name,
|
|
74
|
+
sql: content,
|
|
75
|
+
checksum: computeChecksum(content),
|
|
76
|
+
noTransaction: detectNoTransaction(content),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Discovers `db/migrations/*.sql` (or, for a layer not yet converted, the
|
|
82
|
+
* legacy `public/schema.sql` / `public/schema.[name].sql`) across all Nuxt
|
|
83
|
+
* layers, reads their content at build time, and populates runtimeConfig
|
|
84
|
+
* so the migrations engine can apply them at server startup.
|
|
9
85
|
*
|
|
10
86
|
* Also seeds the consuming app's shared/types/supabase.ts with a
|
|
11
87
|
* re-export of this layer's own committed snapshot when the consumer
|
|
@@ -59,62 +135,121 @@ export default defineNuxtModule({
|
|
|
59
135
|
}
|
|
60
136
|
|
|
61
137
|
const layerDirs = getLayerDirectories()
|
|
62
|
-
const
|
|
63
|
-
|
|
138
|
+
const layerMigrations: Record<string, PlutoMigrationFile[]> = {}
|
|
139
|
+
|
|
140
|
+
// Keeps the first-discovered layer for a given key (getLayerDirectories
|
|
141
|
+
// orders the user/project layer first), and warns once when a later
|
|
142
|
+
// layer's key collides with one already discovered.
|
|
143
|
+
function assignLayer(layerKey: string, files: PlutoMigrationFile[]): void {
|
|
144
|
+
if (layerMigrations[layerKey]) {
|
|
145
|
+
console.warn(
|
|
146
|
+
`[pluto-migrations] Layer key "${layerKey}" was already discovered from another layer. Keeping the first one, ignoring this one.`
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
layerMigrations[layerKey] = files
|
|
151
|
+
}
|
|
64
152
|
|
|
65
153
|
for (const layer of layerDirs) {
|
|
66
|
-
|
|
154
|
+
const migrationsDir = join(layer.root, 'db/migrations')
|
|
155
|
+
|
|
156
|
+
if (existsSync(migrationsDir)) {
|
|
157
|
+
let fileNames: string[] = []
|
|
158
|
+
try {
|
|
159
|
+
fileNames = readdirSync(migrationsDir).filter((file) =>
|
|
160
|
+
file.endsWith('.sql')
|
|
161
|
+
)
|
|
162
|
+
} catch {
|
|
163
|
+
console.error(
|
|
164
|
+
`[pluto-migrations] Failed to read migrations directory: ${migrationsDir}`
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fileNames.sort(compareFileNames)
|
|
170
|
+
|
|
171
|
+
const files: PlutoMigrationFile[] = []
|
|
172
|
+
for (const fileName of fileNames) {
|
|
173
|
+
try {
|
|
174
|
+
files.push(readMigrationFile(migrationsDir, fileName, fileName))
|
|
175
|
+
} catch {
|
|
176
|
+
console.error(
|
|
177
|
+
`[pluto-migrations] Failed to read migration file: ${fileName}`
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
assignLayer(deriveLayerKey(layer.root), files)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Legacy fallback for a layer not yet converted to db/migrations/,
|
|
187
|
+
// exactly as before: a single public/schema.sql (always key `core`)
|
|
188
|
+
// and/or public/schema.[name].sql (key comes from the file name, not
|
|
189
|
+
// package.json — this keeps working for an unconverted layer without
|
|
190
|
+
// requiring it to match its package name).
|
|
67
191
|
const publicDir = join(layer.root, 'public')
|
|
68
192
|
|
|
69
193
|
if (!existsSync(publicDir)) {
|
|
70
194
|
continue
|
|
71
195
|
}
|
|
72
196
|
|
|
73
|
-
let
|
|
197
|
+
let publicFiles: string[] = []
|
|
74
198
|
try {
|
|
75
|
-
|
|
199
|
+
publicFiles = readdirSync(publicDir)
|
|
76
200
|
} catch {
|
|
77
201
|
continue
|
|
78
202
|
}
|
|
79
203
|
|
|
80
|
-
for (const
|
|
81
|
-
|
|
82
|
-
|
|
204
|
+
for (const fileName of publicFiles) {
|
|
205
|
+
if (fileName === 'schema.sql') {
|
|
206
|
+
try {
|
|
207
|
+
assignLayer('core', [
|
|
208
|
+
readMigrationFile(publicDir, fileName, '001_baseline.sql'),
|
|
209
|
+
])
|
|
210
|
+
} catch {
|
|
211
|
+
console.error(
|
|
212
|
+
`[pluto-migrations] Failed to read schema file: ${fileName}`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
83
215
|
continue
|
|
84
216
|
}
|
|
85
217
|
|
|
86
|
-
const match =
|
|
218
|
+
const match = fileName.match(schemaPattern)
|
|
87
219
|
if (match?.[1]) {
|
|
88
|
-
const layerName = match[1]
|
|
89
|
-
// Read SQL content at build time
|
|
90
220
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
221
|
+
assignLayer(match[1], [
|
|
222
|
+
readMigrationFile(publicDir, fileName, '001_baseline.sql'),
|
|
223
|
+
])
|
|
95
224
|
} catch {
|
|
96
225
|
console.error(
|
|
97
|
-
`[pluto-migrations] Failed to read schema file: ${
|
|
226
|
+
`[pluto-migrations] Failed to read schema file: ${fileName}`
|
|
98
227
|
)
|
|
99
228
|
}
|
|
100
229
|
}
|
|
101
230
|
}
|
|
102
231
|
}
|
|
103
232
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// Populate runtimeConfig with layer names and their SQL content.
|
|
233
|
+
// Populate runtimeConfig with every layer's ordered migration files.
|
|
107
234
|
// Nuxt's schema inference narrows this to a literal shape based on
|
|
108
|
-
// whatever layer keys
|
|
109
|
-
// `RuntimeConfig` augmentation in
|
|
110
|
-
// which isn't visible from this
|
|
111
|
-
// assignment is cast to the intended
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
235
|
+
// whatever layer keys and file shapes happen to be discovered in a
|
|
236
|
+
// given project (see the `RuntimeConfig` augmentation in
|
|
237
|
+
// shared/types/runtime-config.d.ts, which isn't visible from this
|
|
238
|
+
// module-context tsconfig), so the assignment is cast to the intended
|
|
239
|
+
// general shape. That inferred shape's array elements don't carry
|
|
240
|
+
// enough structure for a direct cast, hence the `unknown` step.
|
|
241
|
+
;(
|
|
242
|
+
nuxt.options.runtimeConfig as unknown as {
|
|
243
|
+
plutoLayerMigrations: Record<string, PlutoMigrationFile[]>
|
|
244
|
+
}
|
|
245
|
+
).plutoLayerMigrations = layerMigrations
|
|
246
|
+
|
|
247
|
+
const layerKeys = Object.keys(layerMigrations)
|
|
248
|
+
if (layerKeys.length > 0) {
|
|
116
249
|
console.warn(
|
|
117
|
-
`[pluto-migrations] Discovered layer
|
|
250
|
+
`[pluto-migrations] Discovered layer migrations: ${layerKeys
|
|
251
|
+
.map((key) => `${key} (${layerMigrations[key]!.length} file(s))`)
|
|
252
|
+
.join(', ')}`
|
|
118
253
|
)
|
|
119
254
|
}
|
|
120
255
|
},
|
package/nuxt.config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PlutoMigrationFile } from './shared/types/migrations'
|
|
2
|
+
|
|
1
3
|
// Point these at a local checkout (e.g. `../pluto`, `../utils`) to test
|
|
2
4
|
// unpublished changes; unset, they resolve to the published npm packages.
|
|
3
5
|
const plutoLayer = process.env.PLUTO_PLUTO_PATH || '@plutocms/pluto'
|
|
@@ -18,7 +20,7 @@ export default defineNuxtConfig({
|
|
|
18
20
|
supabaseUrl: process.env.SUPABASE_URL,
|
|
19
21
|
supabaseKey: process.env.SUPABASE_KEY,
|
|
20
22
|
plutoRootDir: '',
|
|
21
|
-
|
|
23
|
+
plutoLayerMigrations: {} as Record<string, PlutoMigrationFile[]>,
|
|
22
24
|
},
|
|
23
25
|
|
|
24
26
|
alias: {
|
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.4.0",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"supabase-types": "bun ./scripts/supabase-typegen.ts"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@nuxt/eslint": "^1.16.0",
|
|
39
40
|
"@nuxt/ui": "^4.9.0",
|
|
40
41
|
"@nuxtjs/supabase": "^2.0.9",
|
|
41
42
|
"@plutocms/pluto": "^0.3.2",
|
|
@@ -46,7 +47,6 @@
|
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@antfu/eslint-config": "^6.2.0",
|
|
49
|
-
"@nuxt/eslint": "^1.16.0",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"@vueuse/nuxt": "^14.3.0",
|
|
52
52
|
"eslint": "^10.6.0",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { PlutoMigrationFile } from '../../../shared/types/migrations'
|
|
2
|
+
import type { MigrationFileResult } from '../../utils/migrations'
|
|
3
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
4
|
+
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
5
|
+
import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
|
|
6
|
+
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
7
|
+
import { regenerateSupabaseTypes } from '../../utils/typegen'
|
|
8
|
+
|
|
9
|
+
interface Payload {
|
|
10
|
+
connectionString?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default defineEventHandler(async (event) => {
|
|
14
|
+
await requireAdmin(event)
|
|
15
|
+
|
|
16
|
+
const body = await readBody<Payload | undefined>(event)
|
|
17
|
+
|
|
18
|
+
// Connection string precedence: an already-configured DATABASE_URL always
|
|
19
|
+
// wins, and a body-supplied string is ignored completely in that case.
|
|
20
|
+
// This is the whole security model for this endpoint — without it, any
|
|
21
|
+
// admin could point the server at an arbitrary database host. See
|
|
22
|
+
// resolveConnectionString for the full rule.
|
|
23
|
+
const { connStr, usingBody: usingBodyConnectionString } =
|
|
24
|
+
resolveConnectionString(body?.connectionString)
|
|
25
|
+
|
|
26
|
+
if (!connStr) {
|
|
27
|
+
return {
|
|
28
|
+
success: false as const,
|
|
29
|
+
needsConnectionString: true,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const config = useRuntimeConfig()
|
|
34
|
+
// Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
|
|
35
|
+
// layer keys and file shapes it happened to observe at build time (see
|
|
36
|
+
// the `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts),
|
|
37
|
+
// so the read is cast back to the intended general shape.
|
|
38
|
+
const layers = (config.plutoLayerMigrations ?? {}) as unknown as Record<
|
|
39
|
+
string,
|
|
40
|
+
PlutoMigrationFile[]
|
|
41
|
+
>
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const layerResults = await runPendingMigrations({
|
|
45
|
+
connectionString: connStr,
|
|
46
|
+
layers,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const results: MigrationFileResult[] = layerResults.flatMap(
|
|
50
|
+
(layer) => layer.results
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const anyApplied = results.some((result) => result.status === 'applied')
|
|
54
|
+
|
|
55
|
+
// Deliberate runtime mutation: a valid connection string just proved
|
|
56
|
+
// itself against the database, so make it available to
|
|
57
|
+
// getConnectionString() for the rest of this process life. No
|
|
58
|
+
// server restart is needed for later requests to pick it up.
|
|
59
|
+
if (usingBodyConnectionString) {
|
|
60
|
+
process.env.DATABASE_URL = connStr
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Nothing to persist when DATABASE_URL was already configured.
|
|
64
|
+
// Otherwise persist the now-proven body-supplied string.
|
|
65
|
+
let persisted = !usingBodyConnectionString
|
|
66
|
+
let message: string | undefined
|
|
67
|
+
|
|
68
|
+
if (usingBodyConnectionString) {
|
|
69
|
+
persisted = await persistDatabaseUrl(connStr)
|
|
70
|
+
|
|
71
|
+
if (!persisted) {
|
|
72
|
+
message =
|
|
73
|
+
'Migrations were applied, but the connection string could not be ' +
|
|
74
|
+
'saved to .env. Add DATABASE_URL to .env by hand.'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (import.meta.dev && anyApplied && config.plutoRootDir) {
|
|
79
|
+
await regenerateSupabaseTypes(config.plutoRootDir, connStr)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
success: true as const,
|
|
84
|
+
results,
|
|
85
|
+
persisted,
|
|
86
|
+
message,
|
|
87
|
+
}
|
|
88
|
+
} catch (error: any) {
|
|
89
|
+
const errorMessage = scrubConnectionString(
|
|
90
|
+
error?.message ?? 'Unknown migration error.',
|
|
91
|
+
connStr
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
console.error('Error applying migrations:', errorMessage)
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
success: false as const,
|
|
98
|
+
error: errorMessage,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|