@growth-labs/cms 0.8.7 → 0.8.14
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/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +44 -8
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/publisher.d.ts +3 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +21 -11
- package/dist/engine/publisher.js.map +1 -1
- package/dist/migration-vendor.d.ts.map +1 -1
- package/dist/migration-vendor.js +129 -12
- package/dist/migration-vendor.js.map +1 -1
- package/dist/routes/content.d.ts +1 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +166 -10
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/api-error.d.ts +17 -0
- package/dist/ui/api-error.d.ts.map +1 -0
- package/dist/ui/api-error.js +46 -0
- package/dist/ui/api-error.js.map +1 -0
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +5 -4
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/editor-media-upload.d.ts.map +1 -1
- package/dist/ui/editor/editor-media-upload.js +2 -1
- package/dist/ui/editor/editor-media-upload.js.map +1 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +2 -1
- package/dist/ui/inspector/PublishTab.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +4 -3
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/SettingsScreen.d.ts.map +1 -1
- package/dist/ui/screens/SettingsScreen.js +14 -13
- package/dist/ui/screens/SettingsScreen.js.map +1 -1
- package/dist/ui/screens/TopicsScreen.d.ts.map +1 -1
- package/dist/ui/screens/TopicsScreen.js +3 -2
- package/dist/ui/screens/TopicsScreen.js.map +1 -1
- package/dist/ui/screens/author-recovery.d.ts.map +1 -1
- package/dist/ui/screens/author-recovery.js +2 -1
- package/dist/ui/screens/author-recovery.js.map +1 -1
- package/dist/ui/screens/media-upload.d.ts.map +1 -1
- package/dist/ui/screens/media-upload.js +2 -1
- package/dist/ui/screens/media-upload.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/fronts-publish.ts +45 -6
- package/src/engine/publisher.ts +24 -11
- package/src/migration-vendor.ts +146 -14
- package/src/routes/content.ts +188 -18
- package/src/ui/api-error.ts +55 -0
- package/src/ui/editor/ContentForm.tsx +8 -13
- package/src/ui/editor/editor-media-upload.ts +2 -1
- package/src/ui/inspector/PublishTab.tsx +2 -1
- package/src/ui/screens/LibraryScreen.tsx +4 -3
- package/src/ui/screens/SettingsScreen.tsx +14 -13
- package/src/ui/screens/TopicsScreen.tsx +3 -2
- package/src/ui/screens/author-recovery.ts +3 -1
- package/src/ui/screens/media-upload.ts +3 -2
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
PublicationCurrentSnapshotError,
|
|
42
42
|
snapshotRecord,
|
|
43
43
|
} from './publication-current-guard.js'
|
|
44
|
-
import { getContentSnapshot } from './publisher.js'
|
|
44
|
+
import { getContentSnapshot, PublishContentValidationError } from './publisher.js'
|
|
45
45
|
import { getRevision } from './revisions.js'
|
|
46
46
|
|
|
47
47
|
/** The five lifecycle states foundryd reports for a Fronts publish intent. */
|
|
@@ -297,6 +297,10 @@ export async function applyFrontsPublishCallback(
|
|
|
297
297
|
let published = item.status === 'published'
|
|
298
298
|
let attemptId: string | null = null
|
|
299
299
|
let ledgerError: string | null = null
|
|
300
|
+
// A body that fails pre-publish validation will fail again on every retry
|
|
301
|
+
// until an editor changes it. Recording that distinctly lets the marker say
|
|
302
|
+
// `failed` instead of leaving the intent to be re-admitted forever.
|
|
303
|
+
let publishRejectedDeterministically = false
|
|
300
304
|
let committedDekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
|
|
301
305
|
let committedPublishedItem: FrontsPublishCallbackResult['publishedItem'] = null
|
|
302
306
|
const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
|
|
@@ -561,7 +565,19 @@ export async function applyFrontsPublishCallback(
|
|
|
561
565
|
'Scheduled content or media changed before publication.',
|
|
562
566
|
)
|
|
563
567
|
}
|
|
564
|
-
|
|
568
|
+
publishRejectedDeterministically = error instanceof PublishContentValidationError
|
|
569
|
+
// `error.message` is the same generic sentence for every body
|
|
570
|
+
// rejection ("Article body failed pre-publish validation"), which
|
|
571
|
+
// tells an operator that something is wrong but never what. The
|
|
572
|
+
// guard already computed the violated codes; carrying its summary
|
|
573
|
+
// onto the event row is the difference between reading the answer
|
|
574
|
+
// and re-running the validator by hand against the stored body.
|
|
575
|
+
ledgerError =
|
|
576
|
+
error instanceof PublishContentValidationError
|
|
577
|
+
? error.summary
|
|
578
|
+
: error instanceof Error
|
|
579
|
+
? error.message
|
|
580
|
+
: String(error)
|
|
565
581
|
}
|
|
566
582
|
}
|
|
567
583
|
}
|
|
@@ -578,13 +594,20 @@ export async function applyFrontsPublishCallback(
|
|
|
578
594
|
)
|
|
579
595
|
.bind(input.contentId, input.version ?? null, input.version ?? null)
|
|
580
596
|
.first<{ latest: number | null }>()
|
|
597
|
+
// A `live` whose publication was refused for a reason no retry can change
|
|
598
|
+
// records `failed`, not `live`: the editor has to act, and `failed` is what
|
|
599
|
+
// makes that visible in Masthead and to foundryd's late evaluator.
|
|
600
|
+
const markerStatus: FrontsPublishStatus =
|
|
601
|
+
publishRejectedDeterministically && input.status === 'live' ? 'failed' : input.status
|
|
581
602
|
if (
|
|
603
|
+
publishRejectedDeterministically ||
|
|
582
604
|
shouldApplyFrontsMarker(
|
|
583
605
|
item.status,
|
|
584
606
|
item.fronts_publish_status,
|
|
585
607
|
input.status,
|
|
586
608
|
incomingOccurredAt,
|
|
587
609
|
latestSeenRow?.latest ?? null,
|
|
610
|
+
published,
|
|
588
611
|
)
|
|
589
612
|
) {
|
|
590
613
|
const marker = await db
|
|
@@ -593,7 +616,7 @@ export async function applyFrontsPublishCallback(
|
|
|
593
616
|
WHERE id = ? AND canonical_version = ? AND status = ? AND publish_at IS ? AND deleted_at IS NULL`,
|
|
594
617
|
)
|
|
595
618
|
.bind(
|
|
596
|
-
|
|
619
|
+
markerStatus,
|
|
597
620
|
input.contentId,
|
|
598
621
|
item.canonical_version,
|
|
599
622
|
published ? 'published' : item.status,
|
|
@@ -752,8 +775,23 @@ async function readStoredOutcome(
|
|
|
752
775
|
|
|
753
776
|
/**
|
|
754
777
|
* Whether an incoming callback may overwrite `content_items.fronts_publish_status`.
|
|
755
|
-
* `live` is the authoritative terminal-success marker (
|
|
756
|
-
*
|
|
778
|
+
* `live` is the authoritative terminal-success marker (never downgraded once
|
|
779
|
+
* earned); older-by-occurred_at callbacks may not regress a newer marker.
|
|
780
|
+
*
|
|
781
|
+
* `live` is only EARNED by a committed publication. A `live` callback whose
|
|
782
|
+
* publish did not commit -- `publishOne` threw and left `ledgerError` set, or
|
|
783
|
+
* the callback carried no usable receipt so the publish block never ran --
|
|
784
|
+
* must not stamp success onto a row that is still `scheduled`. Doing so cost a
|
|
785
|
+
* missed publish on 2026-09-15 (`can-minilateralism-solve-europes-defense-crisis`):
|
|
786
|
+
* the marker read `live` while the item had zero revisions and zero publication
|
|
787
|
+
* attempts, and both foundryd's admission (anything past `queued` is the
|
|
788
|
+
* executor's) and its unadmitted-late evaluator (`live` means done) then treated
|
|
789
|
+
* the item as finished, so it was never retried and never paged.
|
|
790
|
+
*
|
|
791
|
+
* Leaving the marker alone is what keeps the item recoverable: it stays
|
|
792
|
+
* `queued`/`publishing`, so the next reconciler tick re-admits it and the late
|
|
793
|
+
* evaluator still sees an unfinished intent. The failure itself is not lost --
|
|
794
|
+
* it is recorded on the `fronts_publish_events` row with its `ledgerError`.
|
|
757
795
|
*/
|
|
758
796
|
function shouldApplyFrontsMarker(
|
|
759
797
|
itemStatus: string,
|
|
@@ -761,8 +799,9 @@ function shouldApplyFrontsMarker(
|
|
|
761
799
|
incomingStatus: FrontsPublishStatus,
|
|
762
800
|
incomingOccurredAt: number | null,
|
|
763
801
|
latestSeenOccurredAt: number | null,
|
|
802
|
+
published: boolean,
|
|
764
803
|
): boolean {
|
|
765
|
-
if (incomingStatus === 'live') return
|
|
804
|
+
if (incomingStatus === 'live') return published
|
|
766
805
|
if (itemStatus === 'published' || currentMarker === 'live') return false
|
|
767
806
|
if (
|
|
768
807
|
incomingOccurredAt !== null &&
|
package/src/engine/publisher.ts
CHANGED
|
@@ -799,7 +799,7 @@ export async function updateContentItem(
|
|
|
799
799
|
const current = await db
|
|
800
800
|
.prepare(
|
|
801
801
|
`SELECT script, video_id, duration_seconds, thumbnail_image_id,
|
|
802
|
-
processing_source_url, processing_source_kind
|
|
802
|
+
processing_source_url, processing_source_kind, processing_trigger_token
|
|
803
803
|
FROM video_content
|
|
804
804
|
WHERE content_id = ?
|
|
805
805
|
LIMIT 1`,
|
|
@@ -812,6 +812,7 @@ export async function updateContentItem(
|
|
|
812
812
|
thumbnail_image_id: string | null
|
|
813
813
|
processing_source_url: string | null
|
|
814
814
|
processing_source_kind: string | null
|
|
815
|
+
processing_trigger_token: string | null
|
|
815
816
|
}>()
|
|
816
817
|
|
|
817
818
|
if (current) {
|
|
@@ -836,14 +837,26 @@ export async function updateContentItem(
|
|
|
836
837
|
sourceUrl,
|
|
837
838
|
durationProvided ? videoContent.durationSeconds : undefined,
|
|
838
839
|
)
|
|
840
|
+
const mediaChanged =
|
|
841
|
+
sourceChanged ||
|
|
842
|
+
resolvedDuration !== current.duration_seconds ||
|
|
843
|
+
(input.slug !== undefined && input.slug !== existing.slug) ||
|
|
844
|
+
(input.visibility !== undefined && input.visibility !== existing.visibility)
|
|
845
|
+
const requestedVideoId = hasOwn(videoContent, 'videoId')
|
|
846
|
+
? (videoContent.videoId ?? current.video_id)
|
|
847
|
+
: current.video_id
|
|
848
|
+
// A captured source pins its media identity. The editor form re-derives
|
|
849
|
+
// videoId from the title on every save of a new video, so a title edit
|
|
850
|
+
// alone used to reset the capture; nothing re-captured it, and the video
|
|
851
|
+
// stayed scheduled but was never prepared (Fronts, 2026-09-15..17).
|
|
852
|
+
const videoId =
|
|
853
|
+
current.processing_trigger_token && !mediaChanged ? current.video_id : requestedVideoId
|
|
839
854
|
|
|
840
855
|
videoUpdatePlan = {
|
|
841
856
|
script: hasOwn(videoContent, 'script')
|
|
842
857
|
? (videoContent.script ?? current.script)
|
|
843
858
|
: current.script,
|
|
844
|
-
videoId
|
|
845
|
-
? (videoContent.videoId ?? current.video_id)
|
|
846
|
-
: current.video_id,
|
|
859
|
+
videoId,
|
|
847
860
|
durationSeconds:
|
|
848
861
|
durationProvided || sourceChanged
|
|
849
862
|
? resolvedDuration
|
|
@@ -853,12 +866,7 @@ export async function updateContentItem(
|
|
|
853
866
|
: current.thumbnail_image_id,
|
|
854
867
|
sourceUrl,
|
|
855
868
|
sourceKind,
|
|
856
|
-
sourceChanged:
|
|
857
|
-
sourceChanged ||
|
|
858
|
-
resolvedDuration !== current.duration_seconds ||
|
|
859
|
-
(input.slug !== undefined && input.slug !== existing.slug) ||
|
|
860
|
-
(input.visibility !== undefined && input.visibility !== existing.visibility) ||
|
|
861
|
-
(hasOwn(videoContent, 'videoId') && videoContent.videoId !== current.video_id),
|
|
869
|
+
sourceChanged: mediaChanged || videoId !== current.video_id,
|
|
862
870
|
}
|
|
863
871
|
} else {
|
|
864
872
|
videoUpdatePlan = null
|
|
@@ -1458,6 +1466,7 @@ export async function evaluateContentBodyForPublish(
|
|
|
1458
1466
|
db: D1Database,
|
|
1459
1467
|
id: string,
|
|
1460
1468
|
source: string,
|
|
1469
|
+
options?: { requireBody?: boolean },
|
|
1461
1470
|
): Promise<PublishGuardOutcome | null> {
|
|
1462
1471
|
const row = await db
|
|
1463
1472
|
.prepare(
|
|
@@ -1489,7 +1498,11 @@ export async function evaluateContentBodyForPublish(
|
|
|
1489
1498
|
{
|
|
1490
1499
|
source,
|
|
1491
1500
|
contentId: id,
|
|
1492
|
-
|
|
1501
|
+
// Absence and corruption are different failures. Publishing requires
|
|
1502
|
+
// a body; SCHEDULING does not -- an editor may legitimately claim a
|
|
1503
|
+
// slot before the piece is finished -- but neither may carry a body
|
|
1504
|
+
// that is already broken.
|
|
1505
|
+
requireBody: options?.requireBody ?? true,
|
|
1493
1506
|
// #329: source-section defects block at publish, warn on save.
|
|
1494
1507
|
blockSourceSectionDefects: true,
|
|
1495
1508
|
},
|
package/src/migration-vendor.ts
CHANGED
|
@@ -119,26 +119,149 @@ function sequencePrefix(sequence: number): string {
|
|
|
119
119
|
return String(sequence).padStart(4, '0')
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function targetSequence(target: string): number {
|
|
123
|
+
return Number.parseInt(target.slice(0, 4), 10)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function managedSuffix(source: string): string {
|
|
127
|
+
return `_growth_labs_cms_${source.replace(/^\d{4}_/, '')}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sequences already spoken for in the consumer's migrations directory.
|
|
132
|
+
*
|
|
133
|
+
* Wrangler accepts one `migrations_dir`, so a consumer's own analytics, SEO or
|
|
134
|
+
* product migrations are interleaved with the vendored package ones. Those
|
|
135
|
+
* numbers are not ours to take.
|
|
136
|
+
*/
|
|
137
|
+
async function occupiedSequences(migrationsDir: string): Promise<Set<number>> {
|
|
138
|
+
const names = await readdir(migrationsDir)
|
|
139
|
+
const taken = new Set<number>()
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
if (!name.endsWith('.sql')) continue
|
|
142
|
+
const sequence = targetSequence(name)
|
|
143
|
+
if (Number.isSafeInteger(sequence)) taken.add(sequence)
|
|
144
|
+
}
|
|
145
|
+
return taken
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface PlanContext {
|
|
149
|
+
/** Recorded source -> target, the authority for anything already vendored. */
|
|
150
|
+
recorded: Map<string, CmsMigrationManifestEntry>
|
|
151
|
+
/** Existing consumer files, for sequence avoidance and hash adoption. */
|
|
152
|
+
migrationsDir?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve each package migration to the consumer filename it must occupy.
|
|
157
|
+
*
|
|
158
|
+
* The hard invariant: a migration this consumer has ALREADY vendored keeps its
|
|
159
|
+
* exact filename forever. D1's `d1_migrations` ledger keys off that name, so
|
|
160
|
+
* renumbering an applied migration would re-run it. Every branch below either
|
|
161
|
+
* reuses a name or allocates an unused one; none ever rewrites an assigned one.
|
|
162
|
+
*
|
|
163
|
+
* Resolution order per source:
|
|
164
|
+
* 1. recorded in the receipt -> reuse that target verbatim;
|
|
165
|
+
* 2. an on-disk file with our managed suffix whose bytes hash identical
|
|
166
|
+
* -> adopt it (reconciles migrations vendored by hand before the receipt
|
|
167
|
+
* existed, without creating a duplicate that would apply the same SQL
|
|
168
|
+
* twice);
|
|
169
|
+
* 3. otherwise -> the next sequence above everything already assigned or
|
|
170
|
+
* occupied, skipping consumer-owned numbers.
|
|
171
|
+
*/
|
|
122
172
|
async function planMigrations(
|
|
123
173
|
sourceDir: string,
|
|
124
174
|
startSequence: number,
|
|
175
|
+
context: PlanContext = { recorded: new Map() },
|
|
125
176
|
): Promise<PlannedMigration[]> {
|
|
126
177
|
assertStartSequence(startSequence)
|
|
127
178
|
const sourceNames = (await readdir(sourceDir)).filter((name) => MIGRATION_FILE.test(name)).sort()
|
|
128
179
|
if (sourceNames.length === 0) throw new Error('installed CMS package has no migration SQL')
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
|
|
181
|
+
const occupied = context.migrationsDir
|
|
182
|
+
? await occupiedSequences(context.migrationsDir)
|
|
183
|
+
: new Set<number>()
|
|
184
|
+
const existingNames = context.migrationsDir ? await readdir(context.migrationsDir) : []
|
|
185
|
+
const assigned = new Set<number>()
|
|
186
|
+
let highWater = startSequence - 1
|
|
187
|
+
for (const sequence of occupied) {
|
|
188
|
+
if (sequence > highWater) highWater = sequence
|
|
131
189
|
}
|
|
132
190
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
191
|
+
const planned: PlannedMigration[] = []
|
|
192
|
+
for (const source of sourceNames) {
|
|
193
|
+
const bytes = new Uint8Array(await readFile(join(sourceDir, source)))
|
|
194
|
+
const hash = await sha256(bytes)
|
|
195
|
+
const suffix = managedSuffix(source)
|
|
196
|
+
|
|
197
|
+
const recorded = context.recorded.get(source)
|
|
198
|
+
if (recorded) {
|
|
199
|
+
const sequence = targetSequence(recorded.target)
|
|
200
|
+
assigned.add(sequence)
|
|
201
|
+
if (sequence > highWater) highWater = sequence
|
|
202
|
+
planned.push({ source, target: recorded.target, sequence, sha256: hash, bytes })
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const adopted = await findAdoptableTarget(
|
|
207
|
+
context.migrationsDir,
|
|
208
|
+
existingNames,
|
|
209
|
+
suffix,
|
|
210
|
+
hash,
|
|
211
|
+
assigned,
|
|
212
|
+
)
|
|
213
|
+
if (adopted) {
|
|
214
|
+
const sequence = targetSequence(adopted)
|
|
215
|
+
assigned.add(sequence)
|
|
216
|
+
if (sequence > highWater) highWater = sequence
|
|
217
|
+
planned.push({ source, target: adopted, sequence, sha256: hash, bytes })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let sequence = Math.max(startSequence, highWater + 1)
|
|
222
|
+
while (occupied.has(sequence) || assigned.has(sequence)) sequence += 1
|
|
223
|
+
if (sequence > 9999) throw new Error('reserved CMS migration sequence exceeds 9999')
|
|
224
|
+
assigned.add(sequence)
|
|
225
|
+
highWater = sequence
|
|
226
|
+
planned.push({
|
|
227
|
+
source,
|
|
228
|
+
target: `${sequencePrefix(sequence)}${suffix}`,
|
|
229
|
+
sequence,
|
|
230
|
+
sha256: hash,
|
|
231
|
+
bytes,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return planned
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* An existing consumer file that IS this package migration, byte for byte.
|
|
240
|
+
*
|
|
241
|
+
* Content-addressed on purpose: adopting by filename alone would let an
|
|
242
|
+
* unrelated file with a colliding suffix be claimed as ours.
|
|
243
|
+
*/
|
|
244
|
+
async function findAdoptableTarget(
|
|
245
|
+
migrationsDir: string | undefined,
|
|
246
|
+
existingNames: string[],
|
|
247
|
+
suffix: string,
|
|
248
|
+
hash: string,
|
|
249
|
+
assigned: Set<number>,
|
|
250
|
+
): Promise<string | null> {
|
|
251
|
+
if (!migrationsDir) return null
|
|
252
|
+
for (const name of existingNames.slice().sort()) {
|
|
253
|
+
if (!name.endsWith('.sql') || !name.endsWith(`${suffix}`)) continue
|
|
254
|
+
const sequence = targetSequence(name)
|
|
255
|
+
if (!Number.isSafeInteger(sequence) || assigned.has(sequence)) continue
|
|
256
|
+
let existingHash: string
|
|
257
|
+
try {
|
|
258
|
+
existingHash = await sha256(new Uint8Array(await readFile(join(migrationsDir, name))))
|
|
259
|
+
} catch {
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
if (existingHash === hash) return name
|
|
263
|
+
}
|
|
264
|
+
return null
|
|
142
265
|
}
|
|
143
266
|
|
|
144
267
|
function manifestFor(
|
|
@@ -365,9 +488,15 @@ export async function syncCmsMigrations(
|
|
|
365
488
|
const manifestPath = await resolveManifestPath(migrationsDir, options.manifestPath)
|
|
366
489
|
return withManifestLock(manifestPath, async () => {
|
|
367
490
|
const contract = await packageContract()
|
|
368
|
-
const plan = await planMigrations(contract.sourceDir, options.startSequence)
|
|
369
|
-
const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
|
|
370
491
|
const currentManifest = await readManifest(manifestPath)
|
|
492
|
+
// The receipt is the authority for anything already vendored: plan with it
|
|
493
|
+
// so recorded migrations keep their exact filenames and only genuinely new
|
|
494
|
+
// ones are allocated a sequence.
|
|
495
|
+
const plan = await planMigrations(contract.sourceDir, options.startSequence, {
|
|
496
|
+
recorded: new Map((currentManifest?.files ?? []).map((entry) => [entry.source, entry])),
|
|
497
|
+
migrationsDir,
|
|
498
|
+
})
|
|
499
|
+
const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
|
|
371
500
|
assertManifestCanAdvance(currentManifest, nextManifest)
|
|
372
501
|
await assertNoSequenceCollisions(migrationsDir, plan)
|
|
373
502
|
const preflight = await verifyTargets(migrationsDir, plan, true)
|
|
@@ -424,7 +553,10 @@ export async function verifyCmsMigrations(
|
|
|
424
553
|
`CMS migration manifest version ${manifest.packageVersion} does not match installed ${contract.packageVersion}`,
|
|
425
554
|
)
|
|
426
555
|
}
|
|
427
|
-
const plan = await planMigrations(contract.sourceDir, manifest.startSequence
|
|
556
|
+
const plan = await planMigrations(contract.sourceDir, manifest.startSequence, {
|
|
557
|
+
recorded: new Map(manifest.files.map((entry) => [entry.source, entry])),
|
|
558
|
+
migrationsDir,
|
|
559
|
+
})
|
|
428
560
|
const expected = manifestFor(contract.packageVersion, manifest.startSequence, plan)
|
|
429
561
|
if (
|
|
430
562
|
expected.files.length !== manifest.files.length ||
|
package/src/routes/content.ts
CHANGED
|
@@ -391,23 +391,49 @@ const ContentMetadataSchema = z
|
|
|
391
391
|
})
|
|
392
392
|
.transform((metadata) => metadata as ContentMetadata)
|
|
393
393
|
|
|
394
|
-
//
|
|
395
|
-
// must
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
394
|
+
// A Nextcloud share PAGE is HTML, never ingestable media, so the stored source
|
|
395
|
+
// must be the direct-download URL. Editors paste the share link -- that is what
|
|
396
|
+
// Nextcloud's "Copy link" button yields -- so normalise it here instead of
|
|
397
|
+
// refusing the save. Refusing it stranded every video upload from the 0.8.5
|
|
398
|
+
// deploy until 0.8.8, because the masthead only rendered the top-level
|
|
399
|
+
// 'Invalid request' and never the field message telling them what to change.
|
|
400
|
+
//
|
|
401
|
+
// Only the onboarded share host is claimed, and only the share-link shape:
|
|
402
|
+
// https, no credentials, default port, /s/<token> with an optional /download.
|
|
403
|
+
// Nextcloud's folder-listing "Copy link" appends ?dir=/ to a single-file share;
|
|
404
|
+
// it carries nothing the download route needs, so it is dropped. Anything else
|
|
405
|
+
// -- another host, another path, a non-URL -- is returned untouched for the
|
|
406
|
+
// normal URL validation to accept or reject on its own terms.
|
|
407
|
+
const NEXTCLOUD_SHARE_HOSTS = ['drive.fulcrum-portal.com']
|
|
408
|
+
const NEXTCLOUD_SHARE_PATH_RE = /^\/s\/[A-Za-z0-9_-]+(?:\/download)?\/?$/
|
|
409
|
+
|
|
410
|
+
export function normaliseNextcloudShareUrl(value: string): string {
|
|
411
|
+
let url: URL
|
|
412
|
+
try {
|
|
413
|
+
url = new URL(value)
|
|
414
|
+
} catch {
|
|
415
|
+
return value
|
|
416
|
+
}
|
|
417
|
+
if (
|
|
418
|
+
url.protocol !== 'https:' ||
|
|
419
|
+
url.username ||
|
|
420
|
+
url.password ||
|
|
421
|
+
(url.port && url.port !== '443') ||
|
|
422
|
+
!NEXTCLOUD_SHARE_HOSTS.includes(url.hostname.toLowerCase().replace(/\.$/, '')) ||
|
|
423
|
+
!NEXTCLOUD_SHARE_PATH_RE.test(url.pathname)
|
|
424
|
+
) {
|
|
425
|
+
return value
|
|
426
|
+
}
|
|
427
|
+
if (url.searchParams.get('dir') !== null && url.searchParams.size === 1) {
|
|
428
|
+
url.search = ''
|
|
429
|
+
}
|
|
430
|
+
if (!/\/download\/?$/.test(url.pathname)) {
|
|
431
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/download`
|
|
432
|
+
}
|
|
433
|
+
return url.toString()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const MediaSourceUrlSchema = z.string().url().transform(normaliseNextcloudShareUrl)
|
|
411
437
|
|
|
412
438
|
function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
413
439
|
const category = slugEnum(resolved.primaryCategorySlugs)
|
|
@@ -1114,6 +1140,72 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1114
1140
|
}
|
|
1115
1141
|
}
|
|
1116
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* The video sibling of maybeDispatchPodcastToFoundry: capture a sourced video
|
|
1145
|
+
* that holds no capture. A row with no trigger token is invisible to the
|
|
1146
|
+
* producer whatever its state says (trash and restore clears the token and
|
|
1147
|
+
* leaves `queued`), so only `ready` media and `failed` — the editor's Re-run
|
|
1148
|
+
* decision — are left alone. An archived item is inactive to the dispatcher;
|
|
1149
|
+
* it is captured once scheduling has made it active again.
|
|
1150
|
+
* The editor form captures after a save too, but it de-dupes on local state; a
|
|
1151
|
+
* later save that reset the capture left the video scheduled and never
|
|
1152
|
+
* prepared, with nothing to notice (Fronts, 2026-09-15..17).
|
|
1153
|
+
*/
|
|
1154
|
+
async function maybeCaptureVideoSource(
|
|
1155
|
+
ctx: RouteContext,
|
|
1156
|
+
contentId: string,
|
|
1157
|
+
): Promise<DispatchResult | null> {
|
|
1158
|
+
const hook = resolved.hooks.foundryVideo
|
|
1159
|
+
if (!hook) return null
|
|
1160
|
+
const readCapture = () =>
|
|
1161
|
+
ctx.db
|
|
1162
|
+
.prepare(
|
|
1163
|
+
`SELECT vc.processing_source_url, vc.processing_trigger_token, vc.processing_state,
|
|
1164
|
+
vc.hls_ready, ci.status
|
|
1165
|
+
FROM video_content vc JOIN content_items ci ON ci.id = vc.content_id
|
|
1166
|
+
WHERE vc.content_id = ?
|
|
1167
|
+
LIMIT 1`,
|
|
1168
|
+
)
|
|
1169
|
+
.bind(contentId)
|
|
1170
|
+
.first<{
|
|
1171
|
+
processing_source_url: string | null
|
|
1172
|
+
processing_trigger_token: string | null
|
|
1173
|
+
processing_state: string | null
|
|
1174
|
+
hls_ready: number | null
|
|
1175
|
+
status: string
|
|
1176
|
+
}>()
|
|
1177
|
+
const row = await readCapture()
|
|
1178
|
+
if (!row?.processing_source_url?.trim()) return null
|
|
1179
|
+
if (row.processing_trigger_token?.trim()) return null
|
|
1180
|
+
if (row.status === 'archived') return null
|
|
1181
|
+
if (row.hls_ready === 1) return null
|
|
1182
|
+
if (row.processing_state === 'ready' || row.processing_state === 'failed') return null
|
|
1183
|
+
try {
|
|
1184
|
+
return await dispatchToFoundry(ctx.db, contentId, hook, { ifUncaptured: true })
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
// The editor form fires its own capture after every save. Losing that
|
|
1187
|
+
// race fails this call's compare-and-set, and the capture exists.
|
|
1188
|
+
const raced = await readCapture()
|
|
1189
|
+
if (raced?.processing_trigger_token?.trim()) return null
|
|
1190
|
+
throw error
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function captureScheduledVideoAfterSave(
|
|
1195
|
+
ctx: RouteContext,
|
|
1196
|
+
contentId: string,
|
|
1197
|
+
): Promise<PodcastFoundryAttempt> {
|
|
1198
|
+
try {
|
|
1199
|
+
return { result: await maybeCaptureVideoSource(ctx, contentId), reason: null }
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1202
|
+
return { result: null, reason: 'missing_duration' }
|
|
1203
|
+
}
|
|
1204
|
+
console.error('Scheduled video source capture failed after content save', error)
|
|
1205
|
+
return { result: null, reason: 'dispatch_failed' }
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1117
1209
|
function missingDurationResponse(): Response {
|
|
1118
1210
|
return json(
|
|
1119
1211
|
{
|
|
@@ -1396,6 +1488,33 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1396
1488
|
const publishAtSeconds = Math.floor(publishAtMs / 1000)
|
|
1397
1489
|
const expectedTargets = new Map<string, ScheduleFrontsChannelTargets | undefined>()
|
|
1398
1490
|
for (const id of ids) {
|
|
1491
|
+
// Same gate as the single-item path: no item in a bulk schedule
|
|
1492
|
+
// may take a slot with a body publish will refuse.
|
|
1493
|
+
const bulkGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
|
|
1494
|
+
requireBody: false,
|
|
1495
|
+
})
|
|
1496
|
+
if (bulkGuard && !bulkGuard.ok) {
|
|
1497
|
+
return json(
|
|
1498
|
+
{
|
|
1499
|
+
error: 'Article body failed pre-publish validation',
|
|
1500
|
+
source: 'cms.schedule',
|
|
1501
|
+
contentId: id,
|
|
1502
|
+
errors: bulkGuard.errors,
|
|
1503
|
+
},
|
|
1504
|
+
422,
|
|
1505
|
+
)
|
|
1506
|
+
}
|
|
1507
|
+
// And the same capture guarantee: no sourced video in a bulk
|
|
1508
|
+
// schedule may take a slot the producer cannot see.
|
|
1509
|
+
try {
|
|
1510
|
+
await maybeCaptureVideoSource(ctx, id)
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1513
|
+
return missingDurationResponse()
|
|
1514
|
+
}
|
|
1515
|
+
console.error('Video source capture failed before bulk schedule', error)
|
|
1516
|
+
return json({ error: 'Foundry dispatch failed', contentId: id }, 502)
|
|
1517
|
+
}
|
|
1399
1518
|
const prepared = await prepareScheduleTargets(ctx, id)
|
|
1400
1519
|
if ('error' in prepared) return prepared.error
|
|
1401
1520
|
expectedTargets.set(id, prepared.targets)
|
|
@@ -1407,6 +1526,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1407
1526
|
if (targets !== undefined && !changed) {
|
|
1408
1527
|
return json({ error: 'schedule_targets_changed', contentId: id, scheduled }, 409)
|
|
1409
1528
|
}
|
|
1529
|
+
// Covers an item that was archived above, now active again.
|
|
1530
|
+
await captureScheduledVideoAfterSave(ctx, id)
|
|
1410
1531
|
scheduled.push(id)
|
|
1411
1532
|
}
|
|
1412
1533
|
return json({ scheduled })
|
|
@@ -1712,6 +1833,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1712
1833
|
existing.type === 'podcast'
|
|
1713
1834
|
? await dispatchPodcastAfterSave(ctx, id)
|
|
1714
1835
|
: { result: null, reason: null }
|
|
1836
|
+
// A scheduled video is committed to a slot: a save that reset its capture
|
|
1837
|
+
// must not leave it unprepared until someone notices the missed publish.
|
|
1838
|
+
const videoAttempt: PodcastFoundryAttempt =
|
|
1839
|
+
existing.type === 'video' && existing.status === 'scheduled'
|
|
1840
|
+
? await captureScheduledVideoAfterSave(ctx, id)
|
|
1841
|
+
: { result: null, reason: null }
|
|
1715
1842
|
|
|
1716
1843
|
// If the slug changed, record the old slug as a redirect (spec §8)
|
|
1717
1844
|
// and drop any stale redirect row for the now-live slug — atomically,
|
|
@@ -1726,6 +1853,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1726
1853
|
podcastFoundryQueued: Boolean(podcastAttempt.result),
|
|
1727
1854
|
podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
|
|
1728
1855
|
podcastFoundryReason: podcastAttempt.reason,
|
|
1856
|
+
videoFoundryQueued: Boolean(videoAttempt.result && !videoAttempt.result.skipped),
|
|
1857
|
+
videoFoundryReason: videoAttempt.reason,
|
|
1729
1858
|
...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
|
|
1730
1859
|
// Present and empty when the body linted clean, so a client can
|
|
1731
1860
|
// distinguish "no warnings" from "this build does not report them".
|
|
@@ -1782,6 +1911,41 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1782
1911
|
return json({ error: 'Missing publish time' }, 400)
|
|
1783
1912
|
}
|
|
1784
1913
|
|
|
1914
|
+
// A body the publish guard will refuse must not be allowed to take a
|
|
1915
|
+
// slot. Scheduling was the one write in this lane that never ran
|
|
1916
|
+
// the guard, so a defect an editor could not see at save time
|
|
1917
|
+
// surfaced only when foundryd tried to publish — at 16:30 on a
|
|
1918
|
+
// Monday, as a missed premium article (2026-09-15).
|
|
1919
|
+
const scheduleGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
|
|
1920
|
+
// Claiming a slot before the piece is written stays allowed;
|
|
1921
|
+
// claiming one with an already-broken body does not.
|
|
1922
|
+
requireBody: false,
|
|
1923
|
+
})
|
|
1924
|
+
if (scheduleGuard && !scheduleGuard.ok) {
|
|
1925
|
+
return json(
|
|
1926
|
+
{
|
|
1927
|
+
error: 'Article body failed pre-publish validation',
|
|
1928
|
+
source: 'cms.schedule',
|
|
1929
|
+
errors: scheduleGuard.errors,
|
|
1930
|
+
},
|
|
1931
|
+
422,
|
|
1932
|
+
)
|
|
1933
|
+
}
|
|
1934
|
+
// A sourced video that holds no capture is invisible to the producer:
|
|
1935
|
+
// it would take the slot and never publish. Capture it here, and refuse
|
|
1936
|
+
// the slot if that cannot be done. A video with no source yet may still
|
|
1937
|
+
// claim a slot early.
|
|
1938
|
+
if (existing.type === 'video') {
|
|
1939
|
+
try {
|
|
1940
|
+
await maybeCaptureVideoSource(ctx, id)
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1943
|
+
return missingDurationResponse()
|
|
1944
|
+
}
|
|
1945
|
+
console.error('Video source capture failed before schedule', error)
|
|
1946
|
+
return json({ error: 'Foundry dispatch failed' }, 502)
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1785
1949
|
const prepared = await prepareScheduleTargets(ctx, id)
|
|
1786
1950
|
if ('error' in prepared) return prepared.error
|
|
1787
1951
|
const changed = await scheduleContent(
|
|
@@ -1794,6 +1958,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1794
1958
|
if (prepared.targets !== undefined && !changed) {
|
|
1795
1959
|
return json({ error: 'schedule_targets_changed', contentId: id }, 409)
|
|
1796
1960
|
}
|
|
1961
|
+
// Covers an item that was archived above, now active again.
|
|
1962
|
+
if (existing.type === 'video') await captureScheduledVideoAfterSave(ctx, id)
|
|
1797
1963
|
|
|
1798
1964
|
// Fire content.scheduled webhook.
|
|
1799
1965
|
await fireWebhook(ctx, 'content.scheduled', {
|
|
@@ -1809,6 +1975,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1809
1975
|
if (action === 'publish') {
|
|
1810
1976
|
const now = Math.floor(Date.now() / 1000)
|
|
1811
1977
|
const wasDraft = existing.status === 'draft'
|
|
1978
|
+
const publishedAt =
|
|
1979
|
+
existing.status === 'published' && existing.published_at !== null
|
|
1980
|
+
? existing.published_at
|
|
1981
|
+
: now
|
|
1812
1982
|
let podcastDispatch: DispatchResult | null = null
|
|
1813
1983
|
if (existing.type === 'podcast') {
|
|
1814
1984
|
try {
|
|
@@ -1903,7 +2073,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1903
2073
|
throw error
|
|
1904
2074
|
}
|
|
1905
2075
|
try {
|
|
1906
|
-
await publishContent(ctx.db, id, now)
|
|
2076
|
+
await publishContent(ctx.db, id, publishedAt, now)
|
|
1907
2077
|
await createRevision(ctx.db, id, ctx.userId || null)
|
|
1908
2078
|
} catch (error) {
|
|
1909
2079
|
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|