@growth-labs/cms 0.8.3 → 0.8.5
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/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +47 -24
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +8 -4
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +29 -6
- 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/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 +17 -11
- package/dist/engine/publisher.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +20 -5
- package/dist/routes/content.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 +12 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +8 -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/migrations/0030_media_publication_guard.sql +5 -0
- package/migrations/0031_podcast_processing_error.sql +2 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +93 -35
- package/src/engine/fronts-publish-intent.ts +8 -4
- package/src/engine/fronts-publish.ts +46 -7
- package/src/engine/index.ts +3 -0
- package/src/engine/publication-current-guard.ts +86 -0
- package/src/engine/publication.ts +411 -59
- package/src/engine/publisher.ts +19 -9
- package/src/routes/content.ts +23 -5
- package/src/schema/migrations.ts +12 -0
- package/src/schema/types.ts +8 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ContentMetadataError, MAX_CONTENT_METADATA_BYTES, serializeContentMetadata, } from './content-metadata.js';
|
|
2
|
-
import {
|
|
2
|
+
import { frontsPodcastSourceGeneration, podcastPublishBlocker } from './podcast-source.js';
|
|
3
|
+
import { assertCurrentMediaPublicationSnapshot, assertCurrentPublicationSnapshot, CURRENT_MEDIA_PUBLICATION_GUARD, CURRENT_PUBLICATION_GUARD, currentPublicationSqlGuard, MEDIA_PUBLICATION_ITEM_FIELDS, PUBLISHED_PODCAST_MEDIA_FIELDS, PUBLISHED_VIDEO_MEDIA_FIELDS, PublicationCurrentSnapshotError, snapshotRecord, } from './publication-current-guard.js';
|
|
3
4
|
import { evaluateArticleBody } from './publish-guard.js';
|
|
4
5
|
import { createContent, ensureUniqueSlug, getContentItem, getContentSnapshot, PUBLISH_METADATA_STAMP_SQL, PublishContentValidationError, publishMetadataStampBinds, } from './publisher.js';
|
|
5
6
|
import { getRevision } from './revisions.js';
|
|
6
7
|
import { slugify } from './slug.js';
|
|
7
8
|
import { TagInputError, validateDeclaredTagList } from './taxonomy.js';
|
|
9
|
+
import { frontsVideoSourceGeneration } from './video-source.js';
|
|
8
10
|
export class PublicationReconciliationError extends Error {
|
|
9
11
|
receipts;
|
|
10
12
|
attemptId;
|
|
@@ -340,7 +342,8 @@ function publicationAttemptInsert(db, id, input) {
|
|
|
340
342
|
surface_names_json, pending_surface_names_json,
|
|
341
343
|
prepared_surface_names_json, receipts_json,
|
|
342
344
|
package_version, created_at, updated_at
|
|
343
|
-
) VALUES (?, ?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)
|
|
345
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)
|
|
346
|
+
${input.idempotent ? 'ON CONFLICT(id) DO NOTHING' : ''}`)
|
|
344
347
|
.bind(id, input.operation, input.contentId, input.revisionId, input.previousRevisionId, input.targetPublishedAt, JSON.stringify(input.surfaceNames), JSON.stringify(input.surfaceNames), input.packageVersion, input.now, input.now);
|
|
345
348
|
}
|
|
346
349
|
async function persistPublicationAttempt(db, input) {
|
|
@@ -372,12 +375,19 @@ async function persistPublicationAttempt(db, input) {
|
|
|
372
375
|
return current;
|
|
373
376
|
}
|
|
374
377
|
async function createRevisionAndPublicationAttempt(db, input) {
|
|
375
|
-
const
|
|
376
|
-
if (!
|
|
378
|
+
const current = await getContentSnapshot(db, input.contentId);
|
|
379
|
+
if (!current)
|
|
377
380
|
throw new PublicationNotFoundError(`content not found: ${input.contentId}`);
|
|
381
|
+
const snapshot = input.mediaReplacement?.payload ?? current;
|
|
378
382
|
if (input.expectedCurrentSnapshot) {
|
|
379
|
-
|
|
380
|
-
|
|
383
|
+
if (input.mediaReplacement) {
|
|
384
|
+
assertCurrentMediaPublicationSnapshot(current, input.expectedCurrentSnapshot);
|
|
385
|
+
snapshot.publication_guard = CURRENT_MEDIA_PUBLICATION_GUARD;
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
assertCurrentPublicationSnapshot(snapshot, input.expectedCurrentSnapshot);
|
|
389
|
+
snapshot.publication_guard = CURRENT_PUBLICATION_GUARD;
|
|
390
|
+
}
|
|
381
391
|
if (input.expectedReceiptIdentity !== undefined) {
|
|
382
392
|
if (!/^[a-f0-9]{64}$/.test(input.expectedReceiptIdentity))
|
|
383
393
|
throw new Error('Invalid guarded publication receipt identity');
|
|
@@ -385,11 +395,16 @@ async function createRevisionAndPublicationAttempt(db, input) {
|
|
|
385
395
|
}
|
|
386
396
|
}
|
|
387
397
|
assertPublicationSnapshotBodyValid(snapshot, input.contentId);
|
|
388
|
-
const revisionId =
|
|
389
|
-
|
|
398
|
+
const revisionId = input.mediaReplacement
|
|
399
|
+
? `media:${input.mediaReplacement.identity}`
|
|
400
|
+
: crypto.randomUUID();
|
|
401
|
+
const attemptId = input.mediaReplacement
|
|
402
|
+
? `media:${input.mediaReplacement.identity}`
|
|
403
|
+
: crypto.randomUUID();
|
|
390
404
|
await db.batch([
|
|
391
405
|
db
|
|
392
|
-
.prepare(
|
|
406
|
+
.prepare(`INSERT INTO content_revisions (id, content_id, payload_json, created_at, created_by) VALUES (?, ?, ?, ?, ?)
|
|
407
|
+
${input.mediaReplacement ? 'ON CONFLICT(id) DO NOTHING' : ''}`)
|
|
393
408
|
.bind(revisionId, input.contentId, JSON.stringify(snapshot), input.now, input.createdBy ?? null),
|
|
394
409
|
publicationAttemptInsert(db, attemptId, {
|
|
395
410
|
operation: 'publish',
|
|
@@ -400,9 +415,17 @@ async function createRevisionAndPublicationAttempt(db, input) {
|
|
|
400
415
|
surfaceNames: input.surfaceNames,
|
|
401
416
|
packageVersion: input.packageVersion,
|
|
402
417
|
now: input.now,
|
|
418
|
+
idempotent: Boolean(input.mediaReplacement),
|
|
403
419
|
}),
|
|
404
420
|
]);
|
|
405
|
-
|
|
421
|
+
const persisted = input.mediaReplacement
|
|
422
|
+
? await getRevision(db, input.contentId, revisionId)
|
|
423
|
+
: null;
|
|
424
|
+
return {
|
|
425
|
+
revisionId,
|
|
426
|
+
snapshot: persisted ? snapshotRecord(persisted.payload) : snapshot,
|
|
427
|
+
attempt: await getPublicationAttempt(db, attemptId),
|
|
428
|
+
};
|
|
406
429
|
}
|
|
407
430
|
function assertPublicationSnapshotBodyValid(snapshot, contentId) {
|
|
408
431
|
const item = snapshot.item;
|
|
@@ -574,12 +597,38 @@ function normalizeReceipt(surface, phase, receipt, started) {
|
|
|
574
597
|
durationMs: suppliedDuration ?? durationMs,
|
|
575
598
|
};
|
|
576
599
|
}
|
|
600
|
+
async function mediaPreparationStillCurrent(db, input, allowCommittedPointer = false) {
|
|
601
|
+
if (input.operation !== 'publish' ||
|
|
602
|
+
input.snapshot.publication_guard !== CURRENT_MEDIA_PUBLICATION_GUARD)
|
|
603
|
+
return false;
|
|
604
|
+
const current = await getContentSnapshot(db, input.contentId);
|
|
605
|
+
const expected = structuredClone(snapshotRecord(input.snapshot.publication_current_snapshot));
|
|
606
|
+
if (allowCommittedPointer &&
|
|
607
|
+
snapshotRecord(current?.item).published_revision_id === input.revisionId) {
|
|
608
|
+
snapshotRecord(expected.item).published_revision_id = input.revisionId;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
assertCurrentMediaPublicationSnapshot(current ?? {}, expected);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (error instanceof PublicationCurrentSnapshotError)
|
|
616
|
+
return false;
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
577
620
|
async function prepareSurfaces(db, attempt, surfaces, input, surfaceHookTimeoutMs) {
|
|
578
621
|
let durable = attempt;
|
|
579
622
|
let receipts = [...durable.receipts];
|
|
580
623
|
let preparedNames = new Set(durable.preparedSurfaceNames);
|
|
624
|
+
const mediaReplacement = input.operation === 'publish' &&
|
|
625
|
+
input.snapshot.publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD;
|
|
626
|
+
const preparedSuccessfully = (name) => durable.receipts
|
|
627
|
+
.filter((receipt) => receipt.name === name && receipt.phase === 'prepare')
|
|
628
|
+
.at(-1)?.status === 'ok';
|
|
581
629
|
surfaceLoop: for (const surface of surfaces) {
|
|
582
|
-
if (preparedNames.has(surface.name)
|
|
630
|
+
if (preparedNames.has(surface.name) &&
|
|
631
|
+
(!mediaReplacement || preparedSuccessfully(surface.name)))
|
|
583
632
|
continue;
|
|
584
633
|
if (durable.state !== 'preparing') {
|
|
585
634
|
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${durable.state}`, durable.receipts, durable.id);
|
|
@@ -602,6 +651,39 @@ async function prepareSurfaces(db, attempt, surfaces, input, surfaceHookTimeoutM
|
|
|
602
651
|
}
|
|
603
652
|
receipts = [...durable.receipts, normalized];
|
|
604
653
|
if (normalized.status !== 'ok') {
|
|
654
|
+
// A partial private preparation remains owned by this attempt. Retrying
|
|
655
|
+
// its idempotent prepare must never race a late abort of the same revision.
|
|
656
|
+
if (await mediaPreparationStillCurrent(db, input)) {
|
|
657
|
+
for (;;) {
|
|
658
|
+
if (preparedSuccessfully(surface.name)) {
|
|
659
|
+
receipts = [...durable.receipts];
|
|
660
|
+
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
661
|
+
continue surfaceLoop;
|
|
662
|
+
}
|
|
663
|
+
if (durable.state !== 'preparing')
|
|
664
|
+
break;
|
|
665
|
+
try {
|
|
666
|
+
durable = await persistPublicationAttempt(db, {
|
|
667
|
+
attemptId: durable.id,
|
|
668
|
+
expectedVersion: durable.version,
|
|
669
|
+
expectedStates: ['preparing'],
|
|
670
|
+
state: 'preparing',
|
|
671
|
+
pendingSurfaceNames: durable.pendingSurfaceNames,
|
|
672
|
+
preparedSurfaceNames: [...new Set([...durable.preparedSurfaceNames, surface.name])],
|
|
673
|
+
receipts: [...durable.receipts, normalized],
|
|
674
|
+
lastError: durableDiagnostic('surface_prepare_failed'),
|
|
675
|
+
now: input.now,
|
|
676
|
+
});
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
681
|
+
throw error;
|
|
682
|
+
durable = error.current;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
throw new PublicationReconciliationError(`publication surface requires retry: ${surface.name}`, durable.receipts, durable.id);
|
|
686
|
+
}
|
|
605
687
|
let cleanupNames = new Set([...preparedNames, surface.name]);
|
|
606
688
|
let claimed;
|
|
607
689
|
for (;;) {
|
|
@@ -623,7 +705,8 @@ async function prepareSurfaces(db, attempt, surfaces, input, surfaceHookTimeoutM
|
|
|
623
705
|
if (!(error instanceof PublicationAttemptConflictError))
|
|
624
706
|
throw error;
|
|
625
707
|
durable = error.current;
|
|
626
|
-
if (durable.preparedSurfaceNames.includes(surface.name)
|
|
708
|
+
if (durable.preparedSurfaceNames.includes(surface.name) &&
|
|
709
|
+
(!mediaReplacement || preparedSuccessfully(surface.name))) {
|
|
627
710
|
receipts = [...durable.receipts];
|
|
628
711
|
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
629
712
|
continue surfaceLoop;
|
|
@@ -685,7 +768,8 @@ async function prepareSurfaces(db, attempt, surfaces, input, surfaceHookTimeoutM
|
|
|
685
768
|
if (!(error instanceof PublicationAttemptConflictError))
|
|
686
769
|
throw error;
|
|
687
770
|
durable = error.current;
|
|
688
|
-
if (durable.preparedSurfaceNames.includes(surface.name)
|
|
771
|
+
if (durable.preparedSurfaceNames.includes(surface.name) &&
|
|
772
|
+
(!mediaReplacement || preparedSuccessfully(surface.name))) {
|
|
689
773
|
receipts = [...durable.receipts];
|
|
690
774
|
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
691
775
|
break;
|
|
@@ -1019,6 +1103,8 @@ async function claimPrecanonicalAttemptForCleanup(db, attempt, now) {
|
|
|
1019
1103
|
}
|
|
1020
1104
|
}
|
|
1021
1105
|
async function movePointerAndBeginCommit(db, attempt, now) {
|
|
1106
|
+
const targetRevision = await getRevision(db, attempt.contentId, attempt.revisionId);
|
|
1107
|
+
const mediaReplacement = snapshotRecord(targetRevision?.payload).publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD;
|
|
1022
1108
|
if (attempt.operation === 'publish' &&
|
|
1023
1109
|
(!Number.isSafeInteger(attempt.targetPublishedAt) || attempt.targetPublishedAt < 0)) {
|
|
1024
1110
|
throw new PublicationInputError('published_at_invalid', 'publication attempt has an invalid target publication time');
|
|
@@ -1044,9 +1130,17 @@ async function movePointerAndBeginCommit(db, attempt, now) {
|
|
|
1044
1130
|
json_extract(target_revision.payload_json, '$.item.slug')
|
|
1045
1131
|
)
|
|
1046
1132
|
)`;
|
|
1047
|
-
const pointerStatement = attempt.operation === 'publish'
|
|
1133
|
+
const pointerStatement = mediaReplacement && attempt.operation === 'publish'
|
|
1048
1134
|
? db
|
|
1049
|
-
.prepare(`UPDATE content_items
|
|
1135
|
+
.prepare(`UPDATE content_items SET published_revision_id = ?
|
|
1136
|
+
WHERE id = ? AND published_revision_id IS ?
|
|
1137
|
+
AND EXISTS (SELECT 1 FROM content_publication_attempts
|
|
1138
|
+
WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?)
|
|
1139
|
+
AND ${targetRevisionSlugAvailable}`)
|
|
1140
|
+
.bind(attempt.revisionId, attempt.contentId, attempt.previousRevisionId, attempt.id, attempt.revisionId, attempt.contentId, attempt.revisionId, attempt.contentId, attempt.contentId)
|
|
1141
|
+
: attempt.operation === 'publish'
|
|
1142
|
+
? db
|
|
1143
|
+
.prepare(`UPDATE content_items
|
|
1050
1144
|
SET status = 'published', published_at = ?, publish_at = NULL,
|
|
1051
1145
|
published_revision_id = ?, updated_at = unixepoch(),
|
|
1052
1146
|
${PUBLISH_METADATA_STAMP_SQL}
|
|
@@ -1057,12 +1151,12 @@ async function movePointerAndBeginCommit(db, attempt, now) {
|
|
|
1057
1151
|
WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
|
|
1058
1152
|
)
|
|
1059
1153
|
AND ${targetRevisionSlugAvailable}`)
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1154
|
+
.bind(attempt.targetPublishedAt, attempt.revisionId,
|
|
1155
|
+
// Wall clock, NOT targetPublishedAt — the latter is routinely backdated
|
|
1156
|
+
// to the source video's date on the channel sites.
|
|
1157
|
+
...publishMetadataStampBinds(now), attempt.contentId, attempt.previousRevisionId ?? '', attempt.id, attempt.revisionId, attempt.contentId, attempt.revisionId, attempt.contentId, attempt.contentId)
|
|
1158
|
+
: db
|
|
1159
|
+
.prepare(`UPDATE content_items
|
|
1066
1160
|
SET status = 'published', publish_at = NULL,
|
|
1067
1161
|
published_revision_id = ?, updated_at = unixepoch()
|
|
1068
1162
|
WHERE id = ?
|
|
@@ -1072,7 +1166,7 @@ async function movePointerAndBeginCommit(db, attempt, now) {
|
|
|
1072
1166
|
WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
|
|
1073
1167
|
)
|
|
1074
1168
|
AND ${targetRevisionSlugAvailable}`)
|
|
1075
|
-
|
|
1169
|
+
.bind(attempt.revisionId, attempt.contentId, attempt.previousRevisionId ?? '', attempt.id, attempt.revisionId, attempt.contentId, attempt.revisionId, attempt.contentId, attempt.contentId);
|
|
1076
1170
|
const attemptStatement = db
|
|
1077
1171
|
.prepare(`UPDATE content_publication_attempts
|
|
1078
1172
|
SET state = 'committing', updated_at = ?, version = version + 1
|
|
@@ -1125,6 +1219,11 @@ async function movePointerAndBeginCommit(db, attempt, now) {
|
|
|
1125
1219
|
}
|
|
1126
1220
|
}
|
|
1127
1221
|
async function abortAfterPointerFailure(db, attempt, preparedSurfaces, reconciliationInput, receipts, surfaceHookTimeoutMs) {
|
|
1222
|
+
// A transport failure may precede execution or lose the successful response.
|
|
1223
|
+
// Keep this capture's durable attempt recoverable while its guard still holds.
|
|
1224
|
+
if (await mediaPreparationStillCurrent(db, reconciliationInput, true)) {
|
|
1225
|
+
throw new PublicationReconciliationError('Published media pointer requires retry', receipts, attempt.id);
|
|
1226
|
+
}
|
|
1128
1227
|
let claimed;
|
|
1129
1228
|
try {
|
|
1130
1229
|
claimed = await persistPublicationAttempt(db, {
|
|
@@ -1175,6 +1274,176 @@ async function abortAfterPointerFailure(db, attempt, preparedSurfaces, reconcili
|
|
|
1175
1274
|
}
|
|
1176
1275
|
throw new PublicationPointerMoveError(`canonical pointer did not move for ${finalized.operation}`, finalized.receipts, finalized.id);
|
|
1177
1276
|
}
|
|
1277
|
+
async function mediaIdentity(value) {
|
|
1278
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
1279
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
1280
|
+
}
|
|
1281
|
+
/** A media callback can finish this existing publication without publishing its draft. */
|
|
1282
|
+
export async function commitPreparedPublishedMedia(db, input) {
|
|
1283
|
+
const current = await getContentSnapshot(db, input.contentId);
|
|
1284
|
+
if (!current)
|
|
1285
|
+
throw new PublicationCurrentSnapshotError();
|
|
1286
|
+
const item = snapshotRecord(current.item);
|
|
1287
|
+
const content = snapshotRecord(current.content);
|
|
1288
|
+
if (!['video', 'podcast'].includes(String(item.type)) ||
|
|
1289
|
+
content.processing_publication_guard == null) {
|
|
1290
|
+
return { status: 'ineligible' };
|
|
1291
|
+
}
|
|
1292
|
+
let capture;
|
|
1293
|
+
try {
|
|
1294
|
+
capture = snapshotRecord(JSON.parse(String(content.processing_publication_guard)));
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
throw new PublicationCurrentSnapshotError();
|
|
1298
|
+
}
|
|
1299
|
+
const capturedItem = snapshotRecord(capture.item);
|
|
1300
|
+
if (capture.version !== 1 ||
|
|
1301
|
+
(capture.preparation_generation != null &&
|
|
1302
|
+
(typeof capture.preparation_generation !== 'string' ||
|
|
1303
|
+
!capture.preparation_generation ||
|
|
1304
|
+
capture.preparation_generation.length > 256)) ||
|
|
1305
|
+
capturedItem.status !== 'published' ||
|
|
1306
|
+
typeof capturedItem.published_revision_id !== 'string' ||
|
|
1307
|
+
!capturedItem.published_revision_id ||
|
|
1308
|
+
typeof content.processing_trigger_token !== 'string' ||
|
|
1309
|
+
!content.processing_trigger_token ||
|
|
1310
|
+
capture.trigger_token_hash !==
|
|
1311
|
+
`sha256:${await mediaIdentity(content.processing_trigger_token)}` ||
|
|
1312
|
+
item.status !== 'published' ||
|
|
1313
|
+
item.deleted_at != null ||
|
|
1314
|
+
MEDIA_PUBLICATION_ITEM_FIELDS.some((field) => field !== 'published_revision_id' && item[field] !== capturedItem[field])) {
|
|
1315
|
+
throw new PublicationCurrentSnapshotError();
|
|
1316
|
+
}
|
|
1317
|
+
const source = {
|
|
1318
|
+
content_id: input.contentId,
|
|
1319
|
+
slug: String(item.slug),
|
|
1320
|
+
canonical_version: Number(item.canonical_version),
|
|
1321
|
+
processing_source_url: typeof content.processing_source_url === 'string' ? content.processing_source_url : null,
|
|
1322
|
+
processing_source_kind: typeof content.processing_source_kind === 'string' ? content.processing_source_kind : null,
|
|
1323
|
+
processing_trigger_token: content.processing_trigger_token,
|
|
1324
|
+
};
|
|
1325
|
+
const generation = await (item.type === 'video'
|
|
1326
|
+
? frontsVideoSourceGeneration(source)
|
|
1327
|
+
: frontsPodcastSourceGeneration(source));
|
|
1328
|
+
if (generation !== input.sourceGeneration)
|
|
1329
|
+
throw new PublicationCurrentSnapshotError();
|
|
1330
|
+
const identity = await mediaIdentity(JSON.stringify([
|
|
1331
|
+
CURRENT_MEDIA_PUBLICATION_GUARD,
|
|
1332
|
+
input.contentId,
|
|
1333
|
+
capturedItem.published_revision_id,
|
|
1334
|
+
capture.trigger_token_hash,
|
|
1335
|
+
generation,
|
|
1336
|
+
capture.preparation_generation ?? null,
|
|
1337
|
+
]));
|
|
1338
|
+
const revisionId = `media:${identity}`;
|
|
1339
|
+
const attemptId = revisionId;
|
|
1340
|
+
if (item.published_revision_id !== capturedItem.published_revision_id &&
|
|
1341
|
+
item.published_revision_id !== revisionId) {
|
|
1342
|
+
throw new PublicationCurrentSnapshotError();
|
|
1343
|
+
}
|
|
1344
|
+
const storedRevision = await getRevision(db, input.contentId, revisionId);
|
|
1345
|
+
if (storedRevision) {
|
|
1346
|
+
const stored = snapshotRecord(storedRevision.payload);
|
|
1347
|
+
if (stored.publication_media_identity !== identity ||
|
|
1348
|
+
stored.publication_guard !== CURRENT_MEDIA_PUBLICATION_GUARD) {
|
|
1349
|
+
throw new PublicationCurrentSnapshotError();
|
|
1350
|
+
}
|
|
1351
|
+
const expected = structuredClone(snapshotRecord(stored.publication_current_snapshot));
|
|
1352
|
+
if (item.published_revision_id === revisionId)
|
|
1353
|
+
snapshotRecord(expected.item).published_revision_id = revisionId;
|
|
1354
|
+
assertCurrentMediaPublicationSnapshot(current, expected);
|
|
1355
|
+
const durable = await getPublicationAttempt(db, attemptId);
|
|
1356
|
+
if (durable.state === 'committed') {
|
|
1357
|
+
if (item.published_revision_id !== revisionId)
|
|
1358
|
+
throw new PublicationCurrentSnapshotError();
|
|
1359
|
+
return { status: 'committed', deduped: true, revisionId, attemptId };
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
const incomplete = item.type === 'video'
|
|
1363
|
+
? content.processing_state !== 'ready' ||
|
|
1364
|
+
content.hls_ready !== 1 ||
|
|
1365
|
+
content.transcript_ready !== 1 ||
|
|
1366
|
+
!content.hls_manifest_url ||
|
|
1367
|
+
!content.transcript_url
|
|
1368
|
+
: Boolean(podcastPublishBlocker(content));
|
|
1369
|
+
if (incomplete)
|
|
1370
|
+
return { status: 'pending', reason: 'media_incomplete' };
|
|
1371
|
+
const controller = new AbortController();
|
|
1372
|
+
let timeout;
|
|
1373
|
+
let readiness;
|
|
1374
|
+
try {
|
|
1375
|
+
readiness = await Promise.race([
|
|
1376
|
+
input.checkReadiness({
|
|
1377
|
+
snapshot: structuredClone(current),
|
|
1378
|
+
sourceGeneration: generation,
|
|
1379
|
+
signal: controller.signal,
|
|
1380
|
+
}),
|
|
1381
|
+
new Promise((_resolve, reject) => {
|
|
1382
|
+
timeout = setTimeout(() => {
|
|
1383
|
+
controller.abort();
|
|
1384
|
+
reject(new Error('Published media verification timed out'));
|
|
1385
|
+
}, 10_000);
|
|
1386
|
+
}),
|
|
1387
|
+
]);
|
|
1388
|
+
}
|
|
1389
|
+
finally {
|
|
1390
|
+
if (timeout !== undefined)
|
|
1391
|
+
clearTimeout(timeout);
|
|
1392
|
+
}
|
|
1393
|
+
if (readiness.ready !== true)
|
|
1394
|
+
return { status: 'pending', reason: 'verification_unready' };
|
|
1395
|
+
if (!readiness.receiptIdentity || !/^[a-f0-9]{64}$/.test(readiness.receiptIdentity)) {
|
|
1396
|
+
throw new Error('Verified published media requires an exact receipt identity');
|
|
1397
|
+
}
|
|
1398
|
+
assertCurrentMediaPublicationSnapshot((await getContentSnapshot(db, input.contentId)) ?? {}, current);
|
|
1399
|
+
const surfaces = normalizeSurfaces(input);
|
|
1400
|
+
if (!storedRevision) {
|
|
1401
|
+
const base = await getRevision(db, input.contentId, capturedItem.published_revision_id);
|
|
1402
|
+
if (!base ||
|
|
1403
|
+
base.payload.item.slug !== item.slug ||
|
|
1404
|
+
base.payload.item.visibility !== item.visibility) {
|
|
1405
|
+
throw new PublicationCurrentSnapshotError();
|
|
1406
|
+
}
|
|
1407
|
+
const fields = item.type === 'video' ? PUBLISHED_VIDEO_MEDIA_FIELDS : PUBLISHED_PODCAST_MEDIA_FIELDS;
|
|
1408
|
+
const payload = snapshotRecord(structuredClone(base.payload));
|
|
1409
|
+
payload.content = {
|
|
1410
|
+
...snapshotRecord(payload.content),
|
|
1411
|
+
...Object.fromEntries(fields.map((field) => [field, content[field] ?? null])),
|
|
1412
|
+
};
|
|
1413
|
+
payload.publication_media_identity = identity;
|
|
1414
|
+
payload.publication_current_snapshot = {
|
|
1415
|
+
item: Object.fromEntries(MEDIA_PUBLICATION_ITEM_FIELDS.map((field) => [field, item[field] ?? null])),
|
|
1416
|
+
content: Object.fromEntries([...fields, 'processing_publication_guard'].map((field) => [field, content[field] ?? null])),
|
|
1417
|
+
};
|
|
1418
|
+
await createRevisionAndPublicationAttempt(db, {
|
|
1419
|
+
contentId: input.contentId,
|
|
1420
|
+
previousRevisionId: capturedItem.published_revision_id,
|
|
1421
|
+
targetPublishedAt: Number(capturedItem.published_at),
|
|
1422
|
+
surfaceNames: surfaces.map((surface) => surface.name),
|
|
1423
|
+
packageVersion: input.packageVersion ?? null,
|
|
1424
|
+
now: input.now ?? nowSeconds(),
|
|
1425
|
+
createdBy: 'cms.media-replacement',
|
|
1426
|
+
expectedCurrentSnapshot: current,
|
|
1427
|
+
expectedReceiptIdentity: readiness.receiptIdentity,
|
|
1428
|
+
mediaReplacement: { identity, payload },
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
const persisted = await getRevision(db, input.contentId, revisionId);
|
|
1432
|
+
const persistedPayload = snapshotRecord(persisted?.payload);
|
|
1433
|
+
if (persistedPayload.publication_receipt_identity !== readiness.receiptIdentity)
|
|
1434
|
+
throw new PublicationCurrentSnapshotError();
|
|
1435
|
+
const durable = await getPublicationAttempt(db, attemptId);
|
|
1436
|
+
if (durable.state === 'committed') {
|
|
1437
|
+
const actual = await getContentItem(db, input.contentId);
|
|
1438
|
+
if (actual?.published_revision_id !== revisionId)
|
|
1439
|
+
throw new PublicationCurrentSnapshotError();
|
|
1440
|
+
return { status: 'committed', deduped: true, revisionId, attemptId };
|
|
1441
|
+
}
|
|
1442
|
+
const result = await retryPublicationSurfaces(db, { attemptId, now: input.now, surfaces });
|
|
1443
|
+
if (result.status !== 'reconciled')
|
|
1444
|
+
throw new Error('Published media surfaces require retry');
|
|
1445
|
+
return { status: 'committed', deduped: Boolean(storedRevision), revisionId, attemptId };
|
|
1446
|
+
}
|
|
1178
1447
|
export async function publishOne(db, input) {
|
|
1179
1448
|
const now = input.now ?? nowSeconds();
|
|
1180
1449
|
const targetPublishedAt = normalizePublishedAt(input.publishedAt, now);
|
|
@@ -1316,17 +1585,6 @@ export async function retryPublicationSurfaces(db, input) {
|
|
|
1316
1585
|
attempt = await supersedePublicationAttempt(db, attempt, now);
|
|
1317
1586
|
throw new PublicationAttemptSupersededError(attempt);
|
|
1318
1587
|
}
|
|
1319
|
-
const stalePrecanonicalAttempt = ['preparing', 'prepared'].includes(attempt.state) &&
|
|
1320
|
-
item.published_revision_id !== attempt.previousRevisionId &&
|
|
1321
|
-
item.published_revision_id !== attempt.revisionId;
|
|
1322
|
-
if (stalePrecanonicalAttempt) {
|
|
1323
|
-
attempt = await claimPrecanonicalAttemptForCleanup(db, attempt, now);
|
|
1324
|
-
}
|
|
1325
|
-
const cleanupRecovery = stalePrecanonicalAttempt || ['aborting', 'abort_failed'].includes(attempt.state);
|
|
1326
|
-
const retrySurfaceInput = stalePrecanonicalAttempt
|
|
1327
|
-
? input.surfaces?.filter((surface) => attempt.preparedSurfaceNames.includes(surface.name))
|
|
1328
|
-
: input.surfaces;
|
|
1329
|
-
const surfaces = normalizeRetrySurfaces(retrySurfaceInput, cleanupRecovery ? attempt.preparedSurfaceNames : attempt.pendingSurfaceNames);
|
|
1330
1588
|
const revision = await getRevision(db, attempt.contentId, attempt.revisionId);
|
|
1331
1589
|
if (!revision)
|
|
1332
1590
|
throw new PublicationNotFoundError(`revision not found: ${attempt.revisionId}`);
|
|
@@ -1335,10 +1593,24 @@ export async function retryPublicationSurfaces(db, input) {
|
|
|
1335
1593
|
contentId: attempt.contentId,
|
|
1336
1594
|
revisionId: attempt.revisionId,
|
|
1337
1595
|
previousRevisionId: attempt.previousRevisionId,
|
|
1338
|
-
snapshot: revision.payload,
|
|
1596
|
+
snapshot: snapshotRecord(revision.payload),
|
|
1339
1597
|
now,
|
|
1340
1598
|
packageVersion: attempt.packageVersion,
|
|
1341
1599
|
};
|
|
1600
|
+
const stalePrecanonicalAttempt = ['preparing', 'prepared'].includes(attempt.state) &&
|
|
1601
|
+
((item.published_revision_id !== attempt.previousRevisionId &&
|
|
1602
|
+
item.published_revision_id !== attempt.revisionId) ||
|
|
1603
|
+
(attempt.operation === 'publish' &&
|
|
1604
|
+
reconciliationInput.snapshot.publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD &&
|
|
1605
|
+
!(await mediaPreparationStillCurrent(db, reconciliationInput, true))));
|
|
1606
|
+
if (stalePrecanonicalAttempt) {
|
|
1607
|
+
attempt = await claimPrecanonicalAttemptForCleanup(db, attempt, now);
|
|
1608
|
+
}
|
|
1609
|
+
const cleanupRecovery = stalePrecanonicalAttempt || ['aborting', 'abort_failed'].includes(attempt.state);
|
|
1610
|
+
const retrySurfaceInput = stalePrecanonicalAttempt
|
|
1611
|
+
? input.surfaces?.filter((surface) => attempt.preparedSurfaceNames.includes(surface.name))
|
|
1612
|
+
: input.surfaces;
|
|
1613
|
+
const surfaces = normalizeRetrySurfaces(retrySurfaceInput, cleanupRecovery ? attempt.preparedSurfaceNames : attempt.pendingSurfaceNames);
|
|
1342
1614
|
if (cleanupRecovery) {
|
|
1343
1615
|
const cleanup = await retryAbortCleanup(db, attempt, surfaces, reconciliationInput, surfaceHookTimeoutMs);
|
|
1344
1616
|
if (stalePrecanonicalAttempt) {
|