@learnpack/learnpack 5.0.353 → 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 (33) hide show
  1. package/lib/scripts/descriptionsS3Backfill.js +142 -22
  2. package/lib/utils/awsCredentials.d.ts +20 -0
  3. package/lib/utils/awsCredentials.js +43 -0
  4. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  5. package/lib/utils/descriptions/backfillEvents.js +107 -0
  6. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
  7. package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
  8. package/lib/utils/descriptions/publishStage.js +14 -3
  9. package/lib/utils/descriptions/resumePublication.js +17 -2
  10. package/lib/utils/descriptions/s3Storage.js +13 -6
  11. package/lib/utils/packageManifest.d.ts +8 -0
  12. package/lib/utils/packageManifest.js +8 -0
  13. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  14. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  15. package/lib/utils/repair/repairStorage.d.ts +68 -0
  16. package/lib/utils/repair/repairStorage.js +89 -0
  17. package/lib/utils/s3/packageManifestBackfill.js +3 -8
  18. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  19. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  20. package/package.json +3 -1
  21. package/src/scripts/README.md +244 -0
  22. package/src/scripts/descriptionsS3Backfill.ts +188 -20
  23. package/src/utils/awsCredentials.ts +57 -0
  24. package/src/utils/descriptions/backfillEvents.ts +152 -0
  25. package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
  26. package/src/utils/descriptions/publishStage.ts +394 -382
  27. package/src/utils/descriptions/resumePublication.ts +217 -200
  28. package/src/utils/descriptions/s3Storage.ts +214 -206
  29. package/src/utils/packageManifest.ts +8 -1
  30. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  31. package/src/utils/repair/repairStorage.ts +168 -0
  32. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  33. package/src/utils/s3/packageSourcesAudit.ts +311 -0
@@ -1,206 +1,214 @@
1
- import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
2
- import { CloudFrontClient } from "@aws-sdk/client-cloudfront"
3
- import { Syllabus } from "../../models/creator"
4
- import { PACKAGE_MANIFEST_REL_PATH, PackageManifest } from "../packageManifest"
5
- import {
6
- AwsClient,
7
- fetchJsonObject,
8
- fetchTextObject,
9
- invalidatePackageManifestPaths,
10
- listObjectKeys,
11
- processPackage,
12
- withRetry,
13
- } from "../s3/packageManifestBackfill"
14
- import { SyllabusSyncStorage } from "../syllabusSync"
15
- import { CourseDescriptionsStorage } from "./generateCourseDescriptions"
16
- import { CourseExercise } from "./workList"
17
-
18
- /**
19
- * Published-bucket (S3) adapter for the description flows.
20
- *
21
- * The published package is the snapshot descriptions are generated from: it is
22
- * immutable between publications, unlike the draft in GCS, which keeps moving
23
- * while the teacher edits.
24
- */
25
-
26
- export const syllabusKey = (slug: string) =>
27
- `${slug}/.learn/initialSyllabus.json`
28
-
29
- export const manifestKey = (slug: string) =>
30
- `${slug}/${PACKAGE_MANIFEST_REL_PATH}`
31
-
32
- const configKeys = (slug: string) => [
33
- `${slug}/.learn/config.json`,
34
- `${slug}/config.json`,
35
- ]
36
-
37
- type ConfigShape = {
38
- exercises?: CourseExercise[];
39
- };
40
-
41
- export async function fetchExercises(
42
- s3: AwsClient,
43
- bucket: string,
44
- slug: string
45
- ): Promise<CourseExercise[]> {
46
- for (const key of configKeys(slug)) {
47
- // eslint-disable-next-line no-await-in-loop -- the second key is a fallback
48
- const config = await fetchJsonObject<ConfigShape>(s3, bucket, key)
49
- if (config?.exercises && Array.isArray(config.exercises)) {
50
- return config.exercises
51
- }
52
- }
53
-
54
- return []
55
- }
56
-
57
- export function createS3SyllabusSyncStorage(
58
- s3: AwsClient,
59
- bucket: string
60
- ): SyllabusSyncStorage {
61
- return {
62
- async listExerciseFolderFileCounts(courseSlug) {
63
- const prefix = `${courseSlug}/exercises/`
64
- const keys = await listObjectKeys(s3, bucket, prefix)
65
- const counts = new Map<string, number>()
66
- for (const key of keys) {
67
- const segment = key.slice(prefix.length).split("/")[0]
68
- if (segment) {
69
- counts.set(segment, (counts.get(segment) || 0) + 1)
70
- }
71
- }
72
-
73
- return counts
74
- },
75
- async translationLangsBySlug(courseSlug) {
76
- const exercises = await fetchExercises(s3, bucket, courseSlug)
77
- const map = new Map<string, string[]>()
78
- for (const exercise of exercises) {
79
- const langs = Object.keys(exercise.translations || {})
80
- .map(lang => lang.toLowerCase())
81
- .filter(Boolean)
82
- if (langs.length > 0) {
83
- map.set(exercise.slug, [...new Set(langs)])
84
- }
85
- }
86
-
87
- return map
88
- },
89
- }
90
- }
91
-
92
- export type S3DescriptionsStorageOptions = {
93
- /** CloudFront distribution to invalidate after re-projecting the manifest. */
94
- cloudFront?: { client: AwsClient; distributionId: string };
95
- /** Set false in the backfill, where manifests are projected in a later pass. */
96
- reprojectManifest?: boolean;
97
- };
98
-
99
- /**
100
- * Storage for the published bucket, wired from the environment. Used by the
101
- * post-publish stage and the sweep, which have no CLI flags to read.
102
- */
103
- export function createS3DescriptionsStorageFromEnv(): CourseDescriptionsStorage {
104
- const s3 = new S3Client({
105
- region: process.env.AWS_REGION || "us-east-1",
106
- }) as unknown as AwsClient
107
-
108
- const distributionId = process.env.CLOUDFRONT_DISTRIBUTION_ID
109
- const cloudFront = distributionId ?
110
- {
111
- client: new CloudFrontClient({
112
- region: process.env.AWS_REGION || "us-east-1",
113
- }) as unknown as AwsClient,
114
- distributionId,
115
- } :
116
- undefined
117
-
118
- if (!cloudFront) {
119
- console.warn(
120
- "[descriptions] CLOUDFRONT_DISTRIBUTION_ID is not set: the manifest will be re-projected but not invalidated"
121
- )
122
- }
123
-
124
- return createS3DescriptionsStorage(
125
- s3,
126
- process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes",
127
- { cloudFront }
128
- )
129
- }
130
-
131
- export function createS3DescriptionsStorage(
132
- s3: AwsClient,
133
- bucket: string,
134
- options: S3DescriptionsStorageOptions = {}
135
- ): CourseDescriptionsStorage {
136
- const storage: CourseDescriptionsStorage = {
137
- async readSyllabus(courseSlug) {
138
- return fetchJsonObject<Syllabus>(s3, bucket, syllabusKey(courseSlug))
139
- },
140
- async writeSyllabus(courseSlug, syllabus) {
141
- await withRetry(() =>
142
- s3.send(
143
- new PutObjectCommand({
144
- Bucket: bucket,
145
- Key: syllabusKey(courseSlug),
146
- Body: JSON.stringify(syllabus, null, 2),
147
- ContentType: "application/json",
148
- })
149
- )
150
- )
151
- },
152
- async listExercises(courseSlug) {
153
- return fetchExercises(s3, bucket, courseSlug)
154
- },
155
- async readReadme(courseSlug, exerciseSlug, fileName) {
156
- return fetchTextObject(
157
- s3,
158
- bucket,
159
- `${courseSlug}/exercises/${exerciseSlug}/${fileName}`
160
- )
161
- },
162
- async readManifest(courseSlug) {
163
- return fetchJsonObject<PackageManifest>(
164
- s3,
165
- bucket,
166
- manifestKey(courseSlug)
167
- )
168
- },
169
- syllabusSyncStorage: createS3SyllabusSyncStorage(s3, bucket),
170
- }
171
-
172
- if (options.reprojectManifest !== false) {
173
- storage.reprojectManifest = async courseSlug => {
174
- // Preserve the publication timestamp: this is a re-projection of an
175
- // already published package, not a new publication.
176
- const existing = await fetchJsonObject<PackageManifest>(
177
- s3,
178
- bucket,
179
- manifestKey(courseSlug)
180
- )
181
-
182
- const result = await processPackage(s3, bucket, courseSlug, {
183
- dryRun: false,
184
- force: false,
185
- skipExisting: false,
186
- publishedAt: existing?.publishedAt ?? null,
187
- })
188
-
189
- if (result.status === "failed") {
190
- throw new Error(result.error || "manifest projection failed")
191
- }
192
-
193
- if (result.status === "skipped" || !options.cloudFront) {
194
- return
195
- }
196
-
197
- await invalidatePackageManifestPaths(
198
- options.cloudFront.client,
199
- options.cloudFront.distributionId,
200
- [`/${manifestKey(courseSlug)}`]
201
- )
202
- }
203
- }
204
-
205
- return storage
206
- }
1
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
2
+ import { CloudFrontClient } from "@aws-sdk/client-cloudfront"
3
+ import { Syllabus } from "../../models/creator"
4
+ import { PACKAGE_MANIFEST_REL_PATH, PackageManifest } from "../packageManifest"
5
+ import {
6
+ AwsClient,
7
+ fetchJsonObject,
8
+ fetchTextObject,
9
+ invalidatePackageManifestPaths,
10
+ listObjectKeys,
11
+ processPackage,
12
+ withRetry,
13
+ } from "../s3/packageManifestBackfill"
14
+ import { SyllabusSyncStorage } from "../syllabusSync"
15
+ import {
16
+ awsRegion,
17
+ requireAwsCredentials,
18
+ requireS3PackagesBucket,
19
+ } from "../awsCredentials"
20
+ import { CourseDescriptionsStorage } from "./generateCourseDescriptions"
21
+ import { CourseExercise } from "./workList"
22
+
23
+ /**
24
+ * Published-bucket (S3) adapter for the description flows.
25
+ *
26
+ * The published package is the snapshot descriptions are generated from: it is
27
+ * immutable between publications, unlike the draft in GCS, which keeps moving
28
+ * while the teacher edits.
29
+ */
30
+
31
+ export const syllabusKey = (slug: string) =>
32
+ `${slug}/.learn/initialSyllabus.json`
33
+
34
+ export const manifestKey = (slug: string) =>
35
+ `${slug}/${PACKAGE_MANIFEST_REL_PATH}`
36
+
37
+ const configKeys = (slug: string) => [
38
+ `${slug}/.learn/config.json`,
39
+ `${slug}/config.json`,
40
+ ]
41
+
42
+ type ConfigShape = {
43
+ exercises?: CourseExercise[];
44
+ };
45
+
46
+ export async function fetchExercises(
47
+ s3: AwsClient,
48
+ bucket: string,
49
+ slug: string
50
+ ): Promise<CourseExercise[]> {
51
+ for (const key of configKeys(slug)) {
52
+ // eslint-disable-next-line no-await-in-loop -- the second key is a fallback
53
+ const config = await fetchJsonObject<ConfigShape>(s3, bucket, key)
54
+ if (config?.exercises && Array.isArray(config.exercises)) {
55
+ return config.exercises
56
+ }
57
+ }
58
+
59
+ return []
60
+ }
61
+
62
+ export function createS3SyllabusSyncStorage(
63
+ s3: AwsClient,
64
+ bucket: string
65
+ ): SyllabusSyncStorage {
66
+ return {
67
+ async listExerciseFolderFileCounts(courseSlug) {
68
+ const prefix = `${courseSlug}/exercises/`
69
+ const keys = await listObjectKeys(s3, bucket, prefix)
70
+ const counts = new Map<string, number>()
71
+ for (const key of keys) {
72
+ const segment = key.slice(prefix.length).split("/")[0]
73
+ if (segment) {
74
+ counts.set(segment, (counts.get(segment) || 0) + 1)
75
+ }
76
+ }
77
+
78
+ return counts
79
+ },
80
+ async translationLangsBySlug(courseSlug) {
81
+ const exercises = await fetchExercises(s3, bucket, courseSlug)
82
+ const map = new Map<string, string[]>()
83
+ for (const exercise of exercises) {
84
+ const langs = Object.keys(exercise.translations || {})
85
+ .map(lang => lang.toLowerCase())
86
+ .filter(Boolean)
87
+ if (langs.length > 0) {
88
+ map.set(exercise.slug, [...new Set(langs)])
89
+ }
90
+ }
91
+
92
+ return map
93
+ },
94
+ }
95
+ }
96
+
97
+ export type S3DescriptionsStorageOptions = {
98
+ /** CloudFront distribution to invalidate after re-projecting the manifest. */
99
+ cloudFront?: { client: AwsClient; distributionId: string };
100
+ /** Set false in the backfill, where manifests are projected in a later pass. */
101
+ reprojectManifest?: boolean;
102
+ };
103
+
104
+ /**
105
+ * Storage for the published bucket, wired from the environment. Used by the
106
+ * post-publish stage and the sweep, which have no CLI flags to read.
107
+ */
108
+ export function createS3DescriptionsStorageFromEnv(): CourseDescriptionsStorage {
109
+ // Validated here so a missing variable fails with its own name, instead of
110
+ // surfacing minutes later as "Could not load credentials from any providers"
111
+ // from inside a background job.
112
+ const credentials = requireAwsCredentials()
113
+ const region = awsRegion()
114
+
115
+ const s3 = new S3Client({ region, credentials }) as unknown as AwsClient
116
+
117
+ const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim()
118
+ const cloudFront = distributionId ?
119
+ {
120
+ client: new CloudFrontClient({
121
+ region,
122
+ credentials,
123
+ }) as unknown as AwsClient,
124
+ distributionId,
125
+ } :
126
+ undefined
127
+
128
+ if (!cloudFront) {
129
+ console.warn(
130
+ "[descriptions] CLOUDFRONT_DISTRIBUTION_ID is not set: the manifest will be re-projected but not invalidated"
131
+ )
132
+ }
133
+
134
+ return createS3DescriptionsStorage(s3, requireS3PackagesBucket(), {
135
+ cloudFront,
136
+ })
137
+ }
138
+
139
+ export function createS3DescriptionsStorage(
140
+ s3: AwsClient,
141
+ bucket: string,
142
+ options: S3DescriptionsStorageOptions = {}
143
+ ): CourseDescriptionsStorage {
144
+ const storage: CourseDescriptionsStorage = {
145
+ async readSyllabus(courseSlug) {
146
+ return fetchJsonObject<Syllabus>(s3, bucket, syllabusKey(courseSlug))
147
+ },
148
+ async writeSyllabus(courseSlug, syllabus) {
149
+ await withRetry(() =>
150
+ s3.send(
151
+ new PutObjectCommand({
152
+ Bucket: bucket,
153
+ Key: syllabusKey(courseSlug),
154
+ Body: JSON.stringify(syllabus, null, 2),
155
+ ContentType: "application/json",
156
+ })
157
+ )
158
+ )
159
+ },
160
+ async listExercises(courseSlug) {
161
+ return fetchExercises(s3, bucket, courseSlug)
162
+ },
163
+ async readReadme(courseSlug, exerciseSlug, fileName) {
164
+ return fetchTextObject(
165
+ s3,
166
+ bucket,
167
+ `${courseSlug}/exercises/${exerciseSlug}/${fileName}`
168
+ )
169
+ },
170
+ async readManifest(courseSlug) {
171
+ return fetchJsonObject<PackageManifest>(
172
+ s3,
173
+ bucket,
174
+ manifestKey(courseSlug)
175
+ )
176
+ },
177
+ syllabusSyncStorage: createS3SyllabusSyncStorage(s3, bucket),
178
+ }
179
+
180
+ if (options.reprojectManifest !== false) {
181
+ storage.reprojectManifest = async courseSlug => {
182
+ // Preserve the publication timestamp: this is a re-projection of an
183
+ // already published package, not a new publication.
184
+ const existing = await fetchJsonObject<PackageManifest>(
185
+ s3,
186
+ bucket,
187
+ manifestKey(courseSlug)
188
+ )
189
+
190
+ const result = await processPackage(s3, bucket, courseSlug, {
191
+ dryRun: false,
192
+ force: false,
193
+ skipExisting: false,
194
+ publishedAt: existing?.publishedAt ?? null,
195
+ })
196
+
197
+ if (result.status === "failed") {
198
+ throw new Error(result.error || "manifest projection failed")
199
+ }
200
+
201
+ if (result.status === "skipped" || !options.cloudFront) {
202
+ return
203
+ }
204
+
205
+ await invalidatePackageManifestPaths(
206
+ options.cloudFront.client,
207
+ options.cloudFront.distributionId,
208
+ [`/${manifestKey(courseSlug)}`]
209
+ )
210
+ }
211
+ }
212
+
213
+ return storage
214
+ }
@@ -94,7 +94,14 @@ function findSyllabusLesson(
94
94
  })
95
95
  }
96
96
 
97
- function deriveLessonId(exerciseSlug: string): string {
97
+ /**
98
+ * Lesson id when no syllabus entry matches: the numeric prefix of the folder.
99
+ *
100
+ * Exported because `legacyPackageRepair` has to synthesize syllabus ids with
101
+ * exactly this rule — a repaired package whose ids differed from the ones its
102
+ * manifest already carries would look like a content change to every consumer.
103
+ */
104
+ export function deriveLessonId(exerciseSlug: string): string {
98
105
  const match = exerciseSlug.match(/^(\d+(?:\.\d+)?)/)
99
106
  return match ? match[1] : exerciseSlug
100
107
  }