@growth-labs/cms 0.8.2 → 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 (98) 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 +115 -39
  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 +187 -57
  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 +135 -10
  20. package/dist/engine/fronts-publish.js.map +1 -1
  21. package/dist/engine/index.d.ts +2 -2
  22. package/dist/engine/index.d.ts.map +1 -1
  23. package/dist/engine/index.js +2 -2
  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/publication-current-guard.d.ts +7 -0
  30. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  31. package/dist/engine/publication-current-guard.js +66 -0
  32. package/dist/engine/publication-current-guard.js.map +1 -1
  33. package/dist/engine/publication.d.ts +30 -0
  34. package/dist/engine/publication.d.ts.map +1 -1
  35. package/dist/engine/publication.js +306 -34
  36. package/dist/engine/publication.js.map +1 -1
  37. package/dist/engine/publisher.d.ts.map +1 -1
  38. package/dist/engine/publisher.js +32 -19
  39. package/dist/engine/publisher.js.map +1 -1
  40. package/dist/engine/soft-delete.d.ts +3 -1
  41. package/dist/engine/soft-delete.d.ts.map +1 -1
  42. package/dist/engine/soft-delete.js +21 -5
  43. package/dist/engine/soft-delete.js.map +1 -1
  44. package/dist/engine/video-source.d.ts +4 -2
  45. package/dist/engine/video-source.d.ts.map +1 -1
  46. package/dist/engine/video-source.js +7 -4
  47. package/dist/engine/video-source.js.map +1 -1
  48. package/dist/routes/content.d.ts +14 -0
  49. package/dist/routes/content.d.ts.map +1 -1
  50. package/dist/routes/content.js +56 -28
  51. package/dist/routes/content.js.map +1 -1
  52. package/dist/routes/context.d.ts +6 -0
  53. package/dist/routes/context.d.ts.map +1 -1
  54. package/dist/routes/context.js.map +1 -1
  55. package/dist/routes/fronts-publish.d.ts +2 -0
  56. package/dist/routes/fronts-publish.d.ts.map +1 -1
  57. package/dist/routes/fronts-publish.js +178 -2
  58. package/dist/routes/fronts-publish.js.map +1 -1
  59. package/dist/schema/insights-ingest.d.ts +42 -42
  60. package/dist/schema/migrations.d.ts.map +1 -1
  61. package/dist/schema/migrations.js +8 -0
  62. package/dist/schema/migrations.js.map +1 -1
  63. package/dist/schema/types.d.ts +6 -0
  64. package/dist/schema/types.d.ts.map +1 -1
  65. package/dist/schema/types.js.map +1 -1
  66. package/dist/surveys/schema.d.ts +48 -48
  67. package/dist/ui/api/_content-config.d.ts.map +1 -1
  68. package/dist/ui/api/_content-config.js +7 -0
  69. package/dist/ui/api/_content-config.js.map +1 -1
  70. package/dist/ui/api/fronts/schedule.d.ts +2 -0
  71. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  72. package/dist/ui/api/fronts/schedule.js +2 -0
  73. package/dist/ui/api/fronts/schedule.js.map +1 -1
  74. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  75. package/dist/ui/editor/ContentForm.js +2 -2
  76. package/dist/ui/editor/ContentForm.js.map +1 -1
  77. package/migrations/0030_media_publication_guard.sql +5 -0
  78. package/package.json +1 -1
  79. package/src/engine/ai-dek-notifications.ts +19 -2
  80. package/src/engine/ai-dek.ts +131 -40
  81. package/src/engine/foundry-dispatch.ts +189 -49
  82. package/src/engine/fronts-publish-intent.ts +295 -67
  83. package/src/engine/fronts-publish.ts +167 -11
  84. package/src/engine/index.ts +4 -0
  85. package/src/engine/podcast-source.ts +6 -4
  86. package/src/engine/publication-current-guard.ts +86 -0
  87. package/src/engine/publication.ts +411 -59
  88. package/src/engine/publisher.ts +37 -17
  89. package/src/engine/soft-delete.ts +28 -8
  90. package/src/engine/video-source.ts +7 -4
  91. package/src/routes/content.ts +78 -35
  92. package/src/routes/context.ts +6 -0
  93. package/src/routes/fronts-publish.ts +213 -2
  94. package/src/schema/migrations.ts +8 -0
  95. package/src/schema/types.ts +6 -0
  96. package/src/ui/api/_content-config.ts +15 -0
  97. package/src/ui/api/fronts/schedule.ts +4 -0
  98. package/src/ui/editor/ContentForm.tsx +4 -5
@@ -24,7 +24,14 @@
24
24
  // additive and gated by FRONTS_PUBLISH_ENABLED (default ON — a rollback lever,
25
25
  // not a dark-staging gate).
26
26
 
27
+ import type { LlmDispatch } from '../providers/types.js'
27
28
  import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
29
+ import {
30
+ applyGeneratedContentDek,
31
+ generateContentDekSuggestion,
32
+ loadDekGenerationContextFromSnapshot,
33
+ } from './ai-dek.js'
34
+ import { parseLockedFields } from './ai-writeback.js'
28
35
  import type { D1Database } from './d1.js'
29
36
  import { FrontsScheduleOverflowError } from './fronts-publish-intent.js'
30
37
  import { podcastPublishBlocker } from './podcast-source.js'
@@ -35,6 +42,7 @@ import {
35
42
  snapshotRecord,
36
43
  } from './publication-current-guard.js'
37
44
  import { getContentSnapshot } from './publisher.js'
45
+ import { getRevision } from './revisions.js'
38
46
 
39
47
  /** The five lifecycle states foundryd reports for a Fronts publish intent. */
40
48
  export const FRONTS_PUBLISH_STATUSES: readonly FrontsPublishStatus[] = [
@@ -161,6 +169,8 @@ export interface FrontsPublishCallbackInput {
161
169
  now?: number
162
170
  /** Optional occurrence evidence carried by current publish workers. */
163
171
  publishAt?: number | null
172
+ /** Optional existing CMS LLM hook for best-effort missing-dek generation. */
173
+ llm?: LlmDispatch
164
174
  /** The route supplies the same host hook used by the editorial publish action. */
165
175
  checkReadiness?: (item: {
166
176
  id: string
@@ -183,6 +193,10 @@ export interface FrontsPublishCallbackResult {
183
193
  ledgerError: string | null
184
194
  /** A retryable hold or stale/cancelled snapshot; never consumes event_id. */
185
195
  rejection?: { code: string; status: 404 | 409 | 503; blockers?: string[]; message: string }
196
+ /** Safe post-commit reason for a missing-dek editor notification. */
197
+ dekFailureReason?: 'ai_generation_unavailable' | 'ai_generation_timeout' | null
198
+ /** Existing lifecycle payload fields, present only for this call's committed publication. */
199
+ publishedItem?: { id: string; type: ContentType; title: string } | null
186
200
  }
187
201
 
188
202
  function hasReceipt(receipt: FrontsPublishReceipt | null | undefined): boolean {
@@ -210,6 +224,8 @@ function resolveOccurredAtSeconds(
210
224
  return Math.min(seconds, now)
211
225
  }
212
226
 
227
+ class DekGenerationTimeoutError extends Error {}
228
+
213
229
  /**
214
230
  * Apply one foundryd publish-callback. Idempotent by `event_id`: a repeat
215
231
  * delivery returns the stored outcome without re-applying. See the module
@@ -228,9 +244,17 @@ export async function applyFrontsPublishCallback(
228
244
  // 2. Resolve the target item. A missing item is NOT recorded, so a retry can
229
245
  // still land once the item exists.
230
246
  const item = await db
231
- .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
+ )
232
250
  .bind(input.contentId)
233
- .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
+ }>()
234
258
  if (!item) {
235
259
  return {
236
260
  ok: false,
@@ -243,6 +267,29 @@ export async function applyFrontsPublishCallback(
243
267
  }
244
268
  }
245
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
+
246
293
  // 3. On a confirmed `live` WITH a receipt, drive the publish through the 2PC
247
294
  // ledger — record attempt + commit — flipping the item to published.
248
295
  // A `live` on an already-published item just confirms it (no re-publish,
@@ -250,6 +297,8 @@ export async function applyFrontsPublishCallback(
250
297
  let published = item.status === 'published'
251
298
  let attemptId: string | null = null
252
299
  let ledgerError: string | null = null
300
+ let committedDekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
301
+ let committedPublishedItem: FrontsPublishCallbackResult['publishedItem'] = null
253
302
  const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
254
303
  if (shouldPublish && item.status === 'published') {
255
304
  const current = await getContentSnapshot(db, input.contentId)
@@ -326,9 +375,88 @@ export async function applyFrontsPublishCallback(
326
375
  ['publish_not_due'],
327
376
  )
328
377
  }
378
+ let dekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
379
+ const initialExcerpt =
380
+ typeof expectedItem.excerpt === 'string' ? expectedItem.excerpt.trim() : ''
381
+ const initialLocks = parseLockedFields(
382
+ typeof expectedItem.ai_locked_fields === 'string' ? expectedItem.ai_locked_fields : null,
383
+ )
384
+ if (!initialExcerpt && !initialLocks.includes('excerpt')) {
385
+ if (!input.llm) {
386
+ dekFailureReason = 'ai_generation_unavailable'
387
+ } else {
388
+ const deadlineAt = performance.now() + 8_000
389
+ let timeout: ReturnType<typeof setTimeout> | undefined
390
+ try {
391
+ const generated = await Promise.race([
392
+ (async () => {
393
+ const context = await loadDekGenerationContextFromSnapshot(db, expectedSnapshot)
394
+ if (!context) {
395
+ return {
396
+ context: null,
397
+ suggestion: {
398
+ status: 'unavailable' as const,
399
+ dek: null,
400
+ reason: 'unavailable',
401
+ },
402
+ }
403
+ }
404
+ return {
405
+ context,
406
+ suggestion: await generateContentDekSuggestion({
407
+ context,
408
+ llm: input.llm!,
409
+ }),
410
+ }
411
+ })(),
412
+ new Promise<never>((_resolve, rejectTimeout) => {
413
+ timeout = setTimeout(() => rejectTimeout(new DekGenerationTimeoutError()), 8_000)
414
+ }),
415
+ ])
416
+ if (generated.suggestion.status !== 'generated') {
417
+ dekFailureReason = 'ai_generation_unavailable'
418
+ } else if (!generated.context || performance.now() >= deadlineAt) {
419
+ dekFailureReason = 'ai_generation_timeout'
420
+ } else {
421
+ const applied = await applyGeneratedContentDek({
422
+ db,
423
+ contentId: input.contentId,
424
+ dek: generated.suggestion.dek,
425
+ context: generated.context,
426
+ expectedPublicationSnapshot: expectedSnapshot,
427
+ })
428
+ if (applied.status === 'unavailable') dekFailureReason = 'ai_generation_unavailable'
429
+ }
430
+ } catch (error) {
431
+ dekFailureReason =
432
+ error instanceof DekGenerationTimeoutError
433
+ ? 'ai_generation_timeout'
434
+ : 'ai_generation_unavailable'
435
+ } finally {
436
+ if (timeout !== undefined) clearTimeout(timeout)
437
+ }
438
+ }
439
+ }
440
+
441
+ const currentSnapshot = await getContentSnapshot(db, input.contentId)
442
+ try {
443
+ assertCurrentPublicationSnapshot(currentSnapshot ?? {}, expectedSnapshot)
444
+ } catch {
445
+ return reject(
446
+ 'publish_snapshot_changed',
447
+ 409,
448
+ 'Scheduled content or media changed before publication.',
449
+ )
450
+ }
451
+ const currentItem = snapshotRecord(currentSnapshot?.item)
452
+ const currentExcerpt = typeof currentItem.excerpt === 'string' ? currentItem.excerpt.trim() : ''
453
+ const currentLocks = parseLockedFields(
454
+ typeof currentItem.ai_locked_fields === 'string' ? currentItem.ai_locked_fields : null,
455
+ )
456
+ if (currentExcerpt || currentLocks.includes('excerpt')) dekFailureReason = null
329
457
  const blocker =
330
- expectedItem.type === 'podcast'
331
- ? podcastPublishBlocker(snapshotRecord(expectedSnapshot.content))
458
+ currentItem.type === 'podcast'
459
+ ? podcastPublishBlocker(snapshotRecord(currentSnapshot?.content))
332
460
  : null
333
461
  if (blocker) return reject('publish_not_ready', 503, blocker.message, [blocker.code])
334
462
  if (input.checkReadiness) {
@@ -337,7 +465,7 @@ export async function applyFrontsPublishCallback(
337
465
  const readiness = await Promise.race([
338
466
  input.checkReadiness({
339
467
  id: input.contentId,
340
- type: expectedItem.type as ContentType,
468
+ type: currentItem.type as ContentType,
341
469
  status: 'scheduled',
342
470
  }),
343
471
  new Promise<never>((_resolve, rejectTimeout) => {
@@ -378,11 +506,29 @@ export async function applyFrontsPublishCallback(
378
506
  ...(publishedAt !== null ? { publishedAt } : {}),
379
507
  createdBy: 'foundry.fronts-publish',
380
508
  surfaces: [surface],
381
- expectedCurrentSnapshot: expectedSnapshot,
509
+ expectedCurrentSnapshot: currentSnapshot!,
382
510
  expectedReceiptIdentity: receiptIdentity,
383
511
  })
384
512
  attemptId = receipt.attemptId
385
513
  published = true
514
+ const committedRevision = await getRevision(db, input.contentId, receipt.revisionId)
515
+ if (attemptId && committedRevision) {
516
+ const committedItem = snapshotRecord(committedRevision.payload.item)
517
+ const committedExcerpt =
518
+ typeof committedItem.excerpt === 'string' ? committedItem.excerpt.trim() : ''
519
+ const committedLocks = parseLockedFields(
520
+ typeof committedItem.ai_locked_fields === 'string'
521
+ ? committedItem.ai_locked_fields
522
+ : null,
523
+ )
524
+ committedDekFailureReason =
525
+ committedExcerpt || committedLocks.includes('excerpt') ? null : dekFailureReason
526
+ committedPublishedItem = {
527
+ id: String(committedItem.id ?? input.contentId),
528
+ type: committedItem.type as ContentType,
529
+ title: String(committedItem.title ?? ''),
530
+ }
531
+ }
386
532
  } catch (error) {
387
533
  // A concurrently published row may belong to a newer editor/source
388
534
  // revision. Only a matching committed snapshot and full receipt prove
@@ -428,9 +574,9 @@ export async function applyFrontsPublishCallback(
428
574
  // ordering and the terminal `live`/published state.
429
575
  const latestSeenRow = await db
430
576
  .prepare(
431
- '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 ?)',
432
578
  )
433
- .bind(input.contentId)
579
+ .bind(input.contentId, input.version ?? null, input.version ?? null)
434
580
  .first<{ latest: number | null }>()
435
581
  if (
436
582
  shouldApplyFrontsMarker(
@@ -441,12 +587,20 @@ export async function applyFrontsPublishCallback(
441
587
  latestSeenRow?.latest ?? null,
442
588
  )
443
589
  ) {
444
- await db
590
+ const marker = await db
445
591
  .prepare(
446
- '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,
447
601
  )
448
- .bind(input.status, input.contentId)
449
602
  .run()
603
+ if (marker.meta?.changes !== 1) return staleOccurrence()
450
604
  }
451
605
 
452
606
  // 5. Record the durable, idempotent event row (the "processed" marker). The
@@ -496,6 +650,8 @@ export async function applyFrontsPublishCallback(
496
650
  attemptId,
497
651
  contentFound: true,
498
652
  ledgerError,
653
+ dekFailureReason: committedDekFailureReason,
654
+ publishedItem: committedPublishedItem,
499
655
  }
500
656
  }
501
657
 
@@ -78,6 +78,7 @@ export type { D1Database, D1DatabaseSession, D1PreparedStatement, D1Result } fro
78
78
  export {
79
79
  applyFoundryCallback,
80
80
  type CallbackInput,
81
+ createFoundrySourceCaptureToken,
81
82
  type DispatchResult,
82
83
  dispatchToFoundry,
83
84
  type ProcessingState,
@@ -161,9 +162,12 @@ export {
161
162
  } from './podcast-source.js'
162
163
  // Bounded one-item publication kernel.
163
164
  export {
165
+ type CommitPreparedPublishedMediaInput,
166
+ type CommitPreparedPublishedMediaResult,
164
167
  ContentArchiveImportLimitError,
165
168
  type ContentArchiveInput,
166
169
  type ContentArchiveItem,
170
+ commitPreparedPublishedMedia,
167
171
  DEFAULT_PENDING_PUBLICATION_ATTEMPT_LIMIT,
168
172
  DEFAULT_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS,
169
173
  type ImportContentArchiveOptions,
@@ -9,19 +9,21 @@ export interface FrontsPodcastSourceSnapshot {
9
9
  }
10
10
 
11
11
  /**
12
- * Stable over polling, machine media callbacks and rescheduling. Canonical
13
- * editor/source changes invalidate the old production. Neither the raw source
12
+ * V1 captures keep their canonical-version binding. New v2 captures remain
13
+ * stable over editorial metadata edits, polling, callbacks and rescheduling;
14
+ * source/slug/token changes invalidate production. Neither the raw source
14
15
  * token nor URL needs to appear in callback/notification identities.
15
16
  */
16
17
  export async function frontsPodcastSourceGeneration(
17
18
  input: FrontsPodcastSourceSnapshot,
18
19
  ): Promise<string> {
20
+ const version = input.processing_trigger_token?.startsWith('fronts-source-v2:') ? 2 : 1
19
21
  const bytes = new TextEncoder().encode(
20
22
  JSON.stringify([
21
- 'fronts-podcast-source.v1',
23
+ `fronts-podcast-source.v${version}`,
22
24
  input.content_id,
23
25
  input.slug,
24
- input.canonical_version,
26
+ ...(version === 1 ? [input.canonical_version] : []),
25
27
  input.processing_source_url,
26
28
  input.processing_source_kind,
27
29
  input.processing_trigger_token,
@@ -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
  }