@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.
@@ -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.10",
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
- "@nuxt/ui": "^4.4.0",
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
+ }