@plutocms/supabase 0.3.0 → 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 +16 -0
- package/FEATURES.md +2 -2
- package/app/components/navbar/NavbarAdminProvider.vue +4 -0
- package/app/composables/auth.ts +11 -9
- package/app/composables/migrations.ts +16 -3
- package/app/pages/admin/migrations.vue +32 -8
- package/app/pages/admin/setup.vue +2 -3
- 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 +59 -77
- package/server/api/migrations/status.get.ts +3 -5
- package/server/api/settings/update.post.ts +34 -6
- package/server/api/setup/create.post.ts +64 -74
- 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/ledger.ts +124 -0
- package/server/utils/migrations.ts +0 -0
- package/server/utils/pending-migrations.ts +0 -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,21 @@
|
|
|
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
|
+
|
|
3
19
|
## [0.3.0](https://github.com/plutocms/supabase/compare/v0.2.2...v0.3.0) (2026-09-10)
|
|
4
20
|
|
|
5
21
|
|
package/FEATURES.md
CHANGED
|
@@ -6,5 +6,5 @@ with full detail, so this file stays short.
|
|
|
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
8
|
|
|
9
|
-
- Layer migrations: apply pending
|
|
10
|
-
setup. @.claude/skills/layer-migrations/SKILL.md
|
|
9
|
+
- Layer migrations: apply pending, versioned, per-file database migrations from the admin UI,
|
|
10
|
+
after initial setup. @.claude/skills/layer-migrations/SKILL.md
|
|
@@ -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
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface MigrationFileResult {
|
|
2
2
|
layerName: string
|
|
3
|
+
migrationName: string
|
|
3
4
|
status: 'applied' | 'skipped' | 'failed'
|
|
4
5
|
error?: string
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
export interface LayerStatus {
|
|
9
|
+
layerName: string
|
|
10
|
+
applied: string[]
|
|
11
|
+
pending: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
interface MigrationsStatusResponse {
|
|
8
15
|
success: boolean
|
|
16
|
+
layers: LayerStatus[]
|
|
9
17
|
pending: string[]
|
|
10
18
|
applied: string[]
|
|
19
|
+
discovered: string[]
|
|
20
|
+
pendingFileCount: number
|
|
11
21
|
hasConnection: boolean
|
|
12
22
|
needsConnectionString: boolean
|
|
13
23
|
}
|
|
@@ -15,13 +25,14 @@ interface MigrationsStatusResponse {
|
|
|
15
25
|
interface RunMigrationsResponse {
|
|
16
26
|
success: boolean
|
|
17
27
|
needsConnectionString?: boolean
|
|
18
|
-
results?:
|
|
28
|
+
results?: MigrationFileResult[]
|
|
19
29
|
persisted?: boolean
|
|
20
30
|
message?: string
|
|
31
|
+
error?: string
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/**
|
|
24
|
-
* Reads and applies pending
|
|
35
|
+
* Reads and applies pending migrations, for admin-only pages and the
|
|
25
36
|
* admin-shell banner.
|
|
26
37
|
*
|
|
27
38
|
* `useFetch` keys the request as `pluto-migrations-status`, so every
|
|
@@ -51,6 +62,8 @@ export function useMigrations() {
|
|
|
51
62
|
return data.value ?? null
|
|
52
63
|
})
|
|
53
64
|
|
|
65
|
+
// Layer-level count — a layer with any pending file counts once here.
|
|
66
|
+
// See `status.pendingFileCount` for the per-file count.
|
|
54
67
|
const pendingCount = computed(() => status.value?.pending.length ?? 0)
|
|
55
68
|
|
|
56
69
|
async function runMigrations(connectionString?: string) {
|
|
@@ -15,6 +15,15 @@ const { status, fetchStatus, pendingCount, refresh, runMigrations } =
|
|
|
15
15
|
|
|
16
16
|
const isDev = import.meta.dev
|
|
17
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
|
+
|
|
18
27
|
const connectionForm = ref({
|
|
19
28
|
connectionString: '',
|
|
20
29
|
password: '',
|
|
@@ -175,9 +184,14 @@ async function applyMigrations(useForm: boolean) {
|
|
|
175
184
|
<h2 class="font-semibold">
|
|
176
185
|
Pending layers ({{ status.pending.length }})
|
|
177
186
|
</h2>
|
|
178
|
-
<ul class="
|
|
179
|
-
<li v-for="layer in
|
|
180
|
-
{{ layer }}
|
|
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>
|
|
181
195
|
</li>
|
|
182
196
|
</ul>
|
|
183
197
|
</div>
|
|
@@ -186,9 +200,14 @@ async function applyMigrations(useForm: boolean) {
|
|
|
186
200
|
<h2 class="font-semibold">
|
|
187
201
|
Applied layers ({{ status.applied.length }})
|
|
188
202
|
</h2>
|
|
189
|
-
<ul class="
|
|
190
|
-
<li v-for="layer in
|
|
191
|
-
{{ layer }}
|
|
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>
|
|
192
211
|
</li>
|
|
193
212
|
</ul>
|
|
194
213
|
</div>
|
|
@@ -305,8 +324,13 @@ async function applyMigrations(useForm: boolean) {
|
|
|
305
324
|
v-if="lastRun.results?.length"
|
|
306
325
|
class="flex flex-col gap-y-1 text-sm"
|
|
307
326
|
>
|
|
308
|
-
<li
|
|
309
|
-
|
|
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
|
+
>
|
|
310
334
|
— {{ result.status }}
|
|
311
335
|
<span v-if="result.error" class="text-error">
|
|
312
336
|
: {{ result.error }}</span
|
|
@@ -120,7 +120,6 @@ async function completeDatabaseSetup() {
|
|
|
120
120
|
const data = await $fetch<any>('/api/setup/create', {
|
|
121
121
|
method: 'POST',
|
|
122
122
|
body: {
|
|
123
|
-
baseUrl: window.location.origin,
|
|
124
123
|
connectionString,
|
|
125
124
|
},
|
|
126
125
|
})
|
|
@@ -323,11 +322,11 @@ function handleStepChange(step: number) {
|
|
|
323
322
|
Supabase database and run the SQL queries from
|
|
324
323
|
this
|
|
325
324
|
<ULink
|
|
326
|
-
to="https://github.com/plutocms/supabase/
|
|
325
|
+
to="https://github.com/plutocms/supabase/tree/main/db/migrations"
|
|
327
326
|
target="_blank"
|
|
328
327
|
class="underline"
|
|
329
328
|
>
|
|
330
|
-
SQL
|
|
329
|
+
SQL files</ULink
|
|
331
330
|
>.
|
|
332
331
|
</p>
|
|
333
332
|
|
|
@@ -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",
|