@learnpack/learnpack 5.0.351 → 5.0.353
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.
- package/lib/commands/publish.js +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +157 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +234 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +113 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +134 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +18 -0
- package/lib/utils/packageManifest.js +81 -7
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
- package/lib/utils/s3/packageManifestBackfill.js +487 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +4 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +208 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +143 -141
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +382 -0
- package/src/utils/descriptions/resumePublication.ts +200 -0
- package/src/utils/descriptions/s3Storage.ts +206 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/export/README.md +0 -2
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +101 -10
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +776 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.streamToString = streamToString;
|
|
4
|
+
exports.withRetry = withRetry;
|
|
5
|
+
exports.mapWithConcurrency = mapWithConcurrency;
|
|
6
|
+
exports.listPublishedSlugs = listPublishedSlugs;
|
|
7
|
+
exports.fetchJsonObject = fetchJsonObject;
|
|
8
|
+
exports.fetchTextObject = fetchTextObject;
|
|
9
|
+
exports.listObjectKeys = listObjectKeys;
|
|
10
|
+
exports.fetchPackageSources = fetchPackageSources;
|
|
11
|
+
exports.buildManifestFromS3Sources = buildManifestFromS3Sources;
|
|
12
|
+
exports.processPackage = processPackage;
|
|
13
|
+
exports.invalidatePackageManifestPaths = invalidatePackageManifestPaths;
|
|
14
|
+
exports.runBatch = runBatch;
|
|
15
|
+
exports.getBatchExitCode = getBatchExitCode;
|
|
16
|
+
const tslib_1 = require("tslib");
|
|
17
|
+
const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
|
|
18
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
19
|
+
const packageManifest_1 = require("../packageManifest");
|
|
20
|
+
const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i;
|
|
21
|
+
const INDEX_HTML_PATTERN = /^[^/]+\/index\.html$/;
|
|
22
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
23
|
+
"SlowDown",
|
|
24
|
+
"ServiceUnavailable",
|
|
25
|
+
"RequestTimeout",
|
|
26
|
+
"Throttling",
|
|
27
|
+
"ThrottlingException",
|
|
28
|
+
"TooManyRequestsException",
|
|
29
|
+
]);
|
|
30
|
+
const CLOUDFRONT_INVALIDATION_CHUNK_SIZE = 3000;
|
|
31
|
+
async function streamToString(body) {
|
|
32
|
+
var _a, e_1, _b, _c;
|
|
33
|
+
if (!body) {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
if (typeof body === "string") {
|
|
37
|
+
return body;
|
|
38
|
+
}
|
|
39
|
+
if (Buffer.isBuffer(body)) {
|
|
40
|
+
return body.toString("utf-8");
|
|
41
|
+
}
|
|
42
|
+
if (body instanceof Uint8Array) {
|
|
43
|
+
return Buffer.from(body).toString("utf-8");
|
|
44
|
+
}
|
|
45
|
+
const chunks = [];
|
|
46
|
+
try {
|
|
47
|
+
for (var _d = true, _e = tslib_1.__asyncValues(body), _f; _f = await _e.next(), _a = _f.done, !_a; _d = true) {
|
|
48
|
+
_c = _f.value;
|
|
49
|
+
_d = false;
|
|
50
|
+
const chunk = _c;
|
|
51
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
55
|
+
finally {
|
|
56
|
+
try {
|
|
57
|
+
if (!_d && !_a && (_b = _e.return)) await _b.call(_e);
|
|
58
|
+
}
|
|
59
|
+
finally { if (e_1) throw e_1.error; }
|
|
60
|
+
}
|
|
61
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
62
|
+
}
|
|
63
|
+
function isTransientError(error) {
|
|
64
|
+
var _a;
|
|
65
|
+
const err = error;
|
|
66
|
+
const code = err.name || err.Code || "";
|
|
67
|
+
if (TRANSIENT_ERROR_CODES.has(code)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const status = (_a = err.$metadata) === null || _a === void 0 ? void 0 : _a.httpStatusCode;
|
|
71
|
+
return status === 429 || status === 503;
|
|
72
|
+
}
|
|
73
|
+
function sleep(ms) {
|
|
74
|
+
return new Promise(resolve => {
|
|
75
|
+
setTimeout(resolve, ms);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async function withRetry(fn, attempts = 3, attempt = 1) {
|
|
79
|
+
try {
|
|
80
|
+
return await fn();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!isTransientError(error) || attempt === attempts) {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const multiplier = 2 ** (attempt - 1);
|
|
87
|
+
const delayMs = 250 * multiplier;
|
|
88
|
+
await sleep(delayMs);
|
|
89
|
+
return withRetry(fn, attempts, attempt + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
93
|
+
const results = Array.from({ length: items.length });
|
|
94
|
+
let nextIndex = 0;
|
|
95
|
+
async function worker() {
|
|
96
|
+
while (nextIndex < items.length) {
|
|
97
|
+
const currentIndex = nextIndex;
|
|
98
|
+
nextIndex += 1;
|
|
99
|
+
// eslint-disable-next-line no-await-in-loop -- each worker processes items sequentially
|
|
100
|
+
results[currentIndex] = await fn(items[currentIndex]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
104
|
+
await Promise.all(workers);
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
function manifestKey(slug) {
|
|
108
|
+
return `${slug}/${packageManifest_1.PACKAGE_MANIFEST_REL_PATH}`;
|
|
109
|
+
}
|
|
110
|
+
function cloudFrontManifestPath(slug) {
|
|
111
|
+
return `/${slug}/${packageManifest_1.PACKAGE_MANIFEST_REL_PATH}`;
|
|
112
|
+
}
|
|
113
|
+
function isNotFoundError(error) {
|
|
114
|
+
const err = error;
|
|
115
|
+
const code = err.name || err.Code || "";
|
|
116
|
+
return code === "NoSuchKey" || code === "NotFound" || code === "NoSuchBucket";
|
|
117
|
+
}
|
|
118
|
+
async function listPublishedSlugs(s3, bucket) {
|
|
119
|
+
const slugs = new Set();
|
|
120
|
+
let continuationToken;
|
|
121
|
+
do {
|
|
122
|
+
// eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
|
|
123
|
+
const response = (await withRetry(() => s3.send(new client_s3_1.ListObjectsV2Command({
|
|
124
|
+
Bucket: bucket,
|
|
125
|
+
ContinuationToken: continuationToken,
|
|
126
|
+
}))));
|
|
127
|
+
for (const item of response.Contents || []) {
|
|
128
|
+
const key = item.Key;
|
|
129
|
+
if (key && INDEX_HTML_PATTERN.test(key)) {
|
|
130
|
+
slugs.add(key.split("/")[0]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
continuationToken = response.NextContinuationToken;
|
|
134
|
+
} while (continuationToken);
|
|
135
|
+
return [...slugs].sort();
|
|
136
|
+
}
|
|
137
|
+
async function fetchJsonObject(s3, bucket, key) {
|
|
138
|
+
try {
|
|
139
|
+
const response = (await withRetry(() => s3.send(new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key }))));
|
|
140
|
+
const content = await streamToString(response.Body);
|
|
141
|
+
return JSON.parse(content);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (isNotFoundError(error)) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function fetchTextObject(s3, bucket, key) {
|
|
151
|
+
try {
|
|
152
|
+
const response = (await withRetry(() => s3.send(new client_s3_1.GetObjectCommand({ Bucket: bucket, Key: key }))));
|
|
153
|
+
return await streamToString(response.Body);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (isNotFoundError(error)) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function listObjectKeys(s3, bucket, prefix) {
|
|
163
|
+
const keys = [];
|
|
164
|
+
let continuationToken;
|
|
165
|
+
do {
|
|
166
|
+
// eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
|
|
167
|
+
const response = (await withRetry(() => s3.send(new client_s3_1.ListObjectsV2Command({
|
|
168
|
+
Bucket: bucket,
|
|
169
|
+
Prefix: prefix,
|
|
170
|
+
ContinuationToken: continuationToken,
|
|
171
|
+
}))));
|
|
172
|
+
for (const item of response.Contents || []) {
|
|
173
|
+
if (item.Key) {
|
|
174
|
+
keys.push(item.Key);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
continuationToken = response.NextContinuationToken;
|
|
178
|
+
} while (continuationToken);
|
|
179
|
+
return keys;
|
|
180
|
+
}
|
|
181
|
+
function resolveReadmeLang(filename, exercise) {
|
|
182
|
+
const lower = filename.toLowerCase();
|
|
183
|
+
for (const [lang, readmeFile] of Object.entries(exercise.translations)) {
|
|
184
|
+
if (readmeFile.toLowerCase() === lower) {
|
|
185
|
+
return lang;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
function normalizeLearnAndExercises(slug, learnJson, configJson) {
|
|
191
|
+
const exercises = configJson === null || configJson === void 0 ? void 0 : configJson.exercises;
|
|
192
|
+
if (!Array.isArray(exercises) || exercises.length === 0) {
|
|
193
|
+
throw new Error(`Missing or empty exercises array in ${slug}/config.json`);
|
|
194
|
+
}
|
|
195
|
+
const resolvedLearn = learnJson ||
|
|
196
|
+
(configJson === null || configJson === void 0 ? void 0 : configJson.config) ||
|
|
197
|
+
null;
|
|
198
|
+
if (!resolvedLearn) {
|
|
199
|
+
throw new Error(`Missing learn metadata: no ${slug}/learn.json and no config.config`);
|
|
200
|
+
}
|
|
201
|
+
return { learnJson: resolvedLearn, exercises };
|
|
202
|
+
}
|
|
203
|
+
async function fetchPackageSources(s3, bucket, slug) {
|
|
204
|
+
const [learnJsonRaw, configJsonRoot, configJsonLearn, sidebarLearn, sidebarRoot, syllabus,] = await Promise.all([
|
|
205
|
+
fetchJsonObject(s3, bucket, `${slug}/learn.json`),
|
|
206
|
+
fetchJsonObject(s3, bucket, `${slug}/config.json`),
|
|
207
|
+
fetchJsonObject(s3, bucket, `${slug}/.learn/config.json`),
|
|
208
|
+
fetchJsonObject(s3, bucket, `${slug}/.learn/sidebar.json`),
|
|
209
|
+
fetchJsonObject(s3, bucket, `${slug}/sidebar.json`),
|
|
210
|
+
fetchJsonObject(s3, bucket, `${slug}/.learn/initialSyllabus.json`),
|
|
211
|
+
]);
|
|
212
|
+
const configJson = configJsonRoot || configJsonLearn;
|
|
213
|
+
if (!configJson) {
|
|
214
|
+
throw new Error(`Missing required file: ${slug}/config.json or ${slug}/.learn/config.json`);
|
|
215
|
+
}
|
|
216
|
+
const { learnJson, exercises } = normalizeLearnAndExercises(slug, learnJsonRaw, configJson);
|
|
217
|
+
const exerciseSlugs = new Set(exercises.map(exercise => exercise.slug));
|
|
218
|
+
const readmeKeys = (await listObjectKeys(s3, bucket, `${slug}/exercises/`))
|
|
219
|
+
.filter(key => {
|
|
220
|
+
const filename = key.split("/").pop() || "";
|
|
221
|
+
return README_FILENAME_PATTERN.test(filename);
|
|
222
|
+
})
|
|
223
|
+
.filter(key => {
|
|
224
|
+
const parts = key.split("/");
|
|
225
|
+
const exerciseSlug = parts[2];
|
|
226
|
+
return Boolean(exerciseSlug && exerciseSlugs.has(exerciseSlug));
|
|
227
|
+
});
|
|
228
|
+
const readmeFrontmatters = {};
|
|
229
|
+
await Promise.all(readmeKeys.map(async (key) => {
|
|
230
|
+
const parts = key.split("/");
|
|
231
|
+
const exerciseSlug = parts[2];
|
|
232
|
+
const filename = parts[parts.length - 1];
|
|
233
|
+
const exercise = exercises.find(item => item.slug === exerciseSlug);
|
|
234
|
+
if (!exercise) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const lang = resolveReadmeLang(filename, exercise);
|
|
238
|
+
if (!lang) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const content = await fetchTextObject(s3, bucket, key);
|
|
242
|
+
if (!content) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!readmeFrontmatters[exerciseSlug]) {
|
|
246
|
+
readmeFrontmatters[exerciseSlug] = {};
|
|
247
|
+
}
|
|
248
|
+
readmeFrontmatters[exerciseSlug][lang] = (0, packageManifest_1.parseReadmeContent)(content);
|
|
249
|
+
}));
|
|
250
|
+
const readmeCount = Object.values(readmeFrontmatters).reduce((total, byLang) => total + Object.keys(byLang).length, 0);
|
|
251
|
+
return {
|
|
252
|
+
learnJson,
|
|
253
|
+
exercises,
|
|
254
|
+
sidebar: sidebarLearn || sidebarRoot || {},
|
|
255
|
+
syllabus,
|
|
256
|
+
readmeFrontmatters,
|
|
257
|
+
meta: {
|
|
258
|
+
learnJson: Boolean(learnJsonRaw || configJson.config),
|
|
259
|
+
configJson: true,
|
|
260
|
+
sidebar: Boolean(sidebarLearn || sidebarRoot),
|
|
261
|
+
syllabus: Boolean(syllabus),
|
|
262
|
+
readmes: readmeCount,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function buildManifestFromS3Sources(slug, sources, options) {
|
|
267
|
+
return (0, packageManifest_1.buildPackageManifestFromSources)({
|
|
268
|
+
learnJson: sources.learnJson,
|
|
269
|
+
syllabus: sources.syllabus,
|
|
270
|
+
sidebar: sources.sidebar,
|
|
271
|
+
exercises: sources.exercises,
|
|
272
|
+
readmeFrontmatters: sources.readmeFrontmatters,
|
|
273
|
+
options: {
|
|
274
|
+
slug,
|
|
275
|
+
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
276
|
+
publishedAt: options.publishedAt,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async function processPackage(s3, bucket, slug, options) {
|
|
281
|
+
const started = Date.now();
|
|
282
|
+
try {
|
|
283
|
+
const existing = await fetchJsonObject(s3, bucket, manifestKey(slug));
|
|
284
|
+
if (options.skipExisting && existing) {
|
|
285
|
+
return {
|
|
286
|
+
slug,
|
|
287
|
+
status: "skipped",
|
|
288
|
+
durationMs: Date.now() - started,
|
|
289
|
+
sources: {
|
|
290
|
+
learnJson: false,
|
|
291
|
+
configJson: false,
|
|
292
|
+
sidebar: false,
|
|
293
|
+
syllabus: false,
|
|
294
|
+
readmes: 0,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const sources = await fetchPackageSources(s3, bucket, slug);
|
|
299
|
+
const next = buildManifestFromS3Sources(slug, sources, {
|
|
300
|
+
publishedAt: options.publishedAt,
|
|
301
|
+
});
|
|
302
|
+
let status = (0, packageManifest_1.comparePackageManifests)(existing, next);
|
|
303
|
+
if (options.force && status === "skipped") {
|
|
304
|
+
status = "updated";
|
|
305
|
+
}
|
|
306
|
+
if (!options.dryRun && (status === "created" || status === "updated")) {
|
|
307
|
+
await withRetry(() => s3.send(new client_s3_1.PutObjectCommand({
|
|
308
|
+
Bucket: bucket,
|
|
309
|
+
Key: manifestKey(slug),
|
|
310
|
+
Body: (0, packageManifest_1.serializePackageManifest)(next),
|
|
311
|
+
ContentType: "application/json",
|
|
312
|
+
})));
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
slug,
|
|
316
|
+
status,
|
|
317
|
+
durationMs: Date.now() - started,
|
|
318
|
+
sources: sources.meta,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
const err = error;
|
|
323
|
+
return {
|
|
324
|
+
slug,
|
|
325
|
+
status: "failed",
|
|
326
|
+
durationMs: Date.now() - started,
|
|
327
|
+
error: err.message,
|
|
328
|
+
step: err.step || inferFailureStep(err.message),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function inferFailureStep(message) {
|
|
333
|
+
if (message.includes("config.json") || message.includes("learn")) {
|
|
334
|
+
return "fetch_sources";
|
|
335
|
+
}
|
|
336
|
+
if (message.includes("PutObject")) {
|
|
337
|
+
return "put_object";
|
|
338
|
+
}
|
|
339
|
+
return "build";
|
|
340
|
+
}
|
|
341
|
+
async function invalidatePackageManifestPaths(cf, distributionId, paths) {
|
|
342
|
+
var _a;
|
|
343
|
+
if (paths.length === 0) {
|
|
344
|
+
throw new Error("No paths to invalidate");
|
|
345
|
+
}
|
|
346
|
+
const callerReference = `package-manifest-backfill-${Date.now()}`;
|
|
347
|
+
let lastId = "";
|
|
348
|
+
for (let index = 0; index < paths.length; index += CLOUDFRONT_INVALIDATION_CHUNK_SIZE) {
|
|
349
|
+
const chunk = paths.slice(index, index + CLOUDFRONT_INVALIDATION_CHUNK_SIZE);
|
|
350
|
+
// eslint-disable-next-line no-await-in-loop -- sequential invalidation chunks to avoid rate limits
|
|
351
|
+
const response = (await withRetry(() => cf.send(new client_cloudfront_1.CreateInvalidationCommand({
|
|
352
|
+
DistributionId: distributionId,
|
|
353
|
+
InvalidationBatch: {
|
|
354
|
+
CallerReference: `${callerReference}-${index}`,
|
|
355
|
+
Paths: {
|
|
356
|
+
Quantity: chunk.length,
|
|
357
|
+
Items: chunk,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
}))));
|
|
361
|
+
lastId = ((_a = response.Invalidation) === null || _a === void 0 ? void 0 : _a.Id) || lastId;
|
|
362
|
+
}
|
|
363
|
+
return { id: lastId };
|
|
364
|
+
}
|
|
365
|
+
function loadResumeSlugs(reportPath) {
|
|
366
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
367
|
+
const fs = require("fs");
|
|
368
|
+
const raw = fs.readFileSync(reportPath, "utf-8");
|
|
369
|
+
const report = JSON.parse(raw);
|
|
370
|
+
const skipStatuses = new Set([
|
|
371
|
+
"created",
|
|
372
|
+
"updated",
|
|
373
|
+
"skipped",
|
|
374
|
+
]);
|
|
375
|
+
return new Set(report.results
|
|
376
|
+
.filter(result => skipStatuses.has(result.status))
|
|
377
|
+
.map(result => result.slug));
|
|
378
|
+
}
|
|
379
|
+
function summarizeResults(results) {
|
|
380
|
+
return {
|
|
381
|
+
created: results.filter(result => result.status === "created").length,
|
|
382
|
+
updated: results.filter(result => result.status === "updated").length,
|
|
383
|
+
skipped: results.filter(result => result.status === "skipped").length,
|
|
384
|
+
failed: results.filter(result => result.status === "failed").length,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
async function runBatch(options, clients) {
|
|
388
|
+
const startedAt = new Date().toISOString();
|
|
389
|
+
const s3 = (clients === null || clients === void 0 ? void 0 : clients.s3) ||
|
|
390
|
+
new client_s3_1.S3Client({
|
|
391
|
+
region: options.region,
|
|
392
|
+
credentials: {
|
|
393
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
394
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
const cf = (clients === null || clients === void 0 ? void 0 : clients.cf) ||
|
|
398
|
+
new client_cloudfront_1.CloudFrontClient({
|
|
399
|
+
region: "us-east-1",
|
|
400
|
+
credentials: {
|
|
401
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
402
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
let slugs = options.slug ?
|
|
406
|
+
[options.slug] :
|
|
407
|
+
await listPublishedSlugs(s3, options.bucket);
|
|
408
|
+
if (options.resumeFrom) {
|
|
409
|
+
const resumeSlugs = loadResumeSlugs(options.resumeFrom);
|
|
410
|
+
slugs = slugs.filter(slug => !resumeSlugs.has(slug));
|
|
411
|
+
}
|
|
412
|
+
let completed = 0;
|
|
413
|
+
const results = await mapWithConcurrency(slugs, options.concurrency, async (slug) => {
|
|
414
|
+
var _a;
|
|
415
|
+
const result = await processPackage(s3, options.bucket, slug, options);
|
|
416
|
+
completed += 1;
|
|
417
|
+
(_a = clients === null || clients === void 0 ? void 0 : clients.onProgress) === null || _a === void 0 ? void 0 : _a.call(clients, completed, slugs.length, result);
|
|
418
|
+
return result;
|
|
419
|
+
});
|
|
420
|
+
const summary = summarizeResults(results);
|
|
421
|
+
const invalidationPaths = results
|
|
422
|
+
.filter(result => result.status === "created" || result.status === "updated")
|
|
423
|
+
.map(result => cloudFrontManifestPath(result.slug));
|
|
424
|
+
let invalidation;
|
|
425
|
+
if (options.dryRun) {
|
|
426
|
+
invalidation = {
|
|
427
|
+
attempted: false,
|
|
428
|
+
skippedReason: "dry-run",
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
else if (options.noInvalidate) {
|
|
432
|
+
invalidation = {
|
|
433
|
+
attempted: false,
|
|
434
|
+
skippedReason: "no-invalidate",
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
else if (invalidationPaths.length === 0) {
|
|
438
|
+
invalidation = {
|
|
439
|
+
attempted: false,
|
|
440
|
+
skippedReason: "no-paths",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
else if (!options.cloudFrontDistributionId) {
|
|
444
|
+
invalidation = {
|
|
445
|
+
attempted: true,
|
|
446
|
+
paths: invalidationPaths,
|
|
447
|
+
error: "Missing CLOUDFRONT_DISTRIBUTION_ID",
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
try {
|
|
452
|
+
const result = await invalidatePackageManifestPaths(cf, options.cloudFrontDistributionId, invalidationPaths);
|
|
453
|
+
invalidation = {
|
|
454
|
+
attempted: true,
|
|
455
|
+
id: result.id,
|
|
456
|
+
paths: invalidationPaths,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
invalidation = {
|
|
461
|
+
attempted: true,
|
|
462
|
+
paths: invalidationPaths,
|
|
463
|
+
error: error.message,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const report = Object.assign(Object.assign({ total: slugs.length }, summary), { startedAt, finishedAt: new Date().toISOString(), options,
|
|
468
|
+
results,
|
|
469
|
+
invalidation });
|
|
470
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
471
|
+
const fs = require("fs");
|
|
472
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
473
|
+
const path = require("path");
|
|
474
|
+
fs.mkdirSync(path.dirname(options.reportOut), { recursive: true });
|
|
475
|
+
fs.writeFileSync(options.reportOut, JSON.stringify(report, null, 2), "utf-8");
|
|
476
|
+
return report;
|
|
477
|
+
}
|
|
478
|
+
function getBatchExitCode(report) {
|
|
479
|
+
var _a;
|
|
480
|
+
if (report.failed > 0) {
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
if (((_a = report.invalidation) === null || _a === void 0 ? void 0 : _a.attempted) && report.invalidation.error) {
|
|
484
|
+
return 1;
|
|
485
|
+
}
|
|
486
|
+
return 0;
|
|
487
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Bucket } from "@google-cloud/storage";
|
|
2
|
+
import { Syllabus } from "../models/creator";
|
|
3
|
+
/**
|
|
4
|
+
* Provisional syllabus ↔ bucket reconciliation.
|
|
5
|
+
*
|
|
6
|
+
* The `initialSyllabus.json` occasionally drifts from what actually lives in the
|
|
7
|
+
* bucket (lessons/translations present as files but missing from the syllabus,
|
|
8
|
+
* or vice versa). Because step descriptions are stored per (step, lang) in the
|
|
9
|
+
* syllabus, that drift leaves missing slots and causes skipped/spurious
|
|
10
|
+
* generations. This reconciles the two.
|
|
11
|
+
*
|
|
12
|
+
* Extracted from the inline `/actions/synchronize-syllabus` handler so it can be
|
|
13
|
+
* reused by the sweep/backfill, and generalized over a minimal storage adapter
|
|
14
|
+
* so it works for both GCS and S3.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: this is a temporary patch until the root cause of the drift is fixed.
|
|
17
|
+
* Track its removal via the `DESCRIPTIONS_RECONCILE_SYLLABUS` flag on callers.
|
|
18
|
+
*/
|
|
19
|
+
export interface SyllabusSyncStorage {
|
|
20
|
+
/**
|
|
21
|
+
* Map of `folderSlug -> fileCount` for every folder directly under
|
|
22
|
+
* `exercises/`. A single authoritative listing: if it throws, callers must
|
|
23
|
+
* abort any destructive action (never delete on an errored/unknown listing).
|
|
24
|
+
*/
|
|
25
|
+
listExerciseFolderFileCounts(courseSlug: string): Promise<Map<string, number>>;
|
|
26
|
+
/** README language codes (lowercased) present per exercise folder slug. */
|
|
27
|
+
translationLangsBySlug(courseSlug: string): Promise<Map<string, string[]>>;
|
|
28
|
+
}
|
|
29
|
+
export type SyllabusSyncOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* When false (default, "additive" mode) only non-destructive passes run:
|
|
32
|
+
* add-missing, repair-translations, unstick-status. Removal/dedupe are
|
|
33
|
+
* skipped. When true (manual dev button / root-cause runs) removals apply.
|
|
34
|
+
*/
|
|
35
|
+
prune?: boolean;
|
|
36
|
+
};
|
|
37
|
+
export type SyllabusSyncCounters = {
|
|
38
|
+
totalLessons: number;
|
|
39
|
+
keptLessons: number;
|
|
40
|
+
removedLessons: number;
|
|
41
|
+
duplicatesResolved: number;
|
|
42
|
+
addedLessons: number;
|
|
43
|
+
fixedLessons: number;
|
|
44
|
+
repairedTranslationsInLessons: number;
|
|
45
|
+
repairedTranslationEntries: number;
|
|
46
|
+
};
|
|
47
|
+
type LessonRef = {
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
slug: string;
|
|
51
|
+
};
|
|
52
|
+
type LessonRefWithCount = LessonRef & {
|
|
53
|
+
fileCount: number;
|
|
54
|
+
};
|
|
55
|
+
export type SyllabusSyncResult = {
|
|
56
|
+
syllabus: Syllabus;
|
|
57
|
+
changed: boolean;
|
|
58
|
+
counters: SyllabusSyncCounters;
|
|
59
|
+
details: {
|
|
60
|
+
removed: LessonRef[];
|
|
61
|
+
duplicates: LessonRefWithCount[];
|
|
62
|
+
kept: Array<LessonRef & {
|
|
63
|
+
fileCount?: number;
|
|
64
|
+
}>;
|
|
65
|
+
added: LessonRef[];
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
export declare function synchronizeSyllabusWithBucket(storage: SyllabusSyncStorage, courseSlug: string, syllabus: Syllabus, options?: SyllabusSyncOptions): Promise<SyllabusSyncResult>;
|
|
69
|
+
/** GCS-backed storage adapter (uses the bucket listing + buildConfig). */
|
|
70
|
+
export declare function createGcsSyllabusSyncStorage(bucket: Bucket): SyllabusSyncStorage;
|
|
71
|
+
export {};
|