@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.
- package/README.md +101 -5
- package/dist/cli/migrations.d.ts +3 -0
- package/dist/cli/migrations.d.ts.map +1 -0
- package/dist/cli/migrations.js +73 -0
- package/dist/cli/migrations.js.map +1 -0
- package/dist/engine/content-metadata.d.ts +13 -0
- package/dist/engine/content-metadata.d.ts.map +1 -0
- package/dist/engine/content-metadata.js +80 -0
- package/dist/engine/content-metadata.js.map +1 -0
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +5 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication.d.ts +29 -1
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +197 -15
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/published-content.d.ts +40 -0
- package/dist/engine/published-content.d.ts.map +1 -0
- package/dist/engine/published-content.js +388 -0
- package/dist/engine/published-content.js.map +1 -0
- package/dist/engine/publisher.d.ts +16 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +74 -11
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +2 -0
- package/dist/engine/revisions.js.map +1 -1
- package/dist/migration-vendor-files.d.ts +17 -0
- package/dist/migration-vendor-files.d.ts.map +1 -0
- package/dist/migration-vendor-files.js +67 -0
- package/dist/migration-vendor-files.js.map +1 -0
- package/dist/migration-vendor.d.ts +26 -0
- package/dist/migration-vendor.d.ts.map +1 -0
- package/dist/migration-vendor.js +321 -0
- package/dist/migration-vendor.js.map +1 -0
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +24 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +6 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0020_content_metadata_read_model.sql +19 -0
- package/migrations/0021_published_revision_slug_index.sql +5 -0
- package/package.json +8 -1
- package/src/cli/migrations.ts +85 -0
- package/src/engine/content-metadata.ts +122 -0
- package/src/engine/index.ts +26 -0
- package/src/engine/publication.ts +329 -18
- package/src/engine/published-content.ts +581 -0
- package/src/engine/publisher.ts +106 -10
- package/src/engine/revisions.ts +4 -0
- package/src/migration-vendor-files.ts +100 -0
- package/src/migration-vendor.ts +450 -0
- package/src/schema/migrations.ts +24 -0
- 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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
2116
|
-
|
|
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({
|
|
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 {
|