@growth-labs/cms 0.8.1 → 0.8.3

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 (91) hide show
  1. package/dist/engine/ai-dek-notifications.d.ts +2 -0
  2. package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
  3. package/dist/engine/ai-dek-notifications.js +11 -2
  4. package/dist/engine/ai-dek-notifications.js.map +1 -1
  5. package/dist/engine/ai-dek.d.ts +23 -0
  6. package/dist/engine/ai-dek.d.ts.map +1 -1
  7. package/dist/engine/ai-dek.js +94 -31
  8. package/dist/engine/ai-dek.js.map +1 -1
  9. package/dist/engine/foundry-dispatch.d.ts +13 -3
  10. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  11. package/dist/engine/foundry-dispatch.js +80 -25
  12. package/dist/engine/foundry-dispatch.js.map +1 -1
  13. package/dist/engine/fronts-publish-intent.d.ts +55 -0
  14. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  15. package/dist/engine/fronts-publish-intent.js +181 -55
  16. package/dist/engine/fronts-publish-intent.js.map +1 -1
  17. package/dist/engine/fronts-publish.d.ts +11 -0
  18. package/dist/engine/fronts-publish.d.ts.map +1 -1
  19. package/dist/engine/fronts-publish.js +106 -4
  20. package/dist/engine/fronts-publish.js.map +1 -1
  21. package/dist/engine/index.d.ts +1 -1
  22. package/dist/engine/index.d.ts.map +1 -1
  23. package/dist/engine/index.js +1 -1
  24. package/dist/engine/index.js.map +1 -1
  25. package/dist/engine/podcast-source.d.ts +3 -2
  26. package/dist/engine/podcast-source.d.ts.map +1 -1
  27. package/dist/engine/podcast-source.js +6 -4
  28. package/dist/engine/podcast-source.js.map +1 -1
  29. package/dist/engine/publisher.d.ts.map +1 -1
  30. package/dist/engine/publisher.js +16 -8
  31. package/dist/engine/publisher.js.map +1 -1
  32. package/dist/engine/soft-delete.d.ts +3 -1
  33. package/dist/engine/soft-delete.d.ts.map +1 -1
  34. package/dist/engine/soft-delete.js +21 -5
  35. package/dist/engine/soft-delete.js.map +1 -1
  36. package/dist/engine/video-source.d.ts +4 -2
  37. package/dist/engine/video-source.d.ts.map +1 -1
  38. package/dist/engine/video-source.js +7 -4
  39. package/dist/engine/video-source.js.map +1 -1
  40. package/dist/integration/index.d.ts.map +1 -1
  41. package/dist/integration/index.js +7 -0
  42. package/dist/integration/index.js.map +1 -1
  43. package/dist/routes/content-insights.d.ts +9 -0
  44. package/dist/routes/content-insights.d.ts.map +1 -1
  45. package/dist/routes/content-insights.js +35 -0
  46. package/dist/routes/content-insights.js.map +1 -1
  47. package/dist/routes/content.d.ts +14 -0
  48. package/dist/routes/content.d.ts.map +1 -1
  49. package/dist/routes/content.js +37 -24
  50. package/dist/routes/content.js.map +1 -1
  51. package/dist/routes/context.d.ts +6 -0
  52. package/dist/routes/context.d.ts.map +1 -1
  53. package/dist/routes/context.js.map +1 -1
  54. package/dist/routes/fronts-publish.d.ts +16 -0
  55. package/dist/routes/fronts-publish.d.ts.map +1 -1
  56. package/dist/routes/fronts-publish.js +185 -4
  57. package/dist/routes/fronts-publish.js.map +1 -1
  58. package/dist/ui/api/_content-config.d.ts.map +1 -1
  59. package/dist/ui/api/_content-config.js +7 -0
  60. package/dist/ui/api/_content-config.js.map +1 -1
  61. package/dist/ui/api/content/insights-inputs.d.ts +3 -0
  62. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -0
  63. package/dist/ui/api/content/insights-inputs.js +15 -0
  64. package/dist/ui/api/content/insights-inputs.js.map +1 -0
  65. package/dist/ui/api/fronts/schedule.d.ts +2 -0
  66. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  67. package/dist/ui/api/fronts/schedule.js +2 -0
  68. package/dist/ui/api/fronts/schedule.js.map +1 -1
  69. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  70. package/dist/ui/editor/ContentForm.js +2 -2
  71. package/dist/ui/editor/ContentForm.js.map +1 -1
  72. package/package.json +1 -1
  73. package/src/engine/ai-dek-notifications.ts +19 -2
  74. package/src/engine/ai-dek.ts +131 -40
  75. package/src/engine/foundry-dispatch.ts +110 -24
  76. package/src/engine/fronts-publish-intent.ts +289 -65
  77. package/src/engine/fronts-publish.ts +121 -4
  78. package/src/engine/index.ts +1 -0
  79. package/src/engine/podcast-source.ts +6 -4
  80. package/src/engine/publisher.ts +20 -8
  81. package/src/engine/soft-delete.ts +28 -8
  82. package/src/engine/video-source.ts +7 -4
  83. package/src/integration/index.ts +7 -0
  84. package/src/routes/content-insights.ts +61 -0
  85. package/src/routes/content.ts +56 -31
  86. package/src/routes/context.ts +6 -0
  87. package/src/routes/fronts-publish.ts +220 -4
  88. package/src/ui/api/_content-config.ts +15 -0
  89. package/src/ui/api/content/insights-inputs.ts +26 -0
  90. package/src/ui/api/fronts/schedule.ts +4 -0
  91. package/src/ui/editor/ContentForm.tsx +4 -5
@@ -10,7 +10,7 @@
10
10
  // on `processing_trigger_token` and deduplicated on `event_id`.
11
11
 
12
12
  import type { FoundryDispatch } from '../providers/types.js'
13
- import type { D1Database } from './d1.js'
13
+ import type { D1Database, D1Result } from './d1.js'
14
14
  import { validPodcastSourceUrl } from './podcast-source.js'
15
15
 
16
16
  interface PodcastDispatchSnapshot {
@@ -35,8 +35,10 @@ export type ProcessingState =
35
35
  const TERMINAL_STATES: ProcessingState[] = ['ready', 'failed']
36
36
 
37
37
  export interface DispatchResult {
38
- correlationId: string
38
+ /** Podcast capture reuse has no persisted correlation to return. */
39
+ correlationId: string | null
39
40
  triggerToken: string
41
+ skipped?: boolean
40
42
  }
41
43
 
42
44
  export class FoundryDispatchPrerequisiteError extends Error {
@@ -99,9 +101,23 @@ function inferVideoSourceKind(sourceUrl: string): string {
99
101
  return 'http_url'
100
102
  }
101
103
 
104
+ /**
105
+ * Mint a fresh source identity for the caller's source-snapshot CAS. Capture
106
+ * time records initiation, not worker admission; this helper does not dispatch
107
+ * work or change readiness. The raw token stays in the private capture record.
108
+ */
109
+ export function createFoundrySourceCaptureToken(
110
+ capturedAtSeconds = Math.floor(Date.now() / 1000),
111
+ ): string {
112
+ if (!Number.isSafeInteger(capturedAtSeconds) || capturedAtSeconds <= 0) {
113
+ throw new RangeError('Capture time must be positive safe integer Unix seconds')
114
+ }
115
+ return `fronts-source-v2:${capturedAtSeconds}:${crypto.randomUUID()}`
116
+ }
117
+
102
118
  /**
103
119
  * Dispatch a video or podcast content item to Foundry via the injected hook.
104
- * Mints a fresh `processing_trigger_token` (UUID), calls
120
+ * Mints a fresh versioned `processing_trigger_token` (capture time + UUID), calls
105
121
  * `hook.dispatchVideo({ contentId, sourceUrl, kind, durationSeconds })`, and writes
106
122
  * `processing_correlation_id` + advances to `'queued'` in either
107
123
  * `video_content` or `podcast_content`.
@@ -112,6 +128,7 @@ export async function dispatchToFoundry(
112
128
  db: D1Database,
113
129
  contentId: string,
114
130
  hook: Pick<FoundryDispatch, 'dispatchVideo'>,
131
+ options: { ifUncaptured?: boolean } = {},
115
132
  ): Promise<DispatchResult> {
116
133
  const content = await db
117
134
  .prepare(
@@ -135,7 +152,7 @@ export async function dispatchToFoundry(
135
152
  // Determine which media table owns this content id.
136
153
  const videoRow = await db
137
154
  .prepare(
138
- 'SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind FROM video_content WHERE content_id = ?',
155
+ 'SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token, processing_correlation_id FROM video_content WHERE content_id = ?',
139
156
  )
140
157
  .bind(contentId)
141
158
  .first<{
@@ -144,6 +161,8 @@ export async function dispatchToFoundry(
144
161
  duration_seconds: number | null
145
162
  processing_source_url: string | null
146
163
  processing_source_kind: string | null
164
+ processing_trigger_token: string | null
165
+ processing_correlation_id: string | null
147
166
  }>()
148
167
 
149
168
  let table: 'video_content' | 'podcast_content'
@@ -155,6 +174,20 @@ export async function dispatchToFoundry(
155
174
  let podcastSnapshot: PodcastDispatchSnapshot | null = null
156
175
 
157
176
  if (videoRow) {
177
+ if (content.type !== 'video' || content.deleted_at != null || content.status === 'archived') {
178
+ throw new Error(`dispatchToFoundry: video ${contentId} is inactive`)
179
+ }
180
+ if (
181
+ options.ifUncaptured &&
182
+ videoRow.processing_trigger_token &&
183
+ videoRow.processing_correlation_id
184
+ ) {
185
+ return {
186
+ correlationId: videoRow.processing_correlation_id,
187
+ triggerToken: videoRow.processing_trigger_token,
188
+ skipped: true,
189
+ }
190
+ }
158
191
  table = 'video_content'
159
192
  sourceUrl = videoRow.processing_source_url?.trim() ?? ''
160
193
  if (!sourceUrl) {
@@ -199,8 +232,22 @@ export async function dispatchToFoundry(
199
232
  ) {
200
233
  throw new FoundryDispatchPrerequisiteError(contentId)
201
234
  }
235
+ if (
236
+ options.ifUncaptured &&
237
+ podcastSnapshot?.processing_trigger_token?.trim() &&
238
+ podcastSnapshot.processing_source_kind === 'https' &&
239
+ validPodcastSourceUrl(podcastSnapshot.processing_source_url)
240
+ ) {
241
+ // Source edits clear the token. This source/token pair is the CMS's
242
+ // committed capture evidence; a metadata save need not call the host again.
243
+ return {
244
+ correlationId: null,
245
+ triggerToken: podcastSnapshot.processing_trigger_token,
246
+ skipped: true,
247
+ }
248
+ }
202
249
 
203
- const triggerToken = crypto.randomUUID()
250
+ const triggerToken = createFoundrySourceCaptureToken()
204
251
  const processingTriggerTokenHash = `sha256:${await sha256Hex(triggerToken)}`
205
252
  const { correlationId, podcastSource } = await hook.dispatchVideo({
206
253
  contentId,
@@ -217,7 +264,8 @@ export async function dispatchToFoundry(
217
264
  const now = Math.floor(Date.now() / 1000)
218
265
 
219
266
  if (table === 'video_content') {
220
- await db
267
+ if (!videoRow) throw new Error('Video dispatch snapshot is missing')
268
+ const written = await db
221
269
  .prepare(
222
270
  `UPDATE video_content
223
271
  SET processing_state = 'queued',
@@ -235,10 +283,33 @@ export async function dispatchToFoundry(
235
283
  transcript_url = NULL,
236
284
  hls_manifest_url = NULL,
237
285
  hls_poster_url = NULL
238
- WHERE content_id = ?`,
286
+ WHERE content_id = ? AND video_id IS ? AND duration_seconds IS ?
287
+ AND processing_source_url IS ? AND processing_source_kind IS ?
288
+ AND processing_trigger_token IS ? AND processing_correlation_id IS ?
289
+ AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = video_content.content_id
290
+ AND ci.type = 'video' AND ci.slug = ? AND ci.canonical_version = ?
291
+ AND ci.status = ? AND ci.deleted_at IS NULL)`,
292
+ )
293
+ .bind(
294
+ videoId,
295
+ sourceKind,
296
+ correlationId,
297
+ triggerToken,
298
+ now,
299
+ contentId,
300
+ videoRow.video_id,
301
+ videoRow.duration_seconds,
302
+ videoRow.processing_source_url,
303
+ videoRow.processing_source_kind,
304
+ videoRow.processing_trigger_token,
305
+ videoRow.processing_correlation_id,
306
+ content.slug,
307
+ content.canonical_version,
308
+ content.status,
239
309
  )
240
- .bind(videoId, sourceKind, correlationId, triggerToken, now, contentId)
241
310
  .run()
311
+ if (written.meta?.changes !== 1)
312
+ throw new Error('Video source changed before dispatch acknowledgement')
242
313
  } else {
243
314
  if (!podcastSnapshot) throw new Error('Podcast dispatch snapshot is missing')
244
315
  if (
@@ -259,7 +330,7 @@ export async function dispatchToFoundry(
259
330
  // source/audio/editor snapshot is still current. No stale acknowledgement
260
331
  // can restore a replaced source or overwrite a newer processing token.
261
332
  const written = await db
262
- .prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?
333
+ .prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?, transcript = ''
263
334
  WHERE content_id = ? AND audio_r2_key IS ? AND duration_seconds IS ? AND transcript IS ?
264
335
  AND processing_source_url IS ? AND processing_source_kind IS ? AND processing_trigger_token IS ?
265
336
  AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = podcast_content.content_id
@@ -372,12 +443,12 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
372
443
  ? Math.max(0, Math.floor(durationSeconds))
373
444
  : null
374
445
  if (nextTranscript !== null || nextDurationSeconds !== null) {
375
- await db
446
+ const updated = await db
376
447
  .prepare(
377
448
  `UPDATE podcast_content
378
449
  SET transcript = CASE WHEN ? THEN ? ELSE transcript END,
379
450
  duration_seconds = CASE WHEN ? THEN ? ELSE duration_seconds END
380
- WHERE content_id = ?`,
451
+ WHERE content_id = ? AND processing_trigger_token = ?`,
381
452
  )
382
453
  .bind(
383
454
  nextTranscript !== null ? 1 : 0,
@@ -385,21 +456,26 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
385
456
  nextDurationSeconds !== null ? 1 : 0,
386
457
  nextDurationSeconds,
387
458
  podcastRow.content_id,
459
+ triggerToken,
388
460
  )
389
461
  .run()
462
+ if (updated.meta?.changes !== 1) return false
390
463
  }
391
464
 
392
465
  const nextDescription =
393
466
  typeof description === 'string' && description.trim() ? description.trim() : null
394
467
  const nextExcerpt = typeof excerpt === 'string' && excerpt.trim() ? excerpt.trim() : null
395
468
  if (nextDescription !== null || nextExcerpt !== null) {
396
- await db
469
+ const updated = await db
397
470
  .prepare(
398
471
  `UPDATE content_items
399
472
  SET description = CASE WHEN ? THEN ? ELSE description END,
400
473
  excerpt = CASE WHEN ? THEN ? ELSE excerpt END,
401
474
  updated_at = unixepoch()
402
- WHERE id = ?`,
475
+ WHERE id = ? AND EXISTS (
476
+ SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
477
+ AND p.processing_trigger_token = ?
478
+ )`,
403
479
  )
404
480
  .bind(
405
481
  nextDescription !== null ? 1 : 0,
@@ -407,50 +483,60 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
407
483
  nextExcerpt !== null ? 1 : 0,
408
484
  nextExcerpt,
409
485
  podcastRow.content_id,
486
+ triggerToken,
410
487
  )
411
488
  .run()
489
+ if (updated.meta?.changes !== 1) return false
412
490
  }
413
491
 
414
- return true
492
+ return Boolean(
493
+ await db
494
+ .prepare(
495
+ 'SELECT content_id FROM podcast_content WHERE content_id = ? AND processing_trigger_token = ?',
496
+ )
497
+ .bind(podcastRow.content_id, triggerToken)
498
+ .first(),
499
+ )
415
500
  }
416
501
 
417
502
  if (!videoRow) return false
418
503
  const row = videoRow
504
+ let updated: D1Result
419
505
 
420
506
  if (nextState === 'failed') {
421
- await db
507
+ updated = await db
422
508
  .prepare(
423
509
  `UPDATE video_content
424
510
  SET processing_state = ?,
425
511
  processing_last_event_at = ?,
426
512
  processing_completed_at = ?,
427
513
  processing_error = ?
428
- WHERE content_id = ?`,
514
+ WHERE content_id = ? AND processing_trigger_token = ?`,
429
515
  )
430
- .bind(nextState, now, now, error ?? null, row.content_id)
516
+ .bind(nextState, now, now, error ?? null, row.content_id, triggerToken)
431
517
  .run()
432
518
  } else if (isTerminal) {
433
519
  // 'ready'
434
- await db
520
+ updated = await db
435
521
  .prepare(
436
522
  `UPDATE video_content
437
523
  SET processing_state = ?,
438
524
  processing_last_event_at = ?,
439
525
  processing_completed_at = ?
440
- WHERE content_id = ?`,
526
+ WHERE content_id = ? AND processing_trigger_token = ?`,
441
527
  )
442
- .bind(nextState, now, now, row.content_id)
528
+ .bind(nextState, now, now, row.content_id, triggerToken)
443
529
  .run()
444
530
  } else {
445
- await db
531
+ updated = await db
446
532
  .prepare(
447
- 'UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ?',
533
+ 'UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ? AND processing_trigger_token = ?',
448
534
  )
449
- .bind(nextState, now, row.content_id)
535
+ .bind(nextState, now, row.content_id, triggerToken)
450
536
  .run()
451
537
  }
452
538
 
453
- return true
539
+ return updated.meta?.changes === 1
454
540
  }
455
541
 
456
542
  /**
@@ -304,15 +304,53 @@ export function parseFrontsChannelTargets(
304
304
  return normalizeChannelTargets(parsed)
305
305
  }
306
306
 
307
+ /** Host defaults are opt-in; an invalid configured list must fail before a save. */
308
+ export function parseFrontsDefaultChannelTargets(
309
+ value: unknown,
310
+ ):
311
+ | { targets: string[] }
312
+ | { error: 'missing_fronts_default_channel_targets' | 'invalid_fronts_default_channel_targets' } {
313
+ if (value === undefined) return { error: 'missing_fronts_default_channel_targets' }
314
+ if (!Array.isArray(value)) return { error: 'invalid_fronts_default_channel_targets' }
315
+ const parsed = normalizeChannelTargets(value)
316
+ return 'code' in parsed ? { error: 'invalid_fronts_default_channel_targets' } : parsed
317
+ }
318
+
319
+ interface ScheduledTargetsSnapshot {
320
+ id: string
321
+ canonical_version: number | null
322
+ publish_at: number
323
+ updated_at: number | null
324
+ fronts_publish_trigger_token: string | null
325
+ fronts_publish_status: string | null
326
+ }
327
+
328
+ interface ChannelTargetsWriteOptions {
329
+ onlyIfNull?: boolean
330
+ expectedScheduled?: ScheduledTargetsSnapshot
331
+ }
332
+
307
333
  /**
308
334
  * Arm an item's publish targets. Rejects an empty or non-string list loudly
309
335
  * instead of storing something that would later hydrate as "complete".
310
336
  */
337
+ export function setFrontsChannelTargets(
338
+ db: D1Database,
339
+ contentId: string,
340
+ targets: readonly string[],
341
+ ): Promise<void>
342
+ export function setFrontsChannelTargets(
343
+ db: D1Database,
344
+ contentId: string,
345
+ targets: readonly string[],
346
+ options: ChannelTargetsWriteOptions,
347
+ ): Promise<boolean>
311
348
  export async function setFrontsChannelTargets(
312
349
  db: D1Database,
313
350
  contentId: string,
314
351
  targets: readonly string[],
315
- ): Promise<void> {
352
+ options?: ChannelTargetsWriteOptions,
353
+ ): Promise<void | boolean> {
316
354
  const normalized = normalizeChannelTargets(targets)
317
355
  if ('code' in normalized) {
318
356
  throw new FrontsPublishIntentError(
@@ -321,10 +359,83 @@ export async function setFrontsChannelTargets(
321
359
  'targets must be a non-empty list of non-empty strings',
322
360
  )
323
361
  }
324
- await db
325
- .prepare('UPDATE content_items SET fronts_channel_targets = ? WHERE id = ?')
326
- .bind(JSON.stringify(normalized.targets), contentId)
362
+ const predicates = ['id = ?']
363
+ const values: unknown[] = [JSON.stringify(normalized.targets), contentId]
364
+ if (options?.onlyIfNull || options?.expectedScheduled) {
365
+ predicates.push('fronts_channel_targets IS NULL', 'deleted_at IS NULL')
366
+ }
367
+ if (options?.expectedScheduled) {
368
+ const expected = options.expectedScheduled
369
+ if (expected.id !== contentId)
370
+ throw new Error('Channel target snapshot belongs to different content')
371
+ predicates.push(
372
+ "status = 'scheduled'",
373
+ 'canonical_version IS ?',
374
+ 'publish_at IS ?',
375
+ 'updated_at IS ?',
376
+ 'fronts_publish_trigger_token IS ?',
377
+ 'fronts_publish_status IS ?',
378
+ )
379
+ values.push(
380
+ expected.canonical_version,
381
+ expected.publish_at,
382
+ expected.updated_at,
383
+ expected.fronts_publish_trigger_token,
384
+ expected.fronts_publish_status,
385
+ )
386
+ }
387
+ const result = await db
388
+ .prepare(
389
+ `UPDATE content_items SET fronts_channel_targets = ? WHERE ${predicates.join(' AND ')}`,
390
+ )
391
+ .bind(...values)
327
392
  .run()
393
+ // Preserve the original explicit setter contract. CAS callers need a proved
394
+ // change count, including an honest false when another writer won.
395
+ if (!options) return
396
+ const changes = result.meta?.changes
397
+ if (
398
+ result.success === false ||
399
+ typeof changes !== 'number' ||
400
+ !Number.isInteger(changes) ||
401
+ changes < 0 ||
402
+ changes > 1
403
+ ) {
404
+ throw new Error('Fronts channel target write did not report a valid change count')
405
+ }
406
+ return changes === 1
407
+ }
408
+
409
+ /** A bounded repair requested by Go; all rows keep their current publish/media identity. */
410
+ export async function backfillFrontsChannelTargets(
411
+ db: D1Database,
412
+ targets: readonly string[],
413
+ limit: number,
414
+ ): Promise<{ examined: number; armed: number; skipped: number; contentIds: string[] }> {
415
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50)
416
+ throw new Error('Invalid channel target backfill limit')
417
+ const selected = await db
418
+ .prepare(`SELECT id, canonical_version, publish_at, updated_at,
419
+ fronts_publish_trigger_token, fronts_publish_status
420
+ FROM content_items
421
+ WHERE status = 'scheduled' AND deleted_at IS NULL
422
+ AND publish_at IS NOT NULL AND fronts_channel_targets IS NULL
423
+ ORDER BY publish_at ASC, id ASC LIMIT ?`)
424
+ .bind(limit)
425
+ .all<ScheduledTargetsSnapshot>()
426
+ if (selected.success === false || !Array.isArray(selected.results))
427
+ throw new Error('Fronts channel target selection failed')
428
+ const contentIds: string[] = []
429
+ for (const row of selected.results) {
430
+ if (await setFrontsChannelTargets(db, row.id, targets, { expectedScheduled: row }))
431
+ contentIds.push(row.id)
432
+ }
433
+ return {
434
+ examined: selected.results.length,
435
+ armed: contentIds.length,
436
+ skipped: selected.results.length - contentIds.length,
437
+ contentIds,
438
+ }
328
439
  }
329
440
 
330
441
  // --- hydration -------------------------------------------------------------
@@ -617,6 +728,176 @@ async function loadContentRefSources(
617
728
  return { articles, videos, podcasts, media }
618
729
  }
619
730
 
731
+ /** Preparation and publication projections share the same source validation. */
732
+ async function videoSourceRef(
733
+ row: IntentCandidateRow,
734
+ found: VideoRefRow | undefined,
735
+ ): Promise<FrontsPublishVideoSourceRef | null> {
736
+ const correlation = found?.processing_correlation_id?.trim()
737
+ if (
738
+ !found ||
739
+ !validPodcastSourceUrl(found.processing_source_url) ||
740
+ !videoSourceIngestableOverHttps(found.processing_source_kind) ||
741
+ !correlation ||
742
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlation)
743
+ )
744
+ return null
745
+ if (
746
+ typeof found.duration_seconds !== 'number' ||
747
+ !Number.isFinite(found.duration_seconds) ||
748
+ found.duration_seconds <= 0
749
+ ) {
750
+ throw new FrontsPublishIntentError(
751
+ 'video_source_duration_unavailable',
752
+ row.id,
753
+ 'Video source duration is not verified.',
754
+ )
755
+ }
756
+ return {
757
+ kind: 'https',
758
+ url: found.processing_source_url,
759
+ duration_seconds: found.duration_seconds,
760
+ generation: await frontsVideoSourceGeneration({
761
+ content_id: row.id,
762
+ slug: row.slug,
763
+ canonical_version: row.canonical_version as number,
764
+ processing_source_url: found.processing_source_url,
765
+ processing_source_kind: found.processing_source_kind,
766
+ processing_trigger_token: found.processing_trigger_token,
767
+ }),
768
+ correlation_id: correlation,
769
+ visibility: row.visibility === 'premium' ? 'premium' : 'free',
770
+ }
771
+ }
772
+
773
+ async function podcastSourceRef(
774
+ row: IntentCandidateRow,
775
+ found: PodcastRefRow | undefined,
776
+ ): Promise<FrontsPublishPodcastSourceRef | null> {
777
+ if (
778
+ !found ||
779
+ !validPodcastSourceUrl(found.processing_source_url) ||
780
+ found.processing_source_kind !== 'https'
781
+ )
782
+ return null
783
+ if (
784
+ typeof found.duration_seconds !== 'number' ||
785
+ !Number.isFinite(found.duration_seconds) ||
786
+ found.duration_seconds <= 0
787
+ ) {
788
+ throw new FrontsPublishIntentError(
789
+ 'podcast_source_duration_unavailable',
790
+ row.id,
791
+ 'Podcast source duration is not verified.',
792
+ )
793
+ }
794
+ return {
795
+ kind: 'https',
796
+ url: found.processing_source_url,
797
+ duration_seconds: found.duration_seconds,
798
+ generation: await frontsPodcastSourceGeneration({
799
+ content_id: row.id,
800
+ slug: row.slug,
801
+ canonical_version: row.canonical_version as number,
802
+ processing_source_url: found.processing_source_url,
803
+ processing_source_kind: found.processing_source_kind,
804
+ processing_trigger_token: found.processing_trigger_token,
805
+ }),
806
+ }
807
+ }
808
+
809
+ /** A captured source is preparation authority, never publication authority. */
810
+ export interface FrontsMediaPreparation {
811
+ content_id: string
812
+ type: 'video' | 'podcast'
813
+ slug: string
814
+ channel: string | null
815
+ canonical_version: number
816
+ /** The actual slot, if any; never invented for a draft. */
817
+ publish_at: number | null
818
+ /** Actual capture time; unknown for a legacy token. */
819
+ captured_at: number | null
820
+ video_source: FrontsPublishVideoSourceRef | null
821
+ podcast_source: FrontsPublishPodcastSourceRef | null
822
+ }
823
+
824
+ export interface FrontsMediaPreparations {
825
+ version: 1
826
+ items: FrontsMediaPreparation[]
827
+ incomplete: Array<{ content_id: string; code: string; message: string }>
828
+ }
829
+
830
+ /**
831
+ * Extend the existing authenticated poll with captured media, independently of
832
+ * publish_at/status=scheduled. No publish token, fake deadline or new queue is
833
+ * minted by this read. A finite complete response fails loudly on overflow.
834
+ */
835
+ export async function hydrateFrontsMediaPreparations(
836
+ db: D1Database,
837
+ options: Pick<
838
+ HydrateFrontsPublishIntentsOptions,
839
+ 'limit' | 'videoSourceProductionEnabled' | 'podcastSourceProductionEnabled'
840
+ > = {},
841
+ ): Promise<FrontsMediaPreparations> {
842
+ const limit = Math.min(Math.max(1, Math.floor(options.limit ?? 200)), 1000)
843
+ const result: FrontsMediaPreparations = { version: 1, items: [], incomplete: [] }
844
+ const candidates = await db
845
+ .prepare(`SELECT ${CANDIDATE_COLUMNS} FROM content_items
846
+ WHERE deleted_at IS NULL AND status IN ('draft', 'review', 'scheduled', 'published')
847
+ AND ((type = 'video' AND ? = 1 AND EXISTS (
848
+ SELECT 1 FROM video_content v WHERE v.content_id = content_items.id
849
+ AND v.processing_trigger_token IS NOT NULL
850
+ AND (v.hls_ready <> 1 OR v.hls_manifest_url IS NULL OR v.transcript_ready <> 1)))
851
+ OR (type = 'podcast' AND ? = 1 AND EXISTS (
852
+ SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
853
+ AND p.processing_trigger_token IS NOT NULL
854
+ AND (trim(COALESCE(p.audio_r2_key, '')) = '' OR trim(COALESCE(p.transcript, '')) = ''))))
855
+ ORDER BY created_at ASC, id ASC LIMIT ?`)
856
+ .bind(
857
+ options.videoSourceProductionEnabled === false ? 0 : 1,
858
+ options.podcastSourceProductionEnabled === false ? 0 : 1,
859
+ limit + 1,
860
+ )
861
+ .all<IntentCandidateRow>()
862
+ const rows = candidates.results ?? []
863
+ if (rows.length > limit) throw new FrontsScheduleOverflowError(limit)
864
+ const refs = await loadContentRefSources(db, rows)
865
+ for (const row of rows) {
866
+ try {
867
+ if (!Number.isInteger(row.canonical_version) || (row.canonical_version ?? 0) <= 0) {
868
+ throw new FrontsPublishIntentError('invalid_canonical_version', row.id)
869
+ }
870
+ const video = row.type === 'video' ? await videoSourceRef(row, refs.videos.get(row.id)) : null
871
+ const podcast =
872
+ row.type === 'podcast' ? await podcastSourceRef(row, refs.podcasts.get(row.id)) : null
873
+ if (!video && !podcast)
874
+ throw new FrontsPublishIntentError(
875
+ row.type === 'video' ? 'video_media_not_ready' : 'missing_podcast_audio',
876
+ row.id,
877
+ 'Captured source is incomplete or not ingestable.',
878
+ )
879
+ const token = (row.type === 'video' ? refs.videos.get(row.id) : refs.podcasts.get(row.id))
880
+ ?.processing_trigger_token
881
+ const capturedAt = /^fronts-source-v2:([1-9][0-9]*):[0-9a-f-]{36}$/.exec(token ?? '')?.[1]
882
+ result.items.push({
883
+ content_id: row.id,
884
+ type: row.type as 'video' | 'podcast',
885
+ slug: row.slug,
886
+ channel: row.channel,
887
+ canonical_version: row.canonical_version as number,
888
+ publish_at: row.publish_at,
889
+ captured_at: capturedAt ? Number(capturedAt) : null,
890
+ video_source: video,
891
+ podcast_source: podcast,
892
+ })
893
+ } catch (error) {
894
+ if (!(error instanceof FrontsPublishIntentError)) throw error
895
+ result.incomplete.push({ content_id: row.id, code: error.code, message: error.message })
896
+ }
897
+ }
898
+ return result
899
+ }
900
+
620
901
  /** Build one intent, throwing FrontsPublishIntentError on any missing required value. */
621
902
  async function buildIntent(
622
903
  row: IntentCandidateRow,
@@ -739,41 +1020,8 @@ async function buildContentRefs(
739
1020
  message:
740
1021
  'a video intent requires video_content.hls_ready = 1 and a non-empty hls_manifest_url',
741
1022
  }
742
- const sourceUrl = found?.processing_source_url
743
- const correlationId = found?.processing_correlation_id?.trim()
744
- if (
745
- found &&
746
- validPodcastSourceUrl(sourceUrl) &&
747
- videoSourceIngestableOverHttps(found.processing_source_kind) &&
748
- correlationId &&
749
- /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlationId)
750
- ) {
751
- if (
752
- typeof found.duration_seconds !== 'number' ||
753
- !Number.isFinite(found.duration_seconds) ||
754
- found.duration_seconds <= 0
755
- ) {
756
- throw new FrontsPublishIntentError(
757
- 'video_source_duration_unavailable',
758
- row.id,
759
- 'Video source duration is not verified.',
760
- )
761
- }
762
- videoSource = {
763
- kind: 'https',
764
- url: sourceUrl,
765
- duration_seconds: found.duration_seconds,
766
- generation: await frontsVideoSourceGeneration({
767
- content_id: row.id,
768
- slug: row.slug,
769
- canonical_version: row.canonical_version as number,
770
- processing_source_url: found.processing_source_url,
771
- processing_source_kind: found.processing_source_kind,
772
- processing_trigger_token: found.processing_trigger_token,
773
- }),
774
- correlation_id: correlationId,
775
- visibility: row.visibility === 'premium' ? 'premium' : 'free',
776
- }
1023
+ videoSource = await videoSourceRef(row, found)
1024
+ if (videoSource) {
777
1025
  incomplete = {
778
1026
  content_id: row.id,
779
1027
  code: failure.code,
@@ -824,32 +1072,8 @@ async function buildContentRefs(
824
1072
  code: 'missing_podcast_audio' as const,
825
1073
  message: 'Podcast audio is not ready.',
826
1074
  }
827
- const sourceUrl = found?.processing_source_url
828
- if (found && validPodcastSourceUrl(sourceUrl) && found.processing_source_kind === 'https') {
829
- if (
830
- typeof found.duration_seconds !== 'number' ||
831
- !Number.isFinite(found.duration_seconds) ||
832
- found.duration_seconds <= 0
833
- ) {
834
- throw new FrontsPublishIntentError(
835
- 'podcast_source_duration_unavailable',
836
- row.id,
837
- 'Podcast source duration is not verified.',
838
- )
839
- }
840
- podcastSource = {
841
- kind: 'https',
842
- url: sourceUrl,
843
- duration_seconds: found.duration_seconds,
844
- generation: await frontsPodcastSourceGeneration({
845
- content_id: row.id,
846
- slug: row.slug,
847
- canonical_version: row.canonical_version as number,
848
- processing_source_url: found.processing_source_url,
849
- processing_source_kind: found.processing_source_kind,
850
- processing_trigger_token: found.processing_trigger_token,
851
- }),
852
- }
1075
+ podcastSource = await podcastSourceRef(row, found)
1076
+ if (podcastSource) {
853
1077
  incomplete = {
854
1078
  content_id: row.id,
855
1079
  code: failure.code,