@beechcms/cli 0.6.0-preview.2 → 0.6.0-preview.4
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/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-lint.log +1 -0
- package/coverage/coverage-summary.json +3 -0
- package/dist/index.js +563 -230
- package/package.json +12 -11
- package/src/commands/deploy.ts +126 -126
- package/src/commands/generate-types.ts +65 -0
- package/src/commands/init.ts +599 -599
- package/src/commands/onboard.ts +32 -32
- package/src/commands/reset.ts +157 -0
- package/src/commands/schema-diff.ts +78 -0
- package/src/commands/seed-create.ts +192 -192
- package/src/commands/seed-load.ts +206 -235
- package/src/commands/update.ts +54 -54
- package/src/commands/validate.ts +80 -80
- package/src/index.ts +24 -17
- package/src/lib/migration-writer.ts +106 -0
- package/src/lib/schema-diff.ts +175 -150
- package/src/lib/wrangler.ts +129 -129
- package/src/test/generate-types.test.ts +58 -0
- package/src/test/reset.test.ts +128 -0
- package/src/test/schema-diff.test.ts +232 -0
- package/src/test/seed-load.test.ts +158 -0
- package/src/test/validate.test.ts +261 -0
- package/tsconfig.json +16 -16
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +33 -33
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -116
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -116
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov-report/validate.ts.html +0 -325
- package/coverage/lcov.info +0 -80
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/validate.ts.html +0 -325
- package/dist/commands/seed-load.d.ts +0 -8
- package/dist/commands/seed-load.d.ts.map +0 -1
- package/dist/commands/seed-load.js +0 -89
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/schema-diff.d.ts +0 -15
- package/dist/lib/schema-diff.d.ts.map +0 -1
- package/dist/lib/schema-diff.js +0 -37
- package/dist/lib/wrangler.d.ts +0 -17
- package/dist/lib/wrangler.d.ts.map +0 -1
- package/dist/lib/wrangler.js +0 -65
package/src/commands/validate.ts
CHANGED
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
-
|
|
4
|
-
import pc from 'picocolors'
|
|
5
|
-
import type { Seed } from '@beechcms/core'
|
|
6
|
-
import { SEED_REGISTRY, validateSeedDefinitions } from '@beechcms/core'
|
|
7
|
-
|
|
8
|
-
export interface ValidateOptions {
|
|
9
|
-
registry?: Record<string, Seed> | null
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface SeedValidationError {
|
|
13
|
-
slug: string
|
|
14
|
-
messages: string[]
|
|
15
|
-
/** true = abort seed:load; false = warning only */
|
|
16
|
-
fatal: boolean
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function validateSeeds(registry: Record<string, Seed>): SeedValidationError[] {
|
|
20
|
-
return validateSeedDefinitions(Object.values(registry))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function validate(args: ValidateOptions): Promise<void> {
|
|
24
|
-
const registry = args.registry ?? SEED_REGISTRY
|
|
25
|
-
|
|
26
|
-
if (Object.keys(registry).length === 0) {
|
|
27
|
-
console.warn(pc.yellow('\n Warning: SEED_REGISTRY is empty. Create a seeds.ts in your project root.\n'))
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
console.log(pc.cyan('\n beech validate — checking seeds\n'))
|
|
32
|
-
|
|
33
|
-
const errors = validateSeeds(registry)
|
|
34
|
-
const fatalErrors = errors.filter(e => e.fatal)
|
|
35
|
-
const warnings = errors.filter(e => !e.fatal)
|
|
36
|
-
|
|
37
|
-
// Print fatal errors first
|
|
38
|
-
for (const e of fatalErrors) {
|
|
39
|
-
console.log(pc.red(` ✗ ${e.slug} (fatal)`))
|
|
40
|
-
for (const msg of e.messages) {
|
|
41
|
-
console.log(pc.red(` → ${msg}`))
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Print per-seed warnings
|
|
46
|
-
const warningMap = new Map(warnings.map(e => [e.slug, e.messages]))
|
|
47
|
-
const allWarningSlugsSeen = new Set(warnings.map(e => e.slug))
|
|
48
|
-
|
|
49
|
-
for (const seed of Object.values(registry)) {
|
|
50
|
-
const msgs = warningMap.get(seed.slug)
|
|
51
|
-
if (!msgs) {
|
|
52
|
-
if (!allWarningSlugsSeen.has(seed.slug)) {
|
|
53
|
-
// only print ✓ if no fatal error for this slug either
|
|
54
|
-
const hasFatal = fatalErrors.some(e => e.slug === seed.slug)
|
|
55
|
-
if (!hasFatal) console.log(pc.green(` ✓ ${seed.slug}`))
|
|
56
|
-
}
|
|
57
|
-
} else {
|
|
58
|
-
console.log(pc.yellow(` ⚠ ${seed.slug}`))
|
|
59
|
-
for (const msg of msgs) {
|
|
60
|
-
console.log(pc.yellow(` → ${msg}`))
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
console.log('')
|
|
66
|
-
|
|
67
|
-
const totalFatal = fatalErrors.reduce((n, e) => n + e.messages.length, 0)
|
|
68
|
-
const totalWarnings = warnings.reduce((n, e) => n + e.messages.length, 0)
|
|
69
|
-
|
|
70
|
-
if (totalFatal > 0) {
|
|
71
|
-
const s = totalFatal !== 1 ? 's' : ''
|
|
72
|
-
console.log(pc.red(` Found ${totalFatal} fatal error${s}. Fix before loading.\n`))
|
|
73
|
-
process.exit(1)
|
|
74
|
-
} else if (totalWarnings > 0) {
|
|
75
|
-
const s = totalWarnings !== 1 ? 's' : ''
|
|
76
|
-
console.log(pc.yellow(` Found ${totalWarnings} warning${s}. Review seeds above.\n`))
|
|
77
|
-
} else {
|
|
78
|
-
console.log(pc.green(' All seeds valid.\n'))
|
|
79
|
-
}
|
|
80
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import pc from 'picocolors'
|
|
5
|
+
import type { Seed } from '@beechcms/core'
|
|
6
|
+
import { SEED_REGISTRY, validateSeedDefinitions } from '@beechcms/core'
|
|
7
|
+
|
|
8
|
+
export interface ValidateOptions {
|
|
9
|
+
registry?: Record<string, Seed> | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SeedValidationError {
|
|
13
|
+
slug: string
|
|
14
|
+
messages: string[]
|
|
15
|
+
/** true = abort seed:load; false = warning only */
|
|
16
|
+
fatal: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateSeeds(registry: Record<string, Seed>): SeedValidationError[] {
|
|
20
|
+
return validateSeedDefinitions(Object.values(registry))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function validate(args: ValidateOptions): Promise<void> {
|
|
24
|
+
const registry = args.registry ?? SEED_REGISTRY
|
|
25
|
+
|
|
26
|
+
if (Object.keys(registry).length === 0) {
|
|
27
|
+
console.warn(pc.yellow('\n Warning: SEED_REGISTRY is empty. Create a seeds.ts in your project root.\n'))
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(pc.cyan('\n beech validate — checking seeds\n'))
|
|
32
|
+
|
|
33
|
+
const errors = validateSeeds(registry)
|
|
34
|
+
const fatalErrors = errors.filter(e => e.fatal)
|
|
35
|
+
const warnings = errors.filter(e => !e.fatal)
|
|
36
|
+
|
|
37
|
+
// Print fatal errors first
|
|
38
|
+
for (const e of fatalErrors) {
|
|
39
|
+
console.log(pc.red(` ✗ ${e.slug} (fatal)`))
|
|
40
|
+
for (const msg of e.messages) {
|
|
41
|
+
console.log(pc.red(` → ${msg}`))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Print per-seed warnings
|
|
46
|
+
const warningMap = new Map(warnings.map(e => [e.slug, e.messages]))
|
|
47
|
+
const allWarningSlugsSeen = new Set(warnings.map(e => e.slug))
|
|
48
|
+
|
|
49
|
+
for (const seed of Object.values(registry)) {
|
|
50
|
+
const msgs = warningMap.get(seed.slug)
|
|
51
|
+
if (!msgs) {
|
|
52
|
+
if (!allWarningSlugsSeen.has(seed.slug)) {
|
|
53
|
+
// only print ✓ if no fatal error for this slug either
|
|
54
|
+
const hasFatal = fatalErrors.some(e => e.slug === seed.slug)
|
|
55
|
+
if (!hasFatal) console.log(pc.green(` ✓ ${seed.slug}`))
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.log(pc.yellow(` ⚠ ${seed.slug}`))
|
|
59
|
+
for (const msg of msgs) {
|
|
60
|
+
console.log(pc.yellow(` → ${msg}`))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('')
|
|
66
|
+
|
|
67
|
+
const totalFatal = fatalErrors.reduce((n, e) => n + e.messages.length, 0)
|
|
68
|
+
const totalWarnings = warnings.reduce((n, e) => n + e.messages.length, 0)
|
|
69
|
+
|
|
70
|
+
if (totalFatal > 0) {
|
|
71
|
+
const s = totalFatal !== 1 ? 's' : ''
|
|
72
|
+
console.log(pc.red(` Found ${totalFatal} fatal error${s}. Fix before loading.\n`))
|
|
73
|
+
process.exit(1)
|
|
74
|
+
} else if (totalWarnings > 0) {
|
|
75
|
+
const s = totalWarnings !== 1 ? 's' : ''
|
|
76
|
+
console.log(pc.yellow(` Found ${totalWarnings} warning${s}. Review seeds above.\n`))
|
|
77
|
+
} else {
|
|
78
|
+
console.log(pc.green(' All seeds valid.\n'))
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
-
|
|
4
|
-
export { seedLoad } from './commands/seed-load.js'
|
|
5
|
-
export type { SeedLoadOptions } from './commands/seed-load.js'
|
|
6
|
-
export { init } from './commands/init.js'
|
|
7
|
-
export type { InitOptions } from './commands/init.js'
|
|
8
|
-
export { validate, validateSeeds } from './commands/validate.js'
|
|
9
|
-
export type { ValidateOptions, SeedValidationError } from './commands/validate.js'
|
|
10
|
-
export { seedCreate } from './commands/seed-create.js'
|
|
11
|
-
export type { SeedCreateOptions } from './commands/seed-create.js'
|
|
12
|
-
export { deploy } from './commands/deploy.js'
|
|
13
|
-
export type { DeployOptions } from './commands/deploy.js'
|
|
14
|
-
export { onboard } from './commands/onboard.js'
|
|
15
|
-
export type { OnboardOptions } from './commands/onboard.js'
|
|
16
|
-
export { update } from './commands/update.js'
|
|
17
|
-
export type { UpdateOptions } from './commands/update.js'
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
export { seedLoad } from './commands/seed-load.js'
|
|
5
|
+
export type { SeedLoadOptions } from './commands/seed-load.js'
|
|
6
|
+
export { init } from './commands/init.js'
|
|
7
|
+
export type { InitOptions } from './commands/init.js'
|
|
8
|
+
export { validate, validateSeeds } from './commands/validate.js'
|
|
9
|
+
export type { ValidateOptions, SeedValidationError } from './commands/validate.js'
|
|
10
|
+
export { seedCreate } from './commands/seed-create.js'
|
|
11
|
+
export type { SeedCreateOptions } from './commands/seed-create.js'
|
|
12
|
+
export { deploy } from './commands/deploy.js'
|
|
13
|
+
export type { DeployOptions } from './commands/deploy.js'
|
|
14
|
+
export { onboard } from './commands/onboard.js'
|
|
15
|
+
export type { OnboardOptions } from './commands/onboard.js'
|
|
16
|
+
export { update } from './commands/update.js'
|
|
17
|
+
export type { UpdateOptions } from './commands/update.js'
|
|
18
|
+
export { reset } from './commands/reset.js'
|
|
19
|
+
export type { ResetOptions } from './commands/reset.js'
|
|
20
|
+
export { generateTypes } from './commands/generate-types.js'
|
|
21
|
+
export type { GenerateTypesOptions } from './commands/generate-types.js'
|
|
22
|
+
export { schemaDiff } from './commands/schema-diff.js'
|
|
23
|
+
export type { SchemaDiffOptions } from './commands/schema-diff.js'
|
|
24
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import {
|
|
7
|
+
generateAddColumn,
|
|
8
|
+
generateIndexes,
|
|
9
|
+
planCreateSeed,
|
|
10
|
+
type Seed,
|
|
11
|
+
} from '@beechcms/core'
|
|
12
|
+
import type { SeedDiff } from './schema-diff.js'
|
|
13
|
+
|
|
14
|
+
/** Statuses we will NOT auto-migrate — they need human review in a hand-written file. */
|
|
15
|
+
const DESTRUCTIVE: ReadonlySet<SeedDiff['columns'][number]['status']> = new Set([
|
|
16
|
+
'extra', 'type_mismatch', 'fk_mismatch',
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
export interface MigrationPlan {
|
|
20
|
+
/** Full SQL file body (may be empty if no additive changes). */
|
|
21
|
+
sql: string
|
|
22
|
+
/** Number of executable (additive) statements emitted. */
|
|
23
|
+
additiveCount: number
|
|
24
|
+
/** Slugs whose drift includes destructive changes that were NOT emitted. */
|
|
25
|
+
destructiveSlugs: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Scans a migrations dir and returns the next zero-padded 4-digit prefix (e.g. "0034"). */
|
|
29
|
+
export function nextMigrationIndex(migrationsDir: string): string {
|
|
30
|
+
if (!existsSync(migrationsDir)) return '0000'
|
|
31
|
+
let max = -1
|
|
32
|
+
for (const f of readdirSync(migrationsDir)) {
|
|
33
|
+
const m = /^(\d{4})_/.exec(f)
|
|
34
|
+
if (m) max = Math.max(max, Number(m[1]))
|
|
35
|
+
}
|
|
36
|
+
return String(max + 1).padStart(4, '0')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build an ADDITIVE migration from already-computed diffs.
|
|
41
|
+
* - tableExists === false → full planCreateSeed(seed)
|
|
42
|
+
* - status 'missing' → generateAddColumn(seed, branch)
|
|
43
|
+
* - status 'index_missing' → matching CREATE INDEX IF NOT EXISTS line
|
|
44
|
+
* - destructive statuses → emitted as a commented -- ⚠ block, never executable
|
|
45
|
+
* `seeds` MUST be dependency-sorted by the caller (sortSeedsByDependencies).
|
|
46
|
+
*/
|
|
47
|
+
export function buildMigrationSql(
|
|
48
|
+
diffs: SeedDiff[],
|
|
49
|
+
registry: Record<string, Seed>,
|
|
50
|
+
): MigrationPlan {
|
|
51
|
+
const lines: string[] = []
|
|
52
|
+
let additiveCount = 0
|
|
53
|
+
const destructiveSlugs: string[] = []
|
|
54
|
+
|
|
55
|
+
for (const diff of diffs) {
|
|
56
|
+
const seed = registry[diff.slug]
|
|
57
|
+
if (!seed) continue
|
|
58
|
+
|
|
59
|
+
if (!diff.tableExists) {
|
|
60
|
+
lines.push(`-- ${diff.slug}: create table from scratch`)
|
|
61
|
+
for (const stmt of planCreateSeed(seed)) { lines.push(stmt); additiveCount++ }
|
|
62
|
+
lines.push('')
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const missing = diff.columns.filter(c => c.status === 'missing')
|
|
67
|
+
const idxMissing = diff.columns.filter(c => c.status === 'index_missing')
|
|
68
|
+
const destructive = diff.columns.filter(c => DESTRUCTIVE.has(c.status))
|
|
69
|
+
|
|
70
|
+
if (missing.length || idxMissing.length) {
|
|
71
|
+
lines.push(`-- ${diff.slug}: additive changes`)
|
|
72
|
+
for (const col of missing) {
|
|
73
|
+
const branch = seed.branches.find(b => b.alias === col.name)
|
|
74
|
+
if (branch) { lines.push(generateAddColumn(seed, branch)); additiveCount++ }
|
|
75
|
+
}
|
|
76
|
+
// generateIndexes is idempotent (CREATE INDEX IF NOT EXISTS) — re-emitting is safe.
|
|
77
|
+
if (idxMissing.length) {
|
|
78
|
+
for (const stmt of generateIndexes(seed)) { lines.push(stmt); additiveCount++ }
|
|
79
|
+
}
|
|
80
|
+
lines.push('')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (destructive.length) {
|
|
84
|
+
destructiveSlugs.push(diff.slug)
|
|
85
|
+
lines.push(`-- ⚠ ${diff.slug}: DESTRUCTIVE drift NOT auto-migrated — review manually:`)
|
|
86
|
+
for (const col of destructive) {
|
|
87
|
+
lines.push(`-- ${col.status}: ${col.name}` +
|
|
88
|
+
(col.actualType ? ` (db: ${col.actualType})` : ''))
|
|
89
|
+
}
|
|
90
|
+
lines.push('')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { sql: lines.join('\n').trimEnd() + '\n', additiveCount, destructiveSlugs }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Writes the migration file and returns its absolute path. */
|
|
98
|
+
export function writeMigrationFile(
|
|
99
|
+
migrationsDir: string, index: string, name: string, sql: string,
|
|
100
|
+
): string {
|
|
101
|
+
if (!existsSync(migrationsDir)) mkdirSync(migrationsDir, { recursive: true })
|
|
102
|
+
const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'schema_sync'
|
|
103
|
+
const file = join(migrationsDir, `${index}_${safe}.sql`)
|
|
104
|
+
writeFileSync(file, sql, 'utf-8')
|
|
105
|
+
return file
|
|
106
|
+
}
|
package/src/lib/schema-diff.ts
CHANGED
|
@@ -1,150 +1,175 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import pc from 'picocolors'
|
|
5
|
+
import type { Seed } from '@beechcms/core'
|
|
6
|
+
import { getExpectedColumns } from '@beechcms/core'
|
|
7
|
+
import type { WranglerOptions, D1Row } from './wrangler.js'
|
|
8
|
+
import { queryD1 } from './wrangler.js'
|
|
9
|
+
|
|
10
|
+
interface PragmaRow extends D1Row {
|
|
11
|
+
name: string
|
|
12
|
+
type: string
|
|
13
|
+
notnull: number
|
|
14
|
+
pk: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FkRow extends D1Row {
|
|
18
|
+
id: number
|
|
19
|
+
seq: number
|
|
20
|
+
table: string
|
|
21
|
+
from: string
|
|
22
|
+
to: string
|
|
23
|
+
on_update: string
|
|
24
|
+
on_delete: string
|
|
25
|
+
match: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface IndexRow extends D1Row {
|
|
29
|
+
seq: number
|
|
30
|
+
name: string
|
|
31
|
+
unique: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ColumnDiff {
|
|
35
|
+
name: string
|
|
36
|
+
status: 'ok' | 'missing' | 'extra' | 'type_mismatch' | 'fk_missing' | 'fk_mismatch' | 'index_missing'
|
|
37
|
+
expectedType?: string
|
|
38
|
+
actualType?: string
|
|
39
|
+
/** For fk_missing/fk_mismatch: expected FK target table */
|
|
40
|
+
expectedTarget?: string
|
|
41
|
+
/** For fk_mismatch: what the DB actually has */
|
|
42
|
+
expected?: string
|
|
43
|
+
actual?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SeedDiff {
|
|
47
|
+
slug: string
|
|
48
|
+
tableExists: boolean
|
|
49
|
+
columns: ColumnDiff[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Returns true if the seed's table fully matches its Seed (no drift). */
|
|
53
|
+
export function isSeedClean(diff: SeedDiff): boolean {
|
|
54
|
+
return diff.tableExists && diff.columns.every(c => c.status === 'ok')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Human-readable drift report for one seed. Pure formatting — no I/O decisions. */
|
|
58
|
+
export function renderSeedDiff(diff: SeedDiff): void {
|
|
59
|
+
const table = `content_${diff.slug}`
|
|
60
|
+
if (!diff.tableExists) { console.log(pc.red(` ✗ ${table} — table missing`)); return }
|
|
61
|
+
const problems = diff.columns.filter(c => c.status !== 'ok')
|
|
62
|
+
if (problems.length === 0) { console.log(pc.green(` ✓ ${table}`)); return }
|
|
63
|
+
console.log(pc.yellow(` ⚠ ${table}`))
|
|
64
|
+
for (const col of problems) {
|
|
65
|
+
switch (col.status) {
|
|
66
|
+
case 'missing': console.log(pc.red(` + missing column: ${col.name} ${col.expectedType}`)); break
|
|
67
|
+
case 'extra': console.log(pc.dim(` ~ orphaned column: "${col.name}" (${col.actualType}) — in DB, not in seeds.ts`)); break
|
|
68
|
+
case 'type_mismatch': console.log(pc.red(` ≠ type mismatch: ${col.name} (expected ${col.expectedType}, got ${col.actualType})`)); break
|
|
69
|
+
case 'fk_missing': console.log(pc.red(` ⤬ missing FK: ${col.name} → content_${col.expectedTarget}(id)`)); break
|
|
70
|
+
case 'fk_mismatch': console.log(pc.yellow(` ⤬ FK mismatch: ${col.name} expected ${col.expected}, got ${col.actual}`)); break
|
|
71
|
+
case 'index_missing': console.log(pc.yellow(` ⊘ missing index on ${col.name}`)); break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function diffSeed(seed: Seed, options: WranglerOptions): Promise<SeedDiff> {
|
|
77
|
+
const tableName = `content_${seed.slug}`
|
|
78
|
+
const expected = getExpectedColumns(seed)
|
|
79
|
+
|
|
80
|
+
let actual: PragmaRow[]
|
|
81
|
+
try {
|
|
82
|
+
actual = queryD1<PragmaRow>(`PRAGMA table_info(${tableName})`, options)
|
|
83
|
+
} catch {
|
|
84
|
+
return { slug: seed.slug, tableExists: false, columns: expected.map(c => ({ name: c.name, status: 'missing', expectedType: c.sqlType })) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (actual.length === 0) {
|
|
88
|
+
return { slug: seed.slug, tableExists: false, columns: expected.map(c => ({ name: c.name, status: 'missing', expectedType: c.sqlType })) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const actualMap = new Map<string, PragmaRow>(actual.map(r => [r.name, r]))
|
|
92
|
+
const expectedSet = new Set<string>(expected.map(c => c.name))
|
|
93
|
+
|
|
94
|
+
const columns: ColumnDiff[] = []
|
|
95
|
+
|
|
96
|
+
// ── Column presence + type checks ────────────────────────────────────────
|
|
97
|
+
for (const col of expected) {
|
|
98
|
+
const actualRow = actualMap.get(col.name)
|
|
99
|
+
if (!actualRow) {
|
|
100
|
+
columns.push({ name: col.name, status: 'missing', expectedType: col.sqlType })
|
|
101
|
+
} else if (actualRow.type.toUpperCase() !== col.sqlType) {
|
|
102
|
+
columns.push({ name: col.name, status: 'type_mismatch', expectedType: col.sqlType, actualType: actualRow.type })
|
|
103
|
+
} else {
|
|
104
|
+
columns.push({ name: col.name, status: 'ok' })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const row of actual) {
|
|
109
|
+
if (!expectedSet.has(row.name)) {
|
|
110
|
+
columns.push({ name: row.name, status: 'extra', actualType: row.type })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── FK + index checks for relation branches ──────────────────────────────
|
|
115
|
+
const relationBranches = seed.branches.filter(b => b.type === 'relation' && b.targetSeed)
|
|
116
|
+
|
|
117
|
+
if (relationBranches.length > 0) {
|
|
118
|
+
let fkList: FkRow[] = []
|
|
119
|
+
let indexList: IndexRow[] = []
|
|
120
|
+
try {
|
|
121
|
+
fkList = queryD1<FkRow>(`PRAGMA foreign_key_list(${tableName})`, options)
|
|
122
|
+
indexList = queryD1<IndexRow>(`PRAGMA index_list(${tableName})`, options)
|
|
123
|
+
} catch {
|
|
124
|
+
// If PRAGMA fails (table may not exist yet), skip FK checks
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Build maps for fast lookup
|
|
128
|
+
// fkList has one row per FK column; `from` = local col, `table` = referenced table
|
|
129
|
+
const fkByCol = new Map<string, FkRow>()
|
|
130
|
+
for (const fk of fkList) {
|
|
131
|
+
fkByCol.set(fk.from, fk)
|
|
132
|
+
}
|
|
133
|
+
const indexNames = new Set(indexList.map(i => i.name))
|
|
134
|
+
|
|
135
|
+
for (const branch of relationBranches) {
|
|
136
|
+
const expectedFkTable = `content_${branch.targetSeed}`
|
|
137
|
+
const expectedOnDelete = (branch.onDelete ?? 'SET NULL').toUpperCase()
|
|
138
|
+
const expectedIndexName = `idx_${seed.slug}_${branch.alias}`
|
|
139
|
+
|
|
140
|
+
// Find column diff entry for this branch (already evaluated above)
|
|
141
|
+
const colDiff = columns.find(c => c.name === branch.alias)
|
|
142
|
+
if (!colDiff || colDiff.status === 'missing') continue // already flagged
|
|
143
|
+
|
|
144
|
+
const fk = fkByCol.get(branch.alias)
|
|
145
|
+
|
|
146
|
+
if (!fk) {
|
|
147
|
+
// Column exists but no FK
|
|
148
|
+
colDiff.status = 'fk_missing'
|
|
149
|
+
colDiff.expectedTarget = branch.targetSeed
|
|
150
|
+
} else {
|
|
151
|
+
const actualTable = fk.table
|
|
152
|
+
const actualOnDelete = fk.on_delete.toUpperCase()
|
|
153
|
+
if (actualTable !== expectedFkTable || actualOnDelete !== expectedOnDelete) {
|
|
154
|
+
colDiff.status = 'fk_mismatch'
|
|
155
|
+
colDiff.expected = `→ ${expectedFkTable}(id) ON DELETE ${expectedOnDelete}`
|
|
156
|
+
colDiff.actual = `→ ${actualTable}(id) ON DELETE ${actualOnDelete}`
|
|
157
|
+
colDiff.expectedTarget = branch.targetSeed
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check index separately (can coexist with fk status)
|
|
162
|
+
if (!indexNames.has(expectedIndexName)) {
|
|
163
|
+
// Only add index_missing if the column is otherwise OK (FK issue takes precedence)
|
|
164
|
+
if (colDiff.status === 'ok') {
|
|
165
|
+
colDiff.status = 'index_missing'
|
|
166
|
+
} else {
|
|
167
|
+
// Append index info to the existing diff row as a separate entry
|
|
168
|
+
columns.push({ name: branch.alias, status: 'index_missing' })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { slug: seed.slug, tableExists: true, columns }
|
|
175
|
+
}
|