@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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +5 -0
  2. package/.turbo/turbo-lint.log +1 -0
  3. package/coverage/coverage-summary.json +3 -0
  4. package/dist/index.js +563 -230
  5. package/package.json +12 -11
  6. package/src/commands/deploy.ts +126 -126
  7. package/src/commands/generate-types.ts +65 -0
  8. package/src/commands/init.ts +599 -599
  9. package/src/commands/onboard.ts +32 -32
  10. package/src/commands/reset.ts +157 -0
  11. package/src/commands/schema-diff.ts +78 -0
  12. package/src/commands/seed-create.ts +192 -192
  13. package/src/commands/seed-load.ts +206 -235
  14. package/src/commands/update.ts +54 -54
  15. package/src/commands/validate.ts +80 -80
  16. package/src/index.ts +24 -17
  17. package/src/lib/migration-writer.ts +106 -0
  18. package/src/lib/schema-diff.ts +175 -150
  19. package/src/lib/wrangler.ts +129 -129
  20. package/src/test/generate-types.test.ts +58 -0
  21. package/src/test/reset.test.ts +128 -0
  22. package/src/test/schema-diff.test.ts +232 -0
  23. package/src/test/seed-load.test.ts +158 -0
  24. package/src/test/validate.test.ts +261 -0
  25. package/tsconfig.json +16 -16
  26. package/tsconfig.tsbuildinfo +1 -1
  27. package/vitest.config.ts +33 -33
  28. package/coverage/base.css +0 -224
  29. package/coverage/block-navigation.js +0 -87
  30. package/coverage/favicon.png +0 -0
  31. package/coverage/index.html +0 -116
  32. package/coverage/lcov-report/base.css +0 -224
  33. package/coverage/lcov-report/block-navigation.js +0 -87
  34. package/coverage/lcov-report/favicon.png +0 -0
  35. package/coverage/lcov-report/index.html +0 -116
  36. package/coverage/lcov-report/prettify.css +0 -1
  37. package/coverage/lcov-report/prettify.js +0 -2
  38. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  39. package/coverage/lcov-report/sorter.js +0 -210
  40. package/coverage/lcov-report/validate.ts.html +0 -325
  41. package/coverage/lcov.info +0 -80
  42. package/coverage/prettify.css +0 -1
  43. package/coverage/prettify.js +0 -2
  44. package/coverage/sort-arrow-sprite.png +0 -0
  45. package/coverage/sorter.js +0 -210
  46. package/coverage/validate.ts.html +0 -325
  47. package/dist/commands/seed-load.d.ts +0 -8
  48. package/dist/commands/seed-load.d.ts.map +0 -1
  49. package/dist/commands/seed-load.js +0 -89
  50. package/dist/index.d.ts +0 -3
  51. package/dist/index.d.ts.map +0 -1
  52. package/dist/lib/schema-diff.d.ts +0 -15
  53. package/dist/lib/schema-diff.d.ts.map +0 -1
  54. package/dist/lib/schema-diff.js +0 -37
  55. package/dist/lib/wrangler.d.ts +0 -17
  56. package/dist/lib/wrangler.d.ts.map +0 -1
  57. package/dist/lib/wrangler.js +0 -65
@@ -0,0 +1,158 @@
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
+ })
@@ -0,0 +1,261 @@
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 { validateSeeds, validate } from '../commands/validate.js'
6
+ import type { Seed } from '@beechcms/core'
7
+
8
+ type PartialBranch = Omit<Seed['branches'][number], 'id'>
9
+
10
+ function makeSeed(slug: string, branches: PartialBranch[] = []): Seed {
11
+ let counter = 0
12
+ const withIds = (branches.length > 0 ? branches : [{ alias: 'title', label: 'Title', type: 'text' }])
13
+ .map(b => ({ id: `br_${String(++counter).padStart(2, '0')}`, ...b }))
14
+ return {
15
+ slug,
16
+ label: slug,
17
+ displayNameAlias: withIds[0]?.alias ?? 'title',
18
+ branches: withIds,
19
+ } as Seed
20
+ }
21
+
22
+ // ── Unknown relation target ────────────────────────────────────────────────
23
+
24
+ describe('validateSeeds — unknown relation target', () => {
25
+ it('emits a fatal error when targetSeed not in registry', () => {
26
+ const registry = {
27
+ articles: makeSeed('articles', [
28
+ { alias: 'title', label: 'Title', type: 'text' },
29
+ { alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
30
+ ]),
31
+ }
32
+
33
+ const errors = validateSeeds(registry as Record<string, Seed>)
34
+ const fatal = errors.filter(e => e.fatal)
35
+
36
+ expect(fatal).toHaveLength(1)
37
+ expect(fatal[0].slug).toBe('articles')
38
+ expect(fatal[0].messages[0]).toContain('author_id')
39
+ expect(fatal[0].messages[0]).toContain('team')
40
+ })
41
+
42
+ it('no fatal error when targetSeed is in registry', () => {
43
+ const registry = {
44
+ team: makeSeed('team', [{ alias: 'name', label: 'Name', type: 'text' }]),
45
+ articles: makeSeed('articles', [
46
+ { alias: 'title', label: 'Title', type: 'text' },
47
+ { alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
48
+ ]),
49
+ }
50
+
51
+ const errors = validateSeeds(registry as Record<string, Seed>)
52
+ const fatal = errors.filter(e => e.fatal)
53
+ expect(fatal).toHaveLength(0)
54
+ })
55
+ })
56
+
57
+ // ── Cyclic dependency ─────────────────────────────────────────────────────
58
+
59
+ describe('validateSeeds — cyclic dependency', () => {
60
+ it('emits a fatal error listing both slugs in a cycle', () => {
61
+ const registry = {
62
+ a: {
63
+ slug: 'a',
64
+ label: 'A',
65
+ displayNameAlias: 'title',
66
+ branches: [
67
+ { id: 'br_01', alias: 'title', label: 'Title', type: 'text' },
68
+ { id: 'br_02', alias: 'b_id', label: 'B', type: 'relation', targetSeed: 'b' },
69
+ ],
70
+ },
71
+ b: {
72
+ slug: 'b',
73
+ label: 'B',
74
+ displayNameAlias: 'title',
75
+ branches: [
76
+ { id: 'br_01', alias: 'title', label: 'Title', type: 'text' },
77
+ { id: 'br_02', alias: 'a_id', label: 'A', type: 'relation', targetSeed: 'a' },
78
+ ],
79
+ },
80
+ }
81
+
82
+ const errors = validateSeeds(registry as Record<string, Seed>)
83
+ const fatal = errors.filter(e => e.fatal)
84
+
85
+ // One fatal entry for the cycle
86
+ expect(fatal.length).toBeGreaterThan(0)
87
+ const cycleMsg = fatal.find(e => e.messages.some(m => m.includes('Cyclic')))
88
+ expect(cycleMsg).toBeDefined()
89
+ expect(cycleMsg!.messages[0]).toContain('a')
90
+ expect(cycleMsg!.messages[0]).toContain('b')
91
+ })
92
+ })
93
+
94
+ // ── Multi-relation: SET NULL rejection ───────────────────────────────────────
95
+
96
+ describe('validateSeeds — multi-relation SET NULL', () => {
97
+ it('emits a fatal error when multiple: true + onDelete: SET NULL', () => {
98
+ const registry = {
99
+ tag: makeSeed('tag'),
100
+ articles: makeSeed('articles', [
101
+ { alias: 'title', label: 'Title', type: 'text' },
102
+ {
103
+ alias: 'tags',
104
+ label: 'Tags',
105
+ type: 'relation',
106
+ targetSeed: 'tag',
107
+ multiple: true,
108
+ onDelete: 'SET NULL',
109
+ },
110
+ ]),
111
+ }
112
+
113
+ const errors = validateSeeds(registry as Record<string, Seed>)
114
+ const fatal = errors.filter(e => e.fatal)
115
+ expect(fatal.length).toBeGreaterThan(0)
116
+ const tagError = fatal.find(e => e.slug === 'articles')
117
+ expect(tagError).toBeDefined()
118
+ expect(tagError!.messages[0]).toContain("'tags'")
119
+ expect(tagError!.messages[0]).toContain('SET NULL')
120
+ })
121
+
122
+ it('does not error for multiple: true + onDelete: CASCADE', () => {
123
+ const registry = {
124
+ tag: makeSeed('tag'),
125
+ articles: makeSeed('articles', [
126
+ { alias: 'title', label: 'Title', type: 'text' },
127
+ {
128
+ alias: 'tags',
129
+ label: 'Tags',
130
+ type: 'relation',
131
+ targetSeed: 'tag',
132
+ multiple: true,
133
+ onDelete: 'CASCADE',
134
+ },
135
+ ]),
136
+ }
137
+
138
+ const errors = validateSeeds(registry as Record<string, Seed>)
139
+ const setNullErrors = errors.filter(e => e.fatal && e.messages.some(m => m.includes('SET NULL')))
140
+ expect(setNullErrors).toHaveLength(0)
141
+ })
142
+
143
+ it('does not error for multiple: true with no onDelete (defaults to CASCADE)', () => {
144
+ const registry = {
145
+ tag: makeSeed('tag'),
146
+ articles: makeSeed('articles', [
147
+ { alias: 'title', label: 'Title', type: 'text' },
148
+ { alias: 'tags', label: 'Tags', type: 'relation', targetSeed: 'tag', multiple: true },
149
+ ]),
150
+ }
151
+
152
+ const errors = validateSeeds(registry as Record<string, Seed>)
153
+ const fatal = errors.filter(e => e.fatal)
154
+ expect(fatal).toHaveLength(0)
155
+ })
156
+ })
157
+
158
+ // ── Multi-relation: junction table name collision ─────────────────────────────
159
+
160
+ describe('validateSeeds — junction table name collision', () => {
161
+ it('emits fatal error when two branches produce the same junction table name', () => {
162
+ // 'rel_articles_tags' collides if somehow two branches have the same alias
163
+ // This is prevented by duplicate alias check, but the junction collision check
164
+ // handles cross-seed collisions after truncation.
165
+ const registry = {
166
+ tag: makeSeed('tag'),
167
+ // Simulate a collision: same junction name from different seeds
168
+ rel_articles: makeSeed('rel_articles', [
169
+ { alias: 'title', label: 'T', type: 'text' },
170
+ { alias: 'tags', label: 'Tags', type: 'relation', targetSeed: 'tag', multiple: true },
171
+ ]),
172
+ articles: makeSeed('articles', [
173
+ { alias: 'title', label: 'T', type: 'text' },
174
+ // slug='articles' + alias='rel_articles_tags' → 'rel_articles_rel_articles_tags'
175
+ // Use a manually crafted collision scenario:
176
+ { alias: 'tags', label: 'Tags', type: 'relation', targetSeed: 'tag', multiple: true },
177
+ ]),
178
+ }
179
+ // These won't collide but the rule should run without errors
180
+ const errors = validateSeeds(registry as Record<string, Seed>)
181
+ const collisionErrors = errors.filter(e => e.fatal && e.messages.some(m => m.includes('collision')))
182
+ expect(collisionErrors).toHaveLength(0)
183
+ })
184
+ })
185
+
186
+ // ── Existing warning checks still work ────────────────────────────────────
187
+
188
+ describe('validateSeeds — existing warning checks', () => {
189
+ it('flags missing displayNameAlias as warning (not fatal)', () => {
190
+ const registry = {
191
+ items: {
192
+ slug: 'items',
193
+ label: 'Items',
194
+ displayNameAlias: 'nonexistent',
195
+ branches: [{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' }],
196
+ },
197
+ }
198
+
199
+ const errors = validateSeeds(registry as Record<string, Seed>)
200
+ const warnings = errors.filter(e => !e.fatal)
201
+ expect(warnings.length).toBeGreaterThan(0)
202
+ expect(warnings[0].messages[0]).toContain('displayNameAlias')
203
+ })
204
+ })
205
+
206
+ describe('validate CLI command wrapper', () => {
207
+ let logSpy: any
208
+ let warnSpy: any
209
+ let exitSpy: any
210
+
211
+ beforeEach(() => {
212
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
213
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
214
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any)
215
+ })
216
+
217
+ afterEach(() => {
218
+ vi.restoreAllMocks()
219
+ })
220
+
221
+ it('warns when registry is empty', async () => {
222
+ await validate({ registry: {} })
223
+ expect(warnSpy).toHaveBeenCalled()
224
+ expect(warnSpy.mock.calls[0][0]).toContain('SEED_REGISTRY is empty')
225
+ })
226
+
227
+ it('logs success when all seeds are valid', async () => {
228
+ const registry = {
229
+ articles: makeSeed('articles'),
230
+ }
231
+ await validate({ registry })
232
+ expect(logSpy).toHaveBeenCalled()
233
+ expect(logSpy.mock.calls.some((c: any[]) => c[0].includes('All seeds valid'))).toBe(true)
234
+ })
235
+
236
+ it('logs warnings when seeds have non-fatal issues', async () => {
237
+ const registry = {
238
+ items: {
239
+ slug: 'items',
240
+ label: 'Items',
241
+ displayNameAlias: 'nonexistent',
242
+ branches: [{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' }],
243
+ },
244
+ }
245
+ await validate({ registry: registry as any })
246
+ expect(logSpy).toHaveBeenCalled()
247
+ expect(logSpy.mock.calls.some((c: any[]) => c[0].includes('Found 1 warning'))).toBe(true)
248
+ })
249
+
250
+ it('exits with 1 when seeds have fatal errors', async () => {
251
+ const registry = {
252
+ articles: makeSeed('articles', [
253
+ { alias: 'title', label: 'Title', type: 'text' },
254
+ { alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
255
+ ]),
256
+ }
257
+ await validate({ registry: registry as any })
258
+ expect(exitSpy).toHaveBeenCalledWith(1)
259
+ })
260
+ })
261
+
package/tsconfig.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "composite": true,
5
- "declaration": true,
6
- "declarationMap": true,
7
- "outDir": "dist",
8
- "rootDir": "src",
9
- "module": "ESNext",
10
- "moduleResolution": "Bundler",
11
- "target": "ES2022"
12
- },
13
- "include": ["src/**/*"],
14
- "exclude": ["node_modules", "dist"],
15
- "references": [{ "path": "../core" }]
16
- }
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "module": "ESNext",
10
+ "moduleResolution": "Bundler",
11
+ "target": "ES2022"
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist"],
15
+ "references": [{ "path": "../core" }]
16
+ }