@growth-labs/cms 0.2.0 → 0.3.1
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 +106 -2
- 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/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/reader-state.d.ts +127 -0
- package/dist/engine/reader-state.d.ts.map +1 -0
- package/dist/engine/reader-state.js +493 -0
- package/dist/engine/reader-state.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.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/index.d.ts +1 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +37 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +5 -2
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +27 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0019_reader_state.sql +34 -0
- package/package.json +8 -1
- package/src/cli/migrations.ts +85 -0
- package/src/engine/index.ts +38 -0
- package/src/engine/reader-state.ts +918 -0
- package/src/index.ts +4 -2
- package/src/migration-vendor-files.ts +100 -0
- package/src/migration-vendor.ts +450 -0
- package/src/schema/index.ts +2 -0
- package/src/schema/migrations.ts +37 -0
- package/src/schema/tables.ts +5 -2
- package/src/schema/types.ts +29 -0
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// @growth-labs/cms — the reusable Fronts-proven publishing/admin engine, as a
|
|
2
2
|
// package (NOT a shared engine). See docs/reference/cms-reuse-decision.md.
|
|
3
3
|
//
|
|
4
|
-
// This package ships the D1 schema contract (the 11-table
|
|
5
|
-
// content/revision/media/authors/foundry-callback set) + its TypeScript row
|
|
4
|
+
// This package ships the D1 schema contract (the original 11-table
|
|
5
|
+
// content/revision/media/authors/foundry-callback set plus additive migrations) + its TypeScript row
|
|
6
6
|
// types (WS7-04/06 foundation) AND the publishing engine — the content
|
|
7
7
|
// data-access core (WS7-05):
|
|
8
8
|
//
|
|
@@ -50,6 +50,7 @@ export {
|
|
|
50
50
|
type CmsActivityLogRow,
|
|
51
51
|
type CmsApiKeyRow,
|
|
52
52
|
type CmsNotificationRow,
|
|
53
|
+
type CmsReaderStateRow,
|
|
53
54
|
type CmsRole,
|
|
54
55
|
type CmsTableName,
|
|
55
56
|
type CmsWebhookRow,
|
|
@@ -73,6 +74,7 @@ export {
|
|
|
73
74
|
type MediaAssetRow,
|
|
74
75
|
NON_CMS_TABLES,
|
|
75
76
|
type PodcastContentRow,
|
|
77
|
+
type ReaderManualReadState,
|
|
76
78
|
splitStatements,
|
|
77
79
|
type VideoContentRow,
|
|
78
80
|
type VideoProcessingState,
|
|
@@ -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
|
+
}
|
|
@@ -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/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type {
|
|
|
20
20
|
CmsActivityLogRow,
|
|
21
21
|
CmsApiKeyRow,
|
|
22
22
|
CmsNotificationRow,
|
|
23
|
+
CmsReaderStateRow,
|
|
23
24
|
CmsRole,
|
|
24
25
|
CmsWebhookRow,
|
|
25
26
|
ContentContributorRow,
|
|
@@ -40,6 +41,7 @@ export type {
|
|
|
40
41
|
MastheadUserRow,
|
|
41
42
|
MediaAssetRow,
|
|
42
43
|
PodcastContentRow,
|
|
44
|
+
ReaderManualReadState,
|
|
43
45
|
VideoContentRow,
|
|
44
46
|
VideoProcessingState,
|
|
45
47
|
WorkspaceInviteRow,
|
package/src/schema/migrations.ts
CHANGED
|
@@ -741,6 +741,43 @@ CREATE INDEX idx_content_publication_attempts_recovery
|
|
|
741
741
|
ON content_publication_attempts(state, updated_at);
|
|
742
742
|
CREATE INDEX idx_content_publication_attempts_content
|
|
743
743
|
ON content_publication_attempts(content_id, created_at DESC);
|
|
744
|
+
`,
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
id: '0019_reader_state',
|
|
748
|
+
sql: `
|
|
749
|
+
CREATE TABLE cms_reader_state (
|
|
750
|
+
identity_user_id TEXT NOT NULL,
|
|
751
|
+
site_id TEXT NOT NULL,
|
|
752
|
+
content_type TEXT NOT NULL CHECK (content_type IN ('article','video','podcast','newsletter','page')),
|
|
753
|
+
content_id TEXT NOT NULL,
|
|
754
|
+
first_opened_at INTEGER,
|
|
755
|
+
last_opened_at INTEGER,
|
|
756
|
+
max_progress_bps INTEGER NOT NULL DEFAULT 0 CHECK (max_progress_bps BETWEEN 0 AND 10000),
|
|
757
|
+
media_position_ms INTEGER CHECK (media_position_ms IS NULL OR media_position_ms >= 0),
|
|
758
|
+
media_duration_ms INTEGER CHECK (media_duration_ms IS NULL OR media_duration_ms >= 0),
|
|
759
|
+
progress_observed_at INTEGER,
|
|
760
|
+
automatic_completed_at INTEGER,
|
|
761
|
+
manual_read_state TEXT CHECK (manual_read_state IS NULL OR manual_read_state IN ('read','unread')),
|
|
762
|
+
manual_state_at INTEGER,
|
|
763
|
+
manual_mutation_id TEXT,
|
|
764
|
+
saved INTEGER NOT NULL DEFAULT 0 CHECK (saved IN (0,1)),
|
|
765
|
+
saved_at INTEGER,
|
|
766
|
+
saved_mutation_id TEXT,
|
|
767
|
+
preferences_json TEXT NOT NULL DEFAULT '{}',
|
|
768
|
+
preferences_at INTEGER,
|
|
769
|
+
preferences_mutation_id TEXT,
|
|
770
|
+
version INTEGER NOT NULL DEFAULT 0 CHECK (version >= 0),
|
|
771
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
772
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
773
|
+
PRIMARY KEY (identity_user_id, site_id, content_type, content_id)
|
|
774
|
+
);
|
|
775
|
+
CREATE INDEX idx_cms_reader_state_library
|
|
776
|
+
ON cms_reader_state(identity_user_id, site_id, updated_at DESC, content_type, content_id);
|
|
777
|
+
CREATE INDEX idx_cms_reader_state_export
|
|
778
|
+
ON cms_reader_state(identity_user_id, site_id, created_at DESC, content_type, content_id);
|
|
779
|
+
CREATE INDEX idx_cms_reader_state_saved
|
|
780
|
+
ON cms_reader_state(identity_user_id, site_id, saved, updated_at DESC);
|
|
744
781
|
`,
|
|
745
782
|
},
|
|
746
783
|
]
|
package/src/schema/tables.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
1
|
+
// The content/revision/media/authors/foundry-callback contract plus additive
|
|
2
|
+
// package-owned CMS platform/product tables. The original 11-table core was
|
|
3
|
+
// verified against prod `fronts-data` 2026-05-29; later entries are versioned
|
|
4
|
+
// migrations with their own workerd contract tests.
|
|
3
5
|
//
|
|
4
6
|
// This list is the CMS boundary made executable: it is exactly the tables the
|
|
5
7
|
// package migration creates and nothing else. Identity/entitlement tables
|
|
@@ -23,6 +25,7 @@ export const CMS_TABLES = [
|
|
|
23
25
|
'content_contributors',
|
|
24
26
|
'content_slug_redirects',
|
|
25
27
|
'content_publication_attempts',
|
|
28
|
+
'cms_reader_state',
|
|
26
29
|
// Masthead platform tables (migrations 0008–0011)
|
|
27
30
|
'workspace_settings',
|
|
28
31
|
'masthead_users',
|
package/src/schema/types.ts
CHANGED
|
@@ -567,6 +567,35 @@ export interface ContentPublicationAttemptRow {
|
|
|
567
567
|
updated_at: number
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
export type ReaderManualReadState = 'read' | 'unread'
|
|
571
|
+
|
|
572
|
+
/** Package-owned reader product state from migration 0019. */
|
|
573
|
+
export interface CmsReaderStateRow {
|
|
574
|
+
identity_user_id: string
|
|
575
|
+
site_id: string
|
|
576
|
+
content_type: ContentType
|
|
577
|
+
content_id: string
|
|
578
|
+
first_opened_at: number | null
|
|
579
|
+
last_opened_at: number | null
|
|
580
|
+
max_progress_bps: number
|
|
581
|
+
media_position_ms: number | null
|
|
582
|
+
media_duration_ms: number | null
|
|
583
|
+
progress_observed_at: number | null
|
|
584
|
+
automatic_completed_at: number | null
|
|
585
|
+
manual_read_state: ReaderManualReadState | null
|
|
586
|
+
manual_state_at: number | null
|
|
587
|
+
manual_mutation_id: string | null
|
|
588
|
+
saved: number
|
|
589
|
+
saved_at: number | null
|
|
590
|
+
saved_mutation_id: string | null
|
|
591
|
+
preferences_json: string
|
|
592
|
+
preferences_at: number | null
|
|
593
|
+
preferences_mutation_id: string | null
|
|
594
|
+
version: number
|
|
595
|
+
created_at: number
|
|
596
|
+
updated_at: number
|
|
597
|
+
}
|
|
598
|
+
|
|
570
599
|
/**
|
|
571
600
|
* Schema version for the intent_key derivation. Bump ONLY on a breaking change
|
|
572
601
|
* to the canonical-cluster-id semantics; bumping rotates every intent_key (and
|