@learnpack/learnpack 5.0.328 → 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.
@@ -75,6 +75,10 @@ import {
75
75
  NotFoundError,
76
76
  } from "../utils/errorHandler"
77
77
  import { JSDOM } from "jsdom"
78
+ import { createClient } from "redis"
79
+ import type { RedisClientType } from "redis"
80
+ import { HistoryManager } from "../managers/historyManager"
81
+ import { ReadmeHistoryService } from "../managers/readmeHistoryService"
78
82
 
79
83
  const frontMatter = require("front-matter")
80
84
 
@@ -796,7 +800,8 @@ async function processSyncTranslationsSequentially(
796
800
  sourceReadmeContent: string,
797
801
  targetLanguages: string[],
798
802
  rigoToken: string,
799
- bucket: Bucket
803
+ bucket: Bucket,
804
+ historyManager: HistoryManager | null
800
805
  ) {
801
806
  try {
802
807
  // Process translations sequentially (no race conditions)
@@ -829,14 +834,33 @@ async function processSyncTranslationsSequentially(
829
834
  throw new Error("Translation result is empty")
830
835
  }
831
836
 
832
- // Save translated README
833
- const readmePath = `courses/${courseSlug}/exercises/${exerciseSlug}/README${getReadmeExtension(
834
- targetLang
835
- )}`
837
+ // Save translated README with history tracking
838
+ const fileName = ReadmeHistoryService.getReadmeFileName(targetLang)
839
+ const readmeHistoryService = new ReadmeHistoryService(
840
+ historyManager,
841
+ bucket
842
+ )
843
+
836
844
  // eslint-disable-next-line no-await-in-loop
837
- await bucket
838
- .file(readmePath)
839
- .save(translationResult.parsed.translation)
845
+ const historyResult = await readmeHistoryService.saveReadmeWithHistory({
846
+ courseSlug,
847
+ exerciseSlug,
848
+ lang: targetLang,
849
+ fileName,
850
+ newContent: translationResult.parsed.translation,
851
+ // Don't pass previousContent or currentVersion - let service fetch them
852
+ throwOnConflict: false, // Don't fail sync on version conflicts
853
+ })
854
+
855
+ if (historyResult.success) {
856
+ console.log(
857
+ `🔄 SYNC: Saved ${targetLang} translation with history (version ${historyResult.newVersion})`
858
+ )
859
+ } else {
860
+ console.warn(
861
+ `🔄 SYNC: Translation saved but history failed for ${targetLang}: ${historyResult.error}`
862
+ )
863
+ }
840
864
 
841
865
  // Update progress in syllabus
842
866
  // eslint-disable-next-line no-await-in-loop
@@ -969,9 +993,11 @@ async function processSyncTranslationsSequentially(
969
993
  }
970
994
  }
971
995
 
972
- export default class ServeCommand extends SessionCommand {
996
+ class ServeCommand extends SessionCommand {
973
997
  static description = "Runs a small server to build tutorials"
974
998
 
999
+ private redis: ReturnType<typeof createClient> | null = null
1000
+
975
1001
  static flags = {
976
1002
  ...SessionCommand.flags,
977
1003
  port: flags.string({ char: "p", description: "server port" }),
@@ -1015,6 +1041,64 @@ export default class ServeCommand extends SessionCommand {
1015
1041
  console.log("INFO: HOST is set to", host)
1016
1042
  }
1017
1043
 
1044
+ // Initialize Redis (official client)
1045
+ const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
1046
+
1047
+ if (
1048
+ process.env.NODE_ENV === "production" &&
1049
+ redisUrl === "redis://localhost:6379"
1050
+ ) {
1051
+ console.error("❌ REDIS_URL not configured for production environment!")
1052
+ console.warn("⚠️ History features will be unavailable")
1053
+ this.redis = null
1054
+ } else {
1055
+ try {
1056
+ const useTLS = redisUrl.startsWith("rediss://")
1057
+
1058
+ this.redis = createClient({
1059
+ url: redisUrl,
1060
+ socket: {
1061
+ tls: useTLS,
1062
+ rejectUnauthorized: useTLS,
1063
+ reconnectStrategy: (retries: number) => {
1064
+ if (retries > 10) {
1065
+ console.error("❌ Too many Redis reconnection attempts")
1066
+ return new Error("Too many retries")
1067
+ }
1068
+
1069
+ // Exponential backoff: 50ms, 100ms, 200ms, ...
1070
+ const delay = Math.min(retries * 50, 2000)
1071
+ return delay
1072
+ },
1073
+ },
1074
+ })
1075
+
1076
+ this.redis.on("error", (err: Error) => {
1077
+ console.error("❌ Redis error:", err)
1078
+ })
1079
+
1080
+ this.redis.on("connect", () => {
1081
+ console.log("🔄 Connecting to Redis...")
1082
+ })
1083
+
1084
+ this.redis.on("ready", () => {
1085
+ console.log("✅ Connected to Redis")
1086
+ })
1087
+
1088
+ // Connect the client
1089
+ await this.redis.connect()
1090
+ } catch (redisError) {
1091
+ console.error("❌ Failed to connect to Redis:", redisError)
1092
+ console.warn("⚠️ History features (undo/redo) will be unavailable")
1093
+ this.redis = null
1094
+ }
1095
+ }
1096
+
1097
+ // Create HistoryManager instance if Redis is available
1098
+ const historyManager = this.redis ?
1099
+ new HistoryManager(this.redis as RedisClientType) :
1100
+ null
1101
+
1018
1102
  // async function listFilesWithPrefix(prefix: string) {
1019
1103
  // const [files] = await bucket.getFiles({ prefix })
1020
1104
  // return files
@@ -2573,11 +2657,15 @@ export default class ServeCommand extends SessionCommand {
2573
2657
 
2574
2658
  app.put(
2575
2659
  "/exercise/:slug/file/:fileName",
2576
- express.text(),
2660
+ express.json(),
2577
2661
  asyncHandler(async (req, res) => {
2578
2662
  const { slug, fileName } = req.params
2579
2663
  const query = req.query
2580
2664
  const courseSlug = query.slug as string
2665
+ const lang = (query.lang as string) || "en"
2666
+
2667
+ // Headers for history management
2668
+ const versionId = (req.headers["x-history-version"] as string) || "0"
2581
2669
 
2582
2670
  console.log(`PUT /exercise/${slug}/file/${fileName}`)
2583
2671
 
@@ -2592,21 +2680,64 @@ export default class ServeCommand extends SessionCommand {
2592
2680
  throw new ValidationError("File name and exercise slug are required")
2593
2681
  }
2594
2682
 
2683
+ // Extract content from body (can be text or JSON)
2684
+ let fileContent: string
2685
+ let contentToSaveInHistory: string | undefined
2686
+
2687
+ if (typeof req.body === "string") {
2688
+ // Old format: plain text
2689
+ fileContent = req.body
2690
+ } else if (req.body && typeof req.body === "object") {
2691
+ // New format: JSON with content and historyContent
2692
+ fileContent = req.body.content
2693
+ contentToSaveInHistory = req.body.historyContent
2694
+ } else {
2695
+ throw new ValidationError("Invalid request body format")
2696
+ }
2697
+
2595
2698
  try {
2596
- // Update the file in the bucket
2597
- const file = bucket.file(
2598
- `courses/${courseSlug}/exercises/${slug}/${fileName}`
2599
- )
2699
+ let newVersion = versionId
2700
+ let created = false
2600
2701
 
2601
- await file.save(req.body, {
2602
- resumable: false,
2603
- })
2702
+ // Use ReadmeHistoryService for README files with history support
2703
+ if (fileName.startsWith("README")) {
2704
+ const readmeHistoryService = new ReadmeHistoryService(
2705
+ historyManager,
2706
+ bucket
2707
+ )
2708
+
2709
+ const historyResult =
2710
+ await readmeHistoryService.saveReadmeWithHistory({
2711
+ courseSlug,
2712
+ exerciseSlug: slug,
2713
+ lang,
2714
+ fileName,
2715
+ newContent: fileContent,
2716
+ previousContent: contentToSaveInHistory,
2717
+ currentVersion: versionId,
2718
+ throwOnConflict: true, // Throw error on version conflicts for manual edits
2719
+ })
2720
+
2721
+ newVersion = historyResult.newVersion
2722
+ created = true // File was saved by the service
2723
+ } else {
2724
+ // For non-README files, save directly without history
2725
+ const file = bucket.file(
2726
+ `courses/${courseSlug}/exercises/${slug}/${fileName}`
2727
+ )
2728
+
2729
+ await file.save(fileContent, {
2730
+ resumable: false,
2731
+ })
2604
2732
 
2605
- const created = await file.exists()
2733
+ const [exists] = await file.exists()
2734
+ created = exists
2735
+ }
2606
2736
 
2607
- res.send({
2737
+ res.json({
2608
2738
  message: "File updated",
2609
2739
  created,
2740
+ version: newVersion,
2610
2741
  })
2611
2742
  } catch (error: any) {
2612
2743
  // Handle Google Cloud Storage rate limit errors (429)
@@ -2637,6 +2768,304 @@ export default class ServeCommand extends SessionCommand {
2637
2768
  }
2638
2769
  })
2639
2770
  )
2771
+
2772
+ // Remove code_challenge_proposals from all README files of an exercise
2773
+ app.post(
2774
+ "/exercise/:slug/remove-code-proposals",
2775
+ asyncHandler(async (req, res) => {
2776
+ const { slug } = req.params
2777
+ const { courseSlug } = req.body
2778
+
2779
+ // Validate required parameters
2780
+ if (!courseSlug) {
2781
+ throw new ValidationError("courseSlug is required in request body")
2782
+ }
2783
+
2784
+ console.log(
2785
+ `🧹 Cleaning code_challenge_proposals from exercise: ${slug} in course: ${courseSlug}`
2786
+ )
2787
+
2788
+ try {
2789
+ const exerciseDir = `courses/${courseSlug}/exercises/${slug}`
2790
+
2791
+ // Get all README files (all languages)
2792
+ const [readmeFiles] = await bucket.getFiles({
2793
+ prefix: `${exerciseDir}/README`,
2794
+ })
2795
+
2796
+ if (readmeFiles.length === 0) {
2797
+ return res.status(404).json({
2798
+ success: false,
2799
+ error: "No README files found for this exercise",
2800
+ })
2801
+ }
2802
+
2803
+ let cleanedCount = 0
2804
+ const processedFiles: string[] = []
2805
+
2806
+ // Process each README file
2807
+ for (const readmeFile of readmeFiles) {
2808
+ try {
2809
+ // eslint-disable-next-line no-await-in-loop
2810
+ const [readmeContent] = await readmeFile.download()
2811
+ const readmeText = readmeContent.toString()
2812
+
2813
+ // Remove code_challenge_proposal blocks, EXCEPT those in GENERATING state
2814
+ // The GENERATING state is identified by "GENERATING(" marker
2815
+ // Pattern: ```code_challenge_proposal ... ``` (but not if contains GENERATING)
2816
+ const codeProposalRegex =
2817
+ /(\r?\n)?```code_challenge_proposal(?![\S\s]*?GENERATING\()[\S\s]*?```(\r?\n)?/g
2818
+
2819
+ const updatedReadme = readmeText.replace(codeProposalRegex, "")
2820
+
2821
+ // Only update if there were actual changes
2822
+ if (updatedReadme.trim() !== readmeText.trim()) {
2823
+ // eslint-disable-next-line no-await-in-loop
2824
+ await readmeFile.save(updatedReadme.trim(), {
2825
+ resumable: false,
2826
+ })
2827
+
2828
+ cleanedCount++
2829
+ processedFiles.push(readmeFile.name)
2830
+ console.log(`✅ Cleaned proposals from: ${readmeFile.name}`)
2831
+ }
2832
+ } catch (readmeError) {
2833
+ console.error(
2834
+ `❌ Error cleaning README ${readmeFile.name}:`,
2835
+ readmeError
2836
+ )
2837
+ // Continue processing other files even if one fails
2838
+ }
2839
+ }
2840
+
2841
+ const message =
2842
+ cleanedCount > 0 ?
2843
+ `Successfully cleaned ${cleanedCount} README file(s)` :
2844
+ "No code challenge proposals found to clean"
2845
+
2846
+ console.log(`✅ ${message}`)
2847
+
2848
+ res.json({
2849
+ success: true,
2850
+ message,
2851
+ cleanedCount,
2852
+ processedFiles,
2853
+ })
2854
+ } catch (error: any) {
2855
+ console.error(
2856
+ "❌ Error during code challenge proposal cleanup:",
2857
+ error
2858
+ )
2859
+
2860
+ // Handle rate limit errors
2861
+ if (
2862
+ error.code === 429 ||
2863
+ error.message?.includes("rate limit") ||
2864
+ error.message?.includes("rateLimitExceeded")
2865
+ ) {
2866
+ throw new ConflictError(
2867
+ "Storage rate limit exceeded. Please try again in a few moments.",
2868
+ {
2869
+ code: "STORAGE_RATE_LIMIT",
2870
+ retryAfter: 60,
2871
+ }
2872
+ )
2873
+ }
2874
+
2875
+ throw error
2876
+ }
2877
+ })
2878
+ )
2879
+
2880
+ // GET /exercise/:slug/history/status
2881
+ // Get history status (can undo/redo, current version)
2882
+ app.get(
2883
+ "/exercise/:slug/history/status",
2884
+ asyncHandler(async (req, res) => {
2885
+ const { slug } = req.params
2886
+ const courseSlug = req.query.slug as string
2887
+ const lang = (req.query.lang as string) || "en"
2888
+
2889
+ if (!courseSlug) {
2890
+ throw new ValidationError("Course slug is required")
2891
+ }
2892
+
2893
+ if (!historyManager) {
2894
+ return res.json({
2895
+ canUndo: false,
2896
+ canRedo: false,
2897
+ version: "0",
2898
+ available: false,
2899
+ })
2900
+ }
2901
+
2902
+ const status = await historyManager.getHistoryStatus(
2903
+ courseSlug,
2904
+ slug,
2905
+ lang
2906
+ )
2907
+
2908
+ res.json({
2909
+ ...status,
2910
+ available: true,
2911
+ })
2912
+ })
2913
+ )
2914
+
2915
+ // POST /exercise/:slug/history/undo
2916
+ // Undo the last change
2917
+ app.post(
2918
+ "/exercise/:slug/history/undo",
2919
+ express.json(),
2920
+ asyncHandler(async (req, res) => {
2921
+ const { slug } = req.params
2922
+ const { courseSlug, currentContent, versionId, lang } = req.body
2923
+
2924
+ if (!courseSlug || !currentContent || !versionId) {
2925
+ throw new ValidationError(
2926
+ "Missing required fields: courseSlug, currentContent, versionId"
2927
+ )
2928
+ }
2929
+
2930
+ const language = lang || "en"
2931
+
2932
+ if (!historyManager) {
2933
+ console.error("⏮️ HISTORY: History manager not available")
2934
+ throw new InternalServerError("History service not available")
2935
+ }
2936
+
2937
+ const result = await historyManager.undo(
2938
+ courseSlug,
2939
+ slug,
2940
+ language,
2941
+ currentContent,
2942
+ versionId
2943
+ )
2944
+
2945
+ if (!result.success) {
2946
+ if (result.error === "VERSION_CONFLICT") {
2947
+ throw new ConflictError(
2948
+ "Version conflict. Please refresh and try again."
2949
+ )
2950
+ }
2951
+
2952
+ if (result.error === "NO_UNDO_AVAILABLE") {
2953
+ throw new ValidationError("No more changes to undo")
2954
+ }
2955
+
2956
+ throw new InternalServerError(
2957
+ `Undo operation failed: ${result.error}`
2958
+ )
2959
+ }
2960
+
2961
+ if (!result.content) {
2962
+ throw new InternalServerError(
2963
+ "No content returned from undo operation"
2964
+ )
2965
+ }
2966
+
2967
+ // Save the restored content to GCS
2968
+ const fileName = `README${getReadmeExtension(language)}`
2969
+ const file = bucket.file(
2970
+ `courses/${courseSlug}/exercises/${slug}/${fileName}`
2971
+ )
2972
+
2973
+ try {
2974
+ await file.save(result.content, {
2975
+ resumable: false,
2976
+ })
2977
+ } catch (gcsError) {
2978
+ console.error(
2979
+ "⏮️ HISTORY: Failed to save restored content to GCS:",
2980
+ gcsError
2981
+ )
2982
+ throw new InternalServerError("Failed to save restored content")
2983
+ }
2984
+
2985
+ res.json({
2986
+ content: result.content,
2987
+ version: result.newVersion,
2988
+ })
2989
+ })
2990
+ )
2991
+
2992
+ // POST /exercise/:slug/history/redo
2993
+ // Redo the last undone change
2994
+ app.post(
2995
+ "/exercise/:slug/history/redo",
2996
+ express.json(),
2997
+ asyncHandler(async (req, res) => {
2998
+ const { slug } = req.params
2999
+ const { courseSlug, currentContent, versionId, lang } = req.body
3000
+
3001
+ if (!courseSlug || !currentContent || !versionId) {
3002
+ throw new ValidationError(
3003
+ "Missing required fields: courseSlug, currentContent, versionId"
3004
+ )
3005
+ }
3006
+
3007
+ const language = lang || "en"
3008
+
3009
+ if (!historyManager) {
3010
+ console.error("⏮️ HISTORY: History manager not available")
3011
+ throw new InternalServerError("History service not available")
3012
+ }
3013
+
3014
+ const result = await historyManager.redo(
3015
+ courseSlug,
3016
+ slug,
3017
+ language,
3018
+ currentContent,
3019
+ versionId
3020
+ )
3021
+
3022
+ if (!result.success) {
3023
+ if (result.error === "VERSION_CONFLICT") {
3024
+ throw new ConflictError(
3025
+ "Version conflict. Please refresh and try again."
3026
+ )
3027
+ }
3028
+
3029
+ if (result.error === "NO_REDO_AVAILABLE") {
3030
+ throw new ValidationError("No more changes to redo")
3031
+ }
3032
+
3033
+ throw new InternalServerError(
3034
+ `Redo operation failed: ${result.error}`
3035
+ )
3036
+ }
3037
+
3038
+ if (!result.content) {
3039
+ throw new InternalServerError(
3040
+ "No content returned from redo operation"
3041
+ )
3042
+ }
3043
+
3044
+ // Save the restored content to GCS
3045
+ const fileName = `README${getReadmeExtension(language)}`
3046
+ const file = bucket.file(
3047
+ `courses/${courseSlug}/exercises/${slug}/${fileName}`
3048
+ )
3049
+
3050
+ try {
3051
+ await file.save(result.content, {
3052
+ resumable: false,
3053
+ })
3054
+ } catch (gcsError) {
3055
+ console.error(
3056
+ "⏮️ HISTORY: Failed to save restored content to GCS:",
3057
+ gcsError
3058
+ )
3059
+ throw new InternalServerError("Failed to save restored content")
3060
+ }
3061
+
3062
+ res.json({
3063
+ content: result.content,
3064
+ version: result.newVersion,
3065
+ })
3066
+ })
3067
+ )
3068
+
2640
3069
  // Create a new step for a course
2641
3070
  app.post("/course/:slug/create-step", async (req, res) => {
2642
3071
  console.log("POST /course/:slug/create-step")
@@ -3417,7 +3846,8 @@ export default class ServeCommand extends SessionCommand {
3417
3846
  sourceReadmeContent,
3418
3847
  targetLanguages,
3419
3848
  rigoToken,
3420
- bucket
3849
+ bucket,
3850
+ historyManager
3421
3851
  ).catch(error => {
3422
3852
  console.error("Error in sync translation processing:", error)
3423
3853
 
@@ -4668,3 +5098,5 @@ export default class ServeCommand extends SessionCommand {
4668
5098
  })
4669
5099
  }
4670
5100
  }
5101
+
5102
+ export default ServeCommand
@@ -0,0 +1,48 @@
1
+ -- Lua script to redo the last undone change
2
+ -- Implements Memento pattern with optimistic locking
3
+ --
4
+ -- KEYS[1]: undo:{course}:{exercise}:{lang}
5
+ -- KEYS[2]: redo:{course}:{exercise}:{lang}
6
+ -- KEYS[3]: undo:{course}:{exercise}:{lang}:version
7
+ -- ARGV[1]: current_state_json (state before redo, to save in undo)
8
+ -- ARGV[2]: version_id (current client version for optimistic locking)
9
+ -- ARGV[3]: ttl (432000 seconds = 5 days)
10
+
11
+ -- Get current version
12
+ local current_version = redis.call('GET', KEYS[3])
13
+
14
+ -- Verify version (optimistic locking)
15
+ if current_version ~= false and current_version ~= ARGV[2] then
16
+ return {err = 'VERSION_CONFLICT'}
17
+ end
18
+
19
+ -- Verify there are states to redo
20
+ local redo_length = redis.call('LLEN', KEYS[2])
21
+ if redo_length == 0 then
22
+ return {err = 'NO_REDO_AVAILABLE'}
23
+ end
24
+
25
+ -- Get future state (the most recent in redo stack)
26
+ local future_state = redis.call('LPOP', KEYS[2])
27
+
28
+ if not future_state then
29
+ return {err = 'NO_FUTURE_STATE'}
30
+ end
31
+
32
+ -- Save current state in undo stack
33
+ redis.call('LPUSH', KEYS[1], ARGV[1])
34
+
35
+ -- Maintain state limit in undo (last 5)
36
+ redis.call('LTRIM', KEYS[1], 0, 4)
37
+
38
+ -- Renew TTL of both stacks
39
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
40
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
41
+
42
+ -- Increment version and renew its TTL
43
+ local new_version = redis.call('INCR', KEYS[3])
44
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
45
+
46
+ -- Return array [newVersion, futureState] for compatibility with Redis client
47
+ return {tostring(new_version), future_state}
48
+
@@ -0,0 +1,40 @@
1
+ -- Lua script to save a new state in history
2
+ -- Implements Memento pattern with optimistic locking
3
+ -- NOTE: We save the state BEFORE changes, so users can undo to it
4
+ --
5
+ -- KEYS[1]: undo:{course}:{exercise}:{lang}
6
+ -- KEYS[2]: redo:{course}:{exercise}:{lang}
7
+ -- KEYS[3]: undo:{course}:{exercise}:{lang}:version
8
+ -- ARGV[1]: state_json (complete README BEFORE changes - serialized)
9
+ -- ARGV[2]: version_id (current client version for optimistic locking)
10
+ -- ARGV[3]: ttl (432000 seconds = 5 days)
11
+ -- ARGV[4]: limit (5 states maximum)
12
+
13
+ -- Get current version
14
+ local current_version = redis.call('GET', KEYS[3])
15
+
16
+ -- Verify version (optimistic locking)
17
+ -- If the sent version doesn't match the current one, reject
18
+ if current_version ~= false and current_version ~= ARGV[2] then
19
+ return {err = 'VERSION_CONFLICT'}
20
+ end
21
+
22
+ -- Save new state in undo stack (at the beginning)
23
+ redis.call('LPUSH', KEYS[1], ARGV[1])
24
+
25
+ -- Invalidate redo stack (new change invalidates the "alternative future")
26
+ redis.call('DEL', KEYS[2])
27
+
28
+ -- Keep only the last N states (0 to limit-1)
29
+ redis.call('LTRIM', KEYS[1], 0, tonumber(ARGV[4]) - 1)
30
+
31
+ -- Renew TTL of undo stack
32
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
33
+
34
+ -- Increment version and renew its TTL
35
+ local new_version = redis.call('INCR', KEYS[3])
36
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
37
+
38
+ -- Return new version (as string for compatibility with Redis client)
39
+ return tostring(new_version)
40
+
@@ -0,0 +1,50 @@
1
+ -- Lua script to undo the last change
2
+ -- Implements Memento pattern with optimistic locking
3
+ -- NOTE: Stack contains states BEFORE changes, so index 0 is what we want to restore
4
+ --
5
+ -- KEYS[1]: undo:{course}:{exercise}:{lang}
6
+ -- KEYS[2]: redo:{course}:{exercise}:{lang}
7
+ -- KEYS[3]: undo:{course}:{exercise}:{lang}:version
8
+ -- ARGV[1]: current_state_json (current state to save in redo for potential redo)
9
+ -- ARGV[2]: version_id (current client version for optimistic locking)
10
+ -- ARGV[3]: ttl (432000 seconds = 5 days)
11
+
12
+ -- Get current version
13
+ local current_version = redis.call('GET', KEYS[3])
14
+
15
+ -- Verify version (optimistic locking)
16
+ if current_version ~= false and current_version ~= ARGV[2] then
17
+ return {err = 'VERSION_CONFLICT'}
18
+ end
19
+
20
+ -- Verify there are states to undo
21
+ -- We need at least 1 element (the state before the current change)
22
+ local undo_length = redis.call('LLEN', KEYS[1])
23
+ if undo_length < 1 then
24
+ return {err = 'NO_UNDO_AVAILABLE'}
25
+ end
26
+
27
+ -- Get the saved state (index 0 - the state before current changes)
28
+ local previous_state = redis.call('LINDEX', KEYS[1], 0)
29
+
30
+ if not previous_state then
31
+ return {err = 'NO_PREVIOUS_STATE'}
32
+ end
33
+
34
+ -- Move current state to redo stack
35
+ redis.call('LPUSH', KEYS[2], ARGV[1])
36
+
37
+ -- Remove current state from undo stack (index 0)
38
+ redis.call('LPOP', KEYS[1])
39
+
40
+ -- Renew TTL of both stacks
41
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
42
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
43
+
44
+ -- Increment version and renew its TTL
45
+ local new_version = redis.call('INCR', KEYS[3])
46
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
47
+
48
+ -- Return array [newVersion, previousState] for compatibility with Redis client
49
+ return {tostring(new_version), previous_state}
50
+