@growth-labs/cms 0.8.3 → 0.8.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 (47) hide show
  1. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  2. package/dist/engine/foundry-dispatch.js +42 -21
  3. package/dist/engine/foundry-dispatch.js.map +1 -1
  4. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  5. package/dist/engine/fronts-publish-intent.js +8 -4
  6. package/dist/engine/fronts-publish-intent.js.map +1 -1
  7. package/dist/engine/fronts-publish.d.ts.map +1 -1
  8. package/dist/engine/fronts-publish.js +29 -6
  9. package/dist/engine/fronts-publish.js.map +1 -1
  10. package/dist/engine/index.d.ts +1 -1
  11. package/dist/engine/index.d.ts.map +1 -1
  12. package/dist/engine/index.js +1 -1
  13. package/dist/engine/index.js.map +1 -1
  14. package/dist/engine/publication-current-guard.d.ts +7 -0
  15. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  16. package/dist/engine/publication-current-guard.js +66 -0
  17. package/dist/engine/publication-current-guard.js.map +1 -1
  18. package/dist/engine/publication.d.ts +30 -0
  19. package/dist/engine/publication.d.ts.map +1 -1
  20. package/dist/engine/publication.js +306 -34
  21. package/dist/engine/publication.js.map +1 -1
  22. package/dist/engine/publisher.d.ts.map +1 -1
  23. package/dist/engine/publisher.js +16 -11
  24. package/dist/engine/publisher.js.map +1 -1
  25. package/dist/routes/content.d.ts.map +1 -1
  26. package/dist/routes/content.js +19 -4
  27. package/dist/routes/content.js.map +1 -1
  28. package/dist/schema/insights-ingest.d.ts +42 -42
  29. package/dist/schema/migrations.d.ts.map +1 -1
  30. package/dist/schema/migrations.js +8 -0
  31. package/dist/schema/migrations.js.map +1 -1
  32. package/dist/schema/types.d.ts +6 -0
  33. package/dist/schema/types.d.ts.map +1 -1
  34. package/dist/schema/types.js.map +1 -1
  35. package/dist/surveys/schema.d.ts +48 -48
  36. package/migrations/0030_media_publication_guard.sql +5 -0
  37. package/package.json +1 -1
  38. package/src/engine/foundry-dispatch.ts +86 -32
  39. package/src/engine/fronts-publish-intent.ts +8 -4
  40. package/src/engine/fronts-publish.ts +46 -7
  41. package/src/engine/index.ts +3 -0
  42. package/src/engine/publication-current-guard.ts +86 -0
  43. package/src/engine/publication.ts +411 -59
  44. package/src/engine/publisher.ts +17 -9
  45. package/src/routes/content.ts +22 -4
  46. package/src/schema/migrations.ts +8 -0
  47. package/src/schema/types.ts +6 -0
@@ -22,7 +22,6 @@
22
22
  import type { ContentStatus, ContentType, FrontsPublishStatus } from '../schema/types.js'
23
23
  import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
24
24
  import type { D1Database } from './d1.js'
25
- import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
26
25
  import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
27
26
  import { slugify } from './slug.js'
28
27
  import {
@@ -1319,6 +1318,7 @@ export async function updateContentItem(
1319
1318
  processing_state = CASE WHEN ? THEN 'pending' ELSE processing_state END,
1320
1319
  processing_correlation_id = CASE WHEN ? THEN NULL ELSE processing_correlation_id END,
1321
1320
  processing_trigger_token = CASE WHEN ? THEN NULL ELSE processing_trigger_token END,
1321
+ processing_publication_guard = CASE WHEN ? THEN NULL ELSE processing_publication_guard END,
1322
1322
  processing_started_at = CASE WHEN ? THEN NULL ELSE processing_started_at END,
1323
1323
  processing_completed_at = CASE WHEN ? THEN NULL ELSE processing_completed_at END,
1324
1324
  processing_last_event_at = CASE WHEN ? THEN NULL ELSE processing_last_event_at END,
@@ -1353,6 +1353,7 @@ export async function updateContentItem(
1353
1353
  videoUpdatePlan.sourceChanged ? 1 : 0,
1354
1354
  videoUpdatePlan.sourceChanged ? 1 : 0,
1355
1355
  videoUpdatePlan.sourceChanged ? 1 : 0,
1356
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1356
1357
  id,
1357
1358
  options.expectedMediaSource ? 1 : 0,
1358
1359
  options.expectedMediaSource?.sourceUrl ?? null,
@@ -1374,6 +1375,8 @@ export async function updateContentItem(
1374
1375
  audio_r2_key = ?,
1375
1376
  duration_seconds = ?,
1376
1377
  processing_trigger_token = ?,
1378
+ processing_publication_guard = CASE WHEN ? IS processing_trigger_token THEN processing_publication_guard ELSE NULL END,
1379
+ processing_state = CASE WHEN ? IS processing_trigger_token THEN processing_state ELSE NULL END,
1377
1380
  processing_source_url = ?,
1378
1381
  processing_source_kind = ?
1379
1382
  WHERE content_id = ?
@@ -1385,6 +1388,8 @@ export async function updateContentItem(
1385
1388
  podcastUpdatePlan.audioR2Key,
1386
1389
  podcastUpdatePlan.durationSeconds,
1387
1390
  podcastUpdatePlan.processingTriggerToken,
1391
+ podcastUpdatePlan.processingTriggerToken,
1392
+ podcastUpdatePlan.processingTriggerToken,
1388
1393
  podcastUpdatePlan.sourceUrl,
1389
1394
  podcastUpdatePlan.sourceKind,
1390
1395
  id,
@@ -1411,16 +1416,19 @@ export async function scheduleContent(
1411
1416
  ): Promise<void> {
1412
1417
  await db
1413
1418
  .prepare(
1414
- `UPDATE content_items SET status = 'scheduled', publish_at = ?, publish_tz = ?, updated_at = unixepoch() WHERE id = ?`,
1419
+ `UPDATE content_items SET status = 'scheduled', publish_at = ?, publish_tz = ?,
1420
+ canonical_version = canonical_version + CASE WHEN status = 'published' OR fronts_publish_status = 'live' THEN 1 ELSE 0 END,
1421
+ fronts_publish_status = CASE WHEN status = 'published' OR fronts_publish_status = 'live'
1422
+ THEN 'queued' ELSE fronts_publish_status END,
1423
+ fronts_publish_trigger_token = CASE WHEN status = 'published' OR fronts_publish_status = 'live'
1424
+ OR fronts_publish_trigger_token IS NULL THEN ? ELSE fronts_publish_trigger_token END,
1425
+ updated_at = unixepoch() WHERE id = ?`,
1415
1426
  )
1416
- .bind(publishAt, publishTz, id)
1427
+ .bind(publishAt, publishTz, crypto.randomUUID(), id)
1417
1428
  .run()
1418
- // Arming an item for the additive Fronts publish path happens HERE — the one
1419
- // seam through which an item becomes `scheduled` with a `publish_at`. The mint
1420
- // is compare-and-set and never rotates an existing token, so an item's
1421
- // `trigger_token_hash` is stable across reschedules and across every poll.
1422
- // The raw token stays in D1; only `sha256:<hex>` is ever exposed.
1423
- await armFrontsPublishTriggerToken(db, id)
1429
+ // Slot, terminal-ledger reset and token are one atomic transition. Moving an
1430
+ // already scheduled occurrence preserves its identity; scheduling a completed
1431
+ // publication creates a fresh occurrence without recapturing its media.
1424
1432
  }
1425
1433
 
1426
1434
  export async function evaluateContentBodyForPublish(
@@ -389,6 +389,24 @@ const ContentMetadataSchema = z
389
389
  })
390
390
  .transform((metadata) => metadata as ContentMetadata)
391
391
 
392
+ // Known Nextcloud share pages are HTML, never ingestable media. The editor
393
+ // must save the direct-download URL so the existing provider validates bytes.
394
+ const MediaSourceUrlSchema = z
395
+ .string()
396
+ .url()
397
+ .refine(
398
+ (value) => {
399
+ let url: URL
400
+ try {
401
+ url = new URL(value)
402
+ } catch {
403
+ return false
404
+ }
405
+ return !(url.hostname === 'drive.fulcrum-portal.com' && /^\/s\/[^/]+\/?$/.test(url.pathname))
406
+ },
407
+ { message: 'Use the Nextcloud direct-download URL ending in /download, not its share page.' },
408
+ )
409
+
392
410
  function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
393
411
  const category = slugEnum(resolved.primaryCategorySlugs)
394
412
  const topic = slugEnum(resolved.primaryTopicSlugs)
@@ -430,7 +448,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
430
448
  videoId: z.string().min(1),
431
449
  durationSeconds: z.number().int().positive().optional().nullable(),
432
450
  thumbnailImageId: z.string().optional().nullable(),
433
- processingSourceUrl: z.string().url().optional().nullable(),
451
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
434
452
  processingSourceKind: z.string().min(1).optional().nullable(),
435
453
  }),
436
454
  }),
@@ -450,7 +468,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
450
468
  // there because a video always has a slug-derived identity up front.
451
469
  audioR2Key: z.string().default(''),
452
470
  durationSeconds: z.number().int().positive().optional().nullable(),
453
- processingSourceUrl: z.string().url().optional().nullable(),
471
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
454
472
  processingSourceKind: z.string().min(1).optional().nullable(),
455
473
  }),
456
474
  }),
@@ -515,7 +533,7 @@ const VideoContentUpdateSchema = z.object({
515
533
  videoId: z.string().optional(),
516
534
  durationSeconds: z.number().int().positive().optional().nullable(),
517
535
  thumbnailImageId: z.string().optional().nullable(),
518
- processingSourceUrl: z.string().url().optional().nullable(),
536
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
519
537
  processingSourceKind: z.string().min(1).optional().nullable(),
520
538
  })
521
539
 
@@ -523,7 +541,7 @@ const PodcastContentUpdateSchema = z.object({
523
541
  transcript: z.string().optional(),
524
542
  audioR2Key: z.string().optional(),
525
543
  durationSeconds: z.number().int().positive().optional().nullable(),
526
- processingSourceUrl: z.string().url().optional().nullable(),
544
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
527
545
  processingSourceKind: z.string().min(1).optional().nullable(),
528
546
  })
529
547
 
@@ -902,6 +902,14 @@ CREATE INDEX idx_cms_foundry_articles_updated
902
902
  ON cms_foundry_articles(updated_at DESC);
903
903
  `,
904
904
  },
905
+ {
906
+ id: '0030_media_publication_guard',
907
+ sql: `
908
+ ALTER TABLE video_content ADD COLUMN processing_publication_guard TEXT;
909
+ ALTER TABLE podcast_content ADD COLUMN processing_publication_guard TEXT;
910
+ ALTER TABLE podcast_content ADD COLUMN processing_state TEXT CHECK (processing_state IS NULL OR processing_state IN ('pending','queued','fetching','transcribing','transcoding','ready','failed'));
911
+ `,
912
+ },
905
913
  ]
906
914
 
907
915
  /**
@@ -187,6 +187,8 @@ export interface ArticleContentRow {
187
187
  */
188
188
  export interface VideoContentRow {
189
189
  content_id: string
190
+ /** Capture-time published revision fence; null for historical/unpublished captures. */
191
+ processing_publication_guard?: string | null
190
192
  script: string
191
193
  video_id: string
192
194
  duration_seconds: number | null
@@ -211,6 +213,10 @@ export interface VideoContentRow {
211
213
 
212
214
  export interface PodcastContentRow {
213
215
  content_id: string
216
+ /** Explicit preparation lifecycle; historical captures remain unqueued. */
217
+ processing_state?: VideoProcessingState | null
218
+ /** Capture-time published revision fence; null for historical/unpublished captures. */
219
+ processing_publication_guard?: string | null
214
220
  transcript: string
215
221
  audio_r2_key: string
216
222
  duration_seconds: number | null