@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.
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +47 -24
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +8 -4
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +29 -6
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +7 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +66 -0
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publication.d.ts +30 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +306 -34
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +17 -11
- package/dist/engine/publisher.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +20 -5
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +42 -42
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +12 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +8 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/schema.d.ts +48 -48
- package/migrations/0030_media_publication_guard.sql +5 -0
- package/migrations/0031_podcast_processing_error.sql +2 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +93 -35
- package/src/engine/fronts-publish-intent.ts +8 -4
- package/src/engine/fronts-publish.ts +46 -7
- package/src/engine/index.ts +3 -0
- package/src/engine/publication-current-guard.ts +86 -0
- package/src/engine/publication.ts +411 -59
- package/src/engine/publisher.ts +19 -9
- package/src/routes/content.ts +23 -5
- package/src/schema/migrations.ts +12 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
const updated = await
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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(
|
|
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<{
|
|
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
|
-
|
|
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
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
}
|