@plutocms/supabase 0.0.1-alpha.10 → 0.0.1-alpha.12
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/app/composables/auth.ts +15 -0
- package/app/middleware/auth.ts +1 -0
- package/app/pages/admin/(auth)/confirm.vue +38 -0
- package/app/pages/admin/(auth)/signup.vue +61 -7
- package/bun.lock +139 -201
- package/modules/pluto-migrations.ts +71 -0
- package/package.json +3 -3
- package/public/schema.sql +25 -0
- package/server/api/setup/create.post.ts +34 -82
- package/server/plugins/migrations.ts +48 -0
- package/server/utils/migrations.ts +88 -0
- package/server/utils/sql.ts +81 -0
- package/shared/types/supabase.ts +78 -117
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineNuxtModule, getLayerDirectories } from 'nuxt/kit'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Discovers schema.[layerName].sql files across all Nuxt layers,
|
|
7
|
+
* reads their content at build time, and populates runtimeConfig
|
|
8
|
+
* so the migrations plugin can run them at server startup.
|
|
9
|
+
*/
|
|
10
|
+
export default defineNuxtModule({
|
|
11
|
+
meta: {
|
|
12
|
+
name: 'pluto-migrations',
|
|
13
|
+
configKey: 'plutoMigrations',
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
setup(_options, nuxt) {
|
|
17
|
+
const layerDirs = getLayerDirectories()
|
|
18
|
+
const layerSchemas: Record<string, string> = {}
|
|
19
|
+
const schemaPattern = /^schema\.(.+)\.sql$/
|
|
20
|
+
|
|
21
|
+
for (const layer of layerDirs) {
|
|
22
|
+
// Each layer may have a public/ directory with schema files
|
|
23
|
+
const publicDir = join(layer.root, 'public')
|
|
24
|
+
|
|
25
|
+
if (!existsSync(publicDir)) {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let files: string[] = []
|
|
30
|
+
try {
|
|
31
|
+
files = readdirSync(publicDir, 'utf-8')
|
|
32
|
+
} catch {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
// Skip the core schema
|
|
38
|
+
if (file === 'schema.sql') {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const match = file.match(schemaPattern)
|
|
43
|
+
if (match?.[1]) {
|
|
44
|
+
const layerName = match[1]
|
|
45
|
+
// Read SQL content at build time
|
|
46
|
+
try {
|
|
47
|
+
layerSchemas[layerName] = readFileSync(
|
|
48
|
+
join(publicDir, file),
|
|
49
|
+
'utf-8'
|
|
50
|
+
)
|
|
51
|
+
} catch {
|
|
52
|
+
console.error(
|
|
53
|
+
`[pluto-migrations] Failed to read schema file: ${file}`
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const layerNames = Object.keys(layerSchemas)
|
|
61
|
+
|
|
62
|
+
// Populate runtimeConfig with layer names and their SQL content
|
|
63
|
+
nuxt.options.runtimeConfig.plutoLayerSchemas = layerSchemas
|
|
64
|
+
|
|
65
|
+
if (layerNames.length > 0) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[pluto-migrations] Discovered layer schemas: ${layerNames.join(', ')}`
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.1-alpha.
|
|
4
|
+
"version": "0.0.1-alpha.12",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "nuxt build",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"supabase-types": "bun ./scripts/supabase-typegen.ts"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
+
"@nuxt/ui": "^4.5.1",
|
|
18
19
|
"@nuxtjs/supabase": "2.0.3",
|
|
19
20
|
"@plutocms/pluto": "^0.0.1-alpha.4",
|
|
20
21
|
"@plutocms/utils": "^0.0.1-alpha.5",
|
|
@@ -25,8 +26,7 @@
|
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"@antfu/eslint-config": "^6.2.0",
|
|
27
28
|
"@nuxt/eslint": "^1.15.1",
|
|
28
|
-
"@
|
|
29
|
-
"@types/bun": "^1.3.9",
|
|
29
|
+
"@types/bun": "^1.3.10",
|
|
30
30
|
"@vueuse/nuxt": "^14.2.1",
|
|
31
31
|
"eslint": "^9.39.1",
|
|
32
32
|
"nuxt": "^4.3.1",
|
package/public/schema.sql
CHANGED
|
@@ -199,3 +199,28 @@ begin
|
|
|
199
199
|
end if;
|
|
200
200
|
end
|
|
201
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,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
|
|
|
@@ -123,6 +44,37 @@ export default defineEventHandler(async (event) => {
|
|
|
123
44
|
`UPDATE public.healthcheck SET config_value = 'false' WHERE config_name = 'first_setup'`
|
|
124
45
|
)
|
|
125
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')
|
|
77
|
+
|
|
126
78
|
return {
|
|
127
79
|
success: true,
|
|
128
80
|
message: 'Database setup completed successfully.',
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { runLayerMigration } from '../utils/migrations'
|
|
2
|
+
|
|
3
|
+
export default defineNitroPlugin(async () => {
|
|
4
|
+
// Only run if DATABASE_URL is configured (setup already completed)
|
|
5
|
+
if (!process.env.DATABASE_URL) {
|
|
6
|
+
return
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const config = useRuntimeConfig()
|
|
10
|
+
const layerSchemas: Record<string, string> =
|
|
11
|
+
(config as any).plutoLayerSchemas ?? {}
|
|
12
|
+
|
|
13
|
+
const layerNames = Object.keys(layerSchemas)
|
|
14
|
+
|
|
15
|
+
if (layerNames.length === 0) {
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const layerName of layerNames) {
|
|
20
|
+
const schemaSql = layerSchemas[layerName]
|
|
21
|
+
|
|
22
|
+
if (!schemaSql) {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const result = await runLayerMigration({
|
|
28
|
+
layerName,
|
|
29
|
+
schemaSql,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (result.skipped) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`[migrations] Layer "${layerName}" already applied, skipped.`
|
|
35
|
+
)
|
|
36
|
+
} else if (result.success) {
|
|
37
|
+
console.warn(`[migrations] Layer "${layerName}" migrated successfully.`)
|
|
38
|
+
} else {
|
|
39
|
+
console.error(`[migrations] Layer "${layerName}" failed:`, result.error)
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(
|
|
43
|
+
`[migrations] Error running migration for "${layerName}":`,
|
|
44
|
+
error
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
@@ -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
|
+
}
|