@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.
- package/dist/engine/foundry-dispatch.d.ts +7 -2
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +24 -6
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +174 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
- package/dist/engine/fronts-publish-intent.js +352 -0
- package/dist/engine/fronts-publish-intent.js.map +1 -0
- package/dist/engine/fronts-publish.d.ts +80 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -0
- package/dist/engine/fronts-publish.js +286 -0
- package/dist/engine/fronts-publish.js.map +1 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +10 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-continuity.d.ts +77 -0
- package/dist/engine/publication-continuity.d.ts.map +1 -0
- package/dist/engine/publication-continuity.js +94 -0
- package/dist/engine/publication-continuity.js.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.js +100 -0
- package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
- package/dist/engine/publication-receipt/receipt.d.ts +316 -0
- package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
- package/dist/engine/publication-receipt/receipt.js +131 -0
- package/dist/engine/publication-receipt/receipt.js.map +1 -0
- package/dist/engine/publication-receipt/signature.d.ts +36 -0
- package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
- package/dist/engine/publication-receipt/signature.js +79 -0
- package/dist/engine/publication-receipt/signature.js.map +1 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +2 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +23 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/source-sections.d.ts +20 -0
- package/dist/engine/source-sections.d.ts.map +1 -0
- package/dist/engine/source-sections.js +130 -0
- package/dist/engine/source-sections.js.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
- package/dist/engine/validator/index.d.ts +10 -0
- package/dist/engine/validator/index.d.ts.map +1 -1
- package/dist/engine/validator/index.js +4 -0
- package/dist/engine/validator/index.js.map +1 -1
- package/dist/engine/validator/types.d.ts +1 -1
- package/dist/engine/validator/types.d.ts.map +1 -1
- package/dist/engine/validator/types.js +10 -0
- package/dist/engine/validator/types.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +10 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +22 -2
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +10 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -0
- package/dist/routes/fronts-publish.js +250 -0
- package/dist/routes/fronts-publish.js.map +1 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +13 -1
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +28 -28
- package/dist/schema/layout.d.ts +2 -2
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +36 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/portable-text.d.ts +2 -2
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +59 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/access.d.ts +2 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +7 -0
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/errors.d.ts +8 -0
- package/dist/surveys/errors.d.ts.map +1 -1
- package/dist/surveys/errors.js +12 -0
- package/dist/surveys/errors.js.map +1 -1
- package/dist/surveys/schema.d.ts +129 -54
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +45 -0
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
- package/dist/ui/api/fronts/publish-callback.js +8 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -0
- package/dist/ui/api/fronts/schedule.d.ts +3 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
- package/dist/ui/api/fronts/schedule.js +8 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -0
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +52 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/body-warnings.d.ts +27 -0
- package/dist/ui/editor/body-warnings.d.ts.map +1 -0
- package/dist/ui/editor/body-warnings.js +58 -0
- package/dist/ui/editor/body-warnings.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/migrations/0026_fronts_publish.sql +31 -0
- package/migrations/0027_fronts_publish_intent.sql +44 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +22 -6
- package/src/engine/fronts-publish-intent.ts +676 -0
- package/src/engine/fronts-publish.ts +417 -0
- package/src/engine/index.ts +60 -0
- package/src/engine/publication-continuity.ts +140 -0
- package/src/engine/publication-receipt/canonical-json.ts +123 -0
- package/src/engine/publication-receipt/receipt.ts +160 -0
- package/src/engine/publication-receipt/signature.ts +112 -0
- package/src/engine/publication.ts +2 -0
- package/src/engine/publisher.ts +23 -1
- package/src/engine/source-sections.ts +153 -0
- package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
- package/src/engine/validator/index.ts +12 -0
- package/src/engine/validator/types.ts +19 -0
- package/src/integration/index.ts +10 -0
- package/src/providers/types.ts +1 -1
- package/src/routes/content.ts +23 -2
- package/src/routes/fronts-publish.ts +292 -0
- package/src/routes/index.ts +8 -0
- package/src/routes/surveys.ts +12 -1
- package/src/schema/migrations.ts +36 -0
- package/src/schema/tables.ts +2 -0
- package/src/schema/types.ts +61 -0
- package/src/surveys/access.ts +9 -0
- package/src/surveys/errors.ts +12 -0
- package/src/surveys/schema.ts +47 -0
- package/src/ui/api/fronts/publish-callback.ts +18 -0
- package/src/ui/api/fronts/schedule.ts +18 -0
- package/src/ui/editor/ContentForm.tsx +93 -0
- package/src/ui/editor/body-warnings.ts +78 -0
- 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
|
+
}
|