@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
@@ -1,200 +1,217 @@
1
- import {
2
- abandonJournal,
3
- finalizeJournal,
4
- incrementAttempts,
5
- isJournalStale,
6
- JournalStorage,
7
- markStage,
8
- PublishJournal,
9
- } from "../publishJournal"
10
- import { PackageManifest } from "../packageManifest"
11
- import {
12
- buildPackageManifestUpdatedEvent,
13
- fetchPackageInfo,
14
- sendPublishEvent,
15
- } from "../publishEvents"
16
- import {
17
- CourseDescriptionsStorage,
18
- GenerateCourseDescriptionsOptions,
19
- } from "./generateCourseDescriptions"
20
- import { runPublishDescriptionsStage } from "./publishStage"
21
-
22
- /**
23
- * Recovery of a publication whose background stage never finished.
24
- *
25
- * The stage runs inside the web process, so a dyno restart mid-flight leaves a
26
- * package published but not described, and — worse — breathecode waiting for a
27
- * manifest event that was promised and will never arrive. Content-wise the
28
- * missing descriptions would eventually be picked up by the next publication,
29
- * but the promised event has no such second chance: emitting it is the reason
30
- * this exists.
31
- */
32
-
33
- export const MAX_SWEEP_ATTEMPTS = 3
34
-
35
- /** Only resume publications nobody is working on any more. */
36
- export const DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000
37
-
38
- export type ResumeOutcome =
39
- | "skipped-recent"
40
- | "skipped-abandoned"
41
- | "abandoned"
42
- | "resumed"
43
- | "nothing-to-do";
44
-
45
- export type ResumeDeps = {
46
- journalStorage: JournalStorage;
47
- publishedStorage: CourseDescriptionsStorage;
48
- draftStorage: CourseDescriptionsStorage;
49
- rigobotToken: string;
50
- breathecodeToken?: string;
51
- staleAfterMs?: number;
52
- now?: number;
53
- /** Injection points for tests. */
54
- emit?: typeof sendPublishEvent;
55
- readPackageInfo?: typeof fetchPackageInfo;
56
- generate?: GenerateCourseDescriptionsOptions["generate"];
57
- };
58
-
59
- function isDone(
60
- journal: PublishJournal,
61
- stage: "descriptions" | "gcsMirror" | "manifestEvent"
62
- ): boolean {
63
- return journal.stages[stage].status === "done"
64
- }
65
-
66
- /**
67
- * Whether breathecode is still owed a manifest event: one was promised (the
68
- * publish event announced `queued`) and it never went out.
69
- */
70
- export function owesManifestEvent(journal: PublishJournal): boolean {
71
- const promised =
72
- (
73
- journal.stages.publishedEvent.meta as
74
- | { descriptions?: string }
75
- | undefined
76
- )?.descriptions === "queued"
77
- return promised && !isDone(journal, "manifestEvent")
78
- }
79
-
80
- export async function resumePublication(
81
- journal: PublishJournal,
82
- deps: ResumeDeps
83
- ): Promise<ResumeOutcome> {
84
- const { journalStorage } = deps
85
- const now = deps.now ?? Date.now()
86
-
87
- if (journal.abandoned) {
88
- return "skipped-abandoned"
89
- }
90
-
91
- // A publication still in flight would race with its own stage.
92
- if (
93
- !isJournalStale(journal, deps.staleAfterMs ?? DEFAULT_STALE_AFTER_MS, now)
94
- ) {
95
- return "skipped-recent"
96
- }
97
-
98
- const needsDescriptions =
99
- !isDone(journal, "descriptions") || !isDone(journal, "gcsMirror")
100
- const needsEvent = owesManifestEvent(journal)
101
-
102
- if (!needsDescriptions && !needsEvent) {
103
- // Nothing left that the sweep can do; stage A failures are not its job.
104
- await finalizeJournal(journalStorage, journal)
105
- return "nothing-to-do"
106
- }
107
-
108
- if (journal.attempts >= MAX_SWEEP_ATTEMPTS) {
109
- await abandonJournal(journalStorage, journal)
110
- console.error(
111
- `[sweep] Giving up on "${journal.courseSlug}" (${journal.publishId}) after ${journal.attempts} attempts`
112
- )
113
- return "abandoned"
114
- }
115
-
116
- await incrementAttempts(journalStorage, journal)
117
-
118
- let succeeded = true
119
- let stats: { generated: number; failed: number; missing: number } | undefined
120
-
121
- if (needsDescriptions) {
122
- const outcome = await runPublishDescriptionsStage({
123
- courseSlug: journal.courseSlug,
124
- rigobotToken: deps.rigobotToken,
125
- journal,
126
- journalStorage,
127
- publishedStorage: deps.publishedStorage,
128
- draftStorage: deps.draftStorage,
129
- generate: deps.generate,
130
- })
131
- succeeded = outcome.succeeded
132
- if (outcome.generation) {
133
- stats = {
134
- generated: outcome.generation.generated,
135
- failed: outcome.generation.failed,
136
- missing: outcome.generation.missing,
137
- }
138
- }
139
- }
140
-
141
- if (needsEvent) {
142
- await emitPendingManifestEvent(journal, deps, succeeded, stats)
143
- }
144
-
145
- await finalizeJournal(journalStorage, journal)
146
- return "resumed"
147
- }
148
-
149
- async function emitPendingManifestEvent(
150
- journal: PublishJournal,
151
- deps: ResumeDeps,
152
- succeeded: boolean,
153
- stats?: { generated: number; failed: number; missing: number }
154
- ): Promise<void> {
155
- const emit = deps.emit ?? sendPublishEvent
156
- const readPackageInfo = deps.readPackageInfo ?? fetchPackageInfo
157
-
158
- if (!deps.breathecodeToken) {
159
- await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
160
- error: "BREATHECODE_SYSTEM_TOKEN is not configured",
161
- })
162
- return
163
- }
164
-
165
- try {
166
- let manifest: PackageManifest | null = null
167
- if (deps.publishedStorage.readManifest) {
168
- manifest = await deps.publishedStorage.readManifest(journal.courseSlug)
169
- }
170
-
171
- const delivered = await emit(
172
- buildPackageManifestUpdatedEvent(
173
- {
174
- publishId: journal.publishId,
175
- courseSlug: journal.courseSlug,
176
- packageInfo: await readPackageInfo(
177
- journal.courseSlug,
178
- deps.rigobotToken
179
- ),
180
- manifest,
181
- },
182
- succeeded ? "success" : "failed",
183
- stats
184
- ),
185
- deps.breathecodeToken
186
- )
187
-
188
- await markStage(
189
- deps.journalStorage,
190
- journal,
191
- "manifestEvent",
192
- delivered ? "done" : "failed",
193
- { error: delivered ? undefined : "delivery failed" }
194
- )
195
- } catch (error) {
196
- await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
197
- error: (error as Error).message,
198
- })
199
- }
200
- }
1
+ import {
2
+ abandonJournal,
3
+ finalizeJournal,
4
+ incrementAttempts,
5
+ isJournalStale,
6
+ JournalStorage,
7
+ markStage,
8
+ PublishJournal,
9
+ } from "../publishJournal"
10
+ import { PackageManifest } from "../packageManifest"
11
+ import {
12
+ buildPackageManifestUpdatedEvent,
13
+ fetchPackageInfo,
14
+ sendPublishEvent,
15
+ } from "../publishEvents"
16
+ import {
17
+ CourseDescriptionsStorage,
18
+ GenerateCourseDescriptionsOptions,
19
+ } from "./generateCourseDescriptions"
20
+ import { runPublishDescriptionsStage } from "./publishStage"
21
+
22
+ /**
23
+ * Recovery of a publication whose background stage never finished.
24
+ *
25
+ * The stage runs inside the web process, so a dyno restart mid-flight leaves a
26
+ * package published but not described, and — worse — breathecode waiting for a
27
+ * manifest event that was promised and will never arrive. Content-wise the
28
+ * missing descriptions would eventually be picked up by the next publication,
29
+ * but the promised event has no such second chance: emitting it is the reason
30
+ * this exists.
31
+ */
32
+
33
+ export const MAX_SWEEP_ATTEMPTS = 3
34
+
35
+ /** Only resume publications nobody is working on any more. */
36
+ export const DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000
37
+
38
+ export type ResumeOutcome =
39
+ | "skipped-recent"
40
+ | "skipped-abandoned"
41
+ | "abandoned"
42
+ | "resumed"
43
+ | "nothing-to-do";
44
+
45
+ export type ResumeDeps = {
46
+ journalStorage: JournalStorage;
47
+ publishedStorage: CourseDescriptionsStorage;
48
+ draftStorage: CourseDescriptionsStorage;
49
+ rigobotToken: string;
50
+ breathecodeToken?: string;
51
+ staleAfterMs?: number;
52
+ now?: number;
53
+ /** Injection points for tests. */
54
+ emit?: typeof sendPublishEvent;
55
+ readPackageInfo?: typeof fetchPackageInfo;
56
+ generate?: GenerateCourseDescriptionsOptions["generate"];
57
+ };
58
+
59
+ function isDone(
60
+ journal: PublishJournal,
61
+ stage: "descriptions" | "gcsMirror" | "manifestEvent"
62
+ ): boolean {
63
+ return journal.stages[stage].status === "done"
64
+ }
65
+
66
+ /**
67
+ * Whether breathecode is still owed a manifest event: one was promised (the
68
+ * publish event announced `queued`) and it never went out.
69
+ */
70
+ export function owesManifestEvent(journal: PublishJournal): boolean {
71
+ const promised =
72
+ (
73
+ journal.stages.publishedEvent.meta as
74
+ | { descriptions?: string }
75
+ | undefined
76
+ )?.descriptions === "queued"
77
+ return promised && !isDone(journal, "manifestEvent")
78
+ }
79
+
80
+ export async function resumePublication(
81
+ journal: PublishJournal,
82
+ deps: ResumeDeps
83
+ ): Promise<ResumeOutcome> {
84
+ const { journalStorage } = deps
85
+ const now = deps.now ?? Date.now()
86
+
87
+ if (journal.abandoned) {
88
+ return "skipped-abandoned"
89
+ }
90
+
91
+ // A publication still in flight would race with its own stage.
92
+ if (
93
+ !isJournalStale(journal, deps.staleAfterMs ?? DEFAULT_STALE_AFTER_MS, now)
94
+ ) {
95
+ return "skipped-recent"
96
+ }
97
+
98
+ const needsDescriptions =
99
+ !isDone(journal, "descriptions") || !isDone(journal, "gcsMirror")
100
+ const needsEvent = owesManifestEvent(journal)
101
+
102
+ if (!needsDescriptions && !needsEvent) {
103
+ // Nothing left that the sweep can do; stage A failures are not its job.
104
+ await finalizeJournal(journalStorage, journal)
105
+ return "nothing-to-do"
106
+ }
107
+
108
+ if (journal.attempts >= MAX_SWEEP_ATTEMPTS) {
109
+ await abandonJournal(journalStorage, journal)
110
+ console.error(
111
+ `[sweep] Giving up on "${journal.courseSlug}" (${journal.publishId}) after ${journal.attempts} attempts`
112
+ )
113
+ return "abandoned"
114
+ }
115
+
116
+ await incrementAttempts(journalStorage, journal)
117
+
118
+ let succeeded = true
119
+ let stats: { generated: number; failed: number; missing: number } | undefined
120
+
121
+ if (needsDescriptions) {
122
+ const outcome = await runPublishDescriptionsStage({
123
+ courseSlug: journal.courseSlug,
124
+ rigobotToken: deps.rigobotToken,
125
+ journal,
126
+ journalStorage,
127
+ publishedStorage: deps.publishedStorage,
128
+ draftStorage: deps.draftStorage,
129
+ generate: deps.generate,
130
+ })
131
+ succeeded = outcome.succeeded
132
+ if (outcome.generation) {
133
+ stats = {
134
+ generated: outcome.generation.generated,
135
+ failed: outcome.generation.failed,
136
+ missing: outcome.generation.missing,
137
+ }
138
+ }
139
+ }
140
+
141
+ if (needsEvent) {
142
+ await emitPendingManifestEvent(journal, deps, succeeded, stats)
143
+ }
144
+
145
+ await finalizeJournal(journalStorage, journal)
146
+ return "resumed"
147
+ }
148
+
149
+ async function emitPendingManifestEvent(
150
+ journal: PublishJournal,
151
+ deps: ResumeDeps,
152
+ succeeded: boolean,
153
+ stats?: { generated: number; failed: number; missing: number }
154
+ ): Promise<void> {
155
+ const emit = deps.emit ?? sendPublishEvent
156
+ const readPackageInfo = deps.readPackageInfo ?? fetchPackageInfo
157
+
158
+ if (!deps.breathecodeToken) {
159
+ await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
160
+ error: "BREATHECODE_SYSTEM_TOKEN is not configured",
161
+ })
162
+ return
163
+ }
164
+
165
+ // Enriching the payload must never prevent the delivery: this event is the
166
+ // one breathecode has been waiting for since the publication stalled, and
167
+ // both the manifest and the package info are optional in the contract.
168
+ let manifest: PackageManifest | null = null
169
+ let packageInfo: Record<string, unknown> | null = null
170
+ try {
171
+ if (deps.publishedStorage.readManifest) {
172
+ manifest = await deps.publishedStorage.readManifest(journal.courseSlug)
173
+ }
174
+ } catch (error) {
175
+ console.error(
176
+ `[sweep] Could not read the manifest of "${journal.courseSlug}", reporting without it:`,
177
+ (error as Error).message
178
+ )
179
+ }
180
+
181
+ try {
182
+ packageInfo = await readPackageInfo(journal.courseSlug, deps.rigobotToken)
183
+ } catch (error) {
184
+ console.error(
185
+ `[sweep] Could not read the package of "${journal.courseSlug}", reporting without it:`,
186
+ (error as Error).message
187
+ )
188
+ }
189
+
190
+ try {
191
+ const delivered = await emit(
192
+ buildPackageManifestUpdatedEvent(
193
+ {
194
+ publishId: journal.publishId,
195
+ courseSlug: journal.courseSlug,
196
+ packageInfo,
197
+ manifest,
198
+ },
199
+ succeeded ? "success" : "failed",
200
+ stats
201
+ ),
202
+ deps.breathecodeToken
203
+ )
204
+
205
+ await markStage(
206
+ deps.journalStorage,
207
+ journal,
208
+ "manifestEvent",
209
+ delivered ? "done" : "failed",
210
+ { error: delivered ? undefined : "delivery failed" }
211
+ )
212
+ } catch (error) {
213
+ await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
214
+ error: (error as Error).message,
215
+ })
216
+ }
217
+ }