@learnpack/learnpack 5.0.353 → 5.0.355

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 (35) hide show
  1. package/lib/commands/serve.js +1 -1
  2. package/lib/scripts/descriptionsS3Backfill.js +142 -22
  3. package/lib/utils/awsCredentials.d.ts +20 -0
  4. package/lib/utils/awsCredentials.js +43 -0
  5. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  6. package/lib/utils/descriptions/backfillEvents.js +107 -0
  7. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
  8. package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
  9. package/lib/utils/descriptions/publishStage.js +14 -3
  10. package/lib/utils/descriptions/resumePublication.js +17 -2
  11. package/lib/utils/descriptions/s3Storage.js +13 -6
  12. package/lib/utils/packageManifest.d.ts +8 -0
  13. package/lib/utils/packageManifest.js +8 -0
  14. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  15. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  16. package/lib/utils/repair/repairStorage.d.ts +68 -0
  17. package/lib/utils/repair/repairStorage.js +89 -0
  18. package/lib/utils/s3/packageManifestBackfill.js +3 -8
  19. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  20. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  21. package/package.json +3 -1
  22. package/src/commands/serve.ts +1 -1
  23. package/src/scripts/README.md +244 -0
  24. package/src/scripts/descriptionsS3Backfill.ts +188 -20
  25. package/src/utils/awsCredentials.ts +57 -0
  26. package/src/utils/descriptions/backfillEvents.ts +152 -0
  27. package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
  28. package/src/utils/descriptions/publishStage.ts +394 -382
  29. package/src/utils/descriptions/resumePublication.ts +217 -200
  30. package/src/utils/descriptions/s3Storage.ts +214 -206
  31. package/src/utils/packageManifest.ts +8 -1
  32. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  33. package/src/utils/repair/repairStorage.ts +168 -0
  34. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  35. package/src/utils/s3/packageSourcesAudit.ts +311 -0
@@ -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
  )