@beechcms/cli 0.6.0-preview.4 → 0.6.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.
- package/dist/index.js +1069 -732
- package/package.json +7 -3
- package/.turbo/turbo-build.log +0 -5
- package/.turbo/turbo-lint.log +0 -1
- package/coverage/coverage-summary.json +0 -3
- package/src/commands/deploy.ts +0 -126
- package/src/commands/generate-types.ts +0 -65
- package/src/commands/init.ts +0 -599
- package/src/commands/onboard.ts +0 -32
- package/src/commands/reset.ts +0 -157
- package/src/commands/schema-diff.ts +0 -78
- package/src/commands/seed-create.ts +0 -192
- package/src/commands/seed-load.ts +0 -206
- package/src/commands/update.ts +0 -54
- package/src/commands/validate.ts +0 -80
- package/src/index.ts +0 -24
- package/src/lib/migration-writer.ts +0 -106
- package/src/lib/schema-diff.ts +0 -175
- package/src/lib/wrangler.ts +0 -129
- package/src/test/generate-types.test.ts +0 -58
- package/src/test/reset.test.ts +0 -128
- package/src/test/schema-diff.test.ts +0 -232
- package/src/test/seed-load.test.ts +0 -158
- package/src/test/validate.test.ts +0 -261
- package/tsconfig.json +0 -16
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -33
package/src/test/reset.test.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
-
|
|
4
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
5
|
-
import { reset } from '../commands/reset.js'
|
|
6
|
-
import { spawnSync } from 'node:child_process'
|
|
7
|
-
import { existsSync } from 'node:fs'
|
|
8
|
-
|
|
9
|
-
vi.mock('node:child_process', () => ({
|
|
10
|
-
spawnSync: vi.fn(() => ({ status: 0 })),
|
|
11
|
-
}))
|
|
12
|
-
|
|
13
|
-
vi.mock('node:fs', () => ({
|
|
14
|
-
existsSync: vi.fn(),
|
|
15
|
-
rmSync: vi.fn(),
|
|
16
|
-
readFileSync: vi.fn(() => '{}'),
|
|
17
|
-
}))
|
|
18
|
-
|
|
19
|
-
vi.mock('picocolors', () => ({
|
|
20
|
-
default: {
|
|
21
|
-
cyan: (s: string) => s,
|
|
22
|
-
dim: (s: string) => s,
|
|
23
|
-
green: (s: string) => s,
|
|
24
|
-
red: (s: string) => s,
|
|
25
|
-
yellow: (s: string) => s,
|
|
26
|
-
},
|
|
27
|
-
}))
|
|
28
|
-
|
|
29
|
-
describe('reset command', () => {
|
|
30
|
-
beforeEach(() => {
|
|
31
|
-
vi.clearAllMocks()
|
|
32
|
-
vi.mocked(spawnSync).mockImplementation(() => ({ status: 0 } as any))
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('runs docker compose down when --docker is passed and docker is running', async () => {
|
|
36
|
-
await reset({ docker: true })
|
|
37
|
-
expect(spawnSync).toHaveBeenCalledWith(
|
|
38
|
-
'docker',
|
|
39
|
-
['compose', 'down', '-v'],
|
|
40
|
-
expect.any(Object)
|
|
41
|
-
)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('does not run docker compose down when docker is not installed', async () => {
|
|
45
|
-
// Mock docker --version to fail
|
|
46
|
-
vi.mocked(spawnSync).mockImplementation((cmd: string, args?: any) => {
|
|
47
|
-
if (cmd === 'docker' && args && args[0] === '--version') {
|
|
48
|
-
return { status: 1 } as any
|
|
49
|
-
}
|
|
50
|
-
return { status: 0 } as any
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
54
|
-
try {
|
|
55
|
-
await reset({ docker: true })
|
|
56
|
-
expect(spawnSync).not.toHaveBeenCalledWith(
|
|
57
|
-
'docker',
|
|
58
|
-
['compose', 'down', '-v'],
|
|
59
|
-
expect.any(Object)
|
|
60
|
-
)
|
|
61
|
-
expect(mockExit).toHaveBeenCalledWith(1)
|
|
62
|
-
} finally {
|
|
63
|
-
mockExit.mockRestore()
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('does not run docker compose down when docker daemon is not running', async () => {
|
|
68
|
-
// Mock docker info to fail
|
|
69
|
-
vi.mocked(spawnSync).mockImplementation((cmd: string, args?: any) => {
|
|
70
|
-
if (cmd === 'docker' && args && args[0] === 'info') {
|
|
71
|
-
return { status: 1 } as any
|
|
72
|
-
}
|
|
73
|
-
return { status: 0 } as any
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
77
|
-
try {
|
|
78
|
-
await reset({ docker: true })
|
|
79
|
-
expect(spawnSync).not.toHaveBeenCalledWith(
|
|
80
|
-
'docker',
|
|
81
|
-
['compose', 'down', '-v'],
|
|
82
|
-
expect.any(Object)
|
|
83
|
-
)
|
|
84
|
-
expect(mockExit).toHaveBeenCalledWith(1)
|
|
85
|
-
} finally {
|
|
86
|
-
mockExit.mockRestore()
|
|
87
|
-
}
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('runs db reset using npm script when apps/api has package.json', async () => {
|
|
91
|
-
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
92
|
-
if (typeof path === 'string' && path.includes('apps') && path.includes('package.json')) {
|
|
93
|
-
return true
|
|
94
|
-
}
|
|
95
|
-
return false
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
await reset({ db: true })
|
|
99
|
-
expect(spawnSync).toHaveBeenCalledWith(
|
|
100
|
-
'npm',
|
|
101
|
-
['run', 'db:reset:local'],
|
|
102
|
-
expect.objectContaining({
|
|
103
|
-
cwd: expect.stringMatching(/apps[/\\]api/),
|
|
104
|
-
})
|
|
105
|
-
)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('runs both docker and db when --all is passed', async () => {
|
|
109
|
-
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
110
|
-
if (typeof path === 'string' && path.includes('apps') && path.includes('package.json')) {
|
|
111
|
-
return true
|
|
112
|
-
}
|
|
113
|
-
return false
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
await reset({ all: true })
|
|
117
|
-
expect(spawnSync).toHaveBeenCalledWith(
|
|
118
|
-
'docker',
|
|
119
|
-
['compose', 'down', '-v'],
|
|
120
|
-
expect.any(Object)
|
|
121
|
-
)
|
|
122
|
-
expect(spawnSync).toHaveBeenCalledWith(
|
|
123
|
-
'npm',
|
|
124
|
-
['run', 'db:reset:local'],
|
|
125
|
-
expect.any(Object)
|
|
126
|
-
)
|
|
127
|
-
})
|
|
128
|
-
})
|
|
@@ -1,232 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,158 +0,0 @@
|
|
|
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 { sortSeedsByDependencies } from '@beechcms/core'
|
|
6
|
-
import type { Seed } from '@beechcms/core'
|
|
7
|
-
import { sqlQuote } from '../lib/wrangler.js'
|
|
8
|
-
import { buildSeedRegistrationSql } from '../commands/seed-load.js'
|
|
9
|
-
|
|
10
|
-
// These seeds declare articles BEFORE team (arbitrary user order in seed.ts)
|
|
11
|
-
const TEAM_SEED: Seed = {
|
|
12
|
-
slug: 'team',
|
|
13
|
-
label: 'Team',
|
|
14
|
-
displayNameAlias: 'name',
|
|
15
|
-
branches: [{ alias: 'name', label: 'Name', type: 'text' }],
|
|
16
|
-
} as Seed
|
|
17
|
-
|
|
18
|
-
const ARTICLES_SEED: Seed = {
|
|
19
|
-
slug: 'articles',
|
|
20
|
-
label: 'Articles',
|
|
21
|
-
displayNameAlias: 'title',
|
|
22
|
-
branches: [
|
|
23
|
-
{ alias: 'title', label: 'Title', type: 'text' },
|
|
24
|
-
{ alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
|
|
25
|
-
],
|
|
26
|
-
} as Seed
|
|
27
|
-
|
|
28
|
-
describe('sortSeedsByDependencies — topological ordering', () => {
|
|
29
|
-
it('puts team before articles even when articles is declared first', () => {
|
|
30
|
-
// articles declared first — arbitrary insertion order
|
|
31
|
-
const sorted = sortSeedsByDependencies([ARTICLES_SEED, TEAM_SEED])
|
|
32
|
-
const slugs = sorted.map(s => s.slug)
|
|
33
|
-
expect(slugs.indexOf('team')).toBeLessThan(slugs.indexOf('articles'))
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('preserves order for seeds with no relations', () => {
|
|
37
|
-
const a = { slug: 'a', label: 'A', displayNameAlias: 'x', branches: [{ alias: 'x', label: 'X', type: 'text' }] } as Seed
|
|
38
|
-
const b = { slug: 'b', label: 'B', displayNameAlias: 'x', branches: [{ alias: 'x', label: 'X', type: 'text' }] } as Seed
|
|
39
|
-
const sorted = sortSeedsByDependencies([a, b])
|
|
40
|
-
expect(sorted).toHaveLength(2)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('throws on unknown targetSeed', () => {
|
|
44
|
-
const bad: Seed = {
|
|
45
|
-
slug: 'bad',
|
|
46
|
-
label: 'Bad',
|
|
47
|
-
displayNameAlias: 'title',
|
|
48
|
-
branches: [
|
|
49
|
-
{ alias: 'title', label: 'Title', type: 'text' },
|
|
50
|
-
{ alias: 'ref_id', label: 'Ref', type: 'relation', targetSeed: 'ghost' },
|
|
51
|
-
],
|
|
52
|
-
} as Seed
|
|
53
|
-
expect(() => sortSeedsByDependencies([bad])).toThrow(/unknown target|ghost/)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('throws on cyclic graph', () => {
|
|
57
|
-
const a = {
|
|
58
|
-
slug: 'a', label: 'A', displayNameAlias: 'x',
|
|
59
|
-
branches: [
|
|
60
|
-
{ alias: 'x', label: 'X', type: 'text' },
|
|
61
|
-
{ alias: 'b_id', label: 'B', type: 'relation', targetSeed: 'b' },
|
|
62
|
-
],
|
|
63
|
-
} as Seed
|
|
64
|
-
const b = {
|
|
65
|
-
slug: 'b', label: 'B', displayNameAlias: 'x',
|
|
66
|
-
branches: [
|
|
67
|
-
{ alias: 'x', label: 'X', type: 'text' },
|
|
68
|
-
{ alias: 'a_id', label: 'A', type: 'relation', targetSeed: 'a' },
|
|
69
|
-
],
|
|
70
|
-
} as Seed
|
|
71
|
-
expect(() => sortSeedsByDependencies([a, b])).toThrow(/[Cc]ycl/)
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
// ── sqlQuote ─────────────────────────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
describe('sqlQuote', () => {
|
|
78
|
-
it('wraps value in single quotes', () => {
|
|
79
|
-
expect(sqlQuote('hello')).toBe("'hello'")
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it("escapes internal single quotes by doubling them", () => {
|
|
83
|
-
expect(sqlQuote("it's")).toBe("'it''s'")
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('handles multiple single quotes', () => {
|
|
87
|
-
expect(sqlQuote("a'b'c")).toBe("'a''b''c'")
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('handles empty string', () => {
|
|
91
|
-
expect(sqlQuote('')).toBe("''")
|
|
92
|
-
})
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
// ── buildSeedRegistrationSql ─────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
describe('buildSeedRegistrationSql', () => {
|
|
98
|
-
const SIMPLE_SEED: Seed = {
|
|
99
|
-
slug: 'posts',
|
|
100
|
-
label: 'Posts',
|
|
101
|
-
displayNameAlias: 'title',
|
|
102
|
-
branches: [{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' }],
|
|
103
|
-
} as Seed
|
|
104
|
-
|
|
105
|
-
it('produces INSERT … ON CONFLICT for the correct slug', () => {
|
|
106
|
-
const sql = buildSeedRegistrationSql(SIMPLE_SEED)
|
|
107
|
-
expect(sql).toContain("INSERT INTO seeds")
|
|
108
|
-
expect(sql).toContain("ON CONFLICT(slug) DO UPDATE SET")
|
|
109
|
-
expect(sql).toContain("'posts'")
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it("sets source to 'code'", () => {
|
|
113
|
-
const sql = buildSeedRegistrationSql(SIMPLE_SEED)
|
|
114
|
-
expect(sql).toContain("'code'")
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
it('escapes single quotes in slug and JSON literal', () => {
|
|
118
|
-
const seedWithApostrophe: Seed = {
|
|
119
|
-
...SIMPLE_SEED,
|
|
120
|
-
slug: "it's",
|
|
121
|
-
label: "It's",
|
|
122
|
-
}
|
|
123
|
-
const sql = buildSeedRegistrationSql(seedWithApostrophe)
|
|
124
|
-
// Slug value must have its single quote doubled
|
|
125
|
-
expect(sql).toContain("'it''s'")
|
|
126
|
-
// JSON label must also have its single quote doubled
|
|
127
|
-
expect(sql).toContain("It''s")
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('does not contain unescaped single quotes inside the JSON literal', () => {
|
|
131
|
-
const sql = buildSeedRegistrationSql(SIMPLE_SEED)
|
|
132
|
-
const jsonStart = sql.indexOf("VALUES (")
|
|
133
|
-
const jsonPart = sql.slice(jsonStart)
|
|
134
|
-
// Extract the JSON literal between the second pair of outer quotes
|
|
135
|
-
// Verify it round-trips back to the original seed
|
|
136
|
-
const inner = JSON.stringify(SIMPLE_SEED).replace(/'/g, "''")
|
|
137
|
-
expect(sql).toContain(inner)
|
|
138
|
-
})
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
// ── Dry-run output ordering ───────────────────────────────────────────────
|
|
142
|
-
// Verify that seed-load uses sortSeedsByDependencies (not Object.values order)
|
|
143
|
-
// by testing the pure function behavior that underpins it.
|
|
144
|
-
|
|
145
|
-
describe('seed-load dry-run ordering contract', () => {
|
|
146
|
-
it('content_team CREATE TABLE appears before content_articles when articles declared first', () => {
|
|
147
|
-
// The dry-run loops over sortSeedsByDependencies(Object.values(registry)).
|
|
148
|
-
// We verify the sort result here — the integration is in seed-load.ts.
|
|
149
|
-
const registry = {
|
|
150
|
-
articles: ARTICLES_SEED, // declared first
|
|
151
|
-
team: TEAM_SEED,
|
|
152
|
-
}
|
|
153
|
-
const sorted = sortSeedsByDependencies(Object.values(registry))
|
|
154
|
-
const slugs = sorted.map(s => s.slug)
|
|
155
|
-
// team must come first so its CREATE TABLE is emitted before articles'
|
|
156
|
-
expect(slugs.indexOf('team')).toBeLessThan(slugs.indexOf('articles'))
|
|
157
|
-
})
|
|
158
|
-
})
|