@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
@@ -11,10 +11,18 @@ import {
11
11
  serializeContentMetadata,
12
12
  } from './content-metadata.js'
13
13
  import type { D1Database, D1Result } from './d1.js'
14
+ import { frontsPodcastSourceGeneration, podcastPublishBlocker } from './podcast-source.js'
14
15
  import {
16
+ assertCurrentMediaPublicationSnapshot,
15
17
  assertCurrentPublicationSnapshot,
18
+ CURRENT_MEDIA_PUBLICATION_GUARD,
16
19
  CURRENT_PUBLICATION_GUARD,
17
20
  currentPublicationSqlGuard,
21
+ MEDIA_PUBLICATION_ITEM_FIELDS,
22
+ PUBLISHED_PODCAST_MEDIA_FIELDS,
23
+ PUBLISHED_VIDEO_MEDIA_FIELDS,
24
+ PublicationCurrentSnapshotError,
25
+ snapshotRecord,
18
26
  } from './publication-current-guard.js'
19
27
  import { evaluateArticleBody } from './publish-guard.js'
20
28
  import {
@@ -30,6 +38,7 @@ import {
30
38
  import { getRevision } from './revisions.js'
31
39
  import { slugify } from './slug.js'
32
40
  import { TagInputError, validateDeclaredTagList } from './taxonomy.js'
41
+ import { frontsVideoSourceGeneration } from './video-source.js'
33
42
 
34
43
  export type PublicationOperation = 'publish' | 'rollback'
35
44
  export type PublicationSurfacePhase = 'prepare' | 'commit' | 'abort'
@@ -683,6 +692,7 @@ function publicationAttemptInsert(
683
692
  surfaceNames: string[]
684
693
  packageVersion: string | null
685
694
  now: number
695
+ idempotent?: boolean
686
696
  },
687
697
  ) {
688
698
  return db
@@ -693,7 +703,8 @@ function publicationAttemptInsert(
693
703
  surface_names_json, pending_surface_names_json,
694
704
  prepared_surface_names_json, receipts_json,
695
705
  package_version, created_at, updated_at
696
- ) VALUES (?, ?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)`,
706
+ ) VALUES (?, ?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)
707
+ ${input.idempotent ? 'ON CONFLICT(id) DO NOTHING' : ''}`,
697
708
  )
698
709
  .bind(
699
710
  id,
@@ -776,17 +787,24 @@ async function createRevisionAndPublicationAttempt(
776
787
  createdBy?: string | null
777
788
  expectedCurrentSnapshot?: Record<string, unknown>
778
789
  expectedReceiptIdentity?: string
790
+ mediaReplacement?: { identity: string; payload: Record<string, unknown> }
779
791
  },
780
792
  ): Promise<{
781
793
  revisionId: string
782
794
  snapshot: Record<string, unknown>
783
795
  attempt: PublicationAttempt
784
796
  }> {
785
- const snapshot = await getContentSnapshot(db, input.contentId)
786
- if (!snapshot) throw new PublicationNotFoundError(`content not found: ${input.contentId}`)
797
+ const current = await getContentSnapshot(db, input.contentId)
798
+ if (!current) throw new PublicationNotFoundError(`content not found: ${input.contentId}`)
799
+ const snapshot = input.mediaReplacement?.payload ?? current
787
800
  if (input.expectedCurrentSnapshot) {
788
- assertCurrentPublicationSnapshot(snapshot, input.expectedCurrentSnapshot)
789
- snapshot.publication_guard = CURRENT_PUBLICATION_GUARD
801
+ if (input.mediaReplacement) {
802
+ assertCurrentMediaPublicationSnapshot(current, input.expectedCurrentSnapshot)
803
+ snapshot.publication_guard = CURRENT_MEDIA_PUBLICATION_GUARD
804
+ } else {
805
+ assertCurrentPublicationSnapshot(snapshot, input.expectedCurrentSnapshot)
806
+ snapshot.publication_guard = CURRENT_PUBLICATION_GUARD
807
+ }
790
808
  if (input.expectedReceiptIdentity !== undefined) {
791
809
  if (!/^[a-f0-9]{64}$/.test(input.expectedReceiptIdentity))
792
810
  throw new Error('Invalid guarded publication receipt identity')
@@ -794,12 +812,17 @@ async function createRevisionAndPublicationAttempt(
794
812
  }
795
813
  }
796
814
  assertPublicationSnapshotBodyValid(snapshot, input.contentId)
797
- const revisionId = crypto.randomUUID()
798
- const attemptId = crypto.randomUUID()
815
+ const revisionId = input.mediaReplacement
816
+ ? `media:${input.mediaReplacement.identity}`
817
+ : crypto.randomUUID()
818
+ const attemptId = input.mediaReplacement
819
+ ? `media:${input.mediaReplacement.identity}`
820
+ : crypto.randomUUID()
799
821
  await db.batch([
800
822
  db
801
823
  .prepare(
802
- 'INSERT INTO content_revisions (id, content_id, payload_json, created_at, created_by) VALUES (?, ?, ?, ?, ?)',
824
+ `INSERT INTO content_revisions (id, content_id, payload_json, created_at, created_by) VALUES (?, ?, ?, ?, ?)
825
+ ${input.mediaReplacement ? 'ON CONFLICT(id) DO NOTHING' : ''}`,
803
826
  )
804
827
  .bind(
805
828
  revisionId,
@@ -817,9 +840,17 @@ async function createRevisionAndPublicationAttempt(
817
840
  surfaceNames: input.surfaceNames,
818
841
  packageVersion: input.packageVersion,
819
842
  now: input.now,
843
+ idempotent: Boolean(input.mediaReplacement),
820
844
  }),
821
845
  ])
822
- return { revisionId, snapshot, attempt: await getPublicationAttempt(db, attemptId) }
846
+ const persisted = input.mediaReplacement
847
+ ? await getRevision(db, input.contentId, revisionId)
848
+ : null
849
+ return {
850
+ revisionId,
851
+ snapshot: persisted ? snapshotRecord(persisted.payload) : snapshot,
852
+ attempt: await getPublicationAttempt(db, attemptId),
853
+ }
823
854
  }
824
855
 
825
856
  function assertPublicationSnapshotBodyValid(
@@ -1039,6 +1070,33 @@ function normalizeReceipt(
1039
1070
  }
1040
1071
  }
1041
1072
 
1073
+ async function mediaPreparationStillCurrent(
1074
+ db: D1Database,
1075
+ input: PublicationReconciliationInput,
1076
+ allowCommittedPointer = false,
1077
+ ): Promise<boolean> {
1078
+ if (
1079
+ input.operation !== 'publish' ||
1080
+ input.snapshot.publication_guard !== CURRENT_MEDIA_PUBLICATION_GUARD
1081
+ )
1082
+ return false
1083
+ const current = await getContentSnapshot(db, input.contentId)
1084
+ const expected = structuredClone(snapshotRecord(input.snapshot.publication_current_snapshot))
1085
+ if (
1086
+ allowCommittedPointer &&
1087
+ snapshotRecord(current?.item).published_revision_id === input.revisionId
1088
+ ) {
1089
+ snapshotRecord(expected.item).published_revision_id = input.revisionId
1090
+ }
1091
+ try {
1092
+ assertCurrentMediaPublicationSnapshot(current ?? {}, expected)
1093
+ return true
1094
+ } catch (error) {
1095
+ if (error instanceof PublicationCurrentSnapshotError) return false
1096
+ throw error
1097
+ }
1098
+ }
1099
+
1042
1100
  async function prepareSurfaces(
1043
1101
  db: D1Database,
1044
1102
  attempt: PublicationAttempt,
@@ -1053,8 +1111,19 @@ async function prepareSurfaces(
1053
1111
  let durable = attempt
1054
1112
  let receipts: PublicationSurfaceReceipt[] = [...durable.receipts]
1055
1113
  let preparedNames = new Set(durable.preparedSurfaceNames)
1114
+ const mediaReplacement =
1115
+ input.operation === 'publish' &&
1116
+ input.snapshot.publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD
1117
+ const preparedSuccessfully = (name: string) =>
1118
+ durable.receipts
1119
+ .filter((receipt) => receipt.name === name && receipt.phase === 'prepare')
1120
+ .at(-1)?.status === 'ok'
1056
1121
  surfaceLoop: for (const surface of surfaces) {
1057
- if (preparedNames.has(surface.name)) continue
1122
+ if (
1123
+ preparedNames.has(surface.name) &&
1124
+ (!mediaReplacement || preparedSuccessfully(surface.name))
1125
+ )
1126
+ continue
1058
1127
  if (durable.state !== 'preparing') {
1059
1128
  throw new PublicationReconciliationError(
1060
1129
  `publication attempt advanced concurrently to ${durable.state}`,
@@ -1091,6 +1160,40 @@ async function prepareSurfaces(
1091
1160
  }
1092
1161
  receipts = [...durable.receipts, normalized]
1093
1162
  if (normalized.status !== 'ok') {
1163
+ // A partial private preparation remains owned by this attempt. Retrying
1164
+ // its idempotent prepare must never race a late abort of the same revision.
1165
+ if (await mediaPreparationStillCurrent(db, input)) {
1166
+ for (;;) {
1167
+ if (preparedSuccessfully(surface.name)) {
1168
+ receipts = [...durable.receipts]
1169
+ preparedNames = new Set(durable.preparedSurfaceNames)
1170
+ continue surfaceLoop
1171
+ }
1172
+ if (durable.state !== 'preparing') break
1173
+ try {
1174
+ durable = await persistPublicationAttempt(db, {
1175
+ attemptId: durable.id,
1176
+ expectedVersion: durable.version,
1177
+ expectedStates: ['preparing'],
1178
+ state: 'preparing',
1179
+ pendingSurfaceNames: durable.pendingSurfaceNames,
1180
+ preparedSurfaceNames: [...new Set([...durable.preparedSurfaceNames, surface.name])],
1181
+ receipts: [...durable.receipts, normalized],
1182
+ lastError: durableDiagnostic('surface_prepare_failed'),
1183
+ now: input.now,
1184
+ })
1185
+ break
1186
+ } catch (error) {
1187
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1188
+ durable = error.current
1189
+ }
1190
+ }
1191
+ throw new PublicationReconciliationError(
1192
+ `publication surface requires retry: ${surface.name}`,
1193
+ durable.receipts,
1194
+ durable.id,
1195
+ )
1196
+ }
1094
1197
  let cleanupNames = new Set([...preparedNames, surface.name])
1095
1198
  let claimed: PublicationAttempt
1096
1199
  for (;;) {
@@ -1110,7 +1213,10 @@ async function prepareSurfaces(
1110
1213
  } catch (error) {
1111
1214
  if (!(error instanceof PublicationAttemptConflictError)) throw error
1112
1215
  durable = error.current
1113
- if (durable.preparedSurfaceNames.includes(surface.name)) {
1216
+ if (
1217
+ durable.preparedSurfaceNames.includes(surface.name) &&
1218
+ (!mediaReplacement || preparedSuccessfully(surface.name))
1219
+ ) {
1114
1220
  receipts = [...durable.receipts]
1115
1221
  preparedNames = new Set(durable.preparedSurfaceNames)
1116
1222
  continue surfaceLoop
@@ -1179,7 +1285,10 @@ async function prepareSurfaces(
1179
1285
  } catch (error) {
1180
1286
  if (!(error instanceof PublicationAttemptConflictError)) throw error
1181
1287
  durable = error.current
1182
- if (durable.preparedSurfaceNames.includes(surface.name)) {
1288
+ if (
1289
+ durable.preparedSurfaceNames.includes(surface.name) &&
1290
+ (!mediaReplacement || preparedSuccessfully(surface.name))
1291
+ ) {
1183
1292
  receipts = [...durable.receipts]
1184
1293
  preparedNames = new Set(durable.preparedSurfaceNames)
1185
1294
  break
@@ -1603,6 +1712,9 @@ async function movePointerAndBeginCommit(
1603
1712
  attempt: PublicationAttempt,
1604
1713
  now: number,
1605
1714
  ): Promise<void> {
1715
+ const targetRevision = await getRevision(db, attempt.contentId, attempt.revisionId)
1716
+ const mediaReplacement =
1717
+ snapshotRecord(targetRevision?.payload).publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD
1606
1718
  if (
1607
1719
  attempt.operation === 'publish' &&
1608
1720
  (!Number.isSafeInteger(attempt.targetPublishedAt) || (attempt.targetPublishedAt as number) < 0)
@@ -1634,29 +1746,17 @@ async function movePointerAndBeginCommit(
1634
1746
  )
1635
1747
  )`
1636
1748
  const pointerStatement =
1637
- attempt.operation === 'publish'
1749
+ mediaReplacement && attempt.operation === 'publish'
1638
1750
  ? db
1639
- .prepare(
1640
- `UPDATE content_items
1641
- SET status = 'published', published_at = ?, publish_at = NULL,
1642
- published_revision_id = ?, updated_at = unixepoch(),
1643
- ${PUBLISH_METADATA_STAMP_SQL}
1644
- WHERE id = ?
1645
- AND COALESCE(published_revision_id, '') = ?
1646
- AND EXISTS (
1647
- SELECT 1 FROM content_publication_attempts
1648
- WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
1649
- )
1650
- AND ${targetRevisionSlugAvailable}`,
1651
- )
1751
+ .prepare(`UPDATE content_items SET published_revision_id = ?
1752
+ WHERE id = ? AND published_revision_id IS ?
1753
+ AND EXISTS (SELECT 1 FROM content_publication_attempts
1754
+ WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?)
1755
+ AND ${targetRevisionSlugAvailable}`)
1652
1756
  .bind(
1653
- attempt.targetPublishedAt,
1654
1757
  attempt.revisionId,
1655
- // Wall clock, NOT targetPublishedAt — the latter is routinely backdated
1656
- // to the source video's date on the channel sites.
1657
- ...publishMetadataStampBinds(now),
1658
1758
  attempt.contentId,
1659
- attempt.previousRevisionId ?? '',
1759
+ attempt.previousRevisionId,
1660
1760
  attempt.id,
1661
1761
  attempt.revisionId,
1662
1762
  attempt.contentId,
@@ -1664,9 +1764,39 @@ async function movePointerAndBeginCommit(
1664
1764
  attempt.contentId,
1665
1765
  attempt.contentId,
1666
1766
  )
1667
- : db
1668
- .prepare(
1669
- `UPDATE content_items
1767
+ : attempt.operation === 'publish'
1768
+ ? db
1769
+ .prepare(
1770
+ `UPDATE content_items
1771
+ SET status = 'published', published_at = ?, publish_at = NULL,
1772
+ published_revision_id = ?, updated_at = unixepoch(),
1773
+ ${PUBLISH_METADATA_STAMP_SQL}
1774
+ WHERE id = ?
1775
+ AND COALESCE(published_revision_id, '') = ?
1776
+ AND EXISTS (
1777
+ SELECT 1 FROM content_publication_attempts
1778
+ WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
1779
+ )
1780
+ AND ${targetRevisionSlugAvailable}`,
1781
+ )
1782
+ .bind(
1783
+ attempt.targetPublishedAt,
1784
+ attempt.revisionId,
1785
+ // Wall clock, NOT targetPublishedAt — the latter is routinely backdated
1786
+ // to the source video's date on the channel sites.
1787
+ ...publishMetadataStampBinds(now),
1788
+ attempt.contentId,
1789
+ attempt.previousRevisionId ?? '',
1790
+ attempt.id,
1791
+ attempt.revisionId,
1792
+ attempt.contentId,
1793
+ attempt.revisionId,
1794
+ attempt.contentId,
1795
+ attempt.contentId,
1796
+ )
1797
+ : db
1798
+ .prepare(
1799
+ `UPDATE content_items
1670
1800
  SET status = 'published', publish_at = NULL,
1671
1801
  published_revision_id = ?, updated_at = unixepoch()
1672
1802
  WHERE id = ?
@@ -1676,18 +1806,18 @@ async function movePointerAndBeginCommit(
1676
1806
  WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
1677
1807
  )
1678
1808
  AND ${targetRevisionSlugAvailable}`,
1679
- )
1680
- .bind(
1681
- attempt.revisionId,
1682
- attempt.contentId,
1683
- attempt.previousRevisionId ?? '',
1684
- attempt.id,
1685
- attempt.revisionId,
1686
- attempt.contentId,
1687
- attempt.revisionId,
1688
- attempt.contentId,
1689
- attempt.contentId,
1690
- )
1809
+ )
1810
+ .bind(
1811
+ attempt.revisionId,
1812
+ attempt.contentId,
1813
+ attempt.previousRevisionId ?? '',
1814
+ attempt.id,
1815
+ attempt.revisionId,
1816
+ attempt.contentId,
1817
+ attempt.revisionId,
1818
+ attempt.contentId,
1819
+ attempt.contentId,
1820
+ )
1691
1821
  const attemptStatement = db
1692
1822
  .prepare(
1693
1823
  `UPDATE content_publication_attempts
@@ -1761,6 +1891,15 @@ async function abortAfterPointerFailure(
1761
1891
  receipts: PublicationSurfaceReceipt[],
1762
1892
  surfaceHookTimeoutMs: number,
1763
1893
  ): Promise<never> {
1894
+ // A transport failure may precede execution or lose the successful response.
1895
+ // Keep this capture's durable attempt recoverable while its guard still holds.
1896
+ if (await mediaPreparationStillCurrent(db, reconciliationInput, true)) {
1897
+ throw new PublicationReconciliationError(
1898
+ 'Published media pointer requires retry',
1899
+ receipts,
1900
+ attempt.id,
1901
+ )
1902
+ }
1764
1903
  let claimed: PublicationAttempt
1765
1904
  try {
1766
1905
  claimed = await persistPublicationAttempt(db, {
@@ -1824,6 +1963,216 @@ async function abortAfterPointerFailure(
1824
1963
  )
1825
1964
  }
1826
1965
 
1966
+ export interface CommitPreparedPublishedMediaInput {
1967
+ contentId: string
1968
+ sourceGeneration: string
1969
+ /** The host verifies the exact prepared private objects and binds their receipt. */
1970
+ checkReadiness(input: {
1971
+ snapshot: Record<string, unknown>
1972
+ sourceGeneration: string
1973
+ signal: AbortSignal
1974
+ }): Promise<{ ready: boolean; receiptIdentity?: string }>
1975
+ surfaces: PublicationReconciler[]
1976
+ allowNoSurfaces?: boolean
1977
+ now?: number
1978
+ packageVersion?: string | null
1979
+ }
1980
+
1981
+ export type CommitPreparedPublishedMediaResult =
1982
+ | { status: 'ineligible' }
1983
+ | { status: 'pending'; reason: 'media_incomplete' | 'verification_unready' }
1984
+ | { status: 'committed'; deduped: boolean; revisionId: string; attemptId: string }
1985
+
1986
+ async function mediaIdentity(value: string): Promise<string> {
1987
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
1988
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
1989
+ }
1990
+
1991
+ /** A media callback can finish this existing publication without publishing its draft. */
1992
+ export async function commitPreparedPublishedMedia(
1993
+ db: D1Database,
1994
+ input: CommitPreparedPublishedMediaInput,
1995
+ ): Promise<CommitPreparedPublishedMediaResult> {
1996
+ const current = await getContentSnapshot(db, input.contentId)
1997
+ if (!current) throw new PublicationCurrentSnapshotError()
1998
+ const item = snapshotRecord(current.item)
1999
+ const content = snapshotRecord(current.content)
2000
+ if (
2001
+ !['video', 'podcast'].includes(String(item.type)) ||
2002
+ content.processing_publication_guard == null
2003
+ ) {
2004
+ return { status: 'ineligible' }
2005
+ }
2006
+ let capture: Record<string, unknown>
2007
+ try {
2008
+ capture = snapshotRecord(JSON.parse(String(content.processing_publication_guard)))
2009
+ } catch {
2010
+ throw new PublicationCurrentSnapshotError()
2011
+ }
2012
+ const capturedItem = snapshotRecord(capture.item)
2013
+ if (
2014
+ capture.version !== 1 ||
2015
+ (capture.preparation_generation != null &&
2016
+ (typeof capture.preparation_generation !== 'string' ||
2017
+ !capture.preparation_generation ||
2018
+ capture.preparation_generation.length > 256)) ||
2019
+ capturedItem.status !== 'published' ||
2020
+ typeof capturedItem.published_revision_id !== 'string' ||
2021
+ !capturedItem.published_revision_id ||
2022
+ typeof content.processing_trigger_token !== 'string' ||
2023
+ !content.processing_trigger_token ||
2024
+ capture.trigger_token_hash !==
2025
+ `sha256:${await mediaIdentity(content.processing_trigger_token)}` ||
2026
+ item.status !== 'published' ||
2027
+ item.deleted_at != null ||
2028
+ MEDIA_PUBLICATION_ITEM_FIELDS.some(
2029
+ (field) => field !== 'published_revision_id' && item[field] !== capturedItem[field],
2030
+ )
2031
+ ) {
2032
+ throw new PublicationCurrentSnapshotError()
2033
+ }
2034
+ const source = {
2035
+ content_id: input.contentId,
2036
+ slug: String(item.slug),
2037
+ canonical_version: Number(item.canonical_version),
2038
+ processing_source_url:
2039
+ typeof content.processing_source_url === 'string' ? content.processing_source_url : null,
2040
+ processing_source_kind:
2041
+ typeof content.processing_source_kind === 'string' ? content.processing_source_kind : null,
2042
+ processing_trigger_token: content.processing_trigger_token,
2043
+ }
2044
+ const generation = await (item.type === 'video'
2045
+ ? frontsVideoSourceGeneration(source)
2046
+ : frontsPodcastSourceGeneration(source))
2047
+ if (generation !== input.sourceGeneration) throw new PublicationCurrentSnapshotError()
2048
+ const identity = await mediaIdentity(
2049
+ JSON.stringify([
2050
+ CURRENT_MEDIA_PUBLICATION_GUARD,
2051
+ input.contentId,
2052
+ capturedItem.published_revision_id,
2053
+ capture.trigger_token_hash,
2054
+ generation,
2055
+ capture.preparation_generation ?? null,
2056
+ ]),
2057
+ )
2058
+ const revisionId = `media:${identity}`
2059
+ const attemptId = revisionId
2060
+ if (
2061
+ item.published_revision_id !== capturedItem.published_revision_id &&
2062
+ item.published_revision_id !== revisionId
2063
+ ) {
2064
+ throw new PublicationCurrentSnapshotError()
2065
+ }
2066
+ const storedRevision = await getRevision(db, input.contentId, revisionId)
2067
+ if (storedRevision) {
2068
+ const stored = snapshotRecord(storedRevision.payload)
2069
+ if (
2070
+ stored.publication_media_identity !== identity ||
2071
+ stored.publication_guard !== CURRENT_MEDIA_PUBLICATION_GUARD
2072
+ ) {
2073
+ throw new PublicationCurrentSnapshotError()
2074
+ }
2075
+ const expected = structuredClone(snapshotRecord(stored.publication_current_snapshot))
2076
+ if (item.published_revision_id === revisionId)
2077
+ snapshotRecord(expected.item).published_revision_id = revisionId
2078
+ assertCurrentMediaPublicationSnapshot(current, expected)
2079
+ const durable = await getPublicationAttempt(db, attemptId)
2080
+ if (durable.state === 'committed') {
2081
+ if (item.published_revision_id !== revisionId) throw new PublicationCurrentSnapshotError()
2082
+ return { status: 'committed', deduped: true, revisionId, attemptId }
2083
+ }
2084
+ }
2085
+ const incomplete =
2086
+ item.type === 'video'
2087
+ ? content.processing_state !== 'ready' ||
2088
+ content.hls_ready !== 1 ||
2089
+ content.transcript_ready !== 1 ||
2090
+ !content.hls_manifest_url ||
2091
+ !content.transcript_url
2092
+ : Boolean(podcastPublishBlocker(content))
2093
+ if (incomplete) return { status: 'pending', reason: 'media_incomplete' }
2094
+ const controller = new AbortController()
2095
+ let timeout: ReturnType<typeof setTimeout> | undefined
2096
+ let readiness: { ready: boolean; receiptIdentity?: string }
2097
+ try {
2098
+ readiness = await Promise.race([
2099
+ input.checkReadiness({
2100
+ snapshot: structuredClone(current),
2101
+ sourceGeneration: generation,
2102
+ signal: controller.signal,
2103
+ }),
2104
+ new Promise<never>((_resolve, reject) => {
2105
+ timeout = setTimeout(() => {
2106
+ controller.abort()
2107
+ reject(new Error('Published media verification timed out'))
2108
+ }, 10_000)
2109
+ }),
2110
+ ])
2111
+ } finally {
2112
+ if (timeout !== undefined) clearTimeout(timeout)
2113
+ }
2114
+ if (readiness.ready !== true) return { status: 'pending', reason: 'verification_unready' }
2115
+ if (!readiness.receiptIdentity || !/^[a-f0-9]{64}$/.test(readiness.receiptIdentity)) {
2116
+ throw new Error('Verified published media requires an exact receipt identity')
2117
+ }
2118
+ assertCurrentMediaPublicationSnapshot(
2119
+ (await getContentSnapshot(db, input.contentId)) ?? {},
2120
+ current,
2121
+ )
2122
+ const surfaces = normalizeSurfaces(input)
2123
+ if (!storedRevision) {
2124
+ const base = await getRevision(db, input.contentId, capturedItem.published_revision_id)
2125
+ if (
2126
+ !base ||
2127
+ base.payload.item.slug !== item.slug ||
2128
+ base.payload.item.visibility !== item.visibility
2129
+ ) {
2130
+ throw new PublicationCurrentSnapshotError()
2131
+ }
2132
+ const fields =
2133
+ item.type === 'video' ? PUBLISHED_VIDEO_MEDIA_FIELDS : PUBLISHED_PODCAST_MEDIA_FIELDS
2134
+ const payload = snapshotRecord(structuredClone(base.payload))
2135
+ payload.content = {
2136
+ ...snapshotRecord(payload.content),
2137
+ ...Object.fromEntries(fields.map((field) => [field, content[field] ?? null])),
2138
+ }
2139
+ payload.publication_media_identity = identity
2140
+ payload.publication_current_snapshot = {
2141
+ item: Object.fromEntries(
2142
+ MEDIA_PUBLICATION_ITEM_FIELDS.map((field) => [field, item[field] ?? null]),
2143
+ ),
2144
+ content: Object.fromEntries(
2145
+ [...fields, 'processing_publication_guard'].map((field) => [field, content[field] ?? null]),
2146
+ ),
2147
+ }
2148
+ await createRevisionAndPublicationAttempt(db, {
2149
+ contentId: input.contentId,
2150
+ previousRevisionId: capturedItem.published_revision_id,
2151
+ targetPublishedAt: Number(capturedItem.published_at),
2152
+ surfaceNames: surfaces.map((surface) => surface.name),
2153
+ packageVersion: input.packageVersion ?? null,
2154
+ now: input.now ?? nowSeconds(),
2155
+ createdBy: 'cms.media-replacement',
2156
+ expectedCurrentSnapshot: current,
2157
+ expectedReceiptIdentity: readiness.receiptIdentity,
2158
+ mediaReplacement: { identity, payload },
2159
+ })
2160
+ }
2161
+ const persisted = await getRevision(db, input.contentId, revisionId)
2162
+ const persistedPayload = snapshotRecord(persisted?.payload)
2163
+ if (persistedPayload.publication_receipt_identity !== readiness.receiptIdentity)
2164
+ throw new PublicationCurrentSnapshotError()
2165
+ const durable = await getPublicationAttempt(db, attemptId)
2166
+ if (durable.state === 'committed') {
2167
+ const actual = await getContentItem(db, input.contentId)
2168
+ if (actual?.published_revision_id !== revisionId) throw new PublicationCurrentSnapshotError()
2169
+ return { status: 'committed', deduped: true, revisionId, attemptId }
2170
+ }
2171
+ const result = await retryPublicationSurfaces(db, { attemptId, now: input.now, surfaces })
2172
+ if (result.status !== 'reconciled') throw new Error('Published media surfaces require retry')
2173
+ return { status: 'committed', deduped: Boolean(storedRevision), revisionId, attemptId }
2174
+ }
2175
+
1827
2176
  export async function publishOne(
1828
2177
  db: D1Database,
1829
2178
  input: PublishOneInput,
@@ -2017,10 +2366,24 @@ export async function retryPublicationSurfaces(
2017
2366
  attempt = await supersedePublicationAttempt(db, attempt, now)
2018
2367
  throw new PublicationAttemptSupersededError(attempt)
2019
2368
  }
2369
+ const revision = await getRevision(db, attempt.contentId, attempt.revisionId)
2370
+ if (!revision) throw new PublicationNotFoundError(`revision not found: ${attempt.revisionId}`)
2371
+ const reconciliationInput: PublicationReconciliationInput = {
2372
+ operation: attempt.operation,
2373
+ contentId: attempt.contentId,
2374
+ revisionId: attempt.revisionId,
2375
+ previousRevisionId: attempt.previousRevisionId,
2376
+ snapshot: snapshotRecord(revision.payload),
2377
+ now,
2378
+ packageVersion: attempt.packageVersion,
2379
+ }
2020
2380
  const stalePrecanonicalAttempt =
2021
2381
  ['preparing', 'prepared'].includes(attempt.state) &&
2022
- item.published_revision_id !== attempt.previousRevisionId &&
2023
- item.published_revision_id !== attempt.revisionId
2382
+ ((item.published_revision_id !== attempt.previousRevisionId &&
2383
+ item.published_revision_id !== attempt.revisionId) ||
2384
+ (attempt.operation === 'publish' &&
2385
+ reconciliationInput.snapshot.publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD &&
2386
+ !(await mediaPreparationStillCurrent(db, reconciliationInput, true))))
2024
2387
  if (stalePrecanonicalAttempt) {
2025
2388
  attempt = await claimPrecanonicalAttemptForCleanup(db, attempt, now)
2026
2389
  }
@@ -2033,17 +2396,6 @@ export async function retryPublicationSurfaces(
2033
2396
  retrySurfaceInput,
2034
2397
  cleanupRecovery ? attempt.preparedSurfaceNames : attempt.pendingSurfaceNames,
2035
2398
  )
2036
- const revision = await getRevision(db, attempt.contentId, attempt.revisionId)
2037
- if (!revision) throw new PublicationNotFoundError(`revision not found: ${attempt.revisionId}`)
2038
- const reconciliationInput: PublicationReconciliationInput = {
2039
- operation: attempt.operation,
2040
- contentId: attempt.contentId,
2041
- revisionId: attempt.revisionId,
2042
- previousRevisionId: attempt.previousRevisionId,
2043
- snapshot: revision.payload as unknown as Record<string, unknown>,
2044
- now,
2045
- packageVersion: attempt.packageVersion,
2046
- }
2047
2399
  if (cleanupRecovery) {
2048
2400
  const cleanup = await retryAbortCleanup(
2049
2401
  db,