@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
|
@@ -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
|
+
}
|
package/src/schema/migrations.ts
CHANGED
|
@@ -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
|
]
|
package/src/schema/types.ts
CHANGED
|
@@ -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
|