@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +2 -4
  3. package/app/assets/css/tailwind.css +5 -0
  4. package/app/components/navbar/NavbarAdmin.vue +6 -5
  5. package/app/composables/auth.ts +124 -2
  6. package/app/middleware/auth.ts +13 -6
  7. package/app/middleware/setup-check.ts +25 -21
  8. package/app/pages/admin/(auth)/confirm.vue +38 -0
  9. package/app/pages/admin/(auth)/forgot-password.vue +88 -0
  10. package/app/pages/admin/(auth)/login.vue +5 -1
  11. package/app/pages/admin/(auth)/signup.vue +76 -16
  12. package/app/pages/admin/(auth)/update-password.vue +134 -0
  13. package/app/pages/admin/setup.vue +31 -28
  14. package/app/pages/admin.vue +3 -4
  15. package/modules/pluto-migrations.ts +121 -0
  16. package/nuxt.config.ts +14 -1
  17. package/package.json +40 -24
  18. package/public/schema.sql +66 -8
  19. package/scripts/supabase-typegen.ts +36 -21
  20. package/scripts/tsconfig.json +18 -0
  21. package/server/api/auth/signup.post.ts +46 -0
  22. package/server/api/setup/create.post.ts +38 -88
  23. package/server/plugins/migrations.ts +60 -0
  24. package/server/utils/migrations.ts +88 -0
  25. package/server/utils/sql.ts +81 -0
  26. package/server/utils/typegen.ts +68 -0
  27. package/shared/types/runtime-config.d.ts +16 -0
  28. package/shared/types/supabase.ts +90 -117
  29. package/.agents/skills/nuxt/GENERATION.md +0 -5
  30. package/.agents/skills/nuxt/SKILL.md +0 -55
  31. package/.agents/skills/nuxt/references/advanced-hooks.md +0 -289
  32. package/.agents/skills/nuxt/references/advanced-layers.md +0 -299
  33. package/.agents/skills/nuxt/references/advanced-module-authoring.md +0 -554
  34. package/.agents/skills/nuxt/references/best-practices-data-fetching.md +0 -357
  35. package/.agents/skills/nuxt/references/best-practices-ssr.md +0 -355
  36. package/.agents/skills/nuxt/references/core-cli.md +0 -263
  37. package/.agents/skills/nuxt/references/core-config.md +0 -162
  38. package/.agents/skills/nuxt/references/core-data-fetching.md +0 -236
  39. package/.agents/skills/nuxt/references/core-deployment.md +0 -224
  40. package/.agents/skills/nuxt/references/core-directory-structure.md +0 -269
  41. package/.agents/skills/nuxt/references/core-modules.md +0 -292
  42. package/.agents/skills/nuxt/references/core-routing.md +0 -226
  43. package/.agents/skills/nuxt/references/features-components-autoimport.md +0 -328
  44. package/.agents/skills/nuxt/references/features-components.md +0 -264
  45. package/.agents/skills/nuxt/references/features-composables.md +0 -276
  46. package/.agents/skills/nuxt/references/features-server.md +0 -265
  47. package/.agents/skills/nuxt/references/features-state.md +0 -194
  48. package/.agents/skills/nuxt/references/rendering-modes.md +0 -237
  49. package/.agents/skills/nuxt-ui/SKILL.md +0 -334
  50. package/.agents/skills/nuxt-ui/references/components.md +0 -377
  51. package/.agents/skills/nuxt-ui/references/composables.md +0 -127
  52. package/.agents/skills/nuxt-ui/references/layouts/chat.md +0 -195
  53. package/.agents/skills/nuxt-ui/references/layouts/dashboard.md +0 -220
  54. package/.agents/skills/nuxt-ui/references/layouts/docs.md +0 -141
  55. package/.agents/skills/nuxt-ui/references/layouts/editor.md +0 -167
  56. package/.agents/skills/nuxt-ui/references/layouts/page.md +0 -260
  57. package/.agents/skills/nuxt-ui/references/theming.md +0 -427
  58. package/.agents/skills/supabase-postgres-best-practices/AGENTS.md +0 -68
  59. package/.agents/skills/supabase-postgres-best-practices/CLAUDE.md +0 -1
  60. package/.agents/skills/supabase-postgres-best-practices/README.md +0 -116
  61. package/.agents/skills/supabase-postgres-best-practices/SKILL.md +0 -64
  62. package/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +0 -55
  63. package/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +0 -49
  64. package/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +0 -46
  65. package/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +0 -44
  66. package/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +0 -41
  67. package/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +0 -46
  68. package/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +0 -54
  69. package/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +0 -53
  70. package/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +0 -50
  71. package/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +0 -50
  72. package/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +0 -56
  73. package/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +0 -68
  74. package/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +0 -50
  75. package/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +0 -54
  76. package/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +0 -45
  77. package/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +0 -55
  78. package/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +0 -55
  79. package/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +0 -44
  80. package/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +0 -40
  81. package/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +0 -48
  82. package/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +0 -43
  83. package/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +0 -45
  84. package/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +0 -80
  85. package/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +0 -46
  86. package/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +0 -59
  87. package/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +0 -55
  88. package/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +0 -55
  89. package/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +0 -61
  90. package/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +0 -54
  91. package/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +0 -50
  92. package/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +0 -57
  93. package/.editorconfig +0 -12
  94. package/.eslintignore +0 -1
  95. package/.prettierrc +0 -9
  96. package/.vscode/extensions.json +0 -10
  97. package/.vscode/settings.json +0 -61
  98. package/bun.lock +0 -2837
  99. package/eslint.config.mjs +0 -88
  100. package/server/api/category/list.get.ts +0 -9
  101. package/server/api/category/new.post.ts +0 -31
  102. package/server/api/signup.post.ts +0 -28
  103. 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 and dashboard_user'
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 and dashboard_user"
111
+ create policy "Enable read access for healthcheck to public"
112
112
  on public.healthcheck
113
113
  for select
114
- to public, dashboard_user
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
- first_name text,
125
- last_name text,
126
- constraint first_name_length check (char_length(first_name) >= 3)
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
- insert into public.profiles (id, first_name, last_name)
153
- values (new.id, new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data ->> 'last_name');
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
- consola.info(`Generating Supabase types for project: ${projectId}`)
15
-
16
- // Create file if it doesn't exist
17
- if (!fs.existsSync(_path)) {
18
- consola.info('Types file does not exist. Creating a new one.')
9
+ if (databaseUrl) {
10
+ consola.info('Generating Supabase types via DATABASE_URL...')
11
+ await regenerateSupabaseTypes(cwd(), databaseUrl)
12
+ return
13
+ }
19
14
 
20
- fs.mkdirSync(path.dirname(_path), { recursive: true })
21
- fs.writeFileSync(_path, '', 'utf-8')
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
- const args = [
25
- `--project-id=${projectId || ''}`,
26
- '--schema=public',
27
- '>',
28
- `"${_path}"`,
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
- await $`bunx supabase gen types typescript ${{ raw: args.join(' ') }}`
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
- // Set the first_setup flag in the healthcheck table to false
122
- await sql`
123
- UPDATE healthcheck
124
- SET config_value = 'false'
125
- WHERE config_name = 'first_setup';
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
+ }