@plutocms/supabase 0.3.0 → 0.4.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.
@@ -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 { runLayerMigration } from '../../utils/migrations'
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
- const envConnectionString = process.env.DATABASE_URL
28
- const bodyConnectionString = body?.connectionString
29
- const usingBodyConnectionString = !envConnectionString
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
- const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
42
-
43
- const { pending } = await getMigrationStatus(event)
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
- const results: LayerResult[] = []
46
- let anyApplied = false
47
- // Tracks whether we've already committed a body-supplied connection
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
- for (const layerName of pending) {
52
- const schemaSql = layerSchemas[layerName]
53
+ const anyApplied = results.some((result) => result.status === 'applied')
53
54
 
54
- if (!schemaSql) {
55
- continue
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
- const result = await runLayerMigration({
59
- layerName,
60
- schemaSql,
61
- connectionString: connStr,
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 (result.success) {
65
- results.push({
66
- layerName,
67
- status: result.skipped ? 'skipped' : 'applied',
68
- })
68
+ if (usingBodyConnectionString) {
69
+ persisted = await persistDatabaseUrl(connStr)
69
70
 
70
- if (!result.skipped) {
71
- anyApplied = true
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
- if (usingBodyConnectionString && connStrCommitted) {
100
- persisted = await persistDatabaseUrl(connStr)
78
+ if (import.meta.dev && anyApplied && config.plutoRootDir) {
79
+ await regenerateSupabaseTypes(config.plutoRootDir, connStr)
80
+ }
101
81
 
102
- if (!persisted) {
103
- message =
104
- 'Migrations were applied, but the connection string could not be ' +
105
- 'saved to .env. Add DATABASE_URL to .env by hand.'
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
- if (import.meta.dev && anyApplied && config.plutoRootDir) {
110
- await regenerateSupabaseTypes(config.plutoRootDir, connStr)
111
- }
94
+ console.error('Error applying migrations:', errorMessage)
112
95
 
113
- return {
114
- success: true as const,
115
- results,
116
- persisted,
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 { pending, applied, hasConnection } = await getMigrationStatus(event)
7
+ const status = await getMigrationStatus(event)
8
8
 
9
9
  return {
10
10
  success: true as const,
11
- pending,
12
- applied,
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
- ...Object.entries(body).map(([key, value]) => ({
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 { runLayerMigration } from '../../utils/migrations'
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
- const body = await readBody<Payload>(event)
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
- let schema = await $fetch<string | Blob>('/schema.sql', {
22
- baseURL: body.baseUrl,
23
- })
26
+ const body = await readBody<Payload>(event)
24
27
 
25
- if (typeof schema !== 'string') {
26
- schema = await schema.text()
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
- const statements = splitStatements(schema)
44
-
45
- for (const statement of statements) {
46
- await sql.unsafe(statement)
47
- }
48
-
49
- // Mark first_setup as complete
50
- await sql.unsafe(
51
- `UPDATE public.healthcheck SET config_value = 'false' WHERE config_name = 'first_setup'`
52
- )
53
-
54
- // Record the core schema migration
55
- await sql.unsafe(
56
- `INSERT INTO public.pluto_migrations (layer_name)
57
- VALUES ('core')
58
- ON CONFLICT (layer_name) DO NOTHING`
59
- )
60
-
61
- // Apply every extra layer's schema too. The core schema must run first
62
- // (a layer schema may depend on a core object), so this loop stays
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
- console.error(`Layer migration failed [${layerName}]:`, message)
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 { runLayerMigration } from '../utils/migrations'
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
- const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
12
-
13
- const layerNames = Object.keys(layerSchemas)
14
-
15
- if (layerNames.length === 0) {
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
- 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)
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 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.
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