@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
|
@@ -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
|
|
786
|
-
if (!
|
|
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
|
-
|
|
789
|
-
|
|
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 =
|
|
798
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
:
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
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,
|