@growth-labs/cms 0.6.0 → 0.6.2
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/fronts-publish-intent.d.ts +10 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +12 -2
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/publisher.d.ts +11 -4
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +92 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/routes/content.d.ts +3 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +9 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +33 -6
- package/dist/routes/foundry-publish.js.map +1 -1
- package/docs/foundry-publish.md +17 -0
- package/package.json +1 -1
- package/src/engine/fronts-publish-intent.ts +22 -2
- package/src/engine/publisher.ts +166 -2
- package/src/routes/content.ts +11 -1
- package/src/routes/foundry-publish.ts +41 -7
package/src/engine/publisher.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// timestamps; the video copy hard-codes `processing_state = 'ready'` so a
|
|
20
20
|
// duplicate is NOT re-queued to Foundry. Any future `content_items` column
|
|
21
21
|
// addition requires a matching edit here or the duplicate silently drops it.
|
|
22
|
-
import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
|
|
22
|
+
import type { ContentStatus, ContentType, FrontsPublishStatus } from '../schema/types.js'
|
|
23
23
|
import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
|
|
24
24
|
import type { D1Database } from './d1.js'
|
|
25
25
|
import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
|
|
@@ -340,6 +340,17 @@ export interface UpdateContentInput extends Partial<BaseContentInput> {
|
|
|
340
340
|
aiLockedFields?: string[]
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
export interface UpdateContentOptions {
|
|
344
|
+
/** Atomically apply the update only while the content row has this status. */
|
|
345
|
+
requiredStatus?: ContentStatus
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface UpdateContentResult {
|
|
349
|
+
slug: string
|
|
350
|
+
/** Present when requiredStatus lost a race before the mutation transaction. */
|
|
351
|
+
statusConflict?: string
|
|
352
|
+
}
|
|
353
|
+
|
|
343
354
|
interface VideoUpdatePlan {
|
|
344
355
|
script: string
|
|
345
356
|
videoId: string
|
|
@@ -762,7 +773,8 @@ export async function updateContentItem(
|
|
|
762
773
|
db: D1Database,
|
|
763
774
|
id: string,
|
|
764
775
|
input: UpdateContentInput,
|
|
765
|
-
|
|
776
|
+
options: UpdateContentOptions = {},
|
|
777
|
+
): Promise<UpdateContentResult | null> {
|
|
766
778
|
const tagPairs = input.tags === undefined ? null : validateDeclaredTagList(input.tags)
|
|
767
779
|
const existing = await getContentItem(db, id)
|
|
768
780
|
if (!existing) return null
|
|
@@ -949,6 +961,158 @@ export async function updateContentItem(
|
|
|
949
961
|
? serializeContentMetadata(input.metadata)
|
|
950
962
|
: existing.metadata_json
|
|
951
963
|
|
|
964
|
+
if (options.requiredStatus) {
|
|
965
|
+
if (input.tags !== undefined || input.relations !== undefined) {
|
|
966
|
+
throw new TypeError('Status-constrained updates do not support tags or relations')
|
|
967
|
+
}
|
|
968
|
+
if (!isBodyBackedContentType(existing.type)) {
|
|
969
|
+
throw new TypeError('Status-constrained updates require body-backed content')
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// D1 batch is a transaction: the status predicate and every content-table
|
|
973
|
+
// write linearize together, so a native publish/schedule cannot interleave
|
|
974
|
+
// between the base row and its body row.
|
|
975
|
+
const statements = [
|
|
976
|
+
db
|
|
977
|
+
.prepare(
|
|
978
|
+
`UPDATE content_items SET
|
|
979
|
+
slug = ?, title = ?, seo_title = ?, description = ?, excerpt = ?, byline = ?,
|
|
980
|
+
channel = ?, primary_topic = ?, featured = ?, visibility = ?, author_id = ?,
|
|
981
|
+
hero_image_id = ?, hero_image_alt = ?, hero_image_caption = ?, social_image_id = ?,
|
|
982
|
+
canonical_url = ?, ai_locked_fields = ?, seo_focus_keyword = ?, metadata_json = ?,
|
|
983
|
+
updated_at = unixepoch(), canonical_version = canonical_version + 1
|
|
984
|
+
WHERE id = ? AND status = ? AND deleted_at IS NULL
|
|
985
|
+
RETURNING slug`,
|
|
986
|
+
)
|
|
987
|
+
.bind(
|
|
988
|
+
slug,
|
|
989
|
+
title,
|
|
990
|
+
seoTitle,
|
|
991
|
+
description,
|
|
992
|
+
excerpt,
|
|
993
|
+
byline,
|
|
994
|
+
primaryCategory,
|
|
995
|
+
primaryTopic,
|
|
996
|
+
featured,
|
|
997
|
+
visibility,
|
|
998
|
+
authorId,
|
|
999
|
+
heroImageId,
|
|
1000
|
+
heroImageAlt,
|
|
1001
|
+
heroImageCaption,
|
|
1002
|
+
socialImageId,
|
|
1003
|
+
canonicalUrl,
|
|
1004
|
+
aiLockedFields,
|
|
1005
|
+
seoFocusKeyword,
|
|
1006
|
+
metadataJson,
|
|
1007
|
+
id,
|
|
1008
|
+
options.requiredStatus,
|
|
1009
|
+
),
|
|
1010
|
+
]
|
|
1011
|
+
|
|
1012
|
+
if (input.content) {
|
|
1013
|
+
const current = await db
|
|
1014
|
+
.prepare(
|
|
1015
|
+
`SELECT body_markdown, body_html, body_portable_text, layout_json,
|
|
1016
|
+
word_count, read_time_minutes, subtitle, editor_takeaways
|
|
1017
|
+
FROM article_content WHERE content_id = ? LIMIT 1`,
|
|
1018
|
+
)
|
|
1019
|
+
.bind(id)
|
|
1020
|
+
.first<{
|
|
1021
|
+
body_markdown: string
|
|
1022
|
+
body_html: string | null
|
|
1023
|
+
body_portable_text: string | null
|
|
1024
|
+
layout_json: string | null
|
|
1025
|
+
word_count: number | null
|
|
1026
|
+
read_time_minutes: number | null
|
|
1027
|
+
subtitle: string | null
|
|
1028
|
+
editor_takeaways: string | null
|
|
1029
|
+
}>()
|
|
1030
|
+
if (!current) throw new Error('Body-backed content row is missing')
|
|
1031
|
+
|
|
1032
|
+
const articleContent = input.content as Partial<
|
|
1033
|
+
ArticleInput['content'] | NewsletterInput['content'] | PageInput['content']
|
|
1034
|
+
>
|
|
1035
|
+
const bodyMarkdownProvided = hasOwn(articleContent, 'bodyMarkdown')
|
|
1036
|
+
const wordCountProvided = hasOwn(articleContent, 'wordCount')
|
|
1037
|
+
const readTimeProvided = hasOwn(articleContent, 'readTimeMinutes')
|
|
1038
|
+
const nextBodyMarkdown = bodyMarkdownProvided
|
|
1039
|
+
? (articleContent.bodyMarkdown ?? current.body_markdown)
|
|
1040
|
+
: current.body_markdown
|
|
1041
|
+
const nextWordCount = wordCountProvided
|
|
1042
|
+
? (articleContent.wordCount ?? null)
|
|
1043
|
+
: bodyMarkdownProvided
|
|
1044
|
+
? nextBodyMarkdown
|
|
1045
|
+
? countWords(nextBodyMarkdown)
|
|
1046
|
+
: null
|
|
1047
|
+
: current.word_count
|
|
1048
|
+
const nextReadTime = readTimeProvided
|
|
1049
|
+
? (articleContent.readTimeMinutes ?? null)
|
|
1050
|
+
: bodyMarkdownProvided
|
|
1051
|
+
? nextWordCount
|
|
1052
|
+
? estimateReadTime(nextWordCount)
|
|
1053
|
+
: null
|
|
1054
|
+
: current.read_time_minutes
|
|
1055
|
+
|
|
1056
|
+
statements.push(
|
|
1057
|
+
db
|
|
1058
|
+
.prepare(
|
|
1059
|
+
`UPDATE article_content SET
|
|
1060
|
+
body_markdown = ?, body_html = ?, body_portable_text = ?, layout_json = ?,
|
|
1061
|
+
word_count = ?, read_time_minutes = ?, subtitle = ?, editor_takeaways = ?
|
|
1062
|
+
WHERE content_id = ?
|
|
1063
|
+
AND EXISTS (
|
|
1064
|
+
SELECT 1 FROM content_items
|
|
1065
|
+
WHERE id = ? AND status = ? AND deleted_at IS NULL
|
|
1066
|
+
)
|
|
1067
|
+
RETURNING content_id`,
|
|
1068
|
+
)
|
|
1069
|
+
.bind(
|
|
1070
|
+
nextBodyMarkdown,
|
|
1071
|
+
hasOwn(articleContent, 'bodyHtml')
|
|
1072
|
+
? (articleContent.bodyHtml ?? null)
|
|
1073
|
+
: bodyMarkdownProvided
|
|
1074
|
+
? null
|
|
1075
|
+
: current.body_html,
|
|
1076
|
+
hasOwn(articleContent, 'bodyPortableText')
|
|
1077
|
+
? (articleContent.bodyPortableText ?? null)
|
|
1078
|
+
: bodyMarkdownProvided
|
|
1079
|
+
? null
|
|
1080
|
+
: current.body_portable_text,
|
|
1081
|
+
hasOwn(articleContent, 'layoutJson')
|
|
1082
|
+
? (articleContent.layoutJson ?? null)
|
|
1083
|
+
: current.layout_json,
|
|
1084
|
+
nextWordCount,
|
|
1085
|
+
nextReadTime,
|
|
1086
|
+
hasOwn(articleContent, 'subtitle')
|
|
1087
|
+
? (articleContent.subtitle ?? null)
|
|
1088
|
+
: current.subtitle,
|
|
1089
|
+
hasOwn(articleContent, 'editorTakeaways')
|
|
1090
|
+
? serializeTakeaways(articleContent.editorTakeaways)
|
|
1091
|
+
: current.editor_takeaways,
|
|
1092
|
+
id,
|
|
1093
|
+
id,
|
|
1094
|
+
options.requiredStatus,
|
|
1095
|
+
),
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const statusResultIndex = statements.length
|
|
1100
|
+
statements.push(
|
|
1101
|
+
db
|
|
1102
|
+
.prepare('SELECT status FROM content_items WHERE id = ? AND deleted_at IS NULL LIMIT 1')
|
|
1103
|
+
.bind(id),
|
|
1104
|
+
)
|
|
1105
|
+
const results = await db.batch(statements)
|
|
1106
|
+
const baseChanged =
|
|
1107
|
+
(results[0]?.results?.length ?? 0) > 0 ||
|
|
1108
|
+
(results[0]?.meta as { changes?: number } | undefined)?.changes === 1
|
|
1109
|
+
if (!baseChanged) {
|
|
1110
|
+
const status = results[statusResultIndex]?.results?.[0]?.status
|
|
1111
|
+
return typeof status === 'string' ? { slug: existing.slug, statusConflict: status } : null
|
|
1112
|
+
}
|
|
1113
|
+
return { slug }
|
|
1114
|
+
}
|
|
1115
|
+
|
|
952
1116
|
await db
|
|
953
1117
|
.prepare(
|
|
954
1118
|
`
|
package/src/routes/content.ts
CHANGED
|
@@ -140,6 +140,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
140
140
|
dispatchFaq?: DispatchFaqHook
|
|
141
141
|
/** Optional host-specific readiness guard. A not-ready result blocks publish. */
|
|
142
142
|
publishReadiness?: PublishReadinessHook
|
|
143
|
+
/** Internal machine-route constraint: atomically update only in this status. */
|
|
144
|
+
updateStatusConstraint?: ContentStatus
|
|
143
145
|
/**
|
|
144
146
|
* Optional webhook dispatcher hook (P7 Task 9). Fired at each content
|
|
145
147
|
* lifecycle transition (publish, unpublish, schedule, archive, create, etc.).
|
|
@@ -1238,6 +1240,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1238
1240
|
existing.author_id,
|
|
1239
1241
|
)
|
|
1240
1242
|
if (denied) return denied
|
|
1243
|
+
if (config.updateStatusConstraint && existing.status !== config.updateStatusConstraint) {
|
|
1244
|
+
return json({ error: 'update_status_conflict', state: existing.status }, 409)
|
|
1245
|
+
}
|
|
1241
1246
|
|
|
1242
1247
|
const parsed = UpdateSchema.safeParse(await readJson(ctx))
|
|
1243
1248
|
if (!parsed.success) {
|
|
@@ -1320,7 +1325,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1320
1325
|
const priorSlug = existing.slug
|
|
1321
1326
|
let updated: Awaited<ReturnType<typeof updateContentItem>>
|
|
1322
1327
|
try {
|
|
1323
|
-
updated = await updateContentItem(ctx.db, id, parsed.data
|
|
1328
|
+
updated = await updateContentItem(ctx.db, id, parsed.data, {
|
|
1329
|
+
requiredStatus: config.updateStatusConstraint,
|
|
1330
|
+
})
|
|
1324
1331
|
} catch (error) {
|
|
1325
1332
|
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1326
1333
|
const conflict = verifiedMediaDurationConflictResponse(error)
|
|
@@ -1328,6 +1335,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1328
1335
|
throw error
|
|
1329
1336
|
}
|
|
1330
1337
|
if (!updated) return json({ error: 'Not found' }, 404)
|
|
1338
|
+
if (updated.statusConflict) {
|
|
1339
|
+
return json({ error: 'update_status_conflict', state: updated.statusConflict }, 409)
|
|
1340
|
+
}
|
|
1331
1341
|
const podcastAttempt: PodcastFoundryAttempt =
|
|
1332
1342
|
existing.type === 'podcast'
|
|
1333
1343
|
? await dispatchPodcastAfterSave(ctx, id)
|
|
@@ -123,6 +123,7 @@ function receipt(row: Pick<FoundryArticleRow, 'external_id' | 'receipt_id' | 'st
|
|
|
123
123
|
function machineConfig(config: ContentRouteConfig): ContentRouteConfig {
|
|
124
124
|
return {
|
|
125
125
|
...config,
|
|
126
|
+
updateStatusConstraint: 'draft',
|
|
126
127
|
authz: {
|
|
127
128
|
requireAdmin: () => null,
|
|
128
129
|
requirePublisher: () => null,
|
|
@@ -170,12 +171,16 @@ async function upsertReceipt(
|
|
|
170
171
|
.run()
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
async function heroAssetId(
|
|
174
|
+
async function heroAssetId(url: string): Promise<string> {
|
|
175
|
+
return `foundry-hero-${(await sha256(url)).slice(0, 24)}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function persistHeroAsset(
|
|
174
179
|
ctx: RouteContext,
|
|
175
180
|
config: ContentRouteConfig,
|
|
181
|
+
id: string,
|
|
176
182
|
url: string,
|
|
177
|
-
): Promise<
|
|
178
|
-
const id = `foundry-hero-${(await sha256(url)).slice(0, 24)}`
|
|
183
|
+
): Promise<void> {
|
|
179
184
|
await insertMediaAsset(ctx.db, {
|
|
180
185
|
id,
|
|
181
186
|
siteId: config.mediaSiteId ?? 'fronts',
|
|
@@ -191,7 +196,23 @@ async function heroAssetId(
|
|
|
191
196
|
variantsJson: null,
|
|
192
197
|
r2Binding: config.mediaR2Binding ?? 'PUBLIC_MEDIA',
|
|
193
198
|
})
|
|
194
|
-
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function approvalConflict(id: string, state: string): Response {
|
|
202
|
+
return json({ error: 'published_requires_approval', external_id: id, state }, 409)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function mapUpdateConflict(response: Response, id: string): Promise<Response> {
|
|
206
|
+
if (response.status !== 409) return response
|
|
207
|
+
try {
|
|
208
|
+
const payload = (await response.clone().json()) as { error?: unknown; state?: unknown }
|
|
209
|
+
if (payload.error === 'update_status_conflict' && typeof payload.state === 'string') {
|
|
210
|
+
return approvalConflict(id, payload.state)
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Preserve an unrelated native 409 response byte-for-byte.
|
|
214
|
+
}
|
|
215
|
+
return response
|
|
195
216
|
}
|
|
196
217
|
|
|
197
218
|
export function createFoundryPublishRoutes(
|
|
@@ -224,15 +245,19 @@ export function createFoundryPublishRoutes(
|
|
|
224
245
|
})
|
|
225
246
|
return json(receipt(prior))
|
|
226
247
|
}
|
|
248
|
+
if (prior && prior.status !== 'draft') return approvalConflict(id, prior.status)
|
|
227
249
|
|
|
228
250
|
const sourceContent = prior ? null : await findContentBySource(ctx, id)
|
|
229
251
|
if (sourceContent && sourceContent.type !== 'article')
|
|
230
252
|
return json({ error: 'external_id_conflict' }, 409)
|
|
253
|
+
if (sourceContent && sourceContent.status !== 'draft') {
|
|
254
|
+
return approvalConflict(id, sourceContent.status)
|
|
255
|
+
}
|
|
231
256
|
const contentId = prior?.content_id ?? sourceContent?.id ?? null
|
|
232
257
|
const digestSlug = `article-${auth.bodyHash.slice(0, 12)}`
|
|
233
258
|
const slug = slugify(parsed.data.title) || digestSlug
|
|
234
259
|
const heroImageId = parsed.data.hero_image_url
|
|
235
|
-
? await heroAssetId(
|
|
260
|
+
? await heroAssetId(parsed.data.hero_image_url)
|
|
236
261
|
: undefined
|
|
237
262
|
const body = {
|
|
238
263
|
title: parsed.data.title,
|
|
@@ -243,7 +268,10 @@ export function createFoundryPublishRoutes(
|
|
|
243
268
|
|
|
244
269
|
let nativeResponse: Response
|
|
245
270
|
if (contentId) {
|
|
246
|
-
nativeResponse = await
|
|
271
|
+
nativeResponse = await mapUpdateConflict(
|
|
272
|
+
await content.update(nativeContext(ctx, 'PATCH', body, { id: contentId })),
|
|
273
|
+
id,
|
|
274
|
+
)
|
|
247
275
|
} else {
|
|
248
276
|
try {
|
|
249
277
|
nativeResponse = await content.create(
|
|
@@ -257,10 +285,16 @@ export function createFoundryPublishRoutes(
|
|
|
257
285
|
} catch (error) {
|
|
258
286
|
const raced = await findContentBySource(ctx, id)
|
|
259
287
|
if (!raced || raced.type !== 'article') throw error
|
|
260
|
-
nativeResponse = await
|
|
288
|
+
nativeResponse = await mapUpdateConflict(
|
|
289
|
+
await content.update(nativeContext(ctx, 'PATCH', body, { id: raced.id })),
|
|
290
|
+
id,
|
|
291
|
+
)
|
|
261
292
|
}
|
|
262
293
|
}
|
|
263
294
|
if (!nativeResponse.ok) return nativeResponse
|
|
295
|
+
if (heroImageId && parsed.data.hero_image_url) {
|
|
296
|
+
await persistHeroAsset(ctx, config, heroImageId, parsed.data.hero_image_url)
|
|
297
|
+
}
|
|
264
298
|
const native = (await nativeResponse.json()) as { id?: string; slug?: string }
|
|
265
299
|
const resolvedId = contentId ?? native.id
|
|
266
300
|
if (!resolvedId) return json({ error: 'write_failed' }, 500)
|