@beechcms/cli 0.4.3 → 0.6.0-preview.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/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +116 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +116 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/validate.ts.html +628 -0
- package/coverage/lcov.info +180 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/validate.ts.html +628 -0
- package/dist/index.js +241 -72
- package/package.json +9 -5
- package/src/commands/deploy.ts +3 -0
- package/src/commands/init.ts +45 -3
- package/src/commands/onboard.ts +32 -0
- package/src/commands/seed-create.ts +3 -0
- package/src/commands/seed-load.ts +86 -6
- package/src/commands/update.ts +3 -0
- package/src/commands/validate.ts +38 -41
- package/src/index.ts +5 -0
- package/src/lib/schema-diff.ts +91 -5
- package/src/lib/wrangler.ts +27 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +32 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
1
4
|
import pc from 'picocolors'
|
|
2
5
|
import {
|
|
3
6
|
SEED_REGISTRY,
|
|
@@ -6,9 +9,13 @@ import {
|
|
|
6
9
|
generateIndexes,
|
|
7
10
|
generateFtsTable,
|
|
8
11
|
generateFtsTriggers,
|
|
12
|
+
generateJunctionTable,
|
|
13
|
+
generateJunctionIndexes,
|
|
14
|
+
generateJunctionDraftTable,
|
|
15
|
+
sortSeedsByDependencies,
|
|
9
16
|
type Seed,
|
|
10
17
|
} from '@beechcms/core'
|
|
11
|
-
import { executeD1File, findWranglerConfig, resolveDbName, type WranglerOptions } from '../lib/wrangler.js'
|
|
18
|
+
import { executeD1File, findWranglerConfig, resolveDbName, queryD1, sqlQuote, type WranglerOptions } from '../lib/wrangler.js'
|
|
12
19
|
import { diffSeed } from '../lib/schema-diff.js'
|
|
13
20
|
import { validateSeeds } from './validate.js'
|
|
14
21
|
|
|
@@ -20,6 +27,18 @@ export interface SeedLoadOptions {
|
|
|
20
27
|
registry?: Record<string, Seed> | null
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
export function buildSeedRegistrationSql(seed: Seed): string {
|
|
31
|
+
const json = sqlQuote(JSON.stringify(seed))
|
|
32
|
+
return [
|
|
33
|
+
`INSERT INTO seeds (slug, definition, status, source, created_at, updated_at)`,
|
|
34
|
+
`VALUES (${sqlQuote(seed.slug)}, ${json}, 'active', 'code', unixepoch(), unixepoch())`,
|
|
35
|
+
`ON CONFLICT(slug) DO UPDATE SET definition = excluded.definition, status = 'active', updated_at = excluded.updated_at;`,
|
|
36
|
+
].join('\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SEED_META_BUMP_SQL =
|
|
40
|
+
`UPDATE seed_meta SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE id = 'registry_version';`
|
|
41
|
+
|
|
23
42
|
function buildStatements(seed: Seed): string[] {
|
|
24
43
|
const stmts: string[] = [generateCreateTable(seed), ...generateIndexes(seed)]
|
|
25
44
|
|
|
@@ -31,11 +50,20 @@ function buildStatements(seed: Seed): string[] {
|
|
|
31
50
|
stmts.push(fts, ...generateFtsTriggers(seed))
|
|
32
51
|
}
|
|
33
52
|
|
|
53
|
+
// Many-to-many: junction table + indexes after parent table exists (topological order
|
|
54
|
+
// from sortSeedsByDependencies guarantees the target table also exists at this point).
|
|
55
|
+
for (const branch of seed.branches) {
|
|
56
|
+
if (branch.type !== 'relation' || branch.multiple !== true) continue
|
|
57
|
+
stmts.push(generateJunctionTable(seed, branch), ...generateJunctionIndexes(seed, branch))
|
|
58
|
+
const draftJunction = generateJunctionDraftTable(seed, branch)
|
|
59
|
+
if (draftJunction) stmts.push(draftJunction)
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
return stmts
|
|
35
63
|
}
|
|
36
64
|
|
|
37
65
|
async function runDiff(options: WranglerOptions, registry: Record<string, Seed>): Promise<void> {
|
|
38
|
-
const seeds = Object.values(registry)
|
|
66
|
+
const seeds = sortSeedsByDependencies(Object.values(registry))
|
|
39
67
|
console.log(pc.cyan('\n Diffing schema…\n'))
|
|
40
68
|
|
|
41
69
|
let allOk = true
|
|
@@ -64,6 +92,12 @@ async function runDiff(options: WranglerOptions, registry: Record<string, Seed>)
|
|
|
64
92
|
console.log(pc.dim(` ~ orphaned column: "${col.name}" (${col.actualType}) — exists in DB but not in seeds.ts`))
|
|
65
93
|
} else if (col.status === 'type_mismatch') {
|
|
66
94
|
console.log(pc.red(` ≠ type mismatch: ${col.name} (expected ${col.expectedType}, got ${col.actualType})`))
|
|
95
|
+
} else if (col.status === 'fk_missing') {
|
|
96
|
+
console.log(pc.red(` ⤬ missing FK: ${col.name} → content_${col.expectedTarget}(id)`))
|
|
97
|
+
} else if (col.status === 'fk_mismatch') {
|
|
98
|
+
console.log(pc.yellow(` ⤬ FK mismatch: ${col.name} expected ${col.expected}, got ${col.actual}`))
|
|
99
|
+
} else if (col.status === 'index_missing') {
|
|
100
|
+
console.log(pc.yellow(` ⊘ missing index on ${col.name}`))
|
|
67
101
|
}
|
|
68
102
|
}
|
|
69
103
|
}
|
|
@@ -77,7 +111,7 @@ async function runDiff(options: WranglerOptions, registry: Record<string, Seed>)
|
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
async function runLoad(options: WranglerOptions, dryRun: boolean, registry: Record<string, Seed>): Promise<void> {
|
|
80
|
-
const seeds = Object.values(registry)
|
|
114
|
+
const seeds = sortSeedsByDependencies(Object.values(registry))
|
|
81
115
|
|
|
82
116
|
if (dryRun) {
|
|
83
117
|
console.log(pc.cyan('\n -- dry-run: SQL that would be executed\n'))
|
|
@@ -87,14 +121,18 @@ async function runLoad(options: WranglerOptions, dryRun: boolean, registry: Reco
|
|
|
87
121
|
for (const stmt of stmts) {
|
|
88
122
|
console.log(stmt + '\n')
|
|
89
123
|
}
|
|
124
|
+
console.log(pc.dim(` -- register ${seed.slug} in seeds table`))
|
|
125
|
+
console.log(buildSeedRegistrationSql(seed) + '\n')
|
|
90
126
|
}
|
|
127
|
+
console.log(pc.dim(' -- bump registry_version'))
|
|
128
|
+
console.log(SEED_META_BUMP_SQL + '\n')
|
|
91
129
|
return
|
|
92
130
|
}
|
|
93
131
|
|
|
94
132
|
console.log(pc.cyan(`\n Loading seeds into ${options.local ? 'local' : 'remote'} D1 (${options.db})…\n`))
|
|
95
133
|
|
|
96
134
|
for (const seed of seeds) {
|
|
97
|
-
const stmts = buildStatements(seed)
|
|
135
|
+
const stmts = [...buildStatements(seed), buildSeedRegistrationSql(seed)]
|
|
98
136
|
const sql = stmts.join('\n\n') + '\n'
|
|
99
137
|
process.stdout.write(` ${pc.dim('→')} content_${seed.slug}… `)
|
|
100
138
|
const ok = executeD1File(sql, options)
|
|
@@ -117,7 +155,12 @@ async function runLoad(options: WranglerOptions, dryRun: boolean, registry: Reco
|
|
|
117
155
|
console.log(pc.green('done'))
|
|
118
156
|
}
|
|
119
157
|
|
|
158
|
+
// Bump registry_version so live isolates re-hydrate
|
|
159
|
+
executeD1File(SEED_META_BUMP_SQL, options)
|
|
160
|
+
|
|
120
161
|
console.log(pc.green('\n All seeds loaded.\n'))
|
|
162
|
+
console.log(pc.dim(' Definitions registered in the database.'))
|
|
163
|
+
console.log(pc.dim(' seed.ts is no longer required at runtime — you may keep it for code-first edits or delete it.\n'))
|
|
121
164
|
}
|
|
122
165
|
|
|
123
166
|
export async function seedLoad(args: SeedLoadOptions): Promise<void> {
|
|
@@ -131,8 +174,25 @@ export async function seedLoad(args: SeedLoadOptions): Promise<void> {
|
|
|
131
174
|
}
|
|
132
175
|
|
|
133
176
|
const validationErrors = validateSeeds(registry)
|
|
134
|
-
|
|
135
|
-
|
|
177
|
+
const fatalErrors = validationErrors.filter(e => e.fatal)
|
|
178
|
+
const warnings = validationErrors.filter(e => !e.fatal)
|
|
179
|
+
|
|
180
|
+
if (fatalErrors.length > 0) {
|
|
181
|
+
const total = fatalErrors.reduce((n, e) => n + e.messages.length, 0)
|
|
182
|
+
const s = total !== 1 ? 's' : ''
|
|
183
|
+
console.log(pc.red(`\n ✗ Seed validation found ${total} fatal error${s}. Cannot load schema.\n`))
|
|
184
|
+
for (const e of fatalErrors) {
|
|
185
|
+
console.log(pc.red(` ✗ ${e.slug}`))
|
|
186
|
+
for (const msg of e.messages) {
|
|
187
|
+
console.log(pc.red(` → ${msg}`))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
console.log('')
|
|
191
|
+
process.exit(1)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (warnings.length > 0) {
|
|
195
|
+
const total = warnings.reduce((n, e) => n + e.messages.length, 0)
|
|
136
196
|
const s = total !== 1 ? 's' : ''
|
|
137
197
|
console.log(pc.yellow(`\n ⚠ Seed validation found ${total} issue${s}. Schema changes will still be applied.\n`))
|
|
138
198
|
console.log(pc.dim(' Run "npx beech validate" for details.\n'))
|
|
@@ -147,6 +207,26 @@ export async function seedLoad(args: SeedLoadOptions): Promise<void> {
|
|
|
147
207
|
configPath,
|
|
148
208
|
}
|
|
149
209
|
|
|
210
|
+
if (!args.dryRun && !args.diff) {
|
|
211
|
+
try {
|
|
212
|
+
const rows = queryD1<{ name: string }>(
|
|
213
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name IN ('seeds','seed_meta')`,
|
|
214
|
+
options
|
|
215
|
+
)
|
|
216
|
+
if (rows.length < 2) {
|
|
217
|
+
console.log(pc.red('\n ✗ System tables not found (seeds, seed_meta)\n'))
|
|
218
|
+
console.log(pc.dim(' Run `beech init --db` first to initialise the database.'))
|
|
219
|
+
const flag = args.local ? ' --local' : ''
|
|
220
|
+
console.log(pc.cyan(`\n → Run: npx beech init --db${flag}\n`))
|
|
221
|
+
process.exit(1)
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
console.log(pc.red('\n ✗ Could not query the database\n'))
|
|
225
|
+
console.log(pc.dim(' Run `beech init --db` first to initialise the database.'))
|
|
226
|
+
process.exit(1)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
150
230
|
if (args.diff) {
|
|
151
231
|
await runDiff(options, registry)
|
|
152
232
|
} else {
|
package/src/commands/update.ts
CHANGED
package/src/commands/validate.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
1
4
|
import pc from 'picocolors'
|
|
2
5
|
import type { Seed } from '@beechcms/core'
|
|
3
|
-
import { SEED_REGISTRY } from '@beechcms/core'
|
|
6
|
+
import { SEED_REGISTRY, validateSeedDefinitions } from '@beechcms/core'
|
|
4
7
|
|
|
5
8
|
export interface ValidateOptions {
|
|
6
9
|
registry?: Record<string, Seed> | null
|
|
@@ -9,39 +12,12 @@ export interface ValidateOptions {
|
|
|
9
12
|
export interface SeedValidationError {
|
|
10
13
|
slug: string
|
|
11
14
|
messages: string[]
|
|
15
|
+
/** true = abort seed:load; false = warning only */
|
|
16
|
+
fatal: boolean
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
export function validateSeeds(registry: Record<string, Seed>): SeedValidationError[] {
|
|
15
|
-
|
|
16
|
-
const slugsSeen = new Set<string>()
|
|
17
|
-
|
|
18
|
-
for (const seed of Object.values(registry)) {
|
|
19
|
-
const messages: string[] = []
|
|
20
|
-
|
|
21
|
-
if (slugsSeen.has(seed.slug)) {
|
|
22
|
-
messages.push(`duplicate slug "${seed.slug}" — each seed must have a unique slug`)
|
|
23
|
-
}
|
|
24
|
-
slugsSeen.add(seed.slug)
|
|
25
|
-
|
|
26
|
-
const aliasesSeen = new Set<string>()
|
|
27
|
-
for (const branch of seed.branches) {
|
|
28
|
-
if (aliasesSeen.has(branch.alias)) {
|
|
29
|
-
messages.push(`duplicate branch alias "${branch.alias}"`)
|
|
30
|
-
}
|
|
31
|
-
aliasesSeen.add(branch.alias)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const allAliases = new Set(seed.branches.map(b => b.alias))
|
|
35
|
-
if (!allAliases.has(seed.displayNameAlias)) {
|
|
36
|
-
messages.push(`displayNameAlias "${seed.displayNameAlias}" not found in branches`)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (messages.length > 0) {
|
|
40
|
-
result.push({ slug: seed.slug, messages })
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return result
|
|
20
|
+
return validateSeedDefinitions(Object.values(registry))
|
|
45
21
|
}
|
|
46
22
|
|
|
47
23
|
export async function validate(args: ValidateOptions): Promise<void> {
|
|
@@ -55,28 +31,49 @@ export async function validate(args: ValidateOptions): Promise<void> {
|
|
|
55
31
|
console.log(pc.cyan('\n beech validate — checking seeds\n'))
|
|
56
32
|
|
|
57
33
|
const errors = validateSeeds(registry)
|
|
58
|
-
const
|
|
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))
|
|
59
48
|
|
|
60
|
-
let totalIssues = 0
|
|
61
49
|
for (const seed of Object.values(registry)) {
|
|
62
|
-
const msgs =
|
|
50
|
+
const msgs = warningMap.get(seed.slug)
|
|
63
51
|
if (!msgs) {
|
|
64
|
-
|
|
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
|
+
}
|
|
65
57
|
} else {
|
|
66
|
-
|
|
67
|
-
console.log(pc.red(` ✗ ${seed.slug}`))
|
|
58
|
+
console.log(pc.yellow(` ⚠ ${seed.slug}`))
|
|
68
59
|
for (const msg of msgs) {
|
|
69
|
-
console.log(pc.
|
|
60
|
+
console.log(pc.yellow(` → ${msg}`))
|
|
70
61
|
}
|
|
71
62
|
}
|
|
72
63
|
}
|
|
73
64
|
|
|
74
65
|
console.log('')
|
|
75
66
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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`))
|
|
79
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`))
|
|
80
77
|
} else {
|
|
81
78
|
console.log(pc.green(' All seeds valid.\n'))
|
|
82
79
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
1
4
|
export { seedLoad } from './commands/seed-load.js'
|
|
2
5
|
export type { SeedLoadOptions } from './commands/seed-load.js'
|
|
3
6
|
export { init } from './commands/init.js'
|
|
@@ -8,5 +11,7 @@ export { seedCreate } from './commands/seed-create.js'
|
|
|
8
11
|
export type { SeedCreateOptions } from './commands/seed-create.js'
|
|
9
12
|
export { deploy } from './commands/deploy.js'
|
|
10
13
|
export type { DeployOptions } from './commands/deploy.js'
|
|
14
|
+
export { onboard } from './commands/onboard.js'
|
|
15
|
+
export type { OnboardOptions } from './commands/onboard.js'
|
|
11
16
|
export { update } from './commands/update.js'
|
|
12
17
|
export type { UpdateOptions } from './commands/update.js'
|
package/src/lib/schema-diff.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
1
4
|
import type { Seed } from '@beechcms/core'
|
|
2
5
|
import { getExpectedColumns, type SchemaColumn } from '@beechcms/core'
|
|
3
6
|
import type { WranglerOptions, D1Row } from './wrangler.js'
|
|
@@ -10,11 +13,33 @@ interface PragmaRow extends D1Row {
|
|
|
10
13
|
pk: number
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
interface FkRow extends D1Row {
|
|
17
|
+
id: number
|
|
18
|
+
seq: number
|
|
19
|
+
table: string
|
|
20
|
+
from: string
|
|
21
|
+
to: string
|
|
22
|
+
on_update: string
|
|
23
|
+
on_delete: string
|
|
24
|
+
match: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface IndexRow extends D1Row {
|
|
28
|
+
seq: number
|
|
29
|
+
name: string
|
|
30
|
+
unique: number
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
export interface ColumnDiff {
|
|
14
34
|
name: string
|
|
15
|
-
status: 'ok' | 'missing' | 'extra' | 'type_mismatch'
|
|
35
|
+
status: 'ok' | 'missing' | 'extra' | 'type_mismatch' | 'fk_missing' | 'fk_mismatch' | 'index_missing'
|
|
16
36
|
expectedType?: string
|
|
17
37
|
actualType?: string
|
|
38
|
+
/** For fk_missing/fk_mismatch: expected FK target table */
|
|
39
|
+
expectedTarget?: string
|
|
40
|
+
/** For fk_mismatch: what the DB actually has */
|
|
41
|
+
expected?: string
|
|
42
|
+
actual?: string
|
|
18
43
|
}
|
|
19
44
|
|
|
20
45
|
export interface SeedDiff {
|
|
@@ -43,12 +68,13 @@ export async function diffSeed(seed: Seed, options: WranglerOptions): Promise<Se
|
|
|
43
68
|
|
|
44
69
|
const columns: ColumnDiff[] = []
|
|
45
70
|
|
|
71
|
+
// ── Column presence + type checks ────────────────────────────────────────
|
|
46
72
|
for (const col of expected) {
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
73
|
+
const actualRow = actualMap.get(col.name)
|
|
74
|
+
if (!actualRow) {
|
|
49
75
|
columns.push({ name: col.name, status: 'missing', expectedType: col.sqlType })
|
|
50
|
-
} else if (
|
|
51
|
-
columns.push({ name: col.name, status: 'type_mismatch', expectedType: col.sqlType, actualType:
|
|
76
|
+
} else if (actualRow.type.toUpperCase() !== col.sqlType) {
|
|
77
|
+
columns.push({ name: col.name, status: 'type_mismatch', expectedType: col.sqlType, actualType: actualRow.type })
|
|
52
78
|
} else {
|
|
53
79
|
columns.push({ name: col.name, status: 'ok' })
|
|
54
80
|
}
|
|
@@ -60,5 +86,65 @@ export async function diffSeed(seed: Seed, options: WranglerOptions): Promise<Se
|
|
|
60
86
|
}
|
|
61
87
|
}
|
|
62
88
|
|
|
89
|
+
// ── FK + index checks for relation branches ──────────────────────────────
|
|
90
|
+
const relationBranches = seed.branches.filter(b => b.type === 'relation' && b.targetSeed)
|
|
91
|
+
|
|
92
|
+
if (relationBranches.length > 0) {
|
|
93
|
+
let fkList: FkRow[] = []
|
|
94
|
+
let indexList: IndexRow[] = []
|
|
95
|
+
try {
|
|
96
|
+
fkList = queryD1<FkRow>(`PRAGMA foreign_key_list(${tableName})`, options)
|
|
97
|
+
indexList = queryD1<IndexRow>(`PRAGMA index_list(${tableName})`, options)
|
|
98
|
+
} catch {
|
|
99
|
+
// If PRAGMA fails (table may not exist yet), skip FK checks
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build maps for fast lookup
|
|
103
|
+
// fkList has one row per FK column; `from` = local col, `table` = referenced table
|
|
104
|
+
const fkByCol = new Map<string, FkRow>()
|
|
105
|
+
for (const fk of fkList) {
|
|
106
|
+
fkByCol.set(fk.from, fk)
|
|
107
|
+
}
|
|
108
|
+
const indexNames = new Set(indexList.map(i => i.name))
|
|
109
|
+
|
|
110
|
+
for (const branch of relationBranches) {
|
|
111
|
+
const expectedFkTable = `content_${branch.targetSeed}`
|
|
112
|
+
const expectedOnDelete = (branch.onDelete ?? 'SET NULL').toUpperCase()
|
|
113
|
+
const expectedIndexName = `idx_${seed.slug}_${branch.alias}`
|
|
114
|
+
|
|
115
|
+
// Find column diff entry for this branch (already evaluated above)
|
|
116
|
+
const colDiff = columns.find(c => c.name === branch.alias)
|
|
117
|
+
if (!colDiff || colDiff.status === 'missing') continue // already flagged
|
|
118
|
+
|
|
119
|
+
const fk = fkByCol.get(branch.alias)
|
|
120
|
+
|
|
121
|
+
if (!fk) {
|
|
122
|
+
// Column exists but no FK
|
|
123
|
+
colDiff.status = 'fk_missing'
|
|
124
|
+
colDiff.expectedTarget = branch.targetSeed
|
|
125
|
+
} else {
|
|
126
|
+
const actualTable = fk.table
|
|
127
|
+
const actualOnDelete = fk.on_delete.toUpperCase()
|
|
128
|
+
if (actualTable !== expectedFkTable || actualOnDelete !== expectedOnDelete) {
|
|
129
|
+
colDiff.status = 'fk_mismatch'
|
|
130
|
+
colDiff.expected = `→ ${expectedFkTable}(id) ON DELETE ${expectedOnDelete}`
|
|
131
|
+
colDiff.actual = `→ ${actualTable}(id) ON DELETE ${actualOnDelete}`
|
|
132
|
+
colDiff.expectedTarget = branch.targetSeed
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check index separately (can coexist with fk status)
|
|
137
|
+
if (!indexNames.has(expectedIndexName)) {
|
|
138
|
+
// Only add index_missing if the column is otherwise OK (FK issue takes precedence)
|
|
139
|
+
if (colDiff.status === 'ok') {
|
|
140
|
+
colDiff.status = 'index_missing'
|
|
141
|
+
} else {
|
|
142
|
+
// Append index info to the existing diff row as a separate entry
|
|
143
|
+
columns.push({ name: branch.alias, status: 'index_missing' })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
63
149
|
return { slug: seed.slug, tableExists: true, columns }
|
|
64
150
|
}
|
package/src/lib/wrangler.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
|
|
2
5
|
import { writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
3
6
|
import { tmpdir } from 'node:os'
|
|
4
7
|
import { join, resolve } from 'node:path'
|
|
@@ -40,10 +43,25 @@ export function executeD1File(sql: string, options: WranglerOptions): boolean {
|
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Esegue una query SQL e ritorna i risultati come array di oggetti (--json).
|
|
48
|
+
*
|
|
49
|
+
* Passa il SQL via file temporaneo (`--file`) anziché `--command`: su Windows,
|
|
50
|
+
* `spawnSync` con `shell: true` non preserva le virgolette/spazi/virgole interni
|
|
51
|
+
* a un argomento inline, e wrangler riceve il comando spezzato in token sciolti
|
|
52
|
+
* ("Unknown arguments: name, FROM, sqlite_master, …"). Il file evita del tutto
|
|
53
|
+
* il riquoting della shell — stesso approccio di `executeD1File`.
|
|
54
|
+
*/
|
|
44
55
|
export function queryD1<T extends D1Row = D1Row>(sql: string, options: WranglerOptions): T[] {
|
|
45
|
-
const
|
|
46
|
-
|
|
56
|
+
const tmpFile = join(tmpdir(), `beech-query-${Date.now()}.sql`)
|
|
57
|
+
let result: SpawnSyncReturns<string>
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(tmpFile, sql, 'utf-8')
|
|
60
|
+
const args = ['d1', 'execute', options.db, '--file', tmpFile, '--json', ...buildArgs(options)]
|
|
61
|
+
result = spawnSync('npx', ['wrangler', ...args], { encoding: 'utf-8', cwd: process.cwd(), shell: true })
|
|
62
|
+
} finally {
|
|
63
|
+
try { rmSync(tmpFile) } catch {}
|
|
64
|
+
}
|
|
47
65
|
|
|
48
66
|
if (result.status !== 0) {
|
|
49
67
|
throw new Error(`wrangler d1 execute failed:\n${result.stderr}`)
|
|
@@ -76,6 +94,11 @@ export function findWranglerConfig(): string | null {
|
|
|
76
94
|
return null
|
|
77
95
|
}
|
|
78
96
|
|
|
97
|
+
/** Wraps a string value in single quotes, escaping internal single quotes for SQL literals. */
|
|
98
|
+
export function sqlQuote(value: string): string {
|
|
99
|
+
return `'${value.replace(/'/g, "''")}'`
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
/** Risolve il nome del database D1 da wrangler.jsonc (stripping JSONC comments). */
|
|
80
103
|
export function resolveDbName(configPath: string | null): string {
|
|
81
104
|
if (!configPath) return 'beech-db'
|