@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,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
+ }
@@ -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,
@@ -168,6 +195,35 @@ export {
168
195
  rollbackPublication,
169
196
  type SkippedArchiveItem,
170
197
  } from './publication.js'
198
+ export {
199
+ acceptsProbeStatus,
200
+ type ContinuityArchiveItem,
201
+ type ContinuityDrift,
202
+ type ContinuityDriftField,
203
+ type ContinuityPublishedRow,
204
+ isRetiredItem,
205
+ payloadDrift,
206
+ } from './publication-continuity.js'
207
+ export {
208
+ CanonicalJsonError,
209
+ canonicalJson,
210
+ canonicalJsonBytes,
211
+ canonicalJsonSha256,
212
+ type JsonValue,
213
+ } from './publication-receipt/canonical-json.js'
214
+ export {
215
+ canonicalizeReceiptBody,
216
+ PublicationReceiptError,
217
+ SITE_PUBLICATION_RECEIPT_AUDIENCE,
218
+ SITE_PUBLICATION_RECEIPT_SCHEMA,
219
+ type SitePublicationReceiptBody,
220
+ sitePublicationReceiptBodySchema,
221
+ } from './publication-receipt/receipt.js'
222
+ export {
223
+ type SignedPublicationReceipt,
224
+ signPublicationReceipt,
225
+ verifyPublicationReceipt,
226
+ } from './publication-receipt/signature.js'
171
227
  // Pre-publish body validation guard.
172
228
  export {
173
229
  evaluateArticleBody,
@@ -312,6 +368,10 @@ export {
312
368
  restoreContent,
313
369
  softDeleteContent,
314
370
  } from './soft-delete.js'
371
+ export {
372
+ normalizeSourceSections,
373
+ type SourceSectionNormalization,
374
+ } from './source-sections.js'
315
375
  // Tag CRUD engine — listTags / searchTags / renameTag / deleteTag.
316
376
  export {
317
377
  createTag,
@@ -0,0 +1,140 @@
1
+ // Publication continuity decisions (#342).
2
+ //
3
+ // Extracted from the Warfronts publish bridge so every site shares one rule set
4
+ // instead of each re-deriving it. These are the decisions a continuity run makes
5
+ // before it touches the database: is this item retired, did the published
6
+ // revision drift from the archive, and does a live probe response mean success.
7
+ //
8
+ // All pure. The bridge's DB, network and filesystem work stays site-owned; only
9
+ // the judgement moves here.
10
+
11
+ /** The archive-side fields a continuity run compares. */
12
+ export interface ContinuityArchiveItem {
13
+ title?: string | null
14
+ description?: string | null
15
+ featured?: boolean | null
16
+ heroImageId?: string | null
17
+ visibility?: string | null
18
+ /** The archive's name for what the engine stores as `channel`. */
19
+ primaryCategory?: string | null
20
+ bodyMarkdown?: string | null
21
+ metadata?: { originalStatus?: string | null } | null
22
+ }
23
+
24
+ /** The joined `content_items` + published-revision row, as the engine stores it. */
25
+ export interface ContinuityPublishedRow {
26
+ rev_title?: string | null
27
+ rev_description?: string | null
28
+ rev_featured?: number | null
29
+ rev_hero_image_id?: string | null
30
+ rev_visibility?: string | null
31
+ rev_channel?: string | null
32
+ rev_body_markdown?: string | null
33
+ }
34
+
35
+ export type ContinuityDriftField =
36
+ | 'title'
37
+ | 'description'
38
+ | 'featured'
39
+ | 'heroImage'
40
+ | 'visibility'
41
+ | 'channel'
42
+ | 'body'
43
+
44
+ export interface ContinuityDrift {
45
+ drifted: boolean
46
+ fields: ContinuityDriftField[]
47
+ title: string
48
+ description: string | null
49
+ /** `content_items.featured` is INTEGER; the archive item carries a boolean. */
50
+ featured: 0 | 1
51
+ heroImageId: string | null
52
+ visibility: 'free' | 'premium'
53
+ channel: string | null
54
+ body: string
55
+ bodyDrifted: boolean
56
+ }
57
+
58
+ /** An item the archive has taken down rather than published. */
59
+ export function isRetiredItem(item: ContinuityArchiveItem | null | undefined): boolean {
60
+ return item?.metadata?.originalStatus === 'archived'
61
+ }
62
+
63
+ /**
64
+ * Compare the fields a reader actually sees against the published revision.
65
+ *
66
+ * The site renders from the published revision payload, so an edit to any field
67
+ * this does not examine is classified "already in sync", skipped, and never
68
+ * reaches a reader — while the run reports success. That has stranded real work
69
+ * twice: fourteen restored article bodies, and a `featured` flag flip, both
70
+ * merged, both reporting `failed=0`, neither ever visible.
71
+ *
72
+ * Comparison is exact rather than a length or prefix heuristic. The entire class
73
+ * of bug being prevented is a cheap test that silently misses a real edit: a
74
+ * false "stale" costs one idempotent republish, a false "in sync" loses the edit.
75
+ *
76
+ * NOT COMPARED: tags. The revision stores them canonicalized and sorted while
77
+ * the archive carries raw frontmatter strings, and the engine exports no
78
+ * canonicalizer to reuse. Detecting a field this cannot also REPAIR would be
79
+ * worse than not detecting it — tag writes go through the engine, so a tag-only
80
+ * drift would republish unchanged and stay stale on every run, forever.
81
+ */
82
+ export function payloadDrift(
83
+ item: ContinuityArchiveItem,
84
+ row: ContinuityPublishedRow,
85
+ ): ContinuityDrift {
86
+ // The engine stores an absent body as a single space, so normalize the same
87
+ // way before comparing or the first resync of a body-less article would
88
+ // churn forever.
89
+ const body = item.bodyMarkdown ?? ' '
90
+ const title = String(item.title ?? '')
91
+ const description = item.description ?? null
92
+ const featured: 0 | 1 = item.featured ? 1 : 0
93
+ const heroImageId = item.heroImageId ?? null
94
+ const visibility: 'free' | 'premium' = item.visibility === 'premium' ? 'premium' : 'free'
95
+ const channel = item.primaryCategory ?? null
96
+
97
+ const fields: ContinuityDriftField[] = []
98
+ if (row.rev_title !== title) fields.push('title')
99
+ if ((row.rev_description ?? null) !== description) fields.push('description')
100
+ if (Number(row.rev_featured ?? 0) !== featured) fields.push('featured')
101
+ if ((row.rev_hero_image_id ?? null) !== heroImageId) fields.push('heroImage')
102
+ if ((row.rev_visibility ?? 'free') !== visibility) fields.push('visibility')
103
+ if ((row.rev_channel ?? null) !== channel) fields.push('channel')
104
+ // A revision predating this check can lack the key entirely; treat an absent
105
+ // body as drift rather than silently matching.
106
+ if ((row.rev_body_markdown ?? null) !== body) fields.push('body')
107
+
108
+ return {
109
+ drifted: fields.length > 0,
110
+ fields,
111
+ title,
112
+ description,
113
+ featured,
114
+ heroImageId,
115
+ visibility,
116
+ channel,
117
+ body,
118
+ bodyDrifted: fields.includes('body'),
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Whether a live probe response means the publication succeeded.
124
+ *
125
+ * LIVE → 200 only.
126
+ * RETIRED → 301/308 (redirected to a surviving twin) or 404 (gone, with no
127
+ * honest target to redirect to).
128
+ *
129
+ * The invariant: **a retired article must never answer 200.** That is the
130
+ * stale-row symptom a teardown exists to remove, and it is the only status this
131
+ * rejects for a retirement. Demanding 200 for retirements instead would fail the
132
+ * run forever on exactly the articles the archive deliberately took down.
133
+ *
134
+ * 301 and 308 are both permanent; a server may answer GET with one and other
135
+ * methods with the other, so both are accepted.
136
+ */
137
+ export function acceptsProbeStatus(status: number, retired: boolean): boolean {
138
+ if (!retired) return status === 200
139
+ return status === 301 || status === 308 || status === 404
140
+ }