@growth-labs/cms 0.6.6 → 0.6.7
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 +36 -7
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +15 -4
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +52 -19
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +19 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +158 -5
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +28 -0
- package/dist/engine/podcast-source.d.ts.map +1 -0
- package/dist/engine/podcast-source.js +50 -0
- package/dist/engine/podcast-source.js.map +1 -0
- package/dist/engine/publication-current-guard.d.ts +10 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -0
- package/dist/engine/publication-current-guard.js +76 -0
- package/dist/engine/publication-current-guard.js.map +1 -0
- package/dist/engine/publication.d.ts +4 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +13 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +20 -13
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.js +2 -2
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/providers/types.d.ts +9 -3
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content.d.ts +2 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +1 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -2
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +52 -23
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/routes/index.js +1 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/schema/layout.d.ts +2 -2
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +1 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
- package/dist/ui/api/fronts/publish-callback.js +2 -6
- package/dist/ui/api/fronts/publish-callback.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -6
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +67 -12
- package/src/engine/fronts-publish-intent.ts +78 -26
- package/src/engine/fronts-publish.ts +223 -5
- package/src/engine/index.ts +4 -0
- package/src/engine/podcast-source.ts +82 -0
- package/src/engine/publication-current-guard.ts +87 -0
- package/src/engine/publication.ts +23 -0
- package/src/engine/publisher.ts +21 -13
- package/src/engine/soft-delete.ts +2 -2
- package/src/providers/types.ts +6 -1
- package/src/routes/content.ts +3 -1
- package/src/routes/fronts-publish.ts +63 -25
- package/src/routes/index.ts +1 -1
- package/src/ui/api/_content-config.ts +2 -0
- package/src/ui/api/fronts/publish-callback.ts +2 -8
- package/src/ui/api/fronts/schedule.ts +2 -8
|
@@ -26,7 +26,15 @@
|
|
|
26
26
|
|
|
27
27
|
import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
|
|
28
28
|
import type { D1Database } from './d1.js'
|
|
29
|
+
import { FrontsScheduleOverflowError } from './fronts-publish-intent.js'
|
|
30
|
+
import { podcastPublishBlocker } from './podcast-source.js'
|
|
29
31
|
import { type PublicationReconciler, publishOne } from './publication.js'
|
|
32
|
+
import {
|
|
33
|
+
assertCurrentPublicationSnapshot,
|
|
34
|
+
PublicationCurrentSnapshotError,
|
|
35
|
+
snapshotRecord,
|
|
36
|
+
} from './publication-current-guard.js'
|
|
37
|
+
import { getContentSnapshot } from './publisher.js'
|
|
30
38
|
|
|
31
39
|
/** The five lifecycle states foundryd reports for a Fronts publish intent. */
|
|
32
40
|
export const FRONTS_PUBLISH_STATUSES: readonly FrontsPublishStatus[] = [
|
|
@@ -101,12 +109,13 @@ export async function listFrontsPublishSchedule(
|
|
|
101
109
|
`SELECT id, type, slug, title, channel, publish_at, fronts_publish_status
|
|
102
110
|
FROM content_items
|
|
103
111
|
WHERE status = 'scheduled'
|
|
112
|
+
AND deleted_at IS NULL
|
|
104
113
|
AND publish_at IS NOT NULL
|
|
105
114
|
AND publish_at <= ?
|
|
106
115
|
ORDER BY publish_at ASC
|
|
107
116
|
LIMIT ?`,
|
|
108
117
|
)
|
|
109
|
-
.bind(options.toSeconds, limit)
|
|
118
|
+
.bind(options.toSeconds, limit + 1)
|
|
110
119
|
.all<{
|
|
111
120
|
id: string
|
|
112
121
|
type: ContentType
|
|
@@ -116,6 +125,7 @@ export async function listFrontsPublishSchedule(
|
|
|
116
125
|
publish_at: number
|
|
117
126
|
fronts_publish_status: FrontsPublishStatus | null
|
|
118
127
|
}>()
|
|
128
|
+
if ((result.results?.length ?? 0) > limit) throw new FrontsScheduleOverflowError(limit)
|
|
119
129
|
return (result.results ?? []).map((row) => ({
|
|
120
130
|
id: row.id,
|
|
121
131
|
type: row.type,
|
|
@@ -149,6 +159,14 @@ export interface FrontsPublishCallbackInput {
|
|
|
149
159
|
error?: string | null
|
|
150
160
|
/** Reference time (unix seconds), for tests. Defaults to Date.now(). */
|
|
151
161
|
now?: number
|
|
162
|
+
/** Optional occurrence evidence carried by current publish workers. */
|
|
163
|
+
publishAt?: number | null
|
|
164
|
+
/** The route supplies the same host hook used by the editorial publish action. */
|
|
165
|
+
checkReadiness?: (item: {
|
|
166
|
+
id: string
|
|
167
|
+
type: ContentType
|
|
168
|
+
status: string
|
|
169
|
+
}) => Promise<{ ready: boolean; blockers?: string[]; message?: string }>
|
|
152
170
|
}
|
|
153
171
|
|
|
154
172
|
export interface FrontsPublishCallbackResult {
|
|
@@ -163,6 +181,8 @@ export interface FrontsPublishCallbackResult {
|
|
|
163
181
|
contentFound: boolean
|
|
164
182
|
/** Set when a `live` callback could not be committed through the ledger. */
|
|
165
183
|
ledgerError: string | null
|
|
184
|
+
/** A retryable hold or stale/cancelled snapshot; never consumes event_id. */
|
|
185
|
+
rejection?: { code: string; status: 404 | 409 | 503; blockers?: string[]; message: string }
|
|
166
186
|
}
|
|
167
187
|
|
|
168
188
|
function hasReceipt(receipt: FrontsPublishReceipt | null | undefined): boolean {
|
|
@@ -231,9 +251,117 @@ export async function applyFrontsPublishCallback(
|
|
|
231
251
|
let attemptId: string | null = null
|
|
232
252
|
let ledgerError: string | null = null
|
|
233
253
|
const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
|
|
254
|
+
if (shouldPublish && item.status === 'published') {
|
|
255
|
+
const current = await getContentSnapshot(db, input.contentId)
|
|
256
|
+
if (
|
|
257
|
+
!current ||
|
|
258
|
+
!(await matchesCommittedCallback(
|
|
259
|
+
db,
|
|
260
|
+
input.contentId,
|
|
261
|
+
current,
|
|
262
|
+
await hashReceiptIdentity(input.receipt),
|
|
263
|
+
input,
|
|
264
|
+
))
|
|
265
|
+
) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
deduped: false,
|
|
269
|
+
status: input.status,
|
|
270
|
+
published: false,
|
|
271
|
+
attemptId: null,
|
|
272
|
+
contentFound: true,
|
|
273
|
+
ledgerError: null,
|
|
274
|
+
rejection: {
|
|
275
|
+
code: 'publish_snapshot_changed',
|
|
276
|
+
status: 409,
|
|
277
|
+
message: 'The committed publication does not match this callback.',
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
234
282
|
if (shouldPublish && item.status !== 'published') {
|
|
283
|
+
const reject = (
|
|
284
|
+
code: string,
|
|
285
|
+
status: 404 | 409 | 503,
|
|
286
|
+
message: string,
|
|
287
|
+
blockers?: string[],
|
|
288
|
+
): FrontsPublishCallbackResult => ({
|
|
289
|
+
ok: false,
|
|
290
|
+
deduped: false,
|
|
291
|
+
status: input.status,
|
|
292
|
+
published: false,
|
|
293
|
+
attemptId: null,
|
|
294
|
+
contentFound: true,
|
|
295
|
+
ledgerError: null,
|
|
296
|
+
rejection: { code, status, message, ...(blockers ? { blockers } : {}) },
|
|
297
|
+
})
|
|
298
|
+
const expectedSnapshot = await getContentSnapshot(db, input.contentId)
|
|
299
|
+
const expectedItem = snapshotRecord(expectedSnapshot?.item)
|
|
300
|
+
if (
|
|
301
|
+
!expectedSnapshot ||
|
|
302
|
+
expectedItem.deleted_at != null ||
|
|
303
|
+
expectedItem.status !== 'scheduled'
|
|
304
|
+
) {
|
|
305
|
+
return reject('content_not_scheduled', 404, 'Content is no longer scheduled for publication.')
|
|
306
|
+
}
|
|
307
|
+
if (
|
|
308
|
+
(input.version != null && input.version !== expectedItem.canonical_version) ||
|
|
309
|
+
(input.publishAt != null && input.publishAt !== expectedItem.publish_at)
|
|
310
|
+
) {
|
|
311
|
+
return reject(
|
|
312
|
+
'publish_snapshot_changed',
|
|
313
|
+
409,
|
|
314
|
+
'The scheduled publication has been superseded.',
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
typeof expectedItem.publish_at !== 'number' ||
|
|
319
|
+
!Number.isFinite(expectedItem.publish_at) ||
|
|
320
|
+
expectedItem.publish_at > now
|
|
321
|
+
) {
|
|
322
|
+
return reject(
|
|
323
|
+
'publish_not_ready',
|
|
324
|
+
503,
|
|
325
|
+
'The current scheduled publication time has not arrived.',
|
|
326
|
+
['publish_not_due'],
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
const blocker =
|
|
330
|
+
expectedItem.type === 'podcast'
|
|
331
|
+
? podcastPublishBlocker(snapshotRecord(expectedSnapshot.content))
|
|
332
|
+
: null
|
|
333
|
+
if (blocker) return reject('publish_not_ready', 503, blocker.message, [blocker.code])
|
|
334
|
+
if (input.checkReadiness) {
|
|
335
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
336
|
+
try {
|
|
337
|
+
const readiness = await Promise.race([
|
|
338
|
+
input.checkReadiness({
|
|
339
|
+
id: input.contentId,
|
|
340
|
+
type: expectedItem.type as ContentType,
|
|
341
|
+
status: 'scheduled',
|
|
342
|
+
}),
|
|
343
|
+
new Promise<never>((_resolve, rejectTimeout) => {
|
|
344
|
+
timeout = setTimeout(() => rejectTimeout(new Error('readiness timed out')), 10_000)
|
|
345
|
+
}),
|
|
346
|
+
])
|
|
347
|
+
if (readiness.ready !== true)
|
|
348
|
+
return reject(
|
|
349
|
+
'publish_not_ready',
|
|
350
|
+
503,
|
|
351
|
+
readiness.message || 'Content is not ready for publication.',
|
|
352
|
+
readiness.blockers,
|
|
353
|
+
)
|
|
354
|
+
} catch {
|
|
355
|
+
return reject('publish_not_ready', 503, 'Publish readiness could not be verified.', [
|
|
356
|
+
'readiness_unavailable',
|
|
357
|
+
])
|
|
358
|
+
} finally {
|
|
359
|
+
if (timeout !== undefined) clearTimeout(timeout)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const receiptIdentity = await hashReceiptIdentity(input.receipt)
|
|
363
|
+
const receiptSummary = summarizeReceipt(input.receipt)
|
|
235
364
|
try {
|
|
236
|
-
const receiptSummary = summarizeReceipt(input.receipt)
|
|
237
365
|
const surface: PublicationReconciler = {
|
|
238
366
|
name: 'fronts',
|
|
239
367
|
async prepare() {
|
|
@@ -250,20 +378,43 @@ export async function applyFrontsPublishCallback(
|
|
|
250
378
|
...(publishedAt !== null ? { publishedAt } : {}),
|
|
251
379
|
createdBy: 'foundry.fronts-publish',
|
|
252
380
|
surfaces: [surface],
|
|
381
|
+
expectedCurrentSnapshot: expectedSnapshot,
|
|
382
|
+
expectedReceiptIdentity: receiptIdentity,
|
|
253
383
|
})
|
|
254
384
|
attemptId = receipt.attemptId
|
|
255
385
|
published = true
|
|
256
386
|
} catch (error) {
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
387
|
+
// A concurrently published row may belong to a newer editor/source
|
|
388
|
+
// revision. Only a matching committed snapshot and full receipt prove
|
|
389
|
+
// this callback's success; status='published' alone proves nothing.
|
|
260
390
|
const reread = await db
|
|
261
391
|
.prepare('SELECT status FROM content_items WHERE id = ? LIMIT 1')
|
|
262
392
|
.bind(input.contentId)
|
|
263
393
|
.first<{ status: string }>()
|
|
264
394
|
if (reread?.status === 'published') {
|
|
395
|
+
if (
|
|
396
|
+
!(await matchesCommittedCallback(db, input.contentId, expectedSnapshot, receiptIdentity))
|
|
397
|
+
) {
|
|
398
|
+
return reject(
|
|
399
|
+
'publish_snapshot_changed',
|
|
400
|
+
409,
|
|
401
|
+
'A different publication superseded this callback.',
|
|
402
|
+
)
|
|
403
|
+
}
|
|
265
404
|
published = true
|
|
266
405
|
} else {
|
|
406
|
+
if (error instanceof PublicationCurrentSnapshotError)
|
|
407
|
+
return reject('publish_snapshot_changed', 409, error.message)
|
|
408
|
+
const current = await getContentSnapshot(db, input.contentId)
|
|
409
|
+
try {
|
|
410
|
+
assertCurrentPublicationSnapshot(current ?? {}, expectedSnapshot)
|
|
411
|
+
} catch {
|
|
412
|
+
return reject(
|
|
413
|
+
'publish_snapshot_changed',
|
|
414
|
+
409,
|
|
415
|
+
'Scheduled content or media changed before publication.',
|
|
416
|
+
)
|
|
417
|
+
}
|
|
267
418
|
ledgerError = error instanceof Error ? error.message : String(error)
|
|
268
419
|
}
|
|
269
420
|
}
|
|
@@ -348,6 +499,73 @@ export async function applyFrontsPublishCallback(
|
|
|
348
499
|
}
|
|
349
500
|
}
|
|
350
501
|
|
|
502
|
+
async function hashReceiptIdentity(
|
|
503
|
+
receipt: FrontsPublishReceipt | null | undefined,
|
|
504
|
+
): Promise<string> {
|
|
505
|
+
const bytes = new TextEncoder().encode(
|
|
506
|
+
JSON.stringify([
|
|
507
|
+
'fronts-publish-receipt.v1',
|
|
508
|
+
receipt?.published_urls ?? null,
|
|
509
|
+
receipt?.platform_post_ids ?? null,
|
|
510
|
+
receipt?.sha256 ?? null,
|
|
511
|
+
]),
|
|
512
|
+
)
|
|
513
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
514
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function matchesCommittedCallback(
|
|
518
|
+
db: D1Database,
|
|
519
|
+
contentId: string,
|
|
520
|
+
expectedSnapshot: Record<string, unknown>,
|
|
521
|
+
receiptIdentity: string,
|
|
522
|
+
input?: Pick<FrontsPublishCallbackInput, 'version' | 'publishAt'>,
|
|
523
|
+
): Promise<boolean> {
|
|
524
|
+
const current = await db
|
|
525
|
+
.prepare(`SELECT r.id AS revision_id, r.payload_json, a.receipts_json
|
|
526
|
+
FROM content_items c
|
|
527
|
+
JOIN content_revisions r ON r.id = c.published_revision_id AND r.content_id = c.id
|
|
528
|
+
JOIN content_publication_attempts a ON a.revision_id = r.id AND a.content_id = c.id
|
|
529
|
+
WHERE c.id = ? AND c.status = 'published' AND c.deleted_at IS NULL
|
|
530
|
+
AND r.created_by = 'foundry.fronts-publish' AND a.operation = 'publish' AND a.state = 'committed'
|
|
531
|
+
LIMIT 1`)
|
|
532
|
+
.bind(contentId)
|
|
533
|
+
.first<{ revision_id: string; payload_json: string; receipts_json: string }>()
|
|
534
|
+
if (!current) return false
|
|
535
|
+
try {
|
|
536
|
+
const snapshot = snapshotRecord(JSON.parse(current.payload_json))
|
|
537
|
+
if (snapshot.publication_receipt_identity !== receiptIdentity) return false
|
|
538
|
+
const committedItem = snapshotRecord(snapshot.item)
|
|
539
|
+
const expectedItem = snapshotRecord(expectedSnapshot.item)
|
|
540
|
+
if (expectedItem.status === 'published') {
|
|
541
|
+
if (
|
|
542
|
+
expectedItem.published_revision_id !== current.revision_id ||
|
|
543
|
+
expectedItem.publish_at != null ||
|
|
544
|
+
(input?.version != null && input.version !== committedItem.canonical_version) ||
|
|
545
|
+
(input?.publishAt != null && input.publishAt !== committedItem.publish_at)
|
|
546
|
+
)
|
|
547
|
+
return false
|
|
548
|
+
// Commit deliberately changes only these scheduled guard fields.
|
|
549
|
+
// Every source/editor field must still match the immutable revision.
|
|
550
|
+
expectedSnapshot = {
|
|
551
|
+
...expectedSnapshot,
|
|
552
|
+
item: { ...expectedItem, status: 'scheduled', publish_at: committedItem.publish_at },
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
assertCurrentPublicationSnapshot(snapshot, expectedSnapshot)
|
|
556
|
+
const receipts: unknown = JSON.parse(current.receipts_json)
|
|
557
|
+
return (
|
|
558
|
+
Array.isArray(receipts) &&
|
|
559
|
+
receipts.some((value) => {
|
|
560
|
+
const receipt = snapshotRecord(value)
|
|
561
|
+
return receipt.name === 'fronts' && receipt.phase === 'commit' && receipt.status === 'ok'
|
|
562
|
+
})
|
|
563
|
+
)
|
|
564
|
+
} catch {
|
|
565
|
+
return false
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
351
569
|
/** Replay a stored callback outcome by event_id, or null when not yet recorded. */
|
|
352
570
|
async function readStoredOutcome(
|
|
353
571
|
db: D1Database,
|
package/src/engine/index.ts
CHANGED
|
@@ -155,6 +155,10 @@ export {
|
|
|
155
155
|
type OgCardSpecOpts,
|
|
156
156
|
type OgImageRenderer,
|
|
157
157
|
} from './og-render.js'
|
|
158
|
+
export {
|
|
159
|
+
type FrontsPodcastSourceSnapshot,
|
|
160
|
+
frontsPodcastSourceGeneration,
|
|
161
|
+
} from './podcast-source.js'
|
|
158
162
|
// Bounded one-item publication kernel.
|
|
159
163
|
export {
|
|
160
164
|
ContentArchiveImportLimitError,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Current CMS evidence used by the signed schedule and media callback guards. */
|
|
2
|
+
export interface FrontsPodcastSourceSnapshot {
|
|
3
|
+
content_id: string
|
|
4
|
+
slug: string
|
|
5
|
+
canonical_version: number
|
|
6
|
+
processing_source_url: string | null
|
|
7
|
+
processing_source_kind: string | null
|
|
8
|
+
processing_trigger_token: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable over polling, machine media callbacks and rescheduling. Canonical
|
|
13
|
+
* editor/source changes invalidate the old production. Neither the raw source
|
|
14
|
+
* token nor URL needs to appear in callback/notification identities.
|
|
15
|
+
*/
|
|
16
|
+
export async function frontsPodcastSourceGeneration(
|
|
17
|
+
input: FrontsPodcastSourceSnapshot,
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const bytes = new TextEncoder().encode(
|
|
20
|
+
JSON.stringify([
|
|
21
|
+
'fronts-podcast-source.v1',
|
|
22
|
+
input.content_id,
|
|
23
|
+
input.slug,
|
|
24
|
+
input.canonical_version,
|
|
25
|
+
input.processing_source_url,
|
|
26
|
+
input.processing_source_kind,
|
|
27
|
+
input.processing_trigger_token,
|
|
28
|
+
]),
|
|
29
|
+
)
|
|
30
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
31
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PodcastPublishBlockerCode =
|
|
35
|
+
| 'missing_podcast_audio'
|
|
36
|
+
| 'podcast_transcript_pending'
|
|
37
|
+
| 'podcast_source_duration_unavailable'
|
|
38
|
+
|
|
39
|
+
/** Portable minimum; hosts may enforce additional object/access checks. */
|
|
40
|
+
export function podcastPublishBlocker(
|
|
41
|
+
content:
|
|
42
|
+
| {
|
|
43
|
+
audio_r2_key?: unknown
|
|
44
|
+
transcript?: unknown
|
|
45
|
+
processing_source_url?: unknown
|
|
46
|
+
duration_seconds?: unknown
|
|
47
|
+
}
|
|
48
|
+
| null
|
|
49
|
+
| undefined,
|
|
50
|
+
): { code: PodcastPublishBlockerCode; message: string } | null {
|
|
51
|
+
if (typeof content?.audio_r2_key !== 'string' || !content.audio_r2_key.trim()) {
|
|
52
|
+
return { code: 'missing_podcast_audio', message: 'Podcast audio is not ready.' }
|
|
53
|
+
}
|
|
54
|
+
if (typeof content.transcript !== 'string' || !content.transcript.trim()) {
|
|
55
|
+
return { code: 'podcast_transcript_pending', message: 'Podcast transcript is not ready.' }
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
typeof content.processing_source_url === 'string' &&
|
|
59
|
+
content.processing_source_url.trim() &&
|
|
60
|
+
(typeof content.duration_seconds !== 'number' ||
|
|
61
|
+
!Number.isFinite(content.duration_seconds) ||
|
|
62
|
+
content.duration_seconds <= 0)
|
|
63
|
+
) {
|
|
64
|
+
return {
|
|
65
|
+
code: 'podcast_source_duration_unavailable',
|
|
66
|
+
message: 'Podcast source duration is not verified.',
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validPodcastSourceUrl(value: string | null | undefined): value is string {
|
|
73
|
+
if (!value || !/^https:\/\/\S+$/.test(value) || Array.from(value).length > 2048) return false
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(value)
|
|
76
|
+
return (
|
|
77
|
+
url.protocol === 'https:' && !!url.hostname && !url.username && !url.password && !url.hash
|
|
78
|
+
)
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** A guard lives with its revision, so retries retain the same editorial proof. */
|
|
2
|
+
export const CURRENT_PUBLICATION_GUARD = 'cms.scheduled-publication-current.v1'
|
|
3
|
+
|
|
4
|
+
export class PublicationCurrentSnapshotError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super('Scheduled content or media changed before publication.')
|
|
7
|
+
this.name = 'PublicationCurrentSnapshotError'
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function snapshotRecord(value: unknown): Record<string, unknown> {
|
|
12
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
? (value as Record<string, unknown>)
|
|
14
|
+
: {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ITEM_FIELDS = [
|
|
18
|
+
'id',
|
|
19
|
+
'type',
|
|
20
|
+
'slug',
|
|
21
|
+
'status',
|
|
22
|
+
'deleted_at',
|
|
23
|
+
'canonical_version',
|
|
24
|
+
'publish_at',
|
|
25
|
+
] as const
|
|
26
|
+
const PODCAST_FIELDS = [
|
|
27
|
+
'audio_r2_key',
|
|
28
|
+
'transcript',
|
|
29
|
+
'duration_seconds',
|
|
30
|
+
'processing_source_url',
|
|
31
|
+
'processing_source_kind',
|
|
32
|
+
'processing_trigger_token',
|
|
33
|
+
] as const
|
|
34
|
+
const VIDEO_FIELDS = [
|
|
35
|
+
'video_id',
|
|
36
|
+
'duration_seconds',
|
|
37
|
+
'processing_source_url',
|
|
38
|
+
'processing_source_kind',
|
|
39
|
+
'processing_trigger_token',
|
|
40
|
+
'processing_correlation_id',
|
|
41
|
+
'processing_state',
|
|
42
|
+
'hls_ready',
|
|
43
|
+
'hls_manifest_url',
|
|
44
|
+
'transcript_ready',
|
|
45
|
+
'transcript_url',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
export function assertCurrentPublicationSnapshot(
|
|
49
|
+
current: Record<string, unknown>,
|
|
50
|
+
expected: Record<string, unknown>,
|
|
51
|
+
): void {
|
|
52
|
+
const item = snapshotRecord(current.item)
|
|
53
|
+
const expectedItem = snapshotRecord(expected.item)
|
|
54
|
+
if (
|
|
55
|
+
item.status !== 'scheduled' ||
|
|
56
|
+
item.deleted_at != null ||
|
|
57
|
+
ITEM_FIELDS.some((field) => item[field] !== expectedItem[field])
|
|
58
|
+
) {
|
|
59
|
+
throw new PublicationCurrentSnapshotError()
|
|
60
|
+
}
|
|
61
|
+
const fields =
|
|
62
|
+
item.type === 'podcast' ? PODCAST_FIELDS : item.type === 'video' ? VIDEO_FIELDS : []
|
|
63
|
+
const content = snapshotRecord(current.content)
|
|
64
|
+
const expectedContent = snapshotRecord(expected.content)
|
|
65
|
+
if (fields.some((field) => content[field] !== expectedContent[field]))
|
|
66
|
+
throw new PublicationCurrentSnapshotError()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Uses the immutable revision in the existing pointer CAS; no second write. */
|
|
70
|
+
export function currentPublicationSqlGuard(revisionAlias: string): string {
|
|
71
|
+
const item = (field: string) => `json_extract(${revisionAlias}.payload_json, '$.item.${field}')`
|
|
72
|
+
const content = (field: string) =>
|
|
73
|
+
`json_extract(${revisionAlias}.payload_json, '$.content.${field}')`
|
|
74
|
+
const matches = (table: string, fields: readonly string[]) => `EXISTS (
|
|
75
|
+
SELECT 1 FROM ${table} current_media WHERE current_media.content_id = content_items.id
|
|
76
|
+
AND ${fields.map((field) => `current_media.${field} IS ${content(field)}`).join(' AND ')}
|
|
77
|
+
)`
|
|
78
|
+
return `(json_extract(${revisionAlias}.payload_json, '$.publication_guard') IS NULL OR (
|
|
79
|
+
json_extract(${revisionAlias}.payload_json, '$.publication_guard') = '${CURRENT_PUBLICATION_GUARD}'
|
|
80
|
+
AND content_items.status = 'scheduled' AND content_items.deleted_at IS NULL
|
|
81
|
+
AND ${ITEM_FIELDS.filter((field) => field !== 'deleted_at')
|
|
82
|
+
.map((field) => `content_items.${field} IS ${item(field)}`)
|
|
83
|
+
.join(' AND ')}
|
|
84
|
+
AND (${item('type')} <> 'podcast' OR ${matches('podcast_content', PODCAST_FIELDS)})
|
|
85
|
+
AND (${item('type')} <> 'video' OR ${matches('video_content', VIDEO_FIELDS)})
|
|
86
|
+
))`
|
|
87
|
+
}
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
serializeContentMetadata,
|
|
12
12
|
} from './content-metadata.js'
|
|
13
13
|
import type { D1Database, D1Result } from './d1.js'
|
|
14
|
+
import {
|
|
15
|
+
assertCurrentPublicationSnapshot,
|
|
16
|
+
CURRENT_PUBLICATION_GUARD,
|
|
17
|
+
currentPublicationSqlGuard,
|
|
18
|
+
} from './publication-current-guard.js'
|
|
14
19
|
import { evaluateArticleBody } from './publish-guard.js'
|
|
15
20
|
import {
|
|
16
21
|
type CreateContentInput,
|
|
@@ -81,6 +86,10 @@ export interface PublishOneInput {
|
|
|
81
86
|
requiredSurfaceNames?: string[]
|
|
82
87
|
packageVersion?: string | null
|
|
83
88
|
surfaceHookTimeoutMs?: number
|
|
89
|
+
/** Machine publication must still describe this scheduled editorial/media snapshot. */
|
|
90
|
+
expectedCurrentSnapshot?: Record<string, unknown>
|
|
91
|
+
/** Digest of the external receipt bound to a guarded machine publication. */
|
|
92
|
+
expectedReceiptIdentity?: string
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
export interface PublishOneReceipt {
|
|
@@ -763,6 +772,8 @@ async function createRevisionAndPublicationAttempt(
|
|
|
763
772
|
packageVersion: string | null
|
|
764
773
|
now: number
|
|
765
774
|
createdBy?: string | null
|
|
775
|
+
expectedCurrentSnapshot?: Record<string, unknown>
|
|
776
|
+
expectedReceiptIdentity?: string
|
|
766
777
|
},
|
|
767
778
|
): Promise<{
|
|
768
779
|
revisionId: string
|
|
@@ -771,6 +782,15 @@ async function createRevisionAndPublicationAttempt(
|
|
|
771
782
|
}> {
|
|
772
783
|
const snapshot = await getContentSnapshot(db, input.contentId)
|
|
773
784
|
if (!snapshot) throw new PublicationNotFoundError(`content not found: ${input.contentId}`)
|
|
785
|
+
if (input.expectedCurrentSnapshot) {
|
|
786
|
+
assertCurrentPublicationSnapshot(snapshot, input.expectedCurrentSnapshot)
|
|
787
|
+
snapshot.publication_guard = CURRENT_PUBLICATION_GUARD
|
|
788
|
+
if (input.expectedReceiptIdentity !== undefined) {
|
|
789
|
+
if (!/^[a-f0-9]{64}$/.test(input.expectedReceiptIdentity))
|
|
790
|
+
throw new Error('Invalid guarded publication receipt identity')
|
|
791
|
+
snapshot.publication_receipt_identity = input.expectedReceiptIdentity
|
|
792
|
+
}
|
|
793
|
+
}
|
|
774
794
|
assertPublicationSnapshotBodyValid(snapshot, input.contentId)
|
|
775
795
|
const revisionId = crypto.randomUUID()
|
|
776
796
|
const attemptId = crypto.randomUUID()
|
|
@@ -1597,6 +1617,7 @@ async function movePointerAndBeginCommit(
|
|
|
1597
1617
|
AND target_revision.content_id = ?
|
|
1598
1618
|
AND json_type(target_revision.payload_json, '$.item.slug') = 'text'
|
|
1599
1619
|
AND length(json_extract(target_revision.payload_json, '$.item.slug')) BETWEEN 1 AND 512
|
|
1620
|
+
${attempt.operation === 'publish' ? `AND ${currentPublicationSqlGuard('target_revision')}` : ''}
|
|
1600
1621
|
AND NOT EXISTS (
|
|
1601
1622
|
SELECT 1
|
|
1602
1623
|
FROM content_items other_item
|
|
@@ -1816,6 +1837,8 @@ export async function publishOne(
|
|
|
1816
1837
|
packageVersion: input.packageVersion ?? null,
|
|
1817
1838
|
now,
|
|
1818
1839
|
createdBy: input.createdBy,
|
|
1840
|
+
expectedCurrentSnapshot: input.expectedCurrentSnapshot,
|
|
1841
|
+
expectedReceiptIdentity: input.expectedReceiptIdentity,
|
|
1819
1842
|
})
|
|
1820
1843
|
const reconciliationInput: PublicationReconciliationInput = {
|
|
1821
1844
|
operation: 'publish',
|
package/src/engine/publisher.ts
CHANGED
|
@@ -882,7 +882,20 @@ export async function updateContentItem(
|
|
|
882
882
|
const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
|
|
883
883
|
? (podcastContent.audioR2Key ?? current.audio_r2_key)
|
|
884
884
|
: current.audio_r2_key
|
|
885
|
-
const
|
|
885
|
+
const audioChanged = audioR2Key !== current.audio_r2_key
|
|
886
|
+
const sourceUrl = hasOwn(podcastContent, 'processingSourceUrl')
|
|
887
|
+
? (podcastContent.processingSourceUrl ?? null)
|
|
888
|
+
: audioChanged
|
|
889
|
+
? null
|
|
890
|
+
: current.processing_source_url
|
|
891
|
+
const sourceKind = hasOwn(podcastContent, 'processingSourceKind')
|
|
892
|
+
? (podcastContent.processingSourceKind ?? null)
|
|
893
|
+
: audioChanged
|
|
894
|
+
? null
|
|
895
|
+
: current.processing_source_kind
|
|
896
|
+
const capturedSourceChanged =
|
|
897
|
+
sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
|
|
898
|
+
const sourceChanged = audioChanged || capturedSourceChanged
|
|
886
899
|
const durationProvided = hasOwn(podcastContent, 'durationSeconds')
|
|
887
900
|
const reusesSavedDuration =
|
|
888
901
|
!sourceChanged &&
|
|
@@ -892,7 +905,7 @@ export async function updateContentItem(
|
|
|
892
905
|
: await resolveMediaDuration(
|
|
893
906
|
db,
|
|
894
907
|
'podcast',
|
|
895
|
-
audioR2Key,
|
|
908
|
+
capturedSourceChanged ? sourceUrl : audioR2Key,
|
|
896
909
|
durationProvided ? podcastContent.durationSeconds : undefined,
|
|
897
910
|
)
|
|
898
911
|
const replacementTranscriptProvided =
|
|
@@ -906,18 +919,14 @@ export async function updateContentItem(
|
|
|
906
919
|
: replacementTranscriptProvided
|
|
907
920
|
? (podcastContent.transcript as string)
|
|
908
921
|
: current.transcript,
|
|
909
|
-
audioR2Key,
|
|
922
|
+
audioR2Key: capturedSourceChanged && !audioChanged ? '' : audioR2Key,
|
|
910
923
|
durationSeconds:
|
|
911
924
|
durationProvided || sourceChanged
|
|
912
925
|
? resolvedDuration
|
|
913
926
|
: (resolvedDuration ?? current.duration_seconds),
|
|
914
927
|
processingTriggerToken: sourceChanged ? null : current.processing_trigger_token,
|
|
915
|
-
sourceUrl
|
|
916
|
-
|
|
917
|
-
: current.processing_source_url,
|
|
918
|
-
sourceKind: hasOwn(podcastContent, 'processingSourceKind')
|
|
919
|
-
? (podcastContent.processingSourceKind ?? null)
|
|
920
|
-
: current.processing_source_kind,
|
|
928
|
+
sourceUrl,
|
|
929
|
+
sourceKind,
|
|
921
930
|
}
|
|
922
931
|
} else {
|
|
923
932
|
podcastUpdatePlan = null
|
|
@@ -1146,10 +1155,9 @@ export async function updateContentItem(
|
|
|
1146
1155
|
metadata_json = ?,
|
|
1147
1156
|
updated_at = unixepoch(),
|
|
1148
1157
|
-- The canonical revision counter behind the Fronts publish intent id
|
|
1149
|
-
-- masthead:<content_id>:<version> (migration 0027).
|
|
1150
|
-
--
|
|
1151
|
-
--
|
|
1152
|
-
-- write or a schedule poll.
|
|
1158
|
+
-- masthead:<content_id>:<version> (migration 0027). Canonical edits and
|
|
1159
|
+
-- actual delete/restore/archive transitions advance it; scheduling,
|
|
1160
|
+
-- fronts_publish_status marker writes and polls keep it stable.
|
|
1153
1161
|
canonical_version = canonical_version + 1
|
|
1154
1162
|
WHERE id = ?
|
|
1155
1163
|
`,
|
|
@@ -8,7 +8,7 @@ export async function softDeleteContent(
|
|
|
8
8
|
const ts = now ?? Math.floor(Date.now() / 1000)
|
|
9
9
|
const res = await db
|
|
10
10
|
.prepare(
|
|
11
|
-
'UPDATE content_items SET deleted_at = ?, updated_at = unixepoch() WHERE id = ? AND deleted_at IS NULL',
|
|
11
|
+
'UPDATE content_items SET deleted_at = ?, updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND deleted_at IS NULL',
|
|
12
12
|
)
|
|
13
13
|
.bind(ts, id)
|
|
14
14
|
.run()
|
|
@@ -18,7 +18,7 @@ export async function softDeleteContent(
|
|
|
18
18
|
export async function restoreContent(db: D1Database, id: string): Promise<boolean> {
|
|
19
19
|
const res = await db
|
|
20
20
|
.prepare(
|
|
21
|
-
'UPDATE content_items SET deleted_at = NULL, updated_at = unixepoch() WHERE id = ? AND deleted_at IS NOT NULL',
|
|
21
|
+
'UPDATE content_items SET deleted_at = NULL, updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND deleted_at IS NOT NULL',
|
|
22
22
|
)
|
|
23
23
|
.bind(id)
|
|
24
24
|
.run()
|
package/src/providers/types.ts
CHANGED
|
@@ -397,8 +397,13 @@ export interface FoundryJobSpec {
|
|
|
397
397
|
description?: string | null
|
|
398
398
|
processingTriggerTokenHash?: string
|
|
399
399
|
}
|
|
400
|
+
export interface FoundryDispatchResult {
|
|
401
|
+
correlationId: string
|
|
402
|
+
/** Return source evidence; the CMS owns its atomic capture with the token. */
|
|
403
|
+
podcastSource?: { url: string; kind: 'https' }
|
|
404
|
+
}
|
|
400
405
|
export interface FoundryDispatch {
|
|
401
|
-
dispatchVideo(jobSpec: FoundryJobSpec): Promise<
|
|
406
|
+
dispatchVideo(jobSpec: FoundryJobSpec): Promise<FoundryDispatchResult>
|
|
402
407
|
getJob(correlationId: string): Promise<{ state: string } | null>
|
|
403
408
|
}
|
|
404
409
|
|
package/src/routes/content.ts
CHANGED
|
@@ -197,6 +197,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
197
197
|
dispatchFaq?: DispatchFaqHook
|
|
198
198
|
/** Optional host-specific readiness guard. A not-ready result blocks publish. */
|
|
199
199
|
publishReadiness?: PublishReadinessHook
|
|
200
|
+
/** Host's existing production rollback control; omitted means enabled. */
|
|
201
|
+
podcastSourceProductionEnabled?: boolean
|
|
200
202
|
/** Internal machine-route constraint: atomically update only in this status. */
|
|
201
203
|
updateStatusConstraint?: ContentStatus
|
|
202
204
|
/**
|
|
@@ -1890,7 +1892,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1890
1892
|
if (action === 'archive') {
|
|
1891
1893
|
await ctx.db
|
|
1892
1894
|
.prepare(
|
|
1893
|
-
"UPDATE content_items SET status = 'archived', updated_at = unixepoch() WHERE id = ?",
|
|
1895
|
+
"UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
|
|
1894
1896
|
)
|
|
1895
1897
|
.bind(id)
|
|
1896
1898
|
.run()
|