@growth-labs/cms 0.8.3 → 0.8.5

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 (48) hide show
  1. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  2. package/dist/engine/foundry-dispatch.js +47 -24
  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 +17 -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 +20 -5
  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 +12 -0
  31. package/dist/schema/migrations.js.map +1 -1
  32. package/dist/schema/types.d.ts +8 -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/migrations/0031_podcast_processing_error.sql +2 -0
  38. package/package.json +1 -1
  39. package/src/engine/foundry-dispatch.ts +93 -35
  40. package/src/engine/fronts-publish-intent.ts +8 -4
  41. package/src/engine/fronts-publish.ts +46 -7
  42. package/src/engine/index.ts +3 -0
  43. package/src/engine/publication-current-guard.ts +86 -0
  44. package/src/engine/publication.ts +411 -59
  45. package/src/engine/publisher.ts +19 -9
  46. package/src/routes/content.ts +23 -5
  47. package/src/schema/migrations.ts +12 -0
  48. package/src/schema/types.ts +8 -0
@@ -10,8 +10,9 @@
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, D1Result } from './d1.js'
13
+ import type { D1Database, D1PreparedStatement, D1Result } from './d1.js'
14
14
  import { validPodcastSourceUrl } from './podcast-source.js'
15
+ import { mediaPublicationCaptureGuard } from './publication-current-guard.js'
15
16
 
16
17
  interface PodcastDispatchSnapshot {
17
18
  content_id: string
@@ -132,7 +133,9 @@ export async function dispatchToFoundry(
132
133
  ): Promise<DispatchResult> {
133
134
  const content = await db
134
135
  .prepare(
135
- 'SELECT id, type, slug, title, description, canonical_version, status, deleted_at FROM content_items WHERE id = ? LIMIT 1',
136
+ `SELECT id, type, slug, title, description, canonical_version, status, deleted_at,
137
+ visibility, published_revision_id, published_at, publish_at, publish_tz,
138
+ fronts_publish_status, fronts_publish_trigger_token FROM content_items WHERE id = ? LIMIT 1`,
136
139
  )
137
140
  .bind(contentId)
138
141
  .first<{
@@ -144,6 +147,13 @@ export async function dispatchToFoundry(
144
147
  canonical_version: number
145
148
  status: string
146
149
  deleted_at: number | null
150
+ visibility: string
151
+ published_revision_id: string | null
152
+ published_at: number | null
153
+ publish_at: number | null
154
+ publish_tz: string | null
155
+ fronts_publish_status: string | null
156
+ fronts_publish_trigger_token: string | null
147
157
  }>()
148
158
  if (!content) {
149
159
  throw new Error(`dispatchToFoundry: content ${contentId} not found`)
@@ -215,12 +225,21 @@ export async function dispatchToFoundry(
215
225
  throw new Error(`dispatchToFoundry: podcast ${contentId} is inactive`)
216
226
  }
217
227
  podcastSnapshot = podcastRow
218
- sourceUrl = podcastRow.audio_r2_key?.trim() ?? ''
228
+ const selectedSource = podcastRow.processing_source_url?.trim()
229
+ if (
230
+ selectedSource &&
231
+ (podcastRow.processing_source_kind !== 'https' || !validPodcastSourceUrl(selectedSource))
232
+ ) {
233
+ throw new Error(`dispatchToFoundry: content ${contentId} has an invalid podcast source URL`)
234
+ }
235
+ sourceUrl = selectedSource || podcastRow.audio_r2_key?.trim() || ''
219
236
  if (!sourceUrl) {
220
- throw new Error(`dispatchToFoundry: content ${contentId} has no podcast audio R2 key`)
237
+ throw new Error(
238
+ `dispatchToFoundry: content ${contentId} has no podcast source or audio R2 key`,
239
+ )
221
240
  }
222
241
  kind = 'podcast'
223
- sourceKind = 'r2_key'
242
+ sourceKind = selectedSource ? 'https' : 'r2_key'
224
243
  videoId = normalizeVideoId(content.slug, content.slug)
225
244
  durationSeconds = podcastRow.duration_seconds
226
245
  }
@@ -249,6 +268,7 @@ export async function dispatchToFoundry(
249
268
 
250
269
  const triggerToken = createFoundrySourceCaptureToken()
251
270
  const processingTriggerTokenHash = `sha256:${await sha256Hex(triggerToken)}`
271
+ const publicationGuard = mediaPublicationCaptureGuard(content, processingTriggerTokenHash)
252
272
  const { correlationId, podcastSource } = await hook.dispatchVideo({
253
273
  contentId,
254
274
  sourceUrl,
@@ -273,6 +293,7 @@ export async function dispatchToFoundry(
273
293
  processing_source_kind = ?,
274
294
  processing_correlation_id = ?,
275
295
  processing_trigger_token = ?,
296
+ processing_publication_guard = ?,
276
297
  processing_started_at = ?,
277
298
  processing_completed_at = NULL,
278
299
  processing_last_event_at = NULL,
@@ -288,13 +309,17 @@ export async function dispatchToFoundry(
288
309
  AND processing_trigger_token IS ? AND processing_correlation_id IS ?
289
310
  AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = video_content.content_id
290
311
  AND ci.type = 'video' AND ci.slug = ? AND ci.canonical_version = ?
291
- AND ci.status = ? AND ci.deleted_at IS NULL)`,
312
+ AND ci.status = ? AND ci.deleted_at IS NULL
313
+ AND ci.visibility IS ? AND ci.published_revision_id IS ? AND ci.published_at IS ?
314
+ AND ci.publish_at IS ? AND ci.publish_tz IS ?
315
+ AND ci.fronts_publish_status IS ? AND ci.fronts_publish_trigger_token IS ?)`,
292
316
  )
293
317
  .bind(
294
318
  videoId,
295
319
  sourceKind,
296
320
  correlationId,
297
321
  triggerToken,
322
+ publicationGuard,
298
323
  now,
299
324
  contentId,
300
325
  videoRow.video_id,
@@ -306,6 +331,13 @@ export async function dispatchToFoundry(
306
331
  content.slug,
307
332
  content.canonical_version,
308
333
  content.status,
334
+ content.visibility,
335
+ content.published_revision_id,
336
+ content.published_at,
337
+ content.publish_at,
338
+ content.publish_tz,
339
+ content.fronts_publish_status,
340
+ content.fronts_publish_trigger_token,
309
341
  )
310
342
  .run()
311
343
  if (written.meta?.changes !== 1)
@@ -330,16 +362,20 @@ export async function dispatchToFoundry(
330
362
  // source/audio/editor snapshot is still current. No stale acknowledgement
331
363
  // can restore a replaced source or overwrite a newer processing token.
332
364
  const written = await db
333
- .prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?, transcript = ''
365
+ .prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?, processing_publication_guard = ?, processing_state = 'queued', processing_error = NULL, transcript = ''
334
366
  WHERE content_id = ? AND audio_r2_key IS ? AND duration_seconds IS ? AND transcript IS ?
335
367
  AND processing_source_url IS ? AND processing_source_kind IS ? AND processing_trigger_token IS ?
336
368
  AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = podcast_content.content_id
337
369
  AND ci.type = 'podcast' AND ci.slug = ? AND ci.canonical_version = ?
338
- AND ci.status = ? AND ci.deleted_at IS NULL)`)
370
+ AND ci.status = ? AND ci.deleted_at IS NULL
371
+ AND ci.visibility IS ? AND ci.published_revision_id IS ? AND ci.published_at IS ?
372
+ AND ci.publish_at IS ? AND ci.publish_tz IS ?
373
+ AND ci.fronts_publish_status IS ? AND ci.fronts_publish_trigger_token IS ?)`)
339
374
  .bind(
340
375
  capturedUrl,
341
376
  capturedKind,
342
377
  triggerToken,
378
+ publicationGuard,
343
379
  contentId,
344
380
  podcastSnapshot.audio_r2_key,
345
381
  podcastSnapshot.duration_seconds,
@@ -350,6 +386,13 @@ export async function dispatchToFoundry(
350
386
  content.slug,
351
387
  content.canonical_version,
352
388
  content.status,
389
+ content.visibility,
390
+ content.published_revision_id,
391
+ content.published_at,
392
+ content.publish_at,
393
+ content.publish_tz,
394
+ content.fronts_publish_status,
395
+ content.fronts_publish_trigger_token,
353
396
  )
354
397
  .run()
355
398
  if (written.meta?.changes !== 1)
@@ -423,14 +466,19 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
423
466
  return false // no matching job for this trigger token
424
467
  }
425
468
 
426
- // Insert the event row.
469
+ // A failed event is consumed atomically with terminal state and guard revocation.
470
+ // Otherwise a transient media write failure would make its retry a no-op.
427
471
  const now = Math.floor(Date.now() / 1000)
428
- await db
472
+ const eventStatement = db
429
473
  .prepare(
430
474
  'INSERT INTO foundry_callback_events (event_id, event_kind, correlation_id, received_at) VALUES (?, ?, ?, ?)',
431
475
  )
432
476
  .bind(eventId, eventKind, correlationId, now)
433
- .run()
477
+ if (nextState !== 'failed') await eventStatement.run()
478
+ const applyMediaUpdate = async (statement: D1PreparedStatement): Promise<D1Result> =>
479
+ nextState === 'failed'
480
+ ? (await db.batch([statement, eventStatement]))[0]!
481
+ : await statement.run()
434
482
 
435
483
  // Advance the state machine.
436
484
  const isTerminal = (TERMINAL_STATES as string[]).includes(nextState)
@@ -442,23 +490,31 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
442
490
  typeof durationSeconds === 'number' && Number.isFinite(durationSeconds)
443
491
  ? Math.max(0, Math.floor(durationSeconds))
444
492
  : null
445
- if (nextTranscript !== null || nextDurationSeconds !== null) {
446
- const updated = await db
447
- .prepare(
448
- `UPDATE podcast_content
449
- SET transcript = CASE WHEN ? THEN ? ELSE transcript END,
493
+ {
494
+ const updated = await applyMediaUpdate(
495
+ db
496
+ .prepare(
497
+ `UPDATE podcast_content
498
+ SET processing_state = CASE WHEN processing_state = 'failed' THEN processing_state ELSE ? END,
499
+ processing_error = CASE WHEN ? = 'failed' THEN ? ELSE processing_error END,
500
+ processing_publication_guard = CASE WHEN ? = 'failed' THEN NULL ELSE processing_publication_guard END,
501
+ transcript = CASE WHEN ? THEN ? ELSE transcript END,
450
502
  duration_seconds = CASE WHEN ? THEN ? ELSE duration_seconds END
451
503
  WHERE content_id = ? AND processing_trigger_token = ?`,
452
- )
453
- .bind(
454
- nextTranscript !== null ? 1 : 0,
455
- nextTranscript,
456
- nextDurationSeconds !== null ? 1 : 0,
457
- nextDurationSeconds,
458
- podcastRow.content_id,
459
- triggerToken,
460
- )
461
- .run()
504
+ )
505
+ .bind(
506
+ nextState,
507
+ nextState,
508
+ error ?? null,
509
+ nextState,
510
+ nextTranscript !== null ? 1 : 0,
511
+ nextTranscript,
512
+ nextDurationSeconds !== null ? 1 : 0,
513
+ nextDurationSeconds,
514
+ podcastRow.content_id,
515
+ triggerToken,
516
+ ),
517
+ )
462
518
  if (updated.meta?.changes !== 1) return false
463
519
  }
464
520
 
@@ -504,23 +560,25 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
504
560
  let updated: D1Result
505
561
 
506
562
  if (nextState === 'failed') {
507
- updated = await db
508
- .prepare(
509
- `UPDATE video_content
510
- SET processing_state = ?,
563
+ updated = await applyMediaUpdate(
564
+ db
565
+ .prepare(
566
+ `UPDATE video_content
567
+ SET processing_state = CASE WHEN processing_state = 'failed' THEN processing_state ELSE ? END,
568
+ processing_publication_guard = NULL,
511
569
  processing_last_event_at = ?,
512
570
  processing_completed_at = ?,
513
571
  processing_error = ?
514
572
  WHERE content_id = ? AND processing_trigger_token = ?`,
515
- )
516
- .bind(nextState, now, now, error ?? null, row.content_id, triggerToken)
517
- .run()
573
+ )
574
+ .bind(nextState, now, now, error ?? null, row.content_id, triggerToken),
575
+ )
518
576
  } else if (isTerminal) {
519
577
  // 'ready'
520
578
  updated = await db
521
579
  .prepare(
522
580
  `UPDATE video_content
523
- SET processing_state = ?,
581
+ SET processing_state = CASE WHEN processing_state = 'failed' THEN processing_state ELSE ? END,
524
582
  processing_last_event_at = ?,
525
583
  processing_completed_at = ?
526
584
  WHERE content_id = ? AND processing_trigger_token = ?`,
@@ -530,7 +588,7 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
530
588
  } else {
531
589
  updated = await db
532
590
  .prepare(
533
- 'UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ? AND processing_trigger_token = ?',
591
+ "UPDATE video_content SET processing_state = CASE WHEN processing_state = 'failed' THEN processing_state ELSE ? END, processing_last_event_at = ? WHERE content_id = ? AND processing_trigger_token = ?",
534
592
  )
535
593
  .bind(nextState, now, row.content_id, triggerToken)
536
594
  .run()
@@ -538,6 +538,7 @@ interface VideoRefRow {
538
538
  processing_source_url: string | null
539
539
  processing_source_kind: string | null
540
540
  processing_trigger_token: string | null
541
+ processing_state: string | null
541
542
  processing_correlation_id: string | null
542
543
  }
543
544
 
@@ -549,6 +550,7 @@ interface PodcastRefRow {
549
550
  processing_source_url: string | null
550
551
  processing_source_kind: string | null
551
552
  processing_trigger_token: string | null
553
+ processing_state: string | null
552
554
  }
553
555
 
554
556
  interface MediaRow {
@@ -682,7 +684,7 @@ async function loadContentRefSources(
682
684
  .prepare(
683
685
  `SELECT content_id, hls_ready, hls_manifest_url, hls_poster_url, duration_seconds,
684
686
  thumbnail_image_id, processing_source_url, processing_source_kind,
685
- processing_trigger_token, processing_correlation_id
687
+ processing_trigger_token, processing_correlation_id, processing_state
686
688
  FROM video_content WHERE content_id IN (${placeholders(videoIds.length)})`,
687
689
  )
688
690
  .bind(...videoIds)
@@ -694,7 +696,7 @@ async function loadContentRefSources(
694
696
  if (podcastIds.length > 0) {
695
697
  const res = await db
696
698
  .prepare(
697
- `SELECT content_id, transcript, audio_r2_key, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token FROM podcast_content
699
+ `SELECT content_id, transcript, audio_r2_key, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token, processing_state FROM podcast_content
698
700
  WHERE content_id IN (${placeholders(podcastIds.length)})`,
699
701
  )
700
702
  .bind(...podcastIds)
@@ -753,6 +755,7 @@ async function videoSourceRef(
753
755
  'Video source duration is not verified.',
754
756
  )
755
757
  }
758
+ if (found.processing_state !== 'queued' || !found.processing_trigger_token?.trim()) return null
756
759
  return {
757
760
  kind: 'https',
758
761
  url: found.processing_source_url,
@@ -791,6 +794,7 @@ async function podcastSourceRef(
791
794
  'Podcast source duration is not verified.',
792
795
  )
793
796
  }
797
+ if (found.processing_state !== 'queued' || !found.processing_trigger_token?.trim()) return null
794
798
  return {
795
799
  kind: 'https',
796
800
  url: found.processing_source_url,
@@ -846,11 +850,11 @@ export async function hydrateFrontsMediaPreparations(
846
850
  WHERE deleted_at IS NULL AND status IN ('draft', 'review', 'scheduled', 'published')
847
851
  AND ((type = 'video' AND ? = 1 AND EXISTS (
848
852
  SELECT 1 FROM video_content v WHERE v.content_id = content_items.id
849
- AND v.processing_trigger_token IS NOT NULL
853
+ AND v.processing_trigger_token IS NOT NULL AND v.processing_state = 'queued'
850
854
  AND (v.hls_ready <> 1 OR v.hls_manifest_url IS NULL OR v.transcript_ready <> 1)))
851
855
  OR (type = 'podcast' AND ? = 1 AND EXISTS (
852
856
  SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
853
- AND p.processing_trigger_token IS NOT NULL
857
+ AND p.processing_trigger_token IS NOT NULL AND p.processing_state = 'queued'
854
858
  AND (trim(COALESCE(p.audio_r2_key, '')) = '' OR trim(COALESCE(p.transcript, '')) = ''))))
855
859
  ORDER BY created_at ASC, id ASC LIMIT ?`)
856
860
  .bind(
@@ -244,9 +244,17 @@ export async function applyFrontsPublishCallback(
244
244
  // 2. Resolve the target item. A missing item is NOT recorded, so a retry can
245
245
  // still land once the item exists.
246
246
  const item = await db
247
- .prepare('SELECT id, status, fronts_publish_status FROM content_items WHERE id = ? LIMIT 1')
247
+ .prepare(
248
+ 'SELECT id, status, fronts_publish_status, canonical_version, publish_at FROM content_items WHERE id = ? LIMIT 1',
249
+ )
248
250
  .bind(input.contentId)
249
- .first<{ id: string; status: string; fronts_publish_status: FrontsPublishStatus | null }>()
251
+ .first<{
252
+ id: string
253
+ status: string
254
+ fronts_publish_status: FrontsPublishStatus | null
255
+ canonical_version: number
256
+ publish_at: number | null
257
+ }>()
250
258
  if (!item) {
251
259
  return {
252
260
  ok: false,
@@ -259,6 +267,29 @@ export async function applyFrontsPublishCallback(
259
267
  }
260
268
  }
261
269
 
270
+ const staleOccurrence = (): FrontsPublishCallbackResult => ({
271
+ ok: false,
272
+ deduped: false,
273
+ status: input.status,
274
+ published: false,
275
+ attemptId: null,
276
+ contentFound: true,
277
+ ledgerError: null,
278
+ rejection: {
279
+ code: 'publish_snapshot_changed',
280
+ status: 409,
281
+ message: 'The publication occurrence has been superseded.',
282
+ },
283
+ })
284
+ // Non-live callbacks carry the same occurrence authority as a live receipt.
285
+ // An old progress/failure event must not strand a newly queued publication.
286
+ if (
287
+ (input.version != null && input.version !== item.canonical_version) ||
288
+ (item.status !== 'published' && input.publishAt != null && input.publishAt !== item.publish_at)
289
+ ) {
290
+ return staleOccurrence()
291
+ }
292
+
262
293
  // 3. On a confirmed `live` WITH a receipt, drive the publish through the 2PC
263
294
  // ledger — record attempt + commit — flipping the item to published.
264
295
  // A `live` on an already-published item just confirms it (no re-publish,
@@ -543,9 +574,9 @@ export async function applyFrontsPublishCallback(
543
574
  // ordering and the terminal `live`/published state.
544
575
  const latestSeenRow = await db
545
576
  .prepare(
546
- 'SELECT MAX(occurred_at) AS latest FROM fronts_publish_events WHERE content_id = ? AND occurred_at IS NOT NULL',
577
+ 'SELECT MAX(occurred_at) AS latest FROM fronts_publish_events WHERE content_id = ? AND occurred_at IS NOT NULL AND (? IS NULL OR version IS ?)',
547
578
  )
548
- .bind(input.contentId)
579
+ .bind(input.contentId, input.version ?? null, input.version ?? null)
549
580
  .first<{ latest: number | null }>()
550
581
  if (
551
582
  shouldApplyFrontsMarker(
@@ -556,12 +587,20 @@ export async function applyFrontsPublishCallback(
556
587
  latestSeenRow?.latest ?? null,
557
588
  )
558
589
  ) {
559
- await db
590
+ const marker = await db
560
591
  .prepare(
561
- 'UPDATE content_items SET fronts_publish_status = ?, updated_at = unixepoch() WHERE id = ?',
592
+ `UPDATE content_items SET fronts_publish_status = ?, updated_at = unixepoch()
593
+ WHERE id = ? AND canonical_version = ? AND status = ? AND publish_at IS ? AND deleted_at IS NULL`,
594
+ )
595
+ .bind(
596
+ input.status,
597
+ input.contentId,
598
+ item.canonical_version,
599
+ published ? 'published' : item.status,
600
+ published ? null : item.publish_at,
562
601
  )
563
- .bind(input.status, input.contentId)
564
602
  .run()
603
+ if (marker.meta?.changes !== 1) return staleOccurrence()
565
604
  }
566
605
 
567
606
  // 5. Record the durable, idempotent event row (the "processed" marker). The
@@ -162,9 +162,12 @@ export {
162
162
  } from './podcast-source.js'
163
163
  // Bounded one-item publication kernel.
164
164
  export {
165
+ type CommitPreparedPublishedMediaInput,
166
+ type CommitPreparedPublishedMediaResult,
165
167
  ContentArchiveImportLimitError,
166
168
  type ContentArchiveInput,
167
169
  type ContentArchiveItem,
170
+ commitPreparedPublishedMedia,
168
171
  DEFAULT_PENDING_PUBLICATION_ATTEMPT_LIMIT,
169
172
  DEFAULT_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS,
170
173
  type ImportContentArchiveOptions,
@@ -1,5 +1,38 @@
1
1
  /** A guard lives with its revision, so retries retain the same editorial proof. */
2
2
  export const CURRENT_PUBLICATION_GUARD = 'cms.scheduled-publication-current.v1'
3
+ export const CURRENT_MEDIA_PUBLICATION_GUARD = 'cms.published-media-current.v1'
4
+
5
+ export const MEDIA_PUBLICATION_ITEM_FIELDS = [
6
+ 'id',
7
+ 'type',
8
+ 'slug',
9
+ 'status',
10
+ 'deleted_at',
11
+ 'canonical_version',
12
+ 'visibility',
13
+ 'published_revision_id',
14
+ 'published_at',
15
+ 'publish_at',
16
+ 'publish_tz',
17
+ 'fronts_publish_status',
18
+ 'fronts_publish_trigger_token',
19
+ ] as const
20
+
21
+ /** Bound to the capture CAS, separately from the reusable media generation. */
22
+ export function mediaPublicationCaptureGuard(
23
+ item: Record<string, unknown>,
24
+ triggerTokenHash: string,
25
+ ): string | null {
26
+ if (item.status !== 'published' || !item.published_revision_id || item.deleted_at != null)
27
+ return null
28
+ return JSON.stringify({
29
+ version: 1,
30
+ trigger_token_hash: triggerTokenHash,
31
+ item: Object.fromEntries(
32
+ MEDIA_PUBLICATION_ITEM_FIELDS.map((field) => [field, item[field] ?? null]),
33
+ ),
34
+ })
35
+ }
3
36
 
4
37
  export class PublicationCurrentSnapshotError extends Error {
5
38
  constructor() {
@@ -45,6 +78,42 @@ const VIDEO_FIELDS = [
45
78
  'transcript_url',
46
79
  ] as const
47
80
 
81
+ export const PUBLISHED_VIDEO_MEDIA_FIELDS = [
82
+ ...VIDEO_FIELDS,
83
+ 'hls_poster_url',
84
+ 'source_fetched',
85
+ 'processing_started_at',
86
+ 'processing_completed_at',
87
+ 'processing_last_event_at',
88
+ 'processing_error',
89
+ ] as const
90
+ export const PUBLISHED_PODCAST_MEDIA_FIELDS = PODCAST_FIELDS
91
+ // Delivery observation timestamps can advance on a duplicate/captions event;
92
+ // they do not change the prepared bytes or authorize a different source.
93
+ const MEDIA_VIDEO_GUARD_FIELDS = [...VIDEO_FIELDS, 'hls_poster_url'] as const
94
+
95
+ export function assertCurrentMediaPublicationSnapshot(
96
+ current: Record<string, unknown>,
97
+ expected: Record<string, unknown>,
98
+ ): void {
99
+ const item = snapshotRecord(current.item)
100
+ const expectedItem = snapshotRecord(expected.item)
101
+ const content = snapshotRecord(current.content)
102
+ const expectedContent = snapshotRecord(expected.content)
103
+ const fields = item.type === 'video' ? MEDIA_VIDEO_GUARD_FIELDS : PUBLISHED_PODCAST_MEDIA_FIELDS
104
+ if (
105
+ item.status !== 'published' ||
106
+ item.deleted_at != null ||
107
+ !['video', 'podcast'].includes(String(item.type)) ||
108
+ MEDIA_PUBLICATION_ITEM_FIELDS.some((field) => item[field] !== expectedItem[field]) ||
109
+ [...fields, 'processing_publication_guard'].some(
110
+ (field) => content[field] !== expectedContent[field],
111
+ )
112
+ ) {
113
+ throw new PublicationCurrentSnapshotError()
114
+ }
115
+ }
116
+
48
117
  export function assertCurrentPublicationSnapshot(
49
118
  current: Record<string, unknown>,
50
119
  expected: Record<string, unknown>,
@@ -75,6 +144,17 @@ export function currentPublicationSqlGuard(revisionAlias: string): string {
75
144
  SELECT 1 FROM ${table} current_media WHERE current_media.content_id = content_items.id
76
145
  AND ${fields.map((field) => `current_media.${field} IS ${content(field)}`).join(' AND ')}
77
146
  )`
147
+ const mediaItem = (field: string) =>
148
+ `json_extract(${revisionAlias}.payload_json, '$.publication_current_snapshot.item.${field}')`
149
+ const mediaMatches = (table: string, fields: readonly string[]) => `EXISTS (
150
+ SELECT 1 FROM ${table} current_media WHERE current_media.content_id = content_items.id
151
+ AND ${[...fields, 'processing_publication_guard']
152
+ .map(
153
+ (field) =>
154
+ `current_media.${field} IS json_extract(${revisionAlias}.payload_json, '$.publication_current_snapshot.content.${field}')`,
155
+ )
156
+ .join(' AND ')}
157
+ )`
78
158
  return `(json_extract(${revisionAlias}.payload_json, '$.publication_guard') IS NULL OR (
79
159
  json_extract(${revisionAlias}.payload_json, '$.publication_guard') = '${CURRENT_PUBLICATION_GUARD}'
80
160
  AND content_items.status = 'scheduled' AND content_items.deleted_at IS NULL
@@ -83,5 +163,11 @@ export function currentPublicationSqlGuard(revisionAlias: string): string {
83
163
  .join(' AND ')}
84
164
  AND (${item('type')} <> 'podcast' OR ${matches('podcast_content', PODCAST_FIELDS)})
85
165
  AND (${item('type')} <> 'video' OR ${matches('video_content', VIDEO_FIELDS)})
166
+ ) OR (
167
+ json_extract(${revisionAlias}.payload_json, '$.publication_guard') = '${CURRENT_MEDIA_PUBLICATION_GUARD}'
168
+ AND content_items.status = 'published' AND content_items.deleted_at IS NULL
169
+ AND ${MEDIA_PUBLICATION_ITEM_FIELDS.map((field) => `content_items.${field} IS ${mediaItem(field)}`).join(' AND ')}
170
+ AND ((${mediaItem('type')} = 'video' AND ${mediaMatches('video_content', MEDIA_VIDEO_GUARD_FIELDS)})
171
+ OR (${mediaItem('type')} = 'podcast' AND ${mediaMatches('podcast_content', PUBLISHED_PODCAST_MEDIA_FIELDS)}))
86
172
  ))`
87
173
  }