@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
@@ -15,34 +15,59 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  * advance through the catalogue.
16
16
  * - Or target a single course with --slug (remediation / on-demand).
17
17
  *
18
- * Writes descriptions into the published initialSyllabus.json. Manifest
19
- * projection is a separate pass here: run the package-manifest backfill
20
- * (runBatch) afterwards, which is cheaper than re-projecting course by course.
18
+ * Writes descriptions into the published initialSyllabus.json. By default the
19
+ * manifest projection is a separate pass: run the package-manifest backfill
20
+ * (runBatch) afterwards, which over the whole catalogue is cheaper than
21
+ * re-projecting course by course.
21
22
  *
22
- * Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), plus S3_PACKAGES_BUCKET
23
- * and AWS_REGION as fallbacks for --s3-bucket / --region.
23
+ * That default inverts for small, targeted runs, where three flags reproduce per
24
+ * course what a publication does, in one process instead of three:
25
+ *
26
+ * --reproject-manifest projects each course as it is described (the same
27
+ * `processPackage` the separate pass runs, plus a
28
+ * CloudFront invalidation);
29
+ * --mirror-draft copies the new descriptions back into the GCS draft,
30
+ * so the next publication does not regenerate them;
31
+ * --emit-events announces the result to breathecode.
32
+ *
33
+ * Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), BREATHECODE_SYSTEM_TOKEN
34
+ * (required by --emit-events), GCP_CREDENTIALS_JSON and GCP_BUCKET_NAME (required
35
+ * by --mirror-draft), plus S3_PACKAGES_BUCKET, AWS_REGION and
36
+ * CLOUDFRONT_DISTRIBUTION_ID as fallbacks/optionals.
24
37
  *
25
38
  * Per-run flags:
26
- * --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
27
- * --region <region> AWS region (default us-east-1, or env AWS_REGION)
28
- * --slug <slug> process a single course (remediation / on-demand)
29
- * --limit <n> courses per run (default 50)
30
- * --target-words <n> words per description (default 25)
31
- * --concurrency <n> completions in flight per course (default 5)
32
- * --dry-run preview without writing/generating
33
- * --no-reconcile skip the additive syllabus reconciliation
34
- * --force regenerate even courses already settled at this prompt version
39
+ * --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
40
+ * --gcs-bucket <name> GCS draft bucket for --mirror-draft (or env GCP_BUCKET_NAME)
41
+ * --region <region> AWS region (default us-east-1, or env AWS_REGION)
42
+ * --slug <slug> process a single course (remediation / on-demand)
43
+ * --limit <n> courses per run (default 50)
44
+ * --target-words <n> words per description (default 25)
45
+ * --concurrency <n> completions in flight per course (default 5)
46
+ * --dry-run preview without writing/generating
47
+ * --no-reconcile skip the additive syllabus reconciliation
48
+ * --force regenerate even courses already settled at this prompt version
49
+ * --reproject-manifest re-project package-manifest.json per course, and invalidate it
50
+ * --mirror-draft mirror the descriptions back into the GCS draft
51
+ * --emit-events send package_manifest_updated per course (implies --reproject-manifest)
35
52
  *
36
53
  * Examples:
37
54
  * node lib/scripts/descriptionsS3Backfill.js --limit 50
38
55
  * node lib/scripts/descriptionsS3Backfill.js --slug my-course
39
56
  * node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
57
+ * node lib/scripts/descriptionsS3Backfill.js --slug my-course \
58
+ * --reproject-manifest --mirror-draft --emit-events
40
59
  */
41
60
  const node_util_1 = require("node:util");
42
61
  const client_s3_1 = require("@aws-sdk/client-s3");
62
+ const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
63
+ const storage_1 = require("@google-cloud/storage");
43
64
  const packageManifest_1 = require("../utils/packageManifest");
44
65
  const generateCourseDescriptions_1 = require("../utils/descriptions/generateCourseDescriptions");
45
66
  const s3Storage_1 = require("../utils/descriptions/s3Storage");
67
+ const backfillEvents_1 = require("../utils/descriptions/backfillEvents");
68
+ const gcsStorage_1 = require("../utils/descriptions/gcsStorage");
69
+ const mirrorDescriptions_1 = require("../utils/descriptions/mirrorDescriptions");
70
+ const gcsBucketName_1 = require("../utils/gcsBucketName");
46
71
  const workList_1 = require("../utils/descriptions/workList");
47
72
  const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/;
48
73
  /** Courses (by slug) ordered by initialSyllabus.json LastModified, newest first. */
@@ -67,11 +92,28 @@ async function listCoursesByRecency(s3, bucket) {
67
92
  .sort((a, b) => b[1] - a[1])
68
93
  .map(([slug]) => slug);
69
94
  }
95
+ /**
96
+ * CDN invalidation for the re-projected manifests. Optional: a stale edge cache
97
+ * expires on its own, so a missing distribution id degrades the run instead of
98
+ * stopping it.
99
+ */
100
+ function cloudFrontFor(region) {
101
+ const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim();
102
+ if (!distributionId) {
103
+ console.warn("[s3-backfill] CLOUDFRONT_DISTRIBUTION_ID is not set: manifests will be re-projected but not invalidated");
104
+ return undefined;
105
+ }
106
+ return {
107
+ client: new client_cloudfront_1.CloudFrontClient({ region }),
108
+ distributionId,
109
+ };
110
+ }
70
111
  async function main() {
71
- var _a, _b, _c;
112
+ var _a, _b, _c, _d, _e, _f;
72
113
  const { values } = (0, node_util_1.parseArgs)({
73
114
  options: {
74
115
  "s3-bucket": { type: "string" },
116
+ "gcs-bucket": { type: "string" },
75
117
  region: { type: "string" },
76
118
  slug: { type: "string" },
77
119
  limit: { type: "string" },
@@ -80,6 +122,9 @@ async function main() {
80
122
  "dry-run": { type: "boolean", default: false },
81
123
  "no-reconcile": { type: "boolean", default: false },
82
124
  force: { type: "boolean", default: false },
125
+ "reproject-manifest": { type: "boolean", default: false },
126
+ "mirror-draft": { type: "boolean", default: false },
127
+ "emit-events": { type: "boolean", default: false },
83
128
  },
84
129
  });
85
130
  const token = process.env.RIGOBOT_SYSTEM_TOKEN;
@@ -94,19 +139,61 @@ async function main() {
94
139
  const limit = Number.parseInt(values.limit || "50", 10) || 50;
95
140
  const dryRun = (_a = values["dry-run"]) !== null && _a !== void 0 ? _a : false;
96
141
  const force = (_b = values.force) !== null && _b !== void 0 ? _b : false;
142
+ const emitEvents = (_c = values["emit-events"]) !== null && _c !== void 0 ? _c : false;
143
+ const reprojectManifest = (_d = values["reproject-manifest"]) !== null && _d !== void 0 ? _d : false;
144
+ const mirrorDraft = (_e = values["mirror-draft"]) !== null && _e !== void 0 ? _e : false;
145
+ // The event announces that the manifest changed, so emitting it without
146
+ // projecting the manifest would state something untrue. Required rather than
147
+ // implied: turning on a write to S3 and the CDN as a side effect of another
148
+ // flag is exactly the kind of surprise a backfill should not have.
149
+ if (emitEvents && !reprojectManifest) {
150
+ console.error("[s3-backfill] --emit-events requires --reproject-manifest: the event " +
151
+ "reports a manifest update that would not have happened");
152
+ process.exit(1);
153
+ }
154
+ const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN;
155
+ if (emitEvents && !breathecodeToken) {
156
+ console.error("[s3-backfill] BREATHECODE_SYSTEM_TOKEN (env) is required by --emit-events");
157
+ process.exit(1);
158
+ }
159
+ if (emitEvents && dryRun) {
160
+ console.error("[s3-backfill] --emit-events cannot be combined with --dry-run: there is " +
161
+ "no dry run of an event that breathecode already received");
162
+ process.exit(1);
163
+ }
164
+ // Built up front, not on first use: a misconfigured draft bucket must fail at
165
+ // boot, not after the first course has already been billed to Rigobot.
166
+ let draftStorage;
167
+ if (mirrorDraft) {
168
+ const credentialsEnv = process.env.GCP_CREDENTIALS_JSON;
169
+ if (!credentialsEnv) {
170
+ console.error("[s3-backfill] GCP_CREDENTIALS_JSON (env) is required by --mirror-draft");
171
+ process.exit(1);
172
+ }
173
+ draftStorage = (0, gcsStorage_1.createGcsDescriptionsStorage)(new storage_1.Storage({ credentials: JSON.parse(credentialsEnv) }).bucket(values["gcs-bucket"] || (0, gcsBucketName_1.requireGcsBucketName)()));
174
+ }
97
175
  const s3 = new client_s3_1.S3Client({ region });
98
- // Manifests are projected in a separate pass after this backfill.
99
- const storage = (0, s3Storage_1.createS3DescriptionsStorage)(s3, bucket, {
100
- reprojectManifest: false,
101
- });
176
+ const storage = (0, s3Storage_1.createS3DescriptionsStorage)(s3, bucket,
177
+ // Off by default: over the whole catalogue manifests are cheaper to project
178
+ // in a single later pass than course by course.
179
+ reprojectManifest ?
180
+ { cloudFront: cloudFrontFor(region) } :
181
+ { reprojectManifest: false });
182
+ const runId = (0, backfillEvents_1.newBackfillRunId)();
102
183
  const singleSlug = values.slug;
103
184
  const slugs = singleSlug ?
104
185
  [singleSlug] :
105
186
  await listCoursesByRecency(s3, bucket);
106
- console.log(`[s3-backfill] Starting${singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`);
187
+ console.log(`[s3-backfill] Starting${singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)${reprojectManifest ? ", re-projecting manifests" : ""}${mirrorDraft ? ", mirroring into the draft" : ""}${emitEvents ? `, run ${runId}` : ""}`);
107
188
  let processed = 0;
108
189
  let generated = 0;
109
190
  let failed = 0;
191
+ let mirrored = 0;
192
+ const events = {
193
+ skipped: 0,
194
+ delivered: 0,
195
+ failed: 0,
196
+ };
110
197
  for (const slug of slugs) {
111
198
  if (!singleSlug && processed >= limit) {
112
199
  break;
@@ -127,7 +214,7 @@ async function main() {
127
214
  token,
128
215
  dryRun,
129
216
  force,
130
- reconcile: !((_c = values["no-reconcile"]) !== null && _c !== void 0 ? _c : false),
217
+ reconcile: !((_f = values["no-reconcile"]) !== null && _f !== void 0 ? _f : false),
131
218
  concurrency: Number.parseInt(values.concurrency || "", 10) ||
132
219
  generateCourseDescriptions_1.DEFAULT_DESCRIPTIONS_CONCURRENCY,
133
220
  targetWordCount: Number.parseInt(values["target-words"] || "", 10) ||
@@ -140,14 +227,47 @@ async function main() {
140
227
  generated += result.generated;
141
228
  failed += result.failed;
142
229
  console.log(`[s3-backfill] "${slug}": ${result.steps} step(s), ${result.generated} description(s)${dryRun ? " (dry-run)" : ""}${result.failed > 0 ? `, ${result.failed} failed` : ""}${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`);
230
+ if (draftStorage) {
231
+ // Before the event, so a course is fully settled by the time it is
232
+ // announced. A failure here does NOT change the event status: the
233
+ // published package and its manifest are already correct, and only the
234
+ // draft lags behind — the next publication regenerates what is missing.
235
+ try {
236
+ const published = await storage.readSyllabus(slug);
237
+ const mirror = await (0, mirrorDescriptions_1.mirrorDescriptionsToDraft)(draftStorage, slug, published, { dryRun });
238
+ mirrored += mirror.copied;
239
+ console.log(`[s3-backfill] "${slug}": ${mirror.copied} mirrored, ${mirror.missed} missed, ${mirror.fresh} already fresh`);
240
+ }
241
+ catch (error) {
242
+ console.error(`[s3-backfill] Could not mirror "${slug}" into the draft:`, error.message);
243
+ }
244
+ }
245
+ if (emitEvents) {
246
+ const outcome = await (0, backfillEvents_1.emitBackfillManifestEvent)({
247
+ courseSlug: slug,
248
+ runId,
249
+ result,
250
+ storage,
251
+ rigobotToken: token,
252
+ breathecodeToken: breathecodeToken,
253
+ });
254
+ events[outcome] += 1;
255
+ console.log(`[s3-backfill] "${slug}": manifest event ${outcome}`);
256
+ }
143
257
  }
144
258
  catch (error) {
145
259
  processed += 1;
146
260
  failed += 1;
261
+ // No event here on purpose: unlike a publication, a backfill never
262
+ // announced one, so nothing is owed. The course is simply re-run.
147
263
  console.error(`[s3-backfill] Failed processing "${slug}":`, error.message);
148
264
  }
149
265
  }
150
- console.log(`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).`);
266
+ console.log(`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).` +
267
+ (mirrorDraft ? ` ${mirrored} mirrored into the draft.` : "") +
268
+ (emitEvents ?
269
+ ` Events: ${events.delivered} delivered, ${events.failed} failed (run ${runId}).` :
270
+ ""));
151
271
  }
152
272
  main()
153
273
  .then(() => process.exit(0))
@@ -0,0 +1,20 @@
1
+ /**
2
+ * AWS credentials for the published bucket, required rather than implicit.
3
+ *
4
+ * Building an `S3Client` with no credentials succeeds: the SDK defers to its
5
+ * provider chain and only fails at the first request, with
6
+ * "Could not load credentials from any providers". Inside a background job that
7
+ * surfaces minutes later, detached from the cause. Validating up front turns it
8
+ * into an obvious misconfiguration.
9
+ *
10
+ * Note this deliberately rejects the empty-string fallback pattern
11
+ * (`process.env.X || ""`): passing empty credentials defeats the provider chain
12
+ * instead of failing, which is how the opaque error appeared in the first place.
13
+ */
14
+ export type AwsCredentials = {
15
+ accessKeyId: string;
16
+ secretAccessKey: string;
17
+ };
18
+ export declare function requireAwsCredentials(): AwsCredentials;
19
+ export declare function requireS3PackagesBucket(): string;
20
+ export declare function awsRegion(): string;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ /**
3
+ * AWS credentials for the published bucket, required rather than implicit.
4
+ *
5
+ * Building an `S3Client` with no credentials succeeds: the SDK defers to its
6
+ * provider chain and only fails at the first request, with
7
+ * "Could not load credentials from any providers". Inside a background job that
8
+ * surfaces minutes later, detached from the cause. Validating up front turns it
9
+ * into an obvious misconfiguration.
10
+ *
11
+ * Note this deliberately rejects the empty-string fallback pattern
12
+ * (`process.env.X || ""`): passing empty credentials defeats the provider chain
13
+ * instead of failing, which is how the opaque error appeared in the first place.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.requireAwsCredentials = requireAwsCredentials;
17
+ exports.requireS3PackagesBucket = requireS3PackagesBucket;
18
+ exports.awsRegion = awsRegion;
19
+ function requireAwsCredentials() {
20
+ const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim();
21
+ const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim();
22
+ const missing = [];
23
+ if (!accessKeyId) {
24
+ missing.push("AWS_ACCESS_KEY_ID");
25
+ }
26
+ if (!secretAccessKey) {
27
+ missing.push("AWS_SECRET_ACCESS_KEY");
28
+ }
29
+ if (missing.length > 0) {
30
+ throw new Error(`${missing.join(" and ")} (env) ${missing.length > 1 ? "are" : "is"} required to reach the published package bucket`);
31
+ }
32
+ return { accessKeyId, secretAccessKey };
33
+ }
34
+ function requireS3PackagesBucket() {
35
+ const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim();
36
+ if (!bucket) {
37
+ throw new Error("S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages");
38
+ }
39
+ return bucket;
40
+ }
41
+ function awsRegion() {
42
+ return (process.env.AWS_REGION || "").trim() || "us-east-1";
43
+ }
@@ -0,0 +1,60 @@
1
+ import { fetchPackageInfo, sendPublishEvent } from "../publishEvents";
2
+ import { CourseDescriptionsStorage, GenerateCourseDescriptionsResult } from "./generateCourseDescriptions";
3
+ /**
4
+ * `package_manifest_updated` for courses reconciled by the backfill.
5
+ *
6
+ * The backfill regenerates descriptions of packages that are already published,
7
+ * so breathecode needs the same notification a publication sends — but there is
8
+ * no publication here: no `package_published` precedes this event, and no
9
+ * journal tracks it (the sweep resumes publications, and a backfill is simply
10
+ * re-run instead).
11
+ *
12
+ * That is what makes `publish_id` awkward. The field exists to correlate the two
13
+ * events of one publication, a pair this event has no half of. Since it is
14
+ * required, the value has to be something that cannot be mistaken for a real
15
+ * publication: a bare uuid — the obvious choice — would be exactly that mistake,
16
+ * and if breathecode ever starts pairing events it would search forever for a
17
+ * `package_published` that was never sent.
18
+ *
19
+ * Hence `backfill-{runId}-{slug}`:
20
+ *
21
+ * - the `backfill-` prefix makes the origin readable in the webhook log and
22
+ * discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
23
+ * `createJournal`, so the two spaces cannot collide;
24
+ * - `{slug}` keeps it unique per event, which a per-run id alone would not be;
25
+ * - a `{runId}` shared by every course of one invocation makes "every event from
26
+ * the run I fired at 15:40" a single substring query, which is the question
27
+ * you actually ask when the catalogue is backfilled in small batches.
28
+ *
29
+ * Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
30
+ * event contract untouched: breathecode ignores `publish_id` today, so this
31
+ * costs them nothing and needs no coordination. If they ever start consuming the
32
+ * provenance, it can be promoted to a field of its own.
33
+ */
34
+ export declare const BACKFILL_PUBLISH_ID_PREFIX = "backfill-";
35
+ /** One per script invocation, shared by every course it touches. */
36
+ export declare function newBackfillRunId(): string;
37
+ export declare function backfillPublishId(runId: string, courseSlug: string): string;
38
+ export declare function isBackfillPublishId(publishId: string): boolean;
39
+ /**
40
+ * `skipped` means the event was never attempted, which is not a failure: a
41
+ * course the backfill did not change has nothing to announce.
42
+ */
43
+ export type BackfillEventOutcome = "skipped" | "delivered" | "failed";
44
+ export type EmitBackfillManifestEventParams = {
45
+ courseSlug: string;
46
+ runId: string;
47
+ result: GenerateCourseDescriptionsResult;
48
+ storage: CourseDescriptionsStorage;
49
+ rigobotToken: string;
50
+ breathecodeToken: string;
51
+ /** Injection points for tests. */
52
+ emit?: typeof sendPublishEvent;
53
+ readPackageInfo?: typeof fetchPackageInfo;
54
+ };
55
+ /**
56
+ * Announce one reconciled course. Never throws: the descriptions and the
57
+ * manifest are already written by the time this runs, and losing the
58
+ * notification must not turn a successful course into a failed one.
59
+ */
60
+ export declare function emitBackfillManifestEvent(params: EmitBackfillManifestEventParams): Promise<BackfillEventOutcome>;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BACKFILL_PUBLISH_ID_PREFIX = void 0;
4
+ exports.newBackfillRunId = newBackfillRunId;
5
+ exports.backfillPublishId = backfillPublishId;
6
+ exports.isBackfillPublishId = isBackfillPublishId;
7
+ exports.emitBackfillManifestEvent = emitBackfillManifestEvent;
8
+ const uuid_1 = require("uuid");
9
+ const publishEvents_1 = require("../publishEvents");
10
+ /**
11
+ * `package_manifest_updated` for courses reconciled by the backfill.
12
+ *
13
+ * The backfill regenerates descriptions of packages that are already published,
14
+ * so breathecode needs the same notification a publication sends — but there is
15
+ * no publication here: no `package_published` precedes this event, and no
16
+ * journal tracks it (the sweep resumes publications, and a backfill is simply
17
+ * re-run instead).
18
+ *
19
+ * That is what makes `publish_id` awkward. The field exists to correlate the two
20
+ * events of one publication, a pair this event has no half of. Since it is
21
+ * required, the value has to be something that cannot be mistaken for a real
22
+ * publication: a bare uuid — the obvious choice — would be exactly that mistake,
23
+ * and if breathecode ever starts pairing events it would search forever for a
24
+ * `package_published` that was never sent.
25
+ *
26
+ * Hence `backfill-{runId}-{slug}`:
27
+ *
28
+ * - the `backfill-` prefix makes the origin readable in the webhook log and
29
+ * discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
30
+ * `createJournal`, so the two spaces cannot collide;
31
+ * - `{slug}` keeps it unique per event, which a per-run id alone would not be;
32
+ * - a `{runId}` shared by every course of one invocation makes "every event from
33
+ * the run I fired at 15:40" a single substring query, which is the question
34
+ * you actually ask when the catalogue is backfilled in small batches.
35
+ *
36
+ * Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
37
+ * event contract untouched: breathecode ignores `publish_id` today, so this
38
+ * costs them nothing and needs no coordination. If they ever start consuming the
39
+ * provenance, it can be promoted to a field of its own.
40
+ */
41
+ exports.BACKFILL_PUBLISH_ID_PREFIX = "backfill-";
42
+ /** One per script invocation, shared by every course it touches. */
43
+ function newBackfillRunId() {
44
+ return (0, uuid_1.v4)();
45
+ }
46
+ function backfillPublishId(runId, courseSlug) {
47
+ return `${exports.BACKFILL_PUBLISH_ID_PREFIX}${runId}-${courseSlug}`;
48
+ }
49
+ function isBackfillPublishId(publishId) {
50
+ return publishId.startsWith(exports.BACKFILL_PUBLISH_ID_PREFIX);
51
+ }
52
+ /**
53
+ * Announce one reconciled course. Never throws: the descriptions and the
54
+ * manifest are already written by the time this runs, and losing the
55
+ * notification must not turn a successful course into a failed one.
56
+ */
57
+ async function emitBackfillManifestEvent(params) {
58
+ var _a, _b;
59
+ const { courseSlug, result } = params;
60
+ if (result.status === "skipped") {
61
+ return "skipped";
62
+ }
63
+ const emit = (_a = params.emit) !== null && _a !== void 0 ? _a : publishEvents_1.sendPublishEvent;
64
+ const readPackageInfo = (_b = params.readPackageInfo) !== null && _b !== void 0 ? _b : publishEvents_1.fetchPackageInfo;
65
+ // Enriching the payload must never prevent the delivery: both the manifest and
66
+ // the package info are optional in the contract, and an event with less
67
+ // context beats an event that never arrives.
68
+ let manifest = null;
69
+ try {
70
+ if (params.storage.readManifest) {
71
+ manifest = await params.storage.readManifest(courseSlug);
72
+ }
73
+ }
74
+ catch (error) {
75
+ console.error(`[backfill-events] Could not read the manifest of "${courseSlug}", reporting without it:`, error.message);
76
+ }
77
+ let packageInfo = null;
78
+ try {
79
+ packageInfo = await readPackageInfo(courseSlug, params.rigobotToken);
80
+ }
81
+ catch (error) {
82
+ console.error(`[backfill-events] Could not read the package "${courseSlug}", reporting without it:`, error.message);
83
+ }
84
+ try {
85
+ const delivered = await emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)({
86
+ publishId: backfillPublishId(params.runId, courseSlug),
87
+ courseSlug,
88
+ packageInfo,
89
+ manifest,
90
+ },
91
+ // A failed projection does not fail the run — the syllabus is saved
92
+ // either way — but this event reports the manifest, so it cannot claim
93
+ // success over one that was never rewritten.
94
+ result.status === "failed" || result.manifestProjected === false ?
95
+ "failed" :
96
+ "success", {
97
+ generated: result.generated,
98
+ failed: result.failed,
99
+ missing: result.missing,
100
+ }), params.breathecodeToken);
101
+ return delivered ? "delivered" : "failed";
102
+ }
103
+ catch (error) {
104
+ console.error(`[backfill-events] Could not deliver the manifest event for "${courseSlug}":`, error.message);
105
+ return "failed";
106
+ }
107
+ }
@@ -53,6 +53,13 @@ export type GenerateCourseDescriptionsResult = {
53
53
  errors: string[];
54
54
  /** Completion durations, to watch how close we run to the request limits. */
55
55
  durationsSeconds: number[];
56
+ /**
57
+ * Whether `package-manifest.json` was re-projected; null when the storage does
58
+ * not project at all. A failed projection is not a failed run — the syllabus,
59
+ * which is the source of truth, is already saved — but callers that announce
60
+ * the manifest downstream must not report success on it.
61
+ */
62
+ manifestProjected: boolean | null;
56
63
  };
57
64
  /** Read every README of the course, keyed by exercise slug and language. */
58
65
  export declare function readCourseReadmes(storage: CourseDescriptionsStorage, courseSlug: string, exercises: CourseExercise[], concurrency: number): Promise<CourseReadmes>;
@@ -29,6 +29,7 @@ function emptyResult(status) {
29
29
  missing: 0,
30
30
  errors: [],
31
31
  durationsSeconds: [],
32
+ manifestProjected: null,
32
33
  };
33
34
  }
34
35
  /** Read every README of the course, keyed by exercise slug and language. */
@@ -162,10 +163,12 @@ async function generateCourseDescriptions(storage, courseSlug, options) {
162
163
  if (storage.reprojectManifest) {
163
164
  try {
164
165
  await storage.reprojectManifest(courseSlug);
166
+ result.manifestProjected = true;
165
167
  }
166
168
  catch (error) {
167
169
  // The syllabus (the source of truth) is already saved; a failed
168
170
  // projection is recoverable and must not lose the generated text.
171
+ result.manifestProjected = false;
169
172
  result.errors.push(`manifest projection failed: ${error.message}`);
170
173
  }
171
174
  }
@@ -212,10 +212,21 @@ async function emitManifestUpdated(params) {
212
212
  });
213
213
  return;
214
214
  }
215
+ // Enriching the payload must never prevent the delivery. The manifest is
216
+ // optional in the contract (`manifest: null` is valid); the event is not:
217
+ // breathecode was promised one and is waiting for it. Reading the manifest
218
+ // from the published bucket can fail for reasons that have nothing to do with
219
+ // the event — a bad AWS credential, say — and an event with less context still
220
+ // beats an event that never arrives.
221
+ let updatedManifest = params.eventContext.manifest;
222
+ try {
223
+ updatedManifest =
224
+ (_c = (await ((_b = (_a = params.publishedStorage).readManifest) === null || _b === void 0 ? void 0 : _b.call(_a, params.courseSlug)))) !== null && _c !== void 0 ? _c : params.eventContext.manifest;
225
+ }
226
+ catch (error) {
227
+ console.error(`[descriptions] Could not read the updated manifest for "${params.courseSlug}", reporting without it:`, error.message);
228
+ }
215
229
  try {
216
- // Report the manifest as it is NOW: the descriptions are the whole point of
217
- // this event, so the one captured at publish time would be stale.
218
- const updatedManifest = (_c = (await ((_b = (_a = params.publishedStorage).readManifest) === null || _b === void 0 ? void 0 : _b.call(_a, params.courseSlug)))) !== null && _c !== void 0 ? _c : params.eventContext.manifest;
219
230
  const generation = params.outcome.generation;
220
231
  const delivered = await params.emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)(Object.assign(Object.assign({}, params.eventContext), { manifest: updatedManifest }), params.outcome.succeeded ? "success" : "failed", generation ?
221
232
  {
@@ -92,15 +92,30 @@ async function emitPendingManifestEvent(journal, deps, succeeded, stats) {
92
92
  });
93
93
  return;
94
94
  }
95
+ // Enriching the payload must never prevent the delivery: this event is the
96
+ // one breathecode has been waiting for since the publication stalled, and
97
+ // both the manifest and the package info are optional in the contract.
98
+ let manifest = null;
99
+ let packageInfo = null;
95
100
  try {
96
- let manifest = null;
97
101
  if (deps.publishedStorage.readManifest) {
98
102
  manifest = await deps.publishedStorage.readManifest(journal.courseSlug);
99
103
  }
104
+ }
105
+ catch (error) {
106
+ console.error(`[sweep] Could not read the manifest of "${journal.courseSlug}", reporting without it:`, error.message);
107
+ }
108
+ try {
109
+ packageInfo = await readPackageInfo(journal.courseSlug, deps.rigobotToken);
110
+ }
111
+ catch (error) {
112
+ console.error(`[sweep] Could not read the package of "${journal.courseSlug}", reporting without it:`, error.message);
113
+ }
114
+ try {
100
115
  const delivered = await emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)({
101
116
  publishId: journal.publishId,
102
117
  courseSlug: journal.courseSlug,
103
- packageInfo: await readPackageInfo(journal.courseSlug, deps.rigobotToken),
118
+ packageInfo,
104
119
  manifest,
105
120
  }, succeeded ? "success" : "failed", stats), deps.breathecodeToken);
106
121
  await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", delivered ? "done" : "failed", { error: delivered ? undefined : "delivery failed" });
@@ -9,6 +9,7 @@ const client_s3_1 = require("@aws-sdk/client-s3");
9
9
  const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
10
10
  const packageManifest_1 = require("../packageManifest");
11
11
  const packageManifestBackfill_1 = require("../s3/packageManifestBackfill");
12
+ const awsCredentials_1 = require("../awsCredentials");
12
13
  /**
13
14
  * Published-bucket (S3) adapter for the description flows.
14
15
  *
@@ -68,14 +69,18 @@ function createS3SyllabusSyncStorage(s3, bucket) {
68
69
  * post-publish stage and the sweep, which have no CLI flags to read.
69
70
  */
70
71
  function createS3DescriptionsStorageFromEnv() {
71
- const s3 = new client_s3_1.S3Client({
72
- region: process.env.AWS_REGION || "us-east-1",
73
- });
74
- const distributionId = process.env.CLOUDFRONT_DISTRIBUTION_ID;
72
+ // Validated here so a missing variable fails with its own name, instead of
73
+ // surfacing minutes later as "Could not load credentials from any providers"
74
+ // from inside a background job.
75
+ const credentials = (0, awsCredentials_1.requireAwsCredentials)();
76
+ const region = (0, awsCredentials_1.awsRegion)();
77
+ const s3 = new client_s3_1.S3Client({ region, credentials });
78
+ const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim();
75
79
  const cloudFront = distributionId ?
76
80
  {
77
81
  client: new client_cloudfront_1.CloudFrontClient({
78
- region: process.env.AWS_REGION || "us-east-1",
82
+ region,
83
+ credentials,
79
84
  }),
80
85
  distributionId,
81
86
  } :
@@ -83,7 +88,9 @@ function createS3DescriptionsStorageFromEnv() {
83
88
  if (!cloudFront) {
84
89
  console.warn("[descriptions] CLOUDFRONT_DISTRIBUTION_ID is not set: the manifest will be re-projected but not invalidated");
85
90
  }
86
- return createS3DescriptionsStorage(s3, process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes", { cloudFront });
91
+ return createS3DescriptionsStorage(s3, (0, awsCredentials_1.requireS3PackagesBucket)(), {
92
+ cloudFront,
93
+ });
87
94
  }
88
95
  function createS3DescriptionsStorage(s3, bucket, options = {}) {
89
96
  const storage = {
@@ -48,6 +48,14 @@ export type PackageManifestBuildInput = {
48
48
  };
49
49
  export declare function packageManifestGcsPath(courseSlug: string): string;
50
50
  export declare function resolvePreviewUrl(slug: string, preview?: string): string;
51
+ /**
52
+ * Lesson id when no syllabus entry matches: the numeric prefix of the folder.
53
+ *
54
+ * Exported because `legacyPackageRepair` has to synthesize syllabus ids with
55
+ * exactly this rule — a repaired package whose ids differed from the ones its
56
+ * manifest already carries would look like a content change to every consumer.
57
+ */
58
+ export declare function deriveLessonId(exerciseSlug: string): string;
51
59
  export declare function extractReadmeH1(body: string): string | null;
52
60
  export declare function normalizeReadmeTitle(raw: string): string | null;
53
61
  export declare function buildPackageManifestFromSources(input: PackageManifestBuildInput): PackageManifest;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DESCRIPTION_TARGET_WORD_COUNT = exports.DESCRIPTION_PROMPT_VERSION = exports.SCHEMA_VERSION = exports.PACKAGE_MANIFEST_REL_PATH = exports.PACKAGE_MANIFEST_FILENAME = void 0;
4
4
  exports.packageManifestGcsPath = packageManifestGcsPath;
5
5
  exports.resolvePreviewUrl = resolvePreviewUrl;
6
+ exports.deriveLessonId = deriveLessonId;
6
7
  exports.extractReadmeH1 = extractReadmeH1;
7
8
  exports.normalizeReadmeTitle = normalizeReadmeTitle;
8
9
  exports.buildPackageManifestFromSources = buildPackageManifestFromSources;
@@ -49,6 +50,13 @@ function findSyllabusLesson(exerciseSlug, lessons) {
49
50
  return candidates.includes(exerciseSlug);
50
51
  });
51
52
  }
53
+ /**
54
+ * Lesson id when no syllabus entry matches: the numeric prefix of the folder.
55
+ *
56
+ * Exported because `legacyPackageRepair` has to synthesize syllabus ids with
57
+ * exactly this rule — a repaired package whose ids differed from the ones its
58
+ * manifest already carries would look like a content change to every consumer.
59
+ */
52
60
  function deriveLessonId(exerciseSlug) {
53
61
  const match = exerciseSlug.match(/^(\d+(?:\.\d+)?)/);
54
62
  return match ? match[1] : exerciseSlug;