@beechcms/cli 0.6.0-preview.3 → 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 +2 -2
- package/.turbo/turbo-lint.log +1 -0
- package/coverage/coverage-summary.json +3 -0
- package/dist/index.js +445 -249
- package/package.json +3 -2
- 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/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 -20
- 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/schema-diff.test.ts +232 -0
- package/src/test/seed-load.test.ts +158 -158
- package/src/test/validate.test.ts +261 -261
- 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/lib/wrangler.ts
CHANGED
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
-
|
|
4
|
-
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
|
|
5
|
-
import { writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
6
|
-
import { tmpdir } from 'node:os'
|
|
7
|
-
import { join, resolve } from 'node:path'
|
|
8
|
-
|
|
9
|
-
export interface WranglerOptions {
|
|
10
|
-
db: string
|
|
11
|
-
local: boolean
|
|
12
|
-
configPath: string | null
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface D1Row {
|
|
16
|
-
[key: string]: unknown
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface WranglerResult {
|
|
20
|
-
results: D1Row[]
|
|
21
|
-
success: boolean
|
|
22
|
-
error?: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function buildArgs(options: WranglerOptions): string[] {
|
|
26
|
-
const args: string[] = []
|
|
27
|
-
if (options.configPath) args.push('--config', options.configPath)
|
|
28
|
-
if (options.local) args.push('--local')
|
|
29
|
-
else args.push('--remote')
|
|
30
|
-
return args
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Esegue SQL da file temporaneo via `wrangler d1 execute --file`. Returns true on success. */
|
|
34
|
-
export function executeD1File(sql: string, options: WranglerOptions): boolean {
|
|
35
|
-
const tmpFile = join(tmpdir(), `beech-seed-${Date.now()}.sql`)
|
|
36
|
-
try {
|
|
37
|
-
writeFileSync(tmpFile, sql, 'utf-8')
|
|
38
|
-
const args = ['d1', 'execute', options.db, '--file', tmpFile, ...buildArgs(options)]
|
|
39
|
-
const result = spawnSync('npx', ['wrangler', ...args], { stdio: 'inherit', cwd: process.cwd(), shell: true })
|
|
40
|
-
return result.status === 0
|
|
41
|
-
} finally {
|
|
42
|
-
try { rmSync(tmpFile) } catch {}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
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
|
-
*/
|
|
55
|
-
export function queryD1<T extends D1Row = D1Row>(sql: string, options: WranglerOptions): T[] {
|
|
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
|
-
}
|
|
65
|
-
|
|
66
|
-
if (result.status !== 0) {
|
|
67
|
-
throw new Error(`wrangler d1 execute failed:\n${result.stderr}`)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
const parsed: WranglerResult[] = JSON.parse(result.stdout)
|
|
72
|
-
return (parsed[0]?.results ?? []) as T[]
|
|
73
|
-
} catch {
|
|
74
|
-
throw new Error(`Failed to parse wrangler JSON output:\n${result.stdout}`)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Trova il path di wrangler.jsonc risalendo l'albero da CWD fino alla root del filesystem. */
|
|
79
|
-
export function findWranglerConfig(): string | null {
|
|
80
|
-
let dir = process.cwd()
|
|
81
|
-
while (true) {
|
|
82
|
-
for (const name of ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']) {
|
|
83
|
-
const p = resolve(dir, name)
|
|
84
|
-
if (existsSync(p)) return p
|
|
85
|
-
}
|
|
86
|
-
for (const name of ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']) {
|
|
87
|
-
const p = resolve(dir, 'apps', 'api', name)
|
|
88
|
-
if (existsSync(p)) return p
|
|
89
|
-
}
|
|
90
|
-
const parent = resolve(dir, '..')
|
|
91
|
-
if (parent === dir) break
|
|
92
|
-
dir = parent
|
|
93
|
-
}
|
|
94
|
-
return null
|
|
95
|
-
}
|
|
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
|
-
|
|
102
|
-
/** Risolve il nome del database D1 da wrangler.jsonc (stripping JSONC comments). */
|
|
103
|
-
export function resolveDbName(configPath: string | null): string {
|
|
104
|
-
if (!configPath) return 'beech-db'
|
|
105
|
-
try {
|
|
106
|
-
const raw = readFileSync(configPath, 'utf-8')
|
|
107
|
-
|
|
108
|
-
if (configPath.endsWith('.toml')) {
|
|
109
|
-
// Basic regex-based TOML parsing for d1_databases
|
|
110
|
-
// Matches both [d1_databases] and [[d1_databases]]
|
|
111
|
-
const d1SectionMatch = raw.match(/\[\[?d1_databases\]\]?[\s\S]*?(?=\n\[|$)/)
|
|
112
|
-
if (d1SectionMatch) {
|
|
113
|
-
const section = d1SectionMatch[0]
|
|
114
|
-
const dbNameMatch = section.match(/database_name\s*=\s*["'](.+?)["']/)
|
|
115
|
-
if (dbNameMatch) return dbNameMatch[1]
|
|
116
|
-
}
|
|
117
|
-
return 'beech-db'
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const stripped = raw
|
|
121
|
-
.replace(/\/\/[^\n]*/g, '')
|
|
122
|
-
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
123
|
-
const parsed = JSON.parse(stripped)
|
|
124
|
-
const bindings: { database_name?: string }[] = parsed?.d1_databases ?? []
|
|
125
|
-
return bindings[0]?.database_name ?? 'beech-db'
|
|
126
|
-
} catch {
|
|
127
|
-
return 'beech-db'
|
|
128
|
-
}
|
|
129
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
|
|
5
|
+
import { writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join, resolve } from 'node:path'
|
|
8
|
+
|
|
9
|
+
export interface WranglerOptions {
|
|
10
|
+
db: string
|
|
11
|
+
local: boolean
|
|
12
|
+
configPath: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface D1Row {
|
|
16
|
+
[key: string]: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WranglerResult {
|
|
20
|
+
results: D1Row[]
|
|
21
|
+
success: boolean
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildArgs(options: WranglerOptions): string[] {
|
|
26
|
+
const args: string[] = []
|
|
27
|
+
if (options.configPath) args.push('--config', options.configPath)
|
|
28
|
+
if (options.local) args.push('--local')
|
|
29
|
+
else args.push('--remote')
|
|
30
|
+
return args
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Esegue SQL da file temporaneo via `wrangler d1 execute --file`. Returns true on success. */
|
|
34
|
+
export function executeD1File(sql: string, options: WranglerOptions): boolean {
|
|
35
|
+
const tmpFile = join(tmpdir(), `beech-seed-${Date.now()}.sql`)
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(tmpFile, sql, 'utf-8')
|
|
38
|
+
const args = ['d1', 'execute', options.db, '--file', tmpFile, ...buildArgs(options)]
|
|
39
|
+
const result = spawnSync('npx', ['wrangler', ...args], { stdio: 'inherit', cwd: process.cwd(), shell: true })
|
|
40
|
+
return result.status === 0
|
|
41
|
+
} finally {
|
|
42
|
+
try { rmSync(tmpFile) } catch {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
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
|
+
*/
|
|
55
|
+
export function queryD1<T extends D1Row = D1Row>(sql: string, options: WranglerOptions): T[] {
|
|
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
|
+
}
|
|
65
|
+
|
|
66
|
+
if (result.status !== 0) {
|
|
67
|
+
throw new Error(`wrangler d1 execute failed:\n${result.stderr}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const parsed: WranglerResult[] = JSON.parse(result.stdout)
|
|
72
|
+
return (parsed[0]?.results ?? []) as T[]
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error(`Failed to parse wrangler JSON output:\n${result.stdout}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Trova il path di wrangler.jsonc risalendo l'albero da CWD fino alla root del filesystem. */
|
|
79
|
+
export function findWranglerConfig(): string | null {
|
|
80
|
+
let dir = process.cwd()
|
|
81
|
+
while (true) {
|
|
82
|
+
for (const name of ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']) {
|
|
83
|
+
const p = resolve(dir, name)
|
|
84
|
+
if (existsSync(p)) return p
|
|
85
|
+
}
|
|
86
|
+
for (const name of ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']) {
|
|
87
|
+
const p = resolve(dir, 'apps', 'api', name)
|
|
88
|
+
if (existsSync(p)) return p
|
|
89
|
+
}
|
|
90
|
+
const parent = resolve(dir, '..')
|
|
91
|
+
if (parent === dir) break
|
|
92
|
+
dir = parent
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
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
|
+
|
|
102
|
+
/** Risolve il nome del database D1 da wrangler.jsonc (stripping JSONC comments). */
|
|
103
|
+
export function resolveDbName(configPath: string | null): string {
|
|
104
|
+
if (!configPath) return 'beech-db'
|
|
105
|
+
try {
|
|
106
|
+
const raw = readFileSync(configPath, 'utf-8')
|
|
107
|
+
|
|
108
|
+
if (configPath.endsWith('.toml')) {
|
|
109
|
+
// Basic regex-based TOML parsing for d1_databases
|
|
110
|
+
// Matches both [d1_databases] and [[d1_databases]]
|
|
111
|
+
const d1SectionMatch = raw.match(/\[\[?d1_databases\]\]?[\s\S]*?(?=\n\[|$)/)
|
|
112
|
+
if (d1SectionMatch) {
|
|
113
|
+
const section = d1SectionMatch[0]
|
|
114
|
+
const dbNameMatch = section.match(/database_name\s*=\s*["'](.+?)["']/)
|
|
115
|
+
if (dbNameMatch) return dbNameMatch[1]
|
|
116
|
+
}
|
|
117
|
+
return 'beech-db'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const stripped = raw
|
|
121
|
+
.replace(/\/\/[^\n]*/g, '')
|
|
122
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
123
|
+
const parsed = JSON.parse(stripped)
|
|
124
|
+
const bindings: { database_name?: string }[] = parsed?.d1_databases ?? []
|
|
125
|
+
return bindings[0]?.database_name ?? 'beech-db'
|
|
126
|
+
} catch {
|
|
127
|
+
return 'beech-db'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import type { Seed } from '@beechcms/core'
|
|
9
|
+
import { generateTypes } from '../commands/generate-types.js'
|
|
10
|
+
|
|
11
|
+
const ARTICLES_SEED: Seed = {
|
|
12
|
+
slug: 'articles',
|
|
13
|
+
label: 'Articles',
|
|
14
|
+
displayNameAlias: 'title',
|
|
15
|
+
branches: [
|
|
16
|
+
{ alias: 'title', label: 'Title', type: 'text', requiredOnCreate: true },
|
|
17
|
+
{ alias: 'body', label: 'Body', type: 'richtext' },
|
|
18
|
+
],
|
|
19
|
+
} as Seed
|
|
20
|
+
|
|
21
|
+
const registry: Record<string, Seed> = { articles: ARTICLES_SEED }
|
|
22
|
+
|
|
23
|
+
const outPath = resolve(tmpdir(), `beech-test-${Date.now()}.ts`)
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (existsSync(outPath)) rmSync(outPath)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('generateTypes — local registry (injected)', () => {
|
|
30
|
+
it('writes the output file', async () => {
|
|
31
|
+
await generateTypes({ out: outPath, local: true, registry })
|
|
32
|
+
expect(existsSync(outPath)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('output contains the interface', async () => {
|
|
36
|
+
await generateTypes({ out: outPath, local: true, registry })
|
|
37
|
+
const content = readFileSync(outPath, 'utf-8')
|
|
38
|
+
expect(content).toContain('export interface Articles {')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('output starts with the auto-generated banner', async () => {
|
|
42
|
+
await generateTypes({ out: outPath, local: true, registry })
|
|
43
|
+
const content = readFileSync(outPath, 'utf-8')
|
|
44
|
+
expect(content).toContain('generato automaticamente')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('output contains SeedRegistryTypes', async () => {
|
|
48
|
+
await generateTypes({ out: outPath, local: true, registry })
|
|
49
|
+
const content = readFileSync(outPath, 'utf-8')
|
|
50
|
+
expect(content).toContain('export interface SeedRegistryTypes {')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('exits with an error when registry is empty', async () => {
|
|
54
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
|
|
55
|
+
await expect(generateTypes({ out: outPath, local: true, registry: {} })).rejects.toThrow('exit')
|
|
56
|
+
exitSpy.mockRestore()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { mkdirSync, readdirSync, rmSync } from 'node:fs'
|
|
8
|
+
import type { Seed } from '@beechcms/core'
|
|
9
|
+
|
|
10
|
+
// ── fixtures ─────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const ARTICLE_SEED: Seed = {
|
|
13
|
+
slug: 'articles',
|
|
14
|
+
label: 'Articles',
|
|
15
|
+
displayNameAlias: 'title',
|
|
16
|
+
branches: [
|
|
17
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
18
|
+
{ alias: 'views', label: 'Views', type: 'number' },
|
|
19
|
+
],
|
|
20
|
+
} as Seed
|
|
21
|
+
|
|
22
|
+
const REGISTRY: Record<string, Seed> = { articles: ARTICLE_SEED }
|
|
23
|
+
|
|
24
|
+
// ── nextMigrationIndex ────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('nextMigrationIndex', () => {
|
|
27
|
+
let dir: string
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
dir = join(tmpdir(), `beech-test-${Date.now()}`)
|
|
31
|
+
mkdirSync(dir, { recursive: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
35
|
+
|
|
36
|
+
it('returns 0000 when directory is empty', async () => {
|
|
37
|
+
const { nextMigrationIndex } = await import('../lib/migration-writer.js')
|
|
38
|
+
expect(nextMigrationIndex(dir)).toBe('0000')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns 0034 when highest prefix is 0033', async () => {
|
|
42
|
+
const { nextMigrationIndex } = await import('../lib/migration-writer.js')
|
|
43
|
+
for (const name of ['0031_foo.sql', '0033_bar.sql', '0029_baz.sql']) {
|
|
44
|
+
const { writeFileSync } = await import('node:fs')
|
|
45
|
+
writeFileSync(join(dir, name), '')
|
|
46
|
+
}
|
|
47
|
+
expect(nextMigrationIndex(dir)).toBe('0034')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns 0000 when directory does not exist', async () => {
|
|
51
|
+
const { nextMigrationIndex } = await import('../lib/migration-writer.js')
|
|
52
|
+
expect(nextMigrationIndex(join(tmpdir(), 'no-such-dir-beech'))).toBe('0000')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ── buildMigrationSql ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('buildMigrationSql — additive emission', () => {
|
|
59
|
+
it('emits planCreateSeed statements when table is missing', async () => {
|
|
60
|
+
const { buildMigrationSql } = await import('../lib/migration-writer.js')
|
|
61
|
+
const diffs = [{ slug: 'articles', tableExists: false, columns: [] }]
|
|
62
|
+
const plan = buildMigrationSql(diffs, REGISTRY)
|
|
63
|
+
expect(plan.additiveCount).toBeGreaterThan(0)
|
|
64
|
+
expect(plan.sql).toContain('articles')
|
|
65
|
+
expect(plan.destructiveSlugs).toHaveLength(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('emits generateAddColumn for missing columns', async () => {
|
|
69
|
+
const { buildMigrationSql } = await import('../lib/migration-writer.js')
|
|
70
|
+
const diffs = [{
|
|
71
|
+
slug: 'articles',
|
|
72
|
+
tableExists: true,
|
|
73
|
+
columns: [
|
|
74
|
+
{ name: 'title', status: 'ok' as const },
|
|
75
|
+
{ name: 'views', status: 'missing' as const, expectedType: 'REAL' },
|
|
76
|
+
],
|
|
77
|
+
}]
|
|
78
|
+
const plan = buildMigrationSql(diffs, REGISTRY)
|
|
79
|
+
expect(plan.additiveCount).toBeGreaterThan(0)
|
|
80
|
+
expect(plan.sql).toContain('views')
|
|
81
|
+
expect(plan.destructiveSlugs).toHaveLength(0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('emits commented block for destructive drift, not executable SQL', async () => {
|
|
85
|
+
const { buildMigrationSql } = await import('../lib/migration-writer.js')
|
|
86
|
+
const diffs = [{
|
|
87
|
+
slug: 'articles',
|
|
88
|
+
tableExists: true,
|
|
89
|
+
columns: [
|
|
90
|
+
{ name: 'title', status: 'type_mismatch' as const, expectedType: 'TEXT', actualType: 'INTEGER' },
|
|
91
|
+
],
|
|
92
|
+
}]
|
|
93
|
+
const plan = buildMigrationSql(diffs, REGISTRY)
|
|
94
|
+
expect(plan.additiveCount).toBe(0)
|
|
95
|
+
expect(plan.destructiveSlugs).toContain('articles')
|
|
96
|
+
expect(plan.sql).toContain('-- ⚠')
|
|
97
|
+
expect(plan.sql).not.toMatch(/^ALTER TABLE/m)
|
|
98
|
+
expect(plan.sql).not.toMatch(/^DROP/m)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('additiveCount is 0 when only destructive drift exists', async () => {
|
|
102
|
+
const { buildMigrationSql } = await import('../lib/migration-writer.js')
|
|
103
|
+
const diffs = [{
|
|
104
|
+
slug: 'articles',
|
|
105
|
+
tableExists: true,
|
|
106
|
+
columns: [
|
|
107
|
+
{ name: 'title', status: 'extra' as const, actualType: 'TEXT' },
|
|
108
|
+
],
|
|
109
|
+
}]
|
|
110
|
+
const plan = buildMigrationSql(diffs, REGISTRY)
|
|
111
|
+
expect(plan.additiveCount).toBe(0)
|
|
112
|
+
expect(plan.destructiveSlugs).toContain('articles')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('processes seeds with no drift as no-op', async () => {
|
|
116
|
+
const { buildMigrationSql } = await import('../lib/migration-writer.js')
|
|
117
|
+
const diffs = [{
|
|
118
|
+
slug: 'articles',
|
|
119
|
+
tableExists: true,
|
|
120
|
+
columns: [
|
|
121
|
+
{ name: 'title', status: 'ok' as const },
|
|
122
|
+
{ name: 'views', status: 'ok' as const },
|
|
123
|
+
],
|
|
124
|
+
}]
|
|
125
|
+
const plan = buildMigrationSql(diffs, REGISTRY)
|
|
126
|
+
expect(plan.additiveCount).toBe(0)
|
|
127
|
+
expect(plan.destructiveSlugs).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// ── schemaDiff command ────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe('schemaDiff command', () => {
|
|
134
|
+
beforeEach(() => { vi.resetModules() })
|
|
135
|
+
|
|
136
|
+
it('exits early when registry is empty', async () => {
|
|
137
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
138
|
+
const { schemaDiff } = await import('../commands/schema-diff.js')
|
|
139
|
+
await schemaDiff({ local: true, write: false, registry: {} })
|
|
140
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No seeds found'))
|
|
141
|
+
consoleSpy.mockRestore()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('prints preview SQL without writing files when no --write', async () => {
|
|
145
|
+
vi.doMock('../lib/wrangler.js', () => ({
|
|
146
|
+
findWranglerConfig: () => null,
|
|
147
|
+
resolveDbName: () => 'beech-db',
|
|
148
|
+
queryD1: () => [],
|
|
149
|
+
}))
|
|
150
|
+
vi.doMock('../lib/schema-diff.js', async () => {
|
|
151
|
+
const actual = await vi.importActual('../lib/schema-diff.js') as object
|
|
152
|
+
return {
|
|
153
|
+
...actual,
|
|
154
|
+
diffSeed: vi.fn().mockResolvedValue({
|
|
155
|
+
slug: 'articles', tableExists: false, columns: [],
|
|
156
|
+
}),
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
161
|
+
const { schemaDiff } = await import('../commands/schema-diff.js')
|
|
162
|
+
await schemaDiff({ local: true, write: false, registry: REGISTRY })
|
|
163
|
+
const output = consoleSpy.mock.calls.flat().join('\n')
|
|
164
|
+
expect(output).toContain('Re-run with --write')
|
|
165
|
+
consoleSpy.mockRestore()
|
|
166
|
+
vi.doUnmock('../lib/wrangler.js')
|
|
167
|
+
vi.doUnmock('../lib/schema-diff.js')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('writes migration file when --write and additive drift exists', async () => {
|
|
171
|
+
const dir = join(tmpdir(), `beech-schema-diff-test-${Date.now()}`)
|
|
172
|
+
mkdirSync(dir, { recursive: true })
|
|
173
|
+
|
|
174
|
+
vi.doMock('../lib/wrangler.js', () => ({
|
|
175
|
+
findWranglerConfig: () => null,
|
|
176
|
+
resolveDbName: () => 'beech-db',
|
|
177
|
+
queryD1: () => [],
|
|
178
|
+
}))
|
|
179
|
+
vi.doMock('../lib/schema-diff.js', async () => {
|
|
180
|
+
const actual = await vi.importActual('../lib/schema-diff.js') as object
|
|
181
|
+
return {
|
|
182
|
+
...actual,
|
|
183
|
+
diffSeed: vi.fn().mockResolvedValue({
|
|
184
|
+
slug: 'articles', tableExists: false, columns: [],
|
|
185
|
+
}),
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
190
|
+
const { schemaDiff } = await import('../commands/schema-diff.js')
|
|
191
|
+
await schemaDiff({ local: true, write: true, name: 'test_migration', migrationsDir: dir, registry: REGISTRY })
|
|
192
|
+
|
|
193
|
+
const files = readdirSync(dir)
|
|
194
|
+
expect(files.some(f => f.endsWith('.sql'))).toBe(true)
|
|
195
|
+
const output = consoleSpy.mock.calls.flat().join('\n')
|
|
196
|
+
expect(output).toContain('Wrote')
|
|
197
|
+
|
|
198
|
+
consoleSpy.mockRestore()
|
|
199
|
+
rmSync(dir, { recursive: true, force: true })
|
|
200
|
+
vi.doUnmock('../lib/wrangler.js')
|
|
201
|
+
vi.doUnmock('../lib/schema-diff.js')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('does not write file when only destructive drift', async () => {
|
|
205
|
+
vi.doMock('../lib/wrangler.js', () => ({
|
|
206
|
+
findWranglerConfig: () => null,
|
|
207
|
+
resolveDbName: () => 'beech-db',
|
|
208
|
+
queryD1: () => [],
|
|
209
|
+
}))
|
|
210
|
+
vi.doMock('../lib/schema-diff.js', async () => {
|
|
211
|
+
const actual = await vi.importActual('../lib/schema-diff.js') as object
|
|
212
|
+
return {
|
|
213
|
+
...actual,
|
|
214
|
+
diffSeed: vi.fn().mockResolvedValue({
|
|
215
|
+
slug: 'articles',
|
|
216
|
+
tableExists: true,
|
|
217
|
+
columns: [{ name: 'title', status: 'type_mismatch', expectedType: 'TEXT', actualType: 'INTEGER' }],
|
|
218
|
+
}),
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
223
|
+
const { schemaDiff } = await import('../commands/schema-diff.js')
|
|
224
|
+
await schemaDiff({ local: true, write: true, registry: REGISTRY })
|
|
225
|
+
const output = consoleSpy.mock.calls.flat().join('\n')
|
|
226
|
+
expect(output).toContain('Only destructive drift')
|
|
227
|
+
|
|
228
|
+
consoleSpy.mockRestore()
|
|
229
|
+
vi.doUnmock('../lib/wrangler.js')
|
|
230
|
+
vi.doUnmock('../lib/schema-diff.js')
|
|
231
|
+
})
|
|
232
|
+
})
|