@plutocms/supabase 0.3.0 → 0.4.1
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 +23 -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 +19 -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
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { PlutoMigrationFile } from '../../../shared/types/migrations'
|
|
2
|
+
import type { MigrationFileResult } from '../../utils/migrations'
|
|
1
3
|
import { requireAdmin } from '../../utils/admin-guard'
|
|
2
4
|
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
3
|
-
import {
|
|
4
|
-
import { getMigrationStatus } from '../../utils/pending-migrations'
|
|
5
|
+
import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
|
|
5
6
|
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
6
7
|
import { regenerateSupabaseTypes } from '../../utils/typegen'
|
|
7
8
|
|
|
@@ -9,12 +10,6 @@ interface Payload {
|
|
|
9
10
|
connectionString?: string
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
interface LayerResult {
|
|
13
|
-
layerName: string
|
|
14
|
-
status: 'applied' | 'skipped' | 'failed'
|
|
15
|
-
error?: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
13
|
export default defineEventHandler(async (event) => {
|
|
19
14
|
await requireAdmin(event)
|
|
20
15
|
|
|
@@ -23,12 +18,10 @@ export default defineEventHandler(async (event) => {
|
|
|
23
18
|
// Connection string precedence: an already-configured DATABASE_URL always
|
|
24
19
|
// wins, and a body-supplied string is ignored completely in that case.
|
|
25
20
|
// This is the whole security model for this endpoint — without it, any
|
|
26
|
-
// admin could point the server at an arbitrary database host.
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const connStr = envConnectionString || bodyConnectionString
|
|
21
|
+
// admin could point the server at an arbitrary database host. See
|
|
22
|
+
// resolveConnectionString for the full rule.
|
|
23
|
+
const { connStr, usingBody: usingBodyConnectionString } =
|
|
24
|
+
resolveConnectionString(body?.connectionString)
|
|
32
25
|
|
|
33
26
|
if (!connStr) {
|
|
34
27
|
return {
|
|
@@ -38,82 +31,71 @@ export default defineEventHandler(async (event) => {
|
|
|
38
31
|
}
|
|
39
32
|
|
|
40
33
|
const config = useRuntimeConfig()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
// Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
|
|
35
|
+
// layer keys and file shapes it happened to observe at build time (see
|
|
36
|
+
// the `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts),
|
|
37
|
+
// so the read is cast back to the intended general shape.
|
|
38
|
+
const layers = (config.plutoLayerMigrations ?? {}) as unknown as Record<
|
|
39
|
+
string,
|
|
40
|
+
PlutoMigrationFile[]
|
|
41
|
+
>
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const layerResults = await runPendingMigrations({
|
|
45
|
+
connectionString: connStr,
|
|
46
|
+
layers,
|
|
47
|
+
})
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// string to process.env for this request (done once, on first success).
|
|
49
|
-
let connStrCommitted = false
|
|
49
|
+
const results: MigrationFileResult[] = layerResults.flatMap(
|
|
50
|
+
(layer) => layer.results
|
|
51
|
+
)
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
const schemaSql = layerSchemas[layerName]
|
|
53
|
+
const anyApplied = results.some((result) => result.status === 'applied')
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Deliberate runtime mutation: a valid connection string just proved
|
|
56
|
+
// itself against the database, so make it available to
|
|
57
|
+
// getConnectionString() for the rest of this process life. No
|
|
58
|
+
// server restart is needed for later requests to pick it up.
|
|
59
|
+
if (usingBodyConnectionString) {
|
|
60
|
+
process.env.DATABASE_URL = connStr
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
})
|
|
63
|
+
// Nothing to persist when DATABASE_URL was already configured.
|
|
64
|
+
// Otherwise persist the now-proven body-supplied string.
|
|
65
|
+
let persisted = !usingBodyConnectionString
|
|
66
|
+
let message: string | undefined
|
|
63
67
|
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
layerName,
|
|
67
|
-
status: result.skipped ? 'skipped' : 'applied',
|
|
68
|
-
})
|
|
68
|
+
if (usingBodyConnectionString) {
|
|
69
|
+
persisted = await persistDatabaseUrl(connStr)
|
|
69
70
|
|
|
70
|
-
if (!
|
|
71
|
-
|
|
71
|
+
if (!persisted) {
|
|
72
|
+
message =
|
|
73
|
+
'Migrations were applied, but the connection string could not be ' +
|
|
74
|
+
'saved to .env. Add DATABASE_URL to .env by hand.'
|
|
72
75
|
}
|
|
73
|
-
|
|
74
|
-
if (usingBodyConnectionString && !connStrCommitted) {
|
|
75
|
-
// Deliberate runtime mutation: a valid connection string just
|
|
76
|
-
// proved itself against the database, so make it available to
|
|
77
|
-
// getConnectionString() for the rest of this process life. No
|
|
78
|
-
// server restart is needed for later requests to pick it up.
|
|
79
|
-
process.env.DATABASE_URL = connStr
|
|
80
|
-
connStrCommitted = true
|
|
81
|
-
}
|
|
82
|
-
} else {
|
|
83
|
-
const message = scrubConnectionString(
|
|
84
|
-
result.error ?? 'Unknown migration error.',
|
|
85
|
-
connStr
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
console.error(`Migration failed [${layerName}]:`, message)
|
|
89
|
-
|
|
90
|
-
results.push({ layerName, status: 'failed', error: message })
|
|
91
76
|
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Nothing to persist when DATABASE_URL was already configured. Otherwise
|
|
95
|
-
// only persist once a body-supplied string has proven itself valid.
|
|
96
|
-
let persisted = !usingBodyConnectionString
|
|
97
|
-
let message: string | undefined
|
|
98
77
|
|
|
99
|
-
|
|
100
|
-
|
|
78
|
+
if (import.meta.dev && anyApplied && config.plutoRootDir) {
|
|
79
|
+
await regenerateSupabaseTypes(config.plutoRootDir, connStr)
|
|
80
|
+
}
|
|
101
81
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
82
|
+
return {
|
|
83
|
+
success: true as const,
|
|
84
|
+
results,
|
|
85
|
+
persisted,
|
|
86
|
+
message,
|
|
106
87
|
}
|
|
107
|
-
}
|
|
88
|
+
} catch (error: any) {
|
|
89
|
+
const errorMessage = scrubConnectionString(
|
|
90
|
+
error?.message ?? 'Unknown migration error.',
|
|
91
|
+
connStr
|
|
92
|
+
)
|
|
108
93
|
|
|
109
|
-
|
|
110
|
-
await regenerateSupabaseTypes(config.plutoRootDir, connStr)
|
|
111
|
-
}
|
|
94
|
+
console.error('Error applying migrations:', errorMessage)
|
|
112
95
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
message,
|
|
96
|
+
return {
|
|
97
|
+
success: false as const,
|
|
98
|
+
error: errorMessage,
|
|
99
|
+
}
|
|
118
100
|
}
|
|
119
101
|
})
|
|
@@ -4,13 +4,11 @@ import { getMigrationStatus } from '../../utils/pending-migrations'
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
await requireAdmin(event)
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const status = await getMigrationStatus(event)
|
|
8
8
|
|
|
9
9
|
return {
|
|
10
10
|
success: true as const,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
hasConnection,
|
|
14
|
-
needsConnectionString: !hasConnection && pending.length > 0,
|
|
11
|
+
...status,
|
|
12
|
+
needsConnectionString: !status.hasConnection && status.pendingFileCount > 0,
|
|
15
13
|
}
|
|
16
14
|
})
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
3
|
+
|
|
4
|
+
// Mirrors the TSettings enum in db/migrations/001_baseline.sql. The Postgres enum
|
|
5
|
+
// already rejects an unknown key at the DB level (as a raw constraint
|
|
6
|
+
// error) — this turns that into a clean 400 instead.
|
|
7
|
+
const KNOWN_SETTING_KEYS = [
|
|
8
|
+
'website_title',
|
|
9
|
+
'website_url',
|
|
10
|
+
'website_description',
|
|
11
|
+
] as const
|
|
2
12
|
|
|
3
13
|
export default defineEventHandler(async (event) => {
|
|
14
|
+
await requireAdmin(event)
|
|
15
|
+
|
|
4
16
|
type SettingName =
|
|
5
17
|
Database['public']['Tables']['settings']['Insert']['setting_name']
|
|
6
18
|
type FormBody = Record<SettingName, string>
|
|
@@ -8,16 +20,32 @@ export default defineEventHandler(async (event) => {
|
|
|
8
20
|
const client = await serverSupabaseClient<Database>(event)
|
|
9
21
|
const body = await readBody<FormBody>(event)
|
|
10
22
|
|
|
11
|
-
if (!body) {
|
|
12
|
-
throw createError({ statusMessage: 'No payload sent.' })
|
|
23
|
+
if (!body || typeof body !== 'object') {
|
|
24
|
+
throw createError({ statusCode: 400, statusMessage: 'No payload sent.' })
|
|
13
25
|
}
|
|
14
26
|
|
|
15
|
-
const transformed = [
|
|
16
|
-
|
|
27
|
+
const transformed = Object.entries(body).map(([key, value]) => {
|
|
28
|
+
if (
|
|
29
|
+
!KNOWN_SETTING_KEYS.includes(key as (typeof KNOWN_SETTING_KEYS)[number])
|
|
30
|
+
) {
|
|
31
|
+
throw createError({
|
|
32
|
+
statusCode: 400,
|
|
33
|
+
statusMessage: `Unknown setting: ${key}`,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value !== 'string') {
|
|
38
|
+
throw createError({
|
|
39
|
+
statusCode: 400,
|
|
40
|
+
statusMessage: `Setting "${key}" must be a string.`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
17
45
|
setting_name: key as SettingName,
|
|
18
46
|
setting_value: value,
|
|
19
|
-
}
|
|
20
|
-
|
|
47
|
+
}
|
|
48
|
+
})
|
|
21
49
|
|
|
22
50
|
const { data, error } = await client
|
|
23
51
|
.from('settings')
|
|
@@ -1,29 +1,51 @@
|
|
|
1
|
+
import type { PlutoMigrationFile } from '../../../shared/types/migrations'
|
|
1
2
|
import postgres from 'postgres'
|
|
2
3
|
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
3
|
-
import {
|
|
4
|
+
import { runPendingMigrations } from '../../utils/migrations'
|
|
4
5
|
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
5
|
-
import { splitStatements } from '../../utils/sql'
|
|
6
6
|
|
|
7
7
|
interface Payload {
|
|
8
|
-
baseUrl: string
|
|
9
8
|
connectionString: string
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
interface LayerResult {
|
|
13
|
-
layerName: string
|
|
14
|
-
status: 'applied' | 'skipped' | 'failed'
|
|
15
|
-
error?: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
11
|
export default defineEventHandler(async (event) => {
|
|
19
|
-
|
|
12
|
+
// An already-configured DATABASE_URL means setup has already completed.
|
|
13
|
+
// This route is reachable with no admin session — no admin can exist
|
|
14
|
+
// before setup creates the profiles table — so once real setup has run,
|
|
15
|
+
// this check is the only thing stopping anyone from re-running it against
|
|
16
|
+
// an arbitrary database. Mirrors the same precedence rule in
|
|
17
|
+
// server/api/migrations/run.post.ts, which calls it "the whole security
|
|
18
|
+
// model for this endpoint."
|
|
19
|
+
if (process.env.DATABASE_URL) {
|
|
20
|
+
throw createError({
|
|
21
|
+
statusCode: 403,
|
|
22
|
+
statusMessage: 'Setup has already been completed.',
|
|
23
|
+
})
|
|
24
|
+
}
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
baseURL: body.baseUrl,
|
|
23
|
-
})
|
|
26
|
+
const body = await readBody<Payload>(event)
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const config = useRuntimeConfig()
|
|
29
|
+
// Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
|
|
30
|
+
// layer keys and file shapes it happened to observe at build time (see
|
|
31
|
+
// the `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts),
|
|
32
|
+
// so the read is cast back to the intended general shape.
|
|
33
|
+
const layers = (config.plutoLayerMigrations ?? {}) as unknown as Record<
|
|
34
|
+
string,
|
|
35
|
+
PlutoMigrationFile[]
|
|
36
|
+
>
|
|
37
|
+
|
|
38
|
+
// The core layer's migrations are discovered and embedded at build time
|
|
39
|
+
// by the pluto-migrations module, exactly like every other layer — see
|
|
40
|
+
// modules/pluto-migrations.ts. Reading it from there instead of fetching
|
|
41
|
+
// it over HTTP means this route needs no `baseUrl` from the caller, and
|
|
42
|
+
// can no longer be made to fetch or execute SQL from an arbitrary origin.
|
|
43
|
+
if (!layers.core?.length) {
|
|
44
|
+
throw createError({
|
|
45
|
+
statusCode: 500,
|
|
46
|
+
statusMessage:
|
|
47
|
+
'Core schema not found. Reinstall @plutocms/supabase and restart the dev server.',
|
|
48
|
+
})
|
|
27
49
|
}
|
|
28
50
|
|
|
29
51
|
const supabaseUrl = process.env.SUPABASE_URL
|
|
@@ -37,63 +59,30 @@ export default defineEventHandler(async (event) => {
|
|
|
37
59
|
|
|
38
60
|
const connectionString = body.connectionString
|
|
39
61
|
|
|
40
|
-
const sql = postgres(connectionString)
|
|
41
|
-
|
|
42
62
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// after the core-schema block above. A single failing layer must not
|
|
64
|
-
// fail the whole wizard — the core schema already succeeded and
|
|
65
|
-
// first_setup is already 'false', so this loop reports per-layer
|
|
66
|
-
// failures instead of throwing.
|
|
67
|
-
const config = useRuntimeConfig()
|
|
68
|
-
const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
|
|
69
|
-
|
|
70
|
-
const layers: LayerResult[] = []
|
|
71
|
-
|
|
72
|
-
for (const [layerName, schemaSql] of Object.entries(layerSchemas)) {
|
|
73
|
-
if (!schemaSql) {
|
|
74
|
-
continue
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const result = await runLayerMigration({
|
|
78
|
-
layerName,
|
|
79
|
-
schemaSql,
|
|
80
|
-
connectionString,
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
if (result.success) {
|
|
84
|
-
layers.push({
|
|
85
|
-
layerName,
|
|
86
|
-
status: result.skipped ? 'skipped' : 'applied',
|
|
87
|
-
})
|
|
88
|
-
} else {
|
|
89
|
-
const message = scrubConnectionString(
|
|
90
|
-
result.error ?? 'Unknown migration error.',
|
|
91
|
-
connectionString
|
|
63
|
+
// core must apply before every other layer — a layer's migrations may
|
|
64
|
+
// reference core objects (public.profiles, public.is_admin()).
|
|
65
|
+
// runPendingMigrations guarantees this ordering itself.
|
|
66
|
+
const layerResults = await runPendingMigrations({
|
|
67
|
+
connectionString,
|
|
68
|
+
layers,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const coreResult = layerResults.find((layer) => layer.layerName === 'core')
|
|
72
|
+
|
|
73
|
+
// Only mark first_setup complete once the core layer's own migrations
|
|
74
|
+
// are known good. A single failing extra layer must not block this —
|
|
75
|
+
// core already succeeded, so the wizard can still hand off to sign-up —
|
|
76
|
+
// but a failed core layer must not flip first_setup, since the base
|
|
77
|
+
// schema (profiles, healthcheck, and so on) may be incomplete.
|
|
78
|
+
if (coreResult && !coreResult.failed) {
|
|
79
|
+
const sql = postgres(connectionString, { max: 1 })
|
|
80
|
+
try {
|
|
81
|
+
await sql.unsafe(
|
|
82
|
+
`UPDATE public.healthcheck SET config_value = 'false' WHERE config_name = 'first_setup'`
|
|
92
83
|
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
layers.push({ layerName, status: 'failed', error: message })
|
|
84
|
+
} finally {
|
|
85
|
+
await sql.end()
|
|
97
86
|
}
|
|
98
87
|
}
|
|
99
88
|
|
|
@@ -103,13 +92,16 @@ export default defineEventHandler(async (event) => {
|
|
|
103
92
|
// server restart needed.
|
|
104
93
|
process.env.DATABASE_URL = connectionString
|
|
105
94
|
|
|
106
|
-
// Persist connection string to .env for future layer migrations
|
|
95
|
+
// Persist connection string to .env for future layer migrations. This
|
|
96
|
+
// must stay the very last thing this handler does — see "The
|
|
97
|
+
// dev-server restart on first save" in
|
|
98
|
+
// .claude/skills/layer-migrations/SKILL.md.
|
|
107
99
|
const persisted = await persistDatabaseUrl(connectionString)
|
|
108
100
|
|
|
109
101
|
return {
|
|
110
102
|
success: true,
|
|
111
103
|
message: 'Database setup completed successfully.',
|
|
112
|
-
layers,
|
|
104
|
+
layers: layerResults,
|
|
113
105
|
persisted,
|
|
114
106
|
}
|
|
115
107
|
} catch (error: any) {
|
|
@@ -124,7 +116,5 @@ export default defineEventHandler(async (event) => {
|
|
|
124
116
|
success: false,
|
|
125
117
|
error: message,
|
|
126
118
|
}
|
|
127
|
-
} finally {
|
|
128
|
-
sql.end()
|
|
129
119
|
}
|
|
130
120
|
})
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
2
3
|
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireAdmin(event)
|
|
6
|
+
|
|
4
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
5
8
|
|
|
6
9
|
const id = event.context.params?.id as string
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
2
3
|
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireAdmin(event)
|
|
6
|
+
|
|
4
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
5
8
|
|
|
6
9
|
const { data: users, error } = await client.from('profiles').select('*')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { PlutoMigrationFile } from '../../shared/types/migrations'
|
|
2
|
+
import { runPendingMigrations } from '../utils/migrations'
|
|
2
3
|
import { regenerateSupabaseTypes } from '../utils/typegen'
|
|
3
4
|
|
|
4
5
|
export default defineNitroPlugin(async () => {
|
|
@@ -8,52 +9,48 @@ export default defineNitroPlugin(async () => {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const config = useRuntimeConfig()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
|
|
13
|
+
// layer keys and file shapes it happened to observe at build time (see
|
|
14
|
+
// the `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts),
|
|
15
|
+
// so the read is cast back to the intended general shape.
|
|
16
|
+
const layers = (config.plutoLayerMigrations ?? {}) as unknown as Record<
|
|
17
|
+
string,
|
|
18
|
+
PlutoMigrationFile[]
|
|
19
|
+
>
|
|
20
|
+
|
|
21
|
+
if (Object.keys(layers).length === 0) {
|
|
16
22
|
return
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
let anyApplied = false
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
console.warn(`[migrations] Layer "${layerName}" migrated successfully.`)
|
|
40
|
-
anyApplied = true
|
|
41
|
-
} else {
|
|
42
|
-
console.error(`[migrations] Layer "${layerName}" failed:`, result.error)
|
|
27
|
+
try {
|
|
28
|
+
const layerResults = await runPendingMigrations({
|
|
29
|
+
connectionString: process.env.DATABASE_URL,
|
|
30
|
+
layers,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
for (const layer of layerResults) {
|
|
34
|
+
for (const result of layer.results) {
|
|
35
|
+
const label = `${result.layerName}/${result.migrationName}`
|
|
36
|
+
|
|
37
|
+
if (result.status === 'skipped') {
|
|
38
|
+
console.warn(`[migrations] ${label} already applied, skipped.`)
|
|
39
|
+
} else if (result.status === 'applied') {
|
|
40
|
+
console.warn(`[migrations] ${label} migrated successfully.`)
|
|
41
|
+
anyApplied = true
|
|
42
|
+
} else {
|
|
43
|
+
console.error(`[migrations] ${label} failed:`, result.error)
|
|
44
|
+
}
|
|
43
45
|
}
|
|
44
|
-
} catch (error) {
|
|
45
|
-
console.error(
|
|
46
|
-
`[migrations] Error running migration for "${layerName}":`,
|
|
47
|
-
error
|
|
48
|
-
)
|
|
49
46
|
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('[migrations] Error running pending migrations:', error)
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
// Regenerate Database types from the live schema whenever a
|
|
53
|
-
// was newly applied, so `Database` stays in sync without a manual
|
|
54
|
-
//
|
|
55
|
-
// recorded as `skipped`); use the `supabase-types` script to force a
|
|
56
|
-
// refresh in that case.
|
|
51
|
+
// Regenerate Database types from the live schema whenever a migration
|
|
52
|
+
// file was newly applied, so `Database` stays in sync without a manual
|
|
53
|
+
// step.
|
|
57
54
|
if (import.meta.dev && anyApplied && config.plutoRootDir) {
|
|
58
55
|
await regenerateSupabaseTypes(config.plutoRootDir)
|
|
59
56
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type postgres from 'postgres'
|
|
2
|
+
import { splitStatements } from './sql'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cheap check for whether `public.pluto_migrations` is already in the
|
|
6
|
+
* target shape (has a `migration_name` column). Running this first, and
|
|
7
|
+
* skipping every DDL statement below when it already passes, avoids
|
|
8
|
+
* taking a DDL lock on the table on every server boot once a database has
|
|
9
|
+
* already been upgraded.
|
|
10
|
+
*/
|
|
11
|
+
const PROBE_SQL = `
|
|
12
|
+
select 1
|
|
13
|
+
from information_schema.columns
|
|
14
|
+
where table_schema = 'public'
|
|
15
|
+
and table_name = 'pluto_migrations'
|
|
16
|
+
and column_name = 'migration_name'
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Brings `public.pluto_migrations` from either shape (none yet, or the old
|
|
21
|
+
* one-row-per-layer shape) to the versioned, per-migration shape:
|
|
22
|
+
*
|
|
23
|
+
* ```sql
|
|
24
|
+
* create table if not exists public.pluto_migrations (
|
|
25
|
+
* id bigint generated by default as identity primary key,
|
|
26
|
+
* layer_name text not null,
|
|
27
|
+
* migration_name text not null,
|
|
28
|
+
* checksum text,
|
|
29
|
+
* applied_at timestamptz not null default now(),
|
|
30
|
+
* constraint pluto_migrations_layer_migration_key unique (layer_name, migration_name)
|
|
31
|
+
* );
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* The backfill below (`migration_name = '001_baseline.sql'` for a null
|
|
35
|
+
* row) is safe and correct because an old-shape row can only exist if the
|
|
36
|
+
* old engine ran a layer's single monolithic schema file — that file is,
|
|
37
|
+
* by construction, `001_baseline.sql` for every layer (see
|
|
38
|
+
* `db/migrations/001_baseline.sql` for `core`, and the equivalent file in
|
|
39
|
+
* every converted layer).
|
|
40
|
+
*/
|
|
41
|
+
export const ledgerDdl = `
|
|
42
|
+
create table if not exists public.pluto_migrations (
|
|
43
|
+
id bigint generated by default as identity primary key,
|
|
44
|
+
layer_name text not null,
|
|
45
|
+
migration_name text not null,
|
|
46
|
+
checksum text,
|
|
47
|
+
applied_at timestamptz not null default now(),
|
|
48
|
+
constraint pluto_migrations_layer_migration_key unique (layer_name, migration_name)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
alter table public.pluto_migrations add column if not exists migration_name text;
|
|
52
|
+
|
|
53
|
+
alter table public.pluto_migrations add column if not exists checksum text;
|
|
54
|
+
|
|
55
|
+
update public.pluto_migrations set migration_name = '001_baseline.sql' where migration_name is null;
|
|
56
|
+
|
|
57
|
+
alter table public.pluto_migrations alter column migration_name set not null;
|
|
58
|
+
|
|
59
|
+
alter table public.pluto_migrations drop constraint if exists pluto_migrations_layer_key;
|
|
60
|
+
|
|
61
|
+
do $$
|
|
62
|
+
begin
|
|
63
|
+
if not exists (
|
|
64
|
+
select 1 from pg_constraint
|
|
65
|
+
where conname = 'pluto_migrations_layer_migration_key'
|
|
66
|
+
and conrelid = 'public.pluto_migrations'::regclass
|
|
67
|
+
) then
|
|
68
|
+
alter table public.pluto_migrations
|
|
69
|
+
add constraint pluto_migrations_layer_migration_key unique (layer_name, migration_name);
|
|
70
|
+
end if;
|
|
71
|
+
end
|
|
72
|
+
$$;
|
|
73
|
+
|
|
74
|
+
alter table public.pluto_migrations enable row level security;
|
|
75
|
+
|
|
76
|
+
do $$
|
|
77
|
+
begin
|
|
78
|
+
if not exists (
|
|
79
|
+
select 1 from pg_policies where tablename = 'pluto_migrations' and policyname = 'Enable read access for authenticated users on pluto_migrations'
|
|
80
|
+
) then
|
|
81
|
+
create policy "Enable read access for authenticated users on pluto_migrations"
|
|
82
|
+
on public.pluto_migrations
|
|
83
|
+
for select
|
|
84
|
+
to authenticated, dashboard_user
|
|
85
|
+
using (true);
|
|
86
|
+
end if;
|
|
87
|
+
end
|
|
88
|
+
$$;
|
|
89
|
+
`
|
|
90
|
+
|
|
91
|
+
async function runLedgerSteps(
|
|
92
|
+
tx: postgres.Sql | postgres.TransactionSql
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const probe = await tx.unsafe(PROBE_SQL)
|
|
95
|
+
|
|
96
|
+
if (probe.length > 0) {
|
|
97
|
+
// Already in the target shape. Skip all DDL below.
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const statement of splitStatements(ledgerDdl)) {
|
|
102
|
+
await tx.unsafe(statement)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensures `public.pluto_migrations` is in the versioned, per-migration
|
|
108
|
+
* shape, running every DDL step in one transaction.
|
|
109
|
+
*
|
|
110
|
+
* Accepts either a plain connection or a connection already inside a
|
|
111
|
+
* transaction. A plain connection (it has `.begin`) opens its own
|
|
112
|
+
* transaction here; a connection already inside a transaction runs the
|
|
113
|
+
* same steps directly, since the caller's transaction already wraps them.
|
|
114
|
+
*/
|
|
115
|
+
export async function ensureLedger(
|
|
116
|
+
sql: postgres.Sql | postgres.TransactionSql
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
if (typeof (sql as postgres.Sql).begin === 'function') {
|
|
119
|
+
await (sql as postgres.Sql).begin((tx) => runLedgerSteps(tx))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await runLedgerSteps(sql as postgres.TransactionSql)
|
|
124
|
+
}
|
|
Binary file
|
|
Binary file
|