@learnpack/learnpack 5.0.329 → 5.0.331

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.
@@ -0,0 +1,281 @@
1
+ import type { RedisClientType } from "redis"
2
+ import * as fs from "fs"
3
+ import * as path from "path"
4
+
5
+ interface StateData {
6
+ content: string;
7
+ timestamp: number;
8
+ }
9
+
10
+ export interface HistoryResult {
11
+ success: boolean;
12
+ newVersion?: string;
13
+ content?: string;
14
+ error?: string;
15
+ }
16
+
17
+ export interface HistoryStatus {
18
+ canUndo: boolean;
19
+ canRedo: boolean;
20
+ version: string;
21
+ }
22
+
23
+ export class HistoryManager {
24
+ private redis: RedisClientType
25
+ private saveStateLua: string
26
+ private undoLua: string
27
+ private redoLua: string
28
+
29
+ private readonly TTL = "432000" // 5 days in seconds
30
+ private readonly LIMIT = 5 // Keep last 5 states
31
+
32
+ constructor(redisClient: RedisClientType) {
33
+ this.redis = redisClient
34
+
35
+ // Load Lua scripts
36
+ const luaDir = path.join(__dirname, "../lua")
37
+ this.saveStateLua = fs.readFileSync(
38
+ path.join(luaDir, "saveState.lua"),
39
+ "utf8"
40
+ )
41
+ this.undoLua = fs.readFileSync(path.join(luaDir, "undo.lua"), "utf8")
42
+ this.redoLua = fs.readFileSync(path.join(luaDir, "redo.lua"), "utf8")
43
+ }
44
+
45
+ /**
46
+ * Builds Redis keys for an exercise in a specific language
47
+ * @param courseSlug - The course slug identifier
48
+ * @param exerciseSlug - The exercise slug identifier
49
+ * @param lang - The language code
50
+ * @returns Object containing undo, redo, and version keys
51
+ */
52
+ private getKeys(courseSlug: string, exerciseSlug: string, lang: string) {
53
+ const baseKey = `${courseSlug}:${exerciseSlug}:${lang}`
54
+ return {
55
+ undo: `undo:${baseKey}`,
56
+ redo: `redo:${baseKey}`,
57
+ version: `undo:${baseKey}:version`,
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Saves a new state in history
63
+ * @param courseSlug - The course slug identifier
64
+ * @param exerciseSlug - The exercise slug identifier
65
+ * @param lang - The language code
66
+ * @param content - The content of the state
67
+ * @param versionId - The version ID
68
+ * @returns Object containing success, newVersion, and error
69
+ */
70
+ async saveState(
71
+ courseSlug: string,
72
+ exerciseSlug: string,
73
+ lang: string,
74
+ content: string,
75
+ versionId: string
76
+ ): Promise<HistoryResult> {
77
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang)
78
+
79
+ const state: StateData = {
80
+ content,
81
+ timestamp: Date.now(),
82
+ }
83
+
84
+ const stateJson = JSON.stringify(state)
85
+
86
+ try {
87
+ // Official Redis client syntax: eval(script, { keys: [...], arguments: [...] })
88
+ const result: any = await this.redis.eval(this.saveStateLua, {
89
+ keys: [keys.undo, keys.redo, keys.version],
90
+ arguments: [
91
+ stateJson,
92
+ versionId,
93
+ this.TTL.toString(),
94
+ this.LIMIT.toString(),
95
+ ],
96
+ })
97
+
98
+ // Check if it's an error response (table with err field)
99
+ if (result && typeof result === "object" && result.err) {
100
+ return { success: false, error: result.err }
101
+ }
102
+
103
+ // Success response is a string (the new version)
104
+ const newVersion = typeof result === "string" ? result : String(result)
105
+ return { success: true, newVersion }
106
+ } catch (error: any) {
107
+ console.error("⏮️ HISTORY: Error saving state to Redis:", error)
108
+ return { success: false, error: error.message }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Performs an undo operation
114
+ * @param courseSlug - The course slug identifier
115
+ * @param exerciseSlug - The exercise slug identifier
116
+ * @param lang - The language code
117
+ * @param currentContent - The current content of the state
118
+ * @param versionId - The version ID
119
+ * @returns Object containing success, newVersion, and error
120
+ */
121
+ async undo(
122
+ courseSlug: string,
123
+ exerciseSlug: string,
124
+ lang: string,
125
+ currentContent: string,
126
+ versionId: string
127
+ ): Promise<HistoryResult> {
128
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang)
129
+
130
+ const currentState: StateData = {
131
+ content: currentContent,
132
+ timestamp: Date.now(),
133
+ }
134
+
135
+ try {
136
+ const result: any = await this.redis.eval(this.undoLua, {
137
+ keys: [keys.undo, keys.redo, keys.version],
138
+ arguments: [
139
+ JSON.stringify(currentState),
140
+ versionId,
141
+ this.TTL.toString(),
142
+ ],
143
+ })
144
+
145
+ // Check if it's an error response (table with err field)
146
+ if (result && typeof result === "object" && result.err) {
147
+ return { success: false, error: result.err }
148
+ }
149
+
150
+ // Success response is an array: [newVersion, previousState]
151
+ if (!Array.isArray(result) || result.length < 2) {
152
+ console.error(
153
+ "⏮️ HISTORY: Invalid response from undo script:",
154
+ result
155
+ )
156
+ return { success: false, error: "INVALID_RESPONSE" }
157
+ }
158
+
159
+ const [newVersion, previousStateJson] = result
160
+
161
+ if (!previousStateJson) {
162
+ console.error("⏮️ HISTORY: No state returned from undo script")
163
+ return { success: false, error: "NO_STATE_RETURNED" }
164
+ }
165
+
166
+ const previousState: StateData = JSON.parse(previousStateJson)
167
+
168
+ return {
169
+ success: true,
170
+ newVersion: String(newVersion),
171
+ content: previousState.content,
172
+ }
173
+ } catch (error: any) {
174
+ console.error("⏮️ HISTORY: Error performing undo:", error)
175
+ return { success: false, error: error.message }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Performs a redo operation
181
+ * @param courseSlug - The course slug identifier
182
+ * @param exerciseSlug - The exercise slug identifier
183
+ * @param lang - The language code
184
+ * @param currentContent - The current content of the state
185
+ * @param versionId - The version ID
186
+ * @returns Object containing success, newVersion, and error
187
+ */
188
+ async redo(
189
+ courseSlug: string,
190
+ exerciseSlug: string,
191
+ lang: string,
192
+ currentContent: string,
193
+ versionId: string
194
+ ): Promise<HistoryResult> {
195
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang)
196
+
197
+ const currentState: StateData = {
198
+ content: currentContent,
199
+ timestamp: Date.now(),
200
+ }
201
+
202
+ try {
203
+ const result: any = await this.redis.eval(this.redoLua, {
204
+ keys: [keys.undo, keys.redo, keys.version],
205
+ arguments: [
206
+ JSON.stringify(currentState),
207
+ versionId,
208
+ this.TTL.toString(),
209
+ ],
210
+ })
211
+
212
+ // Check if it's an error response (table with err field)
213
+ if (result && typeof result === "object" && result.err) {
214
+ return { success: false, error: result.err }
215
+ }
216
+
217
+ // Success response is an array: [newVersion, futureState]
218
+ if (!Array.isArray(result) || result.length < 2) {
219
+ console.error(
220
+ "⏮️ HISTORY: Invalid response from redo script:",
221
+ result
222
+ )
223
+ return { success: false, error: "INVALID_RESPONSE" }
224
+ }
225
+
226
+ const [newVersion, futureStateJson] = result
227
+
228
+ if (!futureStateJson) {
229
+ console.error("⏮️ HISTORY: No state returned from redo script")
230
+ return { success: false, error: "NO_STATE_RETURNED" }
231
+ }
232
+
233
+ const futureState: StateData = JSON.parse(futureStateJson)
234
+
235
+ return {
236
+ success: true,
237
+ newVersion: String(newVersion),
238
+ content: futureState.content,
239
+ }
240
+ } catch (error: any) {
241
+ console.error("⏮️ HISTORY: Error performing redo:", error)
242
+ return { success: false, error: error.message }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Gets the history status (can undo/redo, current version)
248
+ * @param courseSlug - The course slug identifier
249
+ * @param exerciseSlug - The exercise slug identifier
250
+ * @param lang - The language code
251
+ * @returns Object containing canUndo, canRedo, and version
252
+ */
253
+ async getHistoryStatus(
254
+ courseSlug: string,
255
+ exerciseSlug: string,
256
+ lang: string
257
+ ): Promise<HistoryStatus> {
258
+ const keys = this.getKeys(courseSlug, exerciseSlug, lang)
259
+
260
+ try {
261
+ const [undoLen, redoLen, version] = await Promise.all([
262
+ this.redis.lLen(keys.undo),
263
+ this.redis.lLen(keys.redo),
264
+ this.redis.get(keys.version),
265
+ ])
266
+
267
+ return {
268
+ canUndo: undoLen >= 1, // Need at least 1 (we save state BEFORE changes)
269
+ canRedo: redoLen > 0,
270
+ version: version || "0",
271
+ }
272
+ } catch (error) {
273
+ console.error("⏮️ HISTORY: Error getting history status:", error)
274
+ return {
275
+ canUndo: false,
276
+ canRedo: false,
277
+ version: "0",
278
+ }
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,284 @@
1
+ import type { Bucket } from "@google-cloud/storage"
2
+ import type { HistoryManager } from "./historyManager"
3
+
4
+ export interface SaveReadmeWithHistoryOptions {
5
+ courseSlug: string;
6
+ exerciseSlug: string;
7
+ lang: string;
8
+ fileName: string;
9
+ newContent: string;
10
+ previousContent?: string; // Optional: if you already have it, pass it
11
+ currentVersion?: string; // Optional: if you already have it, pass it
12
+ throwOnConflict?: boolean; // true = throw error, false = only log
13
+ }
14
+
15
+ export interface SaveReadmeResult {
16
+ success: boolean;
17
+ newVersion: string;
18
+ error?: string;
19
+ }
20
+
21
+ /**
22
+ * Service to handle README file updates with automatic history tracking
23
+ * Encapsulates the logic of saving state to history before updating files
24
+ */
25
+ export class ReadmeHistoryService {
26
+ private historyManager: HistoryManager | null
27
+ private bucket: Bucket
28
+
29
+ /**
30
+ * Constructor for the ReadmeHistoryService
31
+ * @param historyManager - The history manager to use
32
+ * @param bucket - The bucket to use
33
+ */
34
+ constructor(historyManager: HistoryManager | null, bucket: Bucket) {
35
+ this.historyManager = historyManager
36
+ this.bucket = bucket
37
+ }
38
+
39
+ /**
40
+ * Saves a README file to GCS with automatic history tracking
41
+ *
42
+ * Workflow:
43
+ * 1. If previousContent is not provided, downloads it from GCS
44
+ * 2. If currentVersion is not provided, gets it from history
45
+ * 3. Saves the PREVIOUS state to history (for undo)
46
+ * 4. Saves the NEW content to GCS
47
+ *
48
+ * @param options - Options for the saveReadmeWithHistory method
49
+ * @returns Result with success status and new version
50
+ */
51
+ async saveReadmeWithHistory(
52
+ options: SaveReadmeWithHistoryOptions
53
+ ): Promise<SaveReadmeResult> {
54
+ const {
55
+ courseSlug,
56
+ exerciseSlug,
57
+ lang,
58
+ fileName,
59
+ newContent,
60
+ previousContent: providedPreviousContent,
61
+ currentVersion: providedVersion,
62
+ throwOnConflict = false,
63
+ } = options
64
+
65
+ // Guard: Only process README files
66
+ if (!fileName.startsWith("README")) {
67
+ console.warn(
68
+ `⚠️ ReadmeHistoryService called for non-README file: ${fileName}`
69
+ )
70
+ return { success: false, newVersion: "0", error: "NOT_README_FILE" }
71
+ }
72
+
73
+ // Guard: History manager not available
74
+ if (!this.historyManager) {
75
+ console.warn("⚠️ History manager not available, skipping history")
76
+ // Still save the file without history
77
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent)
78
+ return {
79
+ success: false,
80
+ newVersion: "0",
81
+ error: "HISTORY_NOT_AVAILABLE",
82
+ }
83
+ }
84
+
85
+ try {
86
+ // Step 1: Get previous content if not provided
87
+ const previousContent =
88
+ providedPreviousContent ??
89
+ (await this.getPreviousContent(courseSlug, exerciseSlug, fileName))
90
+
91
+ // If there's no previous content, we can't save to history
92
+ // (this is the first version of the file)
93
+ if (!previousContent) {
94
+ console.log(
95
+ `📝 HISTORY: First version of ${fileName} for ${lang}, skipping history`
96
+ )
97
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent)
98
+ return {
99
+ success: false,
100
+ newVersion: "0",
101
+ error: "NO_PREVIOUS_CONTENT",
102
+ }
103
+ }
104
+
105
+ // Step 2: Get current version if not provided
106
+ const currentVersion =
107
+ providedVersion ??
108
+ (await this.getCurrentVersion(courseSlug, exerciseSlug, lang))
109
+
110
+ // Step 3: Save to history BEFORE updating the file
111
+ const historyResult = await this.historyManager.saveState(
112
+ courseSlug,
113
+ exerciseSlug,
114
+ lang,
115
+ previousContent, // Save the content BEFORE the change
116
+ currentVersion
117
+ )
118
+
119
+ // Step 4: Handle history save result
120
+ if (!historyResult.success) {
121
+ // VERSION_CONFLICT: Another user/process modified the file
122
+ if (historyResult.error === "VERSION_CONFLICT") {
123
+ const message =
124
+ "Version conflict detected. Content may have been modified by another process."
125
+
126
+ if (throwOnConflict) {
127
+ throw new Error(message)
128
+ } else {
129
+ console.error(`⏮️ HISTORY: ${message}`)
130
+ // Still save the file despite conflict
131
+ await this.saveToGCS(
132
+ courseSlug,
133
+ exerciseSlug,
134
+ fileName,
135
+ newContent
136
+ )
137
+ return {
138
+ success: false,
139
+ newVersion: currentVersion,
140
+ error: "VERSION_CONFLICT",
141
+ }
142
+ }
143
+ }
144
+
145
+ // Other errors: log but don't fail
146
+ console.error(
147
+ `⏮️ HISTORY: Failed to save history (non-critical): ${historyResult.error}`
148
+ )
149
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent)
150
+ return {
151
+ success: false,
152
+ newVersion: currentVersion,
153
+ error: historyResult.error,
154
+ }
155
+ }
156
+
157
+ // Step 5: History saved successfully, now save the new content
158
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent)
159
+
160
+ console.log(
161
+ `✅ HISTORY: Saved ${fileName} (${lang}) - version ${historyResult.newVersion}`
162
+ )
163
+
164
+ return {
165
+ success: true,
166
+ newVersion: historyResult.newVersion!,
167
+ }
168
+ } catch (error: any) {
169
+ const errorMessage = error.message || "Unknown error"
170
+ console.error(
171
+ `❌ HISTORY: Error in saveReadmeWithHistory: ${errorMessage}`
172
+ )
173
+
174
+ // Try to save the file anyway (best effort)
175
+ try {
176
+ await this.saveToGCS(courseSlug, exerciseSlug, fileName, newContent)
177
+ } catch (saveError) {
178
+ console.error("❌ Failed to save file after history error:", saveError)
179
+ }
180
+
181
+ return {
182
+ success: false,
183
+ newVersion: "0",
184
+ error: errorMessage,
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Gets the previous content of a README from GCS
191
+ * Returns undefined if file doesn't exist (first version)
192
+ * @param courseSlug - The slug of the course
193
+ * @param exerciseSlug - The slug of the exercise
194
+ * @param fileName - The name of the file
195
+ * @returns The previous content of the file or undefined if it doesn't exist
196
+ */
197
+ private async getPreviousContent(
198
+ courseSlug: string,
199
+ exerciseSlug: string,
200
+ fileName: string
201
+ ): Promise<string | undefined> {
202
+ try {
203
+ const filePath = `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`
204
+ const [content] = await this.bucket.file(filePath).download()
205
+ return content.toString()
206
+ } catch (error: any) {
207
+ // File doesn't exist (404) - this is normal for first save
208
+ if (error.code === 404) {
209
+ return undefined
210
+ }
211
+
212
+ console.error(`⚠️ Error downloading previous content: ${error.message}`)
213
+ return undefined
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Gets the current version from history
219
+ * @param courseSlug - The slug of the course
220
+ * @param exerciseSlug - The slug of the exercise
221
+ * @param lang - The language of the file
222
+ * @returns The current version of the file
223
+ */
224
+ private async getCurrentVersion(
225
+ courseSlug: string,
226
+ exerciseSlug: string,
227
+ lang: string
228
+ ): Promise<string> {
229
+ if (!this.historyManager) {
230
+ return "0"
231
+ }
232
+
233
+ try {
234
+ const status = await this.historyManager.getHistoryStatus(
235
+ courseSlug,
236
+ exerciseSlug,
237
+ lang
238
+ )
239
+ return status.version
240
+ } catch (error) {
241
+ console.error(
242
+ "⚠️ Error getting current version, defaulting to '0':",
243
+ error
244
+ )
245
+ return "0"
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Saves content to Google Cloud Storage
251
+ * @param courseSlug - The slug of the course
252
+ * @param exerciseSlug - The slug of the exercise
253
+ * @param fileName - The name of the file
254
+ * @param content - The content to save
255
+ * @returns void
256
+ */
257
+ private async saveToGCS(
258
+ courseSlug: string,
259
+ exerciseSlug: string,
260
+ fileName: string,
261
+ content: string
262
+ ): Promise<void> {
263
+ const filePath = `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`
264
+ const file = this.bucket.file(filePath)
265
+
266
+ await file.save(content, {
267
+ resumable: false,
268
+ })
269
+ }
270
+
271
+ /**
272
+ * Helper to get README extension from language code
273
+ * @param lang - The language of the file
274
+ * @returns The name of the README file
275
+ */
276
+ static getReadmeFileName(lang: string): string {
277
+ if (lang === "en") {
278
+ return "README.md"
279
+ }
280
+
281
+ // es -> README.es.md, fr -> README.fr.md
282
+ return `README.${lang}.md`
283
+ }
284
+ }