diff --git a/libs/checkpoint-redis/.env.example b/libs/checkpoint-redis/.env.example new file mode 100644 index 000000000..aea660a4d --- /dev/null +++ b/libs/checkpoint-redis/.env.example @@ -0,0 +1,6 @@ +# ------------------LangSmith tracing------------------ +LANGCHAIN_TRACING_V2=true +LANGCHAIN_ENDPOINT="https://api.smith.langchain.com" +LANGCHAIN_API_KEY= +LANGCHAIN_PROJECT= +# ----------------------------------------------------- \ No newline at end of file diff --git a/libs/checkpoint-redis/.eslintrc.cjs b/libs/checkpoint-redis/.eslintrc.cjs new file mode 100644 index 000000000..02711dada --- /dev/null +++ b/libs/checkpoint-redis/.eslintrc.cjs @@ -0,0 +1,69 @@ +module.exports = { + extends: [ + "airbnb-base", + "eslint:recommended", + "prettier", + "plugin:@typescript-eslint/recommended", + ], + parserOptions: { + ecmaVersion: 12, + parser: "@typescript-eslint/parser", + project: "./tsconfig.json", + sourceType: "module", + }, + plugins: ["@typescript-eslint", "no-instanceof", "eslint-plugin-jest"], + ignorePatterns: [ + ".eslintrc.cjs", + "scripts", + "node_modules", + "dist", + "dist-cjs", + "*.js", + "*.cjs", + "*.d.ts", + ], + rules: { + "no-process-env": 2, + "no-instanceof/no-instanceof": 2, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/no-shadow": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-use-before-define": ["error", "nofunc"], + "@typescript-eslint/no-unused-vars": ["warn", { args: "none" }], + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + "arrow-body-style": 0, + camelcase: 0, + "class-methods-use-this": 0, + "import/extensions": [2, "ignorePackages"], + "import/no-extraneous-dependencies": [ + "error", + { devDependencies: ["**/*.test.ts"] }, + ], + "import/no-unresolved": 0, + "import/prefer-default-export": 0, + 'jest/no-focused-tests': 'error', + "keyword-spacing": "error", + "max-classes-per-file": 0, + "max-len": 0, + "no-await-in-loop": 0, + "no-bitwise": 0, + "no-console": 0, + "no-empty-function": 0, + "no-restricted-syntax": 0, + "no-shadow": 0, + "no-continue": 0, + "no-void": 0, + "no-underscore-dangle": 0, + "no-use-before-define": 0, + "no-useless-constructor": 0, + "no-return-await": 0, + "consistent-return": 0, + "no-else-return": 0, + "func-names": 0, + "no-lonely-if": 0, + "prefer-rest-params": 0, + "new-cap": ["error", { properties: false, capIsNew: false }], + }, +}; diff --git a/libs/checkpoint-redis/.gitignore b/libs/checkpoint-redis/.gitignore new file mode 100644 index 000000000..c10034e2f --- /dev/null +++ b/libs/checkpoint-redis/.gitignore @@ -0,0 +1,7 @@ +index.cjs +index.js +index.d.ts +index.d.cts +node_modules +dist +.yarn diff --git a/libs/checkpoint-redis/.prettierrc b/libs/checkpoint-redis/.prettierrc new file mode 100644 index 000000000..ba08ff04f --- /dev/null +++ b/libs/checkpoint-redis/.prettierrc @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always", + "requirePragma": false, + "insertPragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "vueIndentScriptAndStyle": false, + "endOfLine": "lf" +} diff --git a/libs/checkpoint-redis/.release-it.json b/libs/checkpoint-redis/.release-it.json new file mode 100644 index 000000000..a1236e8d7 --- /dev/null +++ b/libs/checkpoint-redis/.release-it.json @@ -0,0 +1,13 @@ +{ + "github": { + "release": true, + "autoGenerate": true, + "tokenRef": "GITHUB_TOKEN_RELEASE" + }, + "npm": { + "publish": true, + "versionArgs": [ + "--workspaces-update=false" + ] + } +} diff --git a/libs/checkpoint-redis/LICENSE b/libs/checkpoint-redis/LICENSE new file mode 100644 index 000000000..9b916031a --- /dev/null +++ b/libs/checkpoint-redis/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2024 LangChain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/libs/checkpoint-redis/README.md b/libs/checkpoint-redis/README.md new file mode 100644 index 000000000..c10fb4eea --- /dev/null +++ b/libs/checkpoint-redis/README.md @@ -0,0 +1,24 @@ +# @langchain/langgraph-checkpoint-redis + +Implementation of a [LangGraph.js](https://github.com/langchain-ai/langgraphjs) CheckpointSaver that uses Redis. + +## Usage + +```ts +import { RedisSaver } from "@langchain/langgraph-checkpoint-redis"; +import { Redis } from "ioredis"; + +const redis = new Redis(); + +const checkpointer = new RedisSaver({ connection: redis }); +``` + +## Testing + +Testing the RedisSaver with real Redis + +```bash +docker-compose up -d && docker-compose logs -f +``` + +Then Run the tests. diff --git a/libs/checkpoint-redis/docker-compose.yml b/libs/checkpoint-redis/docker-compose.yml new file mode 100644 index 000000000..c441d91a8 --- /dev/null +++ b/libs/checkpoint-redis/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.8" + +services: + redis: + image: redis:latest + container_name: langgraphjs-redis-test + ports: + - "6381:6379" diff --git a/libs/checkpoint-redis/jest.config.cjs b/libs/checkpoint-redis/jest.config.cjs new file mode 100644 index 000000000..385d19f6b --- /dev/null +++ b/libs/checkpoint-redis/jest.config.cjs @@ -0,0 +1,20 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest/presets/default-esm", + testEnvironment: "./jest.env.cjs", + modulePathIgnorePatterns: ["dist/"], + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": ["@swc/jest"], + }, + transformIgnorePatterns: [ + "/node_modules/", + "\\.pnp\\.[^\\/]+$", + "./scripts/jest-setup-after-env.js", + ], + setupFiles: ["dotenv/config"], + testTimeout: 20_000, + passWithNoTests: true, +}; diff --git a/libs/checkpoint-redis/jest.env.cjs b/libs/checkpoint-redis/jest.env.cjs new file mode 100644 index 000000000..2ccedccb8 --- /dev/null +++ b/libs/checkpoint-redis/jest.env.cjs @@ -0,0 +1,12 @@ +const { TestEnvironment } = require("jest-environment-node"); + +class AdjustedTestEnvironmentToSupportFloat32Array extends TestEnvironment { + constructor(config, context) { + // Make `instanceof Float32Array` return true in tests + // to avoid https://github.com/xenova/transformers.js/issues/57 and https://github.com/jestjs/jest/issues/2549 + super(config, context); + this.global.Float32Array = Float32Array; + } +} + +module.exports = AdjustedTestEnvironmentToSupportFloat32Array; diff --git a/libs/checkpoint-redis/langchain.config.js b/libs/checkpoint-redis/langchain.config.js new file mode 100644 index 000000000..fe70c345c --- /dev/null +++ b/libs/checkpoint-redis/langchain.config.js @@ -0,0 +1,21 @@ +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * @param {string} relativePath + * @returns {string} + */ +function abs(relativePath) { + return resolve(dirname(fileURLToPath(import.meta.url)), relativePath); +} + +export const config = { + internals: [/node\:/, /@langchain\/core\//, /async_hooks/], + entrypoints: { + index: "index" + }, + tsConfigPath: resolve("./tsconfig.json"), + cjsSource: "./dist-cjs", + cjsDestination: "./dist", + abs, +}; diff --git a/libs/checkpoint-redis/package.json b/libs/checkpoint-redis/package.json new file mode 100644 index 000000000..0df311cc4 --- /dev/null +++ b/libs/checkpoint-redis/package.json @@ -0,0 +1,91 @@ +{ + "name": "@langchain/langgraph-checkpoint-redis", + "version": "0.0.1", + "description": "LangGraph", + "type": "module", + "engines": { + "node": ">=18" + }, + "main": "./index.js", + "types": "./index.d.ts", + "repository": { + "type": "git", + "url": "git@github.com:langchain-ai/langgraphjs.git" + }, + "scripts": { + "build": "yarn turbo:command build:internal --filter=@langchain/langgraph-checkpoint-redis", + "build:internal": "yarn clean && yarn lc_build --create-entrypoints --pre --tree-shaking", + "clean": "rm -rf dist/ dist-cjs/ .turbo/", + "lint:eslint": "NODE_OPTIONS=--max-old-space-size=4096 eslint --cache --ext .ts,.js src/", + "lint:dpdm": "dpdm --exit-code circular:1 --no-warning --no-tree src/*.ts src/**/*.ts", + "lint": "yarn lint:eslint && yarn lint:dpdm", + "lint:fix": "yarn lint:eslint --fix && yarn lint:dpdm", + "prepack": "yarn build", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts --testTimeout 30000 --maxWorkers=50%", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch --testPathIgnorePatterns=\\.int\\.test.ts", + "test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.cjs --testTimeout 100000", + "test:int": "NODE_OPTIONS=--experimental-vm-modules jest --testPathPattern=\\.int\\.test.ts --testTimeout 100000 --maxWorkers=50%", + "format": "prettier --config .prettierrc --write \"src\"", + "format:check": "prettier --config .prettierrc --check \"src\"" + }, + "author": "LangChain", + "license": "MIT", + "dependencies": { + "ioredis": "^5.3.0" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.31 <0.4.0", + "@langchain/langgraph-checkpoint": "~0.0.15" + }, + "devDependencies": { + "@jest/globals": "^29.5.0", + "@langchain/langgraph-checkpoint": "workspace:*", + "@langchain/scripts": ">=0.1.3 <0.2.0", + "@swc/core": "^1.3.90", + "@swc/jest": "^0.2.29", + "@tsconfig/recommended": "^1.0.3", + "@types/uuid": "^10", + "@typescript-eslint/eslint-plugin": "^6.12.0", + "@typescript-eslint/parser": "^6.12.0", + "dotenv": "^16.3.1", + "dpdm": "^3.12.0", + "eslint": "^8.33.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^8.6.0", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jest": "^28.8.0", + "eslint-plugin-no-instanceof": "^1.0.1", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.5.0", + "jest-environment-node": "^29.6.4", + "prettier": "^2.8.3", + "release-it": "^19.0.2", + "rollup": "^4.37.0", + "ts-jest": "^29.1.0", + "tsx": "^4.19.3", + "typescript": "^4.9.5 || ^5.4.5" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "exports": { + ".": { + "types": { + "import": "./index.d.ts", + "require": "./index.d.cts", + "default": "./index.d.ts" + }, + "import": "./index.js", + "require": "./index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "index.cjs", + "index.js", + "index.d.ts", + "index.d.cts" + ] +} diff --git a/libs/checkpoint-redis/src/checkpoint-redis-repository.ts b/libs/checkpoint-redis/src/checkpoint-redis-repository.ts new file mode 100644 index 000000000..1dee181ba --- /dev/null +++ b/libs/checkpoint-redis/src/checkpoint-redis-repository.ts @@ -0,0 +1,381 @@ +import type { Redis } from "ioredis"; + +import { + makeRedisCheckpointKey, + makeRedisCheckpointWritesKey, + makeRedisWritesIndexKey, + makeRedisCheckpointIndexKey, +} from "./utils.js"; + +/** + * Interface defining Redis repository operations for checkpoint management + */ +export interface ICheckpointRedisRepository { + setCheckpoint( + data: string, + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string, + checkpoint_ts: string, + ttl?: number + ): Promise; + + setWrites( + writes: Array<{ channel: string; type: string; value: string }>, + task_id: string, + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string, + ttl?: number + ): Promise; + + getCheckpointState( + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string + ): Promise< + | { + serializedCheckpoint: Record; + checkpointKey: string; + writeKeys: string[]; + serializedPendingWrites?: Record[]; + } + | undefined + >; + + getCheckpointKeyDataPairs( + thread_id: string, + checkpoint_ns: string, + before?: string, + limit?: number + ): Promise<{ key: string; data: string }[]>; +} + +/** + * Redis repository implementation for checkpoint data persistence + */ +export class CheckpointRedisRepository implements ICheckpointRedisRepository { + constructor(private readonly connection: Redis) {} + + /** + * Sets a checkpoint in Redis with TTL and creates an index entry atomically + * @param key - Redis key for the checkpoint + * @param data - Checkpoint data to store + * @param indexKey - Key for the sorted set index + * @param score - Score for the sorted set member + * @param member - Member to add to the sorted set + * @param ttl - Time-to-live in seconds + */ + public async setCheckpoint( + data: string, + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string, + checkpoint_ts: string, + ttl?: number + ): Promise { + const indexKey = makeRedisCheckpointIndexKey( + thread_id ?? "", + checkpoint_ns ?? "" + ); + const key = makeRedisCheckpointKey( + thread_id ?? "", + checkpoint_ns, + checkpoint_id + ); + + const score = new Date(checkpoint_ts).getTime(); + + const multi = this.connection.multi(); + + multi.set(key, data, ...(ttl ? ["EX", ttl] : [])); + multi.zadd(indexKey, score, checkpoint_id); + multi.expire(indexKey, ttl); + await multi.exec(); + } + + /** + * Sets multiple writes in Redis with TTL + * @param writes - Array of key-value pairs to write + * @param indexKey - Key for the sorted set index + * @param ttl - Time-to-live in seconds + */ + public async setWrites( + writes: Array<{ channel: string; type: string; value: string }>, + task_id: string, + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string, + ttl: number + ): Promise { + const indexKey = makeRedisWritesIndexKey( + thread_id, + checkpoint_ns, + checkpoint_id + ); + + const writesWithKeys = writes.map((write, idx) => ({ + key: makeRedisCheckpointWritesKey( + thread_id, + checkpoint_ns, + checkpoint_id, + task_id, + idx + ), + value: JSON.stringify(write), + })); + + const multi = this.connection.multi(); + + writesWithKeys.forEach(({ key, value }, idx) => { + multi.set(key, value, ...(ttl ? ["EX", ttl] : [])); + multi.zadd(indexKey, idx, key); + }); + + multi.expire(indexKey, ttl); + await multi.exec(); + } + + public async getWriteKeysByCheckpoint( + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string + ): Promise { + const indexKey = makeRedisWritesIndexKey( + thread_id, + checkpoint_ns, + checkpoint_id + ); + + const writeKeys = await this.connection.zrange(indexKey, 0, -1); + + return writeKeys; + } + + /** + * Gets the checkpoint state from Redis + * @param thread_id - Thread identifier + * @param checkpoint_ns - Checkpoint namespace + * @param checkpoint_id - Checkpoint identifier + * @returns Promise resolving to checkpoint state or undefined if not found + */ + public async getCheckpointState( + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string + ): Promise< + | { + serializedCheckpoint: Record; + checkpointKey: string; + writeKeys: string[]; + serializedPendingWrites?: Record[]; + } + | undefined + > { + const { checkpoint, checkpointKey } = + (await this._getCheckpointData( + thread_id, + checkpoint_ns, + checkpoint_id + )) || {}; + + if (!checkpoint || !checkpointKey) { + return; + } + + const writeKeys = await this._getWriteKeys( + thread_id, + checkpoint_ns, + checkpoint_id + ); + + if (writeKeys.length === 0) { + return { serializedCheckpoint: checkpoint, checkpointKey, writeKeys }; + } + + const pendingWrites = await this.connection.mget(writeKeys); + + const parsedPendingWrites = pendingWrites + .filter(Boolean) + .map((write: string) => JSON.parse(write)); + + return { + serializedCheckpoint: checkpoint, + checkpointKey, + writeKeys, + serializedPendingWrites: parsedPendingWrites, + }; + } + + /** + * Gets checkpoint key data pairs from Redis + * @param thread_id - Thread identifier + * @param checkpoint_ns - Checkpoint namespace + * @param before - Member to get checkpoints before + * @param limit - Maximum number of checkpoints to return + * @returns Array of checkpoint key data pairs + */ + public async getCheckpointKeyDataPairs( + thread_id: string, + checkpoint_ns: string, + before?: string, + limit?: number + ): Promise<{ key: string; data: string }[]> { + const indexKey = makeRedisCheckpointIndexKey( + thread_id ?? "", + checkpoint_ns ?? "" + ); + + const checkpointIds: string[] = before + ? await this._getCheckpointsBefore(indexKey, before, limit) + : await this.connection.zrange(indexKey, 0, limit ? limit - 1 : -1); + + const checkpointKeys = checkpointIds.map((id: string) => + makeRedisCheckpointKey(thread_id ?? "", checkpoint_ns ?? "", id) + ); + + const checkpointData = await this.connection.mget(checkpointKeys); + + return checkpointKeys + .map((key: string, idx: number) => + checkpointData[idx] ? { key, data: checkpointData[idx] } : null + ) + .filter(Boolean) as { key: string; data: string }[]; + } + + /** + * Gets checkpoint from Redis by key + * @param thread_id - Thread identifier + * @param checkpoint_ns - Checkpoint namespace + * @param checkpoint_id - Checkpoint identifier + * @returns Promise resolving to parsed checkpoint data with key or undefined if not found + */ + private async _getCheckpointData( + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string + ): Promise< + { checkpoint: Record; checkpointKey: string } | undefined + > { + const checkpointKey = await this._getCheckpointKey( + thread_id, + checkpoint_ns, + checkpoint_id + ); + + if (!checkpointKey) { + return; + } + + const checkpoint = await this.connection.get(checkpointKey); + + if (!checkpoint) { + return; + } + + return { + checkpoint: JSON.parse(checkpoint) as Record, + checkpointKey, + }; + } + + private async _getWriteKeys( + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string + ): Promise { + const writesIndexKey = makeRedisWritesIndexKey( + thread_id, + checkpoint_ns, + checkpoint_id + ); + + return await this.connection.zrange(writesIndexKey, 0, -1); + } + + /** + * Gets checkpoints before a given member from the sorted set + * @param indexKey - Key for the sorted set index + * @param before - Member to get checkpoints before + * @param limit - Maximum number of checkpoints to return + * @returns Array of checkpoint IDs before the given member + */ + private async _getCheckpointsBefore( + indexKey: string, + before: string, + limit?: number + ): Promise { + const score = await this._getScoreFromIndex(indexKey, before); + + if (!score) { + return []; + } + + return await this._getCheckpointsByScore( + indexKey, + "-inf", + String(Number(score) - 1), + limit + ); + } + + /** + * Gets the Redis key for a checkpoint based on configuration. + * @param thread_id - Thread identifier + * @param checkpoint_ns - Checkpoint namespace + * @param checkpoint_id - Optional checkpoint identifier + * @returns Promise resolving to Redis key or null if not found + * @private + */ + private async _getCheckpointKey( + thread_id: string, + checkpoint_ns: string, + checkpoint_id: string | undefined + ): Promise { + if (checkpoint_id) { + return makeRedisCheckpointKey(thread_id, checkpoint_ns, checkpoint_id); + } + + const indexKey = makeRedisCheckpointIndexKey(thread_id, checkpoint_ns); + const latestCheckpointId = await this.connection.zrange(indexKey, -1, -1); + + if (!latestCheckpointId.length) { + return null; + } + + return makeRedisCheckpointKey( + thread_id, + checkpoint_ns, + latestCheckpointId[0] + ); + } + + /** + * Gets checkpoints by score range from the sorted set + */ + private async _getCheckpointsByScore( + indexKey: string, + min: string, + max: string, + limit?: number + ): Promise { + return await this.connection.zrangebyscore( + indexKey, + min, + max, + "LIMIT", + 0, + limit ?? -1 + ); + } + + /** + * Gets score for a member from the sorted set + */ + private async _getScoreFromIndex( + indexKey: string, + member: string + ): Promise { + return await this.connection.zscore(indexKey, member); + } +} diff --git a/libs/checkpoint-redis/src/index.ts b/libs/checkpoint-redis/src/index.ts new file mode 100644 index 000000000..3ca1a6f32 --- /dev/null +++ b/libs/checkpoint-redis/src/index.ts @@ -0,0 +1 @@ +export * from "./redis-saver.js"; diff --git a/libs/checkpoint-redis/src/redis-saver.ts b/libs/checkpoint-redis/src/redis-saver.ts new file mode 100644 index 000000000..620b09c62 --- /dev/null +++ b/libs/checkpoint-redis/src/redis-saver.ts @@ -0,0 +1,247 @@ +import type { RunnableConfig } from "@langchain/core/runnables"; +import { + type Checkpoint, + type CheckpointListOptions, + type CheckpointMetadata, + type CheckpointTuple, + type PendingWrite, + type SerializerProtocol, + BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import type { Redis } from "ioredis"; + +import { + type ICheckpointRedisRepository, + CheckpointRedisRepository, +} from "./checkpoint-redis-repository.js"; +import { + dumpWrites, + loadWrites, + parseRedisCheckpointData, + parseRedisCheckpointWritesKey, +} from "./utils.js"; + +export type RedisSaverParams = { + connection: Redis; + ttlSeconds?: number; +}; + +/** + * Redis-based implementation of the BaseCheckpointSaver for LangGraph. + * Provides persistence layer for storing and retrieving checkpoints and their associated + * writes using Redis as the backend storage. + * + * @example + * ```typescript + * const redis = new Redis(); + * const saver = new RedisSaver({ connection: redis }); + * ``` + */ +export class RedisSaver extends BaseCheckpointSaver { + private readonly repository: ICheckpointRedisRepository; + + /** + * Time-to-live for Redis keys in seconds (4 days) + * @private + */ + private readonly ttlSeconds?: number; + + /** + * Creates a new RedisSaver instance. + * @param connection - Redis connection instance + * @param serde - Optional serializer protocol implementation + */ + constructor( + { connection, ttlSeconds }: RedisSaverParams, + serde?: SerializerProtocol + ) { + super(serde); + + this.repository = new CheckpointRedisRepository(connection); + this.ttlSeconds = ttlSeconds; + } + + /** + * Stores a checkpoint with its configuration and metadata in Redis. + * @param config - Runnable configuration containing thread and checkpoint identifiers + * @param checkpoint - Checkpoint data to store + * @param metadata - Metadata associated with the checkpoint + * @returns Promise resolving to updated configuration + * @throws Error if checkpoint and metadata types don't match + */ + public async put( + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata + ): Promise { + // gather all the data needed to create the key + const { + thread_id, + checkpoint_ns = "", + checkpoint_id: parent_checkpoint_id, + } = config.configurable ?? {}; + + // serialize the checkpoint and metadata + const [checkpointType, serializedCheckpoint] = + this.serde.dumpsTyped(checkpoint); + const [metadataType, serializedMetadata] = this.serde.dumpsTyped(metadata); + + if (checkpointType !== metadataType) { + throw new Error("Mismatched checkpoint and metadata types."); + } + + // create the data object to be stored in redis + const data = { + checkpoint: Array.from(serializedCheckpoint).join(","), + type: checkpointType, + metadata_type: metadataType, + metadata: Array.from(serializedMetadata).join(","), + parent_checkpoint_id: parent_checkpoint_id ?? "", + }; + + await this.repository.setCheckpoint( + JSON.stringify(data), + thread_id, + checkpoint_ns, + checkpoint.id, + checkpoint.ts, + this.ttlSeconds + ); + + return { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpoint.id, + }, + }; + } + + /** + * Stores intermediate writes linked to a checkpoint. + * @param config - Runnable configuration + * @param writes - Array of pending writes to store + * @param task_id - Identifier for the task + * @throws Error if required configuration fields are missing + */ + public async putWrites( + config: RunnableConfig, + writes: PendingWrite[], + task_id: string + ): Promise { + const { thread_id, checkpoint_ns, checkpoint_id } = + config.configurable ?? {}; + + if ( + thread_id === undefined || + checkpoint_ns === undefined || + checkpoint_id === undefined + ) { + throw new Error( + `The provided config must contain a configurable field with "thread_id", "checkpoint_ns" and "checkpoint_id" fields.` + ); + } + + const dumpedWrites = dumpWrites(this.serde, writes); + + await this.repository.setWrites( + dumpedWrites, + task_id, + thread_id, + checkpoint_ns, + checkpoint_id, + this.ttlSeconds + ); + } + + /** + * Retrieves a checkpoint tuple for a given configuration. + * @param config - Runnable configuration + * @returns Promise resolving to checkpoint tuple or undefined if not found + * @throws Error if thread_id is missing in configuration + */ + public async getTuple( + config: RunnableConfig + ): Promise { + const { thread_id, checkpoint_ns = "" } = config.configurable ?? {}; + const { checkpoint_id } = config.configurable ?? {}; + + if (thread_id === undefined) { + throw new Error("thread_id is required in config.configurable"); + } + + const checkpointState = await this.repository.getCheckpointState( + thread_id, + checkpoint_ns, + checkpoint_id + ); + + if (!checkpointState) { + return; + } + + const { + serializedCheckpoint, + checkpointKey, + writeKeys, + serializedPendingWrites = [], + } = checkpointState; + + if (writeKeys.length === 0) { + return parseRedisCheckpointData( + this.serde, + checkpointKey, + serializedCheckpoint + ); + } + + const pendingWrites = await loadWrites( + this.serde, + Object.fromEntries( + writeKeys.map((key, i) => { + const parsedKey = parseRedisCheckpointWritesKey(key); + const result = serializedPendingWrites[i] ?? {}; + + return [`${parsedKey.task_id},${parsedKey.idx}`, result]; + }) + ) + ); + + return parseRedisCheckpointData( + this.serde, + checkpointKey, + serializedCheckpoint, + pendingWrites + ); + } + + /** + * Lists checkpoints matching given configuration and filter criteria. + * @param config - Runnable configuration + * @param options - Optional listing options (limit, before) + * @yields CheckpointTuple for each matching checkpoint + */ + public async *list( + config: RunnableConfig, + options?: CheckpointListOptions + ): AsyncGenerator { + const { limit, before } = options ?? {}; + const { thread_id, checkpoint_ns } = config.configurable ?? {}; + + const checkpointKeyDataPairs = + await this.repository.getCheckpointKeyDataPairs( + thread_id, + checkpoint_ns, + before?.configurable?.checkpoint_id ?? "", + limit + ); + + for (const { key, data } of checkpointKeyDataPairs) { + const parsedData = data ? JSON.parse(data) : null; + + if (parsedData && parsedData.checkpoint && parsedData.metadata) { + yield parseRedisCheckpointData(this.serde, key, parsedData); + } + } + } +} diff --git a/libs/checkpoint-redis/src/tests/redis-saver.test.ts b/libs/checkpoint-redis/src/tests/redis-saver.test.ts new file mode 100644 index 000000000..c9c9de3fb --- /dev/null +++ b/libs/checkpoint-redis/src/tests/redis-saver.test.ts @@ -0,0 +1,234 @@ +import { Redis } from "ioredis"; +import { + type Checkpoint, + type CheckpointMetadata, + type CheckpointTuple, + type PendingWrite, + uuid6, +} from "@langchain/langgraph-checkpoint"; + +import { RedisSaver } from "../index.js"; + +describe("RedisSaver", () => { + let saver: RedisSaver; + + const redis = new Redis({ port: 6381 }); + + const mockCheckpoint1: Checkpoint = { + v: 1, + id: uuid6(-1), + ts: "2024-04-19T17:19:07.952Z", + channel_values: { + testKey1: "testValue1", + }, + channel_versions: { + testKey2: 1, + }, + versions_seen: { + testKey3: { + testKey4: 1, + }, + }, + pending_sends: [], + }; + + const mockCheckpoint2: Checkpoint = { + v: 1, + id: uuid6(1), + ts: "2024-04-20T17:19:07.952Z", + channel_values: { + testKey1: "testValue2", + }, + channel_versions: { + testKey2: 2, + }, + versions_seen: { + testKey3: { + testKey4: 2, + }, + }, + pending_sends: [], + }; + + const mockMetadata: CheckpointMetadata = { + source: "update", + step: -1, + writes: null, + parents: {}, + }; + + beforeEach(async () => { + saver = new RedisSaver({ connection: redis }); + }); + + afterEach(async () => { + await redis.flushdb(); + }); + + describe("getTuple", () => { + it("should return undefined for non-existent checkpoint", async () => { + const result = await saver.getTuple({ + configurable: { thread_id: "test-thread" }, + }); + + expect(result).toBeUndefined(); + }); + + it("should throw error when thread_id is missing", async () => { + await expect( + saver.getTuple({ + configurable: {}, + }) + ).rejects.toThrow("thread_id is required in config.configurable"); + }); + }); + + describe("put and getTuple", () => { + it("should successfully save and retrieve a checkpoint", async () => { + const config = { + configurable: { + thread_id: "test-thread", + checkpoint_ns: "test-ns", + }, + }; + + const savedConfig = await saver.put( + config, + mockCheckpoint1, + mockMetadata + ); + + expect(savedConfig).toEqual({ + configurable: { + thread_id: "test-thread", + checkpoint_ns: "test-ns", + checkpoint_id: mockCheckpoint1.id, + }, + }); + + const retrievedTuple = await saver.getTuple(savedConfig); + + expect(retrievedTuple).not.toBeUndefined(); + expect(retrievedTuple?.checkpoint).toEqual(mockCheckpoint1); + expect(retrievedTuple?.config).toEqual(savedConfig); + }); + }); + + describe("putWrites and getTuple", () => { + it("should save and retrieve checkpoint with writes", async () => { + const config = { + configurable: { + thread_id: "test-thread", + checkpoint_ns: "test-ns", + checkpoint_id: mockCheckpoint1.id, + }, + }; + + await saver.put(config, mockCheckpoint1, mockMetadata); + + const writes: PendingWrite[] = [ + ["test-channel", "test-value"] as PendingWrite, + ]; + + await saver.putWrites(config, writes, "test-task"); + + const tuple = await saver.getTuple(config); + + expect(tuple).not.toBeUndefined(); + expect(tuple?.pendingWrites).toHaveLength(1); + expect(tuple?.pendingWrites?.[0]).toEqual([ + "test-task", + "test-channel", + "test-value", + ]); + }); + + it("should throw error when required config fields are missing", async () => { + const writes: PendingWrite[] = [ + ["test-channel", "test-value"] as PendingWrite, + ]; + + await expect( + saver.putWrites( + { + configurable: {}, + }, + writes, + "test-task" + ) + ).rejects.toThrow(); + }); + }); + + describe("list", () => { + it("should list checkpoints in chronological order", async () => { + const config = { + configurable: { + thread_id: "test-thread", + checkpoint_ns: "test-ns", + }, + }; + + // Save checkpoints in reverse chronological order + await saver.put(config, mockCheckpoint2, mockMetadata); + await saver.put(config, mockCheckpoint1, mockMetadata); + + const checkpoints: CheckpointTuple[] = []; + + for await (const checkpoint of saver.list(config)) { + checkpoints.push(checkpoint); + } + + expect(checkpoints).toHaveLength(2); + expect(checkpoints[0].checkpoint.ts).toEqual("2024-04-19T17:19:07.952Z"); + expect(checkpoints[1].checkpoint.ts).toEqual("2024-04-20T17:19:07.952Z"); + }); + + it("should respect the limit option", async () => { + const config = { + configurable: { + thread_id: "test-thread", + checkpoint_ns: "test-ns", + }, + }; + + await saver.put(config, mockCheckpoint1, mockMetadata); + await saver.put(config, mockCheckpoint2, mockMetadata); + + const checkpoints: CheckpointTuple[] = []; + + for await (const checkpoint of saver.list(config, { limit: 1 })) { + checkpoints.push(checkpoint); + } + + expect(checkpoints).toHaveLength(1); + }); + + it("should handle the before option correctly", async () => { + const config = { + configurable: { + thread_id: "test-thread", + checkpoint_ns: "test-ns", + }, + }; + + await saver.put(config, mockCheckpoint1, mockMetadata); + const checkpoint2Config = await saver.put( + config, + mockCheckpoint2, + mockMetadata + ); + + const checkpoints: CheckpointTuple[] = []; + + for await (const checkpoint of saver.list(config, { + before: checkpoint2Config, + })) { + checkpoints.push(checkpoint); + } + + expect(checkpoints).toHaveLength(1); + expect(checkpoints[0].checkpoint.ts).toEqual("2024-04-19T17:19:07.952Z"); + }); + }); +}); diff --git a/libs/checkpoint-redis/src/utils.ts b/libs/checkpoint-redis/src/utils.ts new file mode 100644 index 000000000..19f03e8e1 --- /dev/null +++ b/libs/checkpoint-redis/src/utils.ts @@ -0,0 +1,310 @@ +/** + * @fileoverview Utility functions for Redis-based checkpoint management in LangGraph. + * These utilities handle key formatting, parsing, and data serialization/deserialization + * for storing checkpoints and their associated writes in Redis. + */ + +import { + type CheckpointTuple, + type PendingWrite, + type SerializerProtocol, + CheckpointPendingWrite, +} from "@langchain/langgraph-checkpoint"; + +/** Separator used for Redis key components */ +const REDIS_KEY_SEPARATOR = ":"; + +const CHECKPOINT_INDEX_KEY = "checkpoint_index:"; +const WRITES_INDEX_KEY = "writes_index:"; + +/** + * Creates a Redis key for storing checkpoint data by combining components with a separator. + * Format: 'checkpoint:threadId:checkpointNs:checkpointId' + * + * @param threadId - Unique identifier for the execution thread + * @param checkpointNs - Namespace for the checkpoint + * @param checkpointId - Unique identifier for the checkpoint + * @returns {string} Formatted Redis key string using ':' as separator + * @example + * ```typescript + * const key = makeRedisCheckpointKey('thread-1', 'default', 'cp-123'); + * // Returns: 'checkpoint:thread-1:default:cp-123' + * ``` + */ +export function makeRedisCheckpointKey( + threadId: string, + checkpointNs: string, + checkpointId: string +): string { + return ["checkpoint", threadId, checkpointNs, checkpointId].join( + REDIS_KEY_SEPARATOR + ); +} + +/** + * Creates a Redis key for storing checkpoint writes data. + * Format: 'writes:threadId:checkpointNs:checkpointId:taskId[:idx]' + * + * @param threadId - Unique identifier for the execution thread + * @param checkpointNs - Namespace for the checkpoint + * @param checkpointId - Unique identifier for the checkpoint + * @param taskId - Identifier for the specific task + * @param idx - Optional index for the write operation. If null, it's omitted from the key + * @returns {string} Formatted Redis key string for writes + * @example + * ```typescript + * const key = makeRedisCheckpointWritesKey('thread-1', 'default', 'cp-123', 'task-1', 0); + * // Returns: 'writes:thread-1:default:cp-123:task-1:0' + * ``` + */ +export function makeRedisCheckpointWritesKey( + threadId: string, + checkpointNs: string, + checkpointId: string, + taskId: string, + idx: number | null +): string { + const key = ["writes", threadId, checkpointNs, checkpointId, taskId]; + + if (idx === null) { + return key.join(REDIS_KEY_SEPARATOR); + } + + return [...key, idx?.toString()].join(REDIS_KEY_SEPARATOR); +} + +/** + * Parses a Redis key for checkpoint writes into its component parts. + * Expected format: 'writes:threadId:checkpointNs:checkpointId:taskId:idx' + * + * @param redisKey - Redis key string to parse + * @returns {Record} Object containing parsed components: + * - thread_id: Thread identifier + * - checkpoint_ns: Checkpoint namespace + * - checkpoint_id: Checkpoint identifier + * - task_id: Task identifier + * - idx: Write operation index + * @throws {Error} If key doesn't start with 'writes' + * @example + * ```typescript + * const components = parseRedisCheckpointWritesKey('writes:thread-1:default:cp-123:task-1:0'); + * // Returns: { + * // thread_id: 'thread-1', + * // checkpoint_ns: 'default', + * // checkpoint_id: 'cp-123', + * // task_id: 'task-1', + * // idx: '0' + * // } + * ``` + */ +export function parseRedisCheckpointWritesKey( + redisKey: string +): Record { + const [namespace, thread_id, checkpoint_ns, checkpoint_id, task_id, idx] = + redisKey.split(REDIS_KEY_SEPARATOR); + + if (namespace !== "writes") { + throw new Error("Expected checkpoint key to start with 'writes'"); + } + + return { + thread_id, + checkpoint_ns, + checkpoint_id, + task_id, + idx, + }; +} + +/** + * Serializes an array of pending writes using the provided serializer. + * Converts each write into a format suitable for Redis storage. + * + * @param serde - Serializer protocol implementation for type-safe serialization + * @param writes - Array of pending writes to serialize, each containing a channel and value + * @returns {Array} Array of serialized write objects containing: + * - channel: The write channel identifier + * - type: The serialized type information + * - value: The serialized value as a string of comma-separated numbers + * @example + * ```typescript + * const writes = [['channel1', someValue], ['channel2', anotherValue]]; + * const serialized = dumpWrites(serializerInstance, writes); + * ``` + */ +export function dumpWrites( + serde: SerializerProtocol, + writes: PendingWrite[] +): { channel: string; type: string; value: string }[] { + return writes.map(([channel, value]) => { + const [type, serializedValue] = serde.dumpsTyped(value); + + return { + channel, + type, + value: Array.from(serializedValue).join(","), + }; + }); +} + +/** + * Deserializes writes data from Redis storage format back into checkpoint pending writes. + * + * @param serde - Serializer protocol implementation for type-safe deserialization + * @param taskIdToData - Record mapping task IDs to their associated write data + * @returns {Promise} Promise resolving to array of checkpoint pending writes + * @example + * ```typescript + * const taskData = { + * 'task1,0': { channel: 'ch1', type: 'string', value: '...' }, + * 'task1,1': { channel: 'ch2', type: 'number', value: '...' } + * }; + * const writes = await loadWrites(serializerInstance, taskData); + * ``` + */ +export async function loadWrites( + serde: SerializerProtocol, + taskIdToData: Record> +): Promise { + const writesPromises = Object.entries(taskIdToData).map( + async ([taskId, data]) => + [ + taskId.split(",")[0], + data.channel, + await serde.loadsTyped( + data.type, + Uint8Array.from(data.value.split(",").map((num) => parseInt(num, 10))) + ), + ] as CheckpointPendingWrite + ); + + return Promise.all(writesPromises); +} + +/** + * Parses Redis checkpoint data into a complete CheckpointTuple. + * Combines checkpoint data, metadata, and optional pending writes into a unified structure. + * + * @param serde - Serializer protocol implementation + * @param key - Redis key for the checkpoint + * @param data - Raw checkpoint data from Redis containing serialized checkpoint and metadata + * @param pendingWrites - Optional array of pending writes associated with the checkpoint + * @returns {Promise} Promise resolving to parsed CheckpointTuple containing: + * - config: Configuration with thread, namespace, and checkpoint identifiers + * - checkpoint: Deserialized checkpoint data + * - metadata: Deserialized metadata + * - parentConfig: Optional parent checkpoint configuration + * - pendingWrites: Optional array of pending writes + * @example + * ```typescript + * const tuple = await parseRedisCheckpointData( + * serializerInstance, + * 'checkpoint:thread-1:default:cp-123', + * { type: 'json', checkpoint: '...', metadata_type: 'json', metadata: '...' } + * ); + * ``` + */ +export async function parseRedisCheckpointData( + serde: SerializerProtocol, + key: string, + data: Record, + pendingWrites?: CheckpointPendingWrite[] +): Promise { + const parsedKey = parseRedisCheckpointKey(key); + const { thread_id, checkpoint_ns = "", checkpoint_id } = parsedKey; + + const config = { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id, + }, + }; + + const checkpoint = await serde.loadsTyped( + data.type, + Uint8Array.from(data.checkpoint.split(",").map((num) => parseInt(num, 10))) + ); + + const metadata = await serde.loadsTyped( + data.metadata_type, + Uint8Array.from(data.metadata.split(",").map((num) => parseInt(num, 10))) + ); + const parentCheckpointId = data.parent_checkpoint_id; + const parentConfig = parentCheckpointId + ? { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: parentCheckpointId, + }, + } + : undefined; + + return { config, checkpoint, metadata, parentConfig, pendingWrites }; +} + +/** + * Parses a Redis checkpoint key into its component parts. + * Expected format: 'checkpoint:threadId:checkpointNs:checkpointId' + * + * @param redisKey - Redis key string to parse + * @returns {Record} Object containing parsed components: + * - thread_id: Thread identifier + * - checkpoint_ns: Checkpoint namespace + * - checkpoint_id: Checkpoint identifier + * @throws {Error} If key doesn't start with 'checkpoint' + * @example + * ```typescript + * const components = parseRedisCheckpointKey('checkpoint:thread-1:default:cp-123'); + * // Returns: { + * // thread_id: 'thread-1', + * // checkpoint_ns: 'default', + * // checkpoint_id: 'cp-123' + * // } + * ``` + */ +export function parseRedisCheckpointKey( + redisKey: string +): Record { + const [namespace, thread_id, checkpoint_ns, checkpoint_id] = + redisKey.split(REDIS_KEY_SEPARATOR); + + if (namespace !== "checkpoint") { + throw new Error("Expected checkpoint key to start with 'checkpoint'"); + } + + return { + thread_id, + checkpoint_ns, + checkpoint_id, + }; +} + +/** + * Generates the Redis key for checkpoint index + * @param threadId - Thread identifier + * @param checkpointNs - Checkpoint namespace + * @returns Formatted Redis key for checkpoint index + */ +export function makeRedisCheckpointIndexKey( + threadId: string, + checkpointNs: string +): string { + return `${CHECKPOINT_INDEX_KEY}${threadId}:${checkpointNs}`; +} + +/** + * Generates the Redis key for writes index + * @param threadId - Thread identifier + * @param checkpointNs - Checkpoint namespace + * @param checkpointId - Checkpoint identifier + * @returns Formatted Redis key for writes index + */ +export function makeRedisWritesIndexKey( + threadId: string, + checkpointNs: string, + checkpointId: string +): string { + return `${WRITES_INDEX_KEY}${threadId}:${checkpointNs}:${checkpointId}`; +} diff --git a/libs/checkpoint-redis/tsconfig.cjs.json b/libs/checkpoint-redis/tsconfig.cjs.json new file mode 100644 index 000000000..3b7026ea4 --- /dev/null +++ b/libs/checkpoint-redis/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "declaration": false + }, + "exclude": ["node_modules", "dist", "docs", "**/tests"] +} diff --git a/libs/checkpoint-redis/tsconfig.json b/libs/checkpoint-redis/tsconfig.json new file mode 100644 index 000000000..23c9a9856 --- /dev/null +++ b/libs/checkpoint-redis/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@tsconfig/recommended", + "compilerOptions": { + "outDir": "../dist", + "rootDir": "./src", + "target": "ES2021", + "lib": ["ES2021", "ES2022.Object", "DOM"], + "module": "ES2020", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "declaration": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useDefineForClassFields": true, + "strictPropertyInitialization": false, + "allowJs": true, + "strict": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "docs"] +} diff --git a/libs/checkpoint-redis/turbo.json b/libs/checkpoint-redis/turbo.json new file mode 100644 index 000000000..d1bb60a7b --- /dev/null +++ b/libs/checkpoint-redis/turbo.json @@ -0,0 +1,11 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["**/dist/**"] + }, + "build:internal": { + "dependsOn": ["^build:internal"] + } + } +} diff --git a/yarn.lock b/yarn.lock index 3d5f4e984..fc28d87d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -993,6 +993,13 @@ __metadata: languageName: node linkType: hard +"@ioredis/commands@npm:^1.1.1": + version: 1.2.0 + resolution: "@ioredis/commands@npm:1.2.0" + checksum: 9b20225ba36ef3e5caf69b3c0720597c3016cc9b1e157f519ea388f621dd9037177f84cfe7e25c4c32dad7dd90c70ff9123cd411f747e053cf292193c9c461e2 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -1820,6 +1827,43 @@ __metadata: languageName: unknown linkType: soft +"@langchain/langgraph-checkpoint-redis@workspace:libs/checkpoint-redis": + version: 0.0.0-use.local + resolution: "@langchain/langgraph-checkpoint-redis@workspace:libs/checkpoint-redis" + dependencies: + "@jest/globals": ^29.5.0 + "@langchain/langgraph-checkpoint": "workspace:*" + "@langchain/scripts": ">=0.1.3 <0.2.0" + "@swc/core": ^1.3.90 + "@swc/jest": ^0.2.29 + "@tsconfig/recommended": ^1.0.3 + "@types/uuid": ^10 + "@typescript-eslint/eslint-plugin": ^6.12.0 + "@typescript-eslint/parser": ^6.12.0 + dotenv: ^16.3.1 + dpdm: ^3.12.0 + eslint: ^8.33.0 + eslint-config-airbnb-base: ^15.0.0 + eslint-config-prettier: ^8.6.0 + eslint-plugin-import: ^2.29.1 + eslint-plugin-jest: ^28.8.0 + eslint-plugin-no-instanceof: ^1.0.1 + eslint-plugin-prettier: ^4.2.1 + ioredis: ^5.3.0 + jest: ^29.5.0 + jest-environment-node: ^29.6.4 + prettier: ^2.8.3 + release-it: ^19.0.2 + rollup: ^4.37.0 + ts-jest: ^29.1.0 + tsx: ^4.19.3 + typescript: ^4.9.5 || ^5.4.5 + peerDependencies: + "@langchain/core": ">=0.2.31 <0.4.0" + "@langchain/langgraph-checkpoint": ~0.0.15 + languageName: unknown + linkType: soft + "@langchain/langgraph-checkpoint-sqlite@workspace:*, @langchain/langgraph-checkpoint-sqlite@workspace:libs/checkpoint-sqlite": version: 0.0.0-use.local resolution: "@langchain/langgraph-checkpoint-sqlite@workspace:libs/checkpoint-sqlite" @@ -4826,6 +4870,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:^1.1.0": + version: 1.1.2 + resolution: "cluster-key-slot@npm:1.1.2" + checksum: be0ad2d262502adc998597e83f9ded1b80f827f0452127c5a37b22dfca36bab8edf393f7b25bb626006fb9fb2436106939ede6d2d6ecf4229b96a47f27edd681 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -5619,6 +5670,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:^2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 1d4ae1d05e59ac3a3481e7b478293f4b4c813819342273f3d5b826c7ffa9753c520919ba264f377e09108d24ec6cf0ec0ac729a5686cbb8f32d797126c5dae74 + languageName: node + linkType: hard + "destr@npm:^2.0.3": version: 2.0.5 resolution: "destr@npm:2.0.5" @@ -7455,6 +7513,23 @@ __metadata: languageName: node linkType: hard +"ioredis@npm:^5.3.0": + version: 5.6.1 + resolution: "ioredis@npm:5.6.1" + dependencies: + "@ioredis/commands": ^1.1.1 + cluster-key-slot: ^1.1.0 + debug: ^4.3.4 + denque: ^2.1.0 + lodash.defaults: ^4.2.0 + lodash.isarguments: ^3.1.0 + redis-errors: ^1.2.0 + redis-parser: ^3.0.0 + standard-as-callback: ^2.1.0 + checksum: 89100a97b2210fed2aca45daf902adee8aa2294e56f817742651c86234a3efa56f82aa5aa762a94f5fbf806942f259ef5e628f626d1841d20d5cbb163fc8bd3f + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -8671,6 +8746,13 @@ __metadata: languageName: node linkType: hard +"lodash.defaults@npm:^4.2.0": + version: 4.2.0 + resolution: "lodash.defaults@npm:4.2.0" + checksum: 84923258235592c8886e29de5491946ff8c2ae5c82a7ac5cddd2e3cb697e6fbdfbbb6efcca015795c86eec2bb953a5a2ee4016e3735a3f02720428a40efbb8f1 + languageName: node + linkType: hard + "lodash.escaperegexp@npm:^4.1.2": version: 4.1.2 resolution: "lodash.escaperegexp@npm:4.1.2" @@ -8685,6 +8767,13 @@ __metadata: languageName: node linkType: hard +"lodash.isarguments@npm:^3.1.0": + version: 3.1.0 + resolution: "lodash.isarguments@npm:3.1.0" + checksum: ae1526f3eb5c61c77944b101b1f655f846ecbedcb9e6b073526eba6890dc0f13f09f72e11ffbf6540b602caee319af9ac363d6cdd6be41f4ee453436f04f13b5 + languageName: node + linkType: hard + "lodash.isplainobject@npm:^4.0.6": version: 4.0.6 resolution: "lodash.isplainobject@npm:4.0.6" @@ -10606,6 +10695,22 @@ __metadata: languageName: node linkType: hard +"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": + version: 1.2.0 + resolution: "redis-errors@npm:1.2.0" + checksum: f28ac2692113f6f9c222670735aa58aeae413464fd58ccf3fce3f700cae7262606300840c802c64f2b53f19f65993da24dc918afc277e9e33ac1ff09edb394f4 + languageName: node + linkType: hard + +"redis-parser@npm:^3.0.0": + version: 3.0.0 + resolution: "redis-parser@npm:3.0.0" + dependencies: + redis-errors: ^1.0.0 + checksum: 89290ae530332f2ae37577647fa18208d10308a1a6ba750b9d9a093e7398f5e5253f19855b64c98757f7129cccce958e4af2573fdc33bad41405f87f1943459a + languageName: node + linkType: hard + "regexp.prototype.flags@npm:^1.5.2": version: 1.5.2 resolution: "regexp.prototype.flags@npm:1.5.2" @@ -11353,6 +11458,13 @@ __metadata: languageName: node linkType: hard +"standard-as-callback@npm:^2.1.0": + version: 2.1.0 + resolution: "standard-as-callback@npm:2.1.0" + checksum: 88bec83ee220687c72d94fd86a98d5272c91d37ec64b66d830dbc0d79b62bfa6e47f53b71646011835fc9ce7fae62739545d13124262b53be4fbb3e2ebad551c + languageName: node + linkType: hard + "stdin-discarder@npm:^0.2.2": version: 0.2.2 resolution: "stdin-discarder@npm:0.2.2"