@growth-labs/cms 0.8.8 → 0.8.15
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/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +13 -4
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +20 -3
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +25 -11
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- 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/google-drive-source.d.ts +27 -0
- package/dist/engine/google-drive-source.d.ts.map +1 -0
- package/dist/engine/google-drive-source.js +87 -0
- package/dist/engine/google-drive-source.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 +1 -0
- package/dist/engine/index.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 +140 -12
- package/dist/migration-vendor.js.map +1 -1
- package/dist/providers/types.d.ts +6 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +191 -8
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +2 -2
- 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/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +13 -4
- package/src/engine/fronts-publish-intent.ts +50 -15
- package/src/engine/fronts-publish.ts +45 -6
- package/src/engine/google-drive-source.ts +99 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/publisher.ts +24 -11
- package/src/migration-vendor.ts +156 -14
- package/src/providers/types.ts +6 -1
- package/src/routes/content.ts +209 -7
- package/src/ui/editor/ContentForm.tsx +5 -1
- 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
|
@@ -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 &&
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Google Drive share-link recognition for media sources.
|
|
2
|
+
//
|
|
3
|
+
// A Drive share link is an HTML viewer page, never media. The Go media plane
|
|
4
|
+
// fetches a Drive file itself (with the estate's Google identity) and measures
|
|
5
|
+
// its duration, so the CMS only has to recognise the link and hand over the
|
|
6
|
+
// file id (and the resource key that older shared files require).
|
|
7
|
+
//
|
|
8
|
+
// The link stays in the editor's own column exactly as pasted: the
|
|
9
|
+
// `processing_source_kind` column is CHECK-constrained to 'https', so Drive-ness
|
|
10
|
+
// is DERIVED from the stored URL, here, at serve/dispatch time -- one function,
|
|
11
|
+
// no second source of truth to drift.
|
|
12
|
+
|
|
13
|
+
/** Google Drive file ids are URL-safe base64-ish; the Go side enforces the same. */
|
|
14
|
+
const DRIVE_FILE_ID_RE = /^[A-Za-z0-9_-]{10,200}$/
|
|
15
|
+
const DRIVE_RESOURCE_KEY_RE = /^[A-Za-z0-9_-]{1,200}$/
|
|
16
|
+
|
|
17
|
+
const DRIVE_HOSTS = new Set(['drive.google.com', 'docs.google.com'])
|
|
18
|
+
|
|
19
|
+
const NOT_A_FILE_REASON =
|
|
20
|
+
'That is a Google Doc/Sheet/Slides/folder link, not an audio file. Share the audio file itself from Google Drive.'
|
|
21
|
+
const NO_FILE_ID_REASON =
|
|
22
|
+
'That Google Drive link has no valid file id. Copy the share link of the audio file itself from Google Drive.'
|
|
23
|
+
|
|
24
|
+
export type GoogleDriveLink =
|
|
25
|
+
| { kind: 'drive'; fileId: string; resourceKey?: string }
|
|
26
|
+
| { kind: 'drive_invalid'; reason: string }
|
|
27
|
+
| { kind: 'not_drive' }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Classify a pasted media-source URL.
|
|
31
|
+
*
|
|
32
|
+
* - `not_drive`: not on a Google Drive host (drive.google.com, docs.google.com,
|
|
33
|
+
* host compared case-insensitively). The caller handles it as any other URL.
|
|
34
|
+
* - `drive`: a recognised file link carrying a valid file id.
|
|
35
|
+
* - `drive_invalid`: on a Drive host but not usable as an audio file -- a
|
|
36
|
+
* Doc/Sheet/folder, a file link with no or a malformed id, a malformed
|
|
37
|
+
* `resourcekey`, or a shape this function does not know. The caller must
|
|
38
|
+
* refuse it: stored as a plain https URL it would be an HTML page.
|
|
39
|
+
*
|
|
40
|
+
* Recognised shapes (each optionally with `/u/<n>/` user segments):
|
|
41
|
+
* /file/d/<id>[/view|/edit|/...] /open?id=<id>
|
|
42
|
+
* /uc?id=<id>[&export=download] (docs.google.com/uc?id=<id> too)
|
|
43
|
+
*/
|
|
44
|
+
export function classifyGoogleDriveUrl(value: string): GoogleDriveLink {
|
|
45
|
+
let url: URL
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(value.trim())
|
|
48
|
+
} catch {
|
|
49
|
+
return { kind: 'not_drive' }
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return { kind: 'not_drive' }
|
|
52
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, '')
|
|
53
|
+
if (!DRIVE_HOSTS.has(host)) return { kind: 'not_drive' }
|
|
54
|
+
|
|
55
|
+
if (url.protocol !== 'https:') {
|
|
56
|
+
return { kind: 'drive_invalid', reason: 'Google Drive links must use https.' }
|
|
57
|
+
}
|
|
58
|
+
if (url.username || url.password || (url.port && url.port !== '443')) {
|
|
59
|
+
return { kind: 'drive_invalid', reason: 'That Google Drive link is malformed.' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `/u/<n>/` selects the signed-in Google account; it carries nothing else.
|
|
63
|
+
const path = url.pathname.replace(/\/u\/\d+(?=\/)/g, '').replace(/\/+$/, '')
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
/^\/(?:document|spreadsheets|presentation|forms)\/d(?:\/|$)/.test(path) ||
|
|
67
|
+
/^\/(?:drive\/)?folders(?:\/|$)/.test(path) ||
|
|
68
|
+
/^\/(?:embedded)?folderview$/.test(path)
|
|
69
|
+
) {
|
|
70
|
+
return { kind: 'drive_invalid', reason: NOT_A_FILE_REASON }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let candidate: string | null = null
|
|
74
|
+
const filePath = /^\/file\/d(?:\/([^/]*))?(?:\/.*)?$/.exec(path)
|
|
75
|
+
if (filePath) {
|
|
76
|
+
candidate = filePath[1] ?? ''
|
|
77
|
+
} else if (path === '/uc' || (path === '/open' && host === 'drive.google.com')) {
|
|
78
|
+
candidate = url.searchParams.get('id') ?? ''
|
|
79
|
+
} else {
|
|
80
|
+
return {
|
|
81
|
+
kind: 'drive_invalid',
|
|
82
|
+
reason:
|
|
83
|
+
'That Google Drive link is not a file link. Copy the share link of the audio file itself from Google Drive.',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!DRIVE_FILE_ID_RE.test(candidate)) {
|
|
87
|
+
return { kind: 'drive_invalid', reason: NO_FILE_ID_REASON }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resourceKey = url.searchParams.get('resourcekey')
|
|
91
|
+
if (resourceKey === null) return { kind: 'drive', fileId: candidate }
|
|
92
|
+
if (!DRIVE_RESOURCE_KEY_RE.test(resourceKey)) {
|
|
93
|
+
return {
|
|
94
|
+
kind: 'drive_invalid',
|
|
95
|
+
reason: 'That Google Drive link has a malformed resource key. Copy the share link again.',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { kind: 'drive', fileId: candidate, resourceKey }
|
|
99
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -111,6 +111,7 @@ export {
|
|
|
111
111
|
hydrateFrontsPublishIntents,
|
|
112
112
|
setFrontsChannelTargets,
|
|
113
113
|
} from './fronts-publish-intent.js'
|
|
114
|
+
export { classifyGoogleDriveUrl, type GoogleDriveLink } from './google-drive-source.js'
|
|
114
115
|
// Import parsers — pure string→struct transforms for Markdown/Ghost/Substack.
|
|
115
116
|
export {
|
|
116
117
|
htmlToMarkdown,
|
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(
|
|
@@ -218,12 +341,22 @@ function assertManifestCanAdvance(
|
|
|
218
341
|
}
|
|
219
342
|
}
|
|
220
343
|
|
|
344
|
+
/**
|
|
345
|
+
* A sequence the plan is about to allocate must not already be owned by a
|
|
346
|
+
* consumer migration. A target that is already on disk -- recorded in the
|
|
347
|
+
* receipt or adopted by hash -- is a historical fact, not a reservation: it has
|
|
348
|
+
* usually been applied, D1's ledger keys off its exact filename, and the only
|
|
349
|
+
* "fix" for a consumer file sharing its number would be a rename that re-runs
|
|
350
|
+
* applied SQL. So the guard covers new targets only; pre-existing duplicates are
|
|
351
|
+
* the consumer's to live with and never block vendoring the next migration.
|
|
352
|
+
*/
|
|
221
353
|
async function assertNoSequenceCollisions(
|
|
222
354
|
migrationsDir: string,
|
|
223
355
|
plan: PlannedMigration[],
|
|
224
356
|
): Promise<void> {
|
|
225
357
|
const existing = await readdir(migrationsDir)
|
|
226
358
|
for (const migration of plan) {
|
|
359
|
+
if (existing.includes(migration.target)) continue
|
|
227
360
|
const prefix = `${sequencePrefix(migration.sequence)}_`
|
|
228
361
|
const collisions = existing.filter(
|
|
229
362
|
(name) => name.endsWith('.sql') && name.startsWith(prefix) && name !== migration.target,
|
|
@@ -365,9 +498,15 @@ export async function syncCmsMigrations(
|
|
|
365
498
|
const manifestPath = await resolveManifestPath(migrationsDir, options.manifestPath)
|
|
366
499
|
return withManifestLock(manifestPath, async () => {
|
|
367
500
|
const contract = await packageContract()
|
|
368
|
-
const plan = await planMigrations(contract.sourceDir, options.startSequence)
|
|
369
|
-
const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
|
|
370
501
|
const currentManifest = await readManifest(manifestPath)
|
|
502
|
+
// The receipt is the authority for anything already vendored: plan with it
|
|
503
|
+
// so recorded migrations keep their exact filenames and only genuinely new
|
|
504
|
+
// ones are allocated a sequence.
|
|
505
|
+
const plan = await planMigrations(contract.sourceDir, options.startSequence, {
|
|
506
|
+
recorded: new Map((currentManifest?.files ?? []).map((entry) => [entry.source, entry])),
|
|
507
|
+
migrationsDir,
|
|
508
|
+
})
|
|
509
|
+
const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
|
|
371
510
|
assertManifestCanAdvance(currentManifest, nextManifest)
|
|
372
511
|
await assertNoSequenceCollisions(migrationsDir, plan)
|
|
373
512
|
const preflight = await verifyTargets(migrationsDir, plan, true)
|
|
@@ -424,7 +563,10 @@ export async function verifyCmsMigrations(
|
|
|
424
563
|
`CMS migration manifest version ${manifest.packageVersion} does not match installed ${contract.packageVersion}`,
|
|
425
564
|
)
|
|
426
565
|
}
|
|
427
|
-
const plan = await planMigrations(contract.sourceDir, manifest.startSequence
|
|
566
|
+
const plan = await planMigrations(contract.sourceDir, manifest.startSequence, {
|
|
567
|
+
recorded: new Map(manifest.files.map((entry) => [entry.source, entry])),
|
|
568
|
+
migrationsDir,
|
|
569
|
+
})
|
|
428
570
|
const expected = manifestFor(contract.packageVersion, manifest.startSequence, plan)
|
|
429
571
|
if (
|
|
430
572
|
expected.files.length !== manifest.files.length ||
|
package/src/providers/types.ts
CHANGED
|
@@ -391,7 +391,12 @@ export interface FoundryJobSpec {
|
|
|
391
391
|
sourceUrl: string
|
|
392
392
|
sourceKind?: string
|
|
393
393
|
kind: string
|
|
394
|
-
|
|
394
|
+
/**
|
|
395
|
+
* The authoritative media duration. `null` only for a Google Drive podcast
|
|
396
|
+
* source (`sourceKind: 'google_drive'`): the Go plane fetches the file itself
|
|
397
|
+
* and measures the duration, so the CMS has none to send.
|
|
398
|
+
*/
|
|
399
|
+
durationSeconds: number | null
|
|
395
400
|
videoId?: string
|
|
396
401
|
title?: string
|
|
397
402
|
description?: string | null
|