@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.
- package/lib/scripts/descriptionsS3Backfill.js +142 -22
- package/lib/utils/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
- package/lib/utils/descriptions/publishStage.js +14 -3
- package/lib/utils/descriptions/resumePublication.js +17 -2
- package/lib/utils/descriptions/s3Storage.js +13 -6
- package/lib/utils/packageManifest.d.ts +8 -0
- package/lib/utils/packageManifest.js +8 -0
- package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -0
- package/lib/utils/s3/packageManifestBackfill.js +3 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/package.json +3 -1
- package/src/scripts/README.md +244 -0
- package/src/scripts/descriptionsS3Backfill.ts +188 -20
- package/src/utils/awsCredentials.ts +57 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
- package/src/utils/descriptions/publishStage.ts +394 -382
- package/src/utils/descriptions/resumePublication.ts +217 -200
- package/src/utils/descriptions/s3Storage.ts +214 -206
- package/src/utils/packageManifest.ts +8 -1
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
|
@@ -13,31 +13,52 @@
|
|
|
13
13
|
* advance through the catalogue.
|
|
14
14
|
* - Or target a single course with --slug (remediation / on-demand).
|
|
15
15
|
*
|
|
16
|
-
* Writes descriptions into the published initialSyllabus.json.
|
|
17
|
-
* projection is a separate pass
|
|
18
|
-
* (runBatch) afterwards, which
|
|
16
|
+
* Writes descriptions into the published initialSyllabus.json. By default the
|
|
17
|
+
* manifest projection is a separate pass: run the package-manifest backfill
|
|
18
|
+
* (runBatch) afterwards, which over the whole catalogue is cheaper than
|
|
19
|
+
* re-projecting course by course.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* That default inverts for small, targeted runs, where three flags reproduce per
|
|
22
|
+
* course what a publication does, in one process instead of three:
|
|
23
|
+
*
|
|
24
|
+
* --reproject-manifest projects each course as it is described (the same
|
|
25
|
+
* `processPackage` the separate pass runs, plus a
|
|
26
|
+
* CloudFront invalidation);
|
|
27
|
+
* --mirror-draft copies the new descriptions back into the GCS draft,
|
|
28
|
+
* so the next publication does not regenerate them;
|
|
29
|
+
* --emit-events announces the result to breathecode.
|
|
30
|
+
*
|
|
31
|
+
* Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), BREATHECODE_SYSTEM_TOKEN
|
|
32
|
+
* (required by --emit-events), GCP_CREDENTIALS_JSON and GCP_BUCKET_NAME (required
|
|
33
|
+
* by --mirror-draft), plus S3_PACKAGES_BUCKET, AWS_REGION and
|
|
34
|
+
* CLOUDFRONT_DISTRIBUTION_ID as fallbacks/optionals.
|
|
22
35
|
*
|
|
23
36
|
* Per-run flags:
|
|
24
|
-
* --s3-bucket <name>
|
|
25
|
-
* --
|
|
26
|
-
* --
|
|
27
|
-
* --
|
|
28
|
-
* --
|
|
29
|
-
* --
|
|
30
|
-
* --
|
|
31
|
-
* --
|
|
32
|
-
* --
|
|
37
|
+
* --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
|
|
38
|
+
* --gcs-bucket <name> GCS draft bucket for --mirror-draft (or env GCP_BUCKET_NAME)
|
|
39
|
+
* --region <region> AWS region (default us-east-1, or env AWS_REGION)
|
|
40
|
+
* --slug <slug> process a single course (remediation / on-demand)
|
|
41
|
+
* --limit <n> courses per run (default 50)
|
|
42
|
+
* --target-words <n> words per description (default 25)
|
|
43
|
+
* --concurrency <n> completions in flight per course (default 5)
|
|
44
|
+
* --dry-run preview without writing/generating
|
|
45
|
+
* --no-reconcile skip the additive syllabus reconciliation
|
|
46
|
+
* --force regenerate even courses already settled at this prompt version
|
|
47
|
+
* --reproject-manifest re-project package-manifest.json per course, and invalidate it
|
|
48
|
+
* --mirror-draft mirror the descriptions back into the GCS draft
|
|
49
|
+
* --emit-events send package_manifest_updated per course (implies --reproject-manifest)
|
|
33
50
|
*
|
|
34
51
|
* Examples:
|
|
35
52
|
* node lib/scripts/descriptionsS3Backfill.js --limit 50
|
|
36
53
|
* node lib/scripts/descriptionsS3Backfill.js --slug my-course
|
|
37
54
|
* node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
|
|
55
|
+
* node lib/scripts/descriptionsS3Backfill.js --slug my-course \
|
|
56
|
+
* --reproject-manifest --mirror-draft --emit-events
|
|
38
57
|
*/
|
|
39
58
|
import { parseArgs } from "node:util"
|
|
40
59
|
import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"
|
|
60
|
+
import { CloudFrontClient } from "@aws-sdk/client-cloudfront"
|
|
61
|
+
import { Storage } from "@google-cloud/storage"
|
|
41
62
|
import { DESCRIPTION_TARGET_WORD_COUNT } from "../utils/packageManifest"
|
|
42
63
|
import { AwsClient } from "../utils/s3/packageManifestBackfill"
|
|
43
64
|
import {
|
|
@@ -47,7 +68,16 @@ import {
|
|
|
47
68
|
import {
|
|
48
69
|
createS3DescriptionsStorage,
|
|
49
70
|
fetchExercises,
|
|
71
|
+
S3DescriptionsStorageOptions,
|
|
50
72
|
} from "../utils/descriptions/s3Storage"
|
|
73
|
+
import {
|
|
74
|
+
BackfillEventOutcome,
|
|
75
|
+
emitBackfillManifestEvent,
|
|
76
|
+
newBackfillRunId,
|
|
77
|
+
} from "../utils/descriptions/backfillEvents"
|
|
78
|
+
import { createGcsDescriptionsStorage } from "../utils/descriptions/gcsStorage"
|
|
79
|
+
import { mirrorDescriptionsToDraft } from "../utils/descriptions/mirrorDescriptions"
|
|
80
|
+
import { requireGcsBucketName } from "../utils/gcsBucketName"
|
|
51
81
|
import { isCourseSettled } from "../utils/descriptions/workList"
|
|
52
82
|
|
|
53
83
|
const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/
|
|
@@ -86,10 +116,33 @@ async function listCoursesByRecency(
|
|
|
86
116
|
.map(([slug]) => slug)
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
/**
|
|
120
|
+
* CDN invalidation for the re-projected manifests. Optional: a stale edge cache
|
|
121
|
+
* expires on its own, so a missing distribution id degrades the run instead of
|
|
122
|
+
* stopping it.
|
|
123
|
+
*/
|
|
124
|
+
function cloudFrontFor(
|
|
125
|
+
region: string
|
|
126
|
+
): S3DescriptionsStorageOptions["cloudFront"] {
|
|
127
|
+
const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim()
|
|
128
|
+
if (!distributionId) {
|
|
129
|
+
console.warn(
|
|
130
|
+
"[s3-backfill] CLOUDFRONT_DISTRIBUTION_ID is not set: manifests will be re-projected but not invalidated"
|
|
131
|
+
)
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
client: new CloudFrontClient({ region }) as unknown as AwsClient,
|
|
137
|
+
distributionId,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
89
141
|
async function main(): Promise<void> {
|
|
90
142
|
const { values } = parseArgs({
|
|
91
143
|
options: {
|
|
92
144
|
"s3-bucket": { type: "string" },
|
|
145
|
+
"gcs-bucket": { type: "string" },
|
|
93
146
|
region: { type: "string" },
|
|
94
147
|
slug: { type: "string" },
|
|
95
148
|
limit: { type: "string" },
|
|
@@ -98,6 +151,9 @@ async function main(): Promise<void> {
|
|
|
98
151
|
"dry-run": { type: "boolean", default: false },
|
|
99
152
|
"no-reconcile": { type: "boolean", default: false },
|
|
100
153
|
force: { type: "boolean", default: false },
|
|
154
|
+
"reproject-manifest": { type: "boolean", default: false },
|
|
155
|
+
"mirror-draft": { type: "boolean", default: false },
|
|
156
|
+
"emit-events": { type: "boolean", default: false },
|
|
101
157
|
},
|
|
102
158
|
})
|
|
103
159
|
|
|
@@ -116,12 +172,69 @@ async function main(): Promise<void> {
|
|
|
116
172
|
const limit = Number.parseInt(values.limit || "50", 10) || 50
|
|
117
173
|
const dryRun = values["dry-run"] ?? false
|
|
118
174
|
const force = values.force ?? false
|
|
175
|
+
const emitEvents = values["emit-events"] ?? false
|
|
176
|
+
const reprojectManifest = values["reproject-manifest"] ?? false
|
|
177
|
+
const mirrorDraft = values["mirror-draft"] ?? false
|
|
178
|
+
|
|
179
|
+
// The event announces that the manifest changed, so emitting it without
|
|
180
|
+
// projecting the manifest would state something untrue. Required rather than
|
|
181
|
+
// implied: turning on a write to S3 and the CDN as a side effect of another
|
|
182
|
+
// flag is exactly the kind of surprise a backfill should not have.
|
|
183
|
+
if (emitEvents && !reprojectManifest) {
|
|
184
|
+
console.error(
|
|
185
|
+
"[s3-backfill] --emit-events requires --reproject-manifest: the event " +
|
|
186
|
+
"reports a manifest update that would not have happened"
|
|
187
|
+
)
|
|
188
|
+
process.exit(1)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN
|
|
192
|
+
if (emitEvents && !breathecodeToken) {
|
|
193
|
+
console.error(
|
|
194
|
+
"[s3-backfill] BREATHECODE_SYSTEM_TOKEN (env) is required by --emit-events"
|
|
195
|
+
)
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (emitEvents && dryRun) {
|
|
200
|
+
console.error(
|
|
201
|
+
"[s3-backfill] --emit-events cannot be combined with --dry-run: there is " +
|
|
202
|
+
"no dry run of an event that breathecode already received"
|
|
203
|
+
)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Built up front, not on first use: a misconfigured draft bucket must fail at
|
|
208
|
+
// boot, not after the first course has already been billed to Rigobot.
|
|
209
|
+
let draftStorage
|
|
210
|
+
if (mirrorDraft) {
|
|
211
|
+
const credentialsEnv = process.env.GCP_CREDENTIALS_JSON
|
|
212
|
+
if (!credentialsEnv) {
|
|
213
|
+
console.error(
|
|
214
|
+
"[s3-backfill] GCP_CREDENTIALS_JSON (env) is required by --mirror-draft"
|
|
215
|
+
)
|
|
216
|
+
process.exit(1)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
draftStorage = createGcsDescriptionsStorage(
|
|
220
|
+
new Storage({ credentials: JSON.parse(credentialsEnv) }).bucket(
|
|
221
|
+
values["gcs-bucket"] || requireGcsBucketName()
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
}
|
|
119
225
|
|
|
120
226
|
const s3 = new S3Client({ region }) as unknown as AwsClient
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
227
|
+
const storage = createS3DescriptionsStorage(
|
|
228
|
+
s3,
|
|
229
|
+
bucket,
|
|
230
|
+
// Off by default: over the whole catalogue manifests are cheaper to project
|
|
231
|
+
// in a single later pass than course by course.
|
|
232
|
+
reprojectManifest ?
|
|
233
|
+
{ cloudFront: cloudFrontFor(region) } :
|
|
234
|
+
{ reprojectManifest: false }
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const runId = newBackfillRunId()
|
|
125
238
|
|
|
126
239
|
const singleSlug = values.slug
|
|
127
240
|
const slugs = singleSlug ?
|
|
@@ -131,12 +244,22 @@ async function main(): Promise<void> {
|
|
|
131
244
|
console.log(
|
|
132
245
|
`[s3-backfill] Starting${
|
|
133
246
|
singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`
|
|
134
|
-
}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)
|
|
247
|
+
}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)${
|
|
248
|
+
reprojectManifest ? ", re-projecting manifests" : ""
|
|
249
|
+
}${mirrorDraft ? ", mirroring into the draft" : ""}${
|
|
250
|
+
emitEvents ? `, run ${runId}` : ""
|
|
251
|
+
}`
|
|
135
252
|
)
|
|
136
253
|
|
|
137
254
|
let processed = 0
|
|
138
255
|
let generated = 0
|
|
139
256
|
let failed = 0
|
|
257
|
+
let mirrored = 0
|
|
258
|
+
const events: Record<BackfillEventOutcome, number> = {
|
|
259
|
+
skipped: 0,
|
|
260
|
+
delivered: 0,
|
|
261
|
+
failed: 0,
|
|
262
|
+
}
|
|
140
263
|
|
|
141
264
|
for (const slug of slugs) {
|
|
142
265
|
if (!singleSlug && processed >= limit) {
|
|
@@ -185,9 +308,50 @@ async function main(): Promise<void> {
|
|
|
185
308
|
result.failed > 0 ? `, ${result.failed} failed` : ""
|
|
186
309
|
}${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`
|
|
187
310
|
)
|
|
311
|
+
|
|
312
|
+
if (draftStorage) {
|
|
313
|
+
// Before the event, so a course is fully settled by the time it is
|
|
314
|
+
// announced. A failure here does NOT change the event status: the
|
|
315
|
+
// published package and its manifest are already correct, and only the
|
|
316
|
+
// draft lags behind — the next publication regenerates what is missing.
|
|
317
|
+
try {
|
|
318
|
+
const published = await storage.readSyllabus(slug)
|
|
319
|
+
const mirror = await mirrorDescriptionsToDraft(
|
|
320
|
+
draftStorage,
|
|
321
|
+
slug,
|
|
322
|
+
published,
|
|
323
|
+
{ dryRun }
|
|
324
|
+
)
|
|
325
|
+
mirrored += mirror.copied
|
|
326
|
+
console.log(
|
|
327
|
+
`[s3-backfill] "${slug}": ${mirror.copied} mirrored, ${mirror.missed} missed, ${mirror.fresh} already fresh`
|
|
328
|
+
)
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(
|
|
331
|
+
`[s3-backfill] Could not mirror "${slug}" into the draft:`,
|
|
332
|
+
(error as Error).message
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (emitEvents) {
|
|
338
|
+
const outcome = await emitBackfillManifestEvent({
|
|
339
|
+
courseSlug: slug,
|
|
340
|
+
runId,
|
|
341
|
+
result,
|
|
342
|
+
storage,
|
|
343
|
+
rigobotToken: token,
|
|
344
|
+
breathecodeToken: breathecodeToken as string,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
events[outcome] += 1
|
|
348
|
+
console.log(`[s3-backfill] "${slug}": manifest event ${outcome}`)
|
|
349
|
+
}
|
|
188
350
|
} catch (error) {
|
|
189
351
|
processed += 1
|
|
190
352
|
failed += 1
|
|
353
|
+
// No event here on purpose: unlike a publication, a backfill never
|
|
354
|
+
// announced one, so nothing is owed. The course is simply re-run.
|
|
191
355
|
console.error(
|
|
192
356
|
`[s3-backfill] Failed processing "${slug}":`,
|
|
193
357
|
(error as Error).message
|
|
@@ -196,7 +360,11 @@ async function main(): Promise<void> {
|
|
|
196
360
|
}
|
|
197
361
|
|
|
198
362
|
console.log(
|
|
199
|
-
`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).`
|
|
363
|
+
`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).` +
|
|
364
|
+
(mirrorDraft ? ` ${mirrored} mirrored into the draft.` : "") +
|
|
365
|
+
(emitEvents ?
|
|
366
|
+
` Events: ${events.delivered} delivered, ${events.failed} failed (run ${runId}).` :
|
|
367
|
+
"")
|
|
200
368
|
)
|
|
201
369
|
}
|
|
202
370
|
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
|
|
15
|
+
export type AwsCredentials = {
|
|
16
|
+
accessKeyId: string;
|
|
17
|
+
secretAccessKey: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function requireAwsCredentials(): AwsCredentials {
|
|
21
|
+
const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim()
|
|
22
|
+
const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim()
|
|
23
|
+
|
|
24
|
+
const missing: string[] = []
|
|
25
|
+
if (!accessKeyId) {
|
|
26
|
+
missing.push("AWS_ACCESS_KEY_ID")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!secretAccessKey) {
|
|
30
|
+
missing.push("AWS_SECRET_ACCESS_KEY")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (missing.length > 0) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${missing.join(" and ")} (env) ${
|
|
36
|
+
missing.length > 1 ? "are" : "is"
|
|
37
|
+
} required to reach the published package bucket`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { accessKeyId, secretAccessKey }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function requireS3PackagesBucket(): string {
|
|
45
|
+
const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim()
|
|
46
|
+
if (!bucket) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages"
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return bucket
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function awsRegion(): string {
|
|
56
|
+
return (process.env.AWS_REGION || "").trim() || "us-east-1"
|
|
57
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid"
|
|
2
|
+
import { PackageManifest } from "../packageManifest"
|
|
3
|
+
import {
|
|
4
|
+
buildPackageManifestUpdatedEvent,
|
|
5
|
+
fetchPackageInfo,
|
|
6
|
+
sendPublishEvent,
|
|
7
|
+
} from "../publishEvents"
|
|
8
|
+
import {
|
|
9
|
+
CourseDescriptionsStorage,
|
|
10
|
+
GenerateCourseDescriptionsResult,
|
|
11
|
+
} from "./generateCourseDescriptions"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `package_manifest_updated` for courses reconciled by the backfill.
|
|
15
|
+
*
|
|
16
|
+
* The backfill regenerates descriptions of packages that are already published,
|
|
17
|
+
* so breathecode needs the same notification a publication sends — but there is
|
|
18
|
+
* no publication here: no `package_published` precedes this event, and no
|
|
19
|
+
* journal tracks it (the sweep resumes publications, and a backfill is simply
|
|
20
|
+
* re-run instead).
|
|
21
|
+
*
|
|
22
|
+
* That is what makes `publish_id` awkward. The field exists to correlate the two
|
|
23
|
+
* events of one publication, a pair this event has no half of. Since it is
|
|
24
|
+
* required, the value has to be something that cannot be mistaken for a real
|
|
25
|
+
* publication: a bare uuid — the obvious choice — would be exactly that mistake,
|
|
26
|
+
* and if breathecode ever starts pairing events it would search forever for a
|
|
27
|
+
* `package_published` that was never sent.
|
|
28
|
+
*
|
|
29
|
+
* Hence `backfill-{runId}-{slug}`:
|
|
30
|
+
*
|
|
31
|
+
* - the `backfill-` prefix makes the origin readable in the webhook log and
|
|
32
|
+
* discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
|
|
33
|
+
* `createJournal`, so the two spaces cannot collide;
|
|
34
|
+
* - `{slug}` keeps it unique per event, which a per-run id alone would not be;
|
|
35
|
+
* - a `{runId}` shared by every course of one invocation makes "every event from
|
|
36
|
+
* the run I fired at 15:40" a single substring query, which is the question
|
|
37
|
+
* you actually ask when the catalogue is backfilled in small batches.
|
|
38
|
+
*
|
|
39
|
+
* Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
|
|
40
|
+
* event contract untouched: breathecode ignores `publish_id` today, so this
|
|
41
|
+
* costs them nothing and needs no coordination. If they ever start consuming the
|
|
42
|
+
* provenance, it can be promoted to a field of its own.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export const BACKFILL_PUBLISH_ID_PREFIX = "backfill-"
|
|
46
|
+
|
|
47
|
+
/** One per script invocation, shared by every course it touches. */
|
|
48
|
+
export function newBackfillRunId(): string {
|
|
49
|
+
return uuidv4()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function backfillPublishId(runId: string, courseSlug: string): string {
|
|
53
|
+
return `${BACKFILL_PUBLISH_ID_PREFIX}${runId}-${courseSlug}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isBackfillPublishId(publishId: string): boolean {
|
|
57
|
+
return publishId.startsWith(BACKFILL_PUBLISH_ID_PREFIX)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `skipped` means the event was never attempted, which is not a failure: a
|
|
62
|
+
* course the backfill did not change has nothing to announce.
|
|
63
|
+
*/
|
|
64
|
+
export type BackfillEventOutcome = "skipped" | "delivered" | "failed";
|
|
65
|
+
|
|
66
|
+
export type EmitBackfillManifestEventParams = {
|
|
67
|
+
courseSlug: string;
|
|
68
|
+
runId: string;
|
|
69
|
+
result: GenerateCourseDescriptionsResult;
|
|
70
|
+
storage: CourseDescriptionsStorage;
|
|
71
|
+
rigobotToken: string;
|
|
72
|
+
breathecodeToken: string;
|
|
73
|
+
/** Injection points for tests. */
|
|
74
|
+
emit?: typeof sendPublishEvent;
|
|
75
|
+
readPackageInfo?: typeof fetchPackageInfo;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Announce one reconciled course. Never throws: the descriptions and the
|
|
80
|
+
* manifest are already written by the time this runs, and losing the
|
|
81
|
+
* notification must not turn a successful course into a failed one.
|
|
82
|
+
*/
|
|
83
|
+
export async function emitBackfillManifestEvent(
|
|
84
|
+
params: EmitBackfillManifestEventParams
|
|
85
|
+
): Promise<BackfillEventOutcome> {
|
|
86
|
+
const { courseSlug, result } = params
|
|
87
|
+
|
|
88
|
+
if (result.status === "skipped") {
|
|
89
|
+
return "skipped"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const emit = params.emit ?? sendPublishEvent
|
|
93
|
+
const readPackageInfo = params.readPackageInfo ?? fetchPackageInfo
|
|
94
|
+
|
|
95
|
+
// Enriching the payload must never prevent the delivery: both the manifest and
|
|
96
|
+
// the package info are optional in the contract, and an event with less
|
|
97
|
+
// context beats an event that never arrives.
|
|
98
|
+
let manifest: PackageManifest | null = null
|
|
99
|
+
try {
|
|
100
|
+
if (params.storage.readManifest) {
|
|
101
|
+
manifest = await params.storage.readManifest(courseSlug)
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(
|
|
105
|
+
`[backfill-events] Could not read the manifest of "${courseSlug}", reporting without it:`,
|
|
106
|
+
(error as Error).message
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let packageInfo: Record<string, unknown> | null = null
|
|
111
|
+
try {
|
|
112
|
+
packageInfo = await readPackageInfo(courseSlug, params.rigobotToken)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(
|
|
115
|
+
`[backfill-events] Could not read the package "${courseSlug}", reporting without it:`,
|
|
116
|
+
(error as Error).message
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const delivered = await emit(
|
|
122
|
+
buildPackageManifestUpdatedEvent(
|
|
123
|
+
{
|
|
124
|
+
publishId: backfillPublishId(params.runId, courseSlug),
|
|
125
|
+
courseSlug,
|
|
126
|
+
packageInfo,
|
|
127
|
+
manifest,
|
|
128
|
+
},
|
|
129
|
+
// A failed projection does not fail the run — the syllabus is saved
|
|
130
|
+
// either way — but this event reports the manifest, so it cannot claim
|
|
131
|
+
// success over one that was never rewritten.
|
|
132
|
+
result.status === "failed" || result.manifestProjected === false ?
|
|
133
|
+
"failed" :
|
|
134
|
+
"success",
|
|
135
|
+
{
|
|
136
|
+
generated: result.generated,
|
|
137
|
+
failed: result.failed,
|
|
138
|
+
missing: result.missing,
|
|
139
|
+
}
|
|
140
|
+
),
|
|
141
|
+
params.breathecodeToken
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return delivered ? "delivered" : "failed"
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(
|
|
147
|
+
`[backfill-events] Could not deliver the manifest event for "${courseSlug}":`,
|
|
148
|
+
(error as Error).message
|
|
149
|
+
)
|
|
150
|
+
return "failed"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -79,6 +79,13 @@ export type GenerateCourseDescriptionsResult = {
|
|
|
79
79
|
errors: string[];
|
|
80
80
|
/** Completion durations, to watch how close we run to the request limits. */
|
|
81
81
|
durationsSeconds: number[];
|
|
82
|
+
/**
|
|
83
|
+
* Whether `package-manifest.json` was re-projected; null when the storage does
|
|
84
|
+
* not project at all. A failed projection is not a failed run — the syllabus,
|
|
85
|
+
* which is the source of truth, is already saved — but callers that announce
|
|
86
|
+
* the manifest downstream must not report success on it.
|
|
87
|
+
*/
|
|
88
|
+
manifestProjected: boolean | null;
|
|
82
89
|
};
|
|
83
90
|
|
|
84
91
|
function emptyResult(
|
|
@@ -92,6 +99,7 @@ function emptyResult(
|
|
|
92
99
|
missing: 0,
|
|
93
100
|
errors: [],
|
|
94
101
|
durationsSeconds: [],
|
|
102
|
+
manifestProjected: null,
|
|
95
103
|
}
|
|
96
104
|
}
|
|
97
105
|
|
|
@@ -288,9 +296,11 @@ export async function generateCourseDescriptions(
|
|
|
288
296
|
if (storage.reprojectManifest) {
|
|
289
297
|
try {
|
|
290
298
|
await storage.reprojectManifest(courseSlug)
|
|
299
|
+
result.manifestProjected = true
|
|
291
300
|
} catch (error) {
|
|
292
301
|
// The syllabus (the source of truth) is already saved; a failed
|
|
293
302
|
// projection is recoverable and must not lose the generated text.
|
|
303
|
+
result.manifestProjected = false
|
|
294
304
|
result.errors.push(
|
|
295
305
|
`manifest projection failed: ${(error as Error).message}`
|
|
296
306
|
)
|