@plutocms/supabase 0.0.1-alpha.8 → 0.1.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 +98 -0
- package/README.md +2 -4
- package/app/assets/css/tailwind.css +5 -0
- package/app/components/navbar/NavbarAdmin.vue +6 -5
- package/app/composables/auth.ts +124 -2
- package/app/middleware/auth.ts +13 -6
- package/app/middleware/setup-check.ts +25 -21
- package/app/pages/admin/(auth)/confirm.vue +38 -0
- package/app/pages/admin/(auth)/forgot-password.vue +88 -0
- package/app/pages/admin/(auth)/login.vue +5 -1
- package/app/pages/admin/(auth)/signup.vue +76 -16
- package/app/pages/admin/(auth)/update-password.vue +134 -0
- package/app/pages/admin/setup.vue +31 -28
- package/app/pages/admin.vue +3 -4
- package/modules/pluto-migrations.ts +121 -0
- package/nuxt.config.ts +14 -1
- package/package.json +40 -24
- package/public/schema.sql +66 -8
- package/scripts/supabase-typegen.ts +36 -21
- package/scripts/tsconfig.json +18 -0
- package/server/api/auth/signup.post.ts +46 -0
- package/server/api/setup/create.post.ts +38 -88
- package/server/plugins/migrations.ts +60 -0
- package/server/utils/migrations.ts +88 -0
- package/server/utils/sql.ts +81 -0
- package/server/utils/typegen.ts +68 -0
- package/shared/types/runtime-config.d.ts +16 -0
- package/shared/types/supabase.ts +90 -117
- package/.agents/skills/nuxt/GENERATION.md +0 -5
- package/.agents/skills/nuxt/SKILL.md +0 -55
- package/.agents/skills/nuxt/references/advanced-hooks.md +0 -289
- package/.agents/skills/nuxt/references/advanced-layers.md +0 -299
- package/.agents/skills/nuxt/references/advanced-module-authoring.md +0 -554
- package/.agents/skills/nuxt/references/best-practices-data-fetching.md +0 -357
- package/.agents/skills/nuxt/references/best-practices-ssr.md +0 -355
- package/.agents/skills/nuxt/references/core-cli.md +0 -263
- package/.agents/skills/nuxt/references/core-config.md +0 -162
- package/.agents/skills/nuxt/references/core-data-fetching.md +0 -236
- package/.agents/skills/nuxt/references/core-deployment.md +0 -224
- package/.agents/skills/nuxt/references/core-directory-structure.md +0 -269
- package/.agents/skills/nuxt/references/core-modules.md +0 -292
- package/.agents/skills/nuxt/references/core-routing.md +0 -226
- package/.agents/skills/nuxt/references/features-components-autoimport.md +0 -328
- package/.agents/skills/nuxt/references/features-components.md +0 -264
- package/.agents/skills/nuxt/references/features-composables.md +0 -276
- package/.agents/skills/nuxt/references/features-server.md +0 -265
- package/.agents/skills/nuxt/references/features-state.md +0 -194
- package/.agents/skills/nuxt/references/rendering-modes.md +0 -237
- package/.agents/skills/nuxt-ui/SKILL.md +0 -334
- package/.agents/skills/nuxt-ui/references/components.md +0 -377
- package/.agents/skills/nuxt-ui/references/composables.md +0 -127
- package/.agents/skills/nuxt-ui/references/layouts/chat.md +0 -195
- package/.agents/skills/nuxt-ui/references/layouts/dashboard.md +0 -220
- package/.agents/skills/nuxt-ui/references/layouts/docs.md +0 -141
- package/.agents/skills/nuxt-ui/references/layouts/editor.md +0 -167
- package/.agents/skills/nuxt-ui/references/layouts/page.md +0 -260
- package/.agents/skills/nuxt-ui/references/theming.md +0 -427
- package/.agents/skills/supabase-postgres-best-practices/AGENTS.md +0 -68
- package/.agents/skills/supabase-postgres-best-practices/CLAUDE.md +0 -1
- package/.agents/skills/supabase-postgres-best-practices/README.md +0 -116
- package/.agents/skills/supabase-postgres-best-practices/SKILL.md +0 -64
- package/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +0 -55
- package/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +0 -49
- package/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +0 -46
- package/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +0 -44
- package/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +0 -41
- package/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +0 -46
- package/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +0 -54
- package/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +0 -53
- package/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +0 -50
- package/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +0 -50
- package/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +0 -56
- package/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +0 -68
- package/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +0 -50
- package/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +0 -54
- package/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +0 -45
- package/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +0 -55
- package/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +0 -55
- package/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +0 -44
- package/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +0 -40
- package/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +0 -48
- package/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +0 -43
- package/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +0 -45
- package/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +0 -80
- package/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +0 -46
- package/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +0 -59
- package/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +0 -55
- package/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +0 -55
- package/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +0 -61
- package/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +0 -54
- package/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +0 -50
- package/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +0 -57
- package/.editorconfig +0 -12
- package/.eslintignore +0 -1
- package/.prettierrc +0 -9
- package/.vscode/extensions.json +0 -10
- package/.vscode/settings.json +0 -61
- package/bun.lock +0 -2837
- package/eslint.config.mjs +0 -88
- package/server/api/category/list.get.ts +0 -9
- package/server/api/category/new.post.ts +0 -31
- package/server/api/signup.post.ts +0 -28
- package/skills-lock.json +0 -20
package/public/schema.sql
CHANGED
|
@@ -106,24 +106,42 @@ alter table public.healthcheck enable row level security;
|
|
|
106
106
|
do $$
|
|
107
107
|
begin
|
|
108
108
|
if not exists (
|
|
109
|
-
select 1 from pg_policies where tablename = 'healthcheck' and policyname = 'Enable read access for healthcheck to public
|
|
109
|
+
select 1 from pg_policies where tablename = 'healthcheck' and policyname = 'Enable read access for healthcheck to public'
|
|
110
110
|
) then
|
|
111
|
-
create policy "Enable read access for healthcheck to public
|
|
111
|
+
create policy "Enable read access for healthcheck to public"
|
|
112
112
|
on public.healthcheck
|
|
113
113
|
for select
|
|
114
|
-
to public
|
|
114
|
+
to public
|
|
115
115
|
using (true);
|
|
116
116
|
end if;
|
|
117
117
|
end
|
|
118
118
|
$$;
|
|
119
119
|
|
|
120
|
+
-- Allow update access to healthcheck for authenticated users and dashboard_user
|
|
121
|
+
do $$
|
|
122
|
+
begin
|
|
123
|
+
if not exists (
|
|
124
|
+
select 1 from pg_policies where tablename = 'healthcheck' and policyname = 'Enable update for authenticated users on healthcheck'
|
|
125
|
+
) then
|
|
126
|
+
create policy "Enable update for authenticated users on healthcheck"
|
|
127
|
+
on public.healthcheck
|
|
128
|
+
for update
|
|
129
|
+
to authenticated, dashboard_user
|
|
130
|
+
using (true)
|
|
131
|
+
with check (true);
|
|
132
|
+
end if;
|
|
133
|
+
end
|
|
134
|
+
$$;
|
|
135
|
+
|
|
120
136
|
-- Profiles
|
|
121
137
|
create table if not exists public.profiles (
|
|
122
138
|
id uuid references auth.users on delete cascade not null primary key,
|
|
123
139
|
updated_at timestamp with time zone,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
140
|
+
email text,
|
|
141
|
+
username text,
|
|
142
|
+
display_name text,
|
|
143
|
+
is_admin boolean not null default false,
|
|
144
|
+
constraint username_length check (char_length(username) >= 3)
|
|
127
145
|
);
|
|
128
146
|
|
|
129
147
|
alter table public.profiles enable row level security;
|
|
@@ -143,14 +161,29 @@ end
|
|
|
143
161
|
$$;
|
|
144
162
|
|
|
145
163
|
--- inserts a row into public.profiles
|
|
164
|
+
--- automatically grants admin to the first user if no profiles exist yet
|
|
146
165
|
create or replace function public.handle_new_user()
|
|
147
166
|
returns trigger
|
|
148
167
|
language plpgsql
|
|
149
168
|
security definer set search_path = ''
|
|
150
169
|
as $$
|
|
170
|
+
declare
|
|
171
|
+
profile_count int;
|
|
172
|
+
should_be_admin boolean;
|
|
151
173
|
begin
|
|
152
|
-
|
|
153
|
-
|
|
174
|
+
select count(*) into profile_count from public.profiles;
|
|
175
|
+
|
|
176
|
+
should_be_admin := profile_count = 0
|
|
177
|
+
or coalesce((new.raw_user_meta_data ->> 'is_admin')::boolean, false);
|
|
178
|
+
|
|
179
|
+
insert into public.profiles (id, email, username, display_name, is_admin)
|
|
180
|
+
values (
|
|
181
|
+
new.id,
|
|
182
|
+
new.email,
|
|
183
|
+
new.raw_user_meta_data ->> 'username',
|
|
184
|
+
new.raw_user_meta_data ->> 'display_name',
|
|
185
|
+
should_be_admin
|
|
186
|
+
);
|
|
154
187
|
return new;
|
|
155
188
|
end;
|
|
156
189
|
$$;
|
|
@@ -166,3 +199,28 @@ begin
|
|
|
166
199
|
end if;
|
|
167
200
|
end
|
|
168
201
|
$$;
|
|
202
|
+
|
|
203
|
+
-- Migrations tracking
|
|
204
|
+
create table if not exists public.pluto_migrations (
|
|
205
|
+
id bigint generated by default as identity primary key,
|
|
206
|
+
layer_name text not null,
|
|
207
|
+
applied_at timestamptz not null default now(),
|
|
208
|
+
constraint pluto_migrations_layer_key unique (layer_name)
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
alter table public.pluto_migrations enable row level security;
|
|
212
|
+
|
|
213
|
+
-- Only authenticated users can read migrations
|
|
214
|
+
do $$
|
|
215
|
+
begin
|
|
216
|
+
if not exists (
|
|
217
|
+
select 1 from pg_policies where tablename = 'pluto_migrations' and policyname = 'Enable read access for authenticated users on pluto_migrations'
|
|
218
|
+
) then
|
|
219
|
+
create policy "Enable read access for authenticated users on pluto_migrations"
|
|
220
|
+
on public.pluto_migrations
|
|
221
|
+
for select
|
|
222
|
+
to authenticated, dashboard_user
|
|
223
|
+
using (true);
|
|
224
|
+
end if;
|
|
225
|
+
end
|
|
226
|
+
$$;
|
|
@@ -1,35 +1,50 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
1
|
import { cwd } from 'node:process'
|
|
4
|
-
import { $ } from 'bun'
|
|
5
2
|
import { consola } from 'consola'
|
|
3
|
+
import { regenerateSupabaseTypes } from '../server/utils/typegen'
|
|
6
4
|
|
|
5
|
+
const databaseUrl = process.env.DATABASE_URL
|
|
7
6
|
const projectUrl = process.env.SUPABASE_URL
|
|
8
|
-
const projectId = projectUrl?.split('.supabase.co')[0]?.replace('https://', '')
|
|
9
|
-
const _path = path
|
|
10
|
-
.resolve(cwd(), './shared/types/supabase.ts')
|
|
11
|
-
.replace(/\\/g, '/')
|
|
12
7
|
|
|
13
8
|
;(async () => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
if (databaseUrl) {
|
|
10
|
+
consola.info('Generating Supabase types via DATABASE_URL...')
|
|
11
|
+
await regenerateSupabaseTypes(cwd(), databaseUrl)
|
|
12
|
+
return
|
|
13
|
+
}
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
if (!projectUrl) {
|
|
16
|
+
consola.error(
|
|
17
|
+
'Cannot generate Supabase types: neither DATABASE_URL nor SUPABASE_URL is set.'
|
|
18
|
+
)
|
|
19
|
+
return
|
|
22
20
|
}
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
consola.warn(
|
|
23
|
+
'DATABASE_URL is not set; falling back to --project-id (requires `supabase login`).'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const projectId = projectUrl.split('.supabase.co')[0]?.replace('https://', '')
|
|
27
|
+
const { execFile } = await import('node:child_process')
|
|
28
|
+
const { promisify } = await import('node:util')
|
|
29
|
+
const { writeFileSync } = await import('node:fs')
|
|
30
|
+
const { join } = await import('node:path')
|
|
31
|
+
|
|
32
|
+
consola.info(`Generating Supabase types for project: ${projectId}`)
|
|
30
33
|
|
|
31
34
|
try {
|
|
32
|
-
|
|
35
|
+
const { stdout } = await promisify(execFile)(
|
|
36
|
+
'supabase',
|
|
37
|
+
[
|
|
38
|
+
'gen',
|
|
39
|
+
'types',
|
|
40
|
+
'typescript',
|
|
41
|
+
`--project-id=${projectId}`,
|
|
42
|
+
'--schema=public',
|
|
43
|
+
],
|
|
44
|
+
{ maxBuffer: 1024 * 1024 * 16 }
|
|
45
|
+
)
|
|
46
|
+
writeFileSync(join(cwd(), 'shared/types/supabase.ts'), stdout, 'utf-8')
|
|
47
|
+
consola.success('Supabase types generated.')
|
|
33
48
|
} catch (error) {
|
|
34
49
|
consola.error('Unhandled error:', error)
|
|
35
50
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
// This folder is excluded from the Nuxt app itself (see `ignore` in
|
|
3
|
+
// ../nuxt.config.ts), so it needs its own project - otherwise editors have
|
|
4
|
+
// no tsconfig to resolve against here and Node globals (process, fs, etc.)
|
|
5
|
+
// won't show up.
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"target": "ESNext",
|
|
8
|
+
"lib": ["ESNext"],
|
|
9
|
+
"moduleDetection": "force",
|
|
10
|
+
"module": "ESNext",
|
|
11
|
+
"moduleResolution": "Bundler",
|
|
12
|
+
"types": ["node"],
|
|
13
|
+
"strict": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"skipLibCheck": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["./**/*.ts", "../server/utils/typegen.ts"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
+
|
|
3
|
+
interface Payload {
|
|
4
|
+
username: string
|
|
5
|
+
display_name: string
|
|
6
|
+
email: string
|
|
7
|
+
password: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default defineEventHandler(async (event) => {
|
|
11
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
12
|
+
|
|
13
|
+
const body = await readBody<Payload>(event)
|
|
14
|
+
|
|
15
|
+
if (!body.username || body.username.length < 3) {
|
|
16
|
+
return {
|
|
17
|
+
success: false,
|
|
18
|
+
message: 'Username must be at least 3 characters.',
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { data, error } = await client.auth.signUp({
|
|
23
|
+
email: body.email,
|
|
24
|
+
password: body.password,
|
|
25
|
+
options: {
|
|
26
|
+
data: {
|
|
27
|
+
username: body.username,
|
|
28
|
+
display_name: body.display_name,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
if (error) {
|
|
34
|
+
return {
|
|
35
|
+
success: false,
|
|
36
|
+
message: error.message,
|
|
37
|
+
error,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
success: true,
|
|
43
|
+
message: 'User registered successfully',
|
|
44
|
+
data,
|
|
45
|
+
}
|
|
46
|
+
})
|
|
@@ -1,92 +1,13 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
1
3
|
import postgres from 'postgres'
|
|
4
|
+
import { splitStatements } from '../../utils/sql'
|
|
2
5
|
|
|
3
6
|
interface Payload {
|
|
4
7
|
baseUrl: string
|
|
5
8
|
connectionString: string
|
|
6
9
|
}
|
|
7
10
|
|
|
8
|
-
/**
|
|
9
|
-
* Splits a SQL string into individual statements, correctly handling
|
|
10
|
-
* $$ dollar-quoted blocks, single-quoted strings, and -- comments.
|
|
11
|
-
*/
|
|
12
|
-
function splitStatements(sql: string): string[] {
|
|
13
|
-
const statements: string[] = []
|
|
14
|
-
let current = ''
|
|
15
|
-
let i = 0
|
|
16
|
-
|
|
17
|
-
while (i < sql.length) {
|
|
18
|
-
// Check for dollar-quoting ($$)
|
|
19
|
-
if (sql[i] === '$' && sql[i + 1] === '$') {
|
|
20
|
-
current += '$$'
|
|
21
|
-
i += 2
|
|
22
|
-
// Read until closing $$
|
|
23
|
-
while (i < sql.length) {
|
|
24
|
-
if (sql[i] === '$' && sql[i + 1] === '$') {
|
|
25
|
-
current += '$$'
|
|
26
|
-
i += 2
|
|
27
|
-
break
|
|
28
|
-
}
|
|
29
|
-
current += sql[i]
|
|
30
|
-
i++
|
|
31
|
-
}
|
|
32
|
-
continue
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Check for single-quoted strings (handle '' escapes)
|
|
36
|
-
if (sql[i] === `'`) {
|
|
37
|
-
current += sql[i]
|
|
38
|
-
i++
|
|
39
|
-
while (i < sql.length) {
|
|
40
|
-
if (sql[i] === `'` && sql[i + 1] === `'`) {
|
|
41
|
-
// Escaped quote
|
|
42
|
-
current += `''`
|
|
43
|
-
i += 2
|
|
44
|
-
continue
|
|
45
|
-
}
|
|
46
|
-
if (sql[i] === `'`) {
|
|
47
|
-
current += sql[i]
|
|
48
|
-
i++
|
|
49
|
-
break
|
|
50
|
-
}
|
|
51
|
-
current += sql[i]
|
|
52
|
-
i++
|
|
53
|
-
}
|
|
54
|
-
continue
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Check for single-line comments
|
|
58
|
-
if (sql[i] === '-' && sql[i + 1] === '-') {
|
|
59
|
-
while (i < sql.length && sql[i] !== '\n') {
|
|
60
|
-
current += sql[i]
|
|
61
|
-
i++
|
|
62
|
-
}
|
|
63
|
-
continue
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Statement terminator
|
|
67
|
-
if (sql[i] === ';') {
|
|
68
|
-
current += ';'
|
|
69
|
-
const trimmed = current.trim()
|
|
70
|
-
if (trimmed && trimmed !== ';') {
|
|
71
|
-
statements.push(trimmed)
|
|
72
|
-
}
|
|
73
|
-
current = ''
|
|
74
|
-
i++
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
current += sql[i]
|
|
79
|
-
i++
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const trimmed = current.trim()
|
|
83
|
-
if (trimmed && trimmed !== ';') {
|
|
84
|
-
statements.push(trimmed)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return statements
|
|
88
|
-
}
|
|
89
|
-
|
|
90
11
|
export default defineEventHandler(async (event) => {
|
|
91
12
|
const body = await readBody<Payload>(event)
|
|
92
13
|
|
|
@@ -118,12 +39,41 @@ export default defineEventHandler(async (event) => {
|
|
|
118
39
|
await sql.unsafe(statement)
|
|
119
40
|
}
|
|
120
41
|
|
|
121
|
-
//
|
|
122
|
-
await sql
|
|
123
|
-
UPDATE healthcheck
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
42
|
+
// Mark first_setup as complete
|
|
43
|
+
await sql.unsafe(
|
|
44
|
+
`UPDATE public.healthcheck SET config_value = 'false' WHERE config_name = 'first_setup'`
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// Record the core schema migration
|
|
48
|
+
await sql.unsafe(
|
|
49
|
+
`INSERT INTO public.pluto_migrations (layer_name)
|
|
50
|
+
VALUES ('core')
|
|
51
|
+
ON CONFLICT (layer_name) DO NOTHING`
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// Persist connection string to .env for future layer migrations
|
|
55
|
+
const envPath = resolve(process.cwd(), '.env')
|
|
56
|
+
let envContent = ''
|
|
57
|
+
try {
|
|
58
|
+
envContent = await readFile(envPath, 'utf-8')
|
|
59
|
+
} catch {
|
|
60
|
+
// .env doesn't exist yet
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (envContent.includes('DATABASE_URL=')) {
|
|
64
|
+
envContent = envContent.replace(
|
|
65
|
+
/^DATABASE_URL=.*$/m,
|
|
66
|
+
`DATABASE_URL="${connectionString}"`
|
|
67
|
+
)
|
|
68
|
+
} else {
|
|
69
|
+
// Ensure there's a trailing newline before appending
|
|
70
|
+
if (envContent.length > 0 && !envContent.endsWith('\n')) {
|
|
71
|
+
envContent += '\n'
|
|
72
|
+
}
|
|
73
|
+
envContent += `DATABASE_URL="${connectionString}"\n`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await writeFile(envPath, envContent, 'utf-8')
|
|
127
77
|
|
|
128
78
|
return {
|
|
129
79
|
success: true,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { runLayerMigration } from '../utils/migrations'
|
|
2
|
+
import { regenerateSupabaseTypes } from '../utils/typegen'
|
|
3
|
+
|
|
4
|
+
export default defineNitroPlugin(async () => {
|
|
5
|
+
// Only run if DATABASE_URL is configured (setup already completed)
|
|
6
|
+
if (!process.env.DATABASE_URL) {
|
|
7
|
+
return
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const config = useRuntimeConfig()
|
|
11
|
+
const layerSchemas = config.plutoLayerSchemas ?? {}
|
|
12
|
+
|
|
13
|
+
const layerNames = Object.keys(layerSchemas)
|
|
14
|
+
|
|
15
|
+
if (layerNames.length === 0) {
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let anyApplied = false
|
|
20
|
+
|
|
21
|
+
for (const layerName of layerNames) {
|
|
22
|
+
const schemaSql = layerSchemas[layerName]
|
|
23
|
+
|
|
24
|
+
if (!schemaSql) {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await runLayerMigration({
|
|
30
|
+
layerName,
|
|
31
|
+
schemaSql,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (result.skipped) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`[migrations] Layer "${layerName}" already applied, skipped.`
|
|
37
|
+
)
|
|
38
|
+
} else if (result.success) {
|
|
39
|
+
console.warn(`[migrations] Layer "${layerName}" migrated successfully.`)
|
|
40
|
+
anyApplied = true
|
|
41
|
+
} else {
|
|
42
|
+
console.error(`[migrations] Layer "${layerName}" failed:`, result.error)
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error(
|
|
46
|
+
`[migrations] Error running migration for "${layerName}":`,
|
|
47
|
+
error
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Regenerate Database types from the live schema whenever a layer schema
|
|
53
|
+
// was newly applied, so `Database` stays in sync without a manual step.
|
|
54
|
+
// Re-editing an already-applied layer schema is a no-op here (it's
|
|
55
|
+
// recorded as `skipped`); use the `supabase-types` script to force a
|
|
56
|
+
// refresh in that case.
|
|
57
|
+
if (import.meta.dev && anyApplied && config.plutoRootDir) {
|
|
58
|
+
await regenerateSupabaseTypes(config.plutoRootDir)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import postgres from 'postgres'
|
|
2
|
+
import { splitStatements } from './sql'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Retrieves the database connection string from environment variables.
|
|
6
|
+
* Stored in .env during initial setup.
|
|
7
|
+
*/
|
|
8
|
+
export function getConnectionString(): string | null {
|
|
9
|
+
return process.env.DATABASE_URL ?? null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Checks if a specific layer migration has already been applied.
|
|
14
|
+
*/
|
|
15
|
+
async function isMigrationApplied(
|
|
16
|
+
sql: postgres.Sql,
|
|
17
|
+
layerName: string
|
|
18
|
+
): Promise<boolean> {
|
|
19
|
+
const result = await sql.unsafe(
|
|
20
|
+
`SELECT 1 FROM public.pluto_migrations WHERE layer_name = $1`,
|
|
21
|
+
[layerName]
|
|
22
|
+
)
|
|
23
|
+
return result.length > 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Records a migration as applied.
|
|
28
|
+
*/
|
|
29
|
+
async function recordMigration(
|
|
30
|
+
sql: postgres.Sql,
|
|
31
|
+
layerName: string
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
await sql.unsafe(
|
|
34
|
+
`INSERT INTO public.pluto_migrations (layer_name)
|
|
35
|
+
VALUES ($1)
|
|
36
|
+
ON CONFLICT (layer_name) DO NOTHING`,
|
|
37
|
+
[layerName]
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface LayerMigrationResult {
|
|
42
|
+
success: boolean
|
|
43
|
+
skipped?: boolean
|
|
44
|
+
error?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runs a layer's schema SQL if not already applied.
|
|
49
|
+
* Splits and executes statements, then records the migration.
|
|
50
|
+
*/
|
|
51
|
+
export async function runLayerMigration(opts: {
|
|
52
|
+
layerName: string
|
|
53
|
+
schemaSql: string
|
|
54
|
+
connectionString?: string
|
|
55
|
+
}): Promise<LayerMigrationResult> {
|
|
56
|
+
const connStr = opts.connectionString ?? getConnectionString()
|
|
57
|
+
|
|
58
|
+
if (!connStr) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
error: 'No DATABASE_URL configured. Re-run setup or add it to .env.',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sql = postgres(connStr)
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const applied = await isMigrationApplied(sql, opts.layerName)
|
|
69
|
+
if (applied) {
|
|
70
|
+
return { success: true, skipped: true }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const statements = splitStatements(opts.schemaSql)
|
|
74
|
+
|
|
75
|
+
for (const statement of statements) {
|
|
76
|
+
await sql.unsafe(statement)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await recordMigration(sql, opts.layerName)
|
|
80
|
+
|
|
81
|
+
return { success: true }
|
|
82
|
+
} catch (error: any) {
|
|
83
|
+
console.error(`Migration error [${opts.layerName}]:`, error)
|
|
84
|
+
return { success: false, error: error.message }
|
|
85
|
+
} finally {
|
|
86
|
+
await sql.end()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a SQL string into individual statements, correctly handling
|
|
3
|
+
* $$ dollar-quoted blocks, single-quoted strings, and -- comments.
|
|
4
|
+
*/
|
|
5
|
+
export function splitStatements(sql: string): string[] {
|
|
6
|
+
const statements: string[] = []
|
|
7
|
+
let current = ''
|
|
8
|
+
let i = 0
|
|
9
|
+
|
|
10
|
+
while (i < sql.length) {
|
|
11
|
+
// Check for dollar-quoting ($$)
|
|
12
|
+
if (sql[i] === '$' && sql[i + 1] === '$') {
|
|
13
|
+
current += '$$'
|
|
14
|
+
i += 2
|
|
15
|
+
// Read until closing $$
|
|
16
|
+
while (i < sql.length) {
|
|
17
|
+
if (sql[i] === '$' && sql[i + 1] === '$') {
|
|
18
|
+
current += '$$'
|
|
19
|
+
i += 2
|
|
20
|
+
break
|
|
21
|
+
}
|
|
22
|
+
current += sql[i]
|
|
23
|
+
i++
|
|
24
|
+
}
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check for single-quoted strings (handle '' escapes)
|
|
29
|
+
if (sql[i] === `'`) {
|
|
30
|
+
current += sql[i]
|
|
31
|
+
i++
|
|
32
|
+
while (i < sql.length) {
|
|
33
|
+
if (sql[i] === `'` && sql[i + 1] === `'`) {
|
|
34
|
+
// Escaped quote
|
|
35
|
+
current += `''`
|
|
36
|
+
i += 2
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (sql[i] === `'`) {
|
|
40
|
+
current += sql[i]
|
|
41
|
+
i++
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
current += sql[i]
|
|
45
|
+
i++
|
|
46
|
+
}
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check for single-line comments
|
|
51
|
+
if (sql[i] === '-' && sql[i + 1] === '-') {
|
|
52
|
+
while (i < sql.length && sql[i] !== '\n') {
|
|
53
|
+
current += sql[i]
|
|
54
|
+
i++
|
|
55
|
+
}
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Statement terminator
|
|
60
|
+
if (sql[i] === ';') {
|
|
61
|
+
current += ';'
|
|
62
|
+
const trimmed = current.trim()
|
|
63
|
+
if (trimmed && trimmed !== ';') {
|
|
64
|
+
statements.push(trimmed)
|
|
65
|
+
}
|
|
66
|
+
current = ''
|
|
67
|
+
i++
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
current += sql[i]
|
|
72
|
+
i++
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const trimmed = current.trim()
|
|
76
|
+
if (trimmed && trimmed !== ';') {
|
|
77
|
+
statements.push(trimmed)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return statements
|
|
81
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the local `supabase` CLI binary from node_modules/.bin so
|
|
10
|
+
* generation works from within the Nitro dev server without relying on
|
|
11
|
+
* `bunx`/`npx` (and without requiring `supabase login`, since we connect
|
|
12
|
+
* directly via --db-url).
|
|
13
|
+
*/
|
|
14
|
+
function resolveSupabaseBin(rootDir: string): string {
|
|
15
|
+
const binName = process.platform === 'win32' ? 'supabase.cmd' : 'supabase'
|
|
16
|
+
const localBin = join(rootDir, 'node_modules/.bin', binName)
|
|
17
|
+
return existsSync(localBin) ? localBin : 'supabase'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Regenerates the Supabase `Database` types from the live database schema
|
|
22
|
+
* and writes them to <rootDir>/shared/types/supabase.ts, only touching the
|
|
23
|
+
* file if the content actually changed (avoids needless dev-server reloads).
|
|
24
|
+
*
|
|
25
|
+
* Never throws — failures are logged and swallowed so this can safely run
|
|
26
|
+
* as a side effect of the migrations plugin without breaking server boot.
|
|
27
|
+
*/
|
|
28
|
+
export async function regenerateSupabaseTypes(
|
|
29
|
+
rootDir: string,
|
|
30
|
+
connectionString?: string
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const connStr = connectionString ?? process.env.DATABASE_URL
|
|
33
|
+
|
|
34
|
+
if (!connStr) {
|
|
35
|
+
console.warn(
|
|
36
|
+
'[typegen] Skipping Supabase type generation: no DATABASE_URL configured.'
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const targetPath = join(rootDir, 'shared/types/supabase.ts')
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await execFileAsync(
|
|
45
|
+
resolveSupabaseBin(rootDir),
|
|
46
|
+
['gen', 'types', 'typescript', '--db-url', connStr, '--schema', 'public'],
|
|
47
|
+
{ maxBuffer: 1024 * 1024 * 16 }
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if (!stdout.trim()) {
|
|
51
|
+
console.warn('[typegen] Supabase CLI returned no output, skipping write.')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const existing = existsSync(targetPath)
|
|
56
|
+
? readFileSync(targetPath, 'utf-8')
|
|
57
|
+
: null
|
|
58
|
+
|
|
59
|
+
if (existing === stdout) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
writeFileSync(targetPath, stdout, 'utf-8')
|
|
64
|
+
console.warn(`[typegen] Regenerated Supabase types at ${targetPath}`)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.warn('[typegen] Failed to regenerate Supabase types:', error)
|
|
67
|
+
}
|
|
68
|
+
}
|