@learnpack/learnpack 5.0.352 → 5.0.354

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 (85) hide show
  1. package/lib/commands/publish.js +6 -0
  2. package/lib/commands/serve.js +85 -259
  3. package/lib/models/creator.d.ts +6 -0
  4. package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
  5. package/lib/scripts/descriptionsGcsBackfill.js +141 -0
  6. package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
  7. package/lib/scripts/descriptionsS3Backfill.js +277 -0
  8. package/lib/scripts/descriptionsSweep.d.ts +1 -0
  9. package/lib/scripts/descriptionsSweep.js +142 -0
  10. package/lib/utils/api.d.ts +7 -0
  11. package/lib/utils/api.js +8 -1
  12. package/lib/utils/awsCredentials.d.ts +20 -0
  13. package/lib/utils/awsCredentials.js +43 -0
  14. package/lib/utils/creatorUtilities.js +2 -1
  15. package/lib/utils/descriptionHash.d.ts +66 -0
  16. package/lib/utils/descriptionHash.js +173 -0
  17. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  18. package/lib/utils/descriptions/backfillEvents.js +107 -0
  19. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  20. package/lib/utils/descriptions/gcsStorage.js +60 -0
  21. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +66 -0
  22. package/lib/utils/descriptions/generateCourseDescriptions.js +176 -0
  23. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  24. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  25. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  26. package/lib/utils/descriptions/publishStage.js +245 -0
  27. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  28. package/lib/utils/descriptions/resumePublication.js +128 -0
  29. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  30. package/lib/utils/descriptions/s3Storage.js +141 -0
  31. package/lib/utils/descriptions/workList.d.ts +75 -0
  32. package/lib/utils/descriptions/workList.js +177 -0
  33. package/lib/utils/gcsBucketName.d.ts +10 -0
  34. package/lib/utils/gcsBucketName.js +19 -0
  35. package/lib/utils/packageManifest.d.ts +22 -0
  36. package/lib/utils/packageManifest.js +57 -5
  37. package/lib/utils/publishEvents.d.ts +66 -0
  38. package/lib/utils/publishEvents.js +111 -0
  39. package/lib/utils/publishJournal.d.ts +119 -0
  40. package/lib/utils/publishJournal.js +275 -0
  41. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  42. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  43. package/lib/utils/repair/repairStorage.d.ts +68 -0
  44. package/lib/utils/repair/repairStorage.js +89 -0
  45. package/lib/utils/rigoActions.d.ts +44 -0
  46. package/lib/utils/rigoActions.js +75 -1
  47. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  48. package/lib/utils/s3/packageManifestBackfill.js +5 -8
  49. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  50. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  51. package/lib/utils/syllabusSync.d.ts +71 -0
  52. package/lib/utils/syllabusSync.js +273 -0
  53. package/package.json +3 -1
  54. package/src/commands/publish.ts +7 -0
  55. package/src/commands/serve.ts +144 -335
  56. package/src/models/creator.ts +9 -0
  57. package/src/scripts/README.md +244 -0
  58. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  59. package/src/scripts/descriptionsS3Backfill.ts +376 -0
  60. package/src/scripts/descriptionsSweep.ts +185 -0
  61. package/src/ui/_app/app.css +1 -1
  62. package/src/ui/_app/app.js +142 -140
  63. package/src/ui/app.tar.gz +0 -0
  64. package/src/utils/api.ts +9 -0
  65. package/src/utils/awsCredentials.ts +57 -0
  66. package/src/utils/creatorUtilities.ts +2 -1
  67. package/src/utils/descriptionHash.ts +196 -0
  68. package/src/utils/descriptions/backfillEvents.ts +152 -0
  69. package/src/utils/descriptions/gcsStorage.ts +67 -0
  70. package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
  71. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  72. package/src/utils/descriptions/publishStage.ts +394 -0
  73. package/src/utils/descriptions/resumePublication.ts +217 -0
  74. package/src/utils/descriptions/s3Storage.ts +214 -0
  75. package/src/utils/descriptions/workList.ts +283 -0
  76. package/src/utils/gcsBucketName.ts +19 -0
  77. package/src/utils/packageManifest.ts +62 -5
  78. package/src/utils/publishEvents.ts +181 -0
  79. package/src/utils/publishJournal.ts +383 -0
  80. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  81. package/src/utils/repair/repairStorage.ts +168 -0
  82. package/src/utils/rigoActions.ts +130 -0
  83. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  84. package/src/utils/s3/packageSourcesAudit.ts +311 -0
  85. package/src/utils/syllabusSync.ts +390 -0
@@ -1,776 +1,771 @@
1
- import {
2
- CreateInvalidationCommand,
3
- CloudFrontClient,
4
- } from "@aws-sdk/client-cloudfront"
5
- import {
6
- GetObjectCommand,
7
- ListObjectsV2Command,
8
- PutObjectCommand,
9
- S3Client,
10
- } from "@aws-sdk/client-s3"
11
- import { Exercise } from "../configBuilder"
12
- import { Syllabus } from "../../models/creator"
13
- import {
14
- buildPackageManifestFromSources,
15
- comparePackageManifests,
16
- PACKAGE_MANIFEST_REL_PATH,
17
- parseReadmeContent,
18
- PackageManifest,
19
- ReadmeFrontmatter,
20
- serializePackageManifest,
21
- } from "../packageManifest"
22
-
23
- export type PackageResultStatus = "created" | "updated" | "skipped" | "failed";
24
-
25
- export type PackageResult = {
26
- slug: string;
27
- status: PackageResultStatus;
28
- durationMs: number;
29
- error?: string;
30
- step?: string;
31
- sources?: {
32
- learnJson: boolean;
33
- configJson: boolean;
34
- sidebar: boolean;
35
- syllabus: boolean;
36
- readmes: number;
37
- };
38
- };
39
-
40
- export type BackfillOptions = {
41
- dryRun: boolean;
42
- force: boolean;
43
- skipExisting: boolean;
44
- noInvalidate: boolean;
45
- concurrency: number;
46
- publishedAt: string | null;
47
- resumeFrom?: string;
48
- reportOut: string;
49
- slug?: string;
50
- bucket: string;
51
- region: string;
52
- cloudFrontDistributionId?: string;
53
- };
54
-
55
- export type BatchReport = {
56
- total: number;
57
- created: number;
58
- updated: number;
59
- skipped: number;
60
- failed: number;
61
- startedAt: string;
62
- finishedAt: string;
63
- options: BackfillOptions;
64
- results: PackageResult[];
65
- invalidation?: {
66
- attempted: boolean;
67
- skippedReason?: string;
68
- id?: string;
69
- paths?: string[];
70
- error?: string;
71
- };
72
- };
73
-
74
- export type AwsClient = {
75
- send(command: unknown): Promise<unknown>;
76
- };
77
-
78
- type ConfigJsonShape = {
79
- config?: Record<string, unknown>;
80
- exercises?: Exercise[];
81
- };
82
-
83
- type PackageSources = {
84
- learnJson: Record<string, unknown>;
85
- exercises: Exercise[];
86
- sidebar: Record<string, Record<string, string>>;
87
- syllabus: Syllabus | null;
88
- readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>;
89
- meta: PackageResult["sources"];
90
- };
91
-
92
- const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i
93
- const INDEX_HTML_PATTERN = /^[^/]+\/index\.html$/
94
- const TRANSIENT_ERROR_CODES = new Set([
95
- "SlowDown",
96
- "ServiceUnavailable",
97
- "RequestTimeout",
98
- "Throttling",
99
- "ThrottlingException",
100
- "TooManyRequestsException",
101
- ])
102
- const CLOUDFRONT_INVALIDATION_CHUNK_SIZE = 3000
103
-
104
- export async function streamToString(body: unknown): Promise<string> {
105
- if (!body) {
106
- return ""
107
- }
108
-
109
- if (typeof body === "string") {
110
- return body
111
- }
112
-
113
- if (Buffer.isBuffer(body)) {
114
- return body.toString("utf-8")
115
- }
116
-
117
- if (body instanceof Uint8Array) {
118
- return Buffer.from(body).toString("utf-8")
119
- }
120
-
121
- const chunks: Buffer[] = []
122
- for await (const chunk of body as AsyncIterable<Buffer | string>) {
123
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
124
- }
125
-
126
- return Buffer.concat(chunks).toString("utf-8")
127
- }
128
-
129
- function isTransientError(error: unknown): boolean {
130
- const err = error as {
131
- name?: string;
132
- Code?: string;
133
- $metadata?: { httpStatusCode?: number };
134
- }
135
- const code = err.name || err.Code || ""
136
- if (TRANSIENT_ERROR_CODES.has(code)) {
137
- return true
138
- }
139
-
140
- const status = err.$metadata?.httpStatusCode
141
- return status === 429 || status === 503
142
- }
143
-
144
- function sleep(ms: number): Promise<void> {
145
- return new Promise(resolve => {
146
- setTimeout(resolve, ms)
147
- })
148
- }
149
-
150
- export async function withRetry<T>(
151
- fn: () => Promise<T>,
152
- attempts = 3,
153
- attempt = 1
154
- ): Promise<T> {
155
- try {
156
- return await fn()
157
- } catch (error) {
158
- if (!isTransientError(error) || attempt === attempts) {
159
- throw error
160
- }
161
-
162
- const multiplier = 2 ** (attempt - 1)
163
- const delayMs = 250 * multiplier
164
- await sleep(delayMs)
165
- return withRetry(fn, attempts, attempt + 1)
166
- }
167
- }
168
-
169
- export async function mapWithConcurrency<T, R>(
170
- items: T[],
171
- limit: number,
172
- fn: (item: T) => Promise<R>
173
- ): Promise<R[]> {
174
- const results: R[] = Array.from({ length: items.length })
175
- let nextIndex = 0
176
-
177
- async function worker(): Promise<void> {
178
- while (nextIndex < items.length) {
179
- const currentIndex = nextIndex
180
- nextIndex += 1
181
- // eslint-disable-next-line no-await-in-loop -- each worker processes items sequentially
182
- results[currentIndex] = await fn(items[currentIndex])
183
- }
184
- }
185
-
186
- const workers = Array.from({ length: Math.min(limit, items.length) }, () =>
187
- worker()
188
- )
189
- await Promise.all(workers)
190
- return results
191
- }
192
-
193
- function manifestKey(slug: string): string {
194
- return `${slug}/${PACKAGE_MANIFEST_REL_PATH}`
195
- }
196
-
197
- function cloudFrontManifestPath(slug: string): string {
198
- return `/${slug}/${PACKAGE_MANIFEST_REL_PATH}`
199
- }
200
-
201
- function isNotFoundError(error: unknown): boolean {
202
- const err = error as { name?: string; Code?: string }
203
- const code = err.name || err.Code || ""
204
- return code === "NoSuchKey" || code === "NotFound" || code === "NoSuchBucket"
205
- }
206
-
207
- export async function listPublishedSlugs(
208
- s3: AwsClient,
209
- bucket: string
210
- ): Promise<string[]> {
211
- const slugs = new Set<string>()
212
- let continuationToken: string | undefined
213
-
214
- do {
215
- // eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
216
- const response = (await withRetry(() =>
217
- s3.send(
218
- new ListObjectsV2Command({
219
- Bucket: bucket,
220
- ContinuationToken: continuationToken,
221
- })
222
- )
223
- )) as {
224
- Contents?: Array<{ Key?: string }>;
225
- NextContinuationToken?: string;
226
- }
227
-
228
- for (const item of response.Contents || []) {
229
- const key = item.Key
230
- if (key && INDEX_HTML_PATTERN.test(key)) {
231
- slugs.add(key.split("/")[0])
232
- }
233
- }
234
-
235
- continuationToken = response.NextContinuationToken
236
- } while (continuationToken)
237
-
238
- return [...slugs].sort()
239
- }
240
-
241
- export async function fetchJsonObject<T>(
242
- s3: AwsClient,
243
- bucket: string,
244
- key: string
245
- ): Promise<T | null> {
246
- try {
247
- const response = (await withRetry(() =>
248
- s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
249
- )) as { Body?: unknown }
250
-
251
- const content = await streamToString(response.Body)
252
- return JSON.parse(content) as T
253
- } catch (error) {
254
- if (isNotFoundError(error)) {
255
- return null
256
- }
257
-
258
- throw error
259
- }
260
- }
261
-
262
- async function fetchTextObject(
263
- s3: AwsClient,
264
- bucket: string,
265
- key: string
266
- ): Promise<string | null> {
267
- try {
268
- const response = (await withRetry(() =>
269
- s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
270
- )) as { Body?: unknown }
271
-
272
- return await streamToString(response.Body)
273
- } catch (error) {
274
- if (isNotFoundError(error)) {
275
- return null
276
- }
277
-
278
- throw error
279
- }
280
- }
281
-
282
- async function listObjectKeys(
283
- s3: AwsClient,
284
- bucket: string,
285
- prefix: string
286
- ): Promise<string[]> {
287
- const keys: string[] = []
288
- let continuationToken: string | undefined
289
-
290
- do {
291
- // eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
292
- const response = (await withRetry(() =>
293
- s3.send(
294
- new ListObjectsV2Command({
295
- Bucket: bucket,
296
- Prefix: prefix,
297
- ContinuationToken: continuationToken,
298
- })
299
- )
300
- )) as {
301
- Contents?: Array<{ Key?: string }>;
302
- NextContinuationToken?: string;
303
- }
304
-
305
- for (const item of response.Contents || []) {
306
- if (item.Key) {
307
- keys.push(item.Key)
308
- }
309
- }
310
-
311
- continuationToken = response.NextContinuationToken
312
- } while (continuationToken)
313
-
314
- return keys
315
- }
316
-
317
- function resolveReadmeLang(
318
- filename: string,
319
- exercise: Exercise
320
- ): string | null {
321
- const lower = filename.toLowerCase()
322
- for (const [lang, readmeFile] of Object.entries(exercise.translations)) {
323
- if (readmeFile.toLowerCase() === lower) {
324
- return lang
325
- }
326
- }
327
-
328
- return null
329
- }
330
-
331
- function normalizeLearnAndExercises(
332
- slug: string,
333
- learnJson: Record<string, unknown> | null,
334
- configJson: ConfigJsonShape | null
335
- ): { learnJson: Record<string, unknown>; exercises: Exercise[] } {
336
- const exercises = configJson?.exercises
337
- if (!Array.isArray(exercises) || exercises.length === 0) {
338
- throw new Error(`Missing or empty exercises array in ${slug}/config.json`)
339
- }
340
-
341
- const resolvedLearn =
342
- learnJson ||
343
- (configJson?.config as Record<string, unknown> | undefined) ||
344
- null
345
-
346
- if (!resolvedLearn) {
347
- throw new Error(
348
- `Missing learn metadata: no ${slug}/learn.json and no config.config`
349
- )
350
- }
351
-
352
- return { learnJson: resolvedLearn, exercises }
353
- }
354
-
355
- export async function fetchPackageSources(
356
- s3: AwsClient,
357
- bucket: string,
358
- slug: string
359
- ): Promise<PackageSources> {
360
- const [
361
- learnJsonRaw,
362
- configJsonRoot,
363
- configJsonLearn,
364
- sidebarLearn,
365
- sidebarRoot,
366
- syllabus,
367
- ] = await Promise.all([
368
- fetchJsonObject<Record<string, unknown>>(s3, bucket, `${slug}/learn.json`),
369
- fetchJsonObject<ConfigJsonShape>(s3, bucket, `${slug}/config.json`),
370
- fetchJsonObject<ConfigJsonShape>(s3, bucket, `${slug}/.learn/config.json`),
371
- fetchJsonObject<Record<string, Record<string, string>>>(
372
- s3,
373
- bucket,
374
- `${slug}/.learn/sidebar.json`
375
- ),
376
- fetchJsonObject<Record<string, Record<string, string>>>(
377
- s3,
378
- bucket,
379
- `${slug}/sidebar.json`
380
- ),
381
- fetchJsonObject<Syllabus>(
382
- s3,
383
- bucket,
384
- `${slug}/.learn/initialSyllabus.json`
385
- ),
386
- ])
387
-
388
- const configJson = configJsonRoot || configJsonLearn
389
- if (!configJson) {
390
- throw new Error(
391
- `Missing required file: ${slug}/config.json or ${slug}/.learn/config.json`
392
- )
393
- }
394
-
395
- const { learnJson, exercises } = normalizeLearnAndExercises(
396
- slug,
397
- learnJsonRaw,
398
- configJson
399
- )
400
-
401
- const exerciseSlugs = new Set(exercises.map(exercise => exercise.slug))
402
- const readmeKeys = (await listObjectKeys(s3, bucket, `${slug}/exercises/`))
403
- .filter(key => {
404
- const filename = key.split("/").pop() || ""
405
- return README_FILENAME_PATTERN.test(filename)
406
- })
407
- .filter(key => {
408
- const parts = key.split("/")
409
- const exerciseSlug = parts[2]
410
- return Boolean(exerciseSlug && exerciseSlugs.has(exerciseSlug))
411
- })
412
-
413
- const readmeFrontmatters: Record<
414
- string,
415
- Record<string, ReadmeFrontmatter>
416
- > = {}
417
-
418
- await Promise.all(
419
- readmeKeys.map(async key => {
420
- const parts = key.split("/")
421
- const exerciseSlug = parts[2]
422
- const filename = parts[parts.length - 1]
423
- const exercise = exercises.find(item => item.slug === exerciseSlug)
424
- if (!exercise) {
425
- return
426
- }
427
-
428
- const lang = resolveReadmeLang(filename, exercise)
429
- if (!lang) {
430
- return
431
- }
432
-
433
- const content = await fetchTextObject(s3, bucket, key)
434
- if (!content) {
435
- return
436
- }
437
-
438
- if (!readmeFrontmatters[exerciseSlug]) {
439
- readmeFrontmatters[exerciseSlug] = {}
440
- }
441
-
442
- readmeFrontmatters[exerciseSlug][lang] = parseReadmeContent(content)
443
- })
444
- )
445
-
446
- const readmeCount = Object.values(readmeFrontmatters).reduce(
447
- (total, byLang) => total + Object.keys(byLang).length,
448
- 0
449
- )
450
-
451
- return {
452
- learnJson,
453
- exercises,
454
- sidebar: sidebarLearn || sidebarRoot || {},
455
- syllabus,
456
- readmeFrontmatters,
457
- meta: {
458
- learnJson: Boolean(learnJsonRaw || configJson.config),
459
- configJson: true,
460
- sidebar: Boolean(sidebarLearn || sidebarRoot),
461
- syllabus: Boolean(syllabus),
462
- readmes: readmeCount,
463
- },
464
- }
465
- }
466
-
467
- export function buildManifestFromS3Sources(
468
- slug: string,
469
- sources: PackageSources,
470
- options: { publishedAt: string | null; generatedAt?: string }
471
- ): PackageManifest {
472
- return buildPackageManifestFromSources({
473
- learnJson: sources.learnJson,
474
- syllabus: sources.syllabus,
475
- sidebar: sources.sidebar,
476
- exercises: sources.exercises,
477
- readmeFrontmatters: sources.readmeFrontmatters,
478
- options: {
479
- slug,
480
- generatedAt: options.generatedAt || new Date().toISOString(),
481
- publishedAt: options.publishedAt,
482
- },
483
- })
484
- }
485
-
486
- export async function processPackage(
487
- s3: AwsClient,
488
- bucket: string,
489
- slug: string,
490
- options: Pick<
491
- BackfillOptions,
492
- "dryRun" | "force" | "skipExisting" | "publishedAt"
493
- >
494
- ): Promise<PackageResult> {
495
- const started = Date.now()
496
-
497
- try {
498
- const existing = await fetchJsonObject<PackageManifest>(
499
- s3,
500
- bucket,
501
- manifestKey(slug)
502
- )
503
-
504
- if (options.skipExisting && existing) {
505
- return {
506
- slug,
507
- status: "skipped",
508
- durationMs: Date.now() - started,
509
- sources: {
510
- learnJson: false,
511
- configJson: false,
512
- sidebar: false,
513
- syllabus: false,
514
- readmes: 0,
515
- },
516
- }
517
- }
518
-
519
- const sources = await fetchPackageSources(s3, bucket, slug)
520
- const next = buildManifestFromS3Sources(slug, sources, {
521
- publishedAt: options.publishedAt,
522
- })
523
-
524
- let status = comparePackageManifests(existing, next)
525
-
526
- if (options.force && status === "skipped") {
527
- status = "updated"
528
- }
529
-
530
- if (!options.dryRun && (status === "created" || status === "updated")) {
531
- await withRetry(() =>
532
- s3.send(
533
- new PutObjectCommand({
534
- Bucket: bucket,
535
- Key: manifestKey(slug),
536
- Body: serializePackageManifest(next),
537
- ContentType: "application/json",
538
- })
539
- )
540
- )
541
- }
542
-
543
- return {
544
- slug,
545
- status,
546
- durationMs: Date.now() - started,
547
- sources: sources.meta,
548
- }
549
- } catch (error) {
550
- const err = error as Error & { step?: string }
551
- return {
552
- slug,
553
- status: "failed",
554
- durationMs: Date.now() - started,
555
- error: err.message,
556
- step: err.step || inferFailureStep(err.message),
557
- }
558
- }
559
- }
560
-
561
- function inferFailureStep(message: string): string {
562
- if (message.includes("config.json") || message.includes("learn")) {
563
- return "fetch_sources"
564
- }
565
-
566
- if (message.includes("PutObject")) {
567
- return "put_object"
568
- }
569
-
570
- return "build"
571
- }
572
-
573
- export async function invalidatePackageManifestPaths(
574
- cf: AwsClient,
575
- distributionId: string,
576
- paths: string[]
577
- ): Promise<{ id: string }> {
578
- if (paths.length === 0) {
579
- throw new Error("No paths to invalidate")
580
- }
581
-
582
- const callerReference = `package-manifest-backfill-${Date.now()}`
583
- let lastId = ""
584
-
585
- for (
586
- let index = 0;
587
- index < paths.length;
588
- index += CLOUDFRONT_INVALIDATION_CHUNK_SIZE
589
- ) {
590
- const chunk = paths.slice(
591
- index,
592
- index + CLOUDFRONT_INVALIDATION_CHUNK_SIZE
593
- )
594
- // eslint-disable-next-line no-await-in-loop -- sequential invalidation chunks to avoid rate limits
595
- const response = (await withRetry(() =>
596
- cf.send(
597
- new CreateInvalidationCommand({
598
- DistributionId: distributionId,
599
- InvalidationBatch: {
600
- CallerReference: `${callerReference}-${index}`,
601
- Paths: {
602
- Quantity: chunk.length,
603
- Items: chunk,
604
- },
605
- },
606
- })
607
- )
608
- )) as { Invalidation?: { Id?: string } }
609
-
610
- lastId = response.Invalidation?.Id || lastId
611
- }
612
-
613
- return { id: lastId }
614
- }
615
-
616
- function loadResumeSlugs(reportPath: string): Set<string> {
617
- // eslint-disable-next-line @typescript-eslint/no-var-requires
618
- const fs = require("fs") as typeof import("fs")
619
- const raw = fs.readFileSync(reportPath, "utf-8")
620
- const report = JSON.parse(raw) as BatchReport
621
- const skipStatuses = new Set<PackageResultStatus>([
622
- "created",
623
- "updated",
624
- "skipped",
625
- ])
626
-
627
- return new Set(
628
- report.results
629
- .filter(result => skipStatuses.has(result.status))
630
- .map(result => result.slug)
631
- )
632
- }
633
-
634
- function summarizeResults(
635
- results: PackageResult[]
636
- ): Pick<BatchReport, "created" | "updated" | "skipped" | "failed"> {
637
- return {
638
- created: results.filter(result => result.status === "created").length,
639
- updated: results.filter(result => result.status === "updated").length,
640
- skipped: results.filter(result => result.status === "skipped").length,
641
- failed: results.filter(result => result.status === "failed").length,
642
- }
643
- }
644
-
645
- export async function runBatch(
646
- options: BackfillOptions,
647
- clients?: {
648
- s3?: AwsClient;
649
- cf?: AwsClient;
650
- onProgress?: (index: number, total: number, result: PackageResult) => void;
651
- }
652
- ): Promise<BatchReport> {
653
- const startedAt = new Date().toISOString()
654
- const s3 =
655
- clients?.s3 ||
656
- new S3Client({
657
- region: options.region,
658
- credentials: {
659
- accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
660
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
661
- },
662
- })
663
-
664
- const cf =
665
- clients?.cf ||
666
- new CloudFrontClient({
667
- region: "us-east-1",
668
- credentials: {
669
- accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
670
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
671
- },
672
- })
673
-
674
- let slugs = options.slug ?
675
- [options.slug] :
676
- await listPublishedSlugs(s3, options.bucket)
677
-
678
- if (options.resumeFrom) {
679
- const resumeSlugs = loadResumeSlugs(options.resumeFrom)
680
- slugs = slugs.filter(slug => !resumeSlugs.has(slug))
681
- }
682
-
683
- let completed = 0
684
- const results = await mapWithConcurrency(
685
- slugs,
686
- options.concurrency,
687
- async slug => {
688
- const result = await processPackage(s3, options.bucket, slug, options)
689
- completed += 1
690
- clients?.onProgress?.(completed, slugs.length, result)
691
- return result
692
- }
693
- )
694
-
695
- const summary = summarizeResults(results)
696
- const invalidationPaths = results
697
- .filter(
698
- result => result.status === "created" || result.status === "updated"
699
- )
700
- .map(result => cloudFrontManifestPath(result.slug))
701
-
702
- let invalidation: BatchReport["invalidation"]
703
-
704
- if (options.dryRun) {
705
- invalidation = {
706
- attempted: false,
707
- skippedReason: "dry-run",
708
- }
709
- } else if (options.noInvalidate) {
710
- invalidation = {
711
- attempted: false,
712
- skippedReason: "no-invalidate",
713
- }
714
- } else if (invalidationPaths.length === 0) {
715
- invalidation = {
716
- attempted: false,
717
- skippedReason: "no-paths",
718
- }
719
- } else if (!options.cloudFrontDistributionId) {
720
- invalidation = {
721
- attempted: true,
722
- paths: invalidationPaths,
723
- error: "Missing CLOUDFRONT_DISTRIBUTION_ID",
724
- }
725
- } else {
726
- try {
727
- const result = await invalidatePackageManifestPaths(
728
- cf,
729
- options.cloudFrontDistributionId,
730
- invalidationPaths
731
- )
732
- invalidation = {
733
- attempted: true,
734
- id: result.id,
735
- paths: invalidationPaths,
736
- }
737
- } catch (error) {
738
- invalidation = {
739
- attempted: true,
740
- paths: invalidationPaths,
741
- error: (error as Error).message,
742
- }
743
- }
744
- }
745
-
746
- const report: BatchReport = {
747
- total: slugs.length,
748
- ...summary,
749
- startedAt,
750
- finishedAt: new Date().toISOString(),
751
- options,
752
- results,
753
- invalidation,
754
- }
755
-
756
- // eslint-disable-next-line @typescript-eslint/no-var-requires
757
- const fs = require("fs") as typeof import("fs")
758
- // eslint-disable-next-line @typescript-eslint/no-var-requires
759
- const path = require("path") as typeof import("path")
760
- fs.mkdirSync(path.dirname(options.reportOut), { recursive: true })
761
- fs.writeFileSync(options.reportOut, JSON.stringify(report, null, 2), "utf-8")
762
-
763
- return report
764
- }
765
-
766
- export function getBatchExitCode(report: BatchReport): number {
767
- if (report.failed > 0) {
768
- return 1
769
- }
770
-
771
- if (report.invalidation?.attempted && report.invalidation.error) {
772
- return 1
773
- }
774
-
775
- return 0
776
- }
1
+ import {
2
+ CreateInvalidationCommand,
3
+ CloudFrontClient,
4
+ } from "@aws-sdk/client-cloudfront"
5
+ import {
6
+ GetObjectCommand,
7
+ ListObjectsV2Command,
8
+ PutObjectCommand,
9
+ S3Client,
10
+ } from "@aws-sdk/client-s3"
11
+ import { Exercise } from "../configBuilder"
12
+ import { requireAwsCredentials } from "../awsCredentials"
13
+ import { Syllabus } from "../../models/creator"
14
+ import {
15
+ buildPackageManifestFromSources,
16
+ comparePackageManifests,
17
+ PACKAGE_MANIFEST_REL_PATH,
18
+ parseReadmeContent,
19
+ PackageManifest,
20
+ ReadmeFrontmatter,
21
+ serializePackageManifest,
22
+ } from "../packageManifest"
23
+
24
+ export type PackageResultStatus = "created" | "updated" | "skipped" | "failed";
25
+
26
+ export type PackageResult = {
27
+ slug: string;
28
+ status: PackageResultStatus;
29
+ durationMs: number;
30
+ error?: string;
31
+ step?: string;
32
+ sources?: {
33
+ learnJson: boolean;
34
+ configJson: boolean;
35
+ sidebar: boolean;
36
+ syllabus: boolean;
37
+ readmes: number;
38
+ };
39
+ };
40
+
41
+ export type BackfillOptions = {
42
+ dryRun: boolean;
43
+ force: boolean;
44
+ skipExisting: boolean;
45
+ noInvalidate: boolean;
46
+ concurrency: number;
47
+ publishedAt: string | null;
48
+ resumeFrom?: string;
49
+ reportOut: string;
50
+ slug?: string;
51
+ bucket: string;
52
+ region: string;
53
+ cloudFrontDistributionId?: string;
54
+ };
55
+
56
+ export type BatchReport = {
57
+ total: number;
58
+ created: number;
59
+ updated: number;
60
+ skipped: number;
61
+ failed: number;
62
+ startedAt: string;
63
+ finishedAt: string;
64
+ options: BackfillOptions;
65
+ results: PackageResult[];
66
+ invalidation?: {
67
+ attempted: boolean;
68
+ skippedReason?: string;
69
+ id?: string;
70
+ paths?: string[];
71
+ error?: string;
72
+ };
73
+ };
74
+
75
+ export type AwsClient = {
76
+ send(command: unknown): Promise<unknown>;
77
+ };
78
+
79
+ type ConfigJsonShape = {
80
+ config?: Record<string, unknown>;
81
+ exercises?: Exercise[];
82
+ };
83
+
84
+ type PackageSources = {
85
+ learnJson: Record<string, unknown>;
86
+ exercises: Exercise[];
87
+ sidebar: Record<string, Record<string, string>>;
88
+ syllabus: Syllabus | null;
89
+ readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>;
90
+ meta: PackageResult["sources"];
91
+ };
92
+
93
+ const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i
94
+ const INDEX_HTML_PATTERN = /^[^/]+\/index\.html$/
95
+ const TRANSIENT_ERROR_CODES = new Set([
96
+ "SlowDown",
97
+ "ServiceUnavailable",
98
+ "RequestTimeout",
99
+ "Throttling",
100
+ "ThrottlingException",
101
+ "TooManyRequestsException",
102
+ ])
103
+ const CLOUDFRONT_INVALIDATION_CHUNK_SIZE = 3000
104
+
105
+ export async function streamToString(body: unknown): Promise<string> {
106
+ if (!body) {
107
+ return ""
108
+ }
109
+
110
+ if (typeof body === "string") {
111
+ return body
112
+ }
113
+
114
+ if (Buffer.isBuffer(body)) {
115
+ return body.toString("utf-8")
116
+ }
117
+
118
+ if (body instanceof Uint8Array) {
119
+ return Buffer.from(body).toString("utf-8")
120
+ }
121
+
122
+ const chunks: Buffer[] = []
123
+ for await (const chunk of body as AsyncIterable<Buffer | string>) {
124
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
125
+ }
126
+
127
+ return Buffer.concat(chunks).toString("utf-8")
128
+ }
129
+
130
+ function isTransientError(error: unknown): boolean {
131
+ const err = error as {
132
+ name?: string;
133
+ Code?: string;
134
+ $metadata?: { httpStatusCode?: number };
135
+ }
136
+ const code = err.name || err.Code || ""
137
+ if (TRANSIENT_ERROR_CODES.has(code)) {
138
+ return true
139
+ }
140
+
141
+ const status = err.$metadata?.httpStatusCode
142
+ return status === 429 || status === 503
143
+ }
144
+
145
+ function sleep(ms: number): Promise<void> {
146
+ return new Promise(resolve => {
147
+ setTimeout(resolve, ms)
148
+ })
149
+ }
150
+
151
+ export async function withRetry<T>(
152
+ fn: () => Promise<T>,
153
+ attempts = 3,
154
+ attempt = 1
155
+ ): Promise<T> {
156
+ try {
157
+ return await fn()
158
+ } catch (error) {
159
+ if (!isTransientError(error) || attempt === attempts) {
160
+ throw error
161
+ }
162
+
163
+ const multiplier = 2 ** (attempt - 1)
164
+ const delayMs = 250 * multiplier
165
+ await sleep(delayMs)
166
+ return withRetry(fn, attempts, attempt + 1)
167
+ }
168
+ }
169
+
170
+ export async function mapWithConcurrency<T, R>(
171
+ items: T[],
172
+ limit: number,
173
+ fn: (item: T) => Promise<R>
174
+ ): Promise<R[]> {
175
+ const results: R[] = Array.from({ length: items.length })
176
+ let nextIndex = 0
177
+
178
+ async function worker(): Promise<void> {
179
+ while (nextIndex < items.length) {
180
+ const currentIndex = nextIndex
181
+ nextIndex += 1
182
+ // eslint-disable-next-line no-await-in-loop -- each worker processes items sequentially
183
+ results[currentIndex] = await fn(items[currentIndex])
184
+ }
185
+ }
186
+
187
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () =>
188
+ worker()
189
+ )
190
+ await Promise.all(workers)
191
+ return results
192
+ }
193
+
194
+ function manifestKey(slug: string): string {
195
+ return `${slug}/${PACKAGE_MANIFEST_REL_PATH}`
196
+ }
197
+
198
+ function cloudFrontManifestPath(slug: string): string {
199
+ return `/${slug}/${PACKAGE_MANIFEST_REL_PATH}`
200
+ }
201
+
202
+ function isNotFoundError(error: unknown): boolean {
203
+ const err = error as { name?: string; Code?: string }
204
+ const code = err.name || err.Code || ""
205
+ return code === "NoSuchKey" || code === "NotFound" || code === "NoSuchBucket"
206
+ }
207
+
208
+ export async function listPublishedSlugs(
209
+ s3: AwsClient,
210
+ bucket: string
211
+ ): Promise<string[]> {
212
+ const slugs = new Set<string>()
213
+ let continuationToken: string | undefined
214
+
215
+ do {
216
+ // eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
217
+ const response = (await withRetry(() =>
218
+ s3.send(
219
+ new ListObjectsV2Command({
220
+ Bucket: bucket,
221
+ ContinuationToken: continuationToken,
222
+ })
223
+ )
224
+ )) as {
225
+ Contents?: Array<{ Key?: string }>;
226
+ NextContinuationToken?: string;
227
+ }
228
+
229
+ for (const item of response.Contents || []) {
230
+ const key = item.Key
231
+ if (key && INDEX_HTML_PATTERN.test(key)) {
232
+ slugs.add(key.split("/")[0])
233
+ }
234
+ }
235
+
236
+ continuationToken = response.NextContinuationToken
237
+ } while (continuationToken)
238
+
239
+ return [...slugs].sort()
240
+ }
241
+
242
+ export async function fetchJsonObject<T>(
243
+ s3: AwsClient,
244
+ bucket: string,
245
+ key: string
246
+ ): Promise<T | null> {
247
+ try {
248
+ const response = (await withRetry(() =>
249
+ s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
250
+ )) as { Body?: unknown }
251
+
252
+ const content = await streamToString(response.Body)
253
+ return JSON.parse(content) as T
254
+ } catch (error) {
255
+ if (isNotFoundError(error)) {
256
+ return null
257
+ }
258
+
259
+ throw error
260
+ }
261
+ }
262
+
263
+ export async function fetchTextObject(
264
+ s3: AwsClient,
265
+ bucket: string,
266
+ key: string
267
+ ): Promise<string | null> {
268
+ try {
269
+ const response = (await withRetry(() =>
270
+ s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
271
+ )) as { Body?: unknown }
272
+
273
+ return await streamToString(response.Body)
274
+ } catch (error) {
275
+ if (isNotFoundError(error)) {
276
+ return null
277
+ }
278
+
279
+ throw error
280
+ }
281
+ }
282
+
283
+ export async function listObjectKeys(
284
+ s3: AwsClient,
285
+ bucket: string,
286
+ prefix: string
287
+ ): Promise<string[]> {
288
+ const keys: string[] = []
289
+ let continuationToken: string | undefined
290
+
291
+ do {
292
+ // eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
293
+ const response = (await withRetry(() =>
294
+ s3.send(
295
+ new ListObjectsV2Command({
296
+ Bucket: bucket,
297
+ Prefix: prefix,
298
+ ContinuationToken: continuationToken,
299
+ })
300
+ )
301
+ )) as {
302
+ Contents?: Array<{ Key?: string }>;
303
+ NextContinuationToken?: string;
304
+ }
305
+
306
+ for (const item of response.Contents || []) {
307
+ if (item.Key) {
308
+ keys.push(item.Key)
309
+ }
310
+ }
311
+
312
+ continuationToken = response.NextContinuationToken
313
+ } while (continuationToken)
314
+
315
+ return keys
316
+ }
317
+
318
+ function resolveReadmeLang(
319
+ filename: string,
320
+ exercise: Exercise
321
+ ): string | null {
322
+ const lower = filename.toLowerCase()
323
+ for (const [lang, readmeFile] of Object.entries(exercise.translations)) {
324
+ if (readmeFile.toLowerCase() === lower) {
325
+ return lang
326
+ }
327
+ }
328
+
329
+ return null
330
+ }
331
+
332
+ function normalizeLearnAndExercises(
333
+ slug: string,
334
+ learnJson: Record<string, unknown> | null,
335
+ configJson: ConfigJsonShape | null
336
+ ): { learnJson: Record<string, unknown>; exercises: Exercise[] } {
337
+ const exercises = configJson?.exercises
338
+ if (!Array.isArray(exercises) || exercises.length === 0) {
339
+ throw new Error(`Missing or empty exercises array in ${slug}/config.json`)
340
+ }
341
+
342
+ const resolvedLearn =
343
+ learnJson ||
344
+ (configJson?.config as Record<string, unknown> | undefined) ||
345
+ null
346
+
347
+ if (!resolvedLearn) {
348
+ throw new Error(
349
+ `Missing learn metadata: no ${slug}/learn.json and no config.config`
350
+ )
351
+ }
352
+
353
+ return { learnJson: resolvedLearn, exercises }
354
+ }
355
+
356
+ export async function fetchPackageSources(
357
+ s3: AwsClient,
358
+ bucket: string,
359
+ slug: string
360
+ ): Promise<PackageSources> {
361
+ const [
362
+ learnJsonRaw,
363
+ configJsonRoot,
364
+ configJsonLearn,
365
+ sidebarLearn,
366
+ sidebarRoot,
367
+ syllabus,
368
+ ] = await Promise.all([
369
+ fetchJsonObject<Record<string, unknown>>(s3, bucket, `${slug}/learn.json`),
370
+ fetchJsonObject<ConfigJsonShape>(s3, bucket, `${slug}/config.json`),
371
+ fetchJsonObject<ConfigJsonShape>(s3, bucket, `${slug}/.learn/config.json`),
372
+ fetchJsonObject<Record<string, Record<string, string>>>(
373
+ s3,
374
+ bucket,
375
+ `${slug}/.learn/sidebar.json`
376
+ ),
377
+ fetchJsonObject<Record<string, Record<string, string>>>(
378
+ s3,
379
+ bucket,
380
+ `${slug}/sidebar.json`
381
+ ),
382
+ fetchJsonObject<Syllabus>(
383
+ s3,
384
+ bucket,
385
+ `${slug}/.learn/initialSyllabus.json`
386
+ ),
387
+ ])
388
+
389
+ const configJson = configJsonRoot || configJsonLearn
390
+ if (!configJson) {
391
+ throw new Error(
392
+ `Missing required file: ${slug}/config.json or ${slug}/.learn/config.json`
393
+ )
394
+ }
395
+
396
+ const { learnJson, exercises } = normalizeLearnAndExercises(
397
+ slug,
398
+ learnJsonRaw,
399
+ configJson
400
+ )
401
+
402
+ const exerciseSlugs = new Set(exercises.map(exercise => exercise.slug))
403
+ const readmeKeys = (await listObjectKeys(s3, bucket, `${slug}/exercises/`))
404
+ .filter(key => {
405
+ const filename = key.split("/").pop() || ""
406
+ return README_FILENAME_PATTERN.test(filename)
407
+ })
408
+ .filter(key => {
409
+ const parts = key.split("/")
410
+ const exerciseSlug = parts[2]
411
+ return Boolean(exerciseSlug && exerciseSlugs.has(exerciseSlug))
412
+ })
413
+
414
+ const readmeFrontmatters: Record<
415
+ string,
416
+ Record<string, ReadmeFrontmatter>
417
+ > = {}
418
+
419
+ await Promise.all(
420
+ readmeKeys.map(async key => {
421
+ const parts = key.split("/")
422
+ const exerciseSlug = parts[2]
423
+ const filename = parts[parts.length - 1]
424
+ const exercise = exercises.find(item => item.slug === exerciseSlug)
425
+ if (!exercise) {
426
+ return
427
+ }
428
+
429
+ const lang = resolveReadmeLang(filename, exercise)
430
+ if (!lang) {
431
+ return
432
+ }
433
+
434
+ const content = await fetchTextObject(s3, bucket, key)
435
+ if (!content) {
436
+ return
437
+ }
438
+
439
+ if (!readmeFrontmatters[exerciseSlug]) {
440
+ readmeFrontmatters[exerciseSlug] = {}
441
+ }
442
+
443
+ readmeFrontmatters[exerciseSlug][lang] = parseReadmeContent(content)
444
+ })
445
+ )
446
+
447
+ const readmeCount = Object.values(readmeFrontmatters).reduce(
448
+ (total, byLang) => total + Object.keys(byLang).length,
449
+ 0
450
+ )
451
+
452
+ return {
453
+ learnJson,
454
+ exercises,
455
+ sidebar: sidebarLearn || sidebarRoot || {},
456
+ syllabus,
457
+ readmeFrontmatters,
458
+ meta: {
459
+ learnJson: Boolean(learnJsonRaw || configJson.config),
460
+ configJson: true,
461
+ sidebar: Boolean(sidebarLearn || sidebarRoot),
462
+ syllabus: Boolean(syllabus),
463
+ readmes: readmeCount,
464
+ },
465
+ }
466
+ }
467
+
468
+ export function buildManifestFromS3Sources(
469
+ slug: string,
470
+ sources: PackageSources,
471
+ options: { publishedAt: string | null; generatedAt?: string }
472
+ ): PackageManifest {
473
+ return buildPackageManifestFromSources({
474
+ learnJson: sources.learnJson,
475
+ syllabus: sources.syllabus,
476
+ sidebar: sources.sidebar,
477
+ exercises: sources.exercises,
478
+ readmeFrontmatters: sources.readmeFrontmatters,
479
+ options: {
480
+ slug,
481
+ generatedAt: options.generatedAt || new Date().toISOString(),
482
+ publishedAt: options.publishedAt,
483
+ },
484
+ })
485
+ }
486
+
487
+ export async function processPackage(
488
+ s3: AwsClient,
489
+ bucket: string,
490
+ slug: string,
491
+ options: Pick<
492
+ BackfillOptions,
493
+ "dryRun" | "force" | "skipExisting" | "publishedAt"
494
+ >
495
+ ): Promise<PackageResult> {
496
+ const started = Date.now()
497
+
498
+ try {
499
+ const existing = await fetchJsonObject<PackageManifest>(
500
+ s3,
501
+ bucket,
502
+ manifestKey(slug)
503
+ )
504
+
505
+ if (options.skipExisting && existing) {
506
+ return {
507
+ slug,
508
+ status: "skipped",
509
+ durationMs: Date.now() - started,
510
+ sources: {
511
+ learnJson: false,
512
+ configJson: false,
513
+ sidebar: false,
514
+ syllabus: false,
515
+ readmes: 0,
516
+ },
517
+ }
518
+ }
519
+
520
+ const sources = await fetchPackageSources(s3, bucket, slug)
521
+ const next = buildManifestFromS3Sources(slug, sources, {
522
+ publishedAt: options.publishedAt,
523
+ })
524
+
525
+ let status = comparePackageManifests(existing, next)
526
+
527
+ if (options.force && status === "skipped") {
528
+ status = "updated"
529
+ }
530
+
531
+ if (!options.dryRun && (status === "created" || status === "updated")) {
532
+ await withRetry(() =>
533
+ s3.send(
534
+ new PutObjectCommand({
535
+ Bucket: bucket,
536
+ Key: manifestKey(slug),
537
+ Body: serializePackageManifest(next),
538
+ ContentType: "application/json",
539
+ })
540
+ )
541
+ )
542
+ }
543
+
544
+ return {
545
+ slug,
546
+ status,
547
+ durationMs: Date.now() - started,
548
+ sources: sources.meta,
549
+ }
550
+ } catch (error) {
551
+ const err = error as Error & { step?: string }
552
+ return {
553
+ slug,
554
+ status: "failed",
555
+ durationMs: Date.now() - started,
556
+ error: err.message,
557
+ step: err.step || inferFailureStep(err.message),
558
+ }
559
+ }
560
+ }
561
+
562
+ function inferFailureStep(message: string): string {
563
+ if (message.includes("config.json") || message.includes("learn")) {
564
+ return "fetch_sources"
565
+ }
566
+
567
+ if (message.includes("PutObject")) {
568
+ return "put_object"
569
+ }
570
+
571
+ return "build"
572
+ }
573
+
574
+ export async function invalidatePackageManifestPaths(
575
+ cf: AwsClient,
576
+ distributionId: string,
577
+ paths: string[]
578
+ ): Promise<{ id: string }> {
579
+ if (paths.length === 0) {
580
+ throw new Error("No paths to invalidate")
581
+ }
582
+
583
+ const callerReference = `package-manifest-backfill-${Date.now()}`
584
+ let lastId = ""
585
+
586
+ for (
587
+ let index = 0;
588
+ index < paths.length;
589
+ index += CLOUDFRONT_INVALIDATION_CHUNK_SIZE
590
+ ) {
591
+ const chunk = paths.slice(
592
+ index,
593
+ index + CLOUDFRONT_INVALIDATION_CHUNK_SIZE
594
+ )
595
+ // eslint-disable-next-line no-await-in-loop -- sequential invalidation chunks to avoid rate limits
596
+ const response = (await withRetry(() =>
597
+ cf.send(
598
+ new CreateInvalidationCommand({
599
+ DistributionId: distributionId,
600
+ InvalidationBatch: {
601
+ CallerReference: `${callerReference}-${index}`,
602
+ Paths: {
603
+ Quantity: chunk.length,
604
+ Items: chunk,
605
+ },
606
+ },
607
+ })
608
+ )
609
+ )) as { Invalidation?: { Id?: string } }
610
+
611
+ lastId = response.Invalidation?.Id || lastId
612
+ }
613
+
614
+ return { id: lastId }
615
+ }
616
+
617
+ function loadResumeSlugs(reportPath: string): Set<string> {
618
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
619
+ const fs = require("fs") as typeof import("fs")
620
+ const raw = fs.readFileSync(reportPath, "utf-8")
621
+ const report = JSON.parse(raw) as BatchReport
622
+ const skipStatuses = new Set<PackageResultStatus>([
623
+ "created",
624
+ "updated",
625
+ "skipped",
626
+ ])
627
+
628
+ return new Set(
629
+ report.results
630
+ .filter(result => skipStatuses.has(result.status))
631
+ .map(result => result.slug)
632
+ )
633
+ }
634
+
635
+ function summarizeResults(
636
+ results: PackageResult[]
637
+ ): Pick<BatchReport, "created" | "updated" | "skipped" | "failed"> {
638
+ return {
639
+ created: results.filter(result => result.status === "created").length,
640
+ updated: results.filter(result => result.status === "updated").length,
641
+ skipped: results.filter(result => result.status === "skipped").length,
642
+ failed: results.filter(result => result.status === "failed").length,
643
+ }
644
+ }
645
+
646
+ export async function runBatch(
647
+ options: BackfillOptions,
648
+ clients?: {
649
+ s3?: AwsClient;
650
+ cf?: AwsClient;
651
+ onProgress?: (index: number, total: number, result: PackageResult) => void;
652
+ }
653
+ ): Promise<BatchReport> {
654
+ const startedAt = new Date().toISOString()
655
+ const s3 =
656
+ clients?.s3 ||
657
+ new S3Client({
658
+ region: options.region,
659
+ credentials: requireAwsCredentials(),
660
+ })
661
+
662
+ const cf =
663
+ clients?.cf ||
664
+ new CloudFrontClient({
665
+ region: "us-east-1",
666
+ credentials: requireAwsCredentials(),
667
+ })
668
+
669
+ let slugs = options.slug ?
670
+ [options.slug] :
671
+ await listPublishedSlugs(s3, options.bucket)
672
+
673
+ if (options.resumeFrom) {
674
+ const resumeSlugs = loadResumeSlugs(options.resumeFrom)
675
+ slugs = slugs.filter(slug => !resumeSlugs.has(slug))
676
+ }
677
+
678
+ let completed = 0
679
+ const results = await mapWithConcurrency(
680
+ slugs,
681
+ options.concurrency,
682
+ async slug => {
683
+ const result = await processPackage(s3, options.bucket, slug, options)
684
+ completed += 1
685
+ clients?.onProgress?.(completed, slugs.length, result)
686
+ return result
687
+ }
688
+ )
689
+
690
+ const summary = summarizeResults(results)
691
+ const invalidationPaths = results
692
+ .filter(
693
+ result => result.status === "created" || result.status === "updated"
694
+ )
695
+ .map(result => cloudFrontManifestPath(result.slug))
696
+
697
+ let invalidation: BatchReport["invalidation"]
698
+
699
+ if (options.dryRun) {
700
+ invalidation = {
701
+ attempted: false,
702
+ skippedReason: "dry-run",
703
+ }
704
+ } else if (options.noInvalidate) {
705
+ invalidation = {
706
+ attempted: false,
707
+ skippedReason: "no-invalidate",
708
+ }
709
+ } else if (invalidationPaths.length === 0) {
710
+ invalidation = {
711
+ attempted: false,
712
+ skippedReason: "no-paths",
713
+ }
714
+ } else if (!options.cloudFrontDistributionId) {
715
+ invalidation = {
716
+ attempted: true,
717
+ paths: invalidationPaths,
718
+ error: "Missing CLOUDFRONT_DISTRIBUTION_ID",
719
+ }
720
+ } else {
721
+ try {
722
+ const result = await invalidatePackageManifestPaths(
723
+ cf,
724
+ options.cloudFrontDistributionId,
725
+ invalidationPaths
726
+ )
727
+ invalidation = {
728
+ attempted: true,
729
+ id: result.id,
730
+ paths: invalidationPaths,
731
+ }
732
+ } catch (error) {
733
+ invalidation = {
734
+ attempted: true,
735
+ paths: invalidationPaths,
736
+ error: (error as Error).message,
737
+ }
738
+ }
739
+ }
740
+
741
+ const report: BatchReport = {
742
+ total: slugs.length,
743
+ ...summary,
744
+ startedAt,
745
+ finishedAt: new Date().toISOString(),
746
+ options,
747
+ results,
748
+ invalidation,
749
+ }
750
+
751
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
752
+ const fs = require("fs") as typeof import("fs")
753
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
754
+ const path = require("path") as typeof import("path")
755
+ fs.mkdirSync(path.dirname(options.reportOut), { recursive: true })
756
+ fs.writeFileSync(options.reportOut, JSON.stringify(report, null, 2), "utf-8")
757
+
758
+ return report
759
+ }
760
+
761
+ export function getBatchExitCode(report: BatchReport): number {
762
+ if (report.failed > 0) {
763
+ return 1
764
+ }
765
+
766
+ if (report.invalidation?.attempted && report.invalidation.error) {
767
+ return 1
768
+ }
769
+
770
+ return 0
771
+ }