@growth-labs/cms 0.5.29 → 0.5.31

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 (151) hide show
  1. package/dist/engine/foundry-dispatch.d.ts +7 -2
  2. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  3. package/dist/engine/foundry-dispatch.js +24 -6
  4. package/dist/engine/foundry-dispatch.js.map +1 -1
  5. package/dist/engine/fronts-publish-intent.d.ts +174 -0
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
  7. package/dist/engine/fronts-publish-intent.js +352 -0
  8. package/dist/engine/fronts-publish-intent.js.map +1 -0
  9. package/dist/engine/fronts-publish.d.ts +80 -0
  10. package/dist/engine/fronts-publish.d.ts.map +1 -0
  11. package/dist/engine/fronts-publish.js +286 -0
  12. package/dist/engine/fronts-publish.js.map +1 -0
  13. package/dist/engine/index.d.ts +7 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +10 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/publication-continuity.d.ts +77 -0
  18. package/dist/engine/publication-continuity.d.ts.map +1 -0
  19. package/dist/engine/publication-continuity.js +94 -0
  20. package/dist/engine/publication-continuity.js.map +1 -0
  21. package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
  22. package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
  23. package/dist/engine/publication-receipt/canonical-json.js +100 -0
  24. package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
  25. package/dist/engine/publication-receipt/receipt.d.ts +316 -0
  26. package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
  27. package/dist/engine/publication-receipt/receipt.js +131 -0
  28. package/dist/engine/publication-receipt/receipt.js.map +1 -0
  29. package/dist/engine/publication-receipt/signature.d.ts +36 -0
  30. package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
  31. package/dist/engine/publication-receipt/signature.js +79 -0
  32. package/dist/engine/publication-receipt/signature.js.map +1 -0
  33. package/dist/engine/publication.d.ts.map +1 -1
  34. package/dist/engine/publication.js +2 -0
  35. package/dist/engine/publication.js.map +1 -1
  36. package/dist/engine/publisher.d.ts.map +1 -1
  37. package/dist/engine/publisher.js +23 -1
  38. package/dist/engine/publisher.js.map +1 -1
  39. package/dist/engine/source-sections.d.ts +20 -0
  40. package/dist/engine/source-sections.d.ts.map +1 -0
  41. package/dist/engine/source-sections.js +130 -0
  42. package/dist/engine/source-sections.js.map +1 -0
  43. package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
  44. package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
  45. package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
  46. package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
  47. package/dist/engine/validator/index.d.ts +10 -0
  48. package/dist/engine/validator/index.d.ts.map +1 -1
  49. package/dist/engine/validator/index.js +4 -0
  50. package/dist/engine/validator/index.js.map +1 -1
  51. package/dist/engine/validator/types.d.ts +1 -1
  52. package/dist/engine/validator/types.d.ts.map +1 -1
  53. package/dist/engine/validator/types.js +10 -0
  54. package/dist/engine/validator/types.js.map +1 -1
  55. package/dist/integration/index.d.ts.map +1 -1
  56. package/dist/integration/index.js +10 -0
  57. package/dist/integration/index.js.map +1 -1
  58. package/dist/providers/types.d.ts +1 -0
  59. package/dist/providers/types.d.ts.map +1 -1
  60. package/dist/routes/content.d.ts.map +1 -1
  61. package/dist/routes/content.js +22 -2
  62. package/dist/routes/content.js.map +1 -1
  63. package/dist/routes/fronts-publish.d.ts +10 -0
  64. package/dist/routes/fronts-publish.d.ts.map +1 -0
  65. package/dist/routes/fronts-publish.js +250 -0
  66. package/dist/routes/fronts-publish.js.map +1 -0
  67. package/dist/routes/index.d.ts +3 -0
  68. package/dist/routes/index.d.ts.map +1 -1
  69. package/dist/routes/index.js +4 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/surveys.d.ts.map +1 -1
  72. package/dist/routes/surveys.js +13 -1
  73. package/dist/routes/surveys.js.map +1 -1
  74. package/dist/schema/insights-ingest.d.ts +28 -28
  75. package/dist/schema/layout.d.ts +2 -2
  76. package/dist/schema/migrations.d.ts.map +1 -1
  77. package/dist/schema/migrations.js +36 -0
  78. package/dist/schema/migrations.js.map +1 -1
  79. package/dist/schema/portable-text.d.ts +2 -2
  80. package/dist/schema/tables.d.ts +1 -1
  81. package/dist/schema/tables.d.ts.map +1 -1
  82. package/dist/schema/tables.js +2 -0
  83. package/dist/schema/tables.js.map +1 -1
  84. package/dist/schema/types.d.ts +59 -0
  85. package/dist/schema/types.d.ts.map +1 -1
  86. package/dist/schema/types.js.map +1 -1
  87. package/dist/surveys/access.d.ts +2 -0
  88. package/dist/surveys/access.d.ts.map +1 -1
  89. package/dist/surveys/access.js +7 -0
  90. package/dist/surveys/access.js.map +1 -1
  91. package/dist/surveys/errors.d.ts +8 -0
  92. package/dist/surveys/errors.d.ts.map +1 -1
  93. package/dist/surveys/errors.js +12 -0
  94. package/dist/surveys/errors.js.map +1 -1
  95. package/dist/surveys/schema.d.ts +129 -54
  96. package/dist/surveys/schema.d.ts.map +1 -1
  97. package/dist/surveys/schema.js +45 -0
  98. package/dist/surveys/schema.js.map +1 -1
  99. package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
  100. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
  101. package/dist/ui/api/fronts/publish-callback.js +8 -0
  102. package/dist/ui/api/fronts/publish-callback.js.map +1 -0
  103. package/dist/ui/api/fronts/schedule.d.ts +3 -0
  104. package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
  105. package/dist/ui/api/fronts/schedule.js +8 -0
  106. package/dist/ui/api/fronts/schedule.js.map +1 -0
  107. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  108. package/dist/ui/editor/ContentForm.js +52 -1
  109. package/dist/ui/editor/ContentForm.js.map +1 -1
  110. package/dist/ui/editor/body-warnings.d.ts +27 -0
  111. package/dist/ui/editor/body-warnings.d.ts.map +1 -0
  112. package/dist/ui/editor/body-warnings.js +58 -0
  113. package/dist/ui/editor/body-warnings.js.map +1 -0
  114. package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
  115. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  116. package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
  117. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  118. package/migrations/0026_fronts_publish.sql +31 -0
  119. package/migrations/0027_fronts_publish_intent.sql +44 -0
  120. package/package.json +1 -1
  121. package/src/engine/foundry-dispatch.ts +22 -6
  122. package/src/engine/fronts-publish-intent.ts +676 -0
  123. package/src/engine/fronts-publish.ts +417 -0
  124. package/src/engine/index.ts +60 -0
  125. package/src/engine/publication-continuity.ts +140 -0
  126. package/src/engine/publication-receipt/canonical-json.ts +123 -0
  127. package/src/engine/publication-receipt/receipt.ts +160 -0
  128. package/src/engine/publication-receipt/signature.ts +112 -0
  129. package/src/engine/publication.ts +2 -0
  130. package/src/engine/publisher.ts +23 -1
  131. package/src/engine/source-sections.ts +153 -0
  132. package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
  133. package/src/engine/validator/index.ts +12 -0
  134. package/src/engine/validator/types.ts +19 -0
  135. package/src/integration/index.ts +10 -0
  136. package/src/providers/types.ts +1 -1
  137. package/src/routes/content.ts +23 -2
  138. package/src/routes/fronts-publish.ts +292 -0
  139. package/src/routes/index.ts +8 -0
  140. package/src/routes/surveys.ts +12 -1
  141. package/src/schema/migrations.ts +36 -0
  142. package/src/schema/tables.ts +2 -0
  143. package/src/schema/types.ts +61 -0
  144. package/src/surveys/access.ts +9 -0
  145. package/src/surveys/errors.ts +12 -0
  146. package/src/surveys/schema.ts +47 -0
  147. package/src/ui/api/fronts/publish-callback.ts +18 -0
  148. package/src/ui/api/fronts/schedule.ts +18 -0
  149. package/src/ui/editor/ContentForm.tsx +93 -0
  150. package/src/ui/editor/body-warnings.ts +78 -0
  151. package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
@@ -0,0 +1,676 @@
1
+ // Canonical Fronts publish intent — the CMS-owned hydration contract a pull
2
+ // consumer needs to build a COMPLETE publish intent from a due or overdue
3
+ // schedule item (packages#500, under the GO-PLANE PULL-PRIMARY spec).
4
+ //
5
+ // The 0026 schedule poll (engine/fronts-publish.ts `listFrontsPublishSchedule`)
6
+ // returns presentation columns only — id, type, slug, title, channel,
7
+ // publish_at, fronts_publish_status. A consumer polling that surface could not
8
+ // build `intent_id = masthead:<content_id>:<version>`, could not know WHERE to
9
+ // publish, and could not resolve the media to attach; the only ways forward were
10
+ // to invent values or take them from an untrusted payload, both forbidden. This
11
+ // module supplies those values from authoritative CMS storage and FAILS LOUDLY
12
+ // when a required one does not exist.
13
+ //
14
+ // The full field/source table, the version semantics, the per-media-type absence
15
+ // rules and the auth byte contract are documented in `packages-docs/cms.md`
16
+ // ("Fronts publish path — canonical intent contract"). The load-bearing points
17
+ // for a reader of this file:
18
+ //
19
+ // * ALL times are unix epoch SECONDS — the same column and units the legacy
20
+ // cron predicate uses, so there is no D1 `datetime('now')` TEXT comparison
21
+ // on `publish_at` and no space-vs-`T` skew trap.
22
+ // * `version` is `content_items.canonical_version`, advanced by exactly one
23
+ // seam (updateContentItem) — NOT by a fronts_publish_status marker write and
24
+ // NOT by polling, so repeated reads are identity-stable while a real
25
+ // canonical revision does change identity.
26
+ // * `trigger_token_hash` is `sha256:<hex>` of
27
+ // `content_items.fronts_publish_trigger_token`. The raw token is secret at
28
+ // rest exactly like `video_content.processing_trigger_token` (migration
29
+ // 0006) and NEVER leaves this package.
30
+ // * Absence is explicit per media type: an article needs a body, a video needs
31
+ // a READY HLS manifest, a podcast needs an audio object; `images` and
32
+ // `links` are always optionally empty. Anything required and missing is a
33
+ // `FrontsPublishIntentError`, never an empty default.
34
+ // * KNOWN GAP, reported not papered over: the wire field is
35
+ // `podcast_audio_hls`, but the CMS stores only
36
+ // `podcast_content.audio_r2_key`, a progressive-download R2 object and NOT a
37
+ // manifest. The ref carries an explicit `kind` discriminator that today
38
+ // always reads `'r2-object'`, so a consumer can never mistake one for the
39
+ // other. Real audio HLS is a separate media-pipeline change.
40
+ import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
41
+ import type { D1Database } from './d1.js'
42
+
43
+ /** The CMS's own name for the record this intent describes. */
44
+ export const FRONTS_PUBLISH_INTENT_SOURCE = 'masthead'
45
+
46
+ /**
47
+ * `masthead:<content_id>:<version>`. Stable for as long as the canonical
48
+ * content is, and different the moment `canonical_version` advances, so a
49
+ * meaningful revision can never silently reuse a conflicting identity.
50
+ */
51
+ export function frontsPublishIntentId(contentId: string, version: number): string {
52
+ return `${FRONTS_PUBLISH_INTENT_SOURCE}:${contentId}:${version}`
53
+ }
54
+
55
+ /** Every way a due item can fail to form a complete, honest intent. */
56
+ export type FrontsPublishIntentErrorCode =
57
+ | 'missing_publish_at'
58
+ | 'invalid_canonical_version'
59
+ | 'missing_channel_targets'
60
+ | 'invalid_channel_targets'
61
+ | 'missing_trigger_token'
62
+ | 'missing_article_body'
63
+ | 'video_media_not_ready'
64
+ | 'missing_podcast_audio'
65
+
66
+ /**
67
+ * A due item whose canonical data cannot produce a complete intent. Thrown by
68
+ * the single-item hydration; collected (never swallowed) by the batch one.
69
+ */
70
+ export class FrontsPublishIntentError extends Error {
71
+ readonly code: FrontsPublishIntentErrorCode
72
+ readonly contentId: string
73
+ constructor(code: FrontsPublishIntentErrorCode, contentId: string, detail?: string) {
74
+ super(`fronts publish intent ${code} for content ${contentId}${detail ? `: ${detail}` : ''}`)
75
+ this.name = 'FrontsPublishIntentError'
76
+ this.code = code
77
+ this.contentId = contentId
78
+ }
79
+ }
80
+
81
+ // --- wire shape ------------------------------------------------------------
82
+
83
+ export interface FrontsPublishArticleRef {
84
+ /** Absolute when a site base/canonical URL is known, else a site-relative path. */
85
+ url: string
86
+ slug: string
87
+ word_count: number | null
88
+ }
89
+
90
+ export interface FrontsPublishImageRef {
91
+ id: string
92
+ role: 'hero' | 'social' | 'thumbnail'
93
+ url: string
94
+ width: number | null
95
+ height: number | null
96
+ }
97
+
98
+ export interface FrontsPublishVideoHlsRef {
99
+ kind: 'hls-manifest'
100
+ url: string
101
+ poster_url: string | null
102
+ duration_seconds: number | null
103
+ }
104
+
105
+ export interface FrontsPublishPodcastAudioRef {
106
+ /**
107
+ * `'r2-object'` — a progressive-download R2 audio object
108
+ * (podcast_content.audio_r2_key), which is what the CMS actually stores.
109
+ * `'hls-manifest'` is reserved for when an audio HLS pipeline exists. Always
110
+ * check this before treating `url` as a manifest.
111
+ */
112
+ kind: 'r2-object' | 'hls-manifest'
113
+ url: string
114
+ duration_seconds: number | null
115
+ }
116
+
117
+ export interface FrontsPublishLinkRef {
118
+ rel: 'canonical' | 'public'
119
+ url: string
120
+ }
121
+
122
+ export interface FrontsPublishContentRefs {
123
+ article: FrontsPublishArticleRef | null
124
+ /** `[]` is valid — the item simply has no hero/social/thumbnail asset. */
125
+ images: FrontsPublishImageRef[]
126
+ video_hls: FrontsPublishVideoHlsRef | null
127
+ podcast_audio_hls: FrontsPublishPodcastAudioRef | null
128
+ /** `[]` is valid. */
129
+ links: FrontsPublishLinkRef[]
130
+ }
131
+
132
+ /**
133
+ * One due/overdue item's complete canonical publish intent. Contains NO secret:
134
+ * `trigger_token_hash` is a digest and the raw token never leaves D1.
135
+ */
136
+ export interface FrontsPublishIntent {
137
+ intent_id: string
138
+ content_id: string
139
+ version: number
140
+ type: ContentType
141
+ slug: string
142
+ title: string
143
+ /** The single editorial Fronts channel slug — NOT a publish target. */
144
+ channel: string | null
145
+ channel_targets: string[]
146
+ /** Unix epoch SECONDS. Equals content_items.publish_at. */
147
+ deadline: number
148
+ /**
149
+ * Unix epoch SECONDS. Equals `deadline` in this increment: the CMS declares no
150
+ * pre-roll lead of its own rather than inventing one. A host-configurable lead
151
+ * is deliberately deferred — see packages-docs/cms.md.
152
+ */
153
+ trigger_at: number
154
+ content_refs: FrontsPublishContentRefs
155
+ /** `sha256:<64 lowercase hex>`. Never the raw token. */
156
+ trigger_token_hash: string
157
+ /** `deadline <= now` — the consumer is late on this one. */
158
+ overdue: boolean
159
+ fronts_publish_status: FrontsPublishStatus | null
160
+ }
161
+
162
+ // --- trigger token ---------------------------------------------------------
163
+
164
+ /** SHA-256 hex digest via Web Crypto (never node:crypto). */
165
+ async function sha256Hex(input: string): Promise<string> {
166
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
167
+ return Array.from(new Uint8Array(digest))
168
+ .map((b) => b.toString(16).padStart(2, '0'))
169
+ .join('')
170
+ }
171
+
172
+ /** The `sha256:` prefix every exposed CMS trigger-token digest carries. */
173
+ export const FRONTS_PUBLISH_TRIGGER_TOKEN_HASH_PREFIX = 'sha256:'
174
+
175
+ /**
176
+ * The exposed form of a trigger token: `sha256:<hex>`. Same convention
177
+ * `engine/foundry-dispatch.ts` already uses for `processingTriggerTokenHash`.
178
+ */
179
+ export async function frontsPublishTriggerTokenHash(rawToken: string): Promise<string> {
180
+ return `${FRONTS_PUBLISH_TRIGGER_TOKEN_HASH_PREFIX}${await sha256Hex(rawToken)}`
181
+ }
182
+
183
+ /**
184
+ * Mint this item's raw publish trigger token if — and only if — it has none.
185
+ *
186
+ * Idempotent by compare-and-set, so concurrent callers cannot produce two tokens
187
+ * and a re-arm never rotates a token out from under an in-flight intent: an
188
+ * armed item keeps one stable `trigger_token_hash` across every later read.
189
+ * Called from `scheduleContent`, the single seam an item passes through to
190
+ * become `scheduled` with a `publish_at`. The raw value is never returned.
191
+ */
192
+ export async function armFrontsPublishTriggerToken(
193
+ db: D1Database,
194
+ contentId: string,
195
+ ): Promise<{ minted: boolean }> {
196
+ const result = await db
197
+ .prepare(
198
+ `UPDATE content_items
199
+ SET fronts_publish_trigger_token = ?
200
+ WHERE id = ? AND fronts_publish_trigger_token IS NULL`,
201
+ )
202
+ .bind(crypto.randomUUID(), contentId)
203
+ .run()
204
+ // `meta` is a structural Record<string, unknown> on the package's minimal D1
205
+ // surface, so narrow before comparing (same idiom as fronts-publish.ts).
206
+ const changes = result.meta?.changes
207
+ return { minted: typeof changes === 'number' && changes > 0 }
208
+ }
209
+
210
+ // --- channel targets -------------------------------------------------------
211
+
212
+ /**
213
+ * Parse `content_items.fronts_channel_targets`. Returns the target list, or a
214
+ * loud error code — NEVER a silent `[]`, which would read as a complete intent
215
+ * with nothing to publish to.
216
+ */
217
+ type ChannelTargetsFailure = { code: 'missing_channel_targets' | 'invalid_channel_targets' }
218
+
219
+ /** Single source of truth for what a publishable target list IS — shared by the
220
+ * stored-value parser and the setter so the two can never diverge. */
221
+ function normalizeChannelTargets(
222
+ entries: readonly unknown[],
223
+ ): { targets: string[] } | ChannelTargetsFailure {
224
+ const targets: string[] = []
225
+ for (const entry of entries) {
226
+ if (typeof entry !== 'string' || entry.trim() === '') {
227
+ return { code: 'invalid_channel_targets' }
228
+ }
229
+ const target = entry.trim()
230
+ if (!targets.includes(target)) targets.push(target)
231
+ }
232
+ if (targets.length === 0) return { code: 'missing_channel_targets' }
233
+ return { targets }
234
+ }
235
+
236
+ export function parseFrontsChannelTargets(
237
+ raw: string | null | undefined,
238
+ ): { targets: string[] } | ChannelTargetsFailure {
239
+ if (raw === null || raw === undefined || raw.trim() === '') {
240
+ return { code: 'missing_channel_targets' }
241
+ }
242
+ let parsed: unknown
243
+ try {
244
+ parsed = JSON.parse(raw)
245
+ } catch {
246
+ return { code: 'invalid_channel_targets' }
247
+ }
248
+ if (!Array.isArray(parsed)) return { code: 'invalid_channel_targets' }
249
+ return normalizeChannelTargets(parsed)
250
+ }
251
+
252
+ /**
253
+ * Arm an item's publish targets. Rejects an empty or non-string list loudly
254
+ * instead of storing something that would later hydrate as "complete".
255
+ */
256
+ export async function setFrontsChannelTargets(
257
+ db: D1Database,
258
+ contentId: string,
259
+ targets: readonly string[],
260
+ ): Promise<void> {
261
+ const normalized = normalizeChannelTargets(targets)
262
+ if ('code' in normalized) {
263
+ throw new FrontsPublishIntentError(
264
+ normalized.code,
265
+ contentId,
266
+ 'targets must be a non-empty list of non-empty strings',
267
+ )
268
+ }
269
+ await db
270
+ .prepare('UPDATE content_items SET fronts_channel_targets = ? WHERE id = ?')
271
+ .bind(JSON.stringify(normalized.targets), contentId)
272
+ .run()
273
+ }
274
+
275
+ // --- hydration -------------------------------------------------------------
276
+
277
+ /** The candidate columns read from `content_items` for one due item. */
278
+ interface IntentCandidateRow {
279
+ id: string
280
+ type: ContentType
281
+ slug: string
282
+ title: string
283
+ channel: string | null
284
+ publish_at: number | null
285
+ fronts_publish_status: FrontsPublishStatus | null
286
+ canonical_version: number | null
287
+ fronts_channel_targets: string | null
288
+ fronts_publish_trigger_token: string | null
289
+ canonical_url: string | null
290
+ hero_image_id: string | null
291
+ social_image_id: string | null
292
+ }
293
+
294
+ const CANDIDATE_COLUMNS = `id, type, slug, title, channel, publish_at, fronts_publish_status,
295
+ canonical_version, fronts_channel_targets, fronts_publish_trigger_token,
296
+ canonical_url, hero_image_id, social_image_id`
297
+
298
+ export interface HydrateFrontsPublishIntentsOptions {
299
+ /** Window upper bound, unix epoch seconds. Items due at/before this are considered. */
300
+ toSeconds: number
301
+ /** Reference time for `overdue`, unix epoch seconds. Defaults to Date.now(). */
302
+ nowSeconds?: number
303
+ /** Hard cap on candidates. Default 200, max 1000 — mirrors the schedule poll. */
304
+ limit?: number
305
+ /**
306
+ * Resolve an item's public URL (absolute when the host knows its origin, else
307
+ * a site-relative path). Injected rather than imported so this engine module
308
+ * does not depend on the UI layer.
309
+ */
310
+ resolveContentUrl: (row: { type: ContentType; slug: string }) => string
311
+ /** Public media host used for media rows with no stored URL. Default 'media.fronts.co'. */
312
+ mediaPublicDomain?: string
313
+ }
314
+
315
+ /** One due item that could NOT form a complete intent, with the exact reason. */
316
+ export interface FrontsPublishIntentFailure {
317
+ content_id: string
318
+ code: FrontsPublishIntentErrorCode
319
+ message: string
320
+ }
321
+
322
+ export interface HydrateFrontsPublishIntentsResult {
323
+ intents: FrontsPublishIntent[]
324
+ /**
325
+ * Due items that are NOT publishable — never empty-defaulted into `intents`
326
+ * and never silently dropped. One bad item does not suppress the others, so a
327
+ * single broken record cannot block the whole publish lane.
328
+ */
329
+ incomplete: FrontsPublishIntentFailure[]
330
+ }
331
+
332
+ /** `?, ?, ?` for an IN clause of `count` bound values. */
333
+ function placeholders(count: number): string {
334
+ return new Array(count).fill('?').join(', ')
335
+ }
336
+
337
+ interface ArticleRefRow {
338
+ content_id: string
339
+ body_markdown: string | null
340
+ word_count: number | null
341
+ }
342
+
343
+ interface VideoRefRow {
344
+ content_id: string
345
+ hls_ready: number
346
+ hls_manifest_url: string | null
347
+ hls_poster_url: string | null
348
+ duration_seconds: number | null
349
+ thumbnail_image_id: string | null
350
+ }
351
+
352
+ interface PodcastRefRow {
353
+ content_id: string
354
+ audio_r2_key: string | null
355
+ duration_seconds: number | null
356
+ }
357
+
358
+ interface MediaRow {
359
+ id: string
360
+ object_key: string | null
361
+ public_url: string | null
362
+ url: string | null
363
+ width: number | null
364
+ height: number | null
365
+ }
366
+
367
+ /** An R2 object key (or already-absolute URL) against the public media host. */
368
+ function objectKeyUrl(key: string, mediaPublicDomain: string): string {
369
+ if (/^https?:\/\//i.test(key)) return key
370
+ return `https://${mediaPublicDomain}/${key.replace(/^\/+/, '')}`
371
+ }
372
+
373
+ /** Resolve a media row to its public URL, or null when it has no usable form. */
374
+ function mediaUrl(row: MediaRow, mediaPublicDomain: string): string | null {
375
+ const stored = row.public_url?.trim() || row.url?.trim()
376
+ if (stored) return stored
377
+ const key = row.object_key?.trim()
378
+ return key ? objectKeyUrl(key, mediaPublicDomain) : null
379
+ }
380
+
381
+ /**
382
+ * Hydrate every due/overdue `scheduled` item into a complete canonical intent.
383
+ *
384
+ * Uses the SAME due predicate as `listFrontsPublishSchedule` — overdue items
385
+ * included (the window's lower bound is deliberately not applied), future items
386
+ * excluded — so existing window behaviour is preserved exactly. READ ONLY: it
387
+ * never mutates content, advances `canonical_version`, or mints a token, so
388
+ * polling cannot churn an intent identity.
389
+ */
390
+ export async function hydrateFrontsPublishIntents(
391
+ db: D1Database,
392
+ options: HydrateFrontsPublishIntentsOptions,
393
+ ): Promise<HydrateFrontsPublishIntentsResult> {
394
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1_000)
395
+ const limit = Math.min(Math.max(1, Math.floor(options.limit ?? 200)), 1000)
396
+ const mediaPublicDomain = options.mediaPublicDomain ?? 'media.fronts.co'
397
+
398
+ const candidates = await db
399
+ .prepare(
400
+ `SELECT ${CANDIDATE_COLUMNS}
401
+ FROM content_items
402
+ WHERE status = 'scheduled'
403
+ AND publish_at IS NOT NULL
404
+ AND publish_at <= ?
405
+ ORDER BY publish_at ASC
406
+ LIMIT ?`,
407
+ )
408
+ .bind(options.toSeconds, limit)
409
+ .all<IntentCandidateRow>()
410
+
411
+ const rows = candidates.results ?? []
412
+ if (rows.length === 0) return { intents: [], incomplete: [] }
413
+
414
+ const refs = await loadContentRefSources(db, rows)
415
+ const intents: FrontsPublishIntent[] = []
416
+ const incomplete: FrontsPublishIntentFailure[] = []
417
+
418
+ for (const row of rows) {
419
+ try {
420
+ intents.push(
421
+ await buildIntent(row, refs, {
422
+ now,
423
+ mediaPublicDomain,
424
+ resolve: options.resolveContentUrl,
425
+ }),
426
+ )
427
+ } catch (error) {
428
+ if (error instanceof FrontsPublishIntentError) {
429
+ // LOUD: an operator can see exactly which due item is unpublishable and why.
430
+ console.error(`[cms] fronts publish intent incomplete: ${error.message}`)
431
+ incomplete.push({ content_id: error.contentId, code: error.code, message: error.message })
432
+ continue
433
+ }
434
+ throw error
435
+ }
436
+ }
437
+
438
+ return { intents, incomplete }
439
+ }
440
+
441
+ /** Batched per-type + media lookups — a fixed query count, not one per item. */
442
+ async function loadContentRefSources(
443
+ db: D1Database,
444
+ rows: readonly IntentCandidateRow[],
445
+ ): Promise<{
446
+ articles: Map<string, ArticleRefRow>
447
+ videos: Map<string, VideoRefRow>
448
+ podcasts: Map<string, PodcastRefRow>
449
+ media: Map<string, MediaRow>
450
+ }> {
451
+ const bodyBackedIds = rows
452
+ .filter((r) => r.type === 'article' || r.type === 'newsletter' || r.type === 'page')
453
+ .map((r) => r.id)
454
+ const videoIds = rows.filter((r) => r.type === 'video').map((r) => r.id)
455
+ const podcastIds = rows.filter((r) => r.type === 'podcast').map((r) => r.id)
456
+
457
+ const articles = new Map<string, ArticleRefRow>()
458
+ if (bodyBackedIds.length > 0) {
459
+ const res = await db
460
+ .prepare(
461
+ `SELECT content_id, body_markdown, word_count FROM article_content
462
+ WHERE content_id IN (${placeholders(bodyBackedIds.length)})`,
463
+ )
464
+ .bind(...bodyBackedIds)
465
+ .all<ArticleRefRow>()
466
+ for (const r of res.results ?? []) articles.set(r.content_id, r)
467
+ }
468
+
469
+ const videos = new Map<string, VideoRefRow>()
470
+ if (videoIds.length > 0) {
471
+ const res = await db
472
+ .prepare(
473
+ `SELECT content_id, hls_ready, hls_manifest_url, hls_poster_url, duration_seconds,
474
+ thumbnail_image_id
475
+ FROM video_content WHERE content_id IN (${placeholders(videoIds.length)})`,
476
+ )
477
+ .bind(...videoIds)
478
+ .all<VideoRefRow>()
479
+ for (const r of res.results ?? []) videos.set(r.content_id, r)
480
+ }
481
+
482
+ const podcasts = new Map<string, PodcastRefRow>()
483
+ if (podcastIds.length > 0) {
484
+ const res = await db
485
+ .prepare(
486
+ `SELECT content_id, audio_r2_key, duration_seconds FROM podcast_content
487
+ WHERE content_id IN (${placeholders(podcastIds.length)})`,
488
+ )
489
+ .bind(...podcastIds)
490
+ .all<PodcastRefRow>()
491
+ for (const r of res.results ?? []) podcasts.set(r.content_id, r)
492
+ }
493
+
494
+ const mediaIds: string[] = []
495
+ const pushMediaId = (id: string | null | undefined) => {
496
+ const trimmed = id?.trim()
497
+ if (trimmed && !mediaIds.includes(trimmed)) mediaIds.push(trimmed)
498
+ }
499
+ for (const row of rows) {
500
+ pushMediaId(row.hero_image_id)
501
+ pushMediaId(row.social_image_id)
502
+ }
503
+ for (const video of videos.values()) pushMediaId(video.thumbnail_image_id)
504
+
505
+ const media = new Map<string, MediaRow>()
506
+ if (mediaIds.length > 0) {
507
+ const res = await db
508
+ .prepare(
509
+ `SELECT id, object_key, public_url, url, width, height FROM media_assets
510
+ WHERE id IN (${placeholders(mediaIds.length)})`,
511
+ )
512
+ .bind(...mediaIds)
513
+ .all<MediaRow>()
514
+ for (const r of res.results ?? []) media.set(r.id, r)
515
+ }
516
+
517
+ return { articles, videos, podcasts, media }
518
+ }
519
+
520
+ /** Build one intent, throwing FrontsPublishIntentError on any missing required value. */
521
+ async function buildIntent(
522
+ row: IntentCandidateRow,
523
+ refs: Awaited<ReturnType<typeof loadContentRefSources>>,
524
+ ctx: {
525
+ now: number
526
+ mediaPublicDomain: string
527
+ resolve: (r: { type: ContentType; slug: string }) => string
528
+ },
529
+ ): Promise<FrontsPublishIntent> {
530
+ if (row.publish_at === null || !Number.isFinite(row.publish_at)) {
531
+ throw new FrontsPublishIntentError('missing_publish_at', row.id)
532
+ }
533
+ const version = row.canonical_version
534
+ if (version === null || !Number.isInteger(version) || version < 1) {
535
+ throw new FrontsPublishIntentError(
536
+ 'invalid_canonical_version',
537
+ row.id,
538
+ `canonical_version must be a positive integer, got ${String(version)}`,
539
+ )
540
+ }
541
+
542
+ const targets = parseFrontsChannelTargets(row.fronts_channel_targets)
543
+ if ('code' in targets) {
544
+ throw new FrontsPublishIntentError(
545
+ targets.code,
546
+ row.id,
547
+ 'content_items.fronts_channel_targets must be a JSON array of target slugs',
548
+ )
549
+ }
550
+
551
+ const rawToken = row.fronts_publish_trigger_token?.trim()
552
+ if (!rawToken) {
553
+ throw new FrontsPublishIntentError(
554
+ 'missing_trigger_token',
555
+ row.id,
556
+ 'item was never armed for the Fronts publish path (no fronts_publish_trigger_token)',
557
+ )
558
+ }
559
+
560
+ const deadline = Math.floor(row.publish_at)
561
+ return {
562
+ intent_id: frontsPublishIntentId(row.id, version),
563
+ content_id: row.id,
564
+ version,
565
+ type: row.type,
566
+ slug: row.slug,
567
+ title: row.title,
568
+ channel: row.channel,
569
+ channel_targets: targets.targets,
570
+ deadline,
571
+ trigger_at: deadline,
572
+ content_refs: buildContentRefs(row, refs, ctx),
573
+ trigger_token_hash: await frontsPublishTriggerTokenHash(rawToken),
574
+ overdue: deadline <= ctx.now,
575
+ fronts_publish_status: row.fronts_publish_status,
576
+ }
577
+ }
578
+
579
+ /** Per-type content refs, applying the absence rules in the module header. */
580
+ function buildContentRefs(
581
+ row: IntentCandidateRow,
582
+ refs: Awaited<ReturnType<typeof loadContentRefSources>>,
583
+ ctx: {
584
+ mediaPublicDomain: string
585
+ resolve: (r: { type: ContentType; slug: string }) => string
586
+ },
587
+ ): FrontsPublishContentRefs {
588
+ const publicUrl = ctx.resolve({ type: row.type, slug: row.slug })
589
+ const canonicalUrl = row.canonical_url?.trim() || null
590
+
591
+ // --- article: required for body-backed types, validly absent otherwise.
592
+ let article: FrontsPublishArticleRef | null = null
593
+ const bodyBacked = row.type === 'article' || row.type === 'newsletter' || row.type === 'page'
594
+ if (bodyBacked) {
595
+ const found = refs.articles.get(row.id)
596
+ if (!found?.body_markdown || found.body_markdown.trim() === '') {
597
+ throw new FrontsPublishIntentError(
598
+ 'missing_article_body',
599
+ row.id,
600
+ `a ${row.type} intent requires a non-empty article_content.body_markdown`,
601
+ )
602
+ }
603
+ article = {
604
+ url: canonicalUrl ?? publicUrl,
605
+ slug: row.slug,
606
+ word_count: found.word_count ?? null,
607
+ }
608
+ }
609
+
610
+ // --- video_hls: required for video, and only when genuinely ready.
611
+ let videoHls: FrontsPublishVideoHlsRef | null = null
612
+ if (row.type === 'video') {
613
+ const found = refs.videos.get(row.id)
614
+ const manifest = found?.hls_manifest_url?.trim()
615
+ if (!found || found.hls_ready !== 1 || !manifest) {
616
+ throw new FrontsPublishIntentError(
617
+ 'video_media_not_ready',
618
+ row.id,
619
+ 'a video intent requires video_content.hls_ready = 1 and a non-empty hls_manifest_url',
620
+ )
621
+ }
622
+ videoHls = {
623
+ kind: 'hls-manifest',
624
+ url: manifest,
625
+ poster_url: found.hls_poster_url?.trim() || null,
626
+ duration_seconds: found.duration_seconds ?? null,
627
+ }
628
+ }
629
+
630
+ // --- podcast_audio_hls: required for podcast. See the module header for why
631
+ // `kind` is 'r2-object' today rather than a manifest.
632
+ let podcastAudio: FrontsPublishPodcastAudioRef | null = null
633
+ if (row.type === 'podcast') {
634
+ const found = refs.podcasts.get(row.id)
635
+ const key = found?.audio_r2_key?.trim()
636
+ if (!found || !key) {
637
+ throw new FrontsPublishIntentError(
638
+ 'missing_podcast_audio',
639
+ row.id,
640
+ 'a podcast intent requires a non-empty podcast_content.audio_r2_key',
641
+ )
642
+ }
643
+ podcastAudio = {
644
+ kind: 'r2-object',
645
+ url: objectKeyUrl(key, ctx.mediaPublicDomain),
646
+ duration_seconds: found.duration_seconds ?? null,
647
+ }
648
+ }
649
+
650
+ // --- images: always optional. [] means the item has no assets.
651
+ const images: FrontsPublishImageRef[] = []
652
+ const pushImage = (id: string | null | undefined, role: FrontsPublishImageRef['role']) => {
653
+ const assetId = id?.trim()
654
+ if (!assetId) return
655
+ const asset = refs.media.get(assetId)
656
+ if (!asset) return
657
+ const url = mediaUrl(asset, ctx.mediaPublicDomain)
658
+ if (!url) return
659
+ images.push({
660
+ id: assetId,
661
+ role,
662
+ url,
663
+ width: asset.width ?? null,
664
+ height: asset.height ?? null,
665
+ })
666
+ }
667
+ pushImage(row.hero_image_id, 'hero')
668
+ pushImage(row.social_image_id, 'social')
669
+ pushImage(refs.videos.get(row.id)?.thumbnail_image_id, 'thumbnail')
670
+
671
+ // --- links: always optional.
672
+ const links: FrontsPublishLinkRef[] = [{ rel: 'public', url: publicUrl }]
673
+ if (canonicalUrl) links.push({ rel: 'canonical', url: canonicalUrl })
674
+
675
+ return { article, images, video_hls: videoHls, podcast_audio_hls: podcastAudio, links }
676
+ }