@growth-labs/cms 0.3.1 → 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 (41) hide show
  1. package/README.md +58 -5
  2. package/dist/engine/content-metadata.d.ts +13 -0
  3. package/dist/engine/content-metadata.d.ts.map +1 -0
  4. package/dist/engine/content-metadata.js +80 -0
  5. package/dist/engine/content-metadata.js.map +1 -0
  6. package/dist/engine/index.d.ts +3 -1
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +5 -1
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/engine/publication.d.ts +29 -1
  11. package/dist/engine/publication.d.ts.map +1 -1
  12. package/dist/engine/publication.js +197 -15
  13. package/dist/engine/publication.js.map +1 -1
  14. package/dist/engine/published-content.d.ts +40 -0
  15. package/dist/engine/published-content.d.ts.map +1 -0
  16. package/dist/engine/published-content.js +388 -0
  17. package/dist/engine/published-content.js.map +1 -0
  18. package/dist/engine/publisher.d.ts +16 -1
  19. package/dist/engine/publisher.d.ts.map +1 -1
  20. package/dist/engine/publisher.js +74 -11
  21. package/dist/engine/publisher.js.map +1 -1
  22. package/dist/engine/revisions.d.ts.map +1 -1
  23. package/dist/engine/revisions.js +2 -0
  24. package/dist/engine/revisions.js.map +1 -1
  25. package/dist/schema/migrations.d.ts.map +1 -1
  26. package/dist/schema/migrations.js +24 -0
  27. package/dist/schema/migrations.js.map +1 -1
  28. package/dist/schema/types.d.ts +6 -0
  29. package/dist/schema/types.d.ts.map +1 -1
  30. package/dist/schema/types.js.map +1 -1
  31. package/migrations/0020_content_metadata_read_model.sql +19 -0
  32. package/migrations/0021_published_revision_slug_index.sql +5 -0
  33. package/package.json +1 -1
  34. package/src/engine/content-metadata.ts +122 -0
  35. package/src/engine/index.ts +26 -0
  36. package/src/engine/publication.ts +329 -18
  37. package/src/engine/published-content.ts +581 -0
  38. package/src/engine/publisher.ts +106 -10
  39. package/src/engine/revisions.ts +4 -0
  40. package/src/schema/migrations.ts +24 -0
  41. package/src/schema/types.ts +6 -0
@@ -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 row = await db
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
- if (!row || (excludeId && row.id === excludeId)) {
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
- ) VALUES (?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 carries over (seo_title, seo_focus_keyword, seo_score, ai_og_image_id)
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 }>()
@@ -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,
@@ -778,6 +778,30 @@ CREATE INDEX idx_cms_reader_state_export
778
778
  ON cms_reader_state(identity_user_id, site_id, created_at DESC, content_type, content_id);
779
779
  CREATE INDEX idx_cms_reader_state_saved
780
780
  ON cms_reader_state(identity_user_id, site_id, saved, updated_at DESC);
781
+ `,
782
+ },
783
+ {
784
+ id: '0020_content_metadata_read_model',
785
+ sql: `
786
+ ALTER TABLE content_items
787
+ ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';
788
+ ALTER TABLE content_items
789
+ ADD COLUMN source_id TEXT;
790
+ CREATE UNIQUE INDEX idx_content_items_source_id_unique
791
+ ON content_items(source_id)
792
+ WHERE source_id IS NOT NULL;
793
+ ALTER TABLE content_publication_attempts
794
+ ADD COLUMN target_published_at INTEGER;
795
+ UPDATE content_publication_attempts
796
+ SET target_published_at = created_at
797
+ WHERE operation = 'publish' AND target_published_at IS NULL;
798
+ `,
799
+ },
800
+ {
801
+ id: '0021_published_revision_slug_index',
802
+ sql: `
803
+ CREATE INDEX IF NOT EXISTS idx_content_revisions_published_slug
804
+ ON content_revisions(json_extract(payload_json, '$.item.slug'));
781
805
  `,
782
806
  },
783
807
  ]
@@ -70,6 +70,10 @@ export interface ContentItemRow {
70
70
  seo_focus_keyword: string | null
71
71
  /** AI-generated OG image asset id (migration 0004). */
72
72
  ai_og_image_id: string | null
73
+ /** Bounded JSON object for declared site-specific presentation metadata (migration 0020). */
74
+ metadata_json: string
75
+ /** Optional namespaced source key used for idempotent archive imports (migration 0020). */
76
+ source_id: string | null
73
77
  }
74
78
 
75
79
  /** Standalone topic taxonomy row. Counts are derived from content_items.primary_topic. */
@@ -555,6 +559,8 @@ export interface ContentPublicationAttemptRow {
555
559
  content_id: string
556
560
  revision_id: string
557
561
  previous_revision_id: string | null
562
+ /** Explicit historical publication timestamp for crash-safe publish retries. */
563
+ target_published_at: number | null
558
564
  state: ContentPublicationAttemptState
559
565
  surface_names_json: string
560
566
  pending_surface_names_json: string