@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
@@ -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 } 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
@@ -35,8 +36,10 @@ export type ProcessingState =
35
36
  const TERMINAL_STATES: ProcessingState[] = ['ready', 'failed']
36
37
 
37
38
  export interface DispatchResult {
38
- correlationId: string
39
+ /** Podcast capture reuse has no persisted correlation to return. */
40
+ correlationId: string | null
39
41
  triggerToken: string
42
+ skipped?: boolean
40
43
  }
41
44
 
42
45
  export class FoundryDispatchPrerequisiteError extends Error {
@@ -99,9 +102,23 @@ function inferVideoSourceKind(sourceUrl: string): string {
99
102
  return 'http_url'
100
103
  }
101
104
 
105
+ /**
106
+ * Mint a fresh source identity for the caller's source-snapshot CAS. Capture
107
+ * time records initiation, not worker admission; this helper does not dispatch
108
+ * work or change readiness. The raw token stays in the private capture record.
109
+ */
110
+ export function createFoundrySourceCaptureToken(
111
+ capturedAtSeconds = Math.floor(Date.now() / 1000),
112
+ ): string {
113
+ if (!Number.isSafeInteger(capturedAtSeconds) || capturedAtSeconds <= 0) {
114
+ throw new RangeError('Capture time must be positive safe integer Unix seconds')
115
+ }
116
+ return `fronts-source-v2:${capturedAtSeconds}:${crypto.randomUUID()}`
117
+ }
118
+
102
119
  /**
103
120
  * Dispatch a video or podcast content item to Foundry via the injected hook.
104
- * Mints a fresh `processing_trigger_token` (UUID), calls
121
+ * Mints a fresh versioned `processing_trigger_token` (capture time + UUID), calls
105
122
  * `hook.dispatchVideo({ contentId, sourceUrl, kind, durationSeconds })`, and writes
106
123
  * `processing_correlation_id` + advances to `'queued'` in either
107
124
  * `video_content` or `podcast_content`.
@@ -112,10 +129,13 @@ export async function dispatchToFoundry(
112
129
  db: D1Database,
113
130
  contentId: string,
114
131
  hook: Pick<FoundryDispatch, 'dispatchVideo'>,
132
+ options: { ifUncaptured?: boolean } = {},
115
133
  ): Promise<DispatchResult> {
116
134
  const content = await db
117
135
  .prepare(
118
- 'SELECT id, type, slug, title, description, canonical_version, status, deleted_at FROM content_items WHERE id = ? LIMIT 1',
136
+ `SELECT id, type, slug, title, description, canonical_version, status, deleted_at,
137
+ visibility, published_revision_id, published_at, publish_at, publish_tz,
138
+ fronts_publish_status, fronts_publish_trigger_token FROM content_items WHERE id = ? LIMIT 1`,
119
139
  )
120
140
  .bind(contentId)
121
141
  .first<{
@@ -127,6 +147,13 @@ export async function dispatchToFoundry(
127
147
  canonical_version: number
128
148
  status: string
129
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
130
157
  }>()
131
158
  if (!content) {
132
159
  throw new Error(`dispatchToFoundry: content ${contentId} not found`)
@@ -135,7 +162,7 @@ export async function dispatchToFoundry(
135
162
  // Determine which media table owns this content id.
136
163
  const videoRow = await db
137
164
  .prepare(
138
- 'SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind FROM video_content WHERE content_id = ?',
165
+ '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
166
  )
140
167
  .bind(contentId)
141
168
  .first<{
@@ -144,6 +171,8 @@ export async function dispatchToFoundry(
144
171
  duration_seconds: number | null
145
172
  processing_source_url: string | null
146
173
  processing_source_kind: string | null
174
+ processing_trigger_token: string | null
175
+ processing_correlation_id: string | null
147
176
  }>()
148
177
 
149
178
  let table: 'video_content' | 'podcast_content'
@@ -155,6 +184,20 @@ export async function dispatchToFoundry(
155
184
  let podcastSnapshot: PodcastDispatchSnapshot | null = null
156
185
 
157
186
  if (videoRow) {
187
+ if (content.type !== 'video' || content.deleted_at != null || content.status === 'archived') {
188
+ throw new Error(`dispatchToFoundry: video ${contentId} is inactive`)
189
+ }
190
+ if (
191
+ options.ifUncaptured &&
192
+ videoRow.processing_trigger_token &&
193
+ videoRow.processing_correlation_id
194
+ ) {
195
+ return {
196
+ correlationId: videoRow.processing_correlation_id,
197
+ triggerToken: videoRow.processing_trigger_token,
198
+ skipped: true,
199
+ }
200
+ }
158
201
  table = 'video_content'
159
202
  sourceUrl = videoRow.processing_source_url?.trim() ?? ''
160
203
  if (!sourceUrl) {
@@ -182,12 +225,21 @@ export async function dispatchToFoundry(
182
225
  throw new Error(`dispatchToFoundry: podcast ${contentId} is inactive`)
183
226
  }
184
227
  podcastSnapshot = podcastRow
185
- sourceUrl = podcastRow.audio_r2_key?.trim() ?? ''
228
+ const selectedSource = podcastRow.processing_source_url?.trim()
229
+ if (
230
+ selectedSource &&
231
+ (podcastRow.processing_source_kind !== 'https' || !validPodcastSourceUrl(selectedSource))
232
+ ) {
233
+ throw new Error(`dispatchToFoundry: content ${contentId} has an invalid podcast source URL`)
234
+ }
235
+ sourceUrl = selectedSource || podcastRow.audio_r2_key?.trim() || ''
186
236
  if (!sourceUrl) {
187
- throw new Error(`dispatchToFoundry: content ${contentId} has no podcast audio R2 key`)
237
+ throw new Error(
238
+ `dispatchToFoundry: content ${contentId} has no podcast source or audio R2 key`,
239
+ )
188
240
  }
189
241
  kind = 'podcast'
190
- sourceKind = 'r2_key'
242
+ sourceKind = selectedSource ? 'https' : 'r2_key'
191
243
  videoId = normalizeVideoId(content.slug, content.slug)
192
244
  durationSeconds = podcastRow.duration_seconds
193
245
  }
@@ -199,9 +251,24 @@ export async function dispatchToFoundry(
199
251
  ) {
200
252
  throw new FoundryDispatchPrerequisiteError(contentId)
201
253
  }
254
+ if (
255
+ options.ifUncaptured &&
256
+ podcastSnapshot?.processing_trigger_token?.trim() &&
257
+ podcastSnapshot.processing_source_kind === 'https' &&
258
+ validPodcastSourceUrl(podcastSnapshot.processing_source_url)
259
+ ) {
260
+ // Source edits clear the token. This source/token pair is the CMS's
261
+ // committed capture evidence; a metadata save need not call the host again.
262
+ return {
263
+ correlationId: null,
264
+ triggerToken: podcastSnapshot.processing_trigger_token,
265
+ skipped: true,
266
+ }
267
+ }
202
268
 
203
- const triggerToken = crypto.randomUUID()
269
+ const triggerToken = createFoundrySourceCaptureToken()
204
270
  const processingTriggerTokenHash = `sha256:${await sha256Hex(triggerToken)}`
271
+ const publicationGuard = mediaPublicationCaptureGuard(content, processingTriggerTokenHash)
205
272
  const { correlationId, podcastSource } = await hook.dispatchVideo({
206
273
  contentId,
207
274
  sourceUrl,
@@ -217,7 +284,8 @@ export async function dispatchToFoundry(
217
284
  const now = Math.floor(Date.now() / 1000)
218
285
 
219
286
  if (table === 'video_content') {
220
- await db
287
+ if (!videoRow) throw new Error('Video dispatch snapshot is missing')
288
+ const written = await db
221
289
  .prepare(
222
290
  `UPDATE video_content
223
291
  SET processing_state = 'queued',
@@ -225,6 +293,7 @@ export async function dispatchToFoundry(
225
293
  processing_source_kind = ?,
226
294
  processing_correlation_id = ?,
227
295
  processing_trigger_token = ?,
296
+ processing_publication_guard = ?,
228
297
  processing_started_at = ?,
229
298
  processing_completed_at = NULL,
230
299
  processing_last_event_at = NULL,
@@ -235,10 +304,44 @@ export async function dispatchToFoundry(
235
304
  transcript_url = NULL,
236
305
  hls_manifest_url = NULL,
237
306
  hls_poster_url = NULL
238
- WHERE content_id = ?`,
307
+ WHERE content_id = ? AND video_id IS ? AND duration_seconds IS ?
308
+ AND processing_source_url IS ? AND processing_source_kind IS ?
309
+ AND processing_trigger_token IS ? AND processing_correlation_id IS ?
310
+ AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = video_content.content_id
311
+ AND ci.type = 'video' AND ci.slug = ? AND ci.canonical_version = ?
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 ?)`,
316
+ )
317
+ .bind(
318
+ videoId,
319
+ sourceKind,
320
+ correlationId,
321
+ triggerToken,
322
+ publicationGuard,
323
+ now,
324
+ contentId,
325
+ videoRow.video_id,
326
+ videoRow.duration_seconds,
327
+ videoRow.processing_source_url,
328
+ videoRow.processing_source_kind,
329
+ videoRow.processing_trigger_token,
330
+ videoRow.processing_correlation_id,
331
+ content.slug,
332
+ content.canonical_version,
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,
239
341
  )
240
- .bind(videoId, sourceKind, correlationId, triggerToken, now, contentId)
241
342
  .run()
343
+ if (written.meta?.changes !== 1)
344
+ throw new Error('Video source changed before dispatch acknowledgement')
242
345
  } else {
243
346
  if (!podcastSnapshot) throw new Error('Podcast dispatch snapshot is missing')
244
347
  if (
@@ -259,16 +362,20 @@ export async function dispatchToFoundry(
259
362
  // source/audio/editor snapshot is still current. No stale acknowledgement
260
363
  // can restore a replaced source or overwrite a newer processing token.
261
364
  const written = await db
262
- .prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?
365
+ .prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?, processing_publication_guard = ?, processing_state = 'queued', transcript = ''
263
366
  WHERE content_id = ? AND audio_r2_key IS ? AND duration_seconds IS ? AND transcript IS ?
264
367
  AND processing_source_url IS ? AND processing_source_kind IS ? AND processing_trigger_token IS ?
265
368
  AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = podcast_content.content_id
266
369
  AND ci.type = 'podcast' AND ci.slug = ? AND ci.canonical_version = ?
267
- 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 ?)`)
268
374
  .bind(
269
375
  capturedUrl,
270
376
  capturedKind,
271
377
  triggerToken,
378
+ publicationGuard,
272
379
  contentId,
273
380
  podcastSnapshot.audio_r2_key,
274
381
  podcastSnapshot.duration_seconds,
@@ -279,6 +386,13 @@ export async function dispatchToFoundry(
279
386
  content.slug,
280
387
  content.canonical_version,
281
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,
282
396
  )
283
397
  .run()
284
398
  if (written.meta?.changes !== 1)
@@ -352,14 +466,19 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
352
466
  return false // no matching job for this trigger token
353
467
  }
354
468
 
355
- // Insert the event row.
469
+ // A failed event is consumed atomically with terminal state and guard revocation.
470
+ // Otherwise a transient media write failure would make its retry a no-op.
356
471
  const now = Math.floor(Date.now() / 1000)
357
- await db
472
+ const eventStatement = db
358
473
  .prepare(
359
474
  'INSERT INTO foundry_callback_events (event_id, event_kind, correlation_id, received_at) VALUES (?, ?, ?, ?)',
360
475
  )
361
476
  .bind(eventId, eventKind, correlationId, now)
362
- .run()
477
+ if (nextState !== 'failed') await eventStatement.run()
478
+ const applyMediaUpdate = async (statement: D1PreparedStatement): Promise<D1Result> =>
479
+ nextState === 'failed'
480
+ ? (await db.batch([statement, eventStatement]))[0]!
481
+ : await statement.run()
363
482
 
364
483
  // Advance the state machine.
365
484
  const isTerminal = (TERMINAL_STATES as string[]).includes(nextState)
@@ -371,35 +490,44 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
371
490
  typeof durationSeconds === 'number' && Number.isFinite(durationSeconds)
372
491
  ? Math.max(0, Math.floor(durationSeconds))
373
492
  : null
374
- if (nextTranscript !== null || nextDurationSeconds !== null) {
375
- await db
376
- .prepare(
377
- `UPDATE podcast_content
378
- SET transcript = CASE WHEN ? THEN ? ELSE transcript END,
493
+ {
494
+ const updated = await applyMediaUpdate(
495
+ db
496
+ .prepare(
497
+ `UPDATE podcast_content
498
+ SET processing_state = ?, processing_publication_guard = CASE WHEN ? = 'failed' THEN NULL ELSE processing_publication_guard END,
499
+ transcript = CASE WHEN ? THEN ? ELSE transcript END,
379
500
  duration_seconds = CASE WHEN ? THEN ? ELSE duration_seconds END
380
- WHERE content_id = ?`,
381
- )
382
- .bind(
383
- nextTranscript !== null ? 1 : 0,
384
- nextTranscript,
385
- nextDurationSeconds !== null ? 1 : 0,
386
- nextDurationSeconds,
387
- podcastRow.content_id,
388
- )
389
- .run()
501
+ WHERE content_id = ? AND processing_trigger_token = ?`,
502
+ )
503
+ .bind(
504
+ nextState,
505
+ nextState,
506
+ nextTranscript !== null ? 1 : 0,
507
+ nextTranscript,
508
+ nextDurationSeconds !== null ? 1 : 0,
509
+ nextDurationSeconds,
510
+ podcastRow.content_id,
511
+ triggerToken,
512
+ ),
513
+ )
514
+ if (updated.meta?.changes !== 1) return false
390
515
  }
391
516
 
392
517
  const nextDescription =
393
518
  typeof description === 'string' && description.trim() ? description.trim() : null
394
519
  const nextExcerpt = typeof excerpt === 'string' && excerpt.trim() ? excerpt.trim() : null
395
520
  if (nextDescription !== null || nextExcerpt !== null) {
396
- await db
521
+ const updated = await db
397
522
  .prepare(
398
523
  `UPDATE content_items
399
524
  SET description = CASE WHEN ? THEN ? ELSE description END,
400
525
  excerpt = CASE WHEN ? THEN ? ELSE excerpt END,
401
526
  updated_at = unixepoch()
402
- WHERE id = ?`,
527
+ WHERE id = ? AND EXISTS (
528
+ SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
529
+ AND p.processing_trigger_token = ?
530
+ )`,
403
531
  )
404
532
  .bind(
405
533
  nextDescription !== null ? 1 : 0,
@@ -407,50 +535,62 @@ export async function applyFoundryCallback(db: D1Database, input: CallbackInput)
407
535
  nextExcerpt !== null ? 1 : 0,
408
536
  nextExcerpt,
409
537
  podcastRow.content_id,
538
+ triggerToken,
410
539
  )
411
540
  .run()
541
+ if (updated.meta?.changes !== 1) return false
412
542
  }
413
543
 
414
- return true
544
+ return Boolean(
545
+ await db
546
+ .prepare(
547
+ 'SELECT content_id FROM podcast_content WHERE content_id = ? AND processing_trigger_token = ?',
548
+ )
549
+ .bind(podcastRow.content_id, triggerToken)
550
+ .first(),
551
+ )
415
552
  }
416
553
 
417
554
  if (!videoRow) return false
418
555
  const row = videoRow
556
+ let updated: D1Result
419
557
 
420
558
  if (nextState === 'failed') {
421
- await db
422
- .prepare(
423
- `UPDATE video_content
559
+ updated = await applyMediaUpdate(
560
+ db
561
+ .prepare(
562
+ `UPDATE video_content
424
563
  SET processing_state = ?,
564
+ processing_publication_guard = NULL,
425
565
  processing_last_event_at = ?,
426
566
  processing_completed_at = ?,
427
567
  processing_error = ?
428
- WHERE content_id = ?`,
429
- )
430
- .bind(nextState, now, now, error ?? null, row.content_id)
431
- .run()
568
+ WHERE content_id = ? AND processing_trigger_token = ?`,
569
+ )
570
+ .bind(nextState, now, now, error ?? null, row.content_id, triggerToken),
571
+ )
432
572
  } else if (isTerminal) {
433
573
  // 'ready'
434
- await db
574
+ updated = await db
435
575
  .prepare(
436
576
  `UPDATE video_content
437
577
  SET processing_state = ?,
438
578
  processing_last_event_at = ?,
439
579
  processing_completed_at = ?
440
- WHERE content_id = ?`,
580
+ WHERE content_id = ? AND processing_trigger_token = ?`,
441
581
  )
442
- .bind(nextState, now, now, row.content_id)
582
+ .bind(nextState, now, now, row.content_id, triggerToken)
443
583
  .run()
444
584
  } else {
445
- await db
585
+ updated = await db
446
586
  .prepare(
447
- 'UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ?',
587
+ 'UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ? AND processing_trigger_token = ?',
448
588
  )
449
- .bind(nextState, now, row.content_id)
589
+ .bind(nextState, now, row.content_id, triggerToken)
450
590
  .run()
451
591
  }
452
592
 
453
- return true
593
+ return updated.meta?.changes === 1
454
594
  }
455
595
 
456
596
  /**