@growth-labs/cms 0.8.3 → 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 (47) hide show
  1. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  2. package/dist/engine/foundry-dispatch.js +42 -21
  3. package/dist/engine/foundry-dispatch.js.map +1 -1
  4. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  5. package/dist/engine/fronts-publish-intent.js +8 -4
  6. package/dist/engine/fronts-publish-intent.js.map +1 -1
  7. package/dist/engine/fronts-publish.d.ts.map +1 -1
  8. package/dist/engine/fronts-publish.js +29 -6
  9. package/dist/engine/fronts-publish.js.map +1 -1
  10. package/dist/engine/index.d.ts +1 -1
  11. package/dist/engine/index.d.ts.map +1 -1
  12. package/dist/engine/index.js +1 -1
  13. package/dist/engine/index.js.map +1 -1
  14. package/dist/engine/publication-current-guard.d.ts +7 -0
  15. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  16. package/dist/engine/publication-current-guard.js +66 -0
  17. package/dist/engine/publication-current-guard.js.map +1 -1
  18. package/dist/engine/publication.d.ts +30 -0
  19. package/dist/engine/publication.d.ts.map +1 -1
  20. package/dist/engine/publication.js +306 -34
  21. package/dist/engine/publication.js.map +1 -1
  22. package/dist/engine/publisher.d.ts.map +1 -1
  23. package/dist/engine/publisher.js +16 -11
  24. package/dist/engine/publisher.js.map +1 -1
  25. package/dist/routes/content.d.ts.map +1 -1
  26. package/dist/routes/content.js +19 -4
  27. package/dist/routes/content.js.map +1 -1
  28. package/dist/schema/insights-ingest.d.ts +42 -42
  29. package/dist/schema/migrations.d.ts.map +1 -1
  30. package/dist/schema/migrations.js +8 -0
  31. package/dist/schema/migrations.js.map +1 -1
  32. package/dist/schema/types.d.ts +6 -0
  33. package/dist/schema/types.d.ts.map +1 -1
  34. package/dist/schema/types.js.map +1 -1
  35. package/dist/surveys/schema.d.ts +48 -48
  36. package/migrations/0030_media_publication_guard.sql +5 -0
  37. package/package.json +1 -1
  38. package/src/engine/foundry-dispatch.ts +86 -32
  39. package/src/engine/fronts-publish-intent.ts +8 -4
  40. package/src/engine/fronts-publish.ts +46 -7
  41. package/src/engine/index.ts +3 -0
  42. package/src/engine/publication-current-guard.ts +86 -0
  43. package/src/engine/publication.ts +411 -59
  44. package/src/engine/publisher.ts +17 -9
  45. package/src/routes/content.ts +22 -4
  46. package/src/schema/migrations.ts +8 -0
  47. package/src/schema/types.ts +6 -0
@@ -1,10 +1,12 @@
1
1
  import { ContentMetadataError, MAX_CONTENT_METADATA_BYTES, serializeContentMetadata, } from './content-metadata.js';
2
- import { assertCurrentPublicationSnapshot, CURRENT_PUBLICATION_GUARD, currentPublicationSqlGuard, } from './publication-current-guard.js';
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 snapshot = await getContentSnapshot(db, input.contentId);
376
- if (!snapshot)
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
- assertCurrentPublicationSnapshot(snapshot, input.expectedCurrentSnapshot);
380
- snapshot.publication_guard = CURRENT_PUBLICATION_GUARD;
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 = crypto.randomUUID();
389
- const attemptId = crypto.randomUUID();
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('INSERT INTO content_revisions (id, content_id, payload_json, created_at, created_by) VALUES (?, ?, ?, ?, ?)')
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
- return { revisionId, snapshot, attempt: await getPublicationAttempt(db, attemptId) };
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
- .bind(attempt.targetPublishedAt, attempt.revisionId,
1061
- // Wall clock, NOT targetPublishedAt — the latter is routinely backdated
1062
- // to the source video's date on the channel sites.
1063
- ...publishMetadataStampBinds(now), attempt.contentId, attempt.previousRevisionId ?? '', attempt.id, attempt.revisionId, attempt.contentId, attempt.revisionId, attempt.contentId, attempt.contentId)
1064
- : db
1065
- .prepare(`UPDATE content_items
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
- .bind(attempt.revisionId, attempt.contentId, attempt.previousRevisionId ?? '', attempt.id, attempt.revisionId, attempt.contentId, attempt.revisionId, attempt.contentId, attempt.contentId);
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) {