@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
@@ -0,0 +1,450 @@
1
+ import {
2
+ link,
3
+ lstat,
4
+ readdir,
5
+ readFile,
6
+ realpath,
7
+ rename,
8
+ unlink,
9
+ writeFile,
10
+ } from 'node:fs/promises'
11
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import {
14
+ commitStagedMigrationBatch,
15
+ type StagedMigrationVendorFile,
16
+ } from './migration-vendor-files.js'
17
+
18
+ const PACKAGE_NAME = '@growth-labs/cms'
19
+ const MANIFEST_NAME = '.growth-labs-cms-migrations.json'
20
+ const MANIFEST_SCHEMA_VERSION = 1
21
+ const MIGRATION_FILE = /^\d{4}_[a-z0-9][a-z0-9_-]*\.sql$/
22
+
23
+ interface PackageJson {
24
+ name?: string
25
+ version?: string
26
+ }
27
+
28
+ interface PlannedMigration {
29
+ source: string
30
+ target: string
31
+ sequence: number
32
+ sha256: string
33
+ bytes: Uint8Array
34
+ }
35
+
36
+ interface CmsMigrationManifestEntry {
37
+ source: string
38
+ target: string
39
+ sha256: string
40
+ }
41
+
42
+ interface CmsMigrationManifest {
43
+ schemaVersion: 1
44
+ packageName: typeof PACKAGE_NAME
45
+ packageVersion: string
46
+ startSequence: number
47
+ files: CmsMigrationManifestEntry[]
48
+ }
49
+
50
+ export interface SyncCmsMigrationsOptions {
51
+ /** Existing consumer-owned Wrangler migrations directory. */
52
+ migrationsDir: string
53
+ /** First unused sequence number reserved for the package migrations. */
54
+ startSequence: number
55
+ /** Override only when a consumer keeps the receipt outside migrationsDir. */
56
+ manifestPath?: string
57
+ }
58
+
59
+ export interface VerifyCmsMigrationsOptions {
60
+ migrationsDir: string
61
+ manifestPath?: string
62
+ }
63
+
64
+ export interface CmsMigrationVendorResult {
65
+ packageName: typeof PACKAGE_NAME
66
+ packageVersion: string
67
+ startSequence: number
68
+ managedFiles: string[]
69
+ written: number
70
+ verified: number
71
+ manifestPath: string
72
+ }
73
+
74
+ interface PackageContract {
75
+ packageVersion: string
76
+ sourceDir: string
77
+ }
78
+
79
+ async function packageContract(): Promise<PackageContract> {
80
+ const packageRoot = fileURLToPath(new URL('..', import.meta.url))
81
+ const packageJsonPath = join(packageRoot, 'package.json')
82
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as PackageJson
83
+ if (packageJson.name !== PACKAGE_NAME || !packageJson.version) {
84
+ throw new Error('installed CMS package metadata is invalid')
85
+ }
86
+ return {
87
+ packageVersion: packageJson.version,
88
+ sourceDir: join(packageRoot, 'migrations'),
89
+ }
90
+ }
91
+
92
+ async function sha256(bytes: Uint8Array): Promise<string> {
93
+ const input = new Uint8Array(bytes.byteLength)
94
+ input.set(bytes)
95
+ const digest = await crypto.subtle.digest('SHA-256', input.buffer)
96
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
97
+ }
98
+
99
+ async function requireExistingDirectory(path: string, label = 'migrationsDir'): Promise<string> {
100
+ let stat: Awaited<ReturnType<typeof lstat>>
101
+ try {
102
+ stat = await lstat(path)
103
+ } catch {
104
+ throw new Error(`${label} must be an existing directory: ${path}`)
105
+ }
106
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
107
+ throw new Error(`${label} must be an existing non-symlink directory: ${path}`)
108
+ }
109
+ return realpath(path)
110
+ }
111
+
112
+ function assertStartSequence(value: number): void {
113
+ if (!Number.isSafeInteger(value) || value < 1) {
114
+ throw new Error('startSequence must be a positive integer')
115
+ }
116
+ }
117
+
118
+ function sequencePrefix(sequence: number): string {
119
+ return String(sequence).padStart(4, '0')
120
+ }
121
+
122
+ async function planMigrations(
123
+ sourceDir: string,
124
+ startSequence: number,
125
+ ): Promise<PlannedMigration[]> {
126
+ assertStartSequence(startSequence)
127
+ const sourceNames = (await readdir(sourceDir)).filter((name) => MIGRATION_FILE.test(name)).sort()
128
+ if (sourceNames.length === 0) throw new Error('installed CMS package has no migration SQL')
129
+ if (startSequence + sourceNames.length - 1 > 9999) {
130
+ throw new Error('reserved CMS migration sequence exceeds 9999')
131
+ }
132
+
133
+ return Promise.all(
134
+ sourceNames.map(async (source, index) => {
135
+ const sequence = startSequence + index
136
+ const suffix = source.replace(/^\d{4}_/, '')
137
+ const target = `${sequencePrefix(sequence)}_growth_labs_cms_${suffix}`
138
+ const bytes = new Uint8Array(await readFile(join(sourceDir, source)))
139
+ return { source, target, sequence, sha256: await sha256(bytes), bytes }
140
+ }),
141
+ )
142
+ }
143
+
144
+ function manifestFor(
145
+ packageVersion: string,
146
+ startSequence: number,
147
+ plan: PlannedMigration[],
148
+ ): CmsMigrationManifest {
149
+ return {
150
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
151
+ packageName: PACKAGE_NAME,
152
+ packageVersion,
153
+ startSequence,
154
+ files: plan.map(({ source, target, sha256: hash }) => ({ source, target, sha256: hash })),
155
+ }
156
+ }
157
+
158
+ async function readManifest(path: string): Promise<CmsMigrationManifest | null> {
159
+ try {
160
+ const stat = await lstat(path)
161
+ if (!stat.isFile() || stat.isSymbolicLink()) {
162
+ throw new Error(`CMS migration manifest must be a regular file: ${path}`)
163
+ }
164
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Partial<CmsMigrationManifest>
165
+ if (
166
+ parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION ||
167
+ parsed.packageName !== PACKAGE_NAME ||
168
+ !parsed.packageVersion ||
169
+ !Number.isSafeInteger(parsed.startSequence) ||
170
+ (parsed.startSequence as number) < 1 ||
171
+ !Array.isArray(parsed.files)
172
+ ) {
173
+ throw new Error(`CMS migration manifest is invalid: ${path}`)
174
+ }
175
+ for (const entry of parsed.files) {
176
+ if (
177
+ !entry ||
178
+ typeof entry.source !== 'string' ||
179
+ typeof entry.target !== 'string' ||
180
+ !MIGRATION_FILE.test(entry.source) ||
181
+ !MIGRATION_FILE.test(entry.target) ||
182
+ !/^([a-f0-9]{64})$/.test(entry.sha256)
183
+ ) {
184
+ throw new Error(`CMS migration manifest contains an invalid file entry: ${path}`)
185
+ }
186
+ }
187
+ return parsed as CmsMigrationManifest
188
+ } catch (error) {
189
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
190
+ throw error
191
+ }
192
+ }
193
+
194
+ function entriesEqual(left: CmsMigrationManifestEntry, right: CmsMigrationManifestEntry): boolean {
195
+ return (
196
+ left.source === right.source && left.target === right.target && left.sha256 === right.sha256
197
+ )
198
+ }
199
+
200
+ function assertManifestCanAdvance(
201
+ current: CmsMigrationManifest | null,
202
+ next: CmsMigrationManifest,
203
+ ): void {
204
+ if (!current) return
205
+ if (current.startSequence !== next.startSequence) {
206
+ throw new Error(
207
+ `CMS migration manifest reserves startSequence ${current.startSequence}; received ${next.startSequence}`,
208
+ )
209
+ }
210
+ if (current.files.length > next.files.length) {
211
+ throw new Error('installed CMS package removed migrations recorded by the consumer manifest')
212
+ }
213
+ for (const [index, entry] of current.files.entries()) {
214
+ const nextEntry = next.files[index]
215
+ if (!nextEntry || !entriesEqual(entry, nextEntry)) {
216
+ throw new Error(`installed CMS migration changed after vendoring: ${entry.source}`)
217
+ }
218
+ }
219
+ }
220
+
221
+ async function assertNoSequenceCollisions(
222
+ migrationsDir: string,
223
+ plan: PlannedMigration[],
224
+ ): Promise<void> {
225
+ const existing = await readdir(migrationsDir)
226
+ for (const migration of plan) {
227
+ const prefix = `${sequencePrefix(migration.sequence)}_`
228
+ const collisions = existing.filter(
229
+ (name) => name.endsWith('.sql') && name.startsWith(prefix) && name !== migration.target,
230
+ )
231
+ if (collisions.length > 0) {
232
+ throw new Error(
233
+ `reserved sequence ${sequencePrefix(migration.sequence)} collides with ${collisions.join(', ')}`,
234
+ )
235
+ }
236
+ }
237
+ }
238
+
239
+ async function verifyTargets(
240
+ migrationsDir: string,
241
+ plan: PlannedMigration[],
242
+ allowMissing: boolean,
243
+ ): Promise<{ verified: number; missing: PlannedMigration[] }> {
244
+ let verified = 0
245
+ const missing: PlannedMigration[] = []
246
+ for (const migration of plan) {
247
+ const targetPath = join(migrationsDir, migration.target)
248
+ try {
249
+ const stat = await lstat(targetPath)
250
+ if (!stat.isFile() || stat.isSymbolicLink()) {
251
+ throw new Error(`managed CMS migration must be a regular file: ${migration.target}`)
252
+ }
253
+ const targetHash = await sha256(new Uint8Array(await readFile(targetPath)))
254
+ if (targetHash !== migration.sha256) {
255
+ throw new Error(`CMS migration hash mismatch: ${migration.target}`)
256
+ }
257
+ verified += 1
258
+ } catch (error) {
259
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT' && allowMissing) {
260
+ missing.push(migration)
261
+ continue
262
+ }
263
+ throw error
264
+ }
265
+ }
266
+ return { verified, missing }
267
+ }
268
+
269
+ async function stageNewFile(path: string, bytes: Uint8Array): Promise<string> {
270
+ const temp = `${path}.tmp-${crypto.randomUUID()}`
271
+ await writeFile(temp, bytes, { flag: 'wx' })
272
+ return temp
273
+ }
274
+
275
+ async function stageManifest(path: string, manifest: CmsMigrationManifest): Promise<string> {
276
+ const temp = `${path}.tmp-${crypto.randomUUID()}`
277
+ await writeFile(temp, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' })
278
+ return temp
279
+ }
280
+
281
+ async function commitManifest(
282
+ tempPath: string,
283
+ manifestPath: string,
284
+ currentManifest: CmsMigrationManifest | null,
285
+ ): Promise<void> {
286
+ if (currentManifest) {
287
+ const latest = await readManifest(manifestPath)
288
+ if (!latest || JSON.stringify(latest) !== JSON.stringify(currentManifest)) {
289
+ throw new Error('CMS migration manifest changed during sync')
290
+ }
291
+ await rename(tempPath, manifestPath)
292
+ return
293
+ }
294
+ await link(tempPath, manifestPath)
295
+ }
296
+
297
+ function isInsideDirectory(directory: string, path: string): boolean {
298
+ const fromDirectory = relative(directory, path)
299
+ return (
300
+ fromDirectory === '' ||
301
+ (!fromDirectory.startsWith(`..${sep}`) && fromDirectory !== '..' && !isAbsolute(fromDirectory))
302
+ )
303
+ }
304
+
305
+ async function resolveManifestPath(migrationsDir: string, override?: string): Promise<string> {
306
+ if (!override) return join(migrationsDir, MANIFEST_NAME)
307
+ const requested = resolve(override)
308
+ if (extname(requested).toLowerCase() !== '.json') {
309
+ throw new Error('CMS migration manifest override must be a .json file')
310
+ }
311
+ if (isInsideDirectory(migrationsDir, requested)) {
312
+ throw new Error('CMS migration manifest override must be outside migrationsDir')
313
+ }
314
+ const parent = await requireExistingDirectory(dirname(requested), 'manifest parent')
315
+ const resolvedPath = join(parent, basename(requested))
316
+ if (isInsideDirectory(migrationsDir, resolvedPath)) {
317
+ throw new Error('CMS migration manifest override must be outside migrationsDir')
318
+ }
319
+ return resolvedPath
320
+ }
321
+
322
+ async function withManifestLock<T>(manifestPath: string, action: () => Promise<T>): Promise<T> {
323
+ const lockPath = `${manifestPath}.lock`
324
+ try {
325
+ await writeFile(lockPath, `${JSON.stringify({ pid: process.pid, startedAt: Date.now() })}\n`, {
326
+ flag: 'wx',
327
+ mode: 0o600,
328
+ })
329
+ } catch (error) {
330
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
331
+ throw new Error(`another CMS migration operation is already active: ${lockPath}`)
332
+ }
333
+ throw error
334
+ }
335
+
336
+ let result: T | undefined
337
+ let operationError: unknown
338
+ try {
339
+ result = await action()
340
+ } catch (error) {
341
+ operationError = error
342
+ }
343
+ let releaseError: unknown
344
+ try {
345
+ await unlink(lockPath)
346
+ } catch (error) {
347
+ releaseError = error
348
+ }
349
+ if (operationError && releaseError) {
350
+ throw new AggregateError(
351
+ [operationError, releaseError],
352
+ 'CMS migration operation failed and its receipt lock could not be released',
353
+ )
354
+ }
355
+ if (operationError) throw operationError
356
+ if (releaseError) throw releaseError
357
+ return result as T
358
+ }
359
+
360
+ export async function syncCmsMigrations(
361
+ options: SyncCmsMigrationsOptions,
362
+ ): Promise<CmsMigrationVendorResult> {
363
+ assertStartSequence(options.startSequence)
364
+ const migrationsDir = await requireExistingDirectory(resolve(options.migrationsDir))
365
+ const manifestPath = await resolveManifestPath(migrationsDir, options.manifestPath)
366
+ return withManifestLock(manifestPath, async () => {
367
+ const contract = await packageContract()
368
+ const plan = await planMigrations(contract.sourceDir, options.startSequence)
369
+ const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
370
+ const currentManifest = await readManifest(manifestPath)
371
+ assertManifestCanAdvance(currentManifest, nextManifest)
372
+ await assertNoSequenceCollisions(migrationsDir, plan)
373
+ const preflight = await verifyTargets(migrationsDir, plan, true)
374
+ const staged: StagedMigrationVendorFile[] = []
375
+ let manifestTemp: string | null = null
376
+ let verified = 0
377
+
378
+ try {
379
+ for (const migration of preflight.missing) {
380
+ const targetPath = join(migrationsDir, migration.target)
381
+ staged.push({
382
+ targetPath,
383
+ tempPath: await stageNewFile(targetPath, migration.bytes),
384
+ })
385
+ }
386
+ const stagedManifest = await stageManifest(manifestPath, nextManifest)
387
+ manifestTemp = stagedManifest
388
+ await commitStagedMigrationBatch(staged, async () => {
389
+ await assertNoSequenceCollisions(migrationsDir, plan)
390
+ const final = await verifyTargets(migrationsDir, plan, false)
391
+ verified = final.verified
392
+ await commitManifest(stagedManifest, manifestPath, currentManifest)
393
+ })
394
+
395
+ return {
396
+ packageName: PACKAGE_NAME,
397
+ packageVersion: contract.packageVersion,
398
+ startSequence: options.startSequence,
399
+ managedFiles: plan.map(({ target }) => target),
400
+ written: preflight.missing.length,
401
+ verified,
402
+ manifestPath,
403
+ }
404
+ } finally {
405
+ await Promise.all([
406
+ ...staged.map(({ tempPath }) => unlink(tempPath).catch(() => {})),
407
+ ...(manifestTemp ? [unlink(manifestTemp).catch(() => {})] : []),
408
+ ])
409
+ }
410
+ })
411
+ }
412
+
413
+ export async function verifyCmsMigrations(
414
+ options: VerifyCmsMigrationsOptions,
415
+ ): Promise<CmsMigrationVendorResult> {
416
+ const migrationsDir = await requireExistingDirectory(resolve(options.migrationsDir))
417
+ const manifestPath = await resolveManifestPath(migrationsDir, options.manifestPath)
418
+ return withManifestLock(manifestPath, async () => {
419
+ const manifest = await readManifest(manifestPath)
420
+ if (!manifest) throw new Error(`CMS migration manifest is missing: ${manifestPath}`)
421
+ const contract = await packageContract()
422
+ if (manifest.packageVersion !== contract.packageVersion) {
423
+ throw new Error(
424
+ `CMS migration manifest version ${manifest.packageVersion} does not match installed ${contract.packageVersion}`,
425
+ )
426
+ }
427
+ const plan = await planMigrations(contract.sourceDir, manifest.startSequence)
428
+ const expected = manifestFor(contract.packageVersion, manifest.startSequence, plan)
429
+ if (
430
+ expected.files.length !== manifest.files.length ||
431
+ expected.files.some(
432
+ (entry, index) => !entriesEqual(entry, manifest.files[index] as CmsMigrationManifestEntry),
433
+ )
434
+ ) {
435
+ throw new Error('CMS migration manifest does not match the installed package contract')
436
+ }
437
+ await assertNoSequenceCollisions(migrationsDir, plan)
438
+ const result = await verifyTargets(migrationsDir, plan, false)
439
+
440
+ return {
441
+ packageName: PACKAGE_NAME,
442
+ packageVersion: contract.packageVersion,
443
+ startSequence: manifest.startSequence,
444
+ managedFiles: plan.map(({ target }) => target),
445
+ written: 0,
446
+ verified: result.verified,
447
+ manifestPath,
448
+ }
449
+ })
450
+ }
@@ -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