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