@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
package/src/engine/publisher.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// duplicate is NOT re-queued to Foundry. Any future `content_items` column
|
|
21
21
|
// addition requires a matching edit here or the duplicate silently drops it.
|
|
22
22
|
import type { ContentType } from '../schema/types.js'
|
|
23
|
+
import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
|
|
23
24
|
import type { D1Database } from './d1.js'
|
|
24
25
|
import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
|
|
25
26
|
import { slugify } from './slug.js'
|
|
@@ -95,6 +96,8 @@ export interface BaseContentInput {
|
|
|
95
96
|
tags?: string[]
|
|
96
97
|
/** The SEO focus keyword for this content item (migration 0004 column). */
|
|
97
98
|
seoFocusKeyword?: string | null
|
|
99
|
+
/** Declared site-specific JSON values retained in every immutable revision. */
|
|
100
|
+
metadata?: ContentMetadata
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export interface ArticleInput extends BaseContentInput {
|
|
@@ -154,12 +157,23 @@ export interface PageInput extends BaseContentInput {
|
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
export type CreateContentInput =
|
|
160
|
+
export type CreateContentInput = (
|
|
158
161
|
| ArticleInput
|
|
159
162
|
| VideoInput
|
|
160
163
|
| PodcastInput
|
|
161
164
|
| NewsletterInput
|
|
162
165
|
| PageInput
|
|
166
|
+
) & {
|
|
167
|
+
/** Optional namespaced archive key. New copies deliberately do not inherit it. */
|
|
168
|
+
sourceId?: string | null
|
|
169
|
+
/** Initial historical values for archive ingestion; normal creates omit this. */
|
|
170
|
+
initialTimestamps?: {
|
|
171
|
+
publishedAt?: number | null
|
|
172
|
+
createdAt?: number | null
|
|
173
|
+
updatedAt?: number | null
|
|
174
|
+
publishTz?: string | null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
163
177
|
|
|
164
178
|
type BodyBackedContentInput = ArticleInput | NewsletterInput | PageInput
|
|
165
179
|
|
|
@@ -215,10 +229,57 @@ export interface ContentItemRecord {
|
|
|
215
229
|
ai_locked_fields: string | null
|
|
216
230
|
published_revision_id: string | null
|
|
217
231
|
seo_focus_keyword: string | null
|
|
232
|
+
metadata_json: string
|
|
233
|
+
source_id: string | null
|
|
218
234
|
created_at: number | null
|
|
219
235
|
updated_at: number | null
|
|
220
236
|
}
|
|
221
237
|
|
|
238
|
+
function normalizeSourceId(value: unknown): string | null {
|
|
239
|
+
if (value === null || value === undefined) return null
|
|
240
|
+
if (typeof value !== 'string' || !value || value !== value.trim() || value.length > 256) {
|
|
241
|
+
throw new TypeError('sourceId must be a trimmed non-empty string of at most 256 characters')
|
|
242
|
+
}
|
|
243
|
+
return value
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeInitialTimestamps(value: CreateContentInput['initialTimestamps']): {
|
|
247
|
+
publishedAt: number | null
|
|
248
|
+
createdAt: number | null
|
|
249
|
+
updatedAt: number | null
|
|
250
|
+
publishTz: string | null
|
|
251
|
+
} {
|
|
252
|
+
const timestamps = value ?? {}
|
|
253
|
+
for (const [field, candidate] of [
|
|
254
|
+
['publishedAt', timestamps.publishedAt],
|
|
255
|
+
['createdAt', timestamps.createdAt],
|
|
256
|
+
['updatedAt', timestamps.updatedAt],
|
|
257
|
+
] as const) {
|
|
258
|
+
if (candidate !== null && candidate !== undefined) {
|
|
259
|
+
if (!Number.isSafeInteger(candidate) || candidate < 0) {
|
|
260
|
+
throw new TypeError(`${field} must be a non-negative epoch second`)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const publishTz = timestamps.publishTz
|
|
265
|
+
if (
|
|
266
|
+
publishTz !== null &&
|
|
267
|
+
publishTz !== undefined &&
|
|
268
|
+
(typeof publishTz !== 'string' ||
|
|
269
|
+
!publishTz.trim() ||
|
|
270
|
+
publishTz !== publishTz.trim() ||
|
|
271
|
+
publishTz.length > 64)
|
|
272
|
+
) {
|
|
273
|
+
throw new TypeError('publishTz must be a trimmed non-empty string of at most 64 characters')
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
publishedAt: timestamps.publishedAt ?? null,
|
|
277
|
+
createdAt: timestamps.createdAt ?? null,
|
|
278
|
+
updatedAt: timestamps.updatedAt ?? null,
|
|
279
|
+
publishTz: publishTz ?? null,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
222
283
|
export async function ensureUniqueSlug(
|
|
223
284
|
db: D1Database,
|
|
224
285
|
slug: string,
|
|
@@ -229,12 +290,28 @@ export async function ensureUniqueSlug(
|
|
|
229
290
|
let suffix = 2
|
|
230
291
|
|
|
231
292
|
while (true) {
|
|
232
|
-
const
|
|
293
|
+
const draftRow = await db
|
|
233
294
|
.prepare('SELECT id FROM content_items WHERE slug = ? LIMIT 1')
|
|
234
295
|
.bind(candidate)
|
|
235
296
|
.first<{ id: string }>()
|
|
297
|
+
const publishedRow = await db
|
|
298
|
+
.prepare(
|
|
299
|
+
`SELECT ci.id
|
|
300
|
+
FROM content_items ci
|
|
301
|
+
JOIN content_revisions cr
|
|
302
|
+
ON cr.id = ci.published_revision_id AND cr.content_id = ci.id
|
|
303
|
+
WHERE ci.status = 'published'
|
|
304
|
+
AND ci.deleted_at IS NULL
|
|
305
|
+
AND json_extract(cr.payload_json, '$.item.slug') = ?
|
|
306
|
+
LIMIT 1`,
|
|
307
|
+
)
|
|
308
|
+
.bind(candidate)
|
|
309
|
+
.first<{ id: string }>()
|
|
236
310
|
|
|
237
|
-
|
|
311
|
+
const draftAvailable = !draftRow || (excludeId !== undefined && draftRow.id === excludeId)
|
|
312
|
+
const publishedAvailable =
|
|
313
|
+
!publishedRow || (excludeId !== undefined && publishedRow.id === excludeId)
|
|
314
|
+
if (draftAvailable && publishedAvailable) {
|
|
238
315
|
return candidate
|
|
239
316
|
}
|
|
240
317
|
|
|
@@ -343,6 +420,9 @@ export async function createContent(
|
|
|
343
420
|
const slug = await ensureUniqueSlug(db, input.slug)
|
|
344
421
|
const visibility = input.visibility || 'free'
|
|
345
422
|
const primaryCategory = input.primaryCategory || 'analysis'
|
|
423
|
+
const metadataJson = serializeContentMetadata(input.metadata)
|
|
424
|
+
const sourceId = normalizeSourceId(input.sourceId)
|
|
425
|
+
const initialTimestamps = normalizeInitialTimestamps(input.initialTimestamps)
|
|
346
426
|
|
|
347
427
|
await db
|
|
348
428
|
.prepare(
|
|
@@ -350,8 +430,10 @@ export async function createContent(
|
|
|
350
430
|
INSERT INTO content_items (
|
|
351
431
|
id, type, status, visibility, slug, title, seo_title, description, excerpt,
|
|
352
432
|
byline, channel, primary_topic, featured, author_id, hero_image_id,
|
|
353
|
-
hero_image_alt, hero_image_caption, social_image_id, canonical_url
|
|
354
|
-
|
|
433
|
+
hero_image_alt, hero_image_caption, social_image_id, canonical_url,
|
|
434
|
+
metadata_json, source_id, published_at, created_at, updated_at, publish_tz
|
|
435
|
+
) VALUES (?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
436
|
+
COALESCE(?, unixepoch()), COALESCE(?, unixepoch()), COALESCE(?, 'Europe/Paris'))
|
|
355
437
|
`,
|
|
356
438
|
)
|
|
357
439
|
.bind(
|
|
@@ -373,6 +455,12 @@ export async function createContent(
|
|
|
373
455
|
input.heroImageCaption || null,
|
|
374
456
|
input.socialImageId || null,
|
|
375
457
|
input.canonicalUrl || null,
|
|
458
|
+
metadataJson,
|
|
459
|
+
sourceId,
|
|
460
|
+
initialTimestamps.publishedAt,
|
|
461
|
+
initialTimestamps.createdAt,
|
|
462
|
+
initialTimestamps.updatedAt,
|
|
463
|
+
initialTimestamps.publishTz,
|
|
376
464
|
)
|
|
377
465
|
.run()
|
|
378
466
|
|
|
@@ -505,6 +593,9 @@ export async function updateContentItem(
|
|
|
505
593
|
const seoFocusKeyword = hasOwn(input, 'seoFocusKeyword')
|
|
506
594
|
? (input.seoFocusKeyword ?? null)
|
|
507
595
|
: (existing.seo_focus_keyword ?? null)
|
|
596
|
+
const metadataJson = hasOwn(input, 'metadata')
|
|
597
|
+
? serializeContentMetadata(input.metadata)
|
|
598
|
+
: existing.metadata_json
|
|
508
599
|
|
|
509
600
|
await db
|
|
510
601
|
.prepare(
|
|
@@ -528,6 +619,7 @@ export async function updateContentItem(
|
|
|
528
619
|
canonical_url = ?,
|
|
529
620
|
ai_locked_fields = ?,
|
|
530
621
|
seo_focus_keyword = ?,
|
|
622
|
+
metadata_json = ?,
|
|
531
623
|
updated_at = unixepoch()
|
|
532
624
|
WHERE id = ?
|
|
533
625
|
`,
|
|
@@ -551,6 +643,7 @@ export async function updateContentItem(
|
|
|
551
643
|
canonicalUrl,
|
|
552
644
|
aiLockedFields,
|
|
553
645
|
seoFocusKeyword,
|
|
646
|
+
metadataJson,
|
|
554
647
|
id,
|
|
555
648
|
)
|
|
556
649
|
.run()
|
|
@@ -915,7 +1008,8 @@ export async function duplicateContentItem(
|
|
|
915
1008
|
// DEFAULT (unixepoch()) timestamps; status forced to 'draft', featured to
|
|
916
1009
|
// 0, publish_at/published_at/published_revision_id to NULL. (inventory §6)
|
|
917
1010
|
//
|
|
918
|
-
// SEO
|
|
1011
|
+
// SEO and declared metadata carry over (seo_title, seo_focus_keyword,
|
|
1012
|
+
// seo_score, ai_og_image_id, metadata_json)
|
|
919
1013
|
// — a duplicate should keep the SEO work already done on the source. But
|
|
920
1014
|
// board_position is the source item's slot in the editorial board ordering;
|
|
921
1015
|
// a fresh draft copy must NOT inherit it, so it is reset to NULL.
|
|
@@ -927,7 +1021,8 @@ export async function duplicateContentItem(
|
|
|
927
1021
|
hero_image_id, hero_image_alt, hero_image_caption, social_image_id,
|
|
928
1022
|
canonical_url, publish_at, published_at,
|
|
929
1023
|
publish_tz, ai_locked_fields, published_revision_id,
|
|
930
|
-
seo_focus_keyword, seo_score, ai_og_image_id, board_position
|
|
1024
|
+
seo_focus_keyword, seo_score, ai_og_image_id, board_position,
|
|
1025
|
+
metadata_json
|
|
931
1026
|
)
|
|
932
1027
|
SELECT
|
|
933
1028
|
?, type, 'draft', visibility, ?, title || ' (Copy)', seo_title, description, excerpt,
|
|
@@ -935,7 +1030,8 @@ export async function duplicateContentItem(
|
|
|
935
1030
|
hero_image_id, hero_image_alt, hero_image_caption, social_image_id,
|
|
936
1031
|
canonical_url, NULL, NULL,
|
|
937
1032
|
publish_tz, ai_locked_fields, NULL,
|
|
938
|
-
seo_focus_keyword, seo_score, ai_og_image_id, NULL
|
|
1033
|
+
seo_focus_keyword, seo_score, ai_og_image_id, NULL,
|
|
1034
|
+
metadata_json
|
|
939
1035
|
FROM content_items
|
|
940
1036
|
WHERE id = ?`,
|
|
941
1037
|
)
|
|
@@ -1047,14 +1143,14 @@ export async function getContentSnapshot(
|
|
|
1047
1143
|
|
|
1048
1144
|
const tags = await db
|
|
1049
1145
|
.prepare(
|
|
1050
|
-
'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ?',
|
|
1146
|
+
'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ? ORDER BY t.slug ASC',
|
|
1051
1147
|
)
|
|
1052
1148
|
.bind(contentId)
|
|
1053
1149
|
.all<{ slug: string }>()
|
|
1054
1150
|
|
|
1055
1151
|
const related = await db
|
|
1056
1152
|
.prepare(
|
|
1057
|
-
'SELECT related_id, rank, reason FROM content_relations WHERE content_id = ? ORDER BY rank ASC',
|
|
1153
|
+
'SELECT related_id, rank, reason FROM content_relations WHERE content_id = ? ORDER BY rank ASC, related_id ASC',
|
|
1058
1154
|
)
|
|
1059
1155
|
.bind(contentId)
|
|
1060
1156
|
.all<{ related_id: string; rank: number; reason: string | null }>()
|
package/src/engine/revisions.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// (words_added, words_removed, tag). Snapshot shape is formalized as
|
|
6
6
|
// ContentRevisionPayload, aligned to getContentSnapshot's output.
|
|
7
7
|
|
|
8
|
+
import { parseContentMetadata } from './content-metadata.js'
|
|
8
9
|
import type { D1Database } from './d1.js'
|
|
9
10
|
import { countWords, getContentItem, getContentSnapshot, updateContentItem } from './publisher.js'
|
|
10
11
|
|
|
@@ -233,6 +234,9 @@ export async function restoreRevision(
|
|
|
233
234
|
canonicalUrl: (item.canonical_url as string | null) ?? null,
|
|
234
235
|
// SEO focus keyword is part of the snapshot — restore it too.
|
|
235
236
|
seoFocusKeyword: (item.seo_focus_keyword as string | null) ?? null,
|
|
237
|
+
metadata: parseContentMetadata(
|
|
238
|
+
typeof item.metadata_json === 'string' ? item.metadata_json : '{}',
|
|
239
|
+
),
|
|
236
240
|
tags: target.payload.tags,
|
|
237
241
|
relations: target.payload.related.map((r) => ({
|
|
238
242
|
relatedId: r.related_id,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { link, unlink } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
export interface StagedMigrationVendorFile {
|
|
4
|
+
tempPath: string
|
|
5
|
+
targetPath: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface MigrationVendorFileOperations {
|
|
9
|
+
link(source: string, target: string): Promise<void>
|
|
10
|
+
unlink(path: string): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_OPERATIONS: MigrationVendorFileOperations = { link, unlink }
|
|
14
|
+
|
|
15
|
+
async function removePaths(
|
|
16
|
+
paths: string[],
|
|
17
|
+
operations: MigrationVendorFileOperations,
|
|
18
|
+
): Promise<unknown[]> {
|
|
19
|
+
const errors: unknown[] = []
|
|
20
|
+
for (const path of [...paths].reverse()) {
|
|
21
|
+
try {
|
|
22
|
+
await operations.unlink(path)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') errors.push(error)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return errors
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function rollbackNewFiles(
|
|
31
|
+
paths: string[],
|
|
32
|
+
operations: MigrationVendorFileOperations = DEFAULT_OPERATIONS,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const errors = await removePaths(paths, operations)
|
|
35
|
+
if (errors.length > 0) {
|
|
36
|
+
throw new AggregateError(errors, 'failed to roll back newly linked CMS migration files')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Link every pre-staged file without replacing an existing directory entry.
|
|
42
|
+
* A later failure removes all targets linked by this attempt and every temp.
|
|
43
|
+
*/
|
|
44
|
+
export async function commitNewFilesAtomically(
|
|
45
|
+
files: StagedMigrationVendorFile[],
|
|
46
|
+
operations: MigrationVendorFileOperations = DEFAULT_OPERATIONS,
|
|
47
|
+
): Promise<string[]> {
|
|
48
|
+
const committed: string[] = []
|
|
49
|
+
let failure: unknown
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
await operations.link(file.tempPath, file.targetPath)
|
|
54
|
+
committed.push(file.targetPath)
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
failure = error
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cleanupErrors = await removePaths(
|
|
61
|
+
files.map((file) => file.tempPath),
|
|
62
|
+
operations,
|
|
63
|
+
)
|
|
64
|
+
if (failure) {
|
|
65
|
+
const rollbackErrors = await removePaths(committed, operations)
|
|
66
|
+
const errors = [failure, ...cleanupErrors, ...rollbackErrors].filter(
|
|
67
|
+
(error): error is NonNullable<unknown> => error !== undefined,
|
|
68
|
+
)
|
|
69
|
+
const primary = failure instanceof Error ? `: ${failure.message}` : ''
|
|
70
|
+
throw new AggregateError(errors, `CMS migration file commit failed${primary}`)
|
|
71
|
+
}
|
|
72
|
+
// A hard-linked target remains valid when unlinking only its staged temp
|
|
73
|
+
// fails. The caller's outer finally retries temp cleanup; rolling targets
|
|
74
|
+
// back here would turn a harmless leftover temp into data loss.
|
|
75
|
+
|
|
76
|
+
return committed
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Commit staged SQL, then run the receipt/final verification boundary. */
|
|
80
|
+
export async function commitStagedMigrationBatch(
|
|
81
|
+
files: StagedMigrationVendorFile[],
|
|
82
|
+
finalize: () => Promise<void>,
|
|
83
|
+
operations: MigrationVendorFileOperations = DEFAULT_OPERATIONS,
|
|
84
|
+
): Promise<string[]> {
|
|
85
|
+
const committed = await commitNewFilesAtomically(files, operations)
|
|
86
|
+
try {
|
|
87
|
+
await finalize()
|
|
88
|
+
return committed
|
|
89
|
+
} catch (error) {
|
|
90
|
+
try {
|
|
91
|
+
await rollbackNewFiles(committed, operations)
|
|
92
|
+
} catch (rollbackError) {
|
|
93
|
+
throw new AggregateError(
|
|
94
|
+
[error, rollbackError],
|
|
95
|
+
'CMS migration batch finalization failed and rollback was incomplete',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
throw error
|
|
99
|
+
}
|
|
100
|
+
}
|