@growth-labs/cms 0.5.30 → 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 +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +5 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +21 -1
- package/dist/engine/publisher.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/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/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/schema.d.ts +54 -54
- 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/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 +27 -0
- package/src/engine/publisher.ts +21 -1
- package/src/integration/index.ts +10 -0
- package/src/routes/fronts-publish.ts +292 -0
- package/src/routes/index.ts +8 -0
- package/src/schema/migrations.ts +36 -0
- package/src/schema/tables.ts +2 -0
- package/src/schema/types.ts +61 -0
- package/src/ui/api/fronts/publish-callback.ts +18 -0
- package/src/ui/api/fronts/schedule.ts +18 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Fronts publish path — the additive, flag-gated, out-of-process publish flow
|
|
2
|
+
// (increment: cms/Masthead side).
|
|
3
|
+
//
|
|
4
|
+
// TWO seams, both driven by the external Fronts publish worker (foundryd):
|
|
5
|
+
//
|
|
6
|
+
// 1. listFrontsPublishSchedule — the poll surface. Returns `scheduled` items
|
|
7
|
+
// whose `publish_at` has arrived or is arriving within the caller's window
|
|
8
|
+
// (plus any overdue), so foundryd knows what to publish. Mirrors the
|
|
9
|
+
// `runScheduledPublish` (engine/cron.ts) due-predicate — `content_items`
|
|
10
|
+
// scheduling columns are INTEGER unix seconds, so the window is compared in
|
|
11
|
+
// seconds (NOT the D1 `datetime('now')` TEXT format; there is no
|
|
12
|
+
// space-vs-T comparison trap on this column).
|
|
13
|
+
//
|
|
14
|
+
// 2. applyFrontsPublishCallback — the receiver. Records foundryd's outbound
|
|
15
|
+
// callback (idempotent by `event_id`), reflects the lifecycle status onto
|
|
16
|
+
// `content_items.fronts_publish_status`, and — ONLY on a confirmed `live`
|
|
17
|
+
// with a receipt — drives the item to published THROUGH the existing
|
|
18
|
+
// `content_publication_attempts` 2PC ledger via `publishOne`. A
|
|
19
|
+
// `queued`/`publishing`/`late`/`failed` callback (or a `live` with no
|
|
20
|
+
// receipt) never flips the item to published.
|
|
21
|
+
//
|
|
22
|
+
// The legacy D1 cron publish path (engine/cron.ts `runScheduledPublish` + the
|
|
23
|
+
// site's cron) is UNTOUCHED and remains the rollback lever; this path is purely
|
|
24
|
+
// additive and gated by FRONTS_PUBLISH_ENABLED (default ON — a rollback lever,
|
|
25
|
+
// not a dark-staging gate).
|
|
26
|
+
|
|
27
|
+
import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
|
|
28
|
+
import type { D1Database } from './d1.js'
|
|
29
|
+
import { type PublicationReconciler, publishOne } from './publication.js'
|
|
30
|
+
|
|
31
|
+
/** The five lifecycle states foundryd reports for a Fronts publish intent. */
|
|
32
|
+
export const FRONTS_PUBLISH_STATUSES: readonly FrontsPublishStatus[] = [
|
|
33
|
+
'queued',
|
|
34
|
+
'publishing',
|
|
35
|
+
'live',
|
|
36
|
+
'late',
|
|
37
|
+
'failed',
|
|
38
|
+
] as const
|
|
39
|
+
|
|
40
|
+
export function isFrontsPublishStatus(value: unknown): value is FrontsPublishStatus {
|
|
41
|
+
return typeof value === 'string' && (FRONTS_PUBLISH_STATUSES as string[]).includes(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Feature flag — a ROLLBACK LEVER, not a graduation gate. Default ENABLED; the
|
|
46
|
+
* additive Fronts publish endpoints only go dark when the host explicitly sets
|
|
47
|
+
* FRONTS_PUBLISH_ENABLED to a falsy string ('0' | 'false' | 'off' | 'no') or the
|
|
48
|
+
* boolean false. When off, foundryd's polls/callbacks are refused loudly (503)
|
|
49
|
+
* and the untouched D1 cron path owns publishing.
|
|
50
|
+
*/
|
|
51
|
+
export function isFrontsPublishEnabled(env: Record<string, unknown> | undefined): boolean {
|
|
52
|
+
const raw = env?.FRONTS_PUBLISH_ENABLED
|
|
53
|
+
if (raw === undefined || raw === null) return true
|
|
54
|
+
if (typeof raw === 'boolean') return raw
|
|
55
|
+
if (typeof raw === 'number') return raw !== 0
|
|
56
|
+
if (typeof raw === 'string') {
|
|
57
|
+
return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase())
|
|
58
|
+
}
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- 1. Schedule poll ------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface FrontsScheduleItem {
|
|
65
|
+
id: string
|
|
66
|
+
type: ContentType
|
|
67
|
+
slug: string
|
|
68
|
+
title: string
|
|
69
|
+
channel: string | null
|
|
70
|
+
/** Scheduled publish time, unix epoch seconds. */
|
|
71
|
+
publishAt: number
|
|
72
|
+
/** Current lifecycle marker (null until foundryd first acknowledges it). */
|
|
73
|
+
frontsPublishStatus: FrontsPublishStatus | null
|
|
74
|
+
/** publish_at is at or before `now` — foundryd is late on this one. */
|
|
75
|
+
overdue: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ListFrontsPublishScheduleOptions {
|
|
79
|
+
/** Window upper bound, unix epoch seconds. Items due at/before this are returned. */
|
|
80
|
+
toSeconds: number
|
|
81
|
+
/** Reference time for the `overdue` flag, unix epoch seconds. Defaults to Date.now(). */
|
|
82
|
+
nowSeconds?: number
|
|
83
|
+
/** Hard cap on rows returned. Default 200, max 1000. */
|
|
84
|
+
limit?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List `scheduled` items due at or before the window end. Includes overdue
|
|
89
|
+
* items (publish_at < window start) — the window's lower bound is intentionally
|
|
90
|
+
* not applied, matching foundryd's "next window + any overdue" contract. Ordered
|
|
91
|
+
* by publish_at ascending so the most-overdue item is published first.
|
|
92
|
+
*/
|
|
93
|
+
export async function listFrontsPublishSchedule(
|
|
94
|
+
db: D1Database,
|
|
95
|
+
options: ListFrontsPublishScheduleOptions,
|
|
96
|
+
): Promise<FrontsScheduleItem[]> {
|
|
97
|
+
const now = options.nowSeconds ?? Math.floor(Date.now() / 1_000)
|
|
98
|
+
const limit = Math.min(Math.max(1, Math.floor(options.limit ?? 200)), 1000)
|
|
99
|
+
const result = await db
|
|
100
|
+
.prepare(
|
|
101
|
+
`SELECT id, type, slug, title, channel, publish_at, fronts_publish_status
|
|
102
|
+
FROM content_items
|
|
103
|
+
WHERE status = 'scheduled'
|
|
104
|
+
AND publish_at IS NOT NULL
|
|
105
|
+
AND publish_at <= ?
|
|
106
|
+
ORDER BY publish_at ASC
|
|
107
|
+
LIMIT ?`,
|
|
108
|
+
)
|
|
109
|
+
.bind(options.toSeconds, limit)
|
|
110
|
+
.all<{
|
|
111
|
+
id: string
|
|
112
|
+
type: ContentType
|
|
113
|
+
slug: string
|
|
114
|
+
title: string
|
|
115
|
+
channel: string | null
|
|
116
|
+
publish_at: number
|
|
117
|
+
fronts_publish_status: FrontsPublishStatus | null
|
|
118
|
+
}>()
|
|
119
|
+
return (result.results ?? []).map((row) => ({
|
|
120
|
+
id: row.id,
|
|
121
|
+
type: row.type,
|
|
122
|
+
slug: row.slug,
|
|
123
|
+
title: row.title,
|
|
124
|
+
channel: row.channel,
|
|
125
|
+
publishAt: row.publish_at,
|
|
126
|
+
frontsPublishStatus: row.fronts_publish_status,
|
|
127
|
+
overdue: row.publish_at <= now,
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- 2. Publish callback receiver -----------------------------------------
|
|
132
|
+
|
|
133
|
+
export interface FrontsPublishReceipt {
|
|
134
|
+
published_urls?: string[] | null
|
|
135
|
+
platform_post_ids?: string[] | null
|
|
136
|
+
sha256?: string | null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface FrontsPublishCallbackInput {
|
|
140
|
+
eventId: string
|
|
141
|
+
intentId?: string | null
|
|
142
|
+
contentId: string
|
|
143
|
+
version?: number | null
|
|
144
|
+
correlationId?: string | null
|
|
145
|
+
status: FrontsPublishStatus
|
|
146
|
+
/** ISO-8601 string or unix epoch seconds; used as the published_at on `live`. */
|
|
147
|
+
occurredAt?: string | number | null
|
|
148
|
+
receipt?: FrontsPublishReceipt | null
|
|
149
|
+
error?: string | null
|
|
150
|
+
/** Reference time (unix seconds), for tests. Defaults to Date.now(). */
|
|
151
|
+
now?: number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface FrontsPublishCallbackResult {
|
|
155
|
+
ok: boolean
|
|
156
|
+
/** True when this event_id was already processed — no state change this call. */
|
|
157
|
+
deduped: boolean
|
|
158
|
+
status: FrontsPublishStatus
|
|
159
|
+
/** True when the item is published (flipped by this call, or already was). */
|
|
160
|
+
published: boolean
|
|
161
|
+
/** The content_publication_attempts ledger row id, when this call published. */
|
|
162
|
+
attemptId: string | null
|
|
163
|
+
contentFound: boolean
|
|
164
|
+
/** Set when a `live` callback could not be committed through the ledger. */
|
|
165
|
+
ledgerError: string | null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hasReceipt(receipt: FrontsPublishReceipt | null | undefined): boolean {
|
|
169
|
+
if (!receipt) return false
|
|
170
|
+
const urls = Array.isArray(receipt.published_urls) && receipt.published_urls.length > 0
|
|
171
|
+
const ids = Array.isArray(receipt.platform_post_ids) && receipt.platform_post_ids.length > 0
|
|
172
|
+
const sha = typeof receipt.sha256 === 'string' && receipt.sha256.trim().length > 0
|
|
173
|
+
return urls || ids || sha
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolve occurred_at to a bounded, non-future unix-seconds value (or null). */
|
|
177
|
+
function resolveOccurredAtSeconds(
|
|
178
|
+
occurredAt: string | number | null | undefined,
|
|
179
|
+
now: number,
|
|
180
|
+
): number | null {
|
|
181
|
+
let seconds: number | null = null
|
|
182
|
+
if (typeof occurredAt === 'number' && Number.isFinite(occurredAt)) {
|
|
183
|
+
seconds = Math.floor(occurredAt)
|
|
184
|
+
} else if (typeof occurredAt === 'string' && occurredAt.trim()) {
|
|
185
|
+
const ms = Date.parse(occurredAt)
|
|
186
|
+
if (!Number.isNaN(ms)) seconds = Math.floor(ms / 1_000)
|
|
187
|
+
}
|
|
188
|
+
if (seconds === null || !Number.isSafeInteger(seconds) || seconds < 0) return null
|
|
189
|
+
// publishOne rejects a published_at later than `now`; clamp for clock skew.
|
|
190
|
+
return Math.min(seconds, now)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Apply one foundryd publish-callback. Idempotent by `event_id`: a repeat
|
|
195
|
+
* delivery returns the stored outcome without re-applying. See the module
|
|
196
|
+
* header for the state-transition contract.
|
|
197
|
+
*/
|
|
198
|
+
export async function applyFrontsPublishCallback(
|
|
199
|
+
db: D1Database,
|
|
200
|
+
input: FrontsPublishCallbackInput,
|
|
201
|
+
): Promise<FrontsPublishCallbackResult> {
|
|
202
|
+
const now = input.now ?? Math.floor(Date.now() / 1_000)
|
|
203
|
+
|
|
204
|
+
// 1. Idempotency — replay the stored outcome for a known event_id.
|
|
205
|
+
const existing = await readStoredOutcome(db, input.eventId)
|
|
206
|
+
if (existing) return existing
|
|
207
|
+
|
|
208
|
+
// 2. Resolve the target item. A missing item is NOT recorded, so a retry can
|
|
209
|
+
// still land once the item exists.
|
|
210
|
+
const item = await db
|
|
211
|
+
.prepare('SELECT id, status, fronts_publish_status FROM content_items WHERE id = ? LIMIT 1')
|
|
212
|
+
.bind(input.contentId)
|
|
213
|
+
.first<{ id: string; status: string; fronts_publish_status: FrontsPublishStatus | null }>()
|
|
214
|
+
if (!item) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
deduped: false,
|
|
218
|
+
status: input.status,
|
|
219
|
+
published: false,
|
|
220
|
+
attemptId: null,
|
|
221
|
+
contentFound: false,
|
|
222
|
+
ledgerError: null,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. On a confirmed `live` WITH a receipt, drive the publish through the 2PC
|
|
227
|
+
// ledger — record attempt + commit — flipping the item to published.
|
|
228
|
+
// A `live` on an already-published item just confirms it (no re-publish,
|
|
229
|
+
// so we never race the cron path into a duplicate revision).
|
|
230
|
+
let published = item.status === 'published'
|
|
231
|
+
let attemptId: string | null = null
|
|
232
|
+
let ledgerError: string | null = null
|
|
233
|
+
const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
|
|
234
|
+
if (shouldPublish && item.status !== 'published') {
|
|
235
|
+
try {
|
|
236
|
+
const receiptSummary = summarizeReceipt(input.receipt)
|
|
237
|
+
const surface: PublicationReconciler = {
|
|
238
|
+
name: 'fronts',
|
|
239
|
+
async prepare() {
|
|
240
|
+
return { name: 'fronts', status: 'ok', phase: 'prepare' }
|
|
241
|
+
},
|
|
242
|
+
async commit() {
|
|
243
|
+
return { name: 'fronts', status: 'ok', phase: 'commit', message: receiptSummary }
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
const publishedAt = resolveOccurredAtSeconds(input.occurredAt, now)
|
|
247
|
+
const receipt = await publishOne(db, {
|
|
248
|
+
contentId: input.contentId,
|
|
249
|
+
now,
|
|
250
|
+
...(publishedAt !== null ? { publishedAt } : {}),
|
|
251
|
+
createdBy: 'foundry.fronts-publish',
|
|
252
|
+
surfaces: [surface],
|
|
253
|
+
})
|
|
254
|
+
attemptId = receipt.attemptId
|
|
255
|
+
published = true
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// publishOne can throw when a concurrent/superseding publish already
|
|
258
|
+
// moved the canonical pointer (the item ends up published anyway) — treat
|
|
259
|
+
// that as success. Any other failure is recorded and surfaced loudly.
|
|
260
|
+
const reread = await db
|
|
261
|
+
.prepare('SELECT status FROM content_items WHERE id = ? LIMIT 1')
|
|
262
|
+
.bind(input.contentId)
|
|
263
|
+
.first<{ status: string }>()
|
|
264
|
+
if (reread?.status === 'published') {
|
|
265
|
+
published = true
|
|
266
|
+
} else {
|
|
267
|
+
ledgerError = error instanceof Error ? error.message : String(error)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const incomingOccurredAt = resolveOccurredAtSeconds(input.occurredAt, now)
|
|
273
|
+
|
|
274
|
+
// 4. Reflect the lifecycle marker onto the item — but NEVER regress it. A
|
|
275
|
+
// retried/out-of-order `publishing`/`failed` arriving after `live` must not
|
|
276
|
+
// make a published item read as publishing/failed, so gate on occurred_at
|
|
277
|
+
// ordering and the terminal `live`/published state.
|
|
278
|
+
const latestSeenRow = await db
|
|
279
|
+
.prepare(
|
|
280
|
+
'SELECT MAX(occurred_at) AS latest FROM fronts_publish_events WHERE content_id = ? AND occurred_at IS NOT NULL',
|
|
281
|
+
)
|
|
282
|
+
.bind(input.contentId)
|
|
283
|
+
.first<{ latest: number | null }>()
|
|
284
|
+
if (
|
|
285
|
+
shouldApplyFrontsMarker(
|
|
286
|
+
item.status,
|
|
287
|
+
item.fronts_publish_status,
|
|
288
|
+
input.status,
|
|
289
|
+
incomingOccurredAt,
|
|
290
|
+
latestSeenRow?.latest ?? null,
|
|
291
|
+
)
|
|
292
|
+
) {
|
|
293
|
+
await db
|
|
294
|
+
.prepare(
|
|
295
|
+
'UPDATE content_items SET fronts_publish_status = ?, updated_at = unixepoch() WHERE id = ?',
|
|
296
|
+
)
|
|
297
|
+
.bind(input.status, input.contentId)
|
|
298
|
+
.run()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 5. Record the durable, idempotent event row (the "processed" marker). The
|
|
302
|
+
// dedupe SELECT (step 1) and this INSERT are not one transaction, so two
|
|
303
|
+
// concurrent same-event_id deliveries can both reach here — ON CONFLICT DO
|
|
304
|
+
// NOTHING makes the loser a no-op (never a spurious PK-violation 500), and
|
|
305
|
+
// we then replay the winner's stored outcome. publishOne's own CAS already
|
|
306
|
+
// prevents a double publish.
|
|
307
|
+
const insertResult = await db
|
|
308
|
+
.prepare(
|
|
309
|
+
`INSERT INTO fronts_publish_events
|
|
310
|
+
(event_id, intent_id, content_id, version, correlation_id, status, occurred_at,
|
|
311
|
+
receipt_json, error, published, attempt_id, received_at)
|
|
312
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
313
|
+
ON CONFLICT(event_id) DO NOTHING`,
|
|
314
|
+
)
|
|
315
|
+
.bind(
|
|
316
|
+
input.eventId,
|
|
317
|
+
input.intentId ?? null,
|
|
318
|
+
input.contentId,
|
|
319
|
+
typeof input.version === 'number' && Number.isFinite(input.version)
|
|
320
|
+
? Math.floor(input.version)
|
|
321
|
+
: null,
|
|
322
|
+
input.correlationId ?? null,
|
|
323
|
+
input.status,
|
|
324
|
+
incomingOccurredAt,
|
|
325
|
+
input.receipt ? JSON.stringify(input.receipt) : null,
|
|
326
|
+
ledgerError,
|
|
327
|
+
published ? 1 : 0,
|
|
328
|
+
attemptId,
|
|
329
|
+
now,
|
|
330
|
+
)
|
|
331
|
+
.run()
|
|
332
|
+
|
|
333
|
+
const changes = insertResult.meta?.changes
|
|
334
|
+
if (typeof changes === 'number' && changes === 0) {
|
|
335
|
+
// A concurrent callback with the same event_id won the PK race.
|
|
336
|
+
const winner = await readStoredOutcome(db, input.eventId)
|
|
337
|
+
if (winner) return winner
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
ok: ledgerError === null,
|
|
342
|
+
deduped: false,
|
|
343
|
+
status: input.status,
|
|
344
|
+
published,
|
|
345
|
+
attemptId,
|
|
346
|
+
contentFound: true,
|
|
347
|
+
ledgerError,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Replay a stored callback outcome by event_id, or null when not yet recorded. */
|
|
352
|
+
async function readStoredOutcome(
|
|
353
|
+
db: D1Database,
|
|
354
|
+
eventId: string,
|
|
355
|
+
): Promise<FrontsPublishCallbackResult | null> {
|
|
356
|
+
const row = await db
|
|
357
|
+
.prepare(
|
|
358
|
+
'SELECT status, published, attempt_id, error FROM fronts_publish_events WHERE event_id = ? LIMIT 1',
|
|
359
|
+
)
|
|
360
|
+
.bind(eventId)
|
|
361
|
+
.first<{
|
|
362
|
+
status: FrontsPublishStatus
|
|
363
|
+
published: number
|
|
364
|
+
attempt_id: string | null
|
|
365
|
+
error: string | null
|
|
366
|
+
}>()
|
|
367
|
+
if (!row) return null
|
|
368
|
+
return {
|
|
369
|
+
ok: !row.error,
|
|
370
|
+
deduped: true,
|
|
371
|
+
status: row.status,
|
|
372
|
+
published: row.published === 1,
|
|
373
|
+
attemptId: row.attempt_id,
|
|
374
|
+
contentFound: true,
|
|
375
|
+
ledgerError: row.error,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Whether an incoming callback may overwrite `content_items.fronts_publish_status`.
|
|
381
|
+
* `live` is the authoritative terminal-success marker (always reflected, never
|
|
382
|
+
* downgraded); older-by-occurred_at callbacks may not regress a newer marker.
|
|
383
|
+
*/
|
|
384
|
+
function shouldApplyFrontsMarker(
|
|
385
|
+
itemStatus: string,
|
|
386
|
+
currentMarker: FrontsPublishStatus | null,
|
|
387
|
+
incomingStatus: FrontsPublishStatus,
|
|
388
|
+
incomingOccurredAt: number | null,
|
|
389
|
+
latestSeenOccurredAt: number | null,
|
|
390
|
+
): boolean {
|
|
391
|
+
if (incomingStatus === 'live') return true
|
|
392
|
+
if (itemStatus === 'published' || currentMarker === 'live') return false
|
|
393
|
+
if (
|
|
394
|
+
incomingOccurredAt !== null &&
|
|
395
|
+
latestSeenOccurredAt !== null &&
|
|
396
|
+
incomingOccurredAt < latestSeenOccurredAt
|
|
397
|
+
) {
|
|
398
|
+
return false
|
|
399
|
+
}
|
|
400
|
+
return true
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Bounded, human-readable receipt digest recorded on the ledger surface receipt. */
|
|
404
|
+
function summarizeReceipt(receipt: FrontsPublishReceipt | null | undefined): string {
|
|
405
|
+
if (!receipt) return 'fronts:live'
|
|
406
|
+
const parts: string[] = []
|
|
407
|
+
if (Array.isArray(receipt.published_urls) && receipt.published_urls.length > 0) {
|
|
408
|
+
parts.push(`urls=${receipt.published_urls.length}`)
|
|
409
|
+
}
|
|
410
|
+
if (Array.isArray(receipt.platform_post_ids) && receipt.platform_post_ids.length > 0) {
|
|
411
|
+
parts.push(`posts=${receipt.platform_post_ids.length}`)
|
|
412
|
+
}
|
|
413
|
+
if (typeof receipt.sha256 === 'string' && receipt.sha256.trim()) {
|
|
414
|
+
parts.push(`sha256=${receipt.sha256.trim().slice(0, 64)}`)
|
|
415
|
+
}
|
|
416
|
+
return `fronts:live ${parts.join(' ')}`.trim().slice(0, 500)
|
|
417
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -83,6 +83,33 @@ export {
|
|
|
83
83
|
type ProcessingState,
|
|
84
84
|
verifyHmac,
|
|
85
85
|
} from './foundry-dispatch.js'
|
|
86
|
+
// Fronts publish path — schedule poll + publish-callback receiver (additive, flag-gated).
|
|
87
|
+
export {
|
|
88
|
+
applyFrontsPublishCallback,
|
|
89
|
+
FRONTS_PUBLISH_STATUSES,
|
|
90
|
+
type FrontsPublishCallbackInput,
|
|
91
|
+
type FrontsPublishCallbackResult,
|
|
92
|
+
type FrontsPublishReceipt,
|
|
93
|
+
type FrontsScheduleItem,
|
|
94
|
+
isFrontsPublishEnabled,
|
|
95
|
+
isFrontsPublishStatus,
|
|
96
|
+
type ListFrontsPublishScheduleOptions,
|
|
97
|
+
listFrontsPublishSchedule,
|
|
98
|
+
} from './fronts-publish.js'
|
|
99
|
+
// Canonical Fronts publish INTENT contract — the CMS-owned canonical values a
|
|
100
|
+
// pull consumer needs to build a complete publish intent (packages#500).
|
|
101
|
+
export {
|
|
102
|
+
armFrontsPublishTriggerToken,
|
|
103
|
+
type FrontsPublishContentRefs,
|
|
104
|
+
type FrontsPublishIntent,
|
|
105
|
+
FrontsPublishIntentError,
|
|
106
|
+
type FrontsPublishIntentErrorCode,
|
|
107
|
+
type FrontsPublishIntentFailure,
|
|
108
|
+
frontsPublishTriggerTokenHash,
|
|
109
|
+
type HydrateFrontsPublishIntentsResult,
|
|
110
|
+
hydrateFrontsPublishIntents,
|
|
111
|
+
setFrontsChannelTargets,
|
|
112
|
+
} from './fronts-publish-intent.js'
|
|
86
113
|
// Import parsers — pure string→struct transforms for Markdown/Ghost/Substack.
|
|
87
114
|
export {
|
|
88
115
|
htmlToMarkdown,
|
package/src/engine/publisher.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import type { ContentType } from '../schema/types.js'
|
|
23
23
|
import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
|
|
24
24
|
import type { D1Database } from './d1.js'
|
|
25
|
+
import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
|
|
25
26
|
import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
|
|
26
27
|
import { slugify } from './slug.js'
|
|
27
28
|
import {
|
|
@@ -948,7 +949,13 @@ export async function updateContentItem(
|
|
|
948
949
|
ai_locked_fields = ?,
|
|
949
950
|
seo_focus_keyword = ?,
|
|
950
951
|
metadata_json = ?,
|
|
951
|
-
updated_at = unixepoch()
|
|
952
|
+
updated_at = unixepoch(),
|
|
953
|
+
-- The canonical revision counter behind the Fronts publish intent id
|
|
954
|
+
-- masthead:<content_id>:<version> (migration 0027). This is the ONE seam
|
|
955
|
+
-- that advances it, so an intent identity changes exactly when the item's
|
|
956
|
+
-- canonical content does -- and never on a fronts_publish_status marker
|
|
957
|
+
-- write or a schedule poll.
|
|
958
|
+
canonical_version = canonical_version + 1
|
|
952
959
|
WHERE id = ?
|
|
953
960
|
`,
|
|
954
961
|
)
|
|
@@ -1172,6 +1179,12 @@ export async function scheduleContent(
|
|
|
1172
1179
|
)
|
|
1173
1180
|
.bind(publishAt, publishTz, id)
|
|
1174
1181
|
.run()
|
|
1182
|
+
// Arming an item for the additive Fronts publish path happens HERE — the one
|
|
1183
|
+
// seam through which an item becomes `scheduled` with a `publish_at`. The mint
|
|
1184
|
+
// is compare-and-set and never rotates an existing token, so an item's
|
|
1185
|
+
// `trigger_token_hash` is stable across reschedules and across every poll.
|
|
1186
|
+
// The raw token stays in D1; only `sha256:<hex>` is ever exposed.
|
|
1187
|
+
await armFrontsPublishTriggerToken(db, id)
|
|
1175
1188
|
}
|
|
1176
1189
|
|
|
1177
1190
|
export async function evaluateContentBodyForPublish(
|
|
@@ -1304,6 +1317,13 @@ export async function duplicateContentItem(
|
|
|
1304
1317
|
// — a duplicate should keep the SEO work already done on the source. But
|
|
1305
1318
|
// board_position is the source item's slot in the editorial board ordering;
|
|
1306
1319
|
// a fresh draft copy must NOT inherit it, so it is reset to NULL.
|
|
1320
|
+
//
|
|
1321
|
+
// The migration-0027 Fronts publish columns are DELIBERATELY omitted so the
|
|
1322
|
+
// copy takes their defaults: canonical_version = 1 (a new item starts its own
|
|
1323
|
+
// revision line), fronts_channel_targets = NULL and
|
|
1324
|
+
// fronts_publish_trigger_token = NULL (a distinct item must be armed on its
|
|
1325
|
+
// own and must never inherit another item's publish trigger token). Do not
|
|
1326
|
+
// add them to the column list.
|
|
1307
1327
|
await db
|
|
1308
1328
|
.prepare(
|
|
1309
1329
|
`INSERT INTO content_items (
|
package/src/integration/index.ts
CHANGED
|
@@ -102,6 +102,16 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
102
102
|
pattern: `${base}/api/content/insights-ingest`,
|
|
103
103
|
entrypoint: resolveEntrypoint('../ui/api/content/insights-ingest'),
|
|
104
104
|
})
|
|
105
|
+
// Fronts publish path (additive, flag-gated) — M2M siblings of the
|
|
106
|
+
// foundry-callback / insights-ingest routes. HMAC-guarded.
|
|
107
|
+
injectRoute({
|
|
108
|
+
pattern: `${base}/api/fronts/schedule`,
|
|
109
|
+
entrypoint: resolveEntrypoint('../ui/api/fronts/schedule'),
|
|
110
|
+
})
|
|
111
|
+
injectRoute({
|
|
112
|
+
pattern: `${base}/api/fronts/publish-callback`,
|
|
113
|
+
entrypoint: resolveEntrypoint('../ui/api/fronts/publish-callback'),
|
|
114
|
+
})
|
|
105
115
|
// Content list + create (Library screen)
|
|
106
116
|
injectRoute({
|
|
107
117
|
pattern: `${base}/api/content`,
|