@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
@@ -0,0 +1,168 @@
1
+ import { Bucket } from "@google-cloud/storage"
2
+ import { PutObjectCommand } from "@aws-sdk/client-s3"
3
+ import {
4
+ AwsClient,
5
+ fetchJsonObject,
6
+ fetchTextObject,
7
+ listObjectKeys,
8
+ withRetry,
9
+ } from "../s3/packageManifestBackfill"
10
+
11
+ /**
12
+ * The two buckets a package lives in, behind one interface.
13
+ *
14
+ * A legacy package is repaired **twice, independently** — once against the
15
+ * published snapshot in S3 and once against the draft in GCS — never by copying
16
+ * one into the other. The two hold different content (the draft keeps moving
17
+ * after a publication) and the synthesized structure is a projection of that
18
+ * content: a syllabus derived from the snapshot could name steps the draft does
19
+ * not have. This is the same rule `mirrorDescriptions` enforces with its content
20
+ * hash; deriving from each bucket needs no guard at all.
21
+ *
22
+ * What crosses between them is only the descriptions, through
23
+ * `descriptionsS3Backfill --mirror-draft`, which is hash-guarded per step.
24
+ */
25
+
26
+ export type RepairTarget = "s3" | "gcs";
27
+
28
+ export const ALL_REPAIR_TARGETS: RepairTarget[] = ["s3", "gcs"]
29
+
30
+ /**
31
+ * Where each input lives. The two buckets do not agree on this, and the
32
+ * disagreement is load-bearing:
33
+ *
34
+ * - S3 reads `{slug}/config.json` first (`fetchPackageSources`), which is where
35
+ * the publish writes it into the zip.
36
+ * - GCS reads `courses/{slug}/.learn/config.json` — the publish route downloads
37
+ * that one before anything else, so a config written anywhere else in the
38
+ * draft would not make the package publishable.
39
+ */
40
+ export type PackageLayout = {
41
+ learnJsonKey(slug: string): string;
42
+ /** Config locations in resolution order; the first is the write target. */
43
+ configKeys(slug: string): string[];
44
+ syllabusKey(slug: string): string;
45
+ /** Sidebar locations in resolution order; the first is the write target. */
46
+ sidebarKeys(slug: string): string[];
47
+ exercisesPrefix(slug: string): string;
48
+ /**
49
+ * Whether `config.json` is the authority on the exercise list, or the folder
50
+ * listing is.
51
+ *
52
+ * This decides the **language keys** of the synthesized syllabus, and the two
53
+ * buckets genuinely disagree:
54
+ *
55
+ * - S3 reads the published `config.json` (`fetchPackageSources`), where these
56
+ * legacy packages carry `us` for the unsuffixed README.
57
+ * - GCS has no such reader. Both the publish-time manifest and the
58
+ * descriptions pipeline go through `configBuilder.buildConfig`, which
59
+ * derives exercises from the listing and maps an unsuffixed `README.md` to
60
+ * `en` — regardless of what the draft's `.learn/config.json` says.
61
+ *
62
+ * A syllabus keyed the other way is not read at all: the manifest would look
63
+ * up `translations["en"]`, find nothing, and publish `description: null` for
64
+ * every step. So each side is built the way its own reader will read it.
65
+ */
66
+ exercisesFromConfig: boolean;
67
+ };
68
+
69
+ export type RepairStorage = {
70
+ target: RepairTarget;
71
+ layout: PackageLayout;
72
+ readJson<T>(key: string): Promise<T | null>;
73
+ readText(key: string): Promise<string | null>;
74
+ listKeys(prefix: string): Promise<string[]>;
75
+ writeJson(key: string, value: unknown): Promise<void>;
76
+ };
77
+
78
+ export const s3Layout: PackageLayout = {
79
+ learnJsonKey: slug => `${slug}/learn.json`,
80
+ configKeys: slug => [`${slug}/config.json`, `${slug}/.learn/config.json`],
81
+ syllabusKey: slug => `${slug}/.learn/initialSyllabus.json`,
82
+ // 25 of the published packages keep their sidebar at the root. It has to be
83
+ // resolved before writing: `fetchPackageSources` prefers the `.learn/` copy,
84
+ // so a synthesized one would shadow the published titles.
85
+ sidebarKeys: slug => [
86
+ `${slug}/.learn/sidebar.json`,
87
+ `${slug}/sidebar.json`,
88
+ ],
89
+ exercisesPrefix: slug => `${slug}/exercises/`,
90
+ exercisesFromConfig: true,
91
+ }
92
+
93
+ export const gcsLayout: PackageLayout = {
94
+ learnJsonKey: slug => `courses/${slug}/learn.json`,
95
+ configKeys: slug => [
96
+ `courses/${slug}/.learn/config.json`,
97
+ `courses/${slug}/config.json`,
98
+ ],
99
+ syllabusKey: slug => `courses/${slug}/.learn/initialSyllabus.json`,
100
+ sidebarKeys: slug => [`courses/${slug}/.learn/sidebar.json`],
101
+ exercisesPrefix: slug => `courses/${slug}/exercises/`,
102
+ exercisesFromConfig: false,
103
+ }
104
+
105
+ export function createS3RepairStorage(
106
+ s3: AwsClient,
107
+ bucket: string
108
+ ): RepairStorage {
109
+ return {
110
+ target: "s3",
111
+ layout: s3Layout,
112
+ readJson: key => fetchJsonObject(s3, bucket, key),
113
+ readText: key => fetchTextObject(s3, bucket, key),
114
+ listKeys: prefix => listObjectKeys(s3, bucket, prefix),
115
+ async writeJson(key, value) {
116
+ await withRetry(() =>
117
+ s3.send(
118
+ new PutObjectCommand({
119
+ Bucket: bucket,
120
+ Key: key,
121
+ Body: JSON.stringify(value, null, 2),
122
+ ContentType: "application/json",
123
+ })
124
+ )
125
+ )
126
+ },
127
+ }
128
+ }
129
+
130
+ export function createGcsRepairStorage(bucket: Bucket): RepairStorage {
131
+ const download = async (key: string): Promise<string | null> => {
132
+ try {
133
+ const [buf] = await bucket.file(key).download()
134
+ return buf.toString()
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ return {
141
+ target: "gcs",
142
+ layout: gcsLayout,
143
+ async readJson<T>(key: string): Promise<T | null> {
144
+ const content = await download(key)
145
+ if (content === null) {
146
+ return null
147
+ }
148
+
149
+ try {
150
+ return JSON.parse(content) as T
151
+ } catch {
152
+ return null
153
+ }
154
+ },
155
+ readText: download,
156
+ async listKeys(prefix) {
157
+ const [files] = await bucket.getFiles({ prefix })
158
+ return files.map(file => file.name)
159
+ },
160
+ async writeJson(key, value) {
161
+ await bucket
162
+ .file(key)
163
+ .save(Buffer.from(JSON.stringify(value, null, 2), "utf8"), {
164
+ contentType: "application/json",
165
+ })
166
+ },
167
+ }
168
+ }