@actuate-media/cli 0.8.0 → 0.10.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.
@@ -130,6 +130,103 @@ describe('seed document creation', () => {
130
130
  })
131
131
  })
132
132
 
133
+ describe('seed document upsert', () => {
134
+ function makeDb(existing: { id: string; status: string; publishedAt: Date | null } | null) {
135
+ const tx = {
136
+ document: {
137
+ create: vi.fn().mockResolvedValue({ id: 'doc-new' }),
138
+ update: vi.fn().mockResolvedValue({ id: existing?.id ?? 'doc-new' }),
139
+ },
140
+ version: { create: vi.fn() },
141
+ }
142
+ const db = {
143
+ document: { findFirst: vi.fn().mockResolvedValue(existing) },
144
+ $transaction: vi.fn(async (fn: any) => fn(tx)),
145
+ }
146
+ return { db, tx }
147
+ }
148
+
149
+ const doc = {
150
+ collection: 'pages',
151
+ status: 'PUBLISHED',
152
+ data: { title: 'Home v2', slug: 'home' },
153
+ }
154
+
155
+ it('updates an existing document matched by collection + slug', async () => {
156
+ const publishedAt = new Date('2026-01-01T00:00:00Z')
157
+ const { db, tx } = makeDb({ id: 'doc-1', status: 'PUBLISHED', publishedAt })
158
+
159
+ const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
160
+
161
+ expect(result).toBe('updated')
162
+ expect(db.document.findFirst).toHaveBeenCalledWith({
163
+ where: { collection: 'pages', slug: 'home', deletedAt: null },
164
+ select: { id: true, status: true, publishedAt: true },
165
+ })
166
+ expect(tx.document.create).not.toHaveBeenCalled()
167
+ expect(tx.document.update).toHaveBeenCalledWith({
168
+ where: { id: 'doc-1' },
169
+ data: expect.objectContaining({
170
+ title: 'Home v2',
171
+ status: 'PUBLISHED',
172
+ // Re-publishing an already-published doc keeps the original timestamp.
173
+ publishedAt,
174
+ updatedById: 'admin-1',
175
+ }),
176
+ })
177
+ expect(tx.version.create).toHaveBeenCalledWith({
178
+ data: expect.objectContaining({ documentId: 'doc-1', changeType: 'UPDATE' }),
179
+ })
180
+ })
181
+
182
+ it('stamps publishedAt on the transition into PUBLISHED', async () => {
183
+ const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
184
+
185
+ await createSeedDocument(db, 'admin-1', doc, { upsert: true })
186
+
187
+ expect(tx.document.update).toHaveBeenCalledWith(
188
+ expect.objectContaining({
189
+ data: expect.objectContaining({ publishedAt: expect.any(Date) }),
190
+ }),
191
+ )
192
+ })
193
+
194
+ it('creates when no document matches', async () => {
195
+ const { db, tx } = makeDb(null)
196
+
197
+ const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
198
+
199
+ expect(result).toBe('created')
200
+ expect(tx.document.create).toHaveBeenCalled()
201
+ expect(tx.document.update).not.toHaveBeenCalled()
202
+ })
203
+
204
+ it('always creates slugless documents (no upsert key)', async () => {
205
+ const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
206
+
207
+ const result = await createSeedDocument(
208
+ db,
209
+ 'admin-1',
210
+ { collection: 'pages', status: 'DRAFT', data: { title: 'No slug' } },
211
+ { upsert: true },
212
+ )
213
+
214
+ expect(result).toBe('created')
215
+ expect(db.document.findFirst).not.toHaveBeenCalled()
216
+ expect(tx.document.create).toHaveBeenCalled()
217
+ })
218
+
219
+ it('does not look up existing documents without the upsert flag', async () => {
220
+ const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
221
+
222
+ const result = await createSeedDocument(db, 'admin-1', doc)
223
+
224
+ expect(result).toBe('created')
225
+ expect(db.document.findFirst).not.toHaveBeenCalled()
226
+ expect(tx.document.create).toHaveBeenCalled()
227
+ })
228
+ })
229
+
133
230
  describe('demo navigations', () => {
134
231
  // The scaffold site layout fetches menus by the `main` and `footer` slugs, so
135
232
  // the demo seed must provide exactly those — guard against slug drift.
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'
7
7
  import ora from 'ora'
8
8
  import { logger } from '../utils/logger.js'
9
9
  import { connectProjectDatabase } from '../utils/database.js'
10
+ import { validateFormSeeds } from '../utils/form-seed.js'
10
11
 
11
12
  async function confirm(question: string): Promise<boolean> {
12
13
  const rl = createInterface({ input: process.stdin, output: process.stdout })
@@ -119,37 +120,101 @@ const DEMO_POSTS = [
119
120
  },
120
121
  ]
121
122
 
122
- const DEMO_FORMS = [
123
+ // Canonical form seed shape: the form definition lives in document `data`
124
+ // with its own lifecycle `status: 'active'` (gates the public endpoints) and
125
+ // `fields` keyed by { id, key, label, type, required, sortOrder }. See
126
+ // `validateFormSeeds` — these are validated by the same rules as user seeds.
127
+ export const DEMO_FORMS = [
123
128
  {
129
+ name: 'Contact Form',
124
130
  title: 'Contact Form',
125
131
  slug: 'contact-form',
132
+ status: 'active',
126
133
  fields: [
127
- { name: 'name', type: 'text', required: true },
128
- { name: 'email', type: 'email', required: true },
129
- { name: 'message', type: 'textarea', required: true },
134
+ {
135
+ id: 'contact-name',
136
+ key: 'name',
137
+ label: 'Name',
138
+ type: 'text',
139
+ required: true,
140
+ sortOrder: 0,
141
+ },
142
+ {
143
+ id: 'contact-email',
144
+ key: 'email',
145
+ label: 'Email',
146
+ type: 'email',
147
+ required: true,
148
+ sortOrder: 1,
149
+ },
150
+ {
151
+ id: 'contact-message',
152
+ key: 'message',
153
+ label: 'Message',
154
+ type: 'textarea',
155
+ required: true,
156
+ sortOrder: 2,
157
+ },
130
158
  ],
131
- submitLabel: 'Send Message',
132
159
  successMessage: "Thanks for reaching out! We'll get back to you soon.",
133
160
  },
134
161
  {
162
+ name: 'Newsletter Signup',
135
163
  title: 'Newsletter Signup',
136
164
  slug: 'newsletter',
165
+ status: 'active',
137
166
  fields: [
138
- { name: 'email', type: 'email', required: true },
139
- { name: 'firstName', type: 'text', required: false },
167
+ {
168
+ id: 'newsletter-email',
169
+ key: 'email',
170
+ label: 'Email',
171
+ type: 'email',
172
+ required: true,
173
+ sortOrder: 0,
174
+ },
175
+ {
176
+ id: 'newsletter-first-name',
177
+ key: 'firstName',
178
+ label: 'First name',
179
+ type: 'text',
180
+ required: false,
181
+ sortOrder: 1,
182
+ },
140
183
  ],
141
- submitLabel: 'Subscribe',
142
184
  successMessage: "You're subscribed! Check your inbox for confirmation.",
143
185
  },
144
186
  {
187
+ name: 'Feedback Form',
145
188
  title: 'Feedback Form',
146
189
  slug: 'feedback',
190
+ status: 'active',
147
191
  fields: [
148
- { name: 'name', type: 'text', required: false },
149
- { name: 'rating', type: 'select', options: ['1', '2', '3', '4', '5'], required: true },
150
- { name: 'comments', type: 'textarea', required: false },
192
+ {
193
+ id: 'feedback-name',
194
+ key: 'name',
195
+ label: 'Name',
196
+ type: 'text',
197
+ required: false,
198
+ sortOrder: 0,
199
+ },
200
+ {
201
+ id: 'feedback-rating',
202
+ key: 'rating',
203
+ label: 'Rating',
204
+ type: 'select',
205
+ required: true,
206
+ sortOrder: 1,
207
+ options: ['1', '2', '3', '4', '5'].map((v) => ({ label: v, value: v })),
208
+ },
209
+ {
210
+ id: 'feedback-comments',
211
+ key: 'comments',
212
+ label: 'Comments',
213
+ type: 'textarea',
214
+ required: false,
215
+ sortOrder: 2,
216
+ },
151
217
  ],
152
- submitLabel: 'Submit Feedback',
153
218
  successMessage: 'Thank you for your feedback!',
154
219
  },
155
220
  ]
@@ -188,6 +253,7 @@ interface SeedOptions {
188
253
  demo?: boolean
189
254
  file?: string
190
255
  reset?: boolean
256
+ upsert?: boolean
191
257
  }
192
258
 
193
259
  export interface NormalizedSeedDocument {
@@ -324,7 +390,7 @@ async function runSeed(options: SeedOptions): Promise<void> {
324
390
  }
325
391
 
326
392
  if (file) {
327
- await seedFromFile(db, file)
393
+ await seedFromFile(db, file, { upsert: options.upsert })
328
394
  }
329
395
  } catch (err) {
330
396
  const message = err instanceof Error ? err.message : String(err)
@@ -380,19 +446,63 @@ export async function createSeedDocument(
380
446
  db: any,
381
447
  userId: string,
382
448
  doc: NormalizedSeedDocument,
383
- ): Promise<void> {
449
+ options: { upsert?: boolean } = {},
450
+ ): Promise<'created' | 'updated'> {
384
451
  const { extractPlainText, hashContent, sanitizeHtml } = await import('@actuate-media/cms-core')
385
452
  const data = sanitizeSeedData(doc.data, sanitizeHtml) as Record<string, unknown>
386
453
  const serialized = JSON.stringify(data)
387
454
  const plainText = extractPlainText(serialized)
388
455
  const contentHash = await hashContent(serialized)
456
+ const slug = typeof data.slug === 'string' ? data.slug : null
457
+ const title = typeof data.title === 'string' ? data.title : null
458
+
459
+ // Upsert key: collection + slug. Slugless documents can't be re-identified,
460
+ // so they always insert (same as a plain seed run).
461
+ const existing =
462
+ options.upsert && slug
463
+ ? await db.document.findFirst({
464
+ where: { collection: doc.collection, slug, deletedAt: null },
465
+ select: { id: true, status: true, publishedAt: true },
466
+ })
467
+ : null
468
+
469
+ if (existing) {
470
+ await db.$transaction(async (tx: any) => {
471
+ await tx.document.update({
472
+ where: { id: existing.id },
473
+ data: {
474
+ title,
475
+ data,
476
+ status: doc.status,
477
+ // Stamp publishedAt only on the transition into PUBLISHED.
478
+ publishedAt:
479
+ doc.status === 'PUBLISHED'
480
+ ? (existing.publishedAt ?? new Date())
481
+ : existing.publishedAt,
482
+ updatedById: userId,
483
+ plainText,
484
+ contentHash,
485
+ },
486
+ })
487
+
488
+ await tx.version.create({
489
+ data: {
490
+ documentId: existing.id,
491
+ data,
492
+ changedById: userId,
493
+ changeType: 'UPDATE',
494
+ },
495
+ })
496
+ })
497
+ return 'updated'
498
+ }
389
499
 
390
500
  await db.$transaction(async (tx: any) => {
391
501
  const created = await tx.document.create({
392
502
  data: {
393
503
  collection: doc.collection,
394
- title: typeof data.title === 'string' ? data.title : null,
395
- slug: typeof data.slug === 'string' ? data.slug : null,
504
+ title,
505
+ slug,
396
506
  data,
397
507
  status: doc.status,
398
508
  publishedAt: doc.status === 'PUBLISHED' ? new Date() : null,
@@ -412,6 +522,7 @@ export async function createSeedDocument(
412
522
  },
413
523
  })
414
524
  })
525
+ return 'created'
415
526
  }
416
527
 
417
528
  async function seedDemoData(db: any): Promise<void> {
@@ -486,7 +597,11 @@ async function seedDemoData(db: any): Promise<void> {
486
597
  logger.info(` Users: ${usersCreated} (+ existing admin)`)
487
598
  }
488
599
 
489
- async function seedFromFile(db: any, filePath: string): Promise<void> {
600
+ async function seedFromFile(
601
+ db: any,
602
+ filePath: string,
603
+ options: { upsert?: boolean } = {},
604
+ ): Promise<void> {
490
605
  if (!existsSync(filePath)) {
491
606
  logger.error(`File not found: ${filePath}`)
492
607
  process.exit(1)
@@ -511,13 +626,32 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
511
626
  const userId = adminUser.id
512
627
 
513
628
  const normalized = normalizeSeedPayload(seedData)
629
+
630
+ // Forms have a stricter shape than generic documents (data.status gates the
631
+ // public endpoints; fields need key/label/type). Fail loudly BEFORE writing
632
+ // anything — a malformed form seed used to insert fine and then 404 on
633
+ // /api/cms/public/forms/:slug with no hint why.
634
+ const formErrors = validateFormSeeds(normalized.documents)
635
+ if (formErrors.length > 0) {
636
+ spinner.fail('Form seed validation failed — nothing was written:')
637
+ for (const error of formErrors) {
638
+ logger.error(` ${error}`)
639
+ }
640
+ logger.info(
641
+ ' Expected shape: { "forms": [{ "status": "PUBLISHED", "data": { "name", "slug", "status": "active", "fields": [{ "key", "label", "type", … }] } }] }',
642
+ )
643
+ process.exit(1)
644
+ }
645
+
514
646
  const { updateGlobal } = await import('@actuate-media/cms-core')
515
647
  const ctx = { userId, role: 'ADMIN', db }
516
648
 
517
- let documentCount = 0
649
+ let createdCount = 0
650
+ let updatedCount = 0
518
651
  for (const doc of normalized.documents) {
519
- await createSeedDocument(db, userId, doc)
520
- documentCount++
652
+ const result = await createSeedDocument(db, userId, doc, options)
653
+ if (result === 'updated') updatedCount++
654
+ else createdCount++
521
655
  }
522
656
 
523
657
  let globalCount = 0
@@ -526,7 +660,11 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
526
660
  globalCount++
527
661
  }
528
662
 
529
- spinner.succeed(`Seeded ${documentCount} documents and ${globalCount} globals from ${filePath}.`)
663
+ spinner.succeed(
664
+ options.upsert
665
+ ? `Seeded from ${filePath}: ${createdCount} created, ${updatedCount} updated, ${globalCount} globals.`
666
+ : `Seeded ${createdCount} documents and ${globalCount} globals from ${filePath}.`,
667
+ )
530
668
  }
531
669
 
532
670
  export function registerSeedCommand(program: Command): void {
@@ -536,6 +674,10 @@ export function registerSeedCommand(program: Command): void {
536
674
  .option('--demo', 'Seed demo content (pages, posts, forms, users)')
537
675
  .option('--file <path>', 'Seed from a JSON, JavaScript, or TypeScript file')
538
676
  .option('--reset', 'Clear existing data before seeding')
677
+ .option(
678
+ '--upsert',
679
+ 'Update existing documents matched by collection + slug instead of inserting duplicates',
680
+ )
539
681
  .action(runSeed)
540
682
 
541
683
  program
@@ -543,5 +685,9 @@ export function registerSeedCommand(program: Command): void {
543
685
  .description('Populate the database from actuate.seed.json or a custom seed file')
544
686
  .option('--file <path>', 'Seed from a JSON, JavaScript, or TypeScript file')
545
687
  .option('--reset', 'Clear existing data before seeding')
688
+ .option(
689
+ '--upsert',
690
+ 'Update existing documents matched by collection + slug instead of inserting duplicates',
691
+ )
546
692
  .action(runSeed)
547
693
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Validation for `forms`-collection seed entries.
3
+ *
4
+ * The form definition lives in `document.data` and has its own lifecycle
5
+ * `data.status` (`active` | `draft` | `archived`) that gates the public
6
+ * endpoints — a separate axis from the *document envelope* status
7
+ * (`DRAFT` | `PUBLISHED`). Seeding a form with the wrong shape used to fail
8
+ * silently: the document row was created, but `/api/cms/public/forms/:slug`
9
+ * 404'd (not `active`) or rendered nothing (fields missing `key`/`label`).
10
+ * This validator turns those mistakes into actionable errors at seed time.
11
+ *
12
+ * Type-only imports keep cms-core a runtime peer dependency (the CLI loads it
13
+ * lazily); the literal lists below are typed against the cms-core contracts so
14
+ * a typo here fails the build.
15
+ */
16
+ import type { FormFieldType, FormStatus } from '@actuate-media/cms-core'
17
+
18
+ const VALID_FIELD_TYPES: readonly FormFieldType[] = [
19
+ 'text',
20
+ 'email',
21
+ 'phone',
22
+ 'textarea',
23
+ 'select',
24
+ 'multiselect',
25
+ 'radio',
26
+ 'checkbox',
27
+ 'date',
28
+ 'number',
29
+ 'url',
30
+ 'hidden',
31
+ 'file',
32
+ 'consent',
33
+ 'richtext',
34
+ 'divider',
35
+ 'honeypot',
36
+ ]
37
+
38
+ const VALID_FORM_STATUSES: readonly FormStatus[] = ['active', 'draft', 'archived']
39
+
40
+ /** Document-envelope statuses people mistakenly put in `data.status`. */
41
+ const ENVELOPE_STATUSES = new Set(['DRAFT', 'PUBLISHED', 'SCHEDULED', 'ARCHIVED'])
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
45
+ }
46
+
47
+ /**
48
+ * Validate one form's `data` payload. Returns human-readable errors —
49
+ * empty array means the form will work with the public endpoints
50
+ * (`GET /api/cms/public/forms/:slug` + `/submit`) and `<ActuateForm>`.
51
+ */
52
+ export function validateFormSeedData(data: unknown, label = 'form'): string[] {
53
+ const errors: string[] = []
54
+ if (!isRecord(data)) {
55
+ return [`${label}: form data must be an object.`]
56
+ }
57
+
58
+ if (typeof data.slug !== 'string' || data.slug.trim() === '') {
59
+ errors.push(
60
+ `${label}: missing "slug" (string). The public endpoints look forms up by slug — e.g. /api/cms/public/forms/contact.`,
61
+ )
62
+ }
63
+
64
+ const name = data.name ?? data.title
65
+ if (typeof name !== 'string' || name.trim() === '') {
66
+ errors.push(`${label}: missing "name" (string) — shown in the admin Forms list and inbox.`)
67
+ }
68
+
69
+ const status = data.status
70
+ if (status === undefined || status === null) {
71
+ errors.push(
72
+ `${label}: missing "status". Set data.status to "active" to enable the public form endpoints. ` +
73
+ `Note: this is the FORM's lifecycle status inside data — separate from the document envelope status ` +
74
+ `(DRAFT/PUBLISHED) set next to data.`,
75
+ )
76
+ } else if (typeof status === 'string' && ENVELOPE_STATUSES.has(status)) {
77
+ errors.push(
78
+ `${label}: data.status "${status}" looks like a document envelope status. The form definition's ` +
79
+ `data.status must be one of ${VALID_FORM_STATUSES.map((s) => `"${s}"`).join(' | ')} — use "active" ` +
80
+ `to enable the public endpoints. Put DRAFT/PUBLISHED on the document's top-level "status" key instead.`,
81
+ )
82
+ } else if (!VALID_FORM_STATUSES.includes(status as FormStatus)) {
83
+ errors.push(
84
+ `${label}: invalid data.status ${JSON.stringify(status)} — must be one of ` +
85
+ `${VALID_FORM_STATUSES.map((s) => `"${s}"`).join(' | ')}.`,
86
+ )
87
+ }
88
+
89
+ const fields = data.fields
90
+ if (!Array.isArray(fields) || fields.length === 0) {
91
+ errors.push(
92
+ `${label}: missing "fields" (non-empty array). Each field needs at least { key, label, type }.`,
93
+ )
94
+ return errors
95
+ }
96
+
97
+ fields.forEach((field, i) => {
98
+ const fieldLabel = `${label}: fields[${i}]`
99
+ if (!isRecord(field)) {
100
+ errors.push(`${fieldLabel}: must be an object with at least { key, label, type }.`)
101
+ return
102
+ }
103
+ if (typeof field.key !== 'string' || field.key.trim() === '') {
104
+ errors.push(
105
+ typeof field.name === 'string'
106
+ ? `${fieldLabel}: uses "name" — the form schema calls this "key". Rename name → key (and add a human-readable "label").`
107
+ : `${fieldLabel}: missing "key" (string) — the submission payload is keyed by it.`,
108
+ )
109
+ }
110
+ if (typeof field.label !== 'string' || field.label.trim() === '') {
111
+ errors.push(`${fieldLabel}: missing "label" (string) — shown to visitors and in the inbox.`)
112
+ }
113
+ if (!VALID_FIELD_TYPES.includes(field.type as FormFieldType)) {
114
+ errors.push(
115
+ `${fieldLabel}: invalid type ${JSON.stringify(field.type)} — must be one of ${VALID_FIELD_TYPES.join(', ')}.`,
116
+ )
117
+ }
118
+ })
119
+
120
+ return errors
121
+ }
122
+
123
+ /**
124
+ * Validate every `forms`-collection document in a normalized seed payload.
125
+ * Returns all errors across all forms (so one run surfaces everything).
126
+ */
127
+ export function validateFormSeeds(
128
+ documents: ReadonlyArray<{ collection: string; data: Record<string, unknown> }>,
129
+ ): string[] {
130
+ const errors: string[] = []
131
+ documents.forEach((doc, i) => {
132
+ if (doc.collection !== 'forms') return
133
+ const slug = typeof doc.data.slug === 'string' ? doc.data.slug : `#${i + 1}`
134
+ errors.push(...validateFormSeedData(doc.data, `forms "${slug}"`))
135
+ })
136
+ return errors
137
+ }