diff --git a/libs/checkpoint-mysql/.env.example b/libs/checkpoint-mysql/.env.example new file mode 100644 index 000000000..aea660a4d --- /dev/null +++ b/libs/checkpoint-mysql/.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-mysql/.eslintrc.cjs b/libs/checkpoint-mysql/.eslintrc.cjs new file mode 100644 index 000000000..3b111c39f --- /dev/null +++ b/libs/checkpoint-mysql/.eslintrc.cjs @@ -0,0 +1,68 @@ +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"], + 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, + "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-mysql/.gitignore b/libs/checkpoint-mysql/.gitignore new file mode 100644 index 000000000..c10034e2f --- /dev/null +++ b/libs/checkpoint-mysql/.gitignore @@ -0,0 +1,7 @@ +index.cjs +index.js +index.d.ts +index.d.cts +node_modules +dist +.yarn diff --git a/libs/checkpoint-mysql/.prettierrc b/libs/checkpoint-mysql/.prettierrc new file mode 100644 index 000000000..ba08ff04f --- /dev/null +++ b/libs/checkpoint-mysql/.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-mysql/.release-it.json b/libs/checkpoint-mysql/.release-it.json new file mode 100644 index 000000000..a1236e8d7 --- /dev/null +++ b/libs/checkpoint-mysql/.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-mysql/LICENSE b/libs/checkpoint-mysql/LICENSE new file mode 100644 index 000000000..e7530f5e9 --- /dev/null +++ b/libs/checkpoint-mysql/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. \ No newline at end of file diff --git a/libs/checkpoint-mysql/README.md b/libs/checkpoint-mysql/README.md new file mode 100644 index 000000000..2c9d8de37 --- /dev/null +++ b/libs/checkpoint-mysql/README.md @@ -0,0 +1,101 @@ +# @langchain/langgraph-checkpoint-mysql + +Implementation of a [LangGraph.js](https://github.com/langchain-ai/langgraphjs) CheckpointSaver that uses a MySQL DB. + +- Database operations are implemented through [Sequelize v6](https://sequelize.org/). +- Support `MySQL >= 5.7` +- Implementation follow the style(including code style / table structure) of [@langchain/langgraph-checkpoint-postgres](https://www.npmjs.com/package/@langchain/langgraph-checkpoint-postgres) +- Inspired by [https://github.com/tjni/langgraph-checkpoint-mysql](https://github.com/tjni/langgraph-checkpoint-mysql) + +## Usage + +```ts +import { MySQLSaver } from "@langchain/langgraph-checkpoint-mysql"; + +const writeConfig = { + configurable: { + thread_id: "1", + checkpoint_ns: "", + }, +}; +const readConfig = { + configurable: { + thread_id: "1", + }, +}; + +// you can optionally pass a configuration object as the second parameter +const checkpointer = MySQLSaver.fromConnString("mysql://..."); + +// or you can initialize the sequelize instance first +// const sequelize = new Sequelize({ +// database: 'testdb', +// username: 'root', +// password: '123456', +// host: '127.0.0.1', +// port: 3306, +// dialect: 'mysql', +// }); + +// const checkpointer = new MySQLSaver(sequelize); + +// You should call .setup() the first time you use the checkpointer: +await checkpointer.setup(); + +// or you can set the table manually by using the sql in `/src/migration.sql` + +const checkpoint = { + v: 1, + ts: "2024-07-31T20:14:19.804150+00:00", + id: "1ef4f797-8335-6428-8001-8a1503f9b875", + channel_values: { + my_key: "meow", + node: "node", + }, + channel_versions: { + __start__: 2, + my_key: 3, + "start:node": 3, + node: 3, + }, + versions_seen: { + __input__: {}, + __start__: { + __start__: 1, + }, + node: { + "start:node": 2, + }, + }, + pending_sends: [], +}; + +// store checkpoint +await checkpointer.put(writeConfig, checkpoint, {}, {}); + +// load checkpoint +await checkpointer.get(readConfig); + +// list checkpoints +for await (const checkpoint of checkpointer.list(readConfig)) { + console.log(checkpoint); +} +``` + +## Testing + +Spin up testing MySQL + +```bash +docker-compose up -d && docker-compose logs -f +``` + +Then rename the test file `./src/tests/checkpoints.int.test.ts` to `./src/tests/checkpoints.test.ts` + +Run the test script + +```bash +yarn test + +# or yarn test:watch +``` diff --git a/libs/checkpoint-mysql/docker-compose.yml b/libs/checkpoint-mysql/docker-compose.yml new file mode 100644 index 000000000..7145cd395 --- /dev/null +++ b/libs/checkpoint-mysql/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.8" + +services: + postgres: + image: postgres:latest + container_name: langgraphjs-postgres-test + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: testdb + # Enable logging of connections and disconnections + POSTGRES_LOG_CONNECTIONS: "on" + POSTGRES_LOG_DISCONNECTIONS: "on" + POSTGRES_LOG_MIN_MESSAGES: "info" # Logs messages of level INFO and above + ports: + - "5434:5432" diff --git a/libs/checkpoint-mysql/langchain.config.js b/libs/checkpoint-mysql/langchain.config.js new file mode 100644 index 000000000..fe70c345c --- /dev/null +++ b/libs/checkpoint-mysql/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-mysql/package.json b/libs/checkpoint-mysql/package.json new file mode 100644 index 000000000..9037c978e --- /dev/null +++ b/libs/checkpoint-mysql/package.json @@ -0,0 +1,86 @@ +{ + "name": "@langchain/langgraph-checkpoint-mysql", + "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-mysql", + "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": "vitest run", + "test:watch": "vitest watch", + "test:int": "vitest run --mode int", + "format": "prettier --config .prettierrc --write \"src\"", + "format:check": "prettier --config .prettierrc --check \"src\"" + }, + "author": "LangChain", + "license": "MIT", + "dependencies": { + "mysql2": "^3.14.1", + "sequelize": "^6.37.7" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.31 <0.4.0", + "@langchain/langgraph-checkpoint": "~0.0.15" + }, + "devDependencies": { + "@langchain/langgraph-checkpoint": "workspace:*", + "@langchain/scripts": ">=0.1.2 <0.2.0", + "@tsconfig/recommended": "^1.0.3", + "@types/pg": "^8.11.8", + "@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-no-instanceof": "^1.0.1", + "eslint-plugin-prettier": "^4.2.1", + "prettier": "^2.8.3", + "release-it": "^19.0.2", + "rollup": "^4.37.0", + "tsx": "^4.19.3", + "typescript": "^4.9.5 || ^5.4.5", + "vitest": "^3.1.2" + }, + "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-mysql/src/index.ts b/libs/checkpoint-mysql/src/index.ts new file mode 100644 index 000000000..343fb0f6d --- /dev/null +++ b/libs/checkpoint-mysql/src/index.ts @@ -0,0 +1,869 @@ +import type { RunnableConfig } from "@langchain/core/runnables"; +import { + BaseCheckpointSaver, + type Checkpoint, + type CheckpointListOptions, + type CheckpointTuple, + type SerializerProtocol, + type PendingWrite, + type CheckpointMetadata, + type ChannelVersions, + WRITES_IDX_MAP, + TASKS, +} from "@langchain/langgraph-checkpoint"; +import { QueryTypes, Sequelize } from "sequelize"; +import { TextDecoder, TextEncoder } from "util"; +import { + _initializeModels, + CheckpointBlob, + CheckpointMigration, + CheckpointModel, + CheckpointWrite, +} from "./models.js"; + +/** + * LangGraph checkpointer that uses a MySQL instance as the backing store. + * Uses the [sequelize](https://sequelize.org/) package internally + * to connect to a MySQL instance. + * + * @example + * ``` + * import { ChatOpenAI } from "@langchain/openai"; + * import { MySQLSaver } from "@langchain/langgraph-checkpoint-mysql"; + * import { createReactAgent } from "@langchain/langgraph/prebuilt"; + * import { Sequelize } from "sequelize"; + * + * const sequelize = new Sequelize({ + * database: "langgraph_checkpoint", + * username: "root", + * password: "password", + * host: "localhost", + * port: 3306, + * dialect: "mysql", + * }); + * + * const checkpointer = new MySQLSaver(sequelize); + * + * // NOTE: you need to call .setup() the first time you're using your checkpointer + * await checkpointer.setup(); + * + * const graph = createReactAgent({ + * tools: [getWeather], + * llm: new ChatOpenAI({ + * model: "gpt-4o-mini", + * }), + * checkpointSaver: checkpointer, + * }); + * const config = { configurable: { thread_id: "1" } }; + * + * await graph.invoke({ + * messages: [{ + * role: "user", + * content: "what's the weather in sf" + * }], + * }, config); + * ``` + */ +export class MySQLSaver extends BaseCheckpointSaver { + private readonly sequelize: Sequelize; + + protected isSetup: boolean; + + constructor(sequelize: Sequelize, serde?: SerializerProtocol) { + super(serde); + + this.sequelize = sequelize; + + this.isSetup = false; + + this._initializeModels(); + } + + /** + * Creates a new instance of MySQLSaver from a connection string. + * + * @param {string} connString - The connection string to connect to the MySQL database. + * @returns {MySQLSaver} A new instance of MySQLSaver. + * + * @example + * const connString = "mysql://user:password@localhost:3306/db"; + * const checkpointer = MySQLSaver.fromConnString(connString); + * await checkpointer.setup(); + */ + static fromConnString(connString: string): MySQLSaver { + const sequelize = new Sequelize(connString); + return new MySQLSaver(sequelize); + } + + private _initializeModels() { + _initializeModels(this.sequelize); + } + + /** + * Set up the checkpoint database asynchronously. + * + * This method creates the necessary tables in the MySQL database if they don't + * already exist and runs database migrations. It MUST be called directly by the user + * the first time checkpointer is used. + */ + async setup( + options: Partial<{ force: boolean }> = { force: false } + ): Promise { + try { + await this.sequelize.authenticate(); + console.log("✅ Mysql connected successfully"); + + // Use force: true to force recreation of table structure, ensuring consistency with model definitions + // Note: This will delete existing data, only for development environment + await this.sequelize.sync({ force: options.force }); + console.log("✅ Models synced successfully"); + + const latestMigration = await CheckpointMigration.findOne({ + order: [["v", "DESC"]], + }); + + console.log("ℹ️ latestMigration is: ", latestMigration?.v); + + // Migration logic can be added here if needed + // Currently all table structures are in model definitions, so no additional migration is needed + + this.isSetup = true; + + console.log("✅ MySQLSaver setup successfully"); + } catch (error) { + console.error("❌ MySQLSaver setup failed: ", error); + throw new Error(`Failed to setup MySQL database: ${error}`); + } + } + + /** + * Return WHERE clause predicates for a given list() config, filter, cursor. + * + * This method returns a tuple of a string and a tuple of values. The string + * is the parameterized WHERE clause predicate (including the WHERE keyword): + * "WHERE column1 = ? AND column2 IS ?". The list of values contains the + * values for each of the corresponding parameters. + */ + protected _searchWhere( + config?: RunnableConfig, + filter?: Record, + before?: RunnableConfig + ): [string, unknown[]] { + const wheres: string[] = []; + const paramValues: unknown[] = []; + + // construct predicate for config filter + if (config?.configurable?.thread_id) { + wheres.push(`thread_id = ?`); + paramValues.push(config.configurable.thread_id); + } + + // strict checks for undefined/null because empty strings are falsy + if ( + config?.configurable?.checkpoint_ns !== undefined && + config?.configurable?.checkpoint_ns !== null + ) { + wheres.push(`checkpoint_ns = ?`); + paramValues.push(config.configurable.checkpoint_ns); + } + + if (config?.configurable?.checkpoint_id) { + wheres.push(`checkpoint_id = ?`); + paramValues.push(config.configurable.checkpoint_id); + } + + // construct predicate for metadata filter + if (filter && Object.keys(filter).length > 0) { + // MySQL 的 JSON_CONTAINS 需要检查每个键值对 + const filterConditions = Object.entries(filter).map(([key]) => { + return `JSON_EXTRACT(metadata, '$.${key}') = ?`; + }); + wheres.push(`(${filterConditions.join(" AND ")})`); + paramValues.push(...Object.values(filter)); + } + + // construct predicate for `before` + if (before?.configurable?.checkpoint_id !== undefined) { + wheres.push(`checkpoint_id < ?`); + paramValues.push(before.configurable.checkpoint_id); + } + + return [ + wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "", + paramValues, + ]; + } + + protected async _loadCheckpoint( + checkpoint: Omit, + channelValues: [Uint8Array, Uint8Array, Uint8Array][], + pendingSends: [Uint8Array, Uint8Array][] + ): Promise { + return { + ...checkpoint, + pending_sends: await Promise.all( + (pendingSends || []).map(([c, b]) => + this.serde.loadsTyped(c.toString(), b) + ) + ), + channel_values: await this._loadBlobs(channelValues), + }; + } + + protected async _loadBlobs( + blobValues: [Uint8Array, Uint8Array, Uint8Array][] + ): Promise> { + if (!blobValues || blobValues.length === 0) { + return {}; + } + + const entries = await Promise.all( + blobValues + .filter(([, t]) => new TextDecoder().decode(t) !== "empty") + .map(async ([k, t, v]) => [ + new TextDecoder().decode(k), + await this.serde.loadsTyped(new TextDecoder().decode(t), v), + ]) + ); + + return Object.fromEntries(entries); + } + + protected async _loadMetadata(metadata: Record) { + const [type, dumpedValue] = this.serde.dumpsTyped(metadata); + return this.serde.loadsTyped(type, dumpedValue); + } + + protected async _loadWrites( + writes: [Uint8Array, Uint8Array, Uint8Array, Uint8Array][] + ): Promise<[string, string, unknown][]> { + const decoder = new TextDecoder(); + return writes + ? Promise.all( + writes.map(async ([tid, channel, t, v]) => [ + decoder.decode(tid), + decoder.decode(channel), + await this.serde.loadsTyped(decoder.decode(t), v), + ]) + ) + : []; + } + + protected _dumpBlobs( + threadId: string, + checkpointNs: string, + values: Record, + versions: ChannelVersions + ): [string, string, string, string, string, Uint8Array | undefined][] { + if (Object.keys(versions).length === 0) { + return []; + } + + return Object.entries(versions).map(([k, ver]) => { + const [type, value] = + k in values ? this.serde.dumpsTyped(values[k]) : ["empty", null]; + return [ + threadId, + checkpointNs, + k, + ver.toString(), + type, + value ? new Uint8Array(value) : undefined, + ]; + }); + } + + protected _dumpCheckpoint(checkpoint: Checkpoint) { + const serialized: Record = { + ...checkpoint, + pending_sends: [], + }; + if ("channel_values" in serialized) { + delete serialized.channel_values; + } + return serialized; + } + + protected _dumpMetadata(metadata: CheckpointMetadata) { + const [, serializedMetadata] = this.serde.dumpsTyped(metadata); + // We need to remove null characters before writing + const metadataWithoutNull = JSON.parse( + new TextDecoder().decode(serializedMetadata).replace(/\0/g, "") + ); + return metadataWithoutNull; + } + + protected _dumpWrites( + threadId: string, + checkpointNs: string, + checkpointId: string, + taskId: string, + writes: [string, unknown][] + ): [string, string, string, string, number, string, string, Uint8Array][] { + return writes.map(([channel, value], idx) => { + const [type, serializedValue] = this.serde.dumpsTyped(value); + + return [ + threadId, + checkpointNs, + checkpointId, + taskId, + WRITES_IDX_MAP[channel] ?? idx, + channel, + type, + new Uint8Array(serializedValue), + ]; + }); + } + + /** + * Get a checkpoint tuple from the database. + * This method retrieves a checkpoint tuple from the MySQL database + * based on the provided config. If the config's configurable field contains + * a "checkpoint_id" key, the checkpoint with the matching thread_id and + * namespace is retrieved. Otherwise, the latest checkpoint for the given + * thread_id is retrieved. + * + * Optimized for MySQL 5.7+ InnoDB with improved query performance and compatibility. + * @param config The config to use for retrieving the checkpoint. + * @returns The retrieved checkpoint tuple, or undefined. + */ + async getTuple(config: RunnableConfig): Promise { + const { + thread_id, + checkpoint_ns = "", + checkpoint_id, + } = config.configurable ?? {}; + + if (!thread_id) { + throw new Error("Missing required thread_id in config"); + } + + const checkpointQuery = checkpoint_id + ? "SELECT * FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? LIMIT 1" + : "SELECT * FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ? ORDER BY checkpoint_id DESC LIMIT 1"; + + const checkpointArgs = checkpoint_id + ? [thread_id, checkpoint_ns, checkpoint_id] + : [thread_id, checkpoint_ns]; + + const checkpointResults = await this.sequelize.query(checkpointQuery, { + replacements: checkpointArgs, + type: QueryTypes.SELECT, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const checkpointRow = (checkpointResults as any[])[0]; + + if (!checkpointRow) { + return undefined; + } + + const [channelBlobs, pendingWrites, pendingSends] = await Promise.all([ + // channel_values + this._getChannelBlobs( + checkpointRow.thread_id, + checkpointRow.checkpoint_ns, + checkpointRow.checkpoint + ), + // pending_writes + this._getPendingWrites( + checkpointRow.thread_id, + checkpointRow.checkpoint_ns, + checkpointRow.checkpoint_id + ), + // get pending_sends (from parent checkpoint) + checkpointRow.parent_checkpoint_id + ? this._getPendingSends( + checkpointRow.thread_id, + checkpointRow.checkpoint_ns, + checkpointRow.parent_checkpoint_id + ) + : Promise.resolve([]), + ]); + + // build final result + const checkpoint = await this._loadCheckpoint( + checkpointRow.checkpoint, + channelBlobs, + pendingSends + ); + + const finalConfig = { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpointRow.checkpoint_id, + }, + }; + + const metadata = await this._loadMetadata(checkpointRow.metadata); + + const parentConfig = checkpointRow.parent_checkpoint_id + ? { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpointRow.parent_checkpoint_id, + }, + } + : undefined; + + const pendingWritesResult = await this._loadWrites(pendingWrites); + + return { + config: finalConfig, + checkpoint, + metadata, + parentConfig, + pendingWrites: pendingWritesResult, + }; + } + + /** + * get channel blobs data + * compatible with MySQL 5.7, use JSON_EXTRACT instead of JSON_TABLE + */ + private async _getChannelBlobs( + threadId: string, + checkpointNs: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + checkpoint: any + ): Promise<[Uint8Array, Uint8Array, Uint8Array][]> { + try { + // extract channel_versions from checkpoint + const channelVersions = checkpoint?.channel_versions || {}; + const channels = Object.keys(channelVersions); + + if (channels.length === 0) { + return []; + } + + // Build IN query conditions + const channelConditions = channels + .map(() => { + return `(channel = ? AND version = ?)`; + }) + .join(" OR "); + + const query = ` + SELECT channel, type, \`blob\` + FROM checkpoint_blobs + WHERE thread_id = ? AND checkpoint_ns = ? AND (${channelConditions}) + AND type != 'empty' AND \`blob\` IS NOT NULL AND \`blob\` != '' + `; + + const args = [threadId, checkpointNs]; + channels.forEach((channel) => { + args.push(channel, channelVersions[channel]); + }); + + const results = await this.sequelize.query(query, { + replacements: args, + type: QueryTypes.SELECT, + }); + + const channelValues: [Uint8Array, Uint8Array, Uint8Array][] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const row of results as any[]) { + if (row.channel && row.type && row.blob && row.blob.length > 0) { + channelValues.push([ + new TextEncoder().encode(row.channel), + new TextEncoder().encode(row.type), + new Uint8Array(row.blob), + ]); + } + } + + return channelValues; + } catch (error) { + console.error("Error fetching channel blobs:", error); + return []; + } + } + + /** + * get pending writes data + */ + private async _getPendingWrites( + threadId: string, + checkpointNs: string, + checkpointId: string + ): Promise<[Uint8Array, Uint8Array, Uint8Array, Uint8Array][]> { + try { + const query = ` + SELECT task_id, channel, type, \`blob\` + FROM checkpoint_writes + WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? + AND \`blob\` IS NOT NULL AND \`blob\` != '' + ORDER BY task_id ASC, idx ASC + `; + + const results = await this.sequelize.query(query, { + replacements: [threadId, checkpointNs, checkpointId], + type: QueryTypes.SELECT, + }); + + const pendingWrites: [Uint8Array, Uint8Array, Uint8Array, Uint8Array][] = + []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const row of results as any[]) { + if (row.task_id && row.channel && row.blob && row.blob.length > 0) { + pendingWrites.push([ + new TextEncoder().encode(row.task_id), + new TextEncoder().encode(row.channel), + new TextEncoder().encode(row.type || ""), + new Uint8Array(row.blob), + ]); + } + } + + return pendingWrites; + } catch (error) { + console.error("Error fetching pending writes:", error); + return []; + } + } + + /** + * get pending sends data + */ + private async _getPendingSends( + threadId: string, + checkpointNs: string, + parentCheckpointId: string + ): Promise<[Uint8Array, Uint8Array][]> { + try { + const query = ` + SELECT type, \`blob\` + FROM checkpoint_writes + WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? + AND channel = ? AND \`blob\` IS NOT NULL AND \`blob\` != '' + ORDER BY idx ASC + `; + + const results = await this.sequelize.query(query, { + replacements: [threadId, checkpointNs, parentCheckpointId, TASKS], + type: QueryTypes.SELECT, + }); + + const pendingSends: [Uint8Array, Uint8Array][] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const row of results as any[]) { + if (row.type && row.blob && row.blob.length > 0) { + pendingSends.push([ + new TextEncoder().encode(row.type), + new Uint8Array(row.blob), + ]); + } + } + + return pendingSends; + } catch (error) { + console.error("Error fetching pending sends:", error); + return []; + } + } + + /** + * List checkpoints from the database. + * + * This method retrieves a list of checkpoint tuples from the MySQL database based + * on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). + */ + async *list( + config: RunnableConfig, + options?: CheckpointListOptions + ): AsyncGenerator { + const { filter, before, limit } = options ?? {}; + const [where, paramValues] = this._searchWhere(config, filter, before); + + // Build the query using raw SQL to avoid Sequelize literal issues + let query = "SELECT * FROM checkpoints"; + if (where && where.trim() !== "") { + query += ` ${where}`; + } + query += " ORDER BY checkpoint_id DESC"; + + if (limit !== undefined) { + query += ` LIMIT ${Number.parseInt(limit.toString(), 10)}`; + } + + const checkpoints = (await this.sequelize.query(query, { + replacements: paramValues, + type: QueryTypes.SELECT, + })) as Array<{ + thread_id: string; + checkpoint_ns: string; + checkpoint_id: string; + parent_checkpoint_id?: string; + checkpoint: Omit; + metadata: Record; + }>; + + for (const checkpointRecord of checkpoints) { + // Get channel_values + const channelBlobs = await CheckpointBlob.findAll({ + where: { + thread_id: checkpointRecord.thread_id, + checkpoint_ns: checkpointRecord.checkpoint_ns, + }, + }); + + // Build channel_values array + const channelValues: [Uint8Array, Uint8Array, Uint8Array][] = []; + for (const blob of channelBlobs) { + // Ensure all fields are not empty + if ( + blob.channel && + blob.channel.trim() !== "" && + blob.type && + blob.type.trim() !== "" && + blob.blob && + blob.blob.length > 0 + ) { + channelValues.push([ + new TextEncoder().encode(blob.channel), + new TextEncoder().encode(blob.type), + new Uint8Array(blob.blob), + ]); + } + } + + // Get pending_writes + const pendingWritesRecords = await CheckpointWrite.findAll({ + where: { + thread_id: checkpointRecord.thread_id, + checkpoint_ns: checkpointRecord.checkpoint_ns, + checkpoint_id: checkpointRecord.checkpoint_id, + }, + order: [ + ["task_id", "ASC"], + ["idx", "ASC"], + ], + }); + + const pendingWrites: [Uint8Array, Uint8Array, Uint8Array, Uint8Array][] = + []; + for (const write of pendingWritesRecords) { + // Ensure all fields are not empty + if ( + write.task_id && + write.task_id.trim() !== "" && + write.channel && + write.channel.trim() !== "" && + write.blob && + write.blob.length > 0 + ) { + pendingWrites.push([ + new TextEncoder().encode(write.task_id), + new TextEncoder().encode(write.channel), + new TextEncoder().encode(write.type || ""), + new Uint8Array(write.blob), + ]); + } + } + + // Get pending_sends (from parent checkpoint) + const pendingSends: [Uint8Array, Uint8Array][] = []; + if (checkpointRecord.parent_checkpoint_id) { + const parentWrites = await CheckpointWrite.findAll({ + where: { + thread_id: checkpointRecord.thread_id, + checkpoint_ns: checkpointRecord.checkpoint_ns, + checkpoint_id: checkpointRecord.parent_checkpoint_id, + channel: TASKS, + }, + order: [["idx", "ASC"]], + }); + + for (const write of parentWrites) { + // Ensure type is not empty + if ( + write.type && + write.type.trim() !== "" && + write.blob && + write.blob.length > 0 + ) { + pendingSends.push([ + new TextEncoder().encode(write.type), + new Uint8Array(write.blob), + ]); + } + } + } + + yield { + config: { + configurable: { + thread_id: checkpointRecord.thread_id, + checkpoint_ns: checkpointRecord.checkpoint_ns, + checkpoint_id: checkpointRecord.checkpoint_id, + }, + }, + checkpoint: await this._loadCheckpoint( + checkpointRecord.checkpoint, + channelValues, + pendingSends + ), + metadata: await this._loadMetadata(checkpointRecord.metadata), + parentConfig: checkpointRecord.parent_checkpoint_id + ? { + configurable: { + thread_id: checkpointRecord.thread_id, + checkpoint_ns: checkpointRecord.checkpoint_ns, + checkpoint_id: checkpointRecord.parent_checkpoint_id, + }, + } + : undefined, + pendingWrites: await this._loadWrites(pendingWrites), + }; + } + } + + /** + * Save a checkpoint to the database. + * + * This method saves a checkpoint to the MySQL database. The checkpoint is associated + * with the provided config and its parent config (if any). + */ + async put( + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + newVersions: ChannelVersions + ): Promise { + if (config.configurable?.thread_id === undefined) { + throw new Error( + `Missing "thread_id" field in "config.configurable" param` + ); + } + + const { + thread_id, + checkpoint_ns = "", + checkpoint_id, + } = config.configurable; + + const nextConfig = { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpoint.id, + }, + }; + + const transaction = await this.sequelize.transaction(); + + try { + const serializedBlobs = this._dumpBlobs( + thread_id, + checkpoint_ns, + checkpoint.channel_values, + newVersions + ); + + for (const [ + threadId, + checkpointNs, + channel, + version, + type, + blob, + ] of serializedBlobs) { + await CheckpointBlob.upsert( + { + thread_id: threadId, + checkpoint_ns: checkpointNs, + channel, + version, + type, + blob: blob ? Buffer.from(blob) : null, + }, + { transaction } + ); + } + + // Save checkpoint + await CheckpointModel.upsert( + { + thread_id, + checkpoint_ns, + checkpoint_id: checkpoint.id, + parent_checkpoint_id: checkpoint_id, + checkpoint: this._dumpCheckpoint(checkpoint), + metadata: this._dumpMetadata(metadata), + }, + { transaction } + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error("❌ MySQLSaver put failed: ", error); + throw error; + } + + return nextConfig; + } + + /** + * Store intermediate writes linked to a checkpoint. + * + * This method saves intermediate writes associated with a checkpoint to the MySQL database. + * @param config Configuration of the related checkpoint. + * @param writes List of writes to store. + * @param taskId Identifier for the task creating the writes. + */ + async putWrites( + config: RunnableConfig, + writes: PendingWrite[], + taskId: string + ): Promise { + const dumpedWrites = this._dumpWrites( + config.configurable?.thread_id, + config.configurable?.checkpoint_ns, + config.configurable?.checkpoint_id, + taskId, + writes + ); + + const transaction = await this.sequelize.transaction(); + + try { + for (const [ + threadId, + checkpointNs, + checkpointId, + taskId, + idx, + channel, + type, + blob, + ] of dumpedWrites) { + await CheckpointWrite.upsert( + { + thread_id: threadId, + checkpoint_ns: checkpointNs, + checkpoint_id: checkpointId, + task_id: taskId, + idx, + channel, + type, + blob: Buffer.from(blob), + }, + { transaction } + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + async end() { + return this.sequelize.close(); + } +} diff --git a/libs/checkpoint-mysql/src/migration.sql b/libs/checkpoint-mysql/src/migration.sql new file mode 100644 index 000000000..ef6a4a7d8 --- /dev/null +++ b/libs/checkpoint-mysql/src/migration.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS checkpoint_migrations ( + v INT PRIMARY KEY COMMENT 'Migration version number, used to track database structure version' +) COMMENT 'Database migration version record table, used to manage database structure version control'; + +CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id VARCHAR(150) NOT NULL COMMENT 'Thread ID, used to identify different execution threads', + checkpoint_ns VARCHAR(150) NOT NULL DEFAULT '' COMMENT 'Checkpoint namespace, used to distinguish different types of checkpoints', + checkpoint_id VARCHAR(150) NOT NULL COMMENT 'Checkpoint unique identifier', + parent_checkpoint_id VARCHAR(150) COMMENT 'Parent checkpoint ID, used to build checkpoint hierarchy', + type VARCHAR(150) COMMENT 'Checkpoint type, identifies the purpose of the checkpoint', + checkpoint JSON NOT NULL COMMENT 'Checkpoint data, stores complete checkpoint state information', + metadata JSON NOT NULL DEFAULT ('{}') COMMENT 'Metadata, stores additional information about the checkpoint', + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) +) COMMENT 'Checkpoint main table, stores checkpoint data during LangGraph execution'; + +CREATE TABLE IF NOT EXISTS checkpoint_blobs ( + thread_id VARCHAR(150) NOT NULL COMMENT 'Thread ID, linked to checkpoint main table', + checkpoint_ns VARCHAR(150) NOT NULL DEFAULT '' COMMENT 'Checkpoint namespace', + channel VARCHAR(150) NOT NULL COMMENT 'Channel name, used to identify data channels', + version VARCHAR(150) NOT NULL COMMENT 'Version number, used for version control', + type VARCHAR(150) NOT NULL COMMENT 'Data type, identifies the type of blob data', + `blob` LONGBLOB COMMENT 'Binary data, stores large binary objects', + PRIMARY KEY (thread_id, checkpoint_ns, channel, version) +) COMMENT 'Binary data table, stores checkpoint-related binary large object data'; + +CREATE TABLE IF NOT EXISTS checkpoint_writes ( + thread_id VARCHAR(150) NOT NULL COMMENT 'Thread ID, linked to checkpoint main table', + checkpoint_ns VARCHAR(150) NOT NULL DEFAULT '' COMMENT 'Checkpoint namespace', + checkpoint_id VARCHAR(150) NOT NULL COMMENT 'Checkpoint ID, linked to checkpoint main table', + task_id VARCHAR(150) NOT NULL COMMENT 'Task ID, identifies specific execution tasks', + idx INT NOT NULL COMMENT 'Index number, used to sort write operations', + channel VARCHAR(150) NOT NULL COMMENT 'Channel name, identifies the channel for data writing', + type VARCHAR(150) COMMENT 'Data type, identifies the type of data being written', + `blob` LONGBLOB NOT NULL COMMENT 'Binary data, stores data to be written', + PRIMARY KEY ( + thread_id, + checkpoint_ns, + checkpoint_id, + task_id, + idx + ) +) COMMENT 'Write queue table, stores intermediate data to be written, supports asynchronous write operations'; \ No newline at end of file diff --git a/libs/checkpoint-mysql/src/models.ts b/libs/checkpoint-mysql/src/models.ts new file mode 100644 index 000000000..42062905d --- /dev/null +++ b/libs/checkpoint-mysql/src/models.ts @@ -0,0 +1,240 @@ +import { DataTypes, Model, Sequelize } from "sequelize"; + +export class CheckpointMigration extends Model { + declare v: number; +} + +export class CheckpointModel extends Model { + declare thread_id: string; + + declare checkpoint_ns: string; + + declare checkpoint_id: string; + + declare parent_checkpoint_id: string | null; + + declare type: string | null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + declare checkpoint: any; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + declare metadata: any; +} + +export class CheckpointBlob extends Model { + declare thread_id: string; + + declare checkpoint_ns: string; + + declare channel: string; + + declare version: string; + + declare type: string; + + declare blob: Buffer | null; +} + +export class CheckpointWrite extends Model { + declare thread_id: string; + + declare checkpoint_ns: string; + + declare checkpoint_id: string; + + declare task_id: string; + + declare idx: number; + + declare channel: string; + + declare type: string | null; + + declare blob: Buffer; +} + +export function _initializeModels(sequelize: Sequelize) { + // Initialize migration table model + CheckpointMigration.init( + { + v: { + type: DataTypes.INTEGER, + primaryKey: true, + }, + }, + { + sequelize, + modelName: "CheckpointMigration", + tableName: "checkpoint_migrations", + timestamps: false, + freezeTableName: true, + } + ); + + // Initialize checkpoint table model + CheckpointModel.init( + { + thread_id: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + checkpoint_ns: { + type: DataTypes.STRING(150), + allowNull: false, + defaultValue: "", + primaryKey: true, + }, + checkpoint_id: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + parent_checkpoint_id: { + type: DataTypes.STRING(150), + allowNull: true, + }, + type: { + type: DataTypes.STRING(150), + allowNull: true, + }, + checkpoint: { + type: DataTypes.JSON, + allowNull: false, + }, + metadata: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: {}, + }, + }, + { + sequelize, + modelName: "Checkpoint", + tableName: "checkpoints", + timestamps: false, + freezeTableName: true, + indexes: [ + { + name: "idx_checkpoints_pk", + unique: true, + fields: ["thread_id", "checkpoint_ns", "checkpoint_id"], + }, + ], + } + ); + + // Initialize checkpoint blob table model + CheckpointBlob.init( + { + thread_id: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + checkpoint_ns: { + type: DataTypes.STRING(150), + allowNull: false, + defaultValue: "", + primaryKey: true, + }, + channel: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + version: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + type: { + type: DataTypes.STRING(150), + allowNull: false, + }, + blob: { + type: DataTypes.BLOB("long"), + allowNull: true, + }, + }, + { + sequelize, + modelName: "CheckpointBlob", + tableName: "checkpoint_blobs", + timestamps: false, + freezeTableName: true, + indexes: [ + { + name: "idx_checkpoint_blobs_pk", + unique: true, + fields: ["thread_id", "checkpoint_ns", "channel", "version"], + }, + ], + } + ); + + // Initialize checkpoint write table model + CheckpointWrite.init( + { + thread_id: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + checkpoint_ns: { + type: DataTypes.STRING(150), + allowNull: false, + defaultValue: "", + primaryKey: true, + }, + checkpoint_id: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + task_id: { + type: DataTypes.STRING(150), + allowNull: false, + primaryKey: true, + }, + idx: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + }, + channel: { + type: DataTypes.STRING(150), + allowNull: false, + }, + type: { + type: DataTypes.STRING(150), + allowNull: true, + }, + blob: { + type: DataTypes.BLOB("long"), + allowNull: false, + }, + }, + { + sequelize, + modelName: "CheckpointWrite", + tableName: "checkpoint_writes", + timestamps: false, + freezeTableName: true, + indexes: [ + { + name: "idx_checkpoint_writes_pk", + unique: true, + fields: [ + "thread_id", + "checkpoint_ns", + "checkpoint_id", + "task_id", + "idx", + ], + }, + ], + } + ); +} diff --git a/libs/checkpoint-mysql/src/tests/checkpoints.int.test.ts b/libs/checkpoint-mysql/src/tests/checkpoints.int.test.ts new file mode 100644 index 000000000..87eeeeb49 --- /dev/null +++ b/libs/checkpoint-mysql/src/tests/checkpoints.int.test.ts @@ -0,0 +1,405 @@ +/* eslint-disable no-process-env */ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import type { + Checkpoint, + CheckpointTuple, +} from "@langchain/langgraph-checkpoint"; +import { uuid6 } from "@langchain/langgraph-checkpoint"; +import { Sequelize } from "sequelize"; +import { MySQLSaver } from "../index.js"; + +const checkpoint1: Checkpoint = { + v: 1, + id: uuid6(-1), + ts: "2024-04-19T17:19:07.952Z", + channel_values: { + someKey1: "someValue1", + }, + channel_versions: { + someKey1: 1, + someKey2: 1, + }, + versions_seen: { + someKey3: { + someKey4: 1, + }, + }, + pending_sends: [], +}; + +const checkpoint2: Checkpoint = { + v: 1, + id: uuid6(1), + ts: "2024-04-20T17:19:07.952Z", + channel_values: { + someKey1: "someValue2", + }, + channel_versions: { + someKey1: 1, + someKey2: 2, + }, + versions_seen: { + someKey3: { + someKey4: 2, + }, + }, + pending_sends: [], +}; + +const { TEST_MYSQL_URL } = process.env; + +if (!TEST_MYSQL_URL) { + throw new Error("TEST_MYSQL_URL environment variable is required"); +} +describe("MySQLSaver", () => { + let testDbName: string; + let mysqlSavers: MySQLSaver[] = []; + let testDatabases: string[] = []; + let mysqlSaver: MySQLSaver; + let sequelize: Sequelize; + + beforeEach(async () => { + // Generate a unique database name + testDbName = `lg_test_db_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + testDatabases.push(testDbName); + + // Parse the base connection string to get connection info + const url = new URL(TEST_MYSQL_URL); + const baseConnectionString = `mysql://${url.username}:${url.password}@${ + url.hostname + }:${url.port || "3306"}`; + + // Create base Sequelize instance to create database + const baseSequelize = new Sequelize(baseConnectionString); + + try { + // Create the test database + await baseSequelize.query(`CREATE DATABASE IF NOT EXISTS ${testDbName}`); + console.log(`✅ Created database: ${testDbName}`); + } finally { + await baseSequelize.close(); + } + + // Create connection string with the new database + const dbConnectionString = `${baseConnectionString}/${testDbName}`; + + // Create Sequelize instance for the test database + sequelize = new Sequelize(dbConnectionString); + + // Create MySQLSaver instance + mysqlSaver = new MySQLSaver(sequelize); + mysqlSavers.push(mysqlSaver); + + // Setup the database using MySQLSaver's setup method + await mysqlSaver.setup(); + }); + + afterAll(async () => { + await Promise.all(mysqlSavers.map((saver) => saver.end())); + // clear the ended savers to clean up for the next test + mysqlSavers = []; + + // Drop all test databases + const url = new URL(TEST_MYSQL_URL); + const baseConnectionString = `mysql://${url.username}:${url.password}@${ + url.hostname + }:${url.port || "3306"}`; + const baseSequelize = new Sequelize(baseConnectionString); + + try { + for (const dbName of testDatabases) { + await baseSequelize.query(`DROP DATABASE IF EXISTS ${dbName}`); + console.log(`🗑️ Dropped database: ${dbName}`); + } + } finally { + await baseSequelize.close(); + testDatabases = []; + } + }); + + it("should properly initialize and setup the database", async () => { + // Verify that the database is properly initialized by checking if tables exist + const [tablesResult] = await sequelize.query(` + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('checkpoints', 'checkpoint_blobs', 'checkpoint_writes', 'checkpoint_migrations') + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((tablesResult as any[]).length).toBe(4); + + // Verify table structures + const [checkpointsColumns] = await sequelize.query(` + SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'checkpoints' + ORDER BY ORDINAL_POSITION + `); + + expect(checkpointsColumns).toEqual([ + { + COLUMN_NAME: "thread_id", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "checkpoint_ns", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: "", + }, + { + COLUMN_NAME: "checkpoint_id", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "parent_checkpoint_id", + DATA_TYPE: "varchar", + IS_NULLABLE: "YES", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "type", + DATA_TYPE: "varchar", + IS_NULLABLE: "YES", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "checkpoint", + DATA_TYPE: "json", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "metadata", + DATA_TYPE: "json", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + ]); + + const [checkpointBlobsColumns] = await sequelize.query(` + SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'checkpoint_blobs' + ORDER BY ORDINAL_POSITION + `); + + expect(checkpointBlobsColumns).toEqual([ + { + COLUMN_NAME: "thread_id", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "checkpoint_ns", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: "", + }, + { + COLUMN_NAME: "channel", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "version", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "type", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "blob", + DATA_TYPE: "longblob", + IS_NULLABLE: "YES", + COLUMN_DEFAULT: null, + }, + ]); + + const [checkpointWritesColumns] = await sequelize.query(` + SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'checkpoint_writes' + ORDER BY ORDINAL_POSITION + `); + + expect(checkpointWritesColumns).toEqual([ + { + COLUMN_NAME: "thread_id", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "checkpoint_ns", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: "", + }, + { + COLUMN_NAME: "checkpoint_id", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "task_id", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "idx", + DATA_TYPE: "int", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "channel", + DATA_TYPE: "varchar", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "type", + DATA_TYPE: "varchar", + IS_NULLABLE: "YES", + COLUMN_DEFAULT: null, + }, + { + COLUMN_NAME: "blob", + DATA_TYPE: "longblob", + IS_NULLABLE: "NO", + COLUMN_DEFAULT: null, + }, + ]); + + // Verify migrations table has correct number of entries + const [migrationsResult] = await sequelize.query(` + SELECT COUNT(*) as count + FROM checkpoint_migrations + `); + + console.log("migrationsResult", migrationsResult); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(Number.parseInt((migrationsResult as any[])[0].count, 10)).toBe(0); + }); + + it("should save and retrieve checkpoints correctly", async () => { + // get undefined checkpoint + const undefinedCheckpoint = await mysqlSaver.getTuple({ + configurable: { thread_id: "1", checkpoint_ns: "" }, + }); + expect(undefinedCheckpoint).toBeUndefined(); + + // save first checkpoint + const runnableConfig = await mysqlSaver.put( + { configurable: { thread_id: "1", checkpoint_ns: "" } }, + checkpoint1, + { source: "update", step: -1, writes: null, parents: {} }, + checkpoint1.channel_versions + ); + expect(runnableConfig).toEqual({ + configurable: { + thread_id: "1", + checkpoint_ns: "", + checkpoint_id: checkpoint1.id, + }, + }); + + // add some writes + await mysqlSaver.putWrites( + { + configurable: { + checkpoint_id: checkpoint1.id, + checkpoint_ns: "", + thread_id: "1", + }, + }, + [["bar", "baz"]], + "foo" + ); + + // get first checkpoint tuple + const firstCheckpointTuple = await mysqlSaver.getTuple({ + configurable: { thread_id: "1" }, + }); + expect(firstCheckpointTuple?.config).toEqual({ + configurable: { + thread_id: "1", + checkpoint_ns: "", + checkpoint_id: checkpoint1.id, + }, + }); + expect(firstCheckpointTuple?.checkpoint).toEqual(checkpoint1); + expect(firstCheckpointTuple?.metadata).toEqual({ + source: "update", + step: -1, + writes: null, + parents: {}, + }); + expect(firstCheckpointTuple?.parentConfig).toBeUndefined(); + expect(firstCheckpointTuple?.pendingWrites).toEqual([ + ["foo", "bar", "baz"], + ]); + + // save second checkpoint + await mysqlSaver.put( + { + configurable: { + thread_id: "1", + checkpoint_ns: "", + checkpoint_id: "2024-04-18T17:19:07.952Z", + }, + }, + checkpoint2, + { source: "update", step: -1, writes: null, parents: {} }, + checkpoint2.channel_versions + ); + + // verify that parentTs is set and retrieved correctly for second checkpoint + const secondCheckpointTuple = await mysqlSaver.getTuple({ + configurable: { thread_id: "1" }, + }); + expect(secondCheckpointTuple?.metadata).toEqual({ + source: "update", + step: -1, + writes: null, + parents: {}, + }); + expect(secondCheckpointTuple?.parentConfig).toEqual({ + configurable: { + thread_id: "1", + checkpoint_ns: "", + checkpoint_id: "2024-04-18T17:19:07.952Z", + }, + }); + + // list checkpoints + const checkpointTupleGenerator = mysqlSaver.list({ + configurable: { thread_id: "1" }, + }); + const checkpointTuples: CheckpointTuple[] = []; + for await (const checkpoint of checkpointTupleGenerator) { + checkpointTuples.push(checkpoint); + } + expect(checkpointTuples.length).toBe(2); + const checkpointTuple1 = checkpointTuples[0]; + const checkpointTuple2 = checkpointTuples[1]; + expect(checkpointTuple1.checkpoint.ts).toBe("2024-04-20T17:19:07.952Z"); + expect(checkpointTuple2.checkpoint.ts).toBe("2024-04-19T17:19:07.952Z"); + }); +}); diff --git a/libs/checkpoint-mysql/tsconfig.cjs.json b/libs/checkpoint-mysql/tsconfig.cjs.json new file mode 100644 index 000000000..a67a84ea4 --- /dev/null +++ b/libs/checkpoint-mysql/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node10", + "declaration": false + }, + "exclude": ["node_modules", "dist", "docs", "**/tests"] +} diff --git a/libs/checkpoint-mysql/tsconfig.json b/libs/checkpoint-mysql/tsconfig.json new file mode 100644 index 000000000..e66242436 --- /dev/null +++ b/libs/checkpoint-mysql/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@tsconfig/recommended", + "compilerOptions": { + "outDir": "../dist", + "rootDir": "./src", + "target": "ES2021", + "lib": ["ES2021", "ES2022.Object", "DOM"], + "module": "ES2020", + "moduleResolution": "bundler", + "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-mysql/turbo.json b/libs/checkpoint-mysql/turbo.json new file mode 100644 index 000000000..e1bc5725a --- /dev/null +++ b/libs/checkpoint-mysql/turbo.json @@ -0,0 +1,12 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["**/dist/**"] + }, + "build:internal": { + "dependsOn": ["^build:internal"], + "outputs": ["**/dist/**"] + } + } +} diff --git a/libs/checkpoint-mysql/vitest.config.js b/libs/checkpoint-mysql/vitest.config.js new file mode 100644 index 000000000..cb2ee1e41 --- /dev/null +++ b/libs/checkpoint-mysql/vitest.config.js @@ -0,0 +1,36 @@ +import { configDefaults, defineConfig } from "vitest/config"; + +export default defineConfig((env) => { + /** @type {import("vitest/config").UserConfigExport} */ + const common = { + test: { + hideSkippedTests: true, + globals: true, + testTimeout: 30_000, + exclude: ["**/*.int.test.ts", ...configDefaults.exclude], + passWithNoTests: true, + }, + }; + + if (env.mode === "int") { + return { + test: { + ...common.test, + minWorkers: 0.5, + testTimeout: 100_000, + exclude: configDefaults.exclude, + include: ["**/*.int.test.ts"], + name: "int", + environment: "node", + }, + }; + } + + return { + test: { + ...common.test, + name: "unit", + environment: "node", + }, + }; +}); diff --git a/yarn.lock b/yarn.lock index 11fd2e3ae..1640e013f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1801,6 +1801,39 @@ __metadata: languageName: unknown linkType: soft +"@langchain/langgraph-checkpoint-mysql@workspace:libs/checkpoint-mysql": + version: 0.0.0-use.local + resolution: "@langchain/langgraph-checkpoint-mysql@workspace:libs/checkpoint-mysql" + dependencies: + "@langchain/langgraph-checkpoint": "workspace:*" + "@langchain/scripts": "npm:>=0.1.2 <0.2.0" + "@tsconfig/recommended": "npm:^1.0.3" + "@types/pg": "npm:^8.11.8" + "@types/uuid": "npm:^10" + "@typescript-eslint/eslint-plugin": "npm:^6.12.0" + "@typescript-eslint/parser": "npm:^6.12.0" + dotenv: "npm:^16.3.1" + dpdm: "npm:^3.12.0" + eslint: "npm:^8.33.0" + eslint-config-airbnb-base: "npm:^15.0.0" + eslint-config-prettier: "npm:^8.6.0" + eslint-plugin-import: "npm:^2.29.1" + eslint-plugin-no-instanceof: "npm:^1.0.1" + eslint-plugin-prettier: "npm:^4.2.1" + mysql2: "npm:^3.14.1" + prettier: "npm:^2.8.3" + release-it: "npm:^19.0.2" + rollup: "npm:^4.37.0" + sequelize: "npm:^6.37.7" + tsx: "npm:^4.19.3" + typescript: "npm:^4.9.5 || ^5.4.5" + vitest: "npm:^3.1.2" + peerDependencies: + "@langchain/core": ">=0.2.31 <0.4.0" + "@langchain/langgraph-checkpoint": ~0.0.15 + languageName: unknown + linkType: soft + "@langchain/langgraph-checkpoint-postgres@workspace:*, @langchain/langgraph-checkpoint-postgres@workspace:libs/checkpoint-postgres": version: 0.0.0-use.local resolution: "@langchain/langgraph-checkpoint-postgres@workspace:libs/checkpoint-postgres" @@ -3864,6 +3897,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4.1.8": + version: 4.1.12 + resolution: "@types/debug@npm:4.1.12" + dependencies: + "@types/ms": "npm:*" + checksum: 10/47876a852de8240bfdaf7481357af2b88cb660d30c72e73789abf00c499d6bc7cd5e52f41c915d1b9cd8ec9fef5b05688d7b7aef17f7f272c2d04679508d1053 + languageName: node + linkType: hard + "@types/docker-modem@npm:*": version: 3.0.6 resolution: "@types/docker-modem@npm:3.0.6" @@ -3941,6 +3983,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 2.1.0 + resolution: "@types/ms@npm:2.1.0" + checksum: 10/532d2ebb91937ccc4a89389715e5b47d4c66e708d15942fe6cc25add6dc37b2be058230a327dd50f43f89b8b6d5d52b74685a9e8f70516edfc9bdd6be910eff4 + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.6.4": version: 2.6.11 resolution: "@types/node-fetch@npm:2.6.11" @@ -4136,6 +4185,13 @@ __metadata: languageName: node linkType: hard +"@types/validator@npm:^13.7.17": + version: 13.15.2 + resolution: "@types/validator@npm:13.15.2" + checksum: 10/0d6e349329359c6781b1a6b4f48349fe3221b655041887bdf9e3e3c3508716106f3fe00db9a33002288e5a4b5abdcc49d7128d5b1d3edcfac11bda7aa696b34d + languageName: node + linkType: hard + "@types/webidl-conversions@npm:*": version: 7.0.3 resolution: "@types/webidl-conversions@npm:7.0.3" @@ -4867,6 +4923,13 @@ __metadata: languageName: node linkType: hard +"aws-ssl-profiles@npm:^1.1.1": + version: 1.1.2 + resolution: "aws-ssl-profiles@npm:1.1.2" + checksum: 10/af9e5c5e6e343e0f299106acaf03106a7458be69772d004f3e4cf0e3649bb41131b594126fcbc997ad89d73752d9e1d72886c72fcc8649ac5d590459d6b75827 + languageName: node + linkType: hard + "axios@npm:^1.6.7": version: 1.8.3 resolution: "axios@npm:1.8.3" @@ -6602,6 +6665,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:^2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10/8ea05321576624b90acfc1ee9208b8d1d04b425cf7573b9b4fa40a2c3ed4d4b0af5190567858f532f677ed2003d4d2b73c8130b34e3c7b8d5e88cdcfbfaa1fe7 + languageName: node + linkType: hard + "dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" @@ -6787,6 +6857,13 @@ __metadata: languageName: node linkType: hard +"dottie@npm:^2.0.6": + version: 2.0.6 + resolution: "dottie@npm:2.0.6" + checksum: 10/698731cfa2c1b530ba3491fa864dc572678a2a6de801f25912e2e4d7d4669ae013b696711786016bf41c7b6f98057c678503f14550bb171b3f70cdadffb9218f + languageName: node + linkType: hard + "dpdm@npm:^3.12.0": version: 3.14.0 resolution: "dpdm@npm:3.14.0" @@ -8072,6 +8149,15 @@ __metadata: languageName: node linkType: hard +"generate-function@npm:^2.3.1": + version: 2.3.1 + resolution: "generate-function@npm:2.3.1" + dependencies: + is-property: "npm:^1.0.2" + checksum: 10/318f85af87c3258d86df4ebbb56b63a2ae52e71bd6cde8d0a79de09450de7422a7047fb1f8d52ccc135564a36cb986d73c63149eed96b7ac57e38acba44f29e2 + languageName: node + linkType: hard + "generic-names@npm:^4.0.0": version: 4.0.0 resolution: "generic-names@npm:4.0.0" @@ -8620,7 +8706,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6, iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6, iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -8685,6 +8771,13 @@ __metadata: languageName: node linkType: hard +"inflection@npm:^1.13.4": + version: 1.13.4 + resolution: "inflection@npm:1.13.4" + checksum: 10/a0cc1b105ccbda9607b5d1610b5c7aa35456ca06b7f3573a47c677e1f829052859cacc36601c3c07de89cb756616a440814ef2d190a6ae70398e6aa6efc2a547 + languageName: node + linkType: hard + "inflight@npm:^1.0.4": version: 1.0.6 resolution: "inflight@npm:1.0.6" @@ -8997,6 +9090,13 @@ __metadata: languageName: node linkType: hard +"is-property@npm:^1.0.2": + version: 1.0.2 + resolution: "is-property@npm:1.0.2" + checksum: 10/2f66eacb3d7237ba5c725496672edec656a20b12c80790921988578e6b11c258a062ce1e602f3cd2e3c2e05dd8b6e24e1d59254375207f157424a02ef0abb3d7 + languageName: node + linkType: hard + "is-reference@npm:^1.1.2": version: 1.2.1 resolution: "is-reference@npm:1.2.1" @@ -9777,7 +9877,7 @@ __metadata: languageName: node linkType: hard -"long@npm:^5.0.0": +"long@npm:^5.0.0, long@npm:^5.2.1": version: 5.3.2 resolution: "long@npm:5.3.2" checksum: 10/b6b55ddae56fcce2864d37119d6b02fe28f6dd6d9e44fd22705f86a9254b9321bd69e9ffe35263b4846d54aba197c64882adcb8c543f2383c1e41284b321ea64 @@ -9821,6 +9921,13 @@ __metadata: languageName: node linkType: hard +"lru.min@npm:^1.0.0": + version: 1.1.2 + resolution: "lru.min@npm:1.1.2" + checksum: 10/74e56b21048a496dda6a5bd2b6104d919b3bae7ddfd4e50b2724a0b48516394f0b95dd350b8a8818572bfeac9944c9a69c405dac8825f6ddf082775fe0592c6b + languageName: node + linkType: hard + "lunr@npm:^2.3.9": version: 2.3.9 resolution: "lunr@npm:2.3.9" @@ -10209,6 +10316,22 @@ __metadata: languageName: node linkType: hard +"moment-timezone@npm:^0.5.43": + version: 0.5.48 + resolution: "moment-timezone@npm:0.5.48" + dependencies: + moment: "npm:^2.29.4" + checksum: 10/8e0b7a05577623552293b28eeee4e60634b8be87fdb74084fa6d5ccc516771eb42d88f29c5a5e50a94c494048d14cdef94f94526a9dfd5e1b0050ff29d0a6c0a + languageName: node + linkType: hard + +"moment@npm:^2.29.4": + version: 2.30.1 + resolution: "moment@npm:2.30.1" + checksum: 10/ae42d876d4ec831ef66110bdc302c0657c664991e45cf2afffc4b0f6cd6d251dde11375c982a5c0564ccc0fa593fc564576ddceb8c8845e87c15f58aa6baca69 + languageName: node + linkType: hard + "mongodb-connection-string-url@npm:^3.0.0": version: 3.0.1 resolution: "mongodb-connection-string-url@npm:3.0.1" @@ -10356,6 +10479,32 @@ __metadata: languageName: node linkType: hard +"mysql2@npm:^3.14.1": + version: 3.14.1 + resolution: "mysql2@npm:3.14.1" + dependencies: + aws-ssl-profiles: "npm:^1.1.1" + denque: "npm:^2.1.0" + generate-function: "npm:^2.3.1" + iconv-lite: "npm:^0.6.3" + long: "npm:^5.2.1" + lru.min: "npm:^1.0.0" + named-placeholders: "npm:^1.1.3" + seq-queue: "npm:^0.0.5" + sqlstring: "npm:^2.3.2" + checksum: 10/d3eb069b5f42d735df9f89be1c194d432cafe9064ce5d7352eda6597fb90b33e671dd46adc88256174b46c7972db99923f9db702ac2aaf0fbd0430c19dd90ed0 + languageName: node + linkType: hard + +"named-placeholders@npm:^1.1.3": + version: 1.1.3 + resolution: "named-placeholders@npm:1.1.3" + dependencies: + lru-cache: "npm:^7.14.1" + checksum: 10/7834adc91e92ae1b9c4413384e3ccd297de5168bb44017ff0536705ddc4db421723bd964607849265feb3f6ded390f84cf138e5925f22f7c13324f87a803dc73 + languageName: node + linkType: hard + "nan@npm:^2.19.0, nan@npm:^2.20.0": version: 2.20.0 resolution: "nan@npm:2.20.0" @@ -11249,6 +11398,13 @@ __metadata: languageName: node linkType: hard +"pg-connection-string@npm:^2.6.1": + version: 2.9.1 + resolution: "pg-connection-string@npm:2.9.1" + checksum: 10/40e9e9cd752121e72bff18d83e6c7ecda9056426815a84294de018569a319293c924704c8b7f0604fdc588835c7927647dea4f3c87a014e715bcbb17d794e9f0 + languageName: node + linkType: hard + "pg-connection-string@npm:^2.6.4": version: 2.6.4 resolution: "pg-connection-string@npm:2.6.4" @@ -12250,6 +12406,13 @@ __metadata: languageName: node linkType: hard +"retry-as-promised@npm:^7.0.4": + version: 7.1.1 + resolution: "retry-as-promised@npm:7.1.1" + checksum: 10/95c5e29602704d2615849bf2fb80ec53474d40d3597269afa112c79be851f2935507379a253bebbb7996ef84a1d2464893b7a8dab5b80c4592e2b000fdeb2013 + languageName: node + linkType: hard + "retry@npm:0.13.1, retry@npm:^0.13.1": version: 0.13.1 resolution: "retry@npm:0.13.1" @@ -12633,6 +12796,63 @@ __metadata: languageName: node linkType: hard +"seq-queue@npm:^0.0.5": + version: 0.0.5 + resolution: "seq-queue@npm:0.0.5" + checksum: 10/fa302e3b2aaece644532603ae42d675f9b8750e395a98740dd58dc5e02985ce6f0c2b78715b5984d6f6a807893735a14212a70d6ec591e6fba410397269588a0 + languageName: node + linkType: hard + +"sequelize-pool@npm:^7.1.0": + version: 7.1.0 + resolution: "sequelize-pool@npm:7.1.0" + checksum: 10/eeb0837451afb245cf3aece5d93c50ef051bd7f4397c4e578f8cbf41ebf485e0acd887c1aa3f4394b80dc874229a32ce5aafeaa2f00ec3ecc5dfcc518bd7bf7e + languageName: node + linkType: hard + +"sequelize@npm:^6.37.7": + version: 6.37.7 + resolution: "sequelize@npm:6.37.7" + dependencies: + "@types/debug": "npm:^4.1.8" + "@types/validator": "npm:^13.7.17" + debug: "npm:^4.3.4" + dottie: "npm:^2.0.6" + inflection: "npm:^1.13.4" + lodash: "npm:^4.17.21" + moment: "npm:^2.29.4" + moment-timezone: "npm:^0.5.43" + pg-connection-string: "npm:^2.6.1" + retry-as-promised: "npm:^7.0.4" + semver: "npm:^7.5.4" + sequelize-pool: "npm:^7.1.0" + toposort-class: "npm:^1.0.1" + uuid: "npm:^8.3.2" + validator: "npm:^13.9.0" + wkx: "npm:^0.5.0" + peerDependenciesMeta: + ibm_db: + optional: true + mariadb: + optional: true + mysql2: + optional: true + oracledb: + optional: true + pg: + optional: true + pg-hstore: + optional: true + snowflake-sdk: + optional: true + sqlite3: + optional: true + tedious: + optional: true + checksum: 10/87be264ddf8201f4dde332198a922d05ac57f7f6d2c35fe2ad1484469771103053377d59d197c1cf5d4153178fc0e9fb1e749d3c7c2fa3f60a740e51b715c133 + languageName: node + linkType: hard + "set-function-length@npm:^1.2.1, set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -12978,6 +13198,13 @@ __metadata: languageName: node linkType: hard +"sqlstring@npm:^2.3.2": + version: 2.3.3 + resolution: "sqlstring@npm:2.3.3" + checksum: 10/4e5a25af2d77a031fe00694034bf9fd822ddc3a483c9383124b120aa6b9ae9ab71e173cd29fba9c653998ebfef9e97be668957839960b9b3dc1afcb45f1ddb64 + languageName: node + linkType: hard + "ssh-remote-port-forward@npm:^1.0.4": version: 1.0.4 resolution: "ssh-remote-port-forward@npm:1.0.4" @@ -13565,6 +13792,13 @@ __metadata: languageName: node linkType: hard +"toposort-class@npm:^1.0.1": + version: 1.0.1 + resolution: "toposort-class@npm:1.0.1" + checksum: 10/166cb89ecb544383691e69eb9305b637dbc239f30894af0984cb4ee40f266c8c0fcadadd4ee6879c5466d1fd9153b22cfa3b19b649337d0da927b866352f7d75 + languageName: node + linkType: hard + "totalist@npm:^3.0.0": version: 3.0.1 resolution: "totalist@npm:3.0.1" @@ -14126,6 +14360,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^8.3.2": + version: 8.3.2 + resolution: "uuid@npm:8.3.2" + bin: + uuid: dist/bin/uuid + checksum: 10/9a5f7aa1d6f56dd1e8d5f2478f855f25c645e64e26e347a98e98d95781d5ed20062d6cca2eecb58ba7c84bc3910be95c0451ef4161906abaab44f9cb68ffbdd1 + languageName: node + linkType: hard + "uuid@npm:^9.0.0": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -14135,6 +14378,13 @@ __metadata: languageName: node linkType: hard +"validator@npm:^13.9.0": + version: 13.15.15 + resolution: "validator@npm:13.15.15" + checksum: 10/a43d9271c879468b1ad6dd5d2597b71719a185d2c7ceb3d68f3c9c8c17c25af0d90a53edfa7efaa6ac0d4425ba0345684b9c7d8111bde0d06d915a634a287018 + languageName: node + linkType: hard + "vite-node@npm:3.1.2": version: 3.1.2 resolution: "vite-node@npm:3.1.2" @@ -14536,6 +14786,15 @@ __metadata: languageName: node linkType: hard +"wkx@npm:^0.5.0": + version: 0.5.0 + resolution: "wkx@npm:0.5.0" + dependencies: + "@types/node": "npm:*" + checksum: 10/b8975e33f9431380eb82707ec39689767f967a8ce362eea5303399618896c983a2dec3ad72fd7273bdf126181c760067519130434344891300ebd54f5d5cbf4a + languageName: node + linkType: hard + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0"