@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.
- package/dist/engine/ai-dek-notifications.d.ts +2 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
- package/dist/engine/ai-dek-notifications.js +11 -2
- package/dist/engine/ai-dek-notifications.js.map +1 -1
- package/dist/engine/ai-dek.d.ts +23 -0
- package/dist/engine/ai-dek.d.ts.map +1 -1
- package/dist/engine/ai-dek.js +94 -31
- package/dist/engine/ai-dek.js.map +1 -1
- package/dist/engine/foundry-dispatch.d.ts +13 -3
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +115 -39
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +55 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +187 -57
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +11 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +135 -10
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +3 -2
- package/dist/engine/podcast-source.d.ts.map +1 -1
- package/dist/engine/podcast-source.js +6 -4
- package/dist/engine/podcast-source.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 +32 -19
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.d.ts +3 -1
- package/dist/engine/soft-delete.d.ts.map +1 -1
- package/dist/engine/soft-delete.js +21 -5
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/engine/video-source.d.ts +4 -2
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +7 -4
- package/dist/engine/video-source.js.map +1 -1
- package/dist/routes/content.d.ts +14 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +56 -28
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +6 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +178 -2
- package/dist/routes/fronts-publish.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 +8 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +6 -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/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +7 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts +2 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +2 -2
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/migrations/0030_media_publication_guard.sql +5 -0
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +19 -2
- package/src/engine/ai-dek.ts +131 -40
- package/src/engine/foundry-dispatch.ts +189 -49
- package/src/engine/fronts-publish-intent.ts +295 -67
- package/src/engine/fronts-publish.ts +167 -11
- package/src/engine/index.ts +4 -0
- package/src/engine/podcast-source.ts +6 -4
- package/src/engine/publication-current-guard.ts +86 -0
- package/src/engine/publication.ts +411 -59
- package/src/engine/publisher.ts +37 -17
- package/src/engine/soft-delete.ts +28 -8
- package/src/engine/video-source.ts +7 -4
- package/src/routes/content.ts +78 -35
- package/src/routes/context.ts +6 -0
- package/src/routes/fronts-publish.ts +213 -2
- package/src/schema/migrations.ts +8 -0
- package/src/schema/types.ts +6 -0
- package/src/ui/api/_content-config.ts +15 -0
- package/src/ui/api/fronts/schedule.ts +4 -0
- 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(
|
|
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<{
|
|
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
|
-
|
|
331
|
-
? podcastPublishBlocker(snapshotRecord(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
}
|