@growth-labs/cms 0.1.2 → 0.2.0

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 (42) hide show
  1. package/README.md +146 -0
  2. package/dist/engine/index.d.ts +2 -1
  3. package/dist/engine/index.d.ts.map +1 -1
  4. package/dist/engine/index.js +4 -2
  5. package/dist/engine/index.js.map +1 -1
  6. package/dist/engine/publication.d.ts +210 -0
  7. package/dist/engine/publication.d.ts.map +1 -0
  8. package/dist/engine/publication.js +1436 -0
  9. package/dist/engine/publication.js.map +1 -0
  10. package/dist/engine/slug-redirects.d.ts +16 -0
  11. package/dist/engine/slug-redirects.d.ts.map +1 -1
  12. package/dist/engine/slug-redirects.js +34 -0
  13. package/dist/engine/slug-redirects.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/routes/content.d.ts.map +1 -1
  18. package/dist/routes/content.js +5 -3
  19. package/dist/routes/content.js.map +1 -1
  20. package/dist/schema/index.d.ts +1 -1
  21. package/dist/schema/index.d.ts.map +1 -1
  22. package/dist/schema/migrations.d.ts.map +1 -1
  23. package/dist/schema/migrations.js +28 -0
  24. package/dist/schema/migrations.js.map +1 -1
  25. package/dist/schema/tables.d.ts +1 -1
  26. package/dist/schema/tables.d.ts.map +1 -1
  27. package/dist/schema/tables.js +1 -0
  28. package/dist/schema/tables.js.map +1 -1
  29. package/dist/schema/types.d.ts +20 -0
  30. package/dist/schema/types.d.ts.map +1 -1
  31. package/dist/schema/types.js.map +1 -1
  32. package/migrations/0018_content_publication_attempts.sql +23 -0
  33. package/package.json +1 -1
  34. package/src/engine/index.ts +45 -2
  35. package/src/engine/publication.ts +2152 -0
  36. package/src/engine/slug-redirects.ts +42 -0
  37. package/src/index.ts +3 -0
  38. package/src/routes/content.ts +5 -3
  39. package/src/schema/index.ts +3 -0
  40. package/src/schema/migrations.ts +28 -0
  41. package/src/schema/tables.ts +1 -0
  42. package/src/schema/types.ts +31 -0
@@ -0,0 +1,2152 @@
1
+ import type {
2
+ ContentPublicationAttemptRow,
3
+ ContentPublicationAttemptState,
4
+ ContentType,
5
+ } from '../schema/types.js'
6
+ import type { D1Database, D1Result } from './d1.js'
7
+ import { evaluateArticleBody } from './publish-guard.js'
8
+ import {
9
+ type CreateContentInput,
10
+ createContent,
11
+ ensureUniqueSlug,
12
+ getContentItem,
13
+ getContentSnapshot,
14
+ PublishContentValidationError,
15
+ } from './publisher.js'
16
+ import { getRevision } from './revisions.js'
17
+ import { slugify } from './slug.js'
18
+
19
+ export type PublicationOperation = 'publish' | 'rollback'
20
+ export type PublicationSurfacePhase = 'prepare' | 'commit' | 'abort'
21
+ export type PublicationSurfaceStatus = 'ok' | 'skipped' | 'failed'
22
+ export type PublicationSurfaceCommitStatus = 'none' | 'committed' | 'pending_retry'
23
+
24
+ export interface PublicationSurfaceReceipt {
25
+ name: string
26
+ status: PublicationSurfaceStatus
27
+ phase?: PublicationSurfacePhase
28
+ message?: string
29
+ durationMs?: number
30
+ }
31
+
32
+ export interface PublicationReconciliationInput {
33
+ operation: PublicationOperation
34
+ contentId: string
35
+ revisionId: string
36
+ previousRevisionId: string | null
37
+ snapshot: Record<string, unknown>
38
+ now: number
39
+ packageVersion?: string | null
40
+ phase?: PublicationSurfacePhase
41
+ /** Aborted when the bounded surface-hook deadline expires. */
42
+ signal?: AbortSignal
43
+ }
44
+
45
+ export interface PublicationReconciler {
46
+ name: string
47
+ /**
48
+ * Prepare revision-addressed, non-public artifacts. This phase runs before the
49
+ * canonical pointer CAS, must be idempotent, and must not expose the target
50
+ * revision to readers.
51
+ */
52
+ prepare?(input: PublicationReconciliationInput): Promise<PublicationSurfaceReceipt | undefined>
53
+ /**
54
+ * Commit live surfaces after the canonical pointer moved. Implementations must
55
+ * be idempotent because a Worker can crash after the external commit but before
56
+ * its durable receipt is recorded.
57
+ */
58
+ commit?(input: PublicationReconciliationInput): Promise<PublicationSurfaceReceipt | undefined>
59
+ /** Abort prepared or committed artifacts after a failed prepare/CAS path. */
60
+ abort?(input: PublicationReconciliationInput): Promise<PublicationSurfaceReceipt | undefined>
61
+ /** @deprecated Unsupported by the durable publication contract; use commit. */
62
+ reconcile?(input: PublicationReconciliationInput): Promise<PublicationSurfaceReceipt | undefined>
63
+ }
64
+
65
+ export interface PublishOneInput {
66
+ contentId: string
67
+ now?: number
68
+ createdBy?: string | null
69
+ surfaces?: PublicationReconciler[]
70
+ allowNoSurfaces?: boolean
71
+ requiredSurfaceNames?: string[]
72
+ packageVersion?: string | null
73
+ surfaceHookTimeoutMs?: number
74
+ }
75
+
76
+ export interface PublishOneReceipt {
77
+ status: 'published'
78
+ attemptId: string
79
+ contentId: string
80
+ revisionId: string
81
+ previousRevisionId: string | null
82
+ publishedAt: number
83
+ surfaceStatus: PublicationSurfaceCommitStatus
84
+ surfaces: PublicationSurfaceReceipt[]
85
+ packageVersion: string | null
86
+ }
87
+
88
+ export interface RollbackPublicationInput {
89
+ contentId: string
90
+ targetRevisionId: string
91
+ now?: number
92
+ surfaces?: PublicationReconciler[]
93
+ allowNoSurfaces?: boolean
94
+ requiredSurfaceNames?: string[]
95
+ packageVersion?: string | null
96
+ surfaceHookTimeoutMs?: number
97
+ }
98
+
99
+ export interface RollbackPublicationReceipt {
100
+ status: 'rolled_back'
101
+ attemptId: string
102
+ contentId: string
103
+ revisionId: string
104
+ previousRevisionId: string | null
105
+ rolledBackAt: number
106
+ surfaceStatus: PublicationSurfaceCommitStatus
107
+ surfaces: PublicationSurfaceReceipt[]
108
+ packageVersion: string | null
109
+ }
110
+
111
+ export interface RetryPublicationSurfacesInput {
112
+ attemptId: string
113
+ now?: number
114
+ surfaces?: PublicationReconciler[]
115
+ surfaceHookTimeoutMs?: number
116
+ }
117
+
118
+ export interface RetryPublicationSurfacesReceipt {
119
+ status: 'reconciled' | 'pending_retry' | 'aborted' | 'abort_failed'
120
+ attemptId: string
121
+ contentId: string
122
+ revisionId: string
123
+ previousRevisionId: string | null
124
+ surfaceStatus: PublicationSurfaceCommitStatus
125
+ surfaces: PublicationSurfaceReceipt[]
126
+ packageVersion: string | null
127
+ }
128
+
129
+ export type PublicationAttemptState = ContentPublicationAttemptState
130
+
131
+ export interface PublicationAttempt {
132
+ id: string
133
+ operation: PublicationOperation
134
+ contentId: string
135
+ revisionId: string
136
+ previousRevisionId: string | null
137
+ state: PublicationAttemptState
138
+ surfaceNames: string[]
139
+ pendingSurfaceNames: string[]
140
+ preparedSurfaceNames: string[]
141
+ receipts: PublicationSurfaceReceipt[]
142
+ version: number
143
+ packageVersion: string | null
144
+ lastError: string | null
145
+ recoveryError?: 'invalid_durable_state'
146
+ createdAt: number
147
+ updatedAt: number
148
+ }
149
+
150
+ export interface ListPendingPublicationAttemptsOptions {
151
+ limit?: number
152
+ cursor?: {
153
+ updatedAt: number
154
+ id: string
155
+ }
156
+ }
157
+
158
+ export class PublicationReconciliationError extends Error {
159
+ readonly receipts: PublicationSurfaceReceipt[]
160
+ readonly attemptId: string | null
161
+
162
+ constructor(
163
+ message: string,
164
+ receipts: PublicationSurfaceReceipt[],
165
+ attemptId: string | null = null,
166
+ ) {
167
+ super(message)
168
+ this.name = 'PublicationReconciliationError'
169
+ this.receipts = receipts
170
+ this.attemptId = attemptId
171
+ }
172
+ }
173
+
174
+ export class PublicationSurfaceRequirementError extends Error {
175
+ constructor(message: string) {
176
+ super(message)
177
+ this.name = 'PublicationSurfaceRequirementError'
178
+ }
179
+ }
180
+
181
+ export class PublicationPointerMoveError extends Error {
182
+ readonly receipts: PublicationSurfaceReceipt[]
183
+ readonly attemptId: string | null
184
+
185
+ constructor(
186
+ message: string,
187
+ receipts: PublicationSurfaceReceipt[] = [],
188
+ attemptId: string | null = null,
189
+ ) {
190
+ super(message)
191
+ this.name = 'PublicationPointerMoveError'
192
+ this.receipts = receipts
193
+ this.attemptId = attemptId
194
+ }
195
+ }
196
+
197
+ export class PublicationNotFoundError extends Error {
198
+ constructor(message: string) {
199
+ super(message)
200
+ this.name = 'PublicationNotFoundError'
201
+ }
202
+ }
203
+
204
+ class PublicationAttemptConflictError extends Error {
205
+ readonly current: PublicationAttempt
206
+
207
+ constructor(current: PublicationAttempt) {
208
+ super(`publication attempt advanced concurrently to ${current.state}`)
209
+ this.name = 'PublicationAttemptConflictError'
210
+ this.current = current
211
+ }
212
+ }
213
+
214
+ class PublicationAttemptSupersededError extends PublicationPointerMoveError {
215
+ constructor(attempt: PublicationAttempt) {
216
+ super(
217
+ 'publication attempt was superseded by a newer canonical revision',
218
+ attempt.receipts,
219
+ attempt.id,
220
+ )
221
+ this.name = 'PublicationAttemptSupersededError'
222
+ }
223
+ }
224
+
225
+ export interface ContentArchiveInput {
226
+ schema?: string
227
+ source?: string
228
+ items?: ContentArchiveItem[]
229
+ }
230
+
231
+ export interface ContentArchiveItem {
232
+ type?: ContentType
233
+ slug?: string
234
+ title?: string
235
+ excerpt?: string | null
236
+ primaryCategory?: string | null
237
+ primaryTopic?: string | null
238
+ tags?: string[]
239
+ bodyMarkdown?: string
240
+ }
241
+
242
+ export interface ImportContentArchiveOptions {
243
+ defaultPrimaryCategory?: string | null
244
+ defaultPrimaryTopic?: string | null
245
+ cursor?: number | string | null
246
+ batchSize?: number
247
+ maxBatchItems?: number
248
+ maxArchiveItems?: number
249
+ maxBodyBytes?: number
250
+ }
251
+
252
+ export interface ImportedArchiveItem {
253
+ id: string
254
+ slug: string
255
+ type: 'article' | 'newsletter' | 'page'
256
+ }
257
+
258
+ export interface SkippedArchiveItem {
259
+ index: number
260
+ reason: string
261
+ title: string | null
262
+ slug?: string | null
263
+ existingId?: string | null
264
+ }
265
+
266
+ export interface ImportContentArchiveResult {
267
+ created: ImportedArchiveItem[]
268
+ skipped: SkippedArchiveItem[]
269
+ processed: number
270
+ cursor: number
271
+ nextCursor: number | null
272
+ done: boolean
273
+ checkpoint: {
274
+ cursor: number
275
+ nextCursor: number | null
276
+ batchSize: number
277
+ totalItems: number
278
+ }
279
+ }
280
+
281
+ export class ContentArchiveImportLimitError extends Error {
282
+ readonly code: string
283
+ readonly limit: number
284
+ readonly actual: number
285
+
286
+ constructor(code: string, message: string, limit: number, actual: number) {
287
+ super(message)
288
+ this.name = 'ContentArchiveImportLimitError'
289
+ this.code = code
290
+ this.limit = limit
291
+ this.actual = actual
292
+ }
293
+ }
294
+
295
+ function nowSeconds(): number {
296
+ return Math.floor(Date.now() / 1000)
297
+ }
298
+
299
+ export const DEFAULT_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS = 10_000
300
+ export const MAX_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS = 30_000
301
+ export const DEFAULT_PENDING_PUBLICATION_ATTEMPT_LIMIT = 100
302
+ export const MAX_PENDING_PUBLICATION_ATTEMPT_LIMIT = 500
303
+ const MAX_PUBLICATION_ATTEMPT_JSON_BYTES = 64 * 1024
304
+
305
+ function normalizeSurfaceHookTimeoutMs(value: number | undefined): number {
306
+ const timeoutMs = value ?? DEFAULT_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS
307
+ if (
308
+ !Number.isSafeInteger(timeoutMs) ||
309
+ timeoutMs <= 0 ||
310
+ timeoutMs > MAX_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS
311
+ ) {
312
+ throw new PublicationSurfaceRequirementError(
313
+ `surfaceHookTimeoutMs must be an integer between 1 and ${MAX_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS}`,
314
+ )
315
+ }
316
+ return timeoutMs
317
+ }
318
+
319
+ function d1Changes(result: D1Result): number | null {
320
+ const changes = result.meta?.changes
321
+ return typeof changes === 'number' ? changes : null
322
+ }
323
+
324
+ const DURABLE_DIAGNOSTIC_MAX_LENGTH = 64
325
+ const DURABLE_DIAGNOSTICS = [
326
+ 'abort hook not implemented',
327
+ 'abort_hook_not_implemented',
328
+ 'canonical_pointer_advanced',
329
+ 'canonical_pointer_move_abort_incomplete',
330
+ 'canonical_pointer_move_failed',
331
+ 'invalid_surface_receipt_duration',
332
+ 'invalid_surface_receipt_message',
333
+ 'invalid_surface_receipt_name',
334
+ 'invalid_surface_receipt_object',
335
+ 'invalid surface receipt status',
336
+ 'invalid_durable_state',
337
+ 'surface abort failed',
338
+ 'surface_abort_incomplete',
339
+ 'surface_commit_failed',
340
+ 'surface_hook_failed',
341
+ 'surface_hook_skipped',
342
+ 'surface_hook_timed_out',
343
+ 'surface_hook_threw',
344
+ 'surface_prepare_failed',
345
+ 'untrusted_diagnostic_redacted',
346
+ ] as const
347
+ type DurableDiagnostic = (typeof DURABLE_DIAGNOSTICS)[number]
348
+ const DURABLE_DIAGNOSTIC_SET = new Set<string>(DURABLE_DIAGNOSTICS)
349
+
350
+ function durableDiagnostic(code: DurableDiagnostic): string {
351
+ if (code.length > DURABLE_DIAGNOSTIC_MAX_LENGTH) {
352
+ throw new PublicationReconciliationError('durable diagnostic exceeds its fixed bound', [])
353
+ }
354
+ return code
355
+ }
356
+
357
+ function normalizeStoredDiagnostic(value: string): string {
358
+ return value.length <= DURABLE_DIAGNOSTIC_MAX_LENGTH && DURABLE_DIAGNOSTIC_SET.has(value)
359
+ ? value
360
+ : durableDiagnostic('untrusted_diagnostic_redacted')
361
+ }
362
+
363
+ function parseStringArray(value: string, field: string): string[] {
364
+ let parsed: unknown
365
+ try {
366
+ parsed = JSON.parse(value)
367
+ } catch {
368
+ throw new PublicationReconciliationError(`publication attempt has invalid ${field}`, [])
369
+ }
370
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string')) {
371
+ throw new PublicationReconciliationError(`publication attempt has invalid ${field}`, [])
372
+ }
373
+ return parsed
374
+ }
375
+
376
+ function parseReceipts(value: string): PublicationSurfaceReceipt[] {
377
+ let parsed: unknown
378
+ try {
379
+ parsed = JSON.parse(value)
380
+ } catch {
381
+ throw new PublicationReconciliationError('publication attempt has invalid receipts_json', [])
382
+ }
383
+ if (!Array.isArray(parsed)) {
384
+ throw new PublicationReconciliationError('publication attempt has invalid receipts_json', [])
385
+ }
386
+ const receipts: PublicationSurfaceReceipt[] = []
387
+ for (const receipt of parsed) {
388
+ const candidate = receipt as {
389
+ name?: unknown
390
+ status?: unknown
391
+ phase?: unknown
392
+ message?: unknown
393
+ durationMs?: unknown
394
+ }
395
+ if (
396
+ typeof receipt !== 'object' ||
397
+ receipt === null ||
398
+ typeof candidate.name !== 'string' ||
399
+ (candidate.status !== 'ok' &&
400
+ candidate.status !== 'skipped' &&
401
+ candidate.status !== 'failed') ||
402
+ (candidate.phase !== undefined &&
403
+ candidate.phase !== 'prepare' &&
404
+ candidate.phase !== 'commit' &&
405
+ candidate.phase !== 'abort') ||
406
+ (candidate.message !== undefined && typeof candidate.message !== 'string') ||
407
+ (candidate.durationMs !== undefined &&
408
+ (typeof candidate.durationMs !== 'number' ||
409
+ !Number.isFinite(candidate.durationMs) ||
410
+ candidate.durationMs < 0))
411
+ ) {
412
+ throw new PublicationReconciliationError('publication attempt has invalid receipts_json', [])
413
+ }
414
+ receipts.push({
415
+ name: candidate.name,
416
+ status: candidate.status,
417
+ phase: candidate.phase,
418
+ message:
419
+ typeof candidate.message === 'string'
420
+ ? normalizeStoredDiagnostic(candidate.message)
421
+ : undefined,
422
+ durationMs: candidate.durationMs,
423
+ })
424
+ }
425
+ return receipts
426
+ }
427
+
428
+ function compactPublicationReceipts(
429
+ receipts: readonly PublicationSurfaceReceipt[],
430
+ ): PublicationSurfaceReceipt[] {
431
+ const compacted: PublicationSurfaceReceipt[] = []
432
+ const indexes = new Map<string, number>()
433
+ for (const receipt of receipts) {
434
+ const key = JSON.stringify([receipt.name, receipt.phase ?? null])
435
+ const existingIndex = indexes.get(key)
436
+ if (existingIndex === undefined) {
437
+ indexes.set(key, compacted.length)
438
+ compacted.push(receipt)
439
+ } else {
440
+ compacted[existingIndex] = receipt
441
+ }
442
+ }
443
+ return compacted
444
+ }
445
+
446
+ function decodeAttempt(row: ContentPublicationAttemptRow): PublicationAttempt {
447
+ return {
448
+ id: row.id,
449
+ operation: row.operation,
450
+ contentId: row.content_id,
451
+ revisionId: row.revision_id,
452
+ previousRevisionId: row.previous_revision_id,
453
+ state: row.state,
454
+ surfaceNames: parseStringArray(row.surface_names_json, 'surface_names_json'),
455
+ pendingSurfaceNames: parseStringArray(
456
+ row.pending_surface_names_json,
457
+ 'pending_surface_names_json',
458
+ ),
459
+ preparedSurfaceNames: parseStringArray(
460
+ row.prepared_surface_names_json,
461
+ 'prepared_surface_names_json',
462
+ ),
463
+ receipts: parseReceipts(row.receipts_json),
464
+ version: row.version,
465
+ packageVersion: row.package_version,
466
+ lastError: row.last_error === null ? null : normalizeStoredDiagnostic(row.last_error),
467
+ createdAt: row.created_at,
468
+ updatedAt: row.updated_at,
469
+ }
470
+ }
471
+
472
+ function recoverStringArray(value: string): string[] {
473
+ try {
474
+ const parsed: unknown = JSON.parse(value)
475
+ return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : []
476
+ } catch {
477
+ return []
478
+ }
479
+ }
480
+
481
+ function decodeAttemptForEnumeration(row: ContentPublicationAttemptRow): PublicationAttempt {
482
+ try {
483
+ return decodeAttempt(row)
484
+ } catch {
485
+ const diagnostic = durableDiagnostic('invalid_durable_state')
486
+ return {
487
+ id: row.id,
488
+ operation: row.operation,
489
+ contentId: row.content_id,
490
+ revisionId: row.revision_id,
491
+ previousRevisionId: row.previous_revision_id,
492
+ state: row.state,
493
+ surfaceNames: recoverStringArray(row.surface_names_json),
494
+ pendingSurfaceNames: recoverStringArray(row.pending_surface_names_json),
495
+ preparedSurfaceNames: recoverStringArray(row.prepared_surface_names_json),
496
+ receipts: [{ name: 'publication_attempt', status: 'failed', message: diagnostic }],
497
+ version: row.version,
498
+ packageVersion: row.package_version,
499
+ lastError: diagnostic,
500
+ recoveryError: 'invalid_durable_state',
501
+ createdAt: row.created_at,
502
+ updatedAt: row.updated_at,
503
+ }
504
+ }
505
+ }
506
+
507
+ async function getPublicationAttempt(
508
+ db: D1Database,
509
+ attemptId: string,
510
+ ): Promise<PublicationAttempt> {
511
+ const row = await db
512
+ .prepare(
513
+ `SELECT id, operation, content_id, revision_id, previous_revision_id, state,
514
+ surface_names_json, pending_surface_names_json,
515
+ prepared_surface_names_json, receipts_json, version,
516
+ package_version, last_error, created_at, updated_at
517
+ FROM content_publication_attempts
518
+ WHERE id = ?`,
519
+ )
520
+ .bind(attemptId)
521
+ .first<ContentPublicationAttemptRow>()
522
+ if (!row) throw new PublicationNotFoundError(`publication attempt not found: ${attemptId}`)
523
+ return decodeAttempt(row)
524
+ }
525
+
526
+ export async function listPendingPublicationAttempts(
527
+ db: D1Database,
528
+ options: ListPendingPublicationAttemptsOptions = {},
529
+ ): Promise<PublicationAttempt[]> {
530
+ const limit = options.limit ?? DEFAULT_PENDING_PUBLICATION_ATTEMPT_LIMIT
531
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_PENDING_PUBLICATION_ATTEMPT_LIMIT) {
532
+ throw new PublicationSurfaceRequirementError(
533
+ `pending publication attempt limit must be an integer between 1 and ${MAX_PENDING_PUBLICATION_ATTEMPT_LIMIT}`,
534
+ )
535
+ }
536
+ const cursor = options.cursor
537
+ if (
538
+ cursor &&
539
+ (!Number.isSafeInteger(cursor.updatedAt) ||
540
+ cursor.updatedAt < 0 ||
541
+ typeof cursor.id !== 'string' ||
542
+ cursor.id.length === 0)
543
+ ) {
544
+ throw new PublicationSurfaceRequirementError(
545
+ 'pending publication attempt cursor must contain a non-negative updatedAt and id',
546
+ )
547
+ }
548
+ const cursorClause = cursor ? 'AND (updated_at > ? OR (updated_at = ? AND id > ?))' : ''
549
+ const result = await db
550
+ .prepare(
551
+ `SELECT id, operation, content_id, revision_id, previous_revision_id, state,
552
+ CASE WHEN length(CAST(surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
553
+ THEN surface_names_json ELSE 'null' END AS surface_names_json,
554
+ CASE WHEN length(CAST(pending_surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
555
+ THEN pending_surface_names_json ELSE 'null' END AS pending_surface_names_json,
556
+ CASE WHEN length(CAST(prepared_surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
557
+ THEN prepared_surface_names_json ELSE 'null' END AS prepared_surface_names_json,
558
+ CASE WHEN length(CAST(receipts_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
559
+ THEN receipts_json ELSE 'null' END AS receipts_json,
560
+ version,
561
+ package_version, last_error, created_at, updated_at
562
+ FROM content_publication_attempts
563
+ WHERE (
564
+ state IN ('preparing', 'prepared', 'aborting', 'abort_failed')
565
+ OR (
566
+ state IN ('committing', 'pending_retry')
567
+ AND EXISTS (
568
+ SELECT 1 FROM content_items
569
+ WHERE content_items.id = content_publication_attempts.content_id
570
+ AND content_items.published_revision_id = content_publication_attempts.revision_id
571
+ )
572
+ )
573
+ )
574
+ ${cursorClause}
575
+ ORDER BY updated_at ASC, id ASC
576
+ LIMIT ?`,
577
+ )
578
+ .bind(...(cursor ? [cursor.updatedAt, cursor.updatedAt, cursor.id, limit] : [limit]))
579
+ .all<ContentPublicationAttemptRow>()
580
+ return (result.results ?? []).map(decodeAttemptForEnumeration)
581
+ }
582
+
583
+ async function createPublicationAttempt(
584
+ db: D1Database,
585
+ input: {
586
+ operation: PublicationOperation
587
+ contentId: string
588
+ revisionId: string
589
+ previousRevisionId: string | null
590
+ surfaceNames: string[]
591
+ packageVersion: string | null
592
+ now: number
593
+ },
594
+ ): Promise<PublicationAttempt> {
595
+ const id = crypto.randomUUID()
596
+ await publicationAttemptInsert(db, id, input).run()
597
+ return getPublicationAttempt(db, id)
598
+ }
599
+
600
+ function publicationAttemptInsert(
601
+ db: D1Database,
602
+ id: string,
603
+ input: {
604
+ operation: PublicationOperation
605
+ contentId: string
606
+ revisionId: string
607
+ previousRevisionId: string | null
608
+ surfaceNames: string[]
609
+ packageVersion: string | null
610
+ now: number
611
+ },
612
+ ) {
613
+ return db
614
+ .prepare(
615
+ `INSERT INTO content_publication_attempts (
616
+ id, operation, content_id, revision_id, previous_revision_id, state,
617
+ surface_names_json, pending_surface_names_json,
618
+ prepared_surface_names_json, receipts_json,
619
+ package_version, created_at, updated_at
620
+ ) VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)`,
621
+ )
622
+ .bind(
623
+ id,
624
+ input.operation,
625
+ input.contentId,
626
+ input.revisionId,
627
+ input.previousRevisionId,
628
+ JSON.stringify(input.surfaceNames),
629
+ JSON.stringify(input.surfaceNames),
630
+ input.packageVersion,
631
+ input.now,
632
+ input.now,
633
+ )
634
+ }
635
+
636
+ async function persistPublicationAttempt(
637
+ db: D1Database,
638
+ input: {
639
+ attemptId: string
640
+ expectedVersion: number
641
+ expectedStates: PublicationAttemptState[]
642
+ state: PublicationAttemptState
643
+ pendingSurfaceNames: string[]
644
+ preparedSurfaceNames: string[]
645
+ receipts: PublicationSurfaceReceipt[]
646
+ lastError?: string | null
647
+ now: number
648
+ },
649
+ ): Promise<PublicationAttempt> {
650
+ if (input.expectedStates.length === 0) {
651
+ throw new PublicationAttemptConflictError(await getPublicationAttempt(db, input.attemptId))
652
+ }
653
+ const receipts = compactPublicationReceipts(input.receipts)
654
+ const expectedStatePlaceholders = input.expectedStates.map(() => '?').join(', ')
655
+ const result = await db
656
+ .prepare(
657
+ `UPDATE content_publication_attempts
658
+ SET state = ?, pending_surface_names_json = ?, prepared_surface_names_json = ?,
659
+ receipts_json = ?, last_error = ?, updated_at = ?, version = version + 1
660
+ WHERE id = ? AND version = ? AND state IN (${expectedStatePlaceholders})`,
661
+ )
662
+ .bind(
663
+ input.state,
664
+ JSON.stringify(input.pendingSurfaceNames),
665
+ JSON.stringify(input.preparedSurfaceNames),
666
+ JSON.stringify(receipts),
667
+ input.lastError ?? null,
668
+ input.now,
669
+ input.attemptId,
670
+ input.expectedVersion,
671
+ ...input.expectedStates,
672
+ )
673
+ .run()
674
+ const changes = d1Changes(result)
675
+ if (changes !== null && changes !== 1) {
676
+ throw new PublicationAttemptConflictError(await getPublicationAttempt(db, input.attemptId))
677
+ }
678
+ const current = await getPublicationAttempt(db, input.attemptId)
679
+ const matchesIntendedWrite =
680
+ current.version === input.expectedVersion + 1 &&
681
+ current.state === input.state &&
682
+ JSON.stringify(current.pendingSurfaceNames) === JSON.stringify(input.pendingSurfaceNames) &&
683
+ JSON.stringify(current.preparedSurfaceNames) === JSON.stringify(input.preparedSurfaceNames) &&
684
+ JSON.stringify(current.receipts) === JSON.stringify(receipts) &&
685
+ current.lastError === (input.lastError ?? null)
686
+ if (!matchesIntendedWrite) throw new PublicationAttemptConflictError(current)
687
+ return current
688
+ }
689
+
690
+ async function createRevisionAndPublicationAttempt(
691
+ db: D1Database,
692
+ input: {
693
+ contentId: string
694
+ previousRevisionId: string | null
695
+ surfaceNames: string[]
696
+ packageVersion: string | null
697
+ now: number
698
+ createdBy?: string | null
699
+ },
700
+ ): Promise<{
701
+ revisionId: string
702
+ snapshot: Record<string, unknown>
703
+ attempt: PublicationAttempt
704
+ }> {
705
+ const snapshot = await getContentSnapshot(db, input.contentId)
706
+ if (!snapshot) throw new PublicationNotFoundError(`content not found: ${input.contentId}`)
707
+ assertPublicationSnapshotBodyValid(snapshot, input.contentId)
708
+ const revisionId = crypto.randomUUID()
709
+ const attemptId = crypto.randomUUID()
710
+ await db.batch([
711
+ db
712
+ .prepare(
713
+ 'INSERT INTO content_revisions (id, content_id, payload_json, created_at, created_by) VALUES (?, ?, ?, ?, ?)',
714
+ )
715
+ .bind(
716
+ revisionId,
717
+ input.contentId,
718
+ JSON.stringify(snapshot),
719
+ input.now,
720
+ input.createdBy ?? null,
721
+ ),
722
+ publicationAttemptInsert(db, attemptId, {
723
+ operation: 'publish',
724
+ contentId: input.contentId,
725
+ revisionId,
726
+ previousRevisionId: input.previousRevisionId,
727
+ surfaceNames: input.surfaceNames,
728
+ packageVersion: input.packageVersion,
729
+ now: input.now,
730
+ }),
731
+ ])
732
+ return { revisionId, snapshot, attempt: await getPublicationAttempt(db, attemptId) }
733
+ }
734
+
735
+ function assertPublicationSnapshotBodyValid(
736
+ snapshot: Record<string, unknown>,
737
+ contentId: string,
738
+ ): void {
739
+ const item = snapshot.item
740
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return
741
+ const type = (item as { type?: unknown }).type
742
+ if (type !== 'article' && type !== 'newsletter' && type !== 'page') return
743
+ const content = snapshot.content
744
+ const body = content && typeof content === 'object' && !Array.isArray(content) ? content : {}
745
+ const bodyMarkdown = (body as { body_markdown?: unknown }).body_markdown
746
+ const bodyHtml = (body as { body_html?: unknown }).body_html
747
+ const outcome = evaluateArticleBody(
748
+ {
749
+ body_markdown: typeof bodyMarkdown === 'string' ? bodyMarkdown : null,
750
+ body_html: typeof bodyHtml === 'string' ? bodyHtml : null,
751
+ },
752
+ {
753
+ source: 'publish-one',
754
+ contentId,
755
+ requireBody: true,
756
+ },
757
+ )
758
+ if (!outcome.ok) throw new PublishContentValidationError(outcome)
759
+ }
760
+
761
+ function normalizeSurfaces(input: {
762
+ surfaces?: PublicationReconciler[]
763
+ allowNoSurfaces?: boolean
764
+ requiredSurfaceNames?: string[]
765
+ }): PublicationReconciler[] {
766
+ if (!Array.isArray(input.surfaces)) {
767
+ throw new PublicationSurfaceRequirementError(
768
+ 'publication surfaces must be provided explicitly; pass allowNoSurfaces for no-live-surface mode',
769
+ )
770
+ }
771
+ if (input.surfaces.length === 0) {
772
+ if (input.allowNoSurfaces) return []
773
+ throw new PublicationSurfaceRequirementError(
774
+ 'publication surfaces cannot be empty unless allowNoSurfaces is true',
775
+ )
776
+ }
777
+ const names = new Set<string>()
778
+ for (const surface of input.surfaces) {
779
+ const name = surface.name?.trim()
780
+ if (!name)
781
+ throw new PublicationSurfaceRequirementError('publication surface names are required')
782
+ if (surface.name !== name) {
783
+ throw new PublicationSurfaceRequirementError(
784
+ 'publication surface names must not contain surrounding whitespace',
785
+ )
786
+ }
787
+ if (names.has(name)) {
788
+ throw new PublicationSurfaceRequirementError(`duplicate publication surface: ${name}`)
789
+ }
790
+ if (typeof surface.prepare !== 'function') {
791
+ throw new PublicationSurfaceRequirementError(
792
+ `publication surface ${name} must implement prepare`,
793
+ )
794
+ }
795
+ if (typeof surface.commit !== 'function') {
796
+ throw new PublicationSurfaceRequirementError(
797
+ `publication surface ${name} must implement commit`,
798
+ )
799
+ }
800
+ names.add(name)
801
+ }
802
+ for (const requiredName of input.requiredSurfaceNames ?? []) {
803
+ if (!names.has(requiredName)) {
804
+ throw new PublicationSurfaceRequirementError(
805
+ `required publication surface missing: ${requiredName}`,
806
+ )
807
+ }
808
+ }
809
+ return input.surfaces
810
+ }
811
+
812
+ function normalizeRetrySurfaces(
813
+ surfaces: PublicationReconciler[] | undefined,
814
+ pendingSurfaceNames: string[],
815
+ ): PublicationReconciler[] {
816
+ if (pendingSurfaceNames.length === 0) {
817
+ if (surfaces && surfaces.length > 0) {
818
+ throw new PublicationSurfaceRequirementError(
819
+ 'completed publication attempts cannot accept retry surfaces',
820
+ )
821
+ }
822
+ return []
823
+ }
824
+ const normalized = normalizeSurfaces({
825
+ surfaces,
826
+ requiredSurfaceNames: pendingSurfaceNames,
827
+ })
828
+ const pending = new Set(pendingSurfaceNames)
829
+ for (const surface of normalized) {
830
+ if (!pending.has(surface.name)) {
831
+ throw new PublicationSurfaceRequirementError(
832
+ `publication retry surface is not pending: ${surface.name}`,
833
+ )
834
+ }
835
+ }
836
+ return normalized
837
+ }
838
+
839
+ function withPhase(
840
+ input: PublicationReconciliationInput,
841
+ phase: PublicationSurfacePhase,
842
+ signal?: AbortSignal,
843
+ ): PublicationReconciliationInput {
844
+ return { ...input, phase, signal }
845
+ }
846
+
847
+ class PublicationSurfaceHookTimeoutError extends Error {
848
+ constructor() {
849
+ super('publication surface hook timed out')
850
+ this.name = 'PublicationSurfaceHookTimeoutError'
851
+ }
852
+ }
853
+
854
+ async function runSurfaceHook<T>(
855
+ hook: (input: PublicationReconciliationInput) => Promise<T>,
856
+ input: PublicationReconciliationInput,
857
+ phase: PublicationSurfacePhase,
858
+ timeoutMs: number,
859
+ ): Promise<T> {
860
+ const controller = new AbortController()
861
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
862
+ const timeout = new Promise<never>((_, reject) => {
863
+ timeoutId = setTimeout(() => {
864
+ controller.abort('publication_surface_hook_timeout')
865
+ reject(new PublicationSurfaceHookTimeoutError())
866
+ }, timeoutMs)
867
+ })
868
+ try {
869
+ return await Promise.race([hook(withPhase(input, phase, controller.signal)), timeout])
870
+ } finally {
871
+ if (timeoutId !== undefined) clearTimeout(timeoutId)
872
+ }
873
+ }
874
+
875
+ function normalizeReceipt(
876
+ surface: PublicationReconciler,
877
+ phase: PublicationSurfacePhase,
878
+ receipt: PublicationSurfaceReceipt | undefined,
879
+ started: number,
880
+ ): PublicationSurfaceReceipt {
881
+ const durationMs = Date.now() - started
882
+ if (receipt === undefined) {
883
+ return { name: surface.name, status: 'ok', phase, durationMs }
884
+ }
885
+ if (typeof receipt !== 'object' || receipt === null) {
886
+ return {
887
+ name: surface.name,
888
+ status: 'failed',
889
+ phase,
890
+ message: durableDiagnostic('invalid_surface_receipt_object'),
891
+ durationMs,
892
+ }
893
+ }
894
+ if (receipt.status !== 'ok' && receipt.status !== 'skipped' && receipt.status !== 'failed') {
895
+ return {
896
+ name: surface.name,
897
+ status: 'failed',
898
+ phase,
899
+ message: durableDiagnostic('invalid surface receipt status'),
900
+ durationMs,
901
+ }
902
+ }
903
+ if (receipt.name !== surface.name) {
904
+ return {
905
+ name: surface.name,
906
+ status: 'failed',
907
+ phase,
908
+ message: durableDiagnostic('invalid_surface_receipt_name'),
909
+ durationMs,
910
+ }
911
+ }
912
+ if (receipt.message !== undefined && typeof receipt.message !== 'string') {
913
+ return {
914
+ name: surface.name,
915
+ status: 'failed',
916
+ phase,
917
+ message: durableDiagnostic('invalid_surface_receipt_message'),
918
+ durationMs,
919
+ }
920
+ }
921
+ const suppliedDuration = receipt.durationMs
922
+ if (
923
+ suppliedDuration !== undefined &&
924
+ (typeof suppliedDuration !== 'number' ||
925
+ !Number.isFinite(suppliedDuration) ||
926
+ suppliedDuration < 0)
927
+ ) {
928
+ return {
929
+ name: surface.name,
930
+ status: 'failed',
931
+ phase,
932
+ message: durableDiagnostic('invalid_surface_receipt_duration'),
933
+ durationMs,
934
+ }
935
+ }
936
+ return {
937
+ name: surface.name,
938
+ status: receipt.status,
939
+ phase,
940
+ message:
941
+ receipt.status === 'failed'
942
+ ? durableDiagnostic('surface_hook_failed')
943
+ : receipt.status === 'skipped'
944
+ ? durableDiagnostic('surface_hook_skipped')
945
+ : undefined,
946
+ durationMs: suppliedDuration ?? durationMs,
947
+ }
948
+ }
949
+
950
+ async function prepareSurfaces(
951
+ db: D1Database,
952
+ attempt: PublicationAttempt,
953
+ surfaces: PublicationReconciler[],
954
+ input: PublicationReconciliationInput,
955
+ surfaceHookTimeoutMs: number,
956
+ ): Promise<{
957
+ attempt: PublicationAttempt
958
+ receipts: PublicationSurfaceReceipt[]
959
+ prepared: PublicationReconciler[]
960
+ }> {
961
+ let durable = attempt
962
+ let receipts: PublicationSurfaceReceipt[] = [...durable.receipts]
963
+ let preparedNames = new Set(durable.preparedSurfaceNames)
964
+ surfaceLoop: for (const surface of surfaces) {
965
+ if (preparedNames.has(surface.name)) continue
966
+ if (durable.state !== 'preparing') {
967
+ throw new PublicationReconciliationError(
968
+ `publication attempt advanced concurrently to ${durable.state}`,
969
+ durable.receipts,
970
+ durable.id,
971
+ )
972
+ }
973
+ const started = Date.now()
974
+ let normalized: PublicationSurfaceReceipt
975
+ try {
976
+ normalized = normalizeReceipt(
977
+ surface,
978
+ 'prepare',
979
+ await runSurfaceHook(
980
+ surface.prepare as NonNullable<PublicationReconciler['prepare']>,
981
+ input,
982
+ 'prepare',
983
+ surfaceHookTimeoutMs,
984
+ ),
985
+ started,
986
+ )
987
+ } catch (error) {
988
+ normalized = {
989
+ name: surface.name,
990
+ status: 'failed',
991
+ phase: 'prepare',
992
+ message: durableDiagnostic(
993
+ error instanceof PublicationSurfaceHookTimeoutError
994
+ ? 'surface_hook_timed_out'
995
+ : 'surface_hook_threw',
996
+ ),
997
+ durationMs: Date.now() - started,
998
+ }
999
+ }
1000
+ receipts = [...durable.receipts, normalized]
1001
+ if (normalized.status !== 'ok') {
1002
+ let cleanupNames = new Set([...preparedNames, surface.name])
1003
+ let claimed: PublicationAttempt
1004
+ for (;;) {
1005
+ try {
1006
+ claimed = await persistPublicationAttempt(db, {
1007
+ attemptId: durable.id,
1008
+ expectedVersion: durable.version,
1009
+ expectedStates: ['preparing'],
1010
+ state: 'aborting',
1011
+ pendingSurfaceNames: [],
1012
+ preparedSurfaceNames: [...cleanupNames],
1013
+ receipts,
1014
+ lastError: durableDiagnostic('surface_prepare_failed'),
1015
+ now: input.now,
1016
+ })
1017
+ break
1018
+ } catch (error) {
1019
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1020
+ durable = error.current
1021
+ if (durable.preparedSurfaceNames.includes(surface.name)) {
1022
+ receipts = [...durable.receipts]
1023
+ preparedNames = new Set(durable.preparedSurfaceNames)
1024
+ continue surfaceLoop
1025
+ }
1026
+ if (durable.state !== 'preparing') {
1027
+ throw new PublicationReconciliationError(
1028
+ `publication attempt advanced concurrently to ${durable.state}`,
1029
+ durable.receipts,
1030
+ durable.id,
1031
+ )
1032
+ }
1033
+ preparedNames = new Set(durable.preparedSurfaceNames)
1034
+ cleanupNames = new Set([...preparedNames, surface.name])
1035
+ receipts = [...durable.receipts, normalized]
1036
+ }
1037
+ }
1038
+ const prepared = surfaces.filter((candidate) =>
1039
+ claimed.preparedSurfaceNames.includes(candidate.name),
1040
+ )
1041
+ const abortReceipts = await abortSurfaces(prepared, input, surfaceHookTimeoutMs)
1042
+ const allReceipts = [...receipts, ...abortReceipts]
1043
+ const incompleteAborts = abortReceipts.filter((receipt) => receipt.status !== 'ok')
1044
+ const lastError =
1045
+ incompleteAborts.length > 0
1046
+ ? abortFailureDiagnostic(incompleteAborts, 'surface_abort_incomplete')
1047
+ : durableDiagnostic('surface_prepare_failed')
1048
+ let finalized: PublicationAttempt
1049
+ try {
1050
+ finalized = await persistPublicationAttempt(db, {
1051
+ attemptId: claimed.id,
1052
+ expectedVersion: claimed.version,
1053
+ expectedStates: ['aborting'],
1054
+ state: incompleteAborts.length > 0 ? 'abort_failed' : 'aborted',
1055
+ pendingSurfaceNames: [],
1056
+ preparedSurfaceNames: incompleteAborts.map((receipt) => receipt.name),
1057
+ receipts: allReceipts,
1058
+ lastError,
1059
+ now: input.now,
1060
+ })
1061
+ } catch (error) {
1062
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1063
+ finalized = error.current
1064
+ }
1065
+ throw new PublicationReconciliationError(
1066
+ `publication surface did not prepare: ${surface.name}`,
1067
+ finalized.receipts,
1068
+ finalized.id,
1069
+ )
1070
+ }
1071
+ preparedNames.add(surface.name)
1072
+ for (;;) {
1073
+ try {
1074
+ durable = await persistPublicationAttempt(db, {
1075
+ attemptId: durable.id,
1076
+ expectedVersion: durable.version,
1077
+ expectedStates: ['preparing'],
1078
+ state: 'preparing',
1079
+ pendingSurfaceNames: durable.pendingSurfaceNames,
1080
+ preparedSurfaceNames: [...preparedNames],
1081
+ receipts,
1082
+ now: input.now,
1083
+ })
1084
+ receipts = [...durable.receipts]
1085
+ preparedNames = new Set(durable.preparedSurfaceNames)
1086
+ break
1087
+ } catch (error) {
1088
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1089
+ durable = error.current
1090
+ if (durable.preparedSurfaceNames.includes(surface.name)) {
1091
+ receipts = [...durable.receipts]
1092
+ preparedNames = new Set(durable.preparedSurfaceNames)
1093
+ break
1094
+ }
1095
+ if (durable.state !== 'preparing') {
1096
+ throw new PublicationReconciliationError(
1097
+ `publication attempt advanced concurrently to ${durable.state}`,
1098
+ durable.receipts,
1099
+ durable.id,
1100
+ )
1101
+ }
1102
+ preparedNames = new Set([...durable.preparedSurfaceNames, surface.name])
1103
+ receipts = [...durable.receipts, normalized]
1104
+ }
1105
+ }
1106
+ }
1107
+ for (;;) {
1108
+ try {
1109
+ durable = await persistPublicationAttempt(db, {
1110
+ attemptId: durable.id,
1111
+ expectedVersion: durable.version,
1112
+ expectedStates: ['preparing'],
1113
+ state: 'prepared',
1114
+ pendingSurfaceNames: durable.surfaceNames,
1115
+ preparedSurfaceNames: [...preparedNames],
1116
+ receipts,
1117
+ now: input.now,
1118
+ })
1119
+ break
1120
+ } catch (error) {
1121
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1122
+ durable = error.current
1123
+ if (['prepared', 'committing', 'pending_retry', 'committed'].includes(durable.state)) break
1124
+ if (durable.state !== 'preparing') {
1125
+ throw new PublicationReconciliationError(
1126
+ `publication attempt advanced concurrently to ${durable.state}`,
1127
+ durable.receipts,
1128
+ durable.id,
1129
+ )
1130
+ }
1131
+ receipts = [...durable.receipts]
1132
+ preparedNames = new Set(durable.preparedSurfaceNames)
1133
+ }
1134
+ }
1135
+ const prepared = surfaces.filter((surface) => durable.preparedSurfaceNames.includes(surface.name))
1136
+ return { attempt: durable, receipts: durable.receipts, prepared }
1137
+ }
1138
+
1139
+ async function commitSurfaces(
1140
+ db: D1Database,
1141
+ attempt: PublicationAttempt,
1142
+ surfaces: PublicationReconciler[],
1143
+ input: PublicationReconciliationInput,
1144
+ surfaceHookTimeoutMs: number,
1145
+ ): Promise<{
1146
+ attempt: PublicationAttempt
1147
+ receipts: PublicationSurfaceReceipt[]
1148
+ pendingSurfaceNames: string[]
1149
+ }> {
1150
+ let durable = attempt
1151
+ let receipts: PublicationSurfaceReceipt[] = [...durable.receipts]
1152
+ let pending = new Set(durable.pendingSurfaceNames)
1153
+ for (const surface of surfaces) {
1154
+ if (!pending.has(surface.name)) continue
1155
+ const started = Date.now()
1156
+ let normalized: PublicationSurfaceReceipt
1157
+ try {
1158
+ normalized = normalizeReceipt(
1159
+ surface,
1160
+ 'commit',
1161
+ await runSurfaceHook(
1162
+ surface.commit as NonNullable<PublicationReconciler['commit']>,
1163
+ input,
1164
+ 'commit',
1165
+ surfaceHookTimeoutMs,
1166
+ ),
1167
+ started,
1168
+ )
1169
+ if (normalized.status === 'skipped') normalized.status = 'failed'
1170
+ } catch (error) {
1171
+ normalized = {
1172
+ name: surface.name,
1173
+ status: 'failed',
1174
+ phase: 'commit',
1175
+ message: durableDiagnostic(
1176
+ error instanceof PublicationSurfaceHookTimeoutError
1177
+ ? 'surface_hook_timed_out'
1178
+ : 'surface_hook_threw',
1179
+ ),
1180
+ durationMs: Date.now() - started,
1181
+ }
1182
+ }
1183
+ for (;;) {
1184
+ receipts = [...durable.receipts, normalized]
1185
+ pending = new Set(durable.pendingSurfaceNames)
1186
+ if (normalized.status === 'ok') pending.delete(surface.name)
1187
+ const inProgressState = durable.state === 'pending_retry' ? 'pending_retry' : 'committing'
1188
+ if (!['committing', 'pending_retry'].includes(durable.state)) {
1189
+ if (durable.state === 'committed') {
1190
+ return {
1191
+ attempt: durable,
1192
+ receipts: durable.receipts,
1193
+ pendingSurfaceNames: durable.pendingSurfaceNames,
1194
+ }
1195
+ }
1196
+ throw new PublicationReconciliationError(
1197
+ `publication attempt advanced concurrently to ${durable.state}`,
1198
+ durable.receipts,
1199
+ durable.id,
1200
+ )
1201
+ }
1202
+ try {
1203
+ durable = await persistPublicationAttempt(db, {
1204
+ attemptId: durable.id,
1205
+ expectedVersion: durable.version,
1206
+ expectedStates: [durable.state],
1207
+ state: inProgressState,
1208
+ pendingSurfaceNames: [...pending],
1209
+ preparedSurfaceNames: durable.preparedSurfaceNames,
1210
+ receipts,
1211
+ lastError:
1212
+ normalized.status === 'failed' ? durableDiagnostic('surface_commit_failed') : null,
1213
+ now: input.now,
1214
+ })
1215
+ receipts = [...durable.receipts]
1216
+ pending = new Set(durable.pendingSurfaceNames)
1217
+ break
1218
+ } catch (error) {
1219
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1220
+ durable = error.current
1221
+ if (durable.state === 'committed' || !durable.pendingSurfaceNames.includes(surface.name)) {
1222
+ receipts = [...durable.receipts]
1223
+ pending = new Set(durable.pendingSurfaceNames)
1224
+ break
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+ for (;;) {
1230
+ if (durable.state === 'committed') break
1231
+ if (!['committing', 'pending_retry'].includes(durable.state)) {
1232
+ throw new PublicationReconciliationError(
1233
+ `publication attempt advanced concurrently to ${durable.state}`,
1234
+ durable.receipts,
1235
+ durable.id,
1236
+ )
1237
+ }
1238
+ pending = new Set(durable.pendingSurfaceNames)
1239
+ const pendingFailures = durable.receipts.filter(
1240
+ (receipt) =>
1241
+ receipt.phase === 'commit' && receipt.status === 'failed' && pending.has(receipt.name),
1242
+ )
1243
+ try {
1244
+ durable = await persistPublicationAttempt(db, {
1245
+ attemptId: durable.id,
1246
+ expectedVersion: durable.version,
1247
+ expectedStates: [durable.state],
1248
+ state: pending.size > 0 ? 'pending_retry' : 'committed',
1249
+ pendingSurfaceNames: [...pending],
1250
+ preparedSurfaceNames: durable.preparedSurfaceNames,
1251
+ receipts: durable.receipts,
1252
+ lastError: pendingFailures.length > 0 ? durableDiagnostic('surface_commit_failed') : null,
1253
+ now: input.now,
1254
+ })
1255
+ break
1256
+ } catch (error) {
1257
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1258
+ durable = error.current
1259
+ }
1260
+ }
1261
+ return {
1262
+ attempt: durable,
1263
+ receipts: durable.receipts,
1264
+ pendingSurfaceNames: durable.pendingSurfaceNames,
1265
+ }
1266
+ }
1267
+
1268
+ async function abortSurfaces(
1269
+ surfaces: PublicationReconciler[],
1270
+ input: PublicationReconciliationInput,
1271
+ surfaceHookTimeoutMs: number,
1272
+ ): Promise<PublicationSurfaceReceipt[]> {
1273
+ const receipts: PublicationSurfaceReceipt[] = []
1274
+ for (const surface of [...surfaces].reverse()) {
1275
+ if (!surface.abort) {
1276
+ receipts.push({
1277
+ name: surface.name,
1278
+ status: 'skipped',
1279
+ phase: 'abort',
1280
+ message: durableDiagnostic('abort_hook_not_implemented'),
1281
+ })
1282
+ continue
1283
+ }
1284
+ const started = Date.now()
1285
+ try {
1286
+ receipts.push(
1287
+ normalizeReceipt(
1288
+ surface,
1289
+ 'abort',
1290
+ await runSurfaceHook(surface.abort, input, 'abort', surfaceHookTimeoutMs),
1291
+ started,
1292
+ ),
1293
+ )
1294
+ } catch (error) {
1295
+ receipts.push({
1296
+ name: surface.name,
1297
+ status: 'failed',
1298
+ phase: 'abort',
1299
+ message: durableDiagnostic(
1300
+ error instanceof PublicationSurfaceHookTimeoutError
1301
+ ? 'surface_hook_timed_out'
1302
+ : 'surface_hook_threw',
1303
+ ),
1304
+ durationMs: Date.now() - started,
1305
+ })
1306
+ }
1307
+ }
1308
+ return receipts
1309
+ }
1310
+
1311
+ function abortFailureDiagnostic(
1312
+ receipts: PublicationSurfaceReceipt[],
1313
+ fallback: DurableDiagnostic,
1314
+ ): string {
1315
+ if (receipts.some((receipt) => receipt.message === 'abort_hook_not_implemented')) {
1316
+ return durableDiagnostic('abort hook not implemented')
1317
+ }
1318
+ if (receipts.some((receipt) => receipt.message === 'surface_hook_failed')) {
1319
+ return durableDiagnostic('surface abort failed')
1320
+ }
1321
+ return durableDiagnostic(fallback)
1322
+ }
1323
+
1324
+ async function retryAbortCleanup(
1325
+ db: D1Database,
1326
+ attempt: PublicationAttempt,
1327
+ surfaces: PublicationReconciler[],
1328
+ input: PublicationReconciliationInput,
1329
+ surfaceHookTimeoutMs: number,
1330
+ ): Promise<RetryPublicationSurfacesReceipt> {
1331
+ let claimed: PublicationAttempt
1332
+ try {
1333
+ claimed = await persistPublicationAttempt(db, {
1334
+ attemptId: attempt.id,
1335
+ expectedVersion: attempt.version,
1336
+ expectedStates: [attempt.state],
1337
+ state: 'aborting',
1338
+ pendingSurfaceNames: [],
1339
+ preparedSurfaceNames: attempt.preparedSurfaceNames,
1340
+ receipts: attempt.receipts,
1341
+ lastError: attempt.lastError,
1342
+ now: input.now,
1343
+ })
1344
+ } catch (error) {
1345
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1346
+ if (error.current.state === 'aborted') {
1347
+ return {
1348
+ status: 'aborted',
1349
+ attemptId: error.current.id,
1350
+ contentId: error.current.contentId,
1351
+ revisionId: error.current.revisionId,
1352
+ previousRevisionId: error.current.previousRevisionId,
1353
+ surfaceStatus: 'none',
1354
+ surfaces: error.current.receipts,
1355
+ packageVersion: error.current.packageVersion,
1356
+ }
1357
+ }
1358
+ throw new PublicationReconciliationError(
1359
+ `publication cleanup advanced concurrently to ${error.current.state}`,
1360
+ error.current.receipts,
1361
+ error.current.id,
1362
+ )
1363
+ }
1364
+ const preparedNames = new Set(claimed.preparedSurfaceNames)
1365
+ const abortReceipts = await abortSurfaces(
1366
+ surfaces.filter((surface) => preparedNames.has(surface.name)),
1367
+ input,
1368
+ surfaceHookTimeoutMs,
1369
+ )
1370
+ const receipts = [...claimed.receipts, ...abortReceipts]
1371
+ const incompleteAborts = abortReceipts.filter((receipt) => receipt.status !== 'ok')
1372
+ const lastError =
1373
+ incompleteAborts.length > 0
1374
+ ? abortFailureDiagnostic(incompleteAborts, 'surface_abort_incomplete')
1375
+ : null
1376
+ let finalized: PublicationAttempt
1377
+ try {
1378
+ finalized = await persistPublicationAttempt(db, {
1379
+ attemptId: claimed.id,
1380
+ expectedVersion: claimed.version,
1381
+ expectedStates: ['aborting'],
1382
+ state: incompleteAborts.length > 0 ? 'abort_failed' : 'aborted',
1383
+ pendingSurfaceNames: [],
1384
+ preparedSurfaceNames: incompleteAborts.map((receipt) => receipt.name),
1385
+ receipts,
1386
+ lastError,
1387
+ now: input.now,
1388
+ })
1389
+ } catch (error) {
1390
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1391
+ if (!['aborted', 'abort_failed'].includes(error.current.state)) {
1392
+ throw new PublicationReconciliationError(
1393
+ `publication cleanup advanced concurrently to ${error.current.state}`,
1394
+ error.current.receipts,
1395
+ error.current.id,
1396
+ )
1397
+ }
1398
+ finalized = error.current
1399
+ }
1400
+ return {
1401
+ status: finalized.state === 'aborted' ? 'aborted' : 'abort_failed',
1402
+ attemptId: finalized.id,
1403
+ contentId: finalized.contentId,
1404
+ revisionId: finalized.revisionId,
1405
+ previousRevisionId: finalized.previousRevisionId,
1406
+ surfaceStatus: 'none',
1407
+ surfaces: finalized.receipts,
1408
+ packageVersion: finalized.packageVersion,
1409
+ }
1410
+ }
1411
+
1412
+ function surfaceStatusFor(surfaceNames: string[], pendingSurfaceNames: string[]) {
1413
+ if (surfaceNames.length === 0) return 'none' satisfies PublicationSurfaceCommitStatus
1414
+ return pendingSurfaceNames.length > 0
1415
+ ? ('pending_retry' satisfies PublicationSurfaceCommitStatus)
1416
+ : ('committed' satisfies PublicationSurfaceCommitStatus)
1417
+ }
1418
+
1419
+ async function assertRevisionWasCanonical(
1420
+ db: D1Database,
1421
+ contentId: string,
1422
+ revisionId: string,
1423
+ ): Promise<void> {
1424
+ const provenance = await db
1425
+ .prepare(
1426
+ `SELECT id
1427
+ FROM content_publication_attempts
1428
+ WHERE content_id = ? AND revision_id = ?
1429
+ AND state IN ('committing', 'pending_retry', 'committed', 'superseded')
1430
+ LIMIT 1`,
1431
+ )
1432
+ .bind(contentId, revisionId)
1433
+ .first<{ id: string }>()
1434
+ if (!provenance) {
1435
+ throw new PublicationNotFoundError(
1436
+ `rollback target revision never became canonical: ${revisionId}`,
1437
+ )
1438
+ }
1439
+ }
1440
+
1441
+ async function supersedePublicationAttempt(
1442
+ db: D1Database,
1443
+ attempt: PublicationAttempt,
1444
+ now: number,
1445
+ ): Promise<PublicationAttempt> {
1446
+ let durable = attempt
1447
+ for (;;) {
1448
+ if (durable.state === 'superseded' || durable.state === 'committed') return durable
1449
+ if (!['committing', 'pending_retry'].includes(durable.state)) {
1450
+ throw new PublicationReconciliationError(
1451
+ `publication attempt cannot be superseded from state ${durable.state}`,
1452
+ durable.receipts,
1453
+ durable.id,
1454
+ )
1455
+ }
1456
+ try {
1457
+ return await persistPublicationAttempt(db, {
1458
+ attemptId: durable.id,
1459
+ expectedVersion: durable.version,
1460
+ expectedStates: [durable.state],
1461
+ state: 'superseded',
1462
+ pendingSurfaceNames: [],
1463
+ preparedSurfaceNames: durable.preparedSurfaceNames,
1464
+ receipts: durable.receipts,
1465
+ lastError: durableDiagnostic('canonical_pointer_advanced'),
1466
+ now,
1467
+ })
1468
+ } catch (error) {
1469
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1470
+ durable = error.current
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ async function claimPrecanonicalAttemptForCleanup(
1476
+ db: D1Database,
1477
+ attempt: PublicationAttempt,
1478
+ now: number,
1479
+ ): Promise<PublicationAttempt> {
1480
+ let durable = attempt
1481
+ for (;;) {
1482
+ if (durable.state === 'aborting' || durable.state === 'abort_failed') return durable
1483
+ if (!['preparing', 'prepared'].includes(durable.state)) {
1484
+ throw new PublicationReconciliationError(
1485
+ `publication attempt cannot enter stale cleanup from state ${durable.state}`,
1486
+ durable.receipts,
1487
+ durable.id,
1488
+ )
1489
+ }
1490
+ try {
1491
+ return await persistPublicationAttempt(db, {
1492
+ attemptId: durable.id,
1493
+ expectedVersion: durable.version,
1494
+ expectedStates: [durable.state],
1495
+ state: 'aborting',
1496
+ pendingSurfaceNames: [],
1497
+ preparedSurfaceNames: durable.preparedSurfaceNames,
1498
+ receipts: durable.receipts,
1499
+ lastError: durableDiagnostic('canonical_pointer_advanced'),
1500
+ now,
1501
+ })
1502
+ } catch (error) {
1503
+ if (!(error instanceof PublicationAttemptConflictError)) throw error
1504
+ durable = error.current
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ async function movePointerAndBeginCommit(
1510
+ db: D1Database,
1511
+ attempt: PublicationAttempt,
1512
+ now: number,
1513
+ ): Promise<void> {
1514
+ const pointerStatement =
1515
+ attempt.operation === 'publish'
1516
+ ? db
1517
+ .prepare(
1518
+ `UPDATE content_items
1519
+ SET status = 'published', published_at = ?, publish_at = NULL,
1520
+ published_revision_id = ?, updated_at = unixepoch()
1521
+ WHERE id = ?
1522
+ AND COALESCE(published_revision_id, '') = ?
1523
+ AND EXISTS (
1524
+ SELECT 1 FROM content_publication_attempts
1525
+ WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
1526
+ )`,
1527
+ )
1528
+ .bind(
1529
+ now,
1530
+ attempt.revisionId,
1531
+ attempt.contentId,
1532
+ attempt.previousRevisionId ?? '',
1533
+ attempt.id,
1534
+ attempt.revisionId,
1535
+ attempt.contentId,
1536
+ )
1537
+ : db
1538
+ .prepare(
1539
+ `UPDATE content_items
1540
+ SET status = 'published', publish_at = NULL,
1541
+ published_revision_id = ?, updated_at = unixepoch()
1542
+ WHERE id = ?
1543
+ AND COALESCE(published_revision_id, '') = ?
1544
+ AND EXISTS (
1545
+ SELECT 1 FROM content_publication_attempts
1546
+ WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
1547
+ )`,
1548
+ )
1549
+ .bind(
1550
+ attempt.revisionId,
1551
+ attempt.contentId,
1552
+ attempt.previousRevisionId ?? '',
1553
+ attempt.id,
1554
+ attempt.revisionId,
1555
+ attempt.contentId,
1556
+ )
1557
+ const attemptStatement = db
1558
+ .prepare(
1559
+ `UPDATE content_publication_attempts
1560
+ SET state = 'committing', updated_at = ?, version = version + 1
1561
+ WHERE id = ? AND state = 'prepared' AND version = ?
1562
+ AND EXISTS (
1563
+ SELECT 1 FROM content_items
1564
+ WHERE id = ? AND published_revision_id = ?
1565
+ )`,
1566
+ )
1567
+ .bind(now, attempt.id, attempt.version, attempt.contentId, attempt.revisionId)
1568
+ const [pointerResult, attemptResult] = await db.batch([pointerStatement, attemptStatement])
1569
+ const pointerChanges = pointerResult ? d1Changes(pointerResult) : 0
1570
+ const attemptChanges = attemptResult ? d1Changes(attemptResult) : 0
1571
+ const [item, durableAttempt] = await Promise.all([
1572
+ getContentItem(db, attempt.contentId),
1573
+ getPublicationAttempt(db, attempt.id),
1574
+ ])
1575
+ const canonicalMatches = item?.published_revision_id === attempt.revisionId
1576
+ if (
1577
+ canonicalMatches &&
1578
+ ['committing', 'pending_retry', 'committed'].includes(durableAttempt.state)
1579
+ ) {
1580
+ return
1581
+ }
1582
+ if (!canonicalMatches && durableAttempt.state === 'committed') return
1583
+ if (!canonicalMatches && ['committing', 'pending_retry'].includes(durableAttempt.state)) {
1584
+ const superseded = await supersedePublicationAttempt(db, durableAttempt, now)
1585
+ throw new PublicationAttemptSupersededError(superseded)
1586
+ }
1587
+ if (durableAttempt.state === 'superseded') {
1588
+ throw new PublicationAttemptSupersededError(durableAttempt)
1589
+ }
1590
+ if (canonicalMatches && durableAttempt.state === 'prepared') {
1591
+ await persistPublicationAttempt(db, {
1592
+ attemptId: durableAttempt.id,
1593
+ expectedVersion: durableAttempt.version,
1594
+ expectedStates: ['prepared'],
1595
+ state: 'committing',
1596
+ pendingSurfaceNames: durableAttempt.pendingSurfaceNames,
1597
+ preparedSurfaceNames: durableAttempt.preparedSurfaceNames,
1598
+ receipts: durableAttempt.receipts,
1599
+ now,
1600
+ })
1601
+ return
1602
+ }
1603
+ if (
1604
+ (pointerChanges !== null && pointerChanges !== 1) ||
1605
+ (attemptChanges !== null && attemptChanges !== 1)
1606
+ ) {
1607
+ throw new PublicationPointerMoveError(
1608
+ `canonical pointer changed before ${attempt.operation} move`,
1609
+ attempt.receipts,
1610
+ attempt.id,
1611
+ )
1612
+ }
1613
+ if (!canonicalMatches || durableAttempt.state !== 'committing') {
1614
+ throw new PublicationPointerMoveError(
1615
+ `canonical pointer and durable attempt did not begin ${attempt.operation} commit`,
1616
+ attempt.receipts,
1617
+ attempt.id,
1618
+ )
1619
+ }
1620
+ }
1621
+
1622
+ async function abortAfterPointerFailure(
1623
+ db: D1Database,
1624
+ attempt: PublicationAttempt,
1625
+ preparedSurfaces: PublicationReconciler[],
1626
+ reconciliationInput: PublicationReconciliationInput,
1627
+ receipts: PublicationSurfaceReceipt[],
1628
+ surfaceHookTimeoutMs: number,
1629
+ ): Promise<never> {
1630
+ let claimed: PublicationAttempt
1631
+ try {
1632
+ claimed = await persistPublicationAttempt(db, {
1633
+ attemptId: attempt.id,
1634
+ expectedVersion: attempt.version,
1635
+ expectedStates: ['prepared'],
1636
+ state: 'aborting',
1637
+ pendingSurfaceNames: [],
1638
+ preparedSurfaceNames: attempt.preparedSurfaceNames,
1639
+ receipts,
1640
+ lastError: durableDiagnostic('canonical_pointer_move_failed'),
1641
+ now: reconciliationInput.now,
1642
+ })
1643
+ } catch (claimError) {
1644
+ if (!(claimError instanceof PublicationAttemptConflictError)) throw claimError
1645
+ if (
1646
+ ['committing', 'pending_retry', 'committed', 'superseded'].includes(claimError.current.state)
1647
+ ) {
1648
+ throw new PublicationAttemptSupersededError(claimError.current)
1649
+ }
1650
+ throw new PublicationReconciliationError(
1651
+ `publication attempt advanced concurrently to ${claimError.current.state}`,
1652
+ claimError.current.receipts,
1653
+ claimError.current.id,
1654
+ )
1655
+ }
1656
+ const preparedNames = new Set(claimed.preparedSurfaceNames)
1657
+ const abortReceipts = await abortSurfaces(
1658
+ preparedSurfaces.filter((surface) => preparedNames.has(surface.name)),
1659
+ reconciliationInput,
1660
+ surfaceHookTimeoutMs,
1661
+ )
1662
+ const allReceipts = [...receipts, ...abortReceipts]
1663
+ const incompleteAborts = abortReceipts.filter((receipt) => receipt.status !== 'ok')
1664
+ const lastError = durableDiagnostic(
1665
+ incompleteAborts.length > 0
1666
+ ? 'canonical_pointer_move_abort_incomplete'
1667
+ : 'canonical_pointer_move_failed',
1668
+ )
1669
+ let finalized: PublicationAttempt
1670
+ try {
1671
+ finalized = await persistPublicationAttempt(db, {
1672
+ attemptId: claimed.id,
1673
+ expectedVersion: claimed.version,
1674
+ expectedStates: ['aborting'],
1675
+ state: incompleteAborts.length > 0 ? 'abort_failed' : 'aborted',
1676
+ pendingSurfaceNames: [],
1677
+ preparedSurfaceNames: incompleteAborts.map((receipt) => receipt.name),
1678
+ receipts: allReceipts,
1679
+ lastError,
1680
+ now: reconciliationInput.now,
1681
+ })
1682
+ } catch (finalizeError) {
1683
+ if (!(finalizeError instanceof PublicationAttemptConflictError)) throw finalizeError
1684
+ finalized = finalizeError.current
1685
+ }
1686
+ throw new PublicationPointerMoveError(
1687
+ `canonical pointer did not move for ${finalized.operation}`,
1688
+ finalized.receipts,
1689
+ finalized.id,
1690
+ )
1691
+ }
1692
+
1693
+ export async function publishOne(
1694
+ db: D1Database,
1695
+ input: PublishOneInput,
1696
+ ): Promise<PublishOneReceipt> {
1697
+ const now = input.now ?? nowSeconds()
1698
+ const surfaceHookTimeoutMs = normalizeSurfaceHookTimeoutMs(input.surfaceHookTimeoutMs)
1699
+ const surfaces = normalizeSurfaces(input)
1700
+ const item = await getContentItem(db, input.contentId)
1701
+ if (!item) throw new PublicationNotFoundError(`content not found: ${input.contentId}`)
1702
+ const previousRevisionId = item.published_revision_id
1703
+ const { revisionId, snapshot, attempt } = await createRevisionAndPublicationAttempt(db, {
1704
+ contentId: input.contentId,
1705
+ previousRevisionId,
1706
+ surfaceNames: surfaces.map((surface) => surface.name),
1707
+ packageVersion: input.packageVersion ?? null,
1708
+ now,
1709
+ createdBy: input.createdBy,
1710
+ })
1711
+ const reconciliationInput: PublicationReconciliationInput = {
1712
+ operation: 'publish',
1713
+ contentId: input.contentId,
1714
+ revisionId,
1715
+ previousRevisionId,
1716
+ snapshot,
1717
+ now,
1718
+ packageVersion: input.packageVersion ?? null,
1719
+ }
1720
+ const prepared = await prepareSurfaces(
1721
+ db,
1722
+ attempt,
1723
+ surfaces,
1724
+ reconciliationInput,
1725
+ surfaceHookTimeoutMs,
1726
+ )
1727
+ const durablePrepared = await getPublicationAttempt(db, attempt.id)
1728
+ try {
1729
+ await movePointerAndBeginCommit(db, durablePrepared, now)
1730
+ } catch (error) {
1731
+ if (error instanceof PublicationAttemptSupersededError) throw error
1732
+ return abortAfterPointerFailure(
1733
+ db,
1734
+ durablePrepared,
1735
+ prepared.prepared,
1736
+ reconciliationInput,
1737
+ prepared.receipts,
1738
+ surfaceHookTimeoutMs,
1739
+ )
1740
+ }
1741
+ const afterMove = await getPublicationAttempt(db, attempt.id)
1742
+ const pendingAfterMove = new Set(afterMove.pendingSurfaceNames)
1743
+ const committed = await commitSurfaces(
1744
+ db,
1745
+ afterMove,
1746
+ surfaces.filter((surface) => pendingAfterMove.has(surface.name)),
1747
+ reconciliationInput,
1748
+ surfaceHookTimeoutMs,
1749
+ )
1750
+ const surfaceStatus = surfaceStatusFor(attempt.surfaceNames, committed.pendingSurfaceNames)
1751
+ return {
1752
+ status: 'published',
1753
+ attemptId: attempt.id,
1754
+ contentId: input.contentId,
1755
+ revisionId,
1756
+ previousRevisionId,
1757
+ publishedAt: now,
1758
+ surfaceStatus,
1759
+ surfaces: committed.receipts,
1760
+ packageVersion: input.packageVersion ?? null,
1761
+ }
1762
+ }
1763
+
1764
+ export async function rollbackPublication(
1765
+ db: D1Database,
1766
+ input: RollbackPublicationInput,
1767
+ ): Promise<RollbackPublicationReceipt> {
1768
+ const now = input.now ?? nowSeconds()
1769
+ const surfaceHookTimeoutMs = normalizeSurfaceHookTimeoutMs(input.surfaceHookTimeoutMs)
1770
+ const surfaces = normalizeSurfaces(input)
1771
+ const item = await getContentItem(db, input.contentId)
1772
+ if (!item) throw new PublicationNotFoundError(`content not found: ${input.contentId}`)
1773
+ const target = await getRevision(db, input.contentId, input.targetRevisionId)
1774
+ if (!target) {
1775
+ throw new PublicationNotFoundError(`revision not found: ${input.targetRevisionId}`)
1776
+ }
1777
+ await assertRevisionWasCanonical(db, input.contentId, input.targetRevisionId)
1778
+ const previousRevisionId = item.published_revision_id
1779
+ const reconciliationInput: PublicationReconciliationInput = {
1780
+ operation: 'rollback',
1781
+ contentId: input.contentId,
1782
+ revisionId: input.targetRevisionId,
1783
+ previousRevisionId,
1784
+ snapshot: target.payload as unknown as Record<string, unknown>,
1785
+ now,
1786
+ packageVersion: input.packageVersion ?? null,
1787
+ }
1788
+ const attempt = await createPublicationAttempt(db, {
1789
+ operation: 'rollback',
1790
+ contentId: input.contentId,
1791
+ revisionId: input.targetRevisionId,
1792
+ previousRevisionId,
1793
+ surfaceNames: surfaces.map((surface) => surface.name),
1794
+ packageVersion: input.packageVersion ?? null,
1795
+ now,
1796
+ })
1797
+ const prepared = await prepareSurfaces(
1798
+ db,
1799
+ attempt,
1800
+ surfaces,
1801
+ reconciliationInput,
1802
+ surfaceHookTimeoutMs,
1803
+ )
1804
+ const durablePrepared = await getPublicationAttempt(db, attempt.id)
1805
+ try {
1806
+ await movePointerAndBeginCommit(db, durablePrepared, now)
1807
+ } catch (error) {
1808
+ if (error instanceof PublicationAttemptSupersededError) throw error
1809
+ return abortAfterPointerFailure(
1810
+ db,
1811
+ durablePrepared,
1812
+ prepared.prepared,
1813
+ reconciliationInput,
1814
+ prepared.receipts,
1815
+ surfaceHookTimeoutMs,
1816
+ )
1817
+ }
1818
+ const afterMove = await getPublicationAttempt(db, attempt.id)
1819
+ const pendingAfterMove = new Set(afterMove.pendingSurfaceNames)
1820
+ const committed = await commitSurfaces(
1821
+ db,
1822
+ afterMove,
1823
+ surfaces.filter((surface) => pendingAfterMove.has(surface.name)),
1824
+ reconciliationInput,
1825
+ surfaceHookTimeoutMs,
1826
+ )
1827
+ const surfaceStatus = surfaceStatusFor(attempt.surfaceNames, committed.pendingSurfaceNames)
1828
+ return {
1829
+ status: 'rolled_back',
1830
+ attemptId: attempt.id,
1831
+ contentId: input.contentId,
1832
+ revisionId: input.targetRevisionId,
1833
+ previousRevisionId,
1834
+ rolledBackAt: now,
1835
+ surfaceStatus,
1836
+ surfaces: committed.receipts,
1837
+ packageVersion: input.packageVersion ?? null,
1838
+ }
1839
+ }
1840
+
1841
+ export async function retryPublicationSurfaces(
1842
+ db: D1Database,
1843
+ input: RetryPublicationSurfacesInput,
1844
+ ): Promise<RetryPublicationSurfacesReceipt> {
1845
+ const now = input.now ?? nowSeconds()
1846
+ const surfaceHookTimeoutMs = normalizeSurfaceHookTimeoutMs(input.surfaceHookTimeoutMs)
1847
+ let attempt = await getPublicationAttempt(db, input.attemptId)
1848
+ if (attempt.state === 'committed') {
1849
+ normalizeRetrySurfaces(input.surfaces, attempt.pendingSurfaceNames)
1850
+ return {
1851
+ status: 'reconciled',
1852
+ attemptId: attempt.id,
1853
+ contentId: attempt.contentId,
1854
+ revisionId: attempt.revisionId,
1855
+ previousRevisionId: attempt.previousRevisionId,
1856
+ surfaceStatus: surfaceStatusFor(attempt.surfaceNames, []),
1857
+ surfaces: attempt.receipts,
1858
+ packageVersion: attempt.packageVersion,
1859
+ }
1860
+ }
1861
+ if (
1862
+ !['preparing', 'prepared', 'committing', 'pending_retry', 'aborting', 'abort_failed'].includes(
1863
+ attempt.state,
1864
+ )
1865
+ ) {
1866
+ throw new PublicationReconciliationError(
1867
+ `publication attempt is not recoverable from state ${attempt.state}`,
1868
+ attempt.receipts,
1869
+ attempt.id,
1870
+ )
1871
+ }
1872
+ const item = await getContentItem(db, attempt.contentId)
1873
+ if (!item) throw new PublicationNotFoundError(`content not found: ${attempt.contentId}`)
1874
+ if (
1875
+ ['committing', 'pending_retry'].includes(attempt.state) &&
1876
+ item.published_revision_id !== attempt.revisionId
1877
+ ) {
1878
+ attempt = await supersedePublicationAttempt(db, attempt, now)
1879
+ throw new PublicationAttemptSupersededError(attempt)
1880
+ }
1881
+ const stalePrecanonicalAttempt =
1882
+ ['preparing', 'prepared'].includes(attempt.state) &&
1883
+ item.published_revision_id !== attempt.previousRevisionId &&
1884
+ item.published_revision_id !== attempt.revisionId
1885
+ if (stalePrecanonicalAttempt) {
1886
+ attempt = await claimPrecanonicalAttemptForCleanup(db, attempt, now)
1887
+ }
1888
+ const cleanupRecovery =
1889
+ stalePrecanonicalAttempt || ['aborting', 'abort_failed'].includes(attempt.state)
1890
+ const retrySurfaceInput = stalePrecanonicalAttempt
1891
+ ? input.surfaces?.filter((surface) => attempt.preparedSurfaceNames.includes(surface.name))
1892
+ : input.surfaces
1893
+ const surfaces = normalizeRetrySurfaces(
1894
+ retrySurfaceInput,
1895
+ cleanupRecovery ? attempt.preparedSurfaceNames : attempt.pendingSurfaceNames,
1896
+ )
1897
+ const revision = await getRevision(db, attempt.contentId, attempt.revisionId)
1898
+ if (!revision) throw new PublicationNotFoundError(`revision not found: ${attempt.revisionId}`)
1899
+ const reconciliationInput: PublicationReconciliationInput = {
1900
+ operation: attempt.operation,
1901
+ contentId: attempt.contentId,
1902
+ revisionId: attempt.revisionId,
1903
+ previousRevisionId: attempt.previousRevisionId,
1904
+ snapshot: revision.payload as unknown as Record<string, unknown>,
1905
+ now,
1906
+ packageVersion: attempt.packageVersion,
1907
+ }
1908
+ if (cleanupRecovery) {
1909
+ const cleanup = await retryAbortCleanup(
1910
+ db,
1911
+ attempt,
1912
+ surfaces,
1913
+ reconciliationInput,
1914
+ surfaceHookTimeoutMs,
1915
+ )
1916
+ if (stalePrecanonicalAttempt) {
1917
+ throw new PublicationAttemptSupersededError(
1918
+ await getPublicationAttempt(db, cleanup.attemptId),
1919
+ )
1920
+ }
1921
+ return cleanup
1922
+ }
1923
+ if (attempt.state === 'preparing') {
1924
+ const prepared = await prepareSurfaces(
1925
+ db,
1926
+ attempt,
1927
+ surfaces,
1928
+ reconciliationInput,
1929
+ surfaceHookTimeoutMs,
1930
+ )
1931
+ attempt = prepared.attempt
1932
+ try {
1933
+ await movePointerAndBeginCommit(db, attempt, now)
1934
+ } catch (error) {
1935
+ if (error instanceof PublicationAttemptSupersededError) throw error
1936
+ return abortAfterPointerFailure(
1937
+ db,
1938
+ attempt,
1939
+ prepared.prepared,
1940
+ reconciliationInput,
1941
+ prepared.receipts,
1942
+ surfaceHookTimeoutMs,
1943
+ )
1944
+ }
1945
+ } else if (attempt.state === 'prepared') {
1946
+ try {
1947
+ await movePointerAndBeginCommit(db, attempt, now)
1948
+ } catch (error) {
1949
+ if (error instanceof PublicationAttemptSupersededError) throw error
1950
+ return abortAfterPointerFailure(
1951
+ db,
1952
+ attempt,
1953
+ surfaces,
1954
+ reconciliationInput,
1955
+ attempt.receipts,
1956
+ surfaceHookTimeoutMs,
1957
+ )
1958
+ }
1959
+ } else if (item.published_revision_id !== attempt.revisionId) {
1960
+ throw new PublicationPointerMoveError(
1961
+ 'canonical pointer does not match durable retry revision',
1962
+ attempt.receipts,
1963
+ attempt.id,
1964
+ )
1965
+ }
1966
+ attempt = await getPublicationAttempt(db, attempt.id)
1967
+ const pending = new Set(attempt.pendingSurfaceNames)
1968
+ const committed = await commitSurfaces(
1969
+ db,
1970
+ attempt,
1971
+ surfaces.filter((surface) => pending.has(surface.name)),
1972
+ reconciliationInput,
1973
+ surfaceHookTimeoutMs,
1974
+ )
1975
+ const surfaceStatus = surfaceStatusFor(attempt.surfaceNames, committed.pendingSurfaceNames)
1976
+ return {
1977
+ status: surfaceStatus === 'pending_retry' ? 'pending_retry' : 'reconciled',
1978
+ attemptId: attempt.id,
1979
+ contentId: attempt.contentId,
1980
+ revisionId: attempt.revisionId,
1981
+ previousRevisionId: attempt.previousRevisionId,
1982
+ surfaceStatus,
1983
+ surfaces: committed.receipts,
1984
+ packageVersion: attempt.packageVersion,
1985
+ }
1986
+ }
1987
+
1988
+ function bodyBackedArchiveType(
1989
+ type: ContentType | undefined,
1990
+ ): 'article' | 'newsletter' | 'page' | null {
1991
+ if (type === 'article' || type === 'newsletter' || type === 'page') return type
1992
+ return null
1993
+ }
1994
+
1995
+ const DEFAULT_IMPORT_BATCH_ITEMS = 100
1996
+ const DEFAULT_MAX_IMPORT_BATCH_ITEMS = 500
1997
+ const DEFAULT_MAX_ARCHIVE_ITEMS = 10_000
1998
+ const DEFAULT_MAX_ARCHIVE_BODY_BYTES = 256 * 1024
1999
+
2000
+ function textBytes(value: string): number {
2001
+ return new TextEncoder().encode(value).byteLength
2002
+ }
2003
+
2004
+ function parseArchiveCursor(cursor: number | string | null | undefined): number {
2005
+ if (cursor === null || cursor === undefined || cursor === '') return 0
2006
+ const parsed = typeof cursor === 'number' ? cursor : Number(cursor)
2007
+ if (!Number.isInteger(parsed) || parsed < 0) {
2008
+ throw new ContentArchiveImportLimitError(
2009
+ 'archive_cursor_invalid',
2010
+ 'archive import cursor must be a non-negative integer',
2011
+ 0,
2012
+ parsed,
2013
+ )
2014
+ }
2015
+ return parsed
2016
+ }
2017
+
2018
+ function assertLimit(code: string, message: string, limit: number, actual: number): void {
2019
+ if (actual > limit) throw new ContentArchiveImportLimitError(code, message, limit, actual)
2020
+ }
2021
+
2022
+ async function findContentBySlug(
2023
+ db: D1Database,
2024
+ slug: string,
2025
+ ): Promise<{ id: string; slug: string; type: 'article' | 'newsletter' | 'page' } | null> {
2026
+ const row = await db
2027
+ .prepare('SELECT id, slug, type FROM content_items WHERE slug = ? LIMIT 1')
2028
+ .bind(slug)
2029
+ .first<{ id: string; slug: string; type: ContentType }>()
2030
+ if (!row) return null
2031
+ const type = bodyBackedArchiveType(row.type)
2032
+ if (!type) return null
2033
+ return { id: row.id, slug: row.slug, type }
2034
+ }
2035
+
2036
+ function createInputForArchiveItem(
2037
+ item: ContentArchiveItem,
2038
+ type: 'article' | 'newsletter' | 'page',
2039
+ slug: string,
2040
+ options: ImportContentArchiveOptions,
2041
+ ): CreateContentInput {
2042
+ const base = {
2043
+ type,
2044
+ slug,
2045
+ title: item.title ?? 'Untitled',
2046
+ excerpt: item.excerpt ?? null,
2047
+ primaryCategory: item.primaryCategory ?? options.defaultPrimaryCategory ?? 'analysis',
2048
+ primaryTopic: item.primaryTopic ?? options.defaultPrimaryTopic ?? null,
2049
+ tags: item.tags ?? [],
2050
+ content: {
2051
+ bodyMarkdown: item.bodyMarkdown ?? ' ',
2052
+ },
2053
+ }
2054
+ if (type === 'article') return { ...base, type: 'article' }
2055
+ if (type === 'newsletter') return { ...base, type: 'newsletter' }
2056
+ return { ...base, type: 'page' }
2057
+ }
2058
+
2059
+ export async function importContentArchive(
2060
+ db: D1Database,
2061
+ archive: ContentArchiveInput,
2062
+ options: ImportContentArchiveOptions = {},
2063
+ ): Promise<ImportContentArchiveResult> {
2064
+ const created: ImportedArchiveItem[] = []
2065
+ const skipped: SkippedArchiveItem[] = []
2066
+ const items = Array.isArray(archive.items) ? archive.items : []
2067
+ const cursor = parseArchiveCursor(options.cursor)
2068
+ const batchSize = options.batchSize ?? DEFAULT_IMPORT_BATCH_ITEMS
2069
+ const maxBatchItems = options.maxBatchItems ?? DEFAULT_MAX_IMPORT_BATCH_ITEMS
2070
+ const maxArchiveItems = options.maxArchiveItems ?? DEFAULT_MAX_ARCHIVE_ITEMS
2071
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_ARCHIVE_BODY_BYTES
2072
+ assertLimit(
2073
+ 'archive_item_limit_exceeded',
2074
+ 'archive item count exceeds the configured import limit',
2075
+ maxArchiveItems,
2076
+ items.length,
2077
+ )
2078
+ if (cursor > items.length) {
2079
+ throw new ContentArchiveImportLimitError(
2080
+ 'archive_cursor_out_of_range',
2081
+ 'archive import cursor exceeds the archive item count',
2082
+ items.length,
2083
+ cursor,
2084
+ )
2085
+ }
2086
+ assertLimit(
2087
+ 'archive_batch_limit_exceeded',
2088
+ 'archive import batch size exceeds the configured import limit',
2089
+ maxBatchItems,
2090
+ batchSize,
2091
+ )
2092
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
2093
+ throw new ContentArchiveImportLimitError(
2094
+ 'archive_batch_limit_invalid',
2095
+ 'archive import batch size must be a positive integer',
2096
+ 1,
2097
+ batchSize,
2098
+ )
2099
+ }
2100
+ const end = Math.min(items.length, cursor + batchSize)
2101
+ for (let index = cursor; index < end; index += 1) {
2102
+ const item = items[index]
2103
+ if (!item) continue
2104
+ const type = bodyBackedArchiveType(item.type)
2105
+ if (!type) {
2106
+ skipped.push({ index, reason: 'unsupported_type', title: item.title ?? null })
2107
+ continue
2108
+ }
2109
+ if (!item.title?.trim()) {
2110
+ skipped.push({ index, reason: 'missing_title', title: null })
2111
+ continue
2112
+ }
2113
+ const requestedSlug = item.slug?.trim() || item.title
2114
+ const baseSlug = slugify(requestedSlug)
2115
+ const bodyMarkdown = item.bodyMarkdown ?? ' '
2116
+ assertLimit(
2117
+ 'archive_body_limit_exceeded',
2118
+ 'archive item body exceeds the configured import limit',
2119
+ maxBodyBytes,
2120
+ textBytes(bodyMarkdown),
2121
+ )
2122
+ const existing = await findContentBySlug(db, baseSlug)
2123
+ if (existing) {
2124
+ skipped.push({
2125
+ index,
2126
+ reason: 'already_imported',
2127
+ title: item.title,
2128
+ slug: existing.slug,
2129
+ existingId: existing.id,
2130
+ })
2131
+ continue
2132
+ }
2133
+ const slug = await ensureUniqueSlug(db, requestedSlug)
2134
+ const result = await createContent(db, createInputForArchiveItem(item, type, slug, options))
2135
+ created.push({ id: result.id, slug: result.slug, type })
2136
+ }
2137
+ const nextCursor = end >= items.length ? null : end
2138
+ return {
2139
+ created,
2140
+ skipped,
2141
+ processed: end - cursor,
2142
+ cursor,
2143
+ nextCursor,
2144
+ done: nextCursor === null,
2145
+ checkpoint: {
2146
+ cursor,
2147
+ nextCursor,
2148
+ batchSize,
2149
+ totalItems: items.length,
2150
+ },
2151
+ }
2152
+ }