@growth-labs/cms 0.3.0 → 0.4.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 (56) hide show
  1. package/README.md +101 -5
  2. package/dist/cli/migrations.d.ts +3 -0
  3. package/dist/cli/migrations.d.ts.map +1 -0
  4. package/dist/cli/migrations.js +73 -0
  5. package/dist/cli/migrations.js.map +1 -0
  6. package/dist/engine/content-metadata.d.ts +13 -0
  7. package/dist/engine/content-metadata.d.ts.map +1 -0
  8. package/dist/engine/content-metadata.js +80 -0
  9. package/dist/engine/content-metadata.js.map +1 -0
  10. package/dist/engine/index.d.ts +3 -1
  11. package/dist/engine/index.d.ts.map +1 -1
  12. package/dist/engine/index.js +5 -1
  13. package/dist/engine/index.js.map +1 -1
  14. package/dist/engine/publication.d.ts +29 -1
  15. package/dist/engine/publication.d.ts.map +1 -1
  16. package/dist/engine/publication.js +197 -15
  17. package/dist/engine/publication.js.map +1 -1
  18. package/dist/engine/published-content.d.ts +40 -0
  19. package/dist/engine/published-content.d.ts.map +1 -0
  20. package/dist/engine/published-content.js +388 -0
  21. package/dist/engine/published-content.js.map +1 -0
  22. package/dist/engine/publisher.d.ts +16 -1
  23. package/dist/engine/publisher.d.ts.map +1 -1
  24. package/dist/engine/publisher.js +74 -11
  25. package/dist/engine/publisher.js.map +1 -1
  26. package/dist/engine/revisions.d.ts.map +1 -1
  27. package/dist/engine/revisions.js +2 -0
  28. package/dist/engine/revisions.js.map +1 -1
  29. package/dist/migration-vendor-files.d.ts +17 -0
  30. package/dist/migration-vendor-files.d.ts.map +1 -0
  31. package/dist/migration-vendor-files.js +67 -0
  32. package/dist/migration-vendor-files.js.map +1 -0
  33. package/dist/migration-vendor.d.ts +26 -0
  34. package/dist/migration-vendor.d.ts.map +1 -0
  35. package/dist/migration-vendor.js +321 -0
  36. package/dist/migration-vendor.js.map +1 -0
  37. package/dist/schema/migrations.d.ts.map +1 -1
  38. package/dist/schema/migrations.js +24 -0
  39. package/dist/schema/migrations.js.map +1 -1
  40. package/dist/schema/types.d.ts +6 -0
  41. package/dist/schema/types.d.ts.map +1 -1
  42. package/dist/schema/types.js.map +1 -1
  43. package/migrations/0020_content_metadata_read_model.sql +19 -0
  44. package/migrations/0021_published_revision_slug_index.sql +5 -0
  45. package/package.json +8 -1
  46. package/src/cli/migrations.ts +85 -0
  47. package/src/engine/content-metadata.ts +122 -0
  48. package/src/engine/index.ts +26 -0
  49. package/src/engine/publication.ts +329 -18
  50. package/src/engine/published-content.ts +581 -0
  51. package/src/engine/publisher.ts +106 -10
  52. package/src/engine/revisions.ts +4 -0
  53. package/src/migration-vendor-files.ts +100 -0
  54. package/src/migration-vendor.ts +450 -0
  55. package/src/schema/migrations.ts +24 -0
  56. package/src/schema/types.ts +6 -0
@@ -2,7 +2,14 @@ import type {
2
2
  ContentPublicationAttemptRow,
3
3
  ContentPublicationAttemptState,
4
4
  ContentType,
5
+ ContentVisibility,
5
6
  } from '../schema/types.js'
7
+ import {
8
+ type ContentMetadata,
9
+ ContentMetadataError,
10
+ MAX_CONTENT_METADATA_BYTES,
11
+ serializeContentMetadata,
12
+ } from './content-metadata.js'
6
13
  import type { D1Database, D1Result } from './d1.js'
7
14
  import { evaluateArticleBody } from './publish-guard.js'
8
15
  import {
@@ -65,6 +72,8 @@ export interface PublicationReconciler {
65
72
  export interface PublishOneInput {
66
73
  contentId: string
67
74
  now?: number
75
+ /** Explicit historical publication time for a staged archive item. */
76
+ publishedAt?: number
68
77
  createdBy?: string | null
69
78
  surfaces?: PublicationReconciler[]
70
79
  allowNoSurfaces?: boolean
@@ -134,6 +143,7 @@ export interface PublicationAttempt {
134
143
  contentId: string
135
144
  revisionId: string
136
145
  previousRevisionId: string | null
146
+ targetPublishedAt: number | null
137
147
  state: PublicationAttemptState
138
148
  surfaceNames: string[]
139
149
  pendingSurfaceNames: string[]
@@ -229,14 +239,32 @@ export interface ContentArchiveInput {
229
239
  }
230
240
 
231
241
  export interface ContentArchiveItem {
242
+ sourceId?: string
232
243
  type?: ContentType
233
244
  slug?: string
234
245
  title?: string
246
+ seoTitle?: string | null
247
+ description?: string | null
235
248
  excerpt?: string | null
249
+ byline?: string | null
236
250
  primaryCategory?: string | null
237
251
  primaryTopic?: string | null
252
+ featured?: boolean
253
+ visibility?: ContentVisibility
254
+ authorId?: string | null
255
+ heroImageId?: string | null
256
+ heroImageAlt?: string | null
257
+ heroImageCaption?: string | null
258
+ socialImageId?: string | null
259
+ canonicalUrl?: string | null
238
260
  tags?: string[]
239
261
  bodyMarkdown?: string
262
+ bodyHtml?: string | null
263
+ metadata?: ContentMetadata
264
+ publishedAt?: number | null
265
+ createdAt?: number | null
266
+ updatedAt?: number | null
267
+ publishTz?: string | null
240
268
  }
241
269
 
242
270
  export interface ImportContentArchiveOptions {
@@ -253,6 +281,7 @@ export interface ImportedArchiveItem {
253
281
  id: string
254
282
  slug: string
255
283
  type: 'article' | 'newsletter' | 'page'
284
+ sourceId?: string
256
285
  }
257
286
 
258
287
  export interface SkippedArchiveItem {
@@ -261,6 +290,7 @@ export interface SkippedArchiveItem {
261
290
  title: string | null
262
291
  slug?: string | null
263
292
  existingId?: string | null
293
+ sourceId?: string
264
294
  }
265
295
 
266
296
  export interface ImportContentArchiveResult {
@@ -292,10 +322,37 @@ export class ContentArchiveImportLimitError extends Error {
292
322
  }
293
323
  }
294
324
 
325
+ export class PublicationInputError extends Error {
326
+ constructor(
327
+ readonly code: 'published_at_invalid',
328
+ message: string,
329
+ ) {
330
+ super(message)
331
+ this.name = 'PublicationInputError'
332
+ }
333
+ }
334
+
295
335
  function nowSeconds(): number {
296
336
  return Math.floor(Date.now() / 1000)
297
337
  }
298
338
 
339
+ function normalizePublishedAt(value: number | undefined, now: number): number {
340
+ const publishedAt = value ?? now
341
+ if (
342
+ !Number.isSafeInteger(now) ||
343
+ now < 0 ||
344
+ !Number.isSafeInteger(publishedAt) ||
345
+ publishedAt < 0 ||
346
+ publishedAt > now
347
+ ) {
348
+ throw new PublicationInputError(
349
+ 'published_at_invalid',
350
+ 'publishedAt must be a non-negative epoch second no later than now',
351
+ )
352
+ }
353
+ return publishedAt
354
+ }
355
+
299
356
  export const DEFAULT_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS = 10_000
300
357
  export const MAX_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS = 30_000
301
358
  export const DEFAULT_PENDING_PUBLICATION_ATTEMPT_LIMIT = 100
@@ -450,6 +507,7 @@ function decodeAttempt(row: ContentPublicationAttemptRow): PublicationAttempt {
450
507
  contentId: row.content_id,
451
508
  revisionId: row.revision_id,
452
509
  previousRevisionId: row.previous_revision_id,
510
+ targetPublishedAt: row.target_published_at,
453
511
  state: row.state,
454
512
  surfaceNames: parseStringArray(row.surface_names_json, 'surface_names_json'),
455
513
  pendingSurfaceNames: parseStringArray(
@@ -489,6 +547,7 @@ function decodeAttemptForEnumeration(row: ContentPublicationAttemptRow): Publica
489
547
  contentId: row.content_id,
490
548
  revisionId: row.revision_id,
491
549
  previousRevisionId: row.previous_revision_id,
550
+ targetPublishedAt: row.target_published_at,
492
551
  state: row.state,
493
552
  surfaceNames: recoverStringArray(row.surface_names_json),
494
553
  pendingSurfaceNames: recoverStringArray(row.pending_surface_names_json),
@@ -510,7 +569,8 @@ async function getPublicationAttempt(
510
569
  ): Promise<PublicationAttempt> {
511
570
  const row = await db
512
571
  .prepare(
513
- `SELECT id, operation, content_id, revision_id, previous_revision_id, state,
572
+ `SELECT id, operation, content_id, revision_id, previous_revision_id,
573
+ target_published_at, state,
514
574
  surface_names_json, pending_surface_names_json,
515
575
  prepared_surface_names_json, receipts_json, version,
516
576
  package_version, last_error, created_at, updated_at
@@ -548,7 +608,8 @@ export async function listPendingPublicationAttempts(
548
608
  const cursorClause = cursor ? 'AND (updated_at > ? OR (updated_at = ? AND id > ?))' : ''
549
609
  const result = await db
550
610
  .prepare(
551
- `SELECT id, operation, content_id, revision_id, previous_revision_id, state,
611
+ `SELECT id, operation, content_id, revision_id, previous_revision_id,
612
+ target_published_at, state,
552
613
  CASE WHEN length(CAST(surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
553
614
  THEN surface_names_json ELSE 'null' END AS surface_names_json,
554
615
  CASE WHEN length(CAST(pending_surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
@@ -587,6 +648,7 @@ async function createPublicationAttempt(
587
648
  contentId: string
588
649
  revisionId: string
589
650
  previousRevisionId: string | null
651
+ targetPublishedAt: number | null
590
652
  surfaceNames: string[]
591
653
  packageVersion: string | null
592
654
  now: number
@@ -605,6 +667,7 @@ function publicationAttemptInsert(
605
667
  contentId: string
606
668
  revisionId: string
607
669
  previousRevisionId: string | null
670
+ targetPublishedAt: number | null
608
671
  surfaceNames: string[]
609
672
  packageVersion: string | null
610
673
  now: number
@@ -613,11 +676,12 @@ function publicationAttemptInsert(
613
676
  return db
614
677
  .prepare(
615
678
  `INSERT INTO content_publication_attempts (
616
- id, operation, content_id, revision_id, previous_revision_id, state,
679
+ id, operation, content_id, revision_id, previous_revision_id,
680
+ target_published_at, state,
617
681
  surface_names_json, pending_surface_names_json,
618
682
  prepared_surface_names_json, receipts_json,
619
683
  package_version, created_at, updated_at
620
- ) VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)`,
684
+ ) VALUES (?, ?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)`,
621
685
  )
622
686
  .bind(
623
687
  id,
@@ -625,6 +689,7 @@ function publicationAttemptInsert(
625
689
  input.contentId,
626
690
  input.revisionId,
627
691
  input.previousRevisionId,
692
+ input.targetPublishedAt,
628
693
  JSON.stringify(input.surfaceNames),
629
694
  JSON.stringify(input.surfaceNames),
630
695
  input.packageVersion,
@@ -692,6 +757,7 @@ async function createRevisionAndPublicationAttempt(
692
757
  input: {
693
758
  contentId: string
694
759
  previousRevisionId: string | null
760
+ targetPublishedAt: number
695
761
  surfaceNames: string[]
696
762
  packageVersion: string | null
697
763
  now: number
@@ -724,6 +790,7 @@ async function createRevisionAndPublicationAttempt(
724
790
  contentId: input.contentId,
725
791
  revisionId,
726
792
  previousRevisionId: input.previousRevisionId,
793
+ targetPublishedAt: input.targetPublishedAt,
727
794
  surfaceNames: input.surfaceNames,
728
795
  packageVersion: input.packageVersion,
729
796
  now: input.now,
@@ -1511,6 +1578,35 @@ async function movePointerAndBeginCommit(
1511
1578
  attempt: PublicationAttempt,
1512
1579
  now: number,
1513
1580
  ): Promise<void> {
1581
+ if (
1582
+ attempt.operation === 'publish' &&
1583
+ (!Number.isSafeInteger(attempt.targetPublishedAt) || (attempt.targetPublishedAt as number) < 0)
1584
+ ) {
1585
+ throw new PublicationInputError(
1586
+ 'published_at_invalid',
1587
+ 'publication attempt has an invalid target publication time',
1588
+ )
1589
+ }
1590
+ const targetRevisionSlugAvailable = `EXISTS (
1591
+ SELECT 1
1592
+ FROM content_revisions target_revision
1593
+ WHERE target_revision.id = ?
1594
+ AND target_revision.content_id = ?
1595
+ AND json_type(target_revision.payload_json, '$.item.slug') = 'text'
1596
+ AND length(json_extract(target_revision.payload_json, '$.item.slug')) BETWEEN 1 AND 512
1597
+ AND NOT EXISTS (
1598
+ SELECT 1
1599
+ FROM content_items other_item
1600
+ JOIN content_revisions other_revision
1601
+ ON other_revision.id = other_item.published_revision_id
1602
+ AND other_revision.content_id = other_item.id
1603
+ WHERE other_item.id <> ?
1604
+ AND other_item.status = 'published'
1605
+ AND other_item.deleted_at IS NULL
1606
+ AND json_extract(other_revision.payload_json, '$.item.slug') =
1607
+ json_extract(target_revision.payload_json, '$.item.slug')
1608
+ )
1609
+ )`
1514
1610
  const pointerStatement =
1515
1611
  attempt.operation === 'publish'
1516
1612
  ? db
@@ -1523,16 +1619,20 @@ async function movePointerAndBeginCommit(
1523
1619
  AND EXISTS (
1524
1620
  SELECT 1 FROM content_publication_attempts
1525
1621
  WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
1526
- )`,
1622
+ )
1623
+ AND ${targetRevisionSlugAvailable}`,
1527
1624
  )
1528
1625
  .bind(
1529
- now,
1626
+ attempt.targetPublishedAt,
1530
1627
  attempt.revisionId,
1531
1628
  attempt.contentId,
1532
1629
  attempt.previousRevisionId ?? '',
1533
1630
  attempt.id,
1534
1631
  attempt.revisionId,
1535
1632
  attempt.contentId,
1633
+ attempt.revisionId,
1634
+ attempt.contentId,
1635
+ attempt.contentId,
1536
1636
  )
1537
1637
  : db
1538
1638
  .prepare(
@@ -1544,7 +1644,8 @@ async function movePointerAndBeginCommit(
1544
1644
  AND EXISTS (
1545
1645
  SELECT 1 FROM content_publication_attempts
1546
1646
  WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
1547
- )`,
1647
+ )
1648
+ AND ${targetRevisionSlugAvailable}`,
1548
1649
  )
1549
1650
  .bind(
1550
1651
  attempt.revisionId,
@@ -1553,6 +1654,9 @@ async function movePointerAndBeginCommit(
1553
1654
  attempt.id,
1554
1655
  attempt.revisionId,
1555
1656
  attempt.contentId,
1657
+ attempt.revisionId,
1658
+ attempt.contentId,
1659
+ attempt.contentId,
1556
1660
  )
1557
1661
  const attemptStatement = db
1558
1662
  .prepare(
@@ -1695,6 +1799,7 @@ export async function publishOne(
1695
1799
  input: PublishOneInput,
1696
1800
  ): Promise<PublishOneReceipt> {
1697
1801
  const now = input.now ?? nowSeconds()
1802
+ const targetPublishedAt = normalizePublishedAt(input.publishedAt, now)
1698
1803
  const surfaceHookTimeoutMs = normalizeSurfaceHookTimeoutMs(input.surfaceHookTimeoutMs)
1699
1804
  const surfaces = normalizeSurfaces(input)
1700
1805
  const item = await getContentItem(db, input.contentId)
@@ -1703,6 +1808,7 @@ export async function publishOne(
1703
1808
  const { revisionId, snapshot, attempt } = await createRevisionAndPublicationAttempt(db, {
1704
1809
  contentId: input.contentId,
1705
1810
  previousRevisionId,
1811
+ targetPublishedAt,
1706
1812
  surfaceNames: surfaces.map((surface) => surface.name),
1707
1813
  packageVersion: input.packageVersion ?? null,
1708
1814
  now,
@@ -1754,7 +1860,7 @@ export async function publishOne(
1754
1860
  contentId: input.contentId,
1755
1861
  revisionId,
1756
1862
  previousRevisionId,
1757
- publishedAt: now,
1863
+ publishedAt: targetPublishedAt,
1758
1864
  surfaceStatus,
1759
1865
  surfaces: committed.receipts,
1760
1866
  packageVersion: input.packageVersion ?? null,
@@ -1790,6 +1896,7 @@ export async function rollbackPublication(
1790
1896
  contentId: input.contentId,
1791
1897
  revisionId: input.targetRevisionId,
1792
1898
  previousRevisionId,
1899
+ targetPublishedAt: null,
1793
1900
  surfaceNames: surfaces.map((surface) => surface.name),
1794
1901
  packageVersion: input.packageVersion ?? null,
1795
1902
  now,
@@ -2033,6 +2140,18 @@ async function findContentBySlug(
2033
2140
  return { id: row.id, slug: row.slug, type }
2034
2141
  }
2035
2142
 
2143
+ async function findContentBySourceId(
2144
+ db: D1Database,
2145
+ sourceId: string,
2146
+ ): Promise<{ id: string; slug: string; type: ContentType } | null> {
2147
+ const row = await db
2148
+ .prepare('SELECT id, slug, type FROM content_items WHERE source_id = ? LIMIT 1')
2149
+ .bind(sourceId)
2150
+ .first<{ id: string; slug: string; type: ContentType }>()
2151
+ if (!row) return null
2152
+ return row
2153
+ }
2154
+
2036
2155
  function createInputForArchiveItem(
2037
2156
  item: ContentArchiveItem,
2038
2157
  type: 'article' | 'newsletter' | 'page',
@@ -2042,13 +2161,33 @@ function createInputForArchiveItem(
2042
2161
  const base = {
2043
2162
  type,
2044
2163
  slug,
2164
+ sourceId: item.sourceId ?? null,
2045
2165
  title: item.title ?? 'Untitled',
2166
+ seoTitle: item.seoTitle ?? null,
2167
+ description: item.description ?? null,
2046
2168
  excerpt: item.excerpt ?? null,
2169
+ byline: item.byline ?? null,
2047
2170
  primaryCategory: item.primaryCategory ?? options.defaultPrimaryCategory ?? 'analysis',
2048
2171
  primaryTopic: item.primaryTopic ?? options.defaultPrimaryTopic ?? null,
2172
+ featured: item.featured ?? false,
2173
+ visibility: item.visibility ?? 'free',
2174
+ authorId: item.authorId ?? null,
2175
+ heroImageId: item.heroImageId ?? null,
2176
+ heroImageAlt: item.heroImageAlt ?? null,
2177
+ heroImageCaption: item.heroImageCaption ?? null,
2178
+ socialImageId: item.socialImageId ?? null,
2179
+ canonicalUrl: item.canonicalUrl ?? null,
2049
2180
  tags: item.tags ?? [],
2181
+ metadata: item.metadata,
2182
+ initialTimestamps: {
2183
+ publishedAt: item.publishedAt ?? null,
2184
+ createdAt: item.createdAt ?? null,
2185
+ updatedAt: item.updatedAt ?? null,
2186
+ publishTz: item.publishTz ?? null,
2187
+ },
2050
2188
  content: {
2051
2189
  bodyMarkdown: item.bodyMarkdown ?? ' ',
2190
+ bodyHtml: item.bodyHtml ?? null,
2052
2191
  },
2053
2192
  }
2054
2193
  if (type === 'article') return { ...base, type: 'article' }
@@ -2056,6 +2195,164 @@ function createInputForArchiveItem(
2056
2195
  return { ...base, type: 'page' }
2057
2196
  }
2058
2197
 
2198
+ function assertArchiveTimestamp(field: string, value: number | null | undefined): void {
2199
+ if (value === null || value === undefined) return
2200
+ if (!Number.isSafeInteger(value) || value < 0) {
2201
+ throw new ContentArchiveImportLimitError(
2202
+ 'archive_timestamp_invalid',
2203
+ `${field} must be a non-negative epoch second`,
2204
+ Number.MAX_SAFE_INTEGER,
2205
+ value,
2206
+ )
2207
+ }
2208
+ }
2209
+
2210
+ function validateArchiveItem(item: ContentArchiveItem, maxBodyBytes: number): void {
2211
+ const optionalStrings: Array<[string, unknown]> = [
2212
+ ['slug', item.slug],
2213
+ ['seo_title', item.seoTitle],
2214
+ ['description', item.description],
2215
+ ['excerpt', item.excerpt],
2216
+ ['byline', item.byline],
2217
+ ['primary_category', item.primaryCategory],
2218
+ ['primary_topic', item.primaryTopic],
2219
+ ['author_id', item.authorId],
2220
+ ['hero_image_id', item.heroImageId],
2221
+ ['hero_image_alt', item.heroImageAlt],
2222
+ ['hero_image_caption', item.heroImageCaption],
2223
+ ['social_image_id', item.socialImageId],
2224
+ ['canonical_url', item.canonicalUrl],
2225
+ ]
2226
+ for (const [field, value] of optionalStrings) {
2227
+ if (value !== null && value !== undefined && typeof value !== 'string') {
2228
+ throw new ContentArchiveImportLimitError(
2229
+ `archive_${field}_invalid`,
2230
+ `${field} must be a string or null`,
2231
+ 0,
2232
+ -1,
2233
+ )
2234
+ }
2235
+ }
2236
+ if (item.featured !== undefined && typeof item.featured !== 'boolean') {
2237
+ throw new ContentArchiveImportLimitError(
2238
+ 'archive_featured_invalid',
2239
+ 'featured must be a boolean',
2240
+ 0,
2241
+ -1,
2242
+ )
2243
+ }
2244
+ if (item.visibility !== undefined && !['free', 'premium'].includes(item.visibility)) {
2245
+ throw new ContentArchiveImportLimitError(
2246
+ 'archive_visibility_invalid',
2247
+ 'visibility must be free or premium',
2248
+ 0,
2249
+ -1,
2250
+ )
2251
+ }
2252
+ if (
2253
+ item.tags !== undefined &&
2254
+ (!Array.isArray(item.tags) ||
2255
+ item.tags.length > 10 ||
2256
+ !item.tags.every((tag) => typeof tag === 'string'))
2257
+ ) {
2258
+ throw new ContentArchiveImportLimitError(
2259
+ 'archive_tags_invalid',
2260
+ 'tags must contain at most 10 strings',
2261
+ 10,
2262
+ Array.isArray(item.tags) ? item.tags.length : -1,
2263
+ )
2264
+ }
2265
+ if (item.bodyMarkdown !== undefined && typeof item.bodyMarkdown !== 'string') {
2266
+ throw new ContentArchiveImportLimitError(
2267
+ 'archive_body_invalid',
2268
+ 'archive item Markdown body must be a string',
2269
+ maxBodyBytes,
2270
+ -1,
2271
+ )
2272
+ }
2273
+ if (item.bodyHtml !== null && item.bodyHtml !== undefined && typeof item.bodyHtml !== 'string') {
2274
+ throw new ContentArchiveImportLimitError(
2275
+ 'archive_body_html_invalid',
2276
+ 'archive item rendered HTML must be a string or null',
2277
+ maxBodyBytes,
2278
+ -1,
2279
+ )
2280
+ }
2281
+ const bodyMarkdown = item.bodyMarkdown ?? ' '
2282
+ assertLimit(
2283
+ 'archive_body_limit_exceeded',
2284
+ 'archive item body exceeds the configured import limit',
2285
+ maxBodyBytes,
2286
+ textBytes(bodyMarkdown),
2287
+ )
2288
+ if (item.bodyHtml !== null && item.bodyHtml !== undefined) {
2289
+ assertLimit(
2290
+ 'archive_body_html_limit_exceeded',
2291
+ 'archive item rendered HTML exceeds the configured import limit',
2292
+ maxBodyBytes,
2293
+ textBytes(item.bodyHtml),
2294
+ )
2295
+ }
2296
+ try {
2297
+ serializeContentMetadata(item.metadata)
2298
+ } catch (error) {
2299
+ if (!(error instanceof ContentMetadataError)) throw error
2300
+ throw new ContentArchiveImportLimitError(
2301
+ error.code === 'content_metadata_limit_exceeded'
2302
+ ? 'archive_metadata_limit_exceeded'
2303
+ : 'archive_metadata_invalid',
2304
+ 'archive item metadata is invalid or exceeds its configured limit',
2305
+ MAX_CONTENT_METADATA_BYTES,
2306
+ -1,
2307
+ )
2308
+ }
2309
+ assertArchiveTimestamp('publishedAt', item.publishedAt)
2310
+ assertArchiveTimestamp('createdAt', item.createdAt)
2311
+ assertArchiveTimestamp('updatedAt', item.updatedAt)
2312
+ if (
2313
+ item.publishTz !== null &&
2314
+ item.publishTz !== undefined &&
2315
+ typeof item.publishTz !== 'string'
2316
+ ) {
2317
+ throw new ContentArchiveImportLimitError(
2318
+ 'archive_publish_tz_invalid',
2319
+ 'archive item publishTz must be a string or null',
2320
+ 64,
2321
+ -1,
2322
+ )
2323
+ }
2324
+ if (
2325
+ typeof item.publishTz === 'string' &&
2326
+ (!item.publishTz.trim() || item.publishTz.length > 64)
2327
+ ) {
2328
+ throw new ContentArchiveImportLimitError(
2329
+ 'archive_publish_tz_invalid',
2330
+ 'archive item publishTz must be a non-empty string of at most 64 characters',
2331
+ 64,
2332
+ item.publishTz.length,
2333
+ )
2334
+ }
2335
+ if (item.sourceId !== undefined && typeof item.sourceId !== 'string') {
2336
+ throw new ContentArchiveImportLimitError(
2337
+ 'archive_source_id_invalid',
2338
+ 'archive item sourceId must be a string',
2339
+ 256,
2340
+ -1,
2341
+ )
2342
+ }
2343
+ if (
2344
+ typeof item.sourceId === 'string' &&
2345
+ (!item.sourceId.trim() || item.sourceId !== item.sourceId.trim() || item.sourceId.length > 256)
2346
+ ) {
2347
+ throw new ContentArchiveImportLimitError(
2348
+ 'archive_source_id_invalid',
2349
+ 'archive item sourceId must be a trimmed non-empty string of at most 256 characters',
2350
+ 256,
2351
+ item.sourceId.length,
2352
+ )
2353
+ }
2354
+ }
2355
+
2059
2356
  export async function importContentArchive(
2060
2357
  db: D1Database,
2061
2358
  archive: ContentArchiveInput,
@@ -2098,6 +2395,20 @@ export async function importContentArchive(
2098
2395
  )
2099
2396
  }
2100
2397
  const end = Math.min(items.length, cursor + batchSize)
2398
+ // Validate the complete selected batch before the first write. D1's injected
2399
+ // structural surface has no transaction callback, so preflight prevents a
2400
+ // malformed later item from leaving earlier drafts partially imported.
2401
+ for (let index = cursor; index < end; index += 1) {
2402
+ const item = items[index]
2403
+ if (
2404
+ !item ||
2405
+ !bodyBackedArchiveType(item.type) ||
2406
+ typeof item.title !== 'string' ||
2407
+ !item.title.trim()
2408
+ )
2409
+ continue
2410
+ validateArchiveItem(item, maxBodyBytes)
2411
+ }
2101
2412
  for (let index = cursor; index < end; index += 1) {
2102
2413
  const item = items[index]
2103
2414
  if (!item) continue
@@ -2106,20 +2417,14 @@ export async function importContentArchive(
2106
2417
  skipped.push({ index, reason: 'unsupported_type', title: item.title ?? null })
2107
2418
  continue
2108
2419
  }
2109
- if (!item.title?.trim()) {
2420
+ if (typeof item.title !== 'string' || !item.title.trim()) {
2110
2421
  skipped.push({ index, reason: 'missing_title', title: null })
2111
2422
  continue
2112
2423
  }
2113
2424
  const requestedSlug = item.slug?.trim() || item.title
2114
2425
  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)
2426
+ const existingBySource = item.sourceId ? await findContentBySourceId(db, item.sourceId) : null
2427
+ const existing = existingBySource ?? (await findContentBySlug(db, baseSlug))
2123
2428
  if (existing) {
2124
2429
  skipped.push({
2125
2430
  index,
@@ -2127,12 +2432,18 @@ export async function importContentArchive(
2127
2432
  title: item.title,
2128
2433
  slug: existing.slug,
2129
2434
  existingId: existing.id,
2435
+ sourceId: item.sourceId,
2130
2436
  })
2131
2437
  continue
2132
2438
  }
2133
2439
  const slug = await ensureUniqueSlug(db, requestedSlug)
2134
2440
  const result = await createContent(db, createInputForArchiveItem(item, type, slug, options))
2135
- created.push({ id: result.id, slug: result.slug, type })
2441
+ created.push({
2442
+ id: result.id,
2443
+ slug: result.slug,
2444
+ type,
2445
+ ...(item.sourceId ? { sourceId: item.sourceId } : {}),
2446
+ })
2136
2447
  }
2137
2448
  const nextCursor = end >= items.length ? null : end
2138
2449
  return {