@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,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,
|
|
@@ -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
|
+
}
|