@growth-labs/cms 0.8.30 → 0.8.32

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 (38) hide show
  1. package/dist/engine/content-metadata.d.ts +2 -0
  2. package/dist/engine/content-metadata.d.ts.map +1 -1
  3. package/dist/engine/content-metadata.js +15 -0
  4. package/dist/engine/content-metadata.js.map +1 -1
  5. package/dist/engine/publication.d.ts.map +1 -1
  6. package/dist/engine/publication.js +23 -3
  7. package/dist/engine/publication.js.map +1 -1
  8. package/dist/engine/publisher.d.ts +8 -8
  9. package/dist/engine/publisher.d.ts.map +1 -1
  10. package/dist/engine/publisher.js +27 -1
  11. package/dist/engine/publisher.js.map +1 -1
  12. package/dist/foundry-emit.d.ts +5 -0
  13. package/dist/foundry-emit.d.ts.map +1 -1
  14. package/dist/foundry-emit.js +3 -0
  15. package/dist/foundry-emit.js.map +1 -1
  16. package/dist/integration/options.d.ts +14 -14
  17. package/dist/routes/content.d.ts.map +1 -1
  18. package/dist/routes/content.js +1 -15
  19. package/dist/routes/content.js.map +1 -1
  20. package/dist/routes/foundry-publish.d.ts.map +1 -1
  21. package/dist/routes/foundry-publish.js +1 -0
  22. package/dist/routes/foundry-publish.js.map +1 -1
  23. package/dist/ui/api/content/insights-ingest.d.ts.map +1 -1
  24. package/dist/ui/api/content/insights-ingest.js +12 -3
  25. package/dist/ui/api/content/insights-ingest.js.map +1 -1
  26. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  27. package/dist/ui/api/content/insights-inputs.js +3 -2
  28. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  29. package/docs/foundry-publish.md +6 -0
  30. package/package.json +1 -1
  31. package/src/engine/content-metadata.ts +17 -0
  32. package/src/engine/publication.ts +26 -2
  33. package/src/engine/publisher.ts +46 -1
  34. package/src/foundry-emit.ts +3 -0
  35. package/src/routes/content.ts +1 -20
  36. package/src/routes/foundry-publish.ts +1 -0
  37. package/src/ui/api/content/insights-ingest.ts +10 -3
  38. package/src/ui/api/content/insights-inputs.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/cms",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "growth-labs-cms-migrations": "./dist/cli/migrations.js",
@@ -1,3 +1,5 @@
1
+ import { z } from 'zod'
2
+
1
3
  export type ContentMetadataPrimitive = string | number | boolean | null
2
4
  export type ContentMetadataValue =
3
5
  | ContentMetadataPrimitive
@@ -120,3 +122,18 @@ export function parseContentMetadata(raw: unknown): ContentMetadata {
120
122
  serializeContentMetadata(parsed as ContentMetadata)
121
123
  return parsed as ContentMetadata
122
124
  }
125
+
126
+ export const ContentMetadataSchema = z
127
+ .record(z.string(), z.unknown())
128
+ .superRefine((metadata, ctx) => {
129
+ try {
130
+ serializeContentMetadata(metadata as ContentMetadata)
131
+ } catch (error) {
132
+ ctx.addIssue({
133
+ code: 'custom',
134
+ message:
135
+ error instanceof ContentMetadataError ? error.message : 'content metadata is invalid',
136
+ })
137
+ }
138
+ })
139
+ .transform((metadata) => metadata as ContentMetadata)
@@ -33,6 +33,7 @@ import {
33
33
  getContentSnapshot,
34
34
  PUBLISH_METADATA_STAMP_SQL,
35
35
  PublishContentValidationError,
36
+ publicationMetadataForSnapshot,
36
37
  publishMetadataStampBinds,
37
38
  } from './publisher.js'
38
39
  import { getRevision } from './revisions.js'
@@ -812,6 +813,20 @@ async function createRevisionAndPublicationAttempt(
812
813
  }
813
814
  }
814
815
  assertPublicationSnapshotBodyValid(snapshot, input.contentId)
816
+ if (!input.mediaReplacement) {
817
+ const item = snapshotRecord(snapshot.item)
818
+ // The native route binds/adopts article source_id under this namespace.
819
+ // Preserve generic and archive-import snapshot metadata verbatim; their
820
+ // historical published_at remains the read model's publication date.
821
+ if (
822
+ item.type === 'article' &&
823
+ typeof item.source_id === 'string' &&
824
+ item.source_id.startsWith('foundry:')
825
+ ) {
826
+ item.metadata_json = await publicationMetadataForSnapshot(db, item, input.now)
827
+ snapshot.publication_metadata_stamp = 'native-article-v1'
828
+ }
829
+ }
815
830
  const revisionId = input.mediaReplacement
816
831
  ? `media:${input.mediaReplacement.identity}`
817
832
  : crypto.randomUUID()
@@ -1713,6 +1728,15 @@ async function movePointerAndBeginCommit(
1713
1728
  now: number,
1714
1729
  ): Promise<void> {
1715
1730
  const targetRevision = await getRevision(db, attempt.contentId, attempt.revisionId)
1731
+ const targetSnapshot = snapshotRecord(targetRevision?.payload)
1732
+ const stampedMetadata = snapshotRecord(targetSnapshot.item).metadata_json
1733
+ const copyStampedMetadata =
1734
+ targetSnapshot.publication_metadata_stamp === 'native-article-v1' &&
1735
+ typeof stampedMetadata === 'string'
1736
+ const metadataStampSql = copyStampedMetadata ? 'metadata_json = ?' : PUBLISH_METADATA_STAMP_SQL
1737
+ const metadataStampBinds = copyStampedMetadata
1738
+ ? [stampedMetadata]
1739
+ : publishMetadataStampBinds(now)
1716
1740
  const mediaReplacement =
1717
1741
  snapshotRecord(targetRevision?.payload).publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD
1718
1742
  if (
@@ -1770,7 +1794,7 @@ async function movePointerAndBeginCommit(
1770
1794
  `UPDATE content_items
1771
1795
  SET status = 'published', published_at = ?, publish_at = NULL,
1772
1796
  published_revision_id = ?, updated_at = unixepoch(),
1773
- ${PUBLISH_METADATA_STAMP_SQL}
1797
+ ${metadataStampSql}
1774
1798
  WHERE id = ?
1775
1799
  AND COALESCE(published_revision_id, '') = ?
1776
1800
  AND EXISTS (
@@ -1784,7 +1808,7 @@ async function movePointerAndBeginCommit(
1784
1808
  attempt.revisionId,
1785
1809
  // Wall clock, NOT targetPublishedAt — the latter is routinely backdated
1786
1810
  // to the source video's date on the channel sites.
1787
- ...publishMetadataStampBinds(now),
1811
+ ...metadataStampBinds,
1788
1812
  attempt.contentId,
1789
1813
  attempt.previousRevisionId ?? '',
1790
1814
  attempt.id,
@@ -1665,13 +1665,58 @@ END`
1665
1665
  * the second disjunct keeps unpublish→republish correct, since `unpublishContent`
1666
1666
  * clears `published_at` but the article did go live once.
1667
1667
  */
1668
- export const PUBLISH_METADATA_STAMP_SQL = `metadata_json = CASE
1668
+ const PUBLISH_METADATA_STAMP_EXPRESSION = `CASE
1669
1669
  WHEN published_at IS NOT NULL
1670
1670
  OR json_extract(${METADATA_OBJECT_SQL}, '$.adaptedAt') IS NOT NULL
1671
1671
  THEN json_set(${METADATA_WITH_ADAPTED_AT_SQL}, '$.updatedAt', date(?, 'unixepoch'))
1672
1672
  ELSE ${METADATA_WITH_ADAPTED_AT_SQL}
1673
1673
  END`
1674
1674
 
1675
+ export const PUBLISH_METADATA_STAMP_SQL = `metadata_json = ${PUBLISH_METADATA_STAMP_EXPRESSION}`
1676
+
1677
+ /** Project the same publish stamp onto the immutable snapshot's captured row. */
1678
+ export async function publicationMetadataForSnapshot(
1679
+ db: D1Database,
1680
+ item: {
1681
+ id?: unknown
1682
+ published_revision_id?: unknown
1683
+ metadata_json?: unknown
1684
+ published_at?: unknown
1685
+ created_at?: unknown
1686
+ },
1687
+ now: number,
1688
+ ): Promise<string> {
1689
+ // A draft metadata edit can remove adaptedAt. The previous immutable native
1690
+ // revision retains the actual first publication day; creation time may be
1691
+ // earlier and published_at may be the source video's historical date.
1692
+ let priorAdaptedAt: string | null = null
1693
+ if (typeof item.id === 'string' && typeof item.published_revision_id === 'string') {
1694
+ const prior = await db
1695
+ .prepare(`SELECT
1696
+ json_extract(json_extract(payload_json, '$.item.metadata_json'), '$.adaptedAt') AS adapted_at
1697
+ FROM content_revisions WHERE id = ? AND content_id = ?`)
1698
+ .bind(item.published_revision_id, item.id)
1699
+ .first<{ adapted_at: string | null }>()
1700
+ priorAdaptedAt = prior?.adapted_at ?? null
1701
+ }
1702
+ const projected = await db
1703
+ .prepare(`SELECT ${PUBLISH_METADATA_STAMP_EXPRESSION} AS metadata_json
1704
+ FROM (SELECT CASE WHEN ? IS NOT NULL THEN json_set(COALESCE(?, '{}'), '$.adaptedAt', ?)
1705
+ ELSE ? END AS metadata_json, ? AS published_at, ? AS created_at)`)
1706
+ .bind(
1707
+ ...publishMetadataStampBinds(now),
1708
+ priorAdaptedAt,
1709
+ item.metadata_json ?? null,
1710
+ priorAdaptedAt,
1711
+ item.metadata_json ?? null,
1712
+ item.published_at ?? null,
1713
+ item.created_at ?? null,
1714
+ )
1715
+ .first<{ metadata_json: string }>()
1716
+ if (!projected) throw new Error('publication metadata projection failed')
1717
+ return projected.metadata_json
1718
+ }
1719
+
1675
1720
  /** Placeholder count in {@link PUBLISH_METADATA_STAMP_SQL}, derived so it cannot drift. */
1676
1721
  const PUBLISH_METADATA_STAMP_PLACEHOLDERS = (PUBLISH_METADATA_STAMP_SQL.match(/\?/g) ?? []).length
1677
1722
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { sha256Hex } from '@growth-labs/foundry-signing'
11
11
  import { z } from 'zod'
12
+ import { ContentMetadataSchema } from './engine/content-metadata.js'
12
13
  import type { ContentArchiveItem } from './engine/publication.js'
13
14
  import { slugify } from './engine/slug.js'
14
15
 
@@ -58,6 +59,7 @@ export const FoundryArticleV2Schema = z
58
59
  tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
59
60
  description: z.string().trim().min(1).optional(),
60
61
  visibility: z.enum(['free', 'premium']).optional(),
62
+ metadata: ContentMetadataSchema.optional(),
61
63
  adoptSourceId: z
62
64
  .string()
63
65
  .min(1)
@@ -147,6 +149,7 @@ export function toFoundryArticleV2(
147
149
  ...(item.tags && item.tags.length > 0 ? { tags: [...item.tags] } : {}),
148
150
  ...(description ? { description } : {}),
149
151
  ...(item.visibility ? { visibility: item.visibility } : {}),
152
+ ...(item.metadata != null ? { metadata: item.metadata } : {}),
150
153
  ...(present(item.sourceId) ? { adoptSourceId: item.sourceId } : {}),
151
154
  }
152
155
  return FoundryArticleV2Schema.parse(body)
@@ -18,11 +18,7 @@ import { z } from 'zod'
18
18
  import { logActivity } from '../engine/activity-log.js'
19
19
  import { generateContentDek } from '../engine/ai-dek.js'
20
20
  import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
21
- import {
22
- type ContentMetadata,
23
- ContentMetadataError,
24
- serializeContentMetadata,
25
- } from '../engine/content-metadata.js'
21
+ import { ContentMetadataSchema } from '../engine/content-metadata.js'
26
22
  import { listContributors, setContributors } from '../engine/contributors.js'
27
23
  import type { D1Database } from '../engine/d1.js'
28
24
  import {
@@ -410,21 +406,6 @@ const BodyContentCreateSchema = z.object({
410
406
  editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
411
407
  })
412
408
 
413
- const ContentMetadataSchema = z
414
- .record(z.string(), z.unknown())
415
- .superRefine((metadata, ctx) => {
416
- try {
417
- serializeContentMetadata(metadata as ContentMetadata)
418
- } catch (error) {
419
- ctx.addIssue({
420
- code: 'custom',
421
- message:
422
- error instanceof ContentMetadataError ? error.message : 'content metadata is invalid',
423
- })
424
- }
425
- })
426
- .transform((metadata) => metadata as ContentMetadata)
427
-
428
409
  // A Nextcloud share PAGE is HTML, never ingestable media, so the stored source
429
410
  // must be the direct-download URL. Editors paste the share link -- that is what
430
411
  // Nextcloud's "Copy link" button yields -- so normalise it here instead of
@@ -671,6 +671,7 @@ async function putV2(
671
671
  ...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
672
672
  ...(body.tags !== undefined ? { tags: body.tags } : {}),
673
673
  ...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
674
+ ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
674
675
  content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
675
676
  }
676
677
 
@@ -2,19 +2,26 @@
2
2
  // POST /admin/api/content/insights-ingest — machine ingest (replay-safe replace).
3
3
  // HMAC-SHA256 (X-Foundry-Signature vs env.INSIGHTS_INGEST_HMAC_SECRET) only —
4
4
  // NOT behind requirePublisher. Sibling of foundry-callback under /content/.
5
+ import { env as cloudflareEnv } from 'cloudflare:workers'
6
+ import { config } from 'virtual:growth-labs/cms/config'
5
7
  import type { APIContext } from 'astro'
6
8
  import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
7
9
  import type { RouteContext } from '../../../routes/context.js'
8
10
  import { buildSiteAuthz } from '../_authz.js'
11
+ import { toContentConfig } from '../_content-config.js'
9
12
 
10
13
  function toCtx(c: APIContext): RouteContext {
11
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
+ const env = cloudflareEnv as Record<string, unknown>
12
15
  return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
13
16
  }
14
17
 
15
18
  function siteId(c: APIContext): string {
16
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
17
- return (env.ACTIVE_WORKSPACE_ID as string | undefined) ?? 'default'
19
+ const env = cloudflareEnv as Record<string, unknown>
20
+ return (
21
+ toContentConfig(c).activeWorkspaceId ??
22
+ (env.ACTIVE_WORKSPACE_ID as string | undefined) ??
23
+ (config.workspaces?.length === 1 ? config.workspaces[0]!.id : 'default')
24
+ )
18
25
  }
19
26
 
20
27
  export const POST = (c: APIContext) =>
@@ -5,6 +5,7 @@
5
5
  // foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
6
6
  // requirePublisher. Sibling of insights-ingest under /content/.
7
7
 
8
+ import { env as cloudflareEnv } from 'cloudflare:workers'
8
9
  import { config } from 'virtual:growth-labs/cms/config'
9
10
  import type { APIContext } from 'astro'
10
11
  import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
@@ -13,12 +14,12 @@ import { buildSiteAuthz } from '../_authz.js'
13
14
  import { toContentConfig } from '../_content-config.js'
14
15
 
15
16
  function toCtx(c: APIContext): RouteContext {
16
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
17
+ const env = cloudflareEnv as Record<string, unknown>
17
18
  return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
18
19
  }
19
20
 
20
21
  function siteId(c: APIContext): string {
21
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
22
+ const env = cloudflareEnv as Record<string, unknown>
22
23
  return (
23
24
  toContentConfig(c).activeWorkspaceId ??
24
25
  (env.ACTIVE_WORKSPACE_ID as string | undefined) ??