@learnpack/learnpack 5.0.350 → 5.0.352

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.
@@ -0,0 +1,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 { 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
+ }