@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.
Files changed (69) hide show
  1. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  2. package/dist/engine/foundry-dispatch.js +13 -4
  3. package/dist/engine/foundry-dispatch.js.map +1 -1
  4. package/dist/engine/fronts-publish-intent.d.ts +20 -3
  5. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  6. package/dist/engine/fronts-publish-intent.js +25 -11
  7. package/dist/engine/fronts-publish-intent.js.map +1 -1
  8. package/dist/engine/fronts-publish.d.ts.map +1 -1
  9. package/dist/engine/fronts-publish.js +44 -8
  10. package/dist/engine/fronts-publish.js.map +1 -1
  11. package/dist/engine/google-drive-source.d.ts +27 -0
  12. package/dist/engine/google-drive-source.d.ts.map +1 -0
  13. package/dist/engine/google-drive-source.js +87 -0
  14. package/dist/engine/google-drive-source.js.map +1 -0
  15. package/dist/engine/index.d.ts +1 -0
  16. package/dist/engine/index.d.ts.map +1 -1
  17. package/dist/engine/index.js +1 -0
  18. package/dist/engine/index.js.map +1 -1
  19. package/dist/engine/publisher.d.ts +3 -1
  20. package/dist/engine/publisher.d.ts.map +1 -1
  21. package/dist/engine/publisher.js +21 -11
  22. package/dist/engine/publisher.js.map +1 -1
  23. package/dist/migration-vendor.d.ts.map +1 -1
  24. package/dist/migration-vendor.js +140 -12
  25. package/dist/migration-vendor.js.map +1 -1
  26. package/dist/providers/types.d.ts +6 -1
  27. package/dist/providers/types.d.ts.map +1 -1
  28. package/dist/providers/types.js.map +1 -1
  29. package/dist/routes/content.d.ts.map +1 -1
  30. package/dist/routes/content.js +191 -8
  31. package/dist/routes/content.js.map +1 -1
  32. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  33. package/dist/ui/editor/ContentForm.js +2 -2
  34. package/dist/ui/editor/ContentForm.js.map +1 -1
  35. package/dist/ui/editor/editor-media-upload.d.ts.map +1 -1
  36. package/dist/ui/editor/editor-media-upload.js +2 -1
  37. package/dist/ui/editor/editor-media-upload.js.map +1 -1
  38. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  39. package/dist/ui/inspector/PublishTab.js +2 -1
  40. package/dist/ui/inspector/PublishTab.js.map +1 -1
  41. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  42. package/dist/ui/screens/LibraryScreen.js +4 -3
  43. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  44. package/dist/ui/screens/SettingsScreen.d.ts.map +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +14 -13
  46. package/dist/ui/screens/SettingsScreen.js.map +1 -1
  47. package/dist/ui/screens/TopicsScreen.d.ts.map +1 -1
  48. package/dist/ui/screens/TopicsScreen.js +3 -2
  49. package/dist/ui/screens/TopicsScreen.js.map +1 -1
  50. package/dist/ui/screens/author-recovery.d.ts.map +1 -1
  51. package/dist/ui/screens/author-recovery.js +2 -1
  52. package/dist/ui/screens/author-recovery.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/engine/foundry-dispatch.ts +13 -4
  55. package/src/engine/fronts-publish-intent.ts +50 -15
  56. package/src/engine/fronts-publish.ts +45 -6
  57. package/src/engine/google-drive-source.ts +99 -0
  58. package/src/engine/index.ts +1 -0
  59. package/src/engine/publisher.ts +24 -11
  60. package/src/migration-vendor.ts +156 -14
  61. package/src/providers/types.ts +6 -1
  62. package/src/routes/content.ts +209 -7
  63. package/src/ui/editor/ContentForm.tsx +5 -1
  64. package/src/ui/editor/editor-media-upload.ts +2 -1
  65. package/src/ui/inspector/PublishTab.tsx +2 -1
  66. package/src/ui/screens/LibraryScreen.tsx +4 -3
  67. package/src/ui/screens/SettingsScreen.tsx +14 -13
  68. package/src/ui/screens/TopicsScreen.tsx +3 -2
  69. 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
- ledgerError = error instanceof Error ? error.message : String(error)
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
- input.status,
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 (always reflected, never
756
- * downgraded); older-by-occurred_at callbacks may not regress a newer marker.
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 true
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
+ }
@@ -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,
@@ -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: hasOwn(videoContent, '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
- requireBody: true,
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
  },
@@ -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
- if (startSequence + sourceNames.length - 1 > 9999) {
130
- throw new Error('reserved CMS migration sequence exceeds 9999')
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
- 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
- )
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 ||
@@ -391,7 +391,12 @@ export interface FoundryJobSpec {
391
391
  sourceUrl: string
392
392
  sourceKind?: string
393
393
  kind: string
394
- durationSeconds: number
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