From 624bd6282049ee5a7c9e0fd6bcbea530aa027cb2 Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:27:48 +1300 Subject: [PATCH 1/7] feat(checkpoint-validation): add custom checkpointer validation tool --- .gitignore | 3 +- libs/checkpoint-validation/.env.example | 6 + libs/checkpoint-validation/.eslintrc.cjs | 69 ++ libs/checkpoint-validation/.gitignore | 7 + libs/checkpoint-validation/.prettierrc | 19 + libs/checkpoint-validation/.release-it.json | 13 + libs/checkpoint-validation/LICENSE | 21 + libs/checkpoint-validation/README.md | 103 +++ libs/checkpoint-validation/bin/cli.js | 5 + .../checkpoint-validation/bin/jest.config.cjs | 19 + libs/checkpoint-validation/jest.config.cjs | 38 + libs/checkpoint-validation/jest.env.cjs | 12 + .../checkpoint-validation/langchain.config.js | 21 + libs/checkpoint-validation/package.json | 106 +++ libs/checkpoint-validation/src/cli.ts | 109 +++ libs/checkpoint-validation/src/importUtils.ts | 68 ++ libs/checkpoint-validation/src/index.ts | 18 + libs/checkpoint-validation/src/runner.ts | 20 + libs/checkpoint-validation/src/spec/data.ts | 290 ++++++ .../src/spec/getTuple.ts | 265 ++++++ libs/checkpoint-validation/src/spec/index.ts | 48 + libs/checkpoint-validation/src/spec/list.ts | 341 +++++++ libs/checkpoint-validation/src/spec/put.ts | 341 +++++++ .../src/spec/putWrites.ts | 152 +++ libs/checkpoint-validation/src/spec/util.ts | 77 ++ libs/checkpoint-validation/src/testUtils.ts | 57 ++ .../src/tests/memory.spec.ts | 4 + .../src/tests/memoryInitializer.ts | 8 + .../src/tests/mongoInitializer.ts | 50 + .../src/tests/mongodb.spec.ts | 4 + .../src/tests/postgres.spec.ts | 4 + .../src/tests/postgresInitializer.ts | 64 ++ .../src/tests/sqlite.spec.ts | 5 + .../src/tests/sqliteInitializer.ts | 17 + libs/checkpoint-validation/src/types.ts | 107 +++ libs/checkpoint-validation/tsconfig.cjs.json | 17 + libs/checkpoint-validation/tsconfig.json | 23 + libs/checkpoint-validation/turbo.json | 11 + yarn.lock | 865 +++++++++++++++++- 39 files changed, 3372 insertions(+), 35 deletions(-) create mode 100644 libs/checkpoint-validation/.env.example create mode 100644 libs/checkpoint-validation/.eslintrc.cjs create mode 100644 libs/checkpoint-validation/.gitignore create mode 100644 libs/checkpoint-validation/.prettierrc create mode 100644 libs/checkpoint-validation/.release-it.json create mode 100644 libs/checkpoint-validation/LICENSE create mode 100644 libs/checkpoint-validation/README.md create mode 100755 libs/checkpoint-validation/bin/cli.js create mode 100644 libs/checkpoint-validation/bin/jest.config.cjs create mode 100644 libs/checkpoint-validation/jest.config.cjs create mode 100644 libs/checkpoint-validation/jest.env.cjs create mode 100644 libs/checkpoint-validation/langchain.config.js create mode 100644 libs/checkpoint-validation/package.json create mode 100644 libs/checkpoint-validation/src/cli.ts create mode 100644 libs/checkpoint-validation/src/importUtils.ts create mode 100644 libs/checkpoint-validation/src/index.ts create mode 100644 libs/checkpoint-validation/src/runner.ts create mode 100644 libs/checkpoint-validation/src/spec/data.ts create mode 100644 libs/checkpoint-validation/src/spec/getTuple.ts create mode 100644 libs/checkpoint-validation/src/spec/index.ts create mode 100644 libs/checkpoint-validation/src/spec/list.ts create mode 100644 libs/checkpoint-validation/src/spec/put.ts create mode 100644 libs/checkpoint-validation/src/spec/putWrites.ts create mode 100644 libs/checkpoint-validation/src/spec/util.ts create mode 100644 libs/checkpoint-validation/src/testUtils.ts create mode 100644 libs/checkpoint-validation/src/tests/memory.spec.ts create mode 100644 libs/checkpoint-validation/src/tests/memoryInitializer.ts create mode 100644 libs/checkpoint-validation/src/tests/mongoInitializer.ts create mode 100644 libs/checkpoint-validation/src/tests/mongodb.spec.ts create mode 100644 libs/checkpoint-validation/src/tests/postgres.spec.ts create mode 100644 libs/checkpoint-validation/src/tests/postgresInitializer.ts create mode 100644 libs/checkpoint-validation/src/tests/sqlite.spec.ts create mode 100644 libs/checkpoint-validation/src/tests/sqliteInitializer.ts create mode 100644 libs/checkpoint-validation/src/types.ts create mode 100644 libs/checkpoint-validation/tsconfig.cjs.json create mode 100644 libs/checkpoint-validation/tsconfig.json create mode 100644 libs/checkpoint-validation/turbo.json diff --git a/.gitignore b/.gitignore index e314d8ae..3224c9cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ index.js index.d.ts node_modules dist +coverage/ .yarn/* !.yarn/patches !.yarn/plugins @@ -18,4 +19,4 @@ dist .ipynb_checkpoints dist-cjs **/dist-cjs -tmp/ \ No newline at end of file +tmp/ diff --git a/libs/checkpoint-validation/.env.example b/libs/checkpoint-validation/.env.example new file mode 100644 index 00000000..aea660a4 --- /dev/null +++ b/libs/checkpoint-validation/.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-validation/.eslintrc.cjs b/libs/checkpoint-validation/.eslintrc.cjs new file mode 100644 index 00000000..02711dad --- /dev/null +++ b/libs/checkpoint-validation/.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-validation/.gitignore b/libs/checkpoint-validation/.gitignore new file mode 100644 index 00000000..c10034e2 --- /dev/null +++ b/libs/checkpoint-validation/.gitignore @@ -0,0 +1,7 @@ +index.cjs +index.js +index.d.ts +index.d.cts +node_modules +dist +.yarn diff --git a/libs/checkpoint-validation/.prettierrc b/libs/checkpoint-validation/.prettierrc new file mode 100644 index 00000000..ba08ff04 --- /dev/null +++ b/libs/checkpoint-validation/.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-validation/.release-it.json b/libs/checkpoint-validation/.release-it.json new file mode 100644 index 00000000..a1236e8d --- /dev/null +++ b/libs/checkpoint-validation/.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-validation/LICENSE b/libs/checkpoint-validation/LICENSE new file mode 100644 index 00000000..e7530f5e --- /dev/null +++ b/libs/checkpoint-validation/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-validation/README.md b/libs/checkpoint-validation/README.md new file mode 100644 index 00000000..0dc841fa --- /dev/null +++ b/libs/checkpoint-validation/README.md @@ -0,0 +1,103 @@ +# @langchain/langgraph-checkpoint-validation + +The checkpoint saver validation tool is used to validate that custom checkpoint saver implementations conform to LangGraph's requirements. LangGraph uses [checkpoint savers](https://langchain-ai.github.io/langgraphjs/concepts/persistence/#checkpointer-libraries) for persisting workflow state, providing the ability to "rewind" your workflow to some earlier point in time, and continue execution from there. + +The overall process for using this tool is as follows: + +1. Write your custom checkpoint saver implementation. +2. Add a file to your project that defines a [`CheckpointSaverTestInitializer`](./src/types.ts) as its default export. +3. Run the checkpoint saver validation tool to test your checkpoint saver and determine whether it meets LangGraph's requirements. +4. Iterate on your custom checkpoint saver as required, until tests pass. + +The tool can be executed from the terminal as a CLI, or you can use it as a library to integrate it into your test suite. + +## Writing a CheckpointSaverTestInitializer + +The `CheckpointSaverTestInitializer` interface ([example](./src/tests/postgresInitializer.ts)) is used by the test harness to create instances of your custom checkpoint saver, and any infrastructure that it requires for testing purposes. + +If you intend to execute the tool via the CLI, your `CheckpointSaverTestInitializer` **must** be the default export of the module in which it is defined. + +**Synchronous vs Asynchronous initializer functions**: You may return promises from any functions defined in your `CheckpointSaverTestInitializer` according to your needs and the test harness will behave accordingly. + +**IMPORTANT**: You must take care to write your `CheckpointSaverTestInitializer` such that instances of your custom checkpoint saver are isolated from one another with respect to persisted state, or else some tests (particularly the ones that exercise the `list` method) will fail. That is, state written by one instance of your checkpoint saver MUST NOT be readable by another instance of your checkpoint saver. That said, there will only ever be one instance of your checkpoint saver live at any given time, so **you may use shared storage, provided it is cleared when your checkpoint saver is created or destroyed.** The structure of the `CheckpointSaverTestInitializer` interface should make this relatively easy to achieve, per the sections below. + + +### (Required) `saverName`: Define a name for your checkpoint saver + +`CheckpointSaverTestInitializer` requires you to define a `saverName` field (of type `string`) for use in the test output. + +### `beforeAll`: Set up required infrastructure + +If your checkpoint saver requires some external infrastructure to be provisioned, you may wish to provision this via the **optional** `beforeAll` function. This function executes exactly once, at the very start of the testing lifecycle. If defined, it is the first function that will be called from your `CheckpointSaverTestInitializer`. + +**Timeout duration**: If your `beforeAll` function may take longer than 10 seconds to execute, you can assign a custom timeout duration (as milliseconds) to the optional `beforeAllTimeout` field of your `CheckpointSaverTestInitializer`. + +**State isolation note**: Depending on the cost/performance/requirements of your checkpoint saver infrastructure, it **may** make more sense for you to provision it during the `createSaver` step, so you can provide each checkpoint saver instance with its own isolated storage backend. However as mentioned above, you may also provision a single shared storage backend, provided you clear any stored data during the `createSaver` or `destroySaver` step. + +### `afterAll`: Tear down required infrastructure + +If you set up infrastructure during the `beforeAll` step, you may need to tear it down once the tests complete their execution. You can define this teardown logic in the optional `afterAll` function. Much like `beforeAll` this function will execute exactly one time, after all tests have finished executing. + +**IMPORTANT**: If you kill the test runner early this function may not be called. To avoid manual clean-up, give preference to test infrastructure management tools like [TestContainers](https://testcontainers.com/guides/getting-started-with-testcontainers-for-nodejs/), as these tools are designed to detect when this happens and clean up after themselves once the controlling process dies. + +### (Required) `createSaver`: Construct your checkpoint saver + +`CheckpointSaverTestInitializer` requires you to define a `createSaver(config: RunnableConfig)` function that returns an instance of your custom checkpoint saver. The `config` argument is provided to this function in case it is necessary for the construction of your custom checkpoint saver. + +**State isolation note:** If you're provisioning storage during this step, make sure that it is "fresh" storage for each instance of your checkpoint saver. Otherwise if you are using a shared storage setup, be sure to clear it either in this function, or in the `destroySaver` function (described in the section below). + +### `destroySaver`: Destroy your checkpoint saver + +If your custom checkpoint saver requires an explicit teardown step (for example, to clean up database connections), you can define this in the **optional** `destroySaver(saver: CheckpointSaverT, config: RunnableConfig)` function. + +**State isolation note:** If you are using a shared storage setup, be sure to clear it either in this function, or in the `createSaver` function (described in the section above). + +### `configure`: Customize the `RunnableConfig` object that is passed during testing + +If you need to customize the config argument that is passed to your custom checkpoint saver during testing, you can implement the **optional** `configure(config: RunnableConfig)` function. This function may inspect the default configuration (passed as the `config` argument) and must return an instance of `RunnableConfig`. The `RunnableConfig` returned by this function will be merged with the default config and passed to your checkpoint saver during testing. + +Some custom checkpoint savers may require additional custom configuration data to be present in the `configurable` object of the `RunnableConfig` in order to work correctly. For example, custom checkpoint savers that work as part of a multi-tenant application may require authentication details to be passed along in order to enforce data isolation requirements. + +## CLI usage + +You may use this tool's CLI either via `npx`, `yarn dlx`, or by installing globally and executing it via the `validate-saver` command. + +The only required argument to the tool is the import path for your `CheckpointSaverTestInitializer`. Relative paths must begin with a leading `./` (or `.\`, for Windows), otherwise the path will be interpreted as a module name rather than a relative path. + +You may optionally pass one or more test filters as positional arguments after the import path argument (separated by spaces). Valid values are `getTuple`, `list`, `put`, and `putWrites`. If present, only the test suites specified in the filter list will be executed. This is useful for working through smaller sets of test failures as you're validating your checkpoint saver. + +TypeScript imports **are** supported, so you may pass a path directly to your TypeScript source file. + +### NPX & Yarn execution + +NPX: + +```bash +cd MySaverProject +npx @langchain/langgraph-checkpoint-validation ./src/mySaverInitializer.ts +``` + +Yarn: + +```bash +yarn dlx @langchain/langgraph-checkpoint-validation ./src/mySaverInitializer.ts +``` + +### Global install + +NPM: + +```bash +npm install -g @langchain/langgraph-checkpoint-validation +validate-saver ./src/mySaverInitializer.ts +``` + +## Usage in existing Jest test suite + +If you wish to integrate this tooling into your existing Jest test suite, you import it as a library, as shown below. + +```ts +import { validate } from "@langchain/langgraph-validation"; + +validate(MyCheckpointSaverInitializer); +``` diff --git a/libs/checkpoint-validation/bin/cli.js b/libs/checkpoint-validation/bin/cli.js new file mode 100755 index 00000000..686ce94f --- /dev/null +++ b/libs/checkpoint-validation/bin/cli.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from "../dist/cli.js"; + +await main(); diff --git a/libs/checkpoint-validation/bin/jest.config.cjs b/libs/checkpoint-validation/bin/jest.config.cjs new file mode 100644 index 00000000..a99445d3 --- /dev/null +++ b/libs/checkpoint-validation/bin/jest.config.cjs @@ -0,0 +1,19 @@ +// This is the Jest config used by the test harness when being executed via the CLI. +// For the Jest config for the tests in this project, see the `jest.config.cjs` in the root of the package workspace. +const path = require("path"); + +const config = { + preset: "ts-jest/presets/default-esm", + rootDir: path.resolve(__dirname, "..", "dist"), + testEnvironment: "node", + testMatch: ["/runner.js"], + transform: { + "^.+\\.(ts|js)x?$": ["@swc/jest"], + }, + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.[jt]sx?$": "$1", + }, + maxWorkers: "50%", +}; + +module.exports = config; diff --git a/libs/checkpoint-validation/jest.config.cjs b/libs/checkpoint-validation/jest.config.cjs new file mode 100644 index 00000000..ab56a4c3 --- /dev/null +++ b/libs/checkpoint-validation/jest.config.cjs @@ -0,0 +1,38 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest/presets/default-esm", + rootDir: "../../", + testEnvironment: "./libs/checkpoint-validation/jest.env.cjs", + testMatch: ["/libs/checkpoint-validation/src/**/*.spec.ts"], + modulePathIgnorePatterns: ["dist/"], + + collectCoverageFrom: [ + "/libs/checkpoint/src/memory.ts", + "/libs/checkpoint-mongodb/src/index.ts", + "/libs/checkpoint-postgres/src/index.ts", + "/libs/checkpoint-sqlite/src/index.ts", + ], + + coveragePathIgnorePatterns: [ + ".+\\.(test|spec)\\.ts", + ], + + coverageDirectory: "/libs/checkpoint-validation/coverage", + + moduleNameMapper: { + "^@langchain/langgraph-(checkpoint(-[^/]+)?)$": "/libs/$1/src/index.ts", + "^@langchain/langgraph-(checkpoint(-[^/]+)?)/(.+)\\.js$": "/libs/$1/src/$2.ts", + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": ["@swc/jest"], + }, + transformIgnorePatterns: [ + "/node_modules/(?!@langchain/langgraph-checkpoint-[^/]+)", + "\\.pnp\\.[^\\/]+$", + "./scripts/jest-setup-after-env.js", + ], + setupFiles: ["dotenv/config"], + testTimeout: 20_000, + passWithNoTests: true, +}; diff --git a/libs/checkpoint-validation/jest.env.cjs b/libs/checkpoint-validation/jest.env.cjs new file mode 100644 index 00000000..2ccedccb --- /dev/null +++ b/libs/checkpoint-validation/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-validation/langchain.config.js b/libs/checkpoint-validation/langchain.config.js new file mode 100644 index 00000000..fe70c345 --- /dev/null +++ b/libs/checkpoint-validation/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-validation/package.json b/libs/checkpoint-validation/package.json new file mode 100644 index 00000000..0aa64ae0 --- /dev/null +++ b/libs/checkpoint-validation/package.json @@ -0,0 +1,106 @@ +{ + "name": "@langchain/langgraph-checkpoint-validation", + "version": "0.0.1-alpha.1", + "description": "Library for validating LangGraph checkpoint saver implementations.", + "type": "module", + "engines": { + "node": ">=18" + }, + "main": "./index.cjs", + "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-validation", + "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": { + "@jest/core": "^29.5.0", + "@jest/globals": "^29.5.0", + "@swc-node/register": "^1.10.9", + "@swc/core": "^1.3.90", + "@swc/jest": "^0.2.29", + "jest": "^29.5.0", + "jest-environment-node": "^29.6.4", + "uuid": "^10.0.0", + "yargs": "^17.7.2", + "zod": "^3.23.8" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.31 <0.4.0", + "@langchain/langgraph-checkpoint": "~0.0.6" + }, + "devDependencies": { + "@langchain/langgraph-checkpoint": "workspace:*", + "@langchain/langgraph-checkpoint-mongodb": "workspace:*", + "@langchain/langgraph-checkpoint-postgres": "workspace:*", + "@langchain/langgraph-checkpoint-sqlite": "workspace:*", + "@langchain/scripts": ">=0.1.3 <0.2.0", + "@testcontainers/mongodb": "^10.13.2", + "@testcontainers/postgresql": "^10.13.2", + "@tsconfig/recommended": "^1.0.3", + "@types/uuid": "^10", + "@typescript-eslint/eslint-plugin": "^6.12.0", + "@typescript-eslint/parser": "^6.12.0", + "better-sqlite3": "^9.5.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", + "mongodb": "^6.8.0", + "pg": "^8.12.0", + "prettier": "^2.8.3", + "release-it": "^17.6.0", + "rollup": "^4.22.4", + "ts-jest": "^29.1.0", + "tsx": "^4.7.0", + "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" + }, + "bin": { + "validate-saver": "./bin/cli.js" + }, + "files": [ + "dist/", + "index.cjs", + "index.js", + "index.d.ts", + "index.d.cts" + ] +} diff --git a/libs/checkpoint-validation/src/cli.ts b/libs/checkpoint-validation/src/cli.ts new file mode 100644 index 00000000..b65f1760 --- /dev/null +++ b/libs/checkpoint-validation/src/cli.ts @@ -0,0 +1,109 @@ +import { dirname, resolve as pathResolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCLI } from "@jest/core"; +import yargs, { ArgumentsCamelCase } from "yargs"; +import { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { + CheckpointSaverTestInitializer, + checkpointSaverTestInitializerSchema, + GlobalThis, + TestTypeFilter, +} from "./types.js"; +import { dynamicImport, resolveImportPath } from "./importUtils.js"; + +// make it so we can import/require .ts files +import "@swc-node/register/esm-register"; + +export async function main() { + const moduleDirname = dirname(fileURLToPath(import.meta.url)); + + const builder = yargs(); + await builder + .command( + "* [filters..]", + "Validate a checkpoint saver", + { + builder: (args) => { + return args + .positional("initializerImportPath", { + type: "string", + describe: + "The import path of the CheckpointSaverTestInitializer for the checkpoint saver (passed to 'import()'). " + + "Must be the default export.", + demandOption: true, + }) + .positional("filters", { + array: true, + choices: ["getTuple", "put", "putWrites", "list"], + default: [], + describe: + "Only run the specified suites. Valid values are 'getTuple', 'put', 'putWrites', and 'list'", + demandOption: false, + }); + }, + handler: async ( + argv: ArgumentsCamelCase<{ + initializerImportPath: string; + filters: string[]; + }> + ) => { + const { initializerImportPath, filters } = argv; + + let resolvedImportPath; + + try { + resolvedImportPath = resolveImportPath(initializerImportPath); + } catch (e) { + console.error( + `Failed to resolve import path '${initializerImportPath}': ${e}` + ); + process.exit(1); + } + + let initializerExport: unknown; + try { + initializerExport = await dynamicImport(resolvedImportPath); + } catch (e) { + console.error( + `Failed to import initializer from import path '${initializerImportPath}' (resolved to '${resolvedImportPath}'): ${e}` + ); + process.exit(1); + } + + let initializer: CheckpointSaverTestInitializer; + try { + initializer = checkpointSaverTestInitializerSchema.parse( + (initializerExport as { default?: unknown }).default ?? + initializerExport + ); + ( + globalThis as GlobalThis + ).__langgraph_checkpoint_validation_initializer = initializer; + ( + globalThis as GlobalThis + ).__langgraph_checkpoint_validation_filters = + filters as TestTypeFilter[]; + } catch (e) { + console.error( + `Initializer imported from '${initializerImportPath}' does not conform to the expected schema. Make sure " + + "it is the default export, and that implements the CheckpointSaverTestInitializer interface. Error: ${e}` + ); + process.exit(1); + } + + await runCLI( + { + _: [], + $0: "", + }, + [pathResolve(moduleDirname, "..", "bin", "jest.config.cjs")] + ); + }, + } + ) + .help() + .alias("h", "help") + .wrap(builder.terminalWidth()) + .strict() + .parseAsync(process.argv.slice(2)); +} diff --git a/libs/checkpoint-validation/src/importUtils.ts b/libs/checkpoint-validation/src/importUtils.ts new file mode 100644 index 00000000..2b428340 --- /dev/null +++ b/libs/checkpoint-validation/src/importUtils.ts @@ -0,0 +1,68 @@ +import { + isAbsolute as pathIsAbsolute, + resolve as pathResolve, + dirname, +} from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +export function findPackageRoot(path: string): string { + const packageJsonPath = pathResolve(path, "package.json"); + if (existsSync(packageJsonPath)) { + return path; + } + + if (pathResolve(dirname(path)) === pathResolve(path)) { + throw new Error("Could not find package root"); + } + + return findPackageRoot(pathResolve(dirname(path))); +} + +export function resolveImportPath(path: string) { + // absolute path + if (pathIsAbsolute(path)) { + return path; + } + + // relative path + if (/^\.\.?(\/|\\)/.test(path)) { + return pathResolve(path); + } + + // module name + const packageRoot = findPackageRoot(process.cwd()); + if (packageRoot === undefined) { + console.error( + "Could not find package root to resolve initializer import path." + ); + process.exit(1); + } + + const localRequire = createRequire(pathResolve(packageRoot, "package.json")); + return localRequire.resolve(path); +} + +export function isESM(path: string) { + if (path.endsWith(".mjs") || path.endsWith(".mts")) { + return true; + } + + if (path.endsWith(".cjs") || path.endsWith(".cts")) { + return false; + } + + const packageJsonPath = pathResolve(findPackageRoot(path), "package.json"); + const packageConfig = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + + return packageConfig.type === "module"; +} + +export async function dynamicImport(modulePath: string) { + if (isESM(modulePath)) { + return import(modulePath); + } + + const localRequire = createRequire(pathResolve(modulePath, "package.json")); + return localRequire(modulePath); +} diff --git a/libs/checkpoint-validation/src/index.ts b/libs/checkpoint-validation/src/index.ts new file mode 100644 index 00000000..0b5729ef --- /dev/null +++ b/libs/checkpoint-validation/src/index.ts @@ -0,0 +1,18 @@ +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { specTest } from "./spec/index.js"; +import type { CheckpointSaverTestInitializer } from "./types.js"; + +export { CheckpointSaverTestInitializer } from "./types.js"; +export { + getTupleTests, + listTests, + putTests, + putWritesTests, + specTest, +} from "./spec/index.js"; + +export function validate( + initializer: CheckpointSaverTestInitializer +) { + specTest(initializer); +} diff --git a/libs/checkpoint-validation/src/runner.ts b/libs/checkpoint-validation/src/runner.ts new file mode 100644 index 00000000..ad5a022c --- /dev/null +++ b/libs/checkpoint-validation/src/runner.ts @@ -0,0 +1,20 @@ +// This file is used by the CLI to dynamically execute tests against the user-provided checkpoint saver. It's written +// as a Jest test file because unfortunately there's no good way to just pass Jest a test definition function and tell +// it to run it. +import { specTest } from "./spec/index.js"; +import type { GlobalThis } from "./types.js"; + +// passing via global is ugly, but there's no good alternative for handling the dynamic import here +const initializer = (globalThis as GlobalThis) + .__langgraph_checkpoint_validation_initializer; + +if (!initializer) { + throw new Error( + "expected global '__langgraph_checkpoint_validation_initializer' is not set" + ); +} + +const filters = (globalThis as GlobalThis) + .__langgraph_checkpoint_validation_filters; + +specTest(initializer, filters); diff --git a/libs/checkpoint-validation/src/spec/data.ts b/libs/checkpoint-validation/src/spec/data.ts new file mode 100644 index 00000000..cbc92819 --- /dev/null +++ b/libs/checkpoint-validation/src/spec/data.ts @@ -0,0 +1,290 @@ +import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; +import { + ChannelVersions, + CheckpointPendingWrite, + PendingWrite, + SendProtocol, + TASKS, + uuid6, + type CheckpointTuple, +} from "@langchain/langgraph-checkpoint"; + +export interface InitialCheckpointTupleConfig { + config: RunnableConfig; + checkpoint_id: string; + checkpoint_ns?: string; + channel_values?: Record; + channel_versions?: ChannelVersions; +} +export function initialCheckpointTuple({ + config, + checkpoint_id, + checkpoint_ns, + channel_values = {}, +}: InitialCheckpointTupleConfig): CheckpointTuple { + if (checkpoint_ns === undefined) { + // eslint-disable-next-line no-param-reassign + checkpoint_ns = config.configurable?.checkpoint_ns; + } + + if (checkpoint_ns === undefined) { + throw new Error("checkpoint_ns is required"); + } + + const channel_versions = Object.fromEntries( + Object.keys(channel_values).map((key) => [key, 1]) + ); + + return { + config: mergeConfigs(config, { + configurable: { + checkpoint_id, + checkpoint_ns, + }, + }), + checkpoint: { + v: 1, + ts: new Date().toISOString(), + id: checkpoint_id, + channel_values, + channel_versions, + versions_seen: { + // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + "": { + someChannel: 1, + }, + }, + pending_sends: [], + }, + + metadata: { + source: "input", + step: -1, + writes: null, + parents: {}, + }, + }; +} + +export interface ParentAndChildCheckpointTuplesWithWritesConfig { + config: RunnableConfig; + parentCheckpointId: string; + childCheckpointId: string; + checkpoint_ns?: string; + initialChannelValues?: Record; + writesToParent?: { taskId: string; writes: PendingWrite[] }[]; + writesToChild?: { taskId: string; writes: PendingWrite[] }[]; +} + +export function parentAndChildCheckpointTuplesWithWrites({ + config, + parentCheckpointId, + childCheckpointId, + checkpoint_ns, + initialChannelValues = {}, + writesToParent = [], + writesToChild = [], +}: ParentAndChildCheckpointTuplesWithWritesConfig): { + parent: CheckpointTuple; + child: CheckpointTuple; +} { + if (checkpoint_ns === undefined) { + // eslint-disable-next-line no-param-reassign + checkpoint_ns = config.configurable?.checkpoint_ns; + } + + if (checkpoint_ns === undefined) { + throw new Error("checkpoint_ns is required"); + } + + const parentChannelVersions = Object.fromEntries( + Object.keys(initialChannelValues).map((key) => [key, 1]) + ); + + const pending_sends = writesToParent.flatMap(({ writes }) => + writes + .filter(([channel]) => channel === TASKS) + .map(([_, value]) => value as SendProtocol) + ); + + const parentPendingWrites = writesToParent.flatMap(({ taskId, writes }) => + writes.map( + ([channel, value]) => [taskId, channel, value] as CheckpointPendingWrite + ) + ); + + const composedChildWritesByChannel = writesToChild.reduce( + (acc, { writes }) => { + writes.forEach(([channel, value]) => { + acc[channel] = [channel, value]; + }); + return acc; + }, + {} as Record + ); + + const childWriteCountByChannel = writesToChild.reduce((acc, { writes }) => { + writes.forEach(([channel, _]) => { + acc[channel] = (acc[channel] || 0) + 1; + }); + return acc; + }, {} as Record); + + const childChannelVersions = Object.fromEntries( + Object.entries(parentChannelVersions).map(([key, value]) => [ + key, + key in childWriteCountByChannel + ? value + childWriteCountByChannel[key] + : value, + ]) + ); + + const childPendingWrites = writesToChild.flatMap(({ taskId, writes }) => + writes.map( + ([channel, value]) => [taskId, channel, value] as CheckpointPendingWrite + ) + ); + + const childChannelValues = { + ...initialChannelValues, + ...composedChildWritesByChannel, + }; + + return { + parent: { + checkpoint: { + v: 1, + ts: new Date().toISOString(), + id: parentCheckpointId, + channel_values: initialChannelValues, + channel_versions: parentChannelVersions, + versions_seen: { + // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + "": { + someChannel: 1, + }, + }, + pending_sends: [], + }, + metadata: { + source: "input", + step: -1, + writes: null, + parents: {}, + }, + config: mergeConfigs(config, { + configurable: { + checkpoint_ns, + checkpoint_id: parentCheckpointId, + }, + }), + parentConfig: undefined, + pendingWrites: parentPendingWrites, + }, + child: { + checkpoint: { + v: 2, + ts: new Date().toISOString(), + id: childCheckpointId, + channel_values: childChannelValues, + channel_versions: childChannelVersions, + versions_seen: { + // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + "": { + someChannel: 1, + }, + }, + pending_sends, + }, + metadata: { + source: "loop", + step: 0, + writes: { + // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + someNode: parentPendingWrites, + }, + parents: { + // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + // I think this is roughly what it'd look like if it were generated by the pregel loop, though + checkpoint_ns: parentCheckpointId, + }, + }, + config: mergeConfigs(config, { + configurable: { + checkpoint_ns, + checkpoint_id: childCheckpointId, + }, + }), + parentConfig: mergeConfigs(config, { + configurable: { + checkpoint_ns, + checkpoint_id: parentCheckpointId, + }, + }), + pendingWrites: childPendingWrites, + }, + }; +} + +export function* generateTuplePairs( + config: RunnableConfig, + countPerNamespace: number, + namespaces: string[] +): Generator<{ + tuple: CheckpointTuple; + writes: { writes: PendingWrite[]; taskId: string }[]; + newVersions: Record; +}> { + for (let i = 0; i < countPerNamespace; i += 1) { + const thread_id = uuid6(-3); + for (const checkpoint_ns of namespaces) { + const parentCheckpointId = uuid6(-3); + const childCheckpointId = uuid6(-3); + + const writesToParent = [ + { + writes: [[TASKS, ["add_fish"]]] as PendingWrite[], + taskId: "pending_sends_task", + }, + ]; + const writesToChild = [ + { + writes: [["animals", ["fish", "dog"]]] as PendingWrite[], + taskId: "add_fish", + }, + ]; + const initialChannelValues = { + animals: ["dog"], + }; + + const { parent, child } = parentAndChildCheckpointTuplesWithWrites({ + config: mergeConfigs(config, { + configurable: { + thread_id, + checkpoint_ns, + }, + }), + parentCheckpointId, + childCheckpointId, + initialChannelValues, + writesToParent, + writesToChild, + }); + + yield { + tuple: parent, + writes: writesToParent, + newVersions: parent.checkpoint.channel_versions, + }; + yield { + tuple: child, + writes: writesToChild, + newVersions: Object.fromEntries( + Object.entries(child.checkpoint.channel_versions).filter( + ([key, ver]) => parent.checkpoint.channel_versions[key] !== ver + ) + ) as Record, + }; + } + } +} diff --git a/libs/checkpoint-validation/src/spec/getTuple.ts b/libs/checkpoint-validation/src/spec/getTuple.ts new file mode 100644 index 00000000..374d942c --- /dev/null +++ b/libs/checkpoint-validation/src/spec/getTuple.ts @@ -0,0 +1,265 @@ +import { + CheckpointTuple, + PendingWrite, + TASKS, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { describe, it, beforeAll, afterAll, expect } from "@jest/globals"; +import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointSaverTestInitializer } from "../types.js"; +import { parentAndChildCheckpointTuplesWithWrites } from "./data.js"; +import { putTuples } from "./util.js"; + +export function getTupleTests( + initializer: CheckpointSaverTestInitializer +) { + describe(`${initializer.saverName}#getTuple`, () => { + let saver: T; + let initializerConfig: RunnableConfig; + beforeAll(async () => { + const baseConfig = { + configurable: {}, + }; + initializerConfig = mergeConfigs( + baseConfig, + await initializer.configure?.(baseConfig) + ); + saver = await initializer.createSaver(initializerConfig); + }); + + afterAll(async () => { + await initializer.destroySaver?.(saver, initializerConfig); + }); + + describe.each(["root", "child"])("namespace: %s", (namespace) => { + let thread_id: string; + const checkpoint_ns = namespace === "root" ? "" : namespace; + + let parentCheckpointId: string; + let childCheckpointId: string; + + let generatedParentTuple: CheckpointTuple; + let generatedChildTuple: CheckpointTuple; + + let parentTuple: CheckpointTuple | undefined; + let childTuple: CheckpointTuple | undefined; + let latestTuple: CheckpointTuple | undefined; + + beforeAll(async () => { + thread_id = uuid6(-3); + parentCheckpointId = uuid6(-3); + childCheckpointId = uuid6(-3); + + const config = mergeConfigs(initializerConfig, { + configurable: { thread_id, checkpoint_ns }, + }); + + const writesToParent = [ + { + taskId: "pending_sends_task", + writes: [[TASKS, ["add_fish"]]] as PendingWrite[], + }, + ]; + + const writesToChild = [ + { + taskId: "add_fish", + writes: [["animals", ["dog", "fish"]]] as PendingWrite[], + }, + ]; + + ({ parent: generatedParentTuple, child: generatedChildTuple } = + parentAndChildCheckpointTuplesWithWrites({ + config, + parentCheckpointId, + childCheckpointId, + checkpoint_ns, + initialChannelValues: { + animals: ["dog"], + }, + writesToParent, + writesToChild, + })); + + const storedTuples = putTuples( + saver, + [ + { + tuple: generatedParentTuple, + writes: writesToParent, + newVersions: { animals: 1 }, + }, + { + tuple: generatedChildTuple, + writes: writesToChild, + newVersions: { animals: 2 }, + }, + ], + config + ); + + parentTuple = (await storedTuples.next()).value; + childTuple = (await storedTuples.next()).value; + + latestTuple = await saver.getTuple( + mergeConfigs(config, { + configurable: { checkpoint_ns, checkpoint_id: undefined }, + }) + ); + }); + + describe("success cases", () => { + describe("when checkpoint_id is provided", () => { + describe("first checkpoint", () => { + it("should return a tuple containing the checkpoint without modification", () => { + expect(parentTuple).not.toBeUndefined(); + expect(parentTuple?.checkpoint).toEqual( + generatedParentTuple.checkpoint + ); + }); + + it("should return a tuple containing the checkpoint's metadata without modification", () => { + expect(parentTuple?.metadata).not.toBeUndefined(); + expect(parentTuple?.metadata).toEqual( + generatedParentTuple.metadata + ); + }); + + it("should return a tuple containing a config object that has the correct thread_id, checkpoint_ns, and checkpoint_id", () => { + expect(parentTuple?.config).not.toBeUndefined(); + + expect(parentTuple?.config).toEqual({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: parentCheckpointId, + }, + }); + }); + + it("should return a tuple containing an undefined parentConfig", () => { + expect(parentTuple?.parentConfig).toBeUndefined(); + }); + + it("should return a tuple containing the writes against the checkpoint", () => { + expect(parentTuple?.pendingWrites).toEqual([ + ["pending_sends_task", TASKS, ["add_fish"]], + ]); + }); + }); + + describe("subsequent checkpoints", () => { + it(`should return a tuple containing the checkpoint`, async () => { + expect(childTuple).not.toBeUndefined(); + expect(childTuple?.checkpoint).toEqual( + generatedChildTuple.checkpoint + ); + }); + + it("should return a tuple containing the checkpoint's metadata without modification", () => { + expect(childTuple?.metadata).not.toBeUndefined(); + expect(childTuple?.metadata).toEqual( + generatedChildTuple.metadata + ); + }); + + it("should return a tuple containing a config object that has the correct thread_id, checkpoint_ns, and checkpoint_id", () => { + expect(childTuple?.config).not.toBeUndefined(); + expect(childTuple?.config).toEqual({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: childCheckpointId, + }, + }); + }); + + it("should return a tuple containing a parentConfig with the correct thread_id, checkpoint_ns, and checkpoint_id", () => { + expect(childTuple?.parentConfig).toEqual({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: parentCheckpointId, + }, + }); + }); + + it("should return a tuple containing the writes against the checkpoint", () => { + expect(childTuple?.pendingWrites).toEqual([ + ["add_fish", "animals", ["dog", "fish"]], + ]); + }); + }); + }); + + describe("when checkpoint_id is not provided", () => { + it(`should return a tuple containing the latest checkpoint`, async () => { + expect(latestTuple).not.toBeUndefined(); + expect(latestTuple?.checkpoint).toEqual( + generatedChildTuple.checkpoint + ); + }); + + it("should return a tuple containing the latest checkpoint's metadata without modification", () => { + expect(latestTuple?.metadata).not.toBeUndefined(); + expect(latestTuple?.metadata).toEqual(generatedChildTuple.metadata); + }); + + it("should return a tuple containing a config object that has the correct thread_id, checkpoint_ns, and checkpoint_id for the latest checkpoint", () => { + expect(latestTuple?.config).not.toBeUndefined(); + expect(latestTuple?.config).toEqual({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: childCheckpointId, + }, + }); + }); + + it("should return a tuple containing a parentConfig with the correct thread_id, checkpoint_ns, and checkpoint_id for the latest checkpoint's parent", () => { + expect(latestTuple?.parentConfig).toEqual({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: parentCheckpointId, + }, + }); + }); + + it("should return a tuple containing the writes against the latest checkpoint", () => { + expect(latestTuple?.pendingWrites).toEqual([ + ["add_fish", "animals", ["dog", "fish"]], + ]); + }); + }); + }); + + describe("failure cases", () => { + it("should return undefined if the checkpoint_id is not found", async () => { + const configWithInvalidCheckpointId = mergeConfigs( + initializerConfig, + { configurable: { checkpoint_ns, checkpoint_id: uuid6(-3) } } + ); + const checkpointTuple = await saver.getTuple( + configWithInvalidCheckpointId + ); + expect(checkpointTuple).toBeUndefined(); + }); + + it("should return undefined if the thread_id is undefined", async () => { + const missingThreadIdConfig: RunnableConfig = { + ...initializerConfig, + configurable: Object.fromEntries( + Object.entries(initializerConfig.configurable ?? {}).filter( + ([key]) => key !== "thread_id" + ) + ), + }; + + expect(await saver.getTuple(missingThreadIdConfig)).toBeUndefined(); + }); + }); + }); + }); +} diff --git a/libs/checkpoint-validation/src/spec/index.ts b/libs/checkpoint-validation/src/spec/index.ts new file mode 100644 index 00000000..faa2b03e --- /dev/null +++ b/libs/checkpoint-validation/src/spec/index.ts @@ -0,0 +1,48 @@ +import { type BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { describe, beforeAll, afterAll } from "@jest/globals"; + +import { CheckpointSaverTestInitializer, TestTypeFilter } from "../types.js"; +import { putTests } from "./put.js"; +import { putWritesTests } from "./putWrites.js"; +import { getTupleTests } from "./getTuple.js"; +import { listTests } from "./list.js"; + +const testTypeMap = { + getTuple: getTupleTests, + list: listTests, + put: putTests, + putWrites: putWritesTests, +}; + +/** + * Kicks off a test suite to validate that the provided checkpoint saver conforms to the specification for classes that extend @see BaseCheckpointSaver. + * @param initializer A @see CheckpointSaverTestInitializer, providing methods for setup and cleanup of the test, and for creation of the saver instance being tested. + * @param filters If specified, only the test suites in this list will be executed. + */ +export function specTest( + initializer: CheckpointSaverTestInitializer, + filters?: TestTypeFilter[] +) { + beforeAll(async () => { + await initializer.beforeAll?.(); + }, initializer.beforeAllTimeout ?? 10000); + + afterAll(async () => { + await initializer.afterAll?.(); + }); + + describe(initializer.saverName, () => { + if (!filters || filters.length === 0) { + putTests(initializer); + putWritesTests(initializer); + getTupleTests(initializer); + listTests(initializer); + } else { + for (const testType of filters) { + testTypeMap[testType](initializer); + } + } + }); +} + +export { getTupleTests, listTests, putTests, putWritesTests }; diff --git a/libs/checkpoint-validation/src/spec/list.ts b/libs/checkpoint-validation/src/spec/list.ts new file mode 100644 index 00000000..79fac0ec --- /dev/null +++ b/libs/checkpoint-validation/src/spec/list.ts @@ -0,0 +1,341 @@ +import { + CheckpointTuple, + PendingWrite, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { describe, it, beforeAll, afterAll, expect } from "@jest/globals"; +import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointSaverTestInitializer } from "../types.js"; +import { generateTuplePairs } from "./data.js"; +import { putTuples, toArray, toMap } from "./util.js"; + +interface ListTestCase { + description: string; + thread_id: string | undefined; + checkpoint_ns: string | undefined; + limit: number | undefined; + before: RunnableConfig | undefined; + filter: Record | undefined; + expectedCheckpointIds: string[]; +} + +/** + * Exercises the `list` method of the CheckpointSaver. + * + * IMPORTANT NOTE: This test relies on the `getTuple` method of the saver functioning properly. If you have failures in + * `getTuple`, you should fix them before addressing the failures in this test. + * + * @param initializer the initializer for the CheckpointSaver + */ +export function listTests( + initializer: CheckpointSaverTestInitializer +) { + const invalidThreadId = uuid6(-3); + + const namespaces = ["", "child"]; + + const generatedTuples: { + tuple: CheckpointTuple; + writes: { writes: PendingWrite[]; taskId: string }[]; + newVersions: Record; + }[] = Array.from(generateTuplePairs({ configurable: {} }, 2, namespaces)); + + const argumentRanges = setupArgumentRanges( + generatedTuples.map(({ tuple }) => tuple), + namespaces + ); + + const argumentCombinations: ListTestCase[] = Array.from( + buildArgumentCombinations( + argumentRanges, + generatedTuples.map(({ tuple }) => tuple) + ) + ); + + describe(`${initializer.saverName}#list`, () => { + let saver: T; + let initializerConfig: RunnableConfig; + + const storedTuples: Map = new Map(); + + beforeAll(async () => { + const baseConfig = { + configurable: {}, + }; + initializerConfig = mergeConfigs( + baseConfig, + await initializer.configure?.(baseConfig) + ); + saver = await initializer.createSaver(initializerConfig); + + // put all the tuples + for await (const tuple of putTuples( + saver, + generatedTuples, + initializerConfig + )) { + storedTuples.set(tuple.checkpoint.id, tuple); + } + }); + + afterAll(async () => { + await initializer.destroySaver?.(saver, initializerConfig); + }); + + // can't reference argumentCombinations directly here because it isn't built at the time this is evaluated. + // We do know how many entries there will be though, so we just pass the index for each entry, instead. + it.each(argumentCombinations)( + "%s", + async ({ + thread_id, + checkpoint_ns, + limit, + before, + filter, + expectedCheckpointIds, + }: ListTestCase) => { + const actualTuplesArray = await toArray( + saver.list( + { configurable: { thread_id, checkpoint_ns } }, + { limit, before, filter } + ) + ); + + const limitEnforced = + limit !== undefined && limit < expectedCheckpointIds.length; + + const expectedCount = limitEnforced + ? limit + : expectedCheckpointIds.length; + + expect(actualTuplesArray.length).toEqual(expectedCount); + + const actualTuplesMap = toMap(actualTuplesArray); + const expectedTuples = expectedCheckpointIds.map( + (tupleId) => storedTuples.get(tupleId)! + ); + + const expectedTuplesMap = toMap(expectedTuples); + + if (limitEnforced) { + for (const tuple of actualTuplesArray) { + expect(expectedTuplesMap.has(tuple.checkpoint.id)).toBeTruthy(); + expect(tuple).toEqual(expectedTuplesMap.get(tuple.checkpoint.id)); + } + } else { + expect(actualTuplesMap.size).toEqual(expectedTuplesMap.size); + for (const [key, value] of actualTuplesMap.entries()) { + // TODO: MongoDBSaver and SQLiteSaver don't return pendingWrites on list, so we need to special case them + // see: https://github.com/langchain-ai/langgraphjs/issues/589 + // see: https://github.com/langchain-ai/langgraphjs/issues/590 + const saverIncludesPendingWritesOnList = + initializer.saverName !== + "@langchain/langgraph-checkpoint-mongodb" && + initializer.saverName !== + "@langchain/langgraph-checkpoint-sqlite"; + + const expectedTuple = expectedTuplesMap.get(key); + if (!saverIncludesPendingWritesOnList) { + delete expectedTuple?.pendingWrites; + } + + expect(value).toEqual(expectedTuple); + } + } + } + ); + }); + + function setupArgumentRanges( + generatedTuples: CheckpointTuple[], + namespaces: string[] + ): { + thread_id: (string | undefined)[]; + checkpoint_ns: (string | undefined)[]; + limit: (number | undefined)[]; + before: (RunnableConfig | undefined)[]; + filter: (Record | undefined)[]; + } { + const parentTupleInDefaultNamespace = generatedTuples[0]; + const childTupleInDefaultNamespace = generatedTuples[1]; + const parentTupleInChildNamespace = generatedTuples[2]; + const childTupleInChildNamespace = generatedTuples[3]; + + return { + thread_id: [ + undefined, + parentTupleInDefaultNamespace.config.configurable?.thread_id, + childTupleInDefaultNamespace.config.configurable?.thread_id, + parentTupleInChildNamespace.config.configurable?.thread_id, + childTupleInChildNamespace.config.configurable?.thread_id, + invalidThreadId, + ], + checkpoint_ns: [undefined, ...namespaces], + limit: [undefined, 1, 2], + before: [ + undefined, + parentTupleInDefaultNamespace.config, + childTupleInDefaultNamespace.config, + ], + filter: + // TODO: MongoDBSaver support for filter is broken and can't be fixed without a breaking change + // see: https://github.com/langchain-ai/langgraphjs/issues/581 + initializer.saverName === "@langchain/langgraph-checkpoint-mongodb" + ? [undefined] + : [undefined, {}, { source: "input" }, { source: "loop" }], + }; + } + + function* buildArgumentCombinations( + argumentRanges: ReturnType, + allTuples: CheckpointTuple[] + ): Generator { + for (const thread_id of argumentRanges.thread_id) { + for (const checkpoint_ns of argumentRanges.checkpoint_ns) { + for (const limit of argumentRanges.limit) { + for (const before of argumentRanges.before) { + for (const filter of argumentRanges.filter) { + const expectedCheckpointIds = allTuples + .filter( + (tuple) => + (thread_id === undefined || + tuple.config.configurable?.thread_id === thread_id) && + (checkpoint_ns === undefined || + tuple.config.configurable?.checkpoint_ns === + checkpoint_ns) && + (before === undefined || + tuple.checkpoint.id < + before.configurable?.checkpoint_id) && + (filter === undefined || + Object.entries(filter).every( + ([key, value]) => + ( + tuple.metadata as + | Record + | undefined + )?.[key] === value + )) + ) + .map((tuple) => tuple.checkpoint.id); + + yield { + description: describeArguments( + argumentRanges, + allTuples.length, + { + thread_id, + checkpoint_ns, + limit, + before, + filter, + expectedCheckpointIds, + } + ), + thread_id, + checkpoint_ns, + limit, + before, + filter, + expectedCheckpointIds, + }; + } + } + } + } + } + } + + function describeArguments( + argumentRanges: ReturnType, + totalTupleCount: number, + { + thread_id, + checkpoint_ns, + limit, + before, + filter, + expectedCheckpointIds, + }: Omit + ) { + const parentTupleBeforeConfig = argumentRanges.before[1]; + const childTupleBeforeConfig = argumentRanges.before[2]; + + let descriptionTupleCount: string; + + if (limit !== undefined && limit < expectedCheckpointIds.length) { + descriptionTupleCount = `${limit} ${limit === 1 ? "tuple" : "tuples"}`; + } else if (expectedCheckpointIds.length === totalTupleCount) { + descriptionTupleCount = "all tuples"; + } else if (expectedCheckpointIds.length === 0) { + descriptionTupleCount = "no tuples"; + } else { + descriptionTupleCount = `${expectedCheckpointIds.length} tuples`; + } + + const descriptionWhenParts: string[] = []; + + if ( + thread_id === undefined && + checkpoint_ns === undefined && + limit === undefined && + before === undefined && + filter === undefined + ) { + descriptionWhenParts.push("no config or options are specified"); + } else { + if (thread_id === undefined) { + descriptionWhenParts.push("thread_id is not specified"); + } else if (thread_id === invalidThreadId) { + descriptionWhenParts.push( + "thread_id does not match pushed checkpoint(s)" + ); + } else { + descriptionWhenParts.push(`thread_id matches pushed checkpoint(s)`); + } + + if (checkpoint_ns === undefined) { + descriptionWhenParts.push("checkpoint_ns is not specified"); + } else if (checkpoint_ns !== undefined && checkpoint_ns === "") { + descriptionWhenParts.push("checkpoint_ns is the default namespace"); + } else if (checkpoint_ns !== undefined && checkpoint_ns !== "") { + descriptionWhenParts.push(`checkpoint_ns matches '${checkpoint_ns}'`); + } + + if (limit === undefined) { + descriptionWhenParts.push("limit is undefined"); + } else if (limit !== undefined) { + descriptionWhenParts.push(`limit is ${limit}`); + } + + if (before === undefined) { + descriptionWhenParts.push("before is not specified"); + } else if (before !== undefined && before === parentTupleBeforeConfig) { + descriptionWhenParts.push("before parent checkpoint"); + } else if (before !== undefined && before === childTupleBeforeConfig) { + descriptionWhenParts.push("before child checkpoint"); + } + + if (filter === undefined) { + descriptionWhenParts.push("filter is undefined"); + } else if (Object.keys(filter).length === 0) { + descriptionWhenParts.push("filter is an empty object"); + } else { + for (const [key, value] of Object.entries(filter)) { + descriptionWhenParts.push( + `metadata.${key} matches ${JSON.stringify(value)}` + ); + } + } + } + + const descriptionWhen = + descriptionWhenParts.length > 1 + ? `${descriptionWhenParts.slice(0, -1).join(", ")}, and ${ + descriptionWhenParts[descriptionWhenParts.length - 1] + }` + : descriptionWhenParts[0]; + + return `should return ${descriptionTupleCount} when ${descriptionWhen}`; + } +} diff --git a/libs/checkpoint-validation/src/spec/put.ts b/libs/checkpoint-validation/src/spec/put.ts new file mode 100644 index 00000000..1f5b0def --- /dev/null +++ b/libs/checkpoint-validation/src/spec/put.ts @@ -0,0 +1,341 @@ +import { + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { describe, it, afterEach, beforeEach, expect } from "@jest/globals"; +import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointSaverTestInitializer } from "../types.js"; +import { initialCheckpointTuple } from "./data.js"; +import { putTuples } from "./util.js"; +import { it_skipForSomeModules } from "../testUtils.js"; + +export function putTests( + initializer: CheckpointSaverTestInitializer +) { + describe(`${initializer.saverName}#put`, () => { + let saver: T; + let initializerConfig: RunnableConfig; + let thread_id: string; + let checkpoint_id1: string; + let checkpoint_id2: string; + let invalid_checkpoint_id: string; + + beforeEach(async () => { + thread_id = uuid6(-3); + checkpoint_id1 = uuid6(-3); + checkpoint_id2 = uuid6(-3); + invalid_checkpoint_id = uuid6(-3); + + const baseConfig = { + configurable: { + thread_id, + }, + }; + + initializerConfig = mergeConfigs( + baseConfig, + await initializer.configure?.(baseConfig) + ); + saver = await initializer.createSaver(initializerConfig); + }); + + afterEach(async () => { + await initializer.destroySaver?.(saver, initializerConfig); + }); + + describe.each(["root", "child"])("namespace: %s", (namespace) => { + const checkpoint_ns = namespace === "root" ? "" : namespace; + let configArgument: RunnableConfig; + let checkpointStoredWithoutIdInConfig: Checkpoint; + let metadataStoredWithoutIdInConfig: CheckpointMetadata | undefined; + let checkpointStoredWithIdInConfig: Checkpoint; + let metadataStoredWithIdInConfig: CheckpointMetadata | undefined; + + describe("success cases", () => { + let basicPutReturnedConfig: RunnableConfig; + let checkpointIdCheckReturnedConfig: RunnableConfig; + let basicPutRoundTripTuple: CheckpointTuple | undefined; + let checkpointIdCheckRoundTripTuple: CheckpointTuple | undefined; + + beforeEach(async () => { + ({ + checkpoint: checkpointStoredWithoutIdInConfig, + metadata: metadataStoredWithoutIdInConfig, + } = initialCheckpointTuple({ + config: initializerConfig, + checkpoint_id: checkpoint_id1, + checkpoint_ns, + })); + + ({ + checkpoint: checkpointStoredWithIdInConfig, + metadata: metadataStoredWithIdInConfig, + } = initialCheckpointTuple({ + config: initializerConfig, + checkpoint_id: checkpoint_id2, + checkpoint_ns, + })); + + configArgument = mergeConfigs(initializerConfig, { + configurable: { checkpoint_ns }, + }); + + // validate assumptions - the test checkpoints must not already exist + const existingCheckpoint1 = await saver.get( + mergeConfigs(configArgument, { + configurable: { + checkpoint_id: checkpoint_id1, + }, + }) + ); + + const existingCheckpoint2 = await saver.get( + mergeConfigs(configArgument, { + configurable: { + checkpoint_id: checkpoint_id1, + }, + }) + ); + + expect(existingCheckpoint1).toBeUndefined(); + expect(existingCheckpoint2).toBeUndefined(); + + // set up + // call put without the `checkpoint_id` in the config + basicPutReturnedConfig = await saver.put( + mergeConfigs(configArgument, { + configurable: { + // adding this to ensure that additional fields are not stored in the checkpoint tuple + canary: "tweet", + }, + }), + checkpointStoredWithoutIdInConfig, + metadataStoredWithoutIdInConfig!, + {} + ); + + // call put with a different `checkpoint_id` in the config to ensure that it treats the `id` field in the `Checkpoint` as + // the authoritative identifier, rather than the `checkpoint_id` in the config + checkpointIdCheckReturnedConfig = await saver.put( + mergeConfigs(configArgument, { + configurable: { + checkpoint_id: invalid_checkpoint_id, + }, + }), + checkpointStoredWithIdInConfig, + metadataStoredWithIdInConfig!, + {} + ); + + basicPutRoundTripTuple = await saver.getTuple( + mergeConfigs(configArgument, basicPutReturnedConfig) + ); + + checkpointIdCheckRoundTripTuple = await saver.getTuple( + mergeConfigs(configArgument, checkpointIdCheckReturnedConfig) + ); + }); + + it("should return a config with a 'configurable' property", () => { + expect(basicPutReturnedConfig.configurable).toBeDefined(); + }); + + it("should return a config with only thread_id, checkpoint_ns, and checkpoint_id in the configurable", () => { + expect( + Object.keys(basicPutReturnedConfig.configurable ?? {}) + ).toEqual( + expect.arrayContaining([ + "thread_id", + "checkpoint_ns", + "checkpoint_id", + ]) + ); + }); + + it("should return config with matching thread_id", () => { + expect(basicPutReturnedConfig.configurable?.thread_id).toEqual( + thread_id + ); + }); + + it("should return config with matching checkpoint_id", () => { + expect(basicPutReturnedConfig.configurable?.checkpoint_id).toEqual( + checkpointStoredWithoutIdInConfig.id + ); + expect( + checkpointIdCheckReturnedConfig.configurable?.checkpoint_id + ).toEqual(checkpointStoredWithIdInConfig.id); + }); + + it("should return config with matching checkpoint_ns", () => { + expect(basicPutReturnedConfig.configurable?.checkpoint_ns).toEqual( + checkpoint_ns + ); + }); + + it("should result in a retrievable checkpoint tuple", () => { + expect(basicPutRoundTripTuple).not.toBeUndefined(); + }); + + it("should store the checkpoint without alteration", () => { + expect(basicPutRoundTripTuple?.checkpoint).toEqual( + checkpointStoredWithoutIdInConfig + ); + }); + + it("should return a checkpoint with a new id when the id in the config on put is invalid", () => { + expect(checkpointIdCheckRoundTripTuple?.checkpoint.id).not.toEqual( + invalid_checkpoint_id + ); + }); + + it("should store the metadata without alteration", () => { + expect(basicPutRoundTripTuple?.metadata).toEqual( + metadataStoredWithoutIdInConfig + ); + }); + }); + + describe("failure cases", () => { + it("should fail if config.configurable is missing", async () => { + const missingConfigurableConfig: RunnableConfig = { + ...configArgument, + configurable: undefined, + }; + + await expect( + async () => + await saver.put( + missingConfigurableConfig, + checkpointStoredWithoutIdInConfig, + metadataStoredWithoutIdInConfig!, + {} + ) + ).rejects.toThrow(); + }); + + it("should fail if the thread_id is missing", async () => { + const missingThreadIdConfig: RunnableConfig = { + ...configArgument, + configurable: Object.fromEntries( + Object.entries(configArgument.configurable ?? {}).filter( + ([key]) => key !== "thread_id" + ) + ), + }; + + await expect( + async () => + await saver.put( + missingThreadIdConfig, + checkpointStoredWithoutIdInConfig, + metadataStoredWithoutIdInConfig!, + {} + ) + ).rejects.toThrow(); + }); + }); + }); + + it_skipForSomeModules(initializer.saverName, { + // TODO: MemorySaver throws instead of defaulting to empty namespace + // see: https://github.com/langchain-ai/langgraphjs/issues/591 + MemorySaver: "TODO: throws instead of defaulting to empty namespace", + // TODO: SqliteSaver stores with undefined namespace instead of empty namespace + // see: https://github.com/langchain-ai/langgraphjs/issues/592 + "@langchain/langgraph-checkpoint-sqlite": + "TODO: SqliteSaver stores config with no checkpoint_ns instead of default namespace", + })( + "should default to empty namespace if the checkpoint namespace is missing from config.configurable", + async () => { + const missingNamespaceConfig: RunnableConfig = { + ...initializerConfig, + configurable: Object.fromEntries( + Object.entries(initializerConfig.configurable ?? {}).filter( + ([key]) => key !== "checkpoint_ns" + ) + ), + }; + + const { checkpoint, metadata } = initialCheckpointTuple({ + config: missingNamespaceConfig, + checkpoint_id: checkpoint_id1, + checkpoint_ns: "", + }); + + const returnedConfig = await saver.put( + missingNamespaceConfig, + checkpoint, + metadata!, + {} + ); + + expect(returnedConfig).not.toBeUndefined(); + expect(returnedConfig?.configurable).not.toBeUndefined(); + expect(returnedConfig?.configurable?.checkpoint_ns).not.toBeUndefined(); + expect(returnedConfig?.configurable?.checkpoint_ns).toEqual(""); + } + ); + + it_skipForSomeModules(initializer.saverName, { + // TODO: all of the savers below store full channel_values on every put, rather than storing deltas + // see: https://github.com/langchain-ai/langgraphjs/issues/593 + // see: https://github.com/langchain-ai/langgraphjs/issues/594 + // see: https://github.com/langchain-ai/langgraphjs/issues/595 + MemorySaver: "TODO: MemorySaver doesn't store channel deltas", + "@langchain/langgraph-checkpoint-mongodb": + "TODO: MongoDBSaver doesn't store channel deltas", + "@langchain/langgraph-checkpoint-sqlite": + "TODO: SQLiteSaver doesn't store channel deltas", + })( + "should only store channel_values that have changed (based on newVersions)", + async () => { + const newVersions = [{}, { foo: 1 }, { foo: 1, baz: 1 }] as Record< + string, + number | string + >[]; + + const generatedPuts = newVersions.map((newVersions) => ({ + tuple: initialCheckpointTuple({ + config: initializerConfig, + checkpoint_id: uuid6(-3), + checkpoint_ns: "", + channel_values: { + foo: "bar", + baz: "qux", + }, + }), + writes: [], + newVersions, + })); + + const storedTuples: CheckpointTuple[] = []; + for await (const tuple of putTuples( + saver, + generatedPuts, + initializerConfig + )) { + storedTuples.push(tuple); + } + + const expectedChannelValues = [ + {}, + { + foo: "bar", + }, + { + foo: "bar", + baz: "qux", + }, + ]; + + expect( + storedTuples.map((tuple) => tuple.checkpoint.channel_values) + ).toEqual(expectedChannelValues); + } + ); + }); +} diff --git a/libs/checkpoint-validation/src/spec/putWrites.ts b/libs/checkpoint-validation/src/spec/putWrites.ts new file mode 100644 index 00000000..33389d59 --- /dev/null +++ b/libs/checkpoint-validation/src/spec/putWrites.ts @@ -0,0 +1,152 @@ +import { + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { describe, it, beforeEach, afterEach, expect } from "@jest/globals"; +import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointSaverTestInitializer } from "../types.js"; +import { initialCheckpointTuple } from "./data.js"; + +export function putWritesTests( + initializer: CheckpointSaverTestInitializer +) { + describe(`${initializer.saverName}#putWrites`, () => { + let saver: T; + let initializerConfig: RunnableConfig; + let thread_id: string; + let checkpoint_id: string; + + beforeEach(async () => { + thread_id = uuid6(-3); + checkpoint_id = uuid6(-3); + + const baseConfig = { + configurable: { + thread_id, + }, + }; + initializerConfig = mergeConfigs( + baseConfig, + await initializer.configure?.(baseConfig) + ); + saver = await initializer.createSaver(initializerConfig); + }); + + afterEach(async () => { + await initializer.destroySaver?.(saver, initializerConfig); + }); + + describe.each(["root", "child"])("namespace: %s", (namespace) => { + const checkpoint_ns = namespace === "root" ? "" : namespace; + let configArgument: RunnableConfig; + let checkpoint: Checkpoint; + let metadata: CheckpointMetadata | undefined; + + describe("success cases", () => { + let returnedConfig!: RunnableConfig; + let savedCheckpointTuple: CheckpointTuple | undefined; + + beforeEach(async () => { + ({ checkpoint, metadata } = initialCheckpointTuple({ + config: initializerConfig, + checkpoint_id, + checkpoint_ns, + })); + + configArgument = mergeConfigs(initializerConfig, { + configurable: { checkpoint_ns }, + }); + + // ensure the test checkpoint does not already exist + const existingCheckpoint = await saver.get( + mergeConfigs(configArgument, { + configurable: { + checkpoint_id, + }, + }) + ); + expect(existingCheckpoint).toBeUndefined(); // our test checkpoint should not exist yet + + returnedConfig = await saver.put( + configArgument, + checkpoint, + metadata!, + {} /* not sure what to do about newVersions, as it's unused */ + ); + + await saver.putWrites( + mergeConfigs(configArgument, returnedConfig), + [["animals", "dog"]], + "pet_task" + ); + + savedCheckpointTuple = await saver.getTuple( + mergeConfigs(configArgument, returnedConfig) + ); + + // fail here if `put` or `getTuple` is broken so we don't get a bunch of noise from the actual test cases below + expect(savedCheckpointTuple).not.toBeUndefined(); + expect(savedCheckpointTuple?.checkpoint).toEqual(checkpoint); + expect(savedCheckpointTuple?.metadata).toEqual(metadata); + expect(savedCheckpointTuple?.config).toEqual( + expect.objectContaining( + // allow the saver to add additional fields to the config + mergeConfigs(configArgument, { configurable: { checkpoint_id } }) + ) + ); + }); + + it("should store writes to the checkpoint", async () => { + expect(savedCheckpointTuple?.pendingWrites).toEqual([ + ["pet_task", "animals", "dog"], + ]); + }); + }); + + describe("failure cases", () => { + it("should fail if the thread_id is missing", async () => { + const missingThreadIdConfig: RunnableConfig = { + ...configArgument, + configurable: Object.fromEntries( + Object.entries(configArgument.configurable ?? {}).filter( + ([key]) => key !== "thread_id" + ) + ), + }; + + await expect( + async () => + await saver.putWrites( + missingThreadIdConfig, + [["animals", "dog"]], + "pet_task" + ) + ).rejects.toThrow(); + }); + + it("should fail if the checkpoint_id is missing", async () => { + const missingCheckpointIdConfig: RunnableConfig = { + ...configArgument, + configurable: Object.fromEntries( + Object.entries(configArgument.configurable ?? {}).filter( + ([key]) => key !== "checkpoint_id" + ) + ), + }; + + await expect( + async () => + await saver.putWrites( + missingCheckpointIdConfig, + [["animals", "dog"]], + "pet_task" + ) + ).rejects.toThrow(); + }); + }); + }); + }); +} diff --git a/libs/checkpoint-validation/src/spec/util.ts b/libs/checkpoint-validation/src/spec/util.ts new file mode 100644 index 00000000..35c3169e --- /dev/null +++ b/libs/checkpoint-validation/src/spec/util.ts @@ -0,0 +1,77 @@ +import { expect } from "@jest/globals"; +import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; +import { + BaseCheckpointSaver, + CheckpointTuple, + PendingWrite, +} from "@langchain/langgraph-checkpoint"; + +export async function* putTuples( + saver: BaseCheckpointSaver, + generatedTuples: { + tuple: CheckpointTuple; + writes: { writes: PendingWrite[]; taskId: string }[]; + newVersions: Record; + }[], + initializerConfig: RunnableConfig +): AsyncGenerator { + for (const generated of generatedTuples) { + const { thread_id, checkpoint_ns } = generated.tuple.config + .configurable as { thread_id: string; checkpoint_ns: string }; + + const checkpoint_id = generated.tuple.parentConfig?.configurable + ?.checkpoint_id as string | undefined; + + const config = mergeConfigs(initializerConfig, { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id, + }, + }); + + const existingTuple = await saver.getTuple( + mergeConfigs(initializerConfig, generated.tuple.config) + ); + + expect(existingTuple).toBeUndefined(); + + const newConfig = await saver.put( + config, + generated.tuple.checkpoint, + generated.tuple.metadata!, + generated.newVersions + ); + + for (const write of generated.writes) { + await saver.putWrites(newConfig, write.writes, write.taskId); + } + + const expectedTuple = await saver.getTuple(newConfig); + + expect(expectedTuple).not.toBeUndefined(); + + if (expectedTuple) { + yield expectedTuple; + } + } +} + +export async function toArray( + generator: AsyncGenerator +): Promise { + const result = []; + for await (const item of generator) { + result.push(item); + } + return result; +} + +export function toMap(tuples: CheckpointTuple[]): Map { + const result = new Map(); + for (const item of tuples) { + const key = item.checkpoint.id; + result.set(key, item); + } + return result; +} diff --git a/libs/checkpoint-validation/src/testUtils.ts b/libs/checkpoint-validation/src/testUtils.ts new file mode 100644 index 00000000..416d3654 --- /dev/null +++ b/libs/checkpoint-validation/src/testUtils.ts @@ -0,0 +1,57 @@ +import { it } from "@jest/globals"; + +// to make the type signature of the skipOnModules function a bit more readable +export type SaverName = string; +export type WhySkipped = string; + +/** + * Conditionally skips a test for a specific checkpoint saver implementation. When the test is skipped, + * the reason for skipping is provided. + * + * @param saverName - The name of the current module being tested (as passed via the `name` argument in the top-level suite entrypoint). + * @param skippedSavers - A list of modules for which the test should be skipped. + * @returns A function that can be used in place of the Jest @see it function and conditionally skips the test for the provided module. + */ +export function it_skipForSomeModules( + saverName: string, + skippedSavers: Record +): typeof it | typeof it.skip { + const skipReason = skippedSavers[saverName]; + + if (skipReason) { + const skip = ( + name: string, + test: () => void | Promise, + timeout?: number + ) => { + it.skip(`[because ${skipReason}] ${name}`, test, timeout); + }; + skip.prototype = it.skip.prototype; + return skip as typeof it.skip; + } + + return it; +} + +export function it_skipIfNot( + saverName: string, + ...savers: SaverName[] +): typeof it | typeof it.skip { + if (!savers.includes(saverName)) { + const skip = ( + name: string, + test: () => void | Promise, + timeout?: number + ) => { + it.skip( + `[only passes for "${savers.join('", "')}"] ${name}`, + test, + timeout + ); + }; + skip.prototype = it.skip.prototype; + return skip as typeof it.skip; + } + + return it; +} diff --git a/libs/checkpoint-validation/src/tests/memory.spec.ts b/libs/checkpoint-validation/src/tests/memory.spec.ts new file mode 100644 index 00000000..90ea4dca --- /dev/null +++ b/libs/checkpoint-validation/src/tests/memory.spec.ts @@ -0,0 +1,4 @@ +import { specTest } from "../spec/index.js"; +import { initializer } from "./memoryInitializer.js"; + +specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/memoryInitializer.ts b/libs/checkpoint-validation/src/tests/memoryInitializer.ts new file mode 100644 index 00000000..c634fdfe --- /dev/null +++ b/libs/checkpoint-validation/src/tests/memoryInitializer.ts @@ -0,0 +1,8 @@ +import { MemorySaver } from "@langchain/langgraph-checkpoint"; + +export const initializer = { + saverName: "MemorySaver", + createSaver: () => new MemorySaver(), +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/tests/mongoInitializer.ts b/libs/checkpoint-validation/src/tests/mongoInitializer.ts new file mode 100644 index 00000000..40f14480 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/mongoInitializer.ts @@ -0,0 +1,50 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { MongoDBSaver } from "@langchain/langgraph-checkpoint-mongodb"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { + MongoDBContainer, + type StartedMongoDBContainer, +} from "@testcontainers/mongodb"; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { MongoClient } from "mongodb"; +import type { CheckpointSaverTestInitializer } from "../types.js"; + +const dbName = "test_db"; + +const container = new MongoDBContainer("mongo:6.0.1"); + +let startedContainer: StartedMongoDBContainer; +let client: MongoClient | undefined; + +export const initializer: CheckpointSaverTestInitializer = { + saverName: "@langchain/langgraph-checkpoint-mongodb", + + async beforeAll() { + startedContainer = await container.start(); + const connectionString = `mongodb://127.0.0.1:${startedContainer.getMappedPort( + 27017 + )}/${dbName}?directConnection=true`; + client = new MongoClient(connectionString); + }, + + beforeAllTimeout: 300_000, // five minutes, to pull docker container + + async createSaver() { + // ensure fresh database for each test + const db = await client!.db(dbName); + await db.dropDatabase(); + await client!.db(dbName); + + return new MongoDBSaver({ + client: client!, + }); + }, + + async afterAll() { + await client?.close(); + await startedContainer.stop(); + }, +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/tests/mongodb.spec.ts b/libs/checkpoint-validation/src/tests/mongodb.spec.ts new file mode 100644 index 00000000..b7256443 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/mongodb.spec.ts @@ -0,0 +1,4 @@ +import { specTest } from "../spec/index.js"; +import { initializer } from "./mongoInitializer.js"; + +specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/postgres.spec.ts b/libs/checkpoint-validation/src/tests/postgres.spec.ts new file mode 100644 index 00000000..828b2425 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/postgres.spec.ts @@ -0,0 +1,4 @@ +import { specTest } from "../spec/index.js"; +import { initializer } from "./postgresInitializer.js"; + +specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/postgresInitializer.ts b/libs/checkpoint-validation/src/tests/postgresInitializer.ts new file mode 100644 index 00000000..a0e12848 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/postgresInitializer.ts @@ -0,0 +1,64 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres"; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { + PostgreSqlContainer, + type StartedPostgreSqlContainer, +} from "@testcontainers/postgresql"; + +// eslint-disable-next-line import/no-extraneous-dependencies +import pg from "pg"; + +import type { CheckpointSaverTestInitializer } from "../types.js"; + +const dbName = "test_db"; + +const container = new PostgreSqlContainer("postgres:16.2") + .withDatabase("postgres") + .withUsername("postgres") + .withPassword("postgres"); + +let startedContainer: StartedPostgreSqlContainer; +let client: pg.Pool | undefined; + +export const initializer: CheckpointSaverTestInitializer = { + saverName: "@langchain/langgraph-checkpoint-postgres", + + async beforeAll() { + startedContainer = await container.start(); + }, + + beforeAllTimeout: 300_000, // five minutes, to pull docker container + + async afterAll() { + await startedContainer.stop(); + }, + + async createSaver() { + client = new pg.Pool({ + connectionString: startedContainer.getConnectionUri(), + }); + + await client?.query(`CREATE DATABASE ${dbName}`); + + const url = new URL("", "postgres://"); + url.hostname = startedContainer.getHost(); + url.port = startedContainer.getPort().toString(); + url.pathname = dbName; + url.username = startedContainer.getUsername(); + url.password = startedContainer.getPassword(); + + const saver = PostgresSaver.fromConnString(url.toString()); + await saver.setup(); + return saver; + }, + + async destroySaver(saver) { + await saver.end(); + await client?.query(`DROP DATABASE ${dbName}`); + await client?.end(); + }, +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/tests/sqlite.spec.ts b/libs/checkpoint-validation/src/tests/sqlite.spec.ts new file mode 100644 index 00000000..e3c25022 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/sqlite.spec.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { specTest } from "../spec/index.js"; +import { initializer } from "./sqliteInitializer.js"; + +specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/sqliteInitializer.ts b/libs/checkpoint-validation/src/tests/sqliteInitializer.ts new file mode 100644 index 00000000..8fe6b751 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/sqliteInitializer.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite"; +import { CheckpointSaverTestInitializer } from "../types.js"; + +export const initializer: CheckpointSaverTestInitializer = { + saverName: "@langchain/langgraph-checkpoint-sqlite", + + async createSaver() { + return SqliteSaver.fromConnString(":memory:"); + }, + + destroySaver(saver) { + saver.db.close(); + }, +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/types.ts b/libs/checkpoint-validation/src/types.ts new file mode 100644 index 00000000..548457c0 --- /dev/null +++ b/libs/checkpoint-validation/src/types.ts @@ -0,0 +1,107 @@ +import { RunnableConfig } from "@langchain/core/runnables"; +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { z } from "zod"; + +export interface CheckpointSaverTestInitializer< + CheckpointSaverT extends BaseCheckpointSaver +> { + /** + * The name of the checkpoint saver being tested. This will be used to identify the saver in test output. + */ + saverName: string; + + /** + * Called once before any tests are run. Use this to perform any setup that your checkpoint saver may require, like + * starting docker containers, etc. + */ + beforeAll?(): void | Promise; + + /** + * Optional timeout for beforeAll. Useful for test setups that might take a while to complete, e.g. due to needing to + * pull a docker container. + * + * @default 10000 + */ + beforeAllTimeout?: number; + + /** + * Called once after all tests are run. Use this to perform any infrastructure cleanup that your checkpoint saver may + * require, like tearing down docker containers, etc. + */ + afterAll?(): void | Promise; + + /** + * Called before each set of validations is run, prior to calling @see createSaver. Use this to modify the @see + * RunnableConfig that will be used during the test, used to include any additional configuration that your + * checkpoint saver may require. + * + * @param config The @see RunnableConfig that will be used during the test. + * @returns an instance of @see RunnableConfig (or a promise that resolves to one) to be merged with the original + * config for use during the test execution. + */ + configure?(config: RunnableConfig): RunnableConfig | Promise; + + /** + * Called before each set of validations is run, after @see configure has been called. The checkpoint saver returned + * will be used during test execution. + * + * @param config The @see RunnableConfig that will be used during the test. Can be used for constructing the saver, + * if required. + * @returns A new saver, or promise that resolves to a new saver. + */ + createSaver( + config: RunnableConfig + ): CheckpointSaverT | Promise; + + /** + * Called after each set of validations is run. Use this to clean up any resources that your checkpoint saver may + * have been using. This should include cleaning up any state that the saver wrote during the tests that just ran. + * + * @param saver The @see BaseCheckpointSaver that was used during the test. + * @param config The @see RunnableConfig that was used during the test. + */ + destroySaver?( + saver: CheckpointSaverT, + config: RunnableConfig + ): void | Promise; +} + +export const checkpointSaverTestInitializerSchema = z.object({ + saverName: z.string(), + beforeAll: z + .function() + .returns(z.void().or(z.promise(z.void()))) + .optional(), + beforeAllTimeout: z.number().default(10000).optional(), + afterAll: z + .function() + .returns(z.void().or(z.promise(z.void()))) + .optional(), + configure: z + .function() + .args(z.custom()) + .returns( + z.custom().or(z.promise(z.custom())) + ) + .optional(), + createSaver: z + .function() + .args(z.custom()) + .returns( + z + .custom() + .or(z.promise(z.custom())) + ), + destroySaver: z + .function() + .args(z.custom(), z.custom()) + .returns(z.void().or(z.promise(z.void()))) + .optional(), +}); + +export type TestTypeFilter = "getTuple" | "list" | "put" | "putWrites"; + +export type GlobalThis = typeof globalThis & { + __langgraph_checkpoint_validation_initializer?: CheckpointSaverTestInitializer; + __langgraph_checkpoint_validation_filters?: TestTypeFilter[]; +}; diff --git a/libs/checkpoint-validation/tsconfig.cjs.json b/libs/checkpoint-validation/tsconfig.cjs.json new file mode 100644 index 00000000..ca674d22 --- /dev/null +++ b/libs/checkpoint-validation/tsconfig.cjs.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": [ + "node_modules", + "dist", + "docs", + "**/tests", + "src/cli.ts", + "src/importUtils.ts", + "src/runner.ts" + ] +} diff --git a/libs/checkpoint-validation/tsconfig.json b/libs/checkpoint-validation/tsconfig.json new file mode 100644 index 00000000..78398e4d --- /dev/null +++ b/libs/checkpoint-validation/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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 + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "docs"] +} diff --git a/libs/checkpoint-validation/turbo.json b/libs/checkpoint-validation/turbo.json new file mode 100644 index 00000000..d1bb60a7 --- /dev/null +++ b/libs/checkpoint-validation/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 60f30f37..c211b52f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -435,6 +435,13 @@ __metadata: languageName: node linkType: hard +"@balena/dockerignore@npm:^1.0.2": + version: 1.0.2 + resolution: "@balena/dockerignore@npm:1.0.2" + checksum: 0d39f8fbcfd1a983a44bced54508471ab81aaaa40e2c62b46a9f97eac9d6b265790799f16919216db486331dedaacdde6ecbd6b7abe285d39bc50de111991699 + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -442,6 +449,34 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.1.0": + version: 1.3.1 + resolution: "@emnapi/core@npm:1.3.1" + dependencies: + "@emnapi/wasi-threads": 1.0.1 + tslib: ^2.4.0 + checksum: 9b4e4bc37e09d901f5d95ca998c4936432a7a2207f33e98e15ae8c9bb34803baa444cef66b8acc80fd701f6634c2718f43709e82432052ea2aa7a71a58cb9164 + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.1.0": + version: 1.3.1 + resolution: "@emnapi/runtime@npm:1.3.1" + dependencies: + tslib: ^2.4.0 + checksum: 9a16ae7905a9c0e8956cf1854ef74e5087fbf36739abdba7aa6b308485aafdc993da07c19d7af104cd5f8e425121120852851bb3a0f78e2160e420a36d47f42f + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.0.1": + version: 1.0.1 + resolution: "@emnapi/wasi-threads@npm:1.0.1" + dependencies: + tslib: ^2.4.0 + checksum: e154880440ff9bfe67b417f30134f0ff6fee28913dbf4a22de2e67dda5bf5b51055647c5d1565281df17ef5dfcc89256546bdf9b8ccfd07e07566617e7ce1498 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.19.12": version: 0.19.12 resolution: "@esbuild/aix-ppc64@npm:0.19.12" @@ -820,6 +855,13 @@ __metadata: languageName: node linkType: hard +"@fastify/busboy@npm:^2.0.0": + version: 2.1.1 + resolution: "@fastify/busboy@npm:2.1.1" + checksum: 42c32ef75e906c9a4809c1e1930a5ca6d4ddc8d138e1a8c8ba5ea07f997db32210617d23b2e4a85fe376316a41a1a0439fc6ff2dedf5126d96f45a9d80754fb2 + languageName: node + linkType: hard + "@huggingface/jinja@npm:^0.2.2": version: 0.2.2 resolution: "@huggingface/jinja@npm:0.2.2" @@ -914,7 +956,7 @@ __metadata: languageName: node linkType: hard -"@jest/core@npm:^29.7.0": +"@jest/core@npm:^29.5.0, @jest/core@npm:^29.7.0": version: 29.7.0 resolution: "@jest/core@npm:29.7.0" dependencies: @@ -1612,7 +1654,7 @@ __metadata: languageName: node linkType: hard -"@langchain/langgraph-checkpoint-mongodb@workspace:libs/checkpoint-mongodb": +"@langchain/langgraph-checkpoint-mongodb@workspace:*, @langchain/langgraph-checkpoint-mongodb@workspace:libs/checkpoint-mongodb": version: 0.0.0-use.local resolution: "@langchain/langgraph-checkpoint-mongodb@workspace:libs/checkpoint-mongodb" dependencies: @@ -1726,6 +1768,57 @@ __metadata: languageName: unknown linkType: soft +"@langchain/langgraph-checkpoint-validation@workspace:libs/checkpoint-validation": + version: 0.0.0-use.local + resolution: "@langchain/langgraph-checkpoint-validation@workspace:libs/checkpoint-validation" + dependencies: + "@jest/core": ^29.5.0 + "@jest/globals": ^29.5.0 + "@langchain/langgraph-checkpoint": "workspace:*" + "@langchain/langgraph-checkpoint-mongodb": "workspace:*" + "@langchain/langgraph-checkpoint-postgres": "workspace:*" + "@langchain/langgraph-checkpoint-sqlite": "workspace:*" + "@langchain/scripts": ">=0.1.3 <0.2.0" + "@swc-node/register": ^1.10.9 + "@swc/core": ^1.3.90 + "@swc/jest": ^0.2.29 + "@testcontainers/mongodb": ^10.13.2 + "@testcontainers/postgresql": ^10.13.2 + "@tsconfig/recommended": ^1.0.3 + "@types/uuid": ^10 + "@typescript-eslint/eslint-plugin": ^6.12.0 + "@typescript-eslint/parser": ^6.12.0 + better-sqlite3: ^9.5.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 + mongodb: ^6.8.0 + pg: ^8.12.0 + prettier: ^2.8.3 + release-it: ^17.6.0 + rollup: ^4.22.4 + ts-jest: ^29.1.0 + tsx: ^4.7.0 + typescript: ^4.9.5 || ^5.4.5 + uuid: ^10.0.0 + yargs: ^17.7.2 + zod: ^3.23.8 + peerDependencies: + "@langchain/core": ">=0.2.31 <0.4.0" + "@langchain/langgraph-checkpoint": ~0.0.6 + bin: + validate-saver: ./bin/cli.js + languageName: unknown + linkType: soft + "@langchain/langgraph-checkpoint@workspace:*, @langchain/langgraph-checkpoint@workspace:libs/checkpoint, @langchain/langgraph-checkpoint@~0.0.10": version: 0.0.0-use.local resolution: "@langchain/langgraph-checkpoint@workspace:libs/checkpoint" @@ -1904,6 +1997,17 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^0.2.4": + version: 0.2.5 + resolution: "@napi-rs/wasm-runtime@npm:0.2.5" + dependencies: + "@emnapi/core": ^1.1.0 + "@emnapi/runtime": ^1.1.0 + "@tybys/wasm-util": ^0.9.0 + checksum: eefece41cfd4990660e06fff69f22ebaac15f5bea5b0f2ef936ee0ee69ff229618f05054472669676079eb1d4d404c8358a6b6c832fd9ae62658add4eefa1531 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2194,6 +2298,85 @@ __metadata: languageName: node linkType: hard +"@oxc-resolver/binding-darwin-arm64@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-darwin-arm64@npm:1.12.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-darwin-x64@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-darwin-x64@npm:1.12.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-freebsd-x64@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-freebsd-x64@npm:1.12.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-arm-gnueabihf@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-linux-arm-gnueabihf@npm:1.12.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-arm64-gnu@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-linux-arm64-gnu@npm:1.12.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-arm64-musl@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-linux-arm64-musl@npm:1.12.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-x64-gnu@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-linux-x64-gnu@npm:1.12.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-x64-musl@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-linux-x64-musl@npm:1.12.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxc-resolver/binding-wasm32-wasi@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-wasm32-wasi@npm:1.12.0" + dependencies: + "@napi-rs/wasm-runtime": ^0.2.4 + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-arm64-msvc@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-win32-arm64-msvc@npm:1.12.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-x64-msvc@npm:1.12.0": + version: 1.12.0 + resolution: "@oxc-resolver/binding-win32-x64-msvc@npm:1.12.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -2637,6 +2820,44 @@ __metadata: languageName: node linkType: hard +"@swc-node/core@npm:^1.13.3": + version: 1.13.3 + resolution: "@swc-node/core@npm:1.13.3" + peerDependencies: + "@swc/core": ">= 1.4.13" + "@swc/types": ">= 0.1" + checksum: 9bad56479b2e980af8cfbcc1f040b95a928e38ead40ee3f980cd5718814cdaa6dc93d3a1d4a584e9fb1105af9a8f06ee2d5d82c6465ac364e6febe637f6139d7 + languageName: node + linkType: hard + +"@swc-node/register@npm:^1.10.9": + version: 1.10.9 + resolution: "@swc-node/register@npm:1.10.9" + dependencies: + "@swc-node/core": ^1.13.3 + "@swc-node/sourcemap-support": ^0.5.1 + colorette: ^2.0.20 + debug: ^4.3.5 + oxc-resolver: ^1.10.2 + pirates: ^4.0.6 + tslib: ^2.6.3 + peerDependencies: + "@swc/core": ">= 1.4.13" + typescript: ">= 4.3" + checksum: 147998eaca7b12dbaf17f937d849f615b8f541bada136a6619b79610ec2fc509599ac48fe61f04cfbe6f459cf9773b87119845b77b30c5d31ebe450a527c6566 + languageName: node + linkType: hard + +"@swc-node/sourcemap-support@npm:^0.5.1": + version: 0.5.1 + resolution: "@swc-node/sourcemap-support@npm:0.5.1" + dependencies: + source-map-support: ^0.5.21 + tslib: ^2.6.3 + checksum: 307be2a52c10f3899871dc316190584e7a6e48375de5b84638cd0ca96681c4ce89891b9f7e86dedb93aac106dea7eff42ac2192f443ac1a1242a206ec93d0caf + languageName: node + linkType: hard + "@swc/core-darwin-arm64@npm:1.4.16": version: 1.4.16 resolution: "@swc/core-darwin-arm64@npm:1.4.16" @@ -2791,6 +3012,24 @@ __metadata: languageName: node linkType: hard +"@testcontainers/mongodb@npm:^10.13.2": + version: 10.13.2 + resolution: "@testcontainers/mongodb@npm:10.13.2" + dependencies: + testcontainers: ^10.13.2 + checksum: 6440db8aaaddb6b73b8db0ba9e4aedc4a508fef65573e03eba355e88cf51799c4b96269f66a1f37b0b5f3185e4708d33f9d9b810e87675f89adc6f0c78509227 + languageName: node + linkType: hard + +"@testcontainers/postgresql@npm:^10.13.2": + version: 10.13.2 + resolution: "@testcontainers/postgresql@npm:10.13.2" + dependencies: + testcontainers: ^10.13.2 + checksum: 444b863f2a92a591f1658e8bb7d4d934ce6f31dda3cb37dbaa004b28cdb7830f8e77f85e7a8ad27ff035a24f3d70d90294818e6910fa400c4fc21db2e31f76f5 + languageName: node + linkType: hard + "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" @@ -2827,6 +3066,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.9.0": + version: 0.9.0 + resolution: "@tybys/wasm-util@npm:0.9.0" + dependencies: + tslib: ^2.4.0 + checksum: 8d44c64e64e39c746e45b5dff7b534716f20e1f6e8fc206f8e4c8ac454ec0eb35b65646e446dd80745bc898db37a4eca549a936766d447c2158c9c43d44e7708 + languageName: node + linkType: hard + "@types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -3149,6 +3397,27 @@ __metadata: languageName: node linkType: hard +"@types/docker-modem@npm:*": + version: 3.0.6 + resolution: "@types/docker-modem@npm:3.0.6" + dependencies: + "@types/node": "*" + "@types/ssh2": "*" + checksum: cc58e8189f6ec5a2b8ca890207402178a97ddac8c80d125dc65d8ab29034b5db736de15e99b91b2d74e66d14e26e73b6b8b33216613dd15fd3aa6b82c11a83ed + languageName: node + linkType: hard + +"@types/dockerode@npm:^3.3.29": + version: 3.3.31 + resolution: "@types/dockerode@npm:3.3.31" + dependencies: + "@types/docker-modem": "*" + "@types/node": "*" + "@types/ssh2": "*" + checksum: f634f18dc0633f8324faefcde53bcd3d8f3c4bd74d31078cbeb65d2e1597f9abcf12c2158abfaea13dc816bae0f5fa08d0bb570d4214ab0df1ded90db5ebabfe + languageName: node + linkType: hard + "@types/double-ended-queue@npm:^2": version: 2.1.7 resolution: "@types/double-ended-queue@npm:2.1.7" @@ -3344,6 +3613,34 @@ __metadata: languageName: node linkType: hard +"@types/ssh2-streams@npm:*": + version: 0.1.12 + resolution: "@types/ssh2-streams@npm:0.1.12" + dependencies: + "@types/node": "*" + checksum: aa0aa45e40cfca34b4443dafa8d28ff49196c05c71867cbf0a8cdd5127be4d8a3840819543fcad16535653ca8b0e29217671ed6500ff1e7a3ad2442c5d1b40a6 + languageName: node + linkType: hard + +"@types/ssh2@npm:*": + version: 1.15.1 + resolution: "@types/ssh2@npm:1.15.1" + dependencies: + "@types/node": ^18.11.18 + checksum: 6a10b4da60817f2939cac18006a7ccbc6421facf2370a263072fc5290b1f5d445b385c5f309e93ce447bb33ad92dac18f562ccda20f092076da1c1a55da299fb + languageName: node + linkType: hard + +"@types/ssh2@npm:^0.5.48": + version: 0.5.52 + resolution: "@types/ssh2@npm:0.5.52" + dependencies: + "@types/node": "*" + "@types/ssh2-streams": "*" + checksum: bc1c76ac727ad73ddd59ba849cf0ea3ed2e930439e7a363aff24f04f29b74f9b1976369b869dc9a018223c9fb8ad041c09a0f07aea8cf46a8c920049188cddae + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -3757,6 +4054,36 @@ __metadata: languageName: node linkType: hard +"archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": + version: 5.0.2 + resolution: "archiver-utils@npm:5.0.2" + dependencies: + glob: ^10.0.0 + graceful-fs: ^4.2.0 + is-stream: ^2.0.1 + lazystream: ^1.0.0 + lodash: ^4.17.15 + normalize-path: ^3.0.0 + readable-stream: ^4.0.0 + checksum: 7dc4f3001dc373bd0fa7671ebf08edf6f815cbc539c78b5478a2eaa67e52e3fc0e92f562cdef2ba016c4dcb5468d3d069eb89535c6844da4a5bb0baf08ad5720 + languageName: node + linkType: hard + +"archiver@npm:^7.0.1": + version: 7.0.1 + resolution: "archiver@npm:7.0.1" + dependencies: + archiver-utils: ^5.0.2 + async: ^3.2.4 + buffer-crc32: ^1.0.0 + readable-stream: ^4.0.0 + readdir-glob: ^1.1.2 + tar-stream: ^3.0.0 + zip-stream: ^6.0.1 + checksum: f93bcc00f919e0bbb6bf38fddf111d6e4d1ed34721b73cc073edd37278303a7a9f67aa4abd6fd2beb80f6c88af77f2eb4f60276343f67605e3aea404e5ad93ea + languageName: node + linkType: hard + "argparse@npm:^1.0.7": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -3858,6 +4185,15 @@ __metadata: languageName: node linkType: hard +"asn1@npm:^0.2.6": + version: 0.2.6 + resolution: "asn1@npm:0.2.6" + dependencies: + safer-buffer: ~2.1.0 + checksum: 39f2ae343b03c15ad4f238ba561e626602a3de8d94ae536c46a4a93e69578826305366dc09fbb9b56aec39b4982a463682f259c38e59f6fa380cd72cd61e493d + languageName: node + linkType: hard + "ast-types@npm:^0.13.4": version: 0.13.4 resolution: "ast-types@npm:0.13.4" @@ -3867,6 +4203,13 @@ __metadata: languageName: node linkType: hard +"async-lock@npm:^1.4.1": + version: 1.4.1 + resolution: "async-lock@npm:1.4.1" + checksum: 29e70cd892932b7c202437786cedc39ff62123cb6941014739bd3cabd6106326416e9e7c21285a5d1dc042cad239a0f7ec9c44658491ee4a615fd36a21c1d10a + languageName: node + linkType: hard + "async-retry@npm:1.3.3": version: 1.3.3 resolution: "async-retry@npm:1.3.3" @@ -3876,6 +4219,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.4": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: ee6eb8cd8a0ab1b58bd2a3ed6c415e93e773573a91d31df9d5ef559baafa9dab37d3b096fa7993e84585cac3697b2af6ddb9086f45d3ac8cae821bb2aab65682 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -4050,6 +4400,15 @@ __metadata: languageName: node linkType: hard +"bcrypt-pbkdf@npm:^1.0.2": + version: 1.0.2 + resolution: "bcrypt-pbkdf@npm:1.0.2" + dependencies: + tweetnacl: ^0.14.3 + checksum: 4edfc9fe7d07019609ccf797a2af28351736e9d012c8402a07120c4453a3b789a15f2ee1530dc49eee8f7eb9379331a8dd4b3766042b9e502f74a68e7f662291 + languageName: node + linkType: hard + "before-after-hook@npm:^2.2.0": version: 2.2.3 resolution: "before-after-hook@npm:2.2.3" @@ -4192,6 +4551,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:^1.0.0": + version: 1.0.0 + resolution: "buffer-crc32@npm:1.0.0" + checksum: bc114c0e02fe621249e0b5093c70e6f12d4c2b1d8ddaf3b1b7bbe3333466700100e6b1ebdc12c050d0db845bc582c4fce8c293da487cc483f97eea027c480b23 + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -4209,6 +4575,23 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.2.1 + checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9 + languageName: node + linkType: hard + +"buildcheck@npm:~0.0.6": + version: 0.0.6 + resolution: "buildcheck@npm:0.0.6" + checksum: ad61759dc98d62e931df2c9f54ccac7b522e600c6e13bdcfdc2c9a872a818648c87765ee209c850f022174da4dd7c6a450c00357c5391705d26b9c5807c2a076 + languageName: node + linkType: hard + "builtin-modules@npm:^3.1.0": version: 3.3.0 resolution: "builtin-modules@npm:3.3.0" @@ -4225,6 +4608,13 @@ __metadata: languageName: node linkType: hard +"byline@npm:^5.0.0": + version: 5.0.0 + resolution: "byline@npm:5.0.0" + checksum: 737ca83e8eda2976728dae62e68bc733aea095fab08db4c6f12d3cee3cf45b6f97dce45d1f6b6ff9c2c947736d10074985b4425b31ce04afa1985a4ef3d334a7 + languageName: node + linkType: hard + "cacache@npm:^18.0.0": version: 18.0.2 resolution: "cacache@npm:18.0.2" @@ -4579,6 +4969,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.20": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 0c016fea2b91b733eb9f4bcdb580018f52c0bc0979443dad930e5037a968237ac53d9beb98e218d2e9235834f8eebce7f8e080422d6194e957454255bde71d3d + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -4616,6 +5013,19 @@ __metadata: languageName: node linkType: hard +"compress-commons@npm:^6.0.2": + version: 6.0.2 + resolution: "compress-commons@npm:6.0.2" + dependencies: + crc-32: ^1.2.0 + crc32-stream: ^6.0.0 + is-stream: ^2.0.1 + normalize-path: ^3.0.0 + readable-stream: ^4.0.0 + checksum: 37d79a54f91344ecde352588e0a128f28ce619b085acd4f887defd76978a0640e3454a42c7dcadb0191bb3f971724ae4b1f9d6ef9620034aa0427382099ac946 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -4660,6 +5070,13 @@ __metadata: languageName: node linkType: hard +"core-util-is@npm:~1.0.0": + version: 1.0.3 + resolution: "core-util-is@npm:1.0.3" + checksum: 9de8597363a8e9b9952491ebe18167e3b36e7707569eed0ebf14f8bba773611376466ae34575bca8cfe3c767890c859c74056084738f09d4e4a6f902b2ad7d99 + languageName: node + linkType: hard + "cosmiconfig@npm:9.0.0": version: 9.0.0 resolution: "cosmiconfig@npm:9.0.0" @@ -4677,6 +5094,36 @@ __metadata: languageName: node linkType: hard +"cpu-features@npm:~0.0.10": + version: 0.0.10 + resolution: "cpu-features@npm:0.0.10" + dependencies: + buildcheck: ~0.0.6 + nan: ^2.19.0 + node-gyp: latest + checksum: ab17e25cea0b642bdcfd163d3d872be4cc7d821e854d41048557799e990d672ee1cc7bd1d4e7c4de0309b1683d4c001d36ba8569b5035d1e7e2ff2d681f681d7 + languageName: node + linkType: hard + +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: ad2d0ad0cbd465b75dcaeeff0600f8195b686816ab5f3ba4c6e052a07f728c3e70df2e3ca9fd3d4484dc4ba70586e161ca5a2334ec8bf5a41bf022a6103ff243 + languageName: node + linkType: hard + +"crc32-stream@npm:^6.0.0": + version: 6.0.0 + resolution: "crc32-stream@npm:6.0.0" + dependencies: + crc-32: ^1.2.0 + readable-stream: ^4.0.0 + checksum: e6edc2f81bc387daef6d18b2ac18c2ffcb01b554d3b5c7d8d29b177505aafffba574658fdd23922767e8dab1183d1962026c98c17e17fb272794c33293ef607c + languageName: node + linkType: hard + "create-jest@npm:^29.7.0": version: 29.7.0 resolution: "create-jest@npm:29.7.0" @@ -5132,6 +5579,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.5": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: ^2.1.3 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 822d74e209cd910ef0802d261b150314bbcf36c582ccdbb3e70f0894823c17e49a50d3e66d96b633524263975ca16b6a833f3e3b7e030c157169a5fabac63160 + languageName: node + linkType: hard + "decamelize@npm:1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -5307,6 +5766,38 @@ __metadata: languageName: node linkType: hard +"docker-compose@npm:^0.24.8": + version: 0.24.8 + resolution: "docker-compose@npm:0.24.8" + dependencies: + yaml: ^2.2.2 + checksum: 48f3564c46490f1f51899a144deb546b61450a76bffddb378379ac7702aa34b055e0237e0dc77507df94d7ad6f1f7daeeac27730230bce9aafe2e35efeda6b45 + languageName: node + linkType: hard + +"docker-modem@npm:^3.0.0": + version: 3.0.8 + resolution: "docker-modem@npm:3.0.8" + dependencies: + debug: ^4.1.1 + readable-stream: ^3.5.0 + split-ca: ^1.0.1 + ssh2: ^1.11.0 + checksum: e3675c9b1ad800be8fb1cb9c5621fbef20a75bfedcd6e01b69808eadd7f0165681e4e30d1700897b788a67dbf4769964fcccd19c3d66f6d2499bb7aede6b34df + languageName: node + linkType: hard + +"dockerode@npm:^3.3.5": + version: 3.3.5 + resolution: "dockerode@npm:3.3.5" + dependencies: + "@balena/dockerignore": ^1.0.2 + docker-modem: ^3.0.0 + tar-fs: ~2.0.1 + checksum: 7f6650422b07fa7ea9d5801f04b1a432634446b5fe37b995b8302b953b64e93abf1bb4596c2fb574ba47aafee685ef2ab959cc86c9654add5a26d09541bbbcc6 + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -6297,6 +6788,13 @@ __metadata: languageName: node linkType: hard +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780 + languageName: node + linkType: hard + "examples@workspace:examples": version: 0.0.0-use.local resolution: "examples@workspace:examples" @@ -6779,6 +7277,13 @@ __metadata: languageName: node linkType: hard +"get-port@npm:^5.1.1": + version: 5.1.1 + resolution: "get-port@npm:5.1.1" + checksum: 0162663ffe5c09e748cd79d97b74cd70e5a5c84b760a475ce5767b357fb2a57cb821cee412d646aa8a156ed39b78aab88974eddaa9e5ee926173c036c0713787 + languageName: node + linkType: hard + "get-stream@npm:^6.0.0, get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1" @@ -6878,22 +7383,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.4": - version: 10.3.12 - resolution: "glob@npm:10.3.12" - dependencies: - foreground-child: ^3.1.0 - jackspeak: ^2.3.6 - minimatch: ^9.0.1 - minipass: ^7.0.4 - path-scurry: ^1.10.2 - bin: - glob: dist/esm/bin.mjs - checksum: 2b0949d6363021aaa561b108ac317bf5a97271b8a5d7a5fac1a176e40e8068ecdcccc992f8a7e958593d501103ac06d673de92adc1efcbdab45edefe35f8d7c6 - languageName: node - linkType: hard - -"glob@npm:^10.3.7": +"glob@npm:^10.0.0, glob@npm:^10.3.7": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -6909,6 +7399,21 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.4": + version: 10.3.12 + resolution: "glob@npm:10.3.12" + dependencies: + foreground-child: ^3.1.0 + jackspeak: ^2.3.6 + minimatch: ^9.0.1 + minipass: ^7.0.4 + path-scurry: ^1.10.2 + bin: + glob: dist/esm/bin.mjs + checksum: 2b0949d6363021aaa561b108ac317bf5a97271b8a5d7a5fac1a176e40e8068ecdcccc992f8a7e958593d501103ac06d673de92adc1efcbdab45edefe35f8d7c6 + languageName: node + linkType: hard + "glob@npm:^7.0.0, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -7020,7 +7525,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7 @@ -7226,7 +7731,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e @@ -7293,7 +7798,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4": +"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -7642,7 +8147,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0": +"is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 @@ -7736,6 +8241,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:~1.0.0": + version: 1.0.0 + resolution: "isarray@npm:1.0.0" + checksum: f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -8621,6 +9133,15 @@ __metadata: languageName: node linkType: hard +"lazystream@npm:^1.0.0": + version: 1.0.1 + resolution: "lazystream@npm:1.0.1" + dependencies: + readable-stream: ^2.0.5 + checksum: 822c54c6b87701a6491c70d4fabc4cafcf0f87d6b656af168ee7bb3c45de9128a801cb612e6eeeefc64d298a7524a698dd49b13b0121ae50c2ae305f0dcc5310 + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -8712,7 +9233,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21, lodash@npm:^4.17.21": +"lodash@npm:4.17.21, lodash@npm:^4.17.15, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -8945,6 +9466,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^5.1.0": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: ^2.0.1 + checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77 + languageName: node + linkType: hard + "minimatch@npm:^9.0.1, minimatch@npm:^9.0.3": version: 9.0.4 resolution: "minimatch@npm:9.0.4" @@ -9068,7 +9598,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^1.0.3": +"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" bin: @@ -9137,7 +9667,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -9160,6 +9690,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.19.0, nan@npm:^2.20.0": + version: 2.20.0 + resolution: "nan@npm:2.20.0" + dependencies: + node-gyp: latest + checksum: eb09286e6c238a3582db4d88c875db73e9b5ab35f60306090acd2f3acae21696c9b653368b4a0e32abcef64ee304a923d6223acaddd16169e5eaaf5c508fb533 + languageName: node + linkType: hard + "napi-build-utils@npm:^1.0.1": version: 1.0.2 resolution: "napi-build-utils@npm:1.0.2" @@ -9603,6 +10142,48 @@ __metadata: languageName: node linkType: hard +"oxc-resolver@npm:^1.10.2": + version: 1.12.0 + resolution: "oxc-resolver@npm:1.12.0" + dependencies: + "@oxc-resolver/binding-darwin-arm64": 1.12.0 + "@oxc-resolver/binding-darwin-x64": 1.12.0 + "@oxc-resolver/binding-freebsd-x64": 1.12.0 + "@oxc-resolver/binding-linux-arm-gnueabihf": 1.12.0 + "@oxc-resolver/binding-linux-arm64-gnu": 1.12.0 + "@oxc-resolver/binding-linux-arm64-musl": 1.12.0 + "@oxc-resolver/binding-linux-x64-gnu": 1.12.0 + "@oxc-resolver/binding-linux-x64-musl": 1.12.0 + "@oxc-resolver/binding-wasm32-wasi": 1.12.0 + "@oxc-resolver/binding-win32-arm64-msvc": 1.12.0 + "@oxc-resolver/binding-win32-x64-msvc": 1.12.0 + dependenciesMeta: + "@oxc-resolver/binding-darwin-arm64": + optional: true + "@oxc-resolver/binding-darwin-x64": + optional: true + "@oxc-resolver/binding-freebsd-x64": + optional: true + "@oxc-resolver/binding-linux-arm-gnueabihf": + optional: true + "@oxc-resolver/binding-linux-arm64-gnu": + optional: true + "@oxc-resolver/binding-linux-arm64-musl": + optional: true + "@oxc-resolver/binding-linux-x64-gnu": + optional: true + "@oxc-resolver/binding-linux-x64-musl": + optional: true + "@oxc-resolver/binding-wasm32-wasi": + optional: true + "@oxc-resolver/binding-win32-arm64-msvc": + optional: true + "@oxc-resolver/binding-win32-x64-msvc": + optional: true + checksum: 32ae094673c8abb4ee74e518b01581b8a59cf66ee59c59c428f7d88cdbb46a81e9df04e310b51aff986d32e760ae31ff9ebba2b576d562d06513dddae74453c7 + languageName: node + linkType: hard + "p-cancelable@npm:^3.0.0": version: 3.0.0 resolution: "p-cancelable@npm:3.0.0" @@ -10039,7 +10620,7 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.4": +"pirates@npm:^4.0.4, pirates@npm:^4.0.6": version: 4.0.6 resolution: "pirates@npm:4.0.6" checksum: 46a65fefaf19c6f57460388a5af9ab81e3d7fd0e7bc44ca59d753cb5c4d0df97c6c6e583674869762101836d68675f027d60f841c105d72734df9dfca97cbcc6 @@ -10201,6 +10782,20 @@ __metadata: languageName: node linkType: hard +"process-nextick-args@npm:~2.0.0": + version: 2.0.1 + resolution: "process-nextick-args@npm:2.0.1" + checksum: 1d38588e520dab7cea67cbbe2efdd86a10cc7a074c09657635e34f035277b59fbb57d09d8638346bf7090f8e8ebc070c96fa5fd183b777fff4f5edff5e9466cf + languageName: node + linkType: hard + +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: bfcce49814f7d172a6e6a14d5fa3ac92cc3d0c3b9feb1279774708a719e19acd673995226351a082a9ae99978254e320ccda4240ddc474ba31a76c79491ca7c3 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -10221,6 +10816,26 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: ^4.2.4 + retry: ^0.12.0 + signal-exit: ^3.0.2 + checksum: 00078ee6a61c216a56a6140c7d2a98c6c733b3678503002dc073ab8beca5d50ca271de4c85fca13b9b8ee2ff546c36674d1850509b84a04a5d0363bcb8638939 + languageName: node + linkType: hard + +"properties-reader@npm:^2.3.0": + version: 2.3.0 + resolution: "properties-reader@npm:2.3.0" + dependencies: + mkdirp: ^1.0.4 + checksum: cbf59e862dc507f8ce1f8d7641ed9737119f16a1d4dad8e79f17b303aaca1c6af7d36ddfef0f649cab4d200ba4334ac159af0b238f6978a085f5b1b5126b6cc3 + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4" @@ -10366,7 +10981,22 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": +"readable-stream@npm:^2.0.5": + version: 2.3.8 + resolution: "readable-stream@npm:2.3.8" + dependencies: + core-util-is: ~1.0.0 + inherits: ~2.0.3 + isarray: ~1.0.0 + process-nextick-args: ~2.0.0 + safe-buffer: ~5.1.1 + string_decoder: ~1.1.1 + util-deprecate: ~1.0.1 + checksum: 65645467038704f0c8aaf026a72fbb588a9e2ef7a75cd57a01702ee9db1c4a1e4b03aaad36861a6a0926546a74d174149c8c207527963e0c2d3eee2f37678a42 + languageName: node + linkType: hard + +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -10377,6 +11007,28 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.0.0": + version: 4.5.2 + resolution: "readable-stream@npm:4.5.2" + dependencies: + abort-controller: ^3.0.0 + buffer: ^6.0.3 + events: ^3.3.0 + process: ^0.11.10 + string_decoder: ^1.3.0 + checksum: c4030ccff010b83e4f33289c535f7830190773e274b3fcb6e2541475070bdfd69c98001c3b0cb78763fc00c8b62f514d96c2b10a8bd35d5ce45203a25fa1d33a + languageName: node + linkType: hard + +"readdir-glob@npm:^1.1.2": + version: 1.1.3 + resolution: "readdir-glob@npm:1.1.3" + dependencies: + minimatch: ^5.1.0 + checksum: 1dc0f7440ff5d9378b593abe9d42f34ebaf387516615e98ab410cf3a68f840abbf9ff1032d15e0a0dbffa78f9e2c46d4fafdbaac1ca435af2efe3264e3f21874 + languageName: node + linkType: hard + "readline@npm:^1.3.0": version: 1.3.0 resolution: "readline@npm:1.3.0" @@ -10629,7 +11281,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.23.0": +"rollup@npm:^4.22.4, rollup@npm:^4.23.0": version: 4.24.0 resolution: "rollup@npm:4.24.0" dependencies: @@ -10813,6 +11465,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: f2f1f7943ca44a594893a852894055cf619c1fbcb611237fc39e461ae751187e7baf4dc391a72125e0ac4fb2d8c5c0b3c71529622e6a58f46b960211e704903c + languageName: node + linkType: hard + "safe-regex-test@npm:^1.0.3": version: 1.0.3 resolution: "safe-regex-test@npm:1.0.3" @@ -10824,7 +11483,7 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": +"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0 @@ -11068,6 +11727,16 @@ __metadata: languageName: node linkType: hard +"source-map-support@npm:^0.5.21": + version: 0.5.21 + resolution: "source-map-support@npm:0.5.21" + dependencies: + buffer-from: ^1.0.0 + source-map: ^0.6.0 + checksum: 43e98d700d79af1d36f859bdb7318e601dfc918c7ba2e98456118ebc4c4872b327773e5a1df09b0524e9e5063bb18f0934538eace60cca2710d1fa687645d137 + languageName: node + linkType: hard + "source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" @@ -11091,6 +11760,13 @@ __metadata: languageName: node linkType: hard +"split-ca@npm:^1.0.1": + version: 1.0.1 + resolution: "split-ca@npm:1.0.1" + checksum: 1e7409938a95ee843fe2593156a5735e6ee63772748ee448ea8477a5a3e3abde193c3325b3696e56a5aff07c7dcf6b1f6a2f2a036895b4f3afe96abb366d893f + languageName: node + linkType: hard + "split2@npm:^4.1.0": version: 4.2.0 resolution: "split2@npm:4.2.0" @@ -11112,6 +11788,33 @@ __metadata: 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" + dependencies: + "@types/ssh2": ^0.5.48 + ssh2: ^1.4.0 + checksum: c6c04c5ddfde7cb06e9a8655a152bd28fe6771c6fe62ff0bc08be229491546c410f30b153c968b8d6817a57d38678a270c228f30143ec0fe1be546efc4f6b65a + languageName: node + linkType: hard + +"ssh2@npm:^1.11.0, ssh2@npm:^1.4.0": + version: 1.16.0 + resolution: "ssh2@npm:1.16.0" + dependencies: + asn1: ^0.2.6 + bcrypt-pbkdf: ^1.0.2 + cpu-features: ~0.0.10 + nan: ^2.20.0 + dependenciesMeta: + cpu-features: + optional: true + nan: + optional: true + checksum: c024c4a432aae2457852037f31c0d9bec323fb062ace3a31e4a6dd6c55842246c80e7d20ff93ffed22dde1e523250d8438bc2f7d4a1450cf4fa4887818176f0e + languageName: node + linkType: hard + "ssri@npm:^10.0.0": version: 10.0.5 resolution: "ssri@npm:10.0.5" @@ -11229,7 +11932,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -11238,6 +11941,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:~1.1.1": + version: 1.1.1 + resolution: "string_decoder@npm:1.1.1" + dependencies: + safe-buffer: ~5.1.0 + checksum: 9ab7e56f9d60a28f2be697419917c50cac19f3e8e6c28ef26ed5f4852289fe0de5d6997d29becf59028556f2c62983790c1d9ba1e2a3cc401768ca12d5183a5b + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -11351,7 +12063,7 @@ __metadata: languageName: node linkType: hard -"tar-fs@npm:^3.0.4": +"tar-fs@npm:^3.0.4, tar-fs@npm:^3.0.6": version: 3.0.6 resolution: "tar-fs@npm:3.0.6" dependencies: @@ -11368,7 +12080,19 @@ __metadata: languageName: node linkType: hard -"tar-stream@npm:^2.1.4": +"tar-fs@npm:~2.0.1": + version: 2.0.1 + resolution: "tar-fs@npm:2.0.1" + dependencies: + chownr: ^1.1.1 + mkdirp-classic: ^0.5.2 + pump: ^3.0.0 + tar-stream: ^2.0.0 + checksum: 26cd297ed2421bc8038ce1a4ca442296b53739f409847d495d46086e5713d8db27f2c03ba2f461d0f5ddbc790045628188a8544f8ae32cbb6238b279b68d0247 + languageName: node + linkType: hard + +"tar-stream@npm:^2.0.0, tar-stream@npm:^2.1.4": version: 2.2.0 resolution: "tar-stream@npm:2.2.0" dependencies: @@ -11381,7 +12105,7 @@ __metadata: languageName: node linkType: hard -"tar-stream@npm:^3.1.5": +"tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5": version: 3.1.7 resolution: "tar-stream@npm:3.1.7" dependencies: @@ -11417,6 +12141,29 @@ __metadata: languageName: node linkType: hard +"testcontainers@npm:^10.13.2": + version: 10.13.2 + resolution: "testcontainers@npm:10.13.2" + dependencies: + "@balena/dockerignore": ^1.0.2 + "@types/dockerode": ^3.3.29 + archiver: ^7.0.1 + async-lock: ^1.4.1 + byline: ^5.0.0 + debug: ^4.3.5 + docker-compose: ^0.24.8 + dockerode: ^3.3.5 + get-port: ^5.1.1 + proper-lockfile: ^4.1.2 + properties-reader: ^2.3.0 + ssh-remote-port-forward: ^1.0.4 + tar-fs: ^3.0.6 + tmp: ^0.2.3 + undici: ^5.28.4 + checksum: dd115745369981d159b9e74ce2461c2d7c9f3cfbe747e021c8268913b0b20beb5234cb160f22743cb40b38442dbcdfb5f985c63aa14d3b367493d0bfece6afe3 + languageName: node + linkType: hard + "text-decoder@npm:^1.1.0": version: 1.1.0 resolution: "text-decoder@npm:1.1.0" @@ -11442,6 +12189,13 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.3": + version: 0.2.3 + resolution: "tmp@npm:0.2.3" + checksum: 73b5c96b6e52da7e104d9d44afb5d106bb1e16d9fa7d00dbeb9e6522e61b571fbdb165c756c62164be9a3bbe192b9b268c236d370a2a0955c7689cd2ae377b95 + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -11590,6 +12344,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.4.0, tslib@npm:^2.6.3": + version: 2.7.0 + resolution: "tslib@npm:2.7.0" + checksum: 1606d5c89f88d466889def78653f3aab0f88692e80bb2066d090ca6112ae250ec1cfa9dbfaab0d17b60da15a4186e8ec4d893801c67896b277c17374e36e1d28 + languageName: node + linkType: hard + "tsx@npm:^4.18.0": version: 4.18.0 resolution: "tsx@npm:4.18.0" @@ -11702,6 +12463,13 @@ __metadata: languageName: node linkType: hard +"tweetnacl@npm:^0.14.3": + version: 0.14.5 + resolution: "tweetnacl@npm:0.14.5" + checksum: 6061daba1724f59473d99a7bb82e13f211cdf6e31315510ae9656fefd4779851cb927adad90f3b488c8ed77c106adc0421ea8055f6f976ff21b27c5c4e918487 + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -11862,6 +12630,15 @@ __metadata: languageName: node linkType: hard +"undici@npm:^5.28.4": + version: 5.28.4 + resolution: "undici@npm:5.28.4" + dependencies: + "@fastify/busboy": ^2.0.0 + checksum: a8193132d84540e4dc1895ecc8dbaa176e8a49d26084d6fbe48a292e28397cd19ec5d13bc13e604484e76f94f6e334b2bdc740d5f06a6e50c44072818d0c19f9 + languageName: node + linkType: hard + "unicorn-magic@npm:^0.1.0": version: 0.1.0 resolution: "unicorn-magic@npm:0.1.0" @@ -11967,7 +12744,7 @@ __metadata: languageName: node linkType: hard -"util-deprecate@npm:^1.0.1": +"util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2 @@ -12269,6 +13046,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.2.2": + version: 2.5.1 + resolution: "yaml@npm:2.5.1" + bin: + yaml: bin.mjs + checksum: 31275223863fbd0b47ba9d2b248fbdf085db8d899e4ca43fff8a3a009497c5741084da6871d11f40e555d61360951c4c910b98216c1325d2c94753c0036d8172 + languageName: node + linkType: hard + "yargs-parser@npm:21.1.1, yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -12319,6 +13105,17 @@ __metadata: languageName: node linkType: hard +"zip-stream@npm:^6.0.1": + version: 6.0.1 + resolution: "zip-stream@npm:6.0.1" + dependencies: + archiver-utils: ^5.0.0 + compress-commons: ^6.0.2 + readable-stream: ^4.0.0 + checksum: aa5abd6a89590eadeba040afbc375f53337f12637e5e98330012a12d9886cde7a3ccc28bd91aafab50576035bbb1de39a9a316eecf2411c8b9009c9f94f0db27 + languageName: node + linkType: hard + "zod-to-json-schema@npm:^3.22.3, zod-to-json-schema@npm:^3.22.4, zod-to-json-schema@npm:^3.22.5": version: 3.22.5 resolution: "zod-to-json-schema@npm:3.22.5" From be4ab79a8329cf595c7b99a83f614ed33bb10f05 Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:12:21 +1300 Subject: [PATCH 2/7] drop invalid put test --- libs/checkpoint-validation/src/spec/put.ts | 43 ---------------------- 1 file changed, 43 deletions(-) diff --git a/libs/checkpoint-validation/src/spec/put.ts b/libs/checkpoint-validation/src/spec/put.ts index 1f5b0def..469e557c 100644 --- a/libs/checkpoint-validation/src/spec/put.ts +++ b/libs/checkpoint-validation/src/spec/put.ts @@ -20,14 +20,10 @@ export function putTests( let initializerConfig: RunnableConfig; let thread_id: string; let checkpoint_id1: string; - let checkpoint_id2: string; - let invalid_checkpoint_id: string; beforeEach(async () => { thread_id = uuid6(-3); checkpoint_id1 = uuid6(-3); - checkpoint_id2 = uuid6(-3); - invalid_checkpoint_id = uuid6(-3); const baseConfig = { configurable: { @@ -51,14 +47,10 @@ export function putTests( let configArgument: RunnableConfig; let checkpointStoredWithoutIdInConfig: Checkpoint; let metadataStoredWithoutIdInConfig: CheckpointMetadata | undefined; - let checkpointStoredWithIdInConfig: Checkpoint; - let metadataStoredWithIdInConfig: CheckpointMetadata | undefined; describe("success cases", () => { let basicPutReturnedConfig: RunnableConfig; - let checkpointIdCheckReturnedConfig: RunnableConfig; let basicPutRoundTripTuple: CheckpointTuple | undefined; - let checkpointIdCheckRoundTripTuple: CheckpointTuple | undefined; beforeEach(async () => { ({ @@ -70,15 +62,6 @@ export function putTests( checkpoint_ns, })); - ({ - checkpoint: checkpointStoredWithIdInConfig, - metadata: metadataStoredWithIdInConfig, - } = initialCheckpointTuple({ - config: initializerConfig, - checkpoint_id: checkpoint_id2, - checkpoint_ns, - })); - configArgument = mergeConfigs(initializerConfig, { configurable: { checkpoint_ns }, }); @@ -117,26 +100,9 @@ export function putTests( {} ); - // call put with a different `checkpoint_id` in the config to ensure that it treats the `id` field in the `Checkpoint` as - // the authoritative identifier, rather than the `checkpoint_id` in the config - checkpointIdCheckReturnedConfig = await saver.put( - mergeConfigs(configArgument, { - configurable: { - checkpoint_id: invalid_checkpoint_id, - }, - }), - checkpointStoredWithIdInConfig, - metadataStoredWithIdInConfig!, - {} - ); - basicPutRoundTripTuple = await saver.getTuple( mergeConfigs(configArgument, basicPutReturnedConfig) ); - - checkpointIdCheckRoundTripTuple = await saver.getTuple( - mergeConfigs(configArgument, checkpointIdCheckReturnedConfig) - ); }); it("should return a config with a 'configurable' property", () => { @@ -165,9 +131,6 @@ export function putTests( expect(basicPutReturnedConfig.configurable?.checkpoint_id).toEqual( checkpointStoredWithoutIdInConfig.id ); - expect( - checkpointIdCheckReturnedConfig.configurable?.checkpoint_id - ).toEqual(checkpointStoredWithIdInConfig.id); }); it("should return config with matching checkpoint_ns", () => { @@ -186,12 +149,6 @@ export function putTests( ); }); - it("should return a checkpoint with a new id when the id in the config on put is invalid", () => { - expect(checkpointIdCheckRoundTripTuple?.checkpoint.id).not.toEqual( - invalid_checkpoint_id - ); - }); - it("should store the metadata without alteration", () => { expect(basicPutRoundTripTuple?.metadata).toEqual( metadataStoredWithoutIdInConfig From dd64de186535c09cb7774240470b93e1bbe17550 Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:15:12 +1300 Subject: [PATCH 3/7] fix invalid getTuple test (supply thread_id) --- libs/checkpoint-validation/src/spec/getTuple.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/checkpoint-validation/src/spec/getTuple.ts b/libs/checkpoint-validation/src/spec/getTuple.ts index 374d942c..2ccd0bb4 100644 --- a/libs/checkpoint-validation/src/spec/getTuple.ts +++ b/libs/checkpoint-validation/src/spec/getTuple.ts @@ -239,7 +239,13 @@ export function getTupleTests( it("should return undefined if the checkpoint_id is not found", async () => { const configWithInvalidCheckpointId = mergeConfigs( initializerConfig, - { configurable: { checkpoint_ns, checkpoint_id: uuid6(-3) } } + { + configurable: { + thread_id: uuid6(-3), + checkpoint_ns, + checkpoint_id: uuid6(-3), + }, + } ); const checkpointTuple = await saver.getTuple( configWithInvalidCheckpointId From ef088b973b355258d7c6a4e0a5a9b64aeac7a97f Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:27:18 +1300 Subject: [PATCH 4/7] skip dockerized tests in CI on Windows and M-series mac --- libs/checkpoint-validation/package.json | 1 + .../src/spec/getTuple.ts | 1 - libs/checkpoint-validation/src/spec/index.ts | 1 - libs/checkpoint-validation/src/spec/list.ts | 1 - libs/checkpoint-validation/src/spec/put.ts | 1 - .../src/spec/putWrites.ts | 1 - libs/checkpoint-validation/src/spec/util.ts | 1 - libs/checkpoint-validation/src/testUtils.ts | 6 ++-- .../src/tests/mongodb.spec.ts | 7 +++- .../src/tests/postgres.spec.ts | 7 +++- libs/checkpoint-validation/src/tests/utils.ts | 33 +++++++++++++++++++ libs/checkpoint-validation/tsconfig.json | 1 + yarn.lock | 15 +++++++-- 13 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 libs/checkpoint-validation/src/tests/utils.ts diff --git a/libs/checkpoint-validation/package.json b/libs/checkpoint-validation/package.json index 0aa64ae0..d87e01bd 100644 --- a/libs/checkpoint-validation/package.json +++ b/libs/checkpoint-validation/package.json @@ -55,6 +55,7 @@ "@testcontainers/mongodb": "^10.13.2", "@testcontainers/postgresql": "^10.13.2", "@tsconfig/recommended": "^1.0.3", + "@types/jest": "^29.5.13", "@types/uuid": "^10", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.12.0", diff --git a/libs/checkpoint-validation/src/spec/getTuple.ts b/libs/checkpoint-validation/src/spec/getTuple.ts index 2ccd0bb4..a31c1647 100644 --- a/libs/checkpoint-validation/src/spec/getTuple.ts +++ b/libs/checkpoint-validation/src/spec/getTuple.ts @@ -5,7 +5,6 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { describe, it, beforeAll, afterAll, expect } from "@jest/globals"; import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; import { CheckpointSaverTestInitializer } from "../types.js"; import { parentAndChildCheckpointTuplesWithWrites } from "./data.js"; diff --git a/libs/checkpoint-validation/src/spec/index.ts b/libs/checkpoint-validation/src/spec/index.ts index faa2b03e..ccce443d 100644 --- a/libs/checkpoint-validation/src/spec/index.ts +++ b/libs/checkpoint-validation/src/spec/index.ts @@ -1,5 +1,4 @@ import { type BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; -import { describe, beforeAll, afterAll } from "@jest/globals"; import { CheckpointSaverTestInitializer, TestTypeFilter } from "../types.js"; import { putTests } from "./put.js"; diff --git a/libs/checkpoint-validation/src/spec/list.ts b/libs/checkpoint-validation/src/spec/list.ts index 79fac0ec..a38992d6 100644 --- a/libs/checkpoint-validation/src/spec/list.ts +++ b/libs/checkpoint-validation/src/spec/list.ts @@ -4,7 +4,6 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { describe, it, beforeAll, afterAll, expect } from "@jest/globals"; import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; import { CheckpointSaverTestInitializer } from "../types.js"; import { generateTuplePairs } from "./data.js"; diff --git a/libs/checkpoint-validation/src/spec/put.ts b/libs/checkpoint-validation/src/spec/put.ts index 469e557c..d2c16bfd 100644 --- a/libs/checkpoint-validation/src/spec/put.ts +++ b/libs/checkpoint-validation/src/spec/put.ts @@ -5,7 +5,6 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { describe, it, afterEach, beforeEach, expect } from "@jest/globals"; import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; import { CheckpointSaverTestInitializer } from "../types.js"; import { initialCheckpointTuple } from "./data.js"; diff --git a/libs/checkpoint-validation/src/spec/putWrites.ts b/libs/checkpoint-validation/src/spec/putWrites.ts index 33389d59..43f179d2 100644 --- a/libs/checkpoint-validation/src/spec/putWrites.ts +++ b/libs/checkpoint-validation/src/spec/putWrites.ts @@ -5,7 +5,6 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { describe, it, beforeEach, afterEach, expect } from "@jest/globals"; import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; import { CheckpointSaverTestInitializer } from "../types.js"; import { initialCheckpointTuple } from "./data.js"; diff --git a/libs/checkpoint-validation/src/spec/util.ts b/libs/checkpoint-validation/src/spec/util.ts index 35c3169e..36f0dab7 100644 --- a/libs/checkpoint-validation/src/spec/util.ts +++ b/libs/checkpoint-validation/src/spec/util.ts @@ -1,4 +1,3 @@ -import { expect } from "@jest/globals"; import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; import { BaseCheckpointSaver, diff --git a/libs/checkpoint-validation/src/testUtils.ts b/libs/checkpoint-validation/src/testUtils.ts index 416d3654..af2d661b 100644 --- a/libs/checkpoint-validation/src/testUtils.ts +++ b/libs/checkpoint-validation/src/testUtils.ts @@ -1,5 +1,3 @@ -import { it } from "@jest/globals"; - // to make the type signature of the skipOnModules function a bit more readable export type SaverName = string; export type WhySkipped = string; @@ -21,7 +19,7 @@ export function it_skipForSomeModules( if (skipReason) { const skip = ( name: string, - test: () => void | Promise, + test: jest.ProvidesCallback | undefined, timeout?: number ) => { it.skip(`[because ${skipReason}] ${name}`, test, timeout); @@ -40,7 +38,7 @@ export function it_skipIfNot( if (!savers.includes(saverName)) { const skip = ( name: string, - test: () => void | Promise, + test: jest.ProvidesCallback | undefined, timeout?: number ) => { it.skip( diff --git a/libs/checkpoint-validation/src/tests/mongodb.spec.ts b/libs/checkpoint-validation/src/tests/mongodb.spec.ts index b7256443..2135c4ff 100644 --- a/libs/checkpoint-validation/src/tests/mongodb.spec.ts +++ b/libs/checkpoint-validation/src/tests/mongodb.spec.ts @@ -1,4 +1,9 @@ import { specTest } from "../spec/index.js"; import { initializer } from "./mongoInitializer.js"; +import { isCI, osHasSupportedContainerRuntime } from "./utils.js"; -specTest(initializer); +if (osHasSupportedContainerRuntime() || !isCI()) { + specTest(initializer); +} else { + it.skip(`${initializer.saverName} skipped in CI because no container runtime is available`, () => {}); +} diff --git a/libs/checkpoint-validation/src/tests/postgres.spec.ts b/libs/checkpoint-validation/src/tests/postgres.spec.ts index 828b2425..ca357de5 100644 --- a/libs/checkpoint-validation/src/tests/postgres.spec.ts +++ b/libs/checkpoint-validation/src/tests/postgres.spec.ts @@ -1,4 +1,9 @@ import { specTest } from "../spec/index.js"; import { initializer } from "./postgresInitializer.js"; +import { isCI, osHasSupportedContainerRuntime } from "./utils.js"; -specTest(initializer); +if (osHasSupportedContainerRuntime() || !isCI()) { + specTest(initializer); +} else { + it.skip(`${initializer.saverName} skipped in CI because no container runtime is available`, () => {}); +} diff --git a/libs/checkpoint-validation/src/tests/utils.ts b/libs/checkpoint-validation/src/tests/utils.ts new file mode 100644 index 00000000..32c8871d --- /dev/null +++ b/libs/checkpoint-validation/src/tests/utils.ts @@ -0,0 +1,33 @@ +import { platform, arch } from "node:os"; + +function isMSeriesMac() { + return platform() === "darwin" && arch() === "arm64"; +} + +function isWindows() { + return platform() === "win32"; +} + +export function isCI() { + // eslint-disable-next-line no-process-env + return (process.env.CI ?? "").toLowerCase() === "true"; +} + +/** + * GitHub Actions doesn't support containers on m-series macOS due to a lack of hypervisor support for nested + * virtualization. + * + * For details, see https://github.com/actions/runner-images/issues/9460#issuecomment-1981203045 + * + * GitHub actions also doesn't support Linux containers on Windows, and may never do so. This is in part due to Docker + * Desktop licensing restrictions, and the complexity of setting up Moby or similar without Docker Desktop. + * Unfortunately, TestContainers doesn't support windows containers, so we can't run the tests on Windows either. + * + * For details, see https://github.com/actions/runner/issues/904 and + * https://java.testcontainers.org/supported_docker_environment/windows/#windows-container-on-windows-wcow + * + * + */ +export function osHasSupportedContainerRuntime() { + return !isWindows() && !isMSeriesMac(); +} diff --git a/libs/checkpoint-validation/tsconfig.json b/libs/checkpoint-validation/tsconfig.json index 78398e4d..60f0e6b9 100644 --- a/libs/checkpoint-validation/tsconfig.json +++ b/libs/checkpoint-validation/tsconfig.json @@ -5,6 +5,7 @@ "rootDir": "./src", "target": "ES2021", "lib": ["ES2021", "ES2022.Object", "DOM"], + "types": ["node", "jest"], "module": "ES2020", "moduleResolution": "NodeNext", "esModuleInterop": true, diff --git a/yarn.lock b/yarn.lock index c211b52f..369c157e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1785,6 +1785,7 @@ __metadata: "@testcontainers/mongodb": ^10.13.2 "@testcontainers/postgresql": ^10.13.2 "@tsconfig/recommended": ^1.0.3 + "@types/jest": ^29.5.13 "@types/uuid": ^10 "@typescript-eslint/eslint-plugin": ^6.12.0 "@typescript-eslint/parser": ^6.12.0 @@ -3494,6 +3495,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.13": + version: 29.5.13 + resolution: "@types/jest@npm:29.5.13" + dependencies: + expect: ^29.0.0 + pretty-format: ^29.0.0 + checksum: 875ac23c2398cdcf22aa56c6ba24560f11d2afda226d4fa23936322dde6202f9fdbd2b91602af51c27ecba223d9fc3c1e33c9df7e47b3bf0e2aefc6baf13ce53 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.12": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -6871,7 +6882,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.7.0": +"expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: @@ -10764,7 +10775,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.7.0": +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: From 2c40e3fdc78eba548d0f2c883979308a672ff23c Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Mon, 21 Oct 2024 15:29:26 +1300 Subject: [PATCH 5/7] style fixes --- libs/checkpoint-validation/README.md | 63 +++--- .../checkpoint-validation/bin/jest.config.cjs | 1 + libs/checkpoint-validation/package.json | 2 +- libs/checkpoint-validation/src/cli.ts | 14 +- .../src/{importUtils.ts => import_utils.ts} | 0 libs/checkpoint-validation/src/index.ts | 6 +- libs/checkpoint-validation/src/runner.ts | 6 +- .../src/spec/{getTuple.ts => get_tuple.ts} | 103 ++++----- libs/checkpoint-validation/src/spec/index.ts | 17 +- libs/checkpoint-validation/src/spec/list.ts | 60 +++--- libs/checkpoint-validation/src/spec/put.ts | 124 +++++------ .../src/spec/{putWrites.ts => put_writes.ts} | 103 ++++----- libs/checkpoint-validation/src/spec/util.ts | 76 ------- libs/checkpoint-validation/src/testUtils.ts | 55 ----- .../src/{spec/data.ts => test_utils.ts} | 196 ++++++++++++++---- .../src/tests/memory.spec.ts | 2 +- .../src/tests/memoryInitializer.ts | 8 - .../src/tests/memory_initializer.ts | 9 + .../src/tests/mongodb.spec.ts | 10 +- ...oInitializer.ts => mongodb_initializer.ts} | 8 +- .../src/tests/postgres.spec.ts | 10 +- ...Initializer.ts => postgres_initializer.ts} | 18 +- .../src/tests/sqlite.spec.ts | 2 +- .../src/tests/sqliteInitializer.ts | 17 -- .../src/tests/sqlite_initializer.ts | 17 ++ libs/checkpoint-validation/src/tests/utils.ts | 6 +- libs/checkpoint-validation/src/types.ts | 67 ++---- 27 files changed, 438 insertions(+), 562 deletions(-) rename libs/checkpoint-validation/src/{importUtils.ts => import_utils.ts} (100%) rename libs/checkpoint-validation/src/spec/{getTuple.ts => get_tuple.ts} (77%) rename libs/checkpoint-validation/src/spec/{putWrites.ts => put_writes.ts} (54%) delete mode 100644 libs/checkpoint-validation/src/spec/util.ts delete mode 100644 libs/checkpoint-validation/src/testUtils.ts rename libs/checkpoint-validation/src/{spec/data.ts => test_utils.ts} (59%) delete mode 100644 libs/checkpoint-validation/src/tests/memoryInitializer.ts create mode 100644 libs/checkpoint-validation/src/tests/memory_initializer.ts rename libs/checkpoint-validation/src/tests/{mongoInitializer.ts => mongodb_initializer.ts} (84%) rename libs/checkpoint-validation/src/tests/{postgresInitializer.ts => postgres_initializer.ts} (75%) delete mode 100644 libs/checkpoint-validation/src/tests/sqliteInitializer.ts create mode 100644 libs/checkpoint-validation/src/tests/sqlite_initializer.ts diff --git a/libs/checkpoint-validation/README.md b/libs/checkpoint-validation/README.md index 0dc841fa..d0e9a8b1 100644 --- a/libs/checkpoint-validation/README.md +++ b/libs/checkpoint-validation/README.md @@ -1,38 +1,38 @@ # @langchain/langgraph-checkpoint-validation -The checkpoint saver validation tool is used to validate that custom checkpoint saver implementations conform to LangGraph's requirements. LangGraph uses [checkpoint savers](https://langchain-ai.github.io/langgraphjs/concepts/persistence/#checkpointer-libraries) for persisting workflow state, providing the ability to "rewind" your workflow to some earlier point in time, and continue execution from there. +The checkpointer validation tool is used to validate that custom checkpointer implementations conform to LangGraph's requirements. LangGraph uses [checkpointers](https://langchain-ai.github.io/langgraphjs/concepts/persistence/#checkpointer-libraries) for persisting workflow state, providing the ability to "rewind" your workflow to some earlier point in time, and continue execution from there. The overall process for using this tool is as follows: -1. Write your custom checkpoint saver implementation. -2. Add a file to your project that defines a [`CheckpointSaverTestInitializer`](./src/types.ts) as its default export. -3. Run the checkpoint saver validation tool to test your checkpoint saver and determine whether it meets LangGraph's requirements. -4. Iterate on your custom checkpoint saver as required, until tests pass. +1. Write your custom checkpointer implementation. +2. Add a file to your project that defines a [`CheckpointerTestInitializer`](./src/types.ts) as its default export. +3. Run the checkpointer validation tool to test your checkpointer and determine whether it meets LangGraph's requirements. +4. Iterate on your custom checkpointer as required, until tests pass. The tool can be executed from the terminal as a CLI, or you can use it as a library to integrate it into your test suite. -## Writing a CheckpointSaverTestInitializer +## Writing a CheckpointerTestInitializer -The `CheckpointSaverTestInitializer` interface ([example](./src/tests/postgresInitializer.ts)) is used by the test harness to create instances of your custom checkpoint saver, and any infrastructure that it requires for testing purposes. +The `CheckpointerTestInitializer` interface ([example](./src/tests/postgres_initializer.ts)) is used by the test harness to create instances of your custom checkpointer, and any infrastructure that it requires for testing purposes. -If you intend to execute the tool via the CLI, your `CheckpointSaverTestInitializer` **must** be the default export of the module in which it is defined. +If you intend to execute the tool via the CLI, your `CheckpointerTestInitializer` **must** be the default export of the module in which it is defined. -**Synchronous vs Asynchronous initializer functions**: You may return promises from any functions defined in your `CheckpointSaverTestInitializer` according to your needs and the test harness will behave accordingly. +**Synchronous vs Asynchronous initializer functions**: You may return promises from any functions defined in your `CheckpointerTestInitializer` according to your needs and the test harness will behave accordingly. -**IMPORTANT**: You must take care to write your `CheckpointSaverTestInitializer` such that instances of your custom checkpoint saver are isolated from one another with respect to persisted state, or else some tests (particularly the ones that exercise the `list` method) will fail. That is, state written by one instance of your checkpoint saver MUST NOT be readable by another instance of your checkpoint saver. That said, there will only ever be one instance of your checkpoint saver live at any given time, so **you may use shared storage, provided it is cleared when your checkpoint saver is created or destroyed.** The structure of the `CheckpointSaverTestInitializer` interface should make this relatively easy to achieve, per the sections below. +**IMPORTANT**: You must take care to write your `CheckpointerTestInitializer` such that instances of your custom checkpointer are isolated from one another with respect to persisted state, or else some tests (particularly the ones that exercise the `list` method) will fail. That is, state written by one instance of your checkpointer MUST NOT be readable by another instance of your checkpointer. That said, there will only ever be one instance of your checkpointer live at any given time, so **you may use shared storage, provided it is cleared when your checkpointer is created or destroyed.** The structure of the `CheckpointerTestInitializer` interface should make this relatively easy to achieve, per the sections below. -### (Required) `saverName`: Define a name for your checkpoint saver +### (Required) `checkpointerName`: Define a name for your checkpointer -`CheckpointSaverTestInitializer` requires you to define a `saverName` field (of type `string`) for use in the test output. +`CheckpointerTestInitializer` requires you to define a `checkpointerName` field (of type `string`) for use in the test output. ### `beforeAll`: Set up required infrastructure -If your checkpoint saver requires some external infrastructure to be provisioned, you may wish to provision this via the **optional** `beforeAll` function. This function executes exactly once, at the very start of the testing lifecycle. If defined, it is the first function that will be called from your `CheckpointSaverTestInitializer`. +If your checkpointer requires some external infrastructure to be provisioned, you may wish to provision this via the **optional** `beforeAll` function. This function executes exactly once, at the very start of the testing lifecycle. If defined, it is the first function that will be called from your `CheckpointerTestInitializer`. -**Timeout duration**: If your `beforeAll` function may take longer than 10 seconds to execute, you can assign a custom timeout duration (as milliseconds) to the optional `beforeAllTimeout` field of your `CheckpointSaverTestInitializer`. +**Timeout duration**: If your `beforeAll` function may take longer than 10 seconds to execute, you can assign a custom timeout duration (as milliseconds) to the optional `beforeAllTimeout` field of your `CheckpointerTestInitializer`. -**State isolation note**: Depending on the cost/performance/requirements of your checkpoint saver infrastructure, it **may** make more sense for you to provision it during the `createSaver` step, so you can provide each checkpoint saver instance with its own isolated storage backend. However as mentioned above, you may also provision a single shared storage backend, provided you clear any stored data during the `createSaver` or `destroySaver` step. +**State isolation note**: Depending on the cost/performance/requirements of your checkpointer infrastructure, it **may** make more sense for you to provision it during the `createCheckpointer` step, so you can provide each checkpointer instance with its own isolated storage backend. However as mentioned above, you may also provision a single shared storage backend, provided you clear any stored data during the `createCheckpointer` or `destroyCheckpointer` step. ### `afterAll`: Tear down required infrastructure @@ -40,31 +40,25 @@ If you set up infrastructure during the `beforeAll` step, you may need to tear i **IMPORTANT**: If you kill the test runner early this function may not be called. To avoid manual clean-up, give preference to test infrastructure management tools like [TestContainers](https://testcontainers.com/guides/getting-started-with-testcontainers-for-nodejs/), as these tools are designed to detect when this happens and clean up after themselves once the controlling process dies. -### (Required) `createSaver`: Construct your checkpoint saver +### (Required) `createCheckpointer`: Construct your checkpointer -`CheckpointSaverTestInitializer` requires you to define a `createSaver(config: RunnableConfig)` function that returns an instance of your custom checkpoint saver. The `config` argument is provided to this function in case it is necessary for the construction of your custom checkpoint saver. +`CheckpointerTestInitializer` requires you to define a `createCheckpointer()` function that returns an instance of your custom checkpointer. -**State isolation note:** If you're provisioning storage during this step, make sure that it is "fresh" storage for each instance of your checkpoint saver. Otherwise if you are using a shared storage setup, be sure to clear it either in this function, or in the `destroySaver` function (described in the section below). +**State isolation note:** If you're provisioning storage during this step, make sure that it is "fresh" storage for each instance of your checkpointer. Otherwise if you are using a shared storage setup, be sure to clear it either in this function, or in the `destroyCheckpointer` function (described in the section below). -### `destroySaver`: Destroy your checkpoint saver +### `destroyCheckpointer`: Destroy your checkpointer -If your custom checkpoint saver requires an explicit teardown step (for example, to clean up database connections), you can define this in the **optional** `destroySaver(saver: CheckpointSaverT, config: RunnableConfig)` function. +If your custom checkpointer requires an explicit teardown step (for example, to clean up database connections), you can define this in the **optional** `destroyCheckpointer(checkpointer: CheckpointerT)` function. -**State isolation note:** If you are using a shared storage setup, be sure to clear it either in this function, or in the `createSaver` function (described in the section above). - -### `configure`: Customize the `RunnableConfig` object that is passed during testing - -If you need to customize the config argument that is passed to your custom checkpoint saver during testing, you can implement the **optional** `configure(config: RunnableConfig)` function. This function may inspect the default configuration (passed as the `config` argument) and must return an instance of `RunnableConfig`. The `RunnableConfig` returned by this function will be merged with the default config and passed to your checkpoint saver during testing. - -Some custom checkpoint savers may require additional custom configuration data to be present in the `configurable` object of the `RunnableConfig` in order to work correctly. For example, custom checkpoint savers that work as part of a multi-tenant application may require authentication details to be passed along in order to enforce data isolation requirements. +**State isolation note:** If you are using a shared storage setup, be sure to clear it either in this function, or in the `createCheckpointer` function (described in the section above). ## CLI usage -You may use this tool's CLI either via `npx`, `yarn dlx`, or by installing globally and executing it via the `validate-saver` command. +You may use this tool's CLI either via `npx`, `yarn dlx`, or by installing globally and executing it via the `validate-checkpointer` command. -The only required argument to the tool is the import path for your `CheckpointSaverTestInitializer`. Relative paths must begin with a leading `./` (or `.\`, for Windows), otherwise the path will be interpreted as a module name rather than a relative path. +The only required argument to the tool is the import path for your `CheckpointerTestInitializer`. Relative paths must begin with a leading `./` (or `.\`, for Windows), otherwise the path will be interpreted as a module name rather than a relative path. -You may optionally pass one or more test filters as positional arguments after the import path argument (separated by spaces). Valid values are `getTuple`, `list`, `put`, and `putWrites`. If present, only the test suites specified in the filter list will be executed. This is useful for working through smaller sets of test failures as you're validating your checkpoint saver. +You may optionally pass one or more test filters as positional arguments after the import path argument (separated by spaces). Valid values are `getTuple`, `list`, `put`, and `putWrites`. If present, only the test suites specified in the filter list will be executed. This is useful for working through smaller sets of test failures as you're validating your checkpointer. TypeScript imports **are** supported, so you may pass a path directly to your TypeScript source file. @@ -73,14 +67,13 @@ TypeScript imports **are** supported, so you may pass a path directly to your Ty NPX: ```bash -cd MySaverProject -npx @langchain/langgraph-checkpoint-validation ./src/mySaverInitializer.ts +npx @langchain/langgraph-checkpoint-validation ./src/my_initializer.ts ``` Yarn: ```bash -yarn dlx @langchain/langgraph-checkpoint-validation ./src/mySaverInitializer.ts +yarn dlx @langchain/langgraph-checkpoint-validation ./src/my_initializer.ts ``` ### Global install @@ -89,7 +82,7 @@ NPM: ```bash npm install -g @langchain/langgraph-checkpoint-validation -validate-saver ./src/mySaverInitializer.ts +validate-checkpointer ./src/my_initializer.ts ``` ## Usage in existing Jest test suite @@ -99,5 +92,5 @@ If you wish to integrate this tooling into your existing Jest test suite, you im ```ts import { validate } from "@langchain/langgraph-validation"; -validate(MyCheckpointSaverInitializer); +validate(MyCheckpointerInitializer); ``` diff --git a/libs/checkpoint-validation/bin/jest.config.cjs b/libs/checkpoint-validation/bin/jest.config.cjs index a99445d3..98e95f92 100644 --- a/libs/checkpoint-validation/bin/jest.config.cjs +++ b/libs/checkpoint-validation/bin/jest.config.cjs @@ -2,6 +2,7 @@ // For the Jest config for the tests in this project, see the `jest.config.cjs` in the root of the package workspace. const path = require("path"); +/** @type {import('ts-jest').JestConfigWithTsJest} */ const config = { preset: "ts-jest/presets/default-esm", rootDir: path.resolve(__dirname, "..", "dist"), diff --git a/libs/checkpoint-validation/package.json b/libs/checkpoint-validation/package.json index d87e01bd..2d20e22e 100644 --- a/libs/checkpoint-validation/package.json +++ b/libs/checkpoint-validation/package.json @@ -95,7 +95,7 @@ "./package.json": "./package.json" }, "bin": { - "validate-saver": "./bin/cli.js" + "validate-checkpointer": "./bin/cli.js" }, "files": [ "dist/", diff --git a/libs/checkpoint-validation/src/cli.ts b/libs/checkpoint-validation/src/cli.ts index b65f1760..299d669b 100644 --- a/libs/checkpoint-validation/src/cli.ts +++ b/libs/checkpoint-validation/src/cli.ts @@ -4,12 +4,12 @@ import { runCLI } from "@jest/core"; import yargs, { ArgumentsCamelCase } from "yargs"; import { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; import { - CheckpointSaverTestInitializer, - checkpointSaverTestInitializerSchema, + CheckpointerTestInitializer, + checkpointerTestInitializerSchema, GlobalThis, TestTypeFilter, } from "./types.js"; -import { dynamicImport, resolveImportPath } from "./importUtils.js"; +import { dynamicImport, resolveImportPath } from "./import_utils.js"; // make it so we can import/require .ts files import "@swc-node/register/esm-register"; @@ -21,14 +21,14 @@ export async function main() { await builder .command( "* [filters..]", - "Validate a checkpoint saver", + "Validate a checkpointer", { builder: (args) => { return args .positional("initializerImportPath", { type: "string", describe: - "The import path of the CheckpointSaverTestInitializer for the checkpoint saver (passed to 'import()'). " + + "The import path of the CheckpointSaverTestInitializer for the checkpointer (passed to 'import()'). " + "Must be the default export.", demandOption: true, }) @@ -70,9 +70,9 @@ export async function main() { process.exit(1); } - let initializer: CheckpointSaverTestInitializer; + let initializer: CheckpointerTestInitializer; try { - initializer = checkpointSaverTestInitializerSchema.parse( + initializer = checkpointerTestInitializerSchema.parse( (initializerExport as { default?: unknown }).default ?? initializerExport ); diff --git a/libs/checkpoint-validation/src/importUtils.ts b/libs/checkpoint-validation/src/import_utils.ts similarity index 100% rename from libs/checkpoint-validation/src/importUtils.ts rename to libs/checkpoint-validation/src/import_utils.ts diff --git a/libs/checkpoint-validation/src/index.ts b/libs/checkpoint-validation/src/index.ts index 0b5729ef..538cf8a9 100644 --- a/libs/checkpoint-validation/src/index.ts +++ b/libs/checkpoint-validation/src/index.ts @@ -1,8 +1,8 @@ import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; import { specTest } from "./spec/index.js"; -import type { CheckpointSaverTestInitializer } from "./types.js"; +import type { CheckpointerTestInitializer } from "./types.js"; -export { CheckpointSaverTestInitializer } from "./types.js"; +export { CheckpointerTestInitializer as CheckpointSaverTestInitializer } from "./types.js"; export { getTupleTests, listTests, @@ -12,7 +12,7 @@ export { } from "./spec/index.js"; export function validate( - initializer: CheckpointSaverTestInitializer + initializer: CheckpointerTestInitializer ) { specTest(initializer); } diff --git a/libs/checkpoint-validation/src/runner.ts b/libs/checkpoint-validation/src/runner.ts index ad5a022c..908e18e7 100644 --- a/libs/checkpoint-validation/src/runner.ts +++ b/libs/checkpoint-validation/src/runner.ts @@ -1,6 +1,6 @@ -// This file is used by the CLI to dynamically execute tests against the user-provided checkpoint saver. It's written -// as a Jest test file because unfortunately there's no good way to just pass Jest a test definition function and tell -// it to run it. +// This file is used by the CLI to dynamically execute tests against the user-provided checkpointer. It's written as a +// Jest test file because unfortunately there's no good way to just pass Jest a test definition function and tell it to +// run it. import { specTest } from "./spec/index.js"; import type { GlobalThis } from "./types.js"; diff --git a/libs/checkpoint-validation/src/spec/getTuple.ts b/libs/checkpoint-validation/src/spec/get_tuple.ts similarity index 77% rename from libs/checkpoint-validation/src/spec/getTuple.ts rename to libs/checkpoint-validation/src/spec/get_tuple.ts index a31c1647..be06f54b 100644 --- a/libs/checkpoint-validation/src/spec/getTuple.ts +++ b/libs/checkpoint-validation/src/spec/get_tuple.ts @@ -5,30 +5,23 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; -import { CheckpointSaverTestInitializer } from "../types.js"; -import { parentAndChildCheckpointTuplesWithWrites } from "./data.js"; -import { putTuples } from "./util.js"; +import { CheckpointerTestInitializer } from "../types.js"; +import { + parentAndChildCheckpointTuplesWithWrites, + putTuples, +} from "../test_utils.js"; export function getTupleTests( - initializer: CheckpointSaverTestInitializer + initializer: CheckpointerTestInitializer ) { - describe(`${initializer.saverName}#getTuple`, () => { - let saver: T; - let initializerConfig: RunnableConfig; + describe(`${initializer.checkpointerName}#getTuple`, () => { + let checkpointer: T; beforeAll(async () => { - const baseConfig = { - configurable: {}, - }; - initializerConfig = mergeConfigs( - baseConfig, - await initializer.configure?.(baseConfig) - ); - saver = await initializer.createSaver(initializerConfig); + checkpointer = await initializer.createCheckpointer(); }); afterAll(async () => { - await initializer.destroySaver?.(saver, initializerConfig); + await initializer.destroyCheckpointer?.(checkpointer); }); describe.each(["root", "child"])("namespace: %s", (namespace) => { @@ -50,10 +43,6 @@ export function getTupleTests( parentCheckpointId = uuid6(-3); childCheckpointId = uuid6(-3); - const config = mergeConfigs(initializerConfig, { - configurable: { thread_id, checkpoint_ns }, - }); - const writesToParent = [ { taskId: "pending_sends_task", @@ -70,7 +59,7 @@ export function getTupleTests( ({ parent: generatedParentTuple, child: generatedChildTuple } = parentAndChildCheckpointTuplesWithWrites({ - config, + thread_id, parentCheckpointId, childCheckpointId, checkpoint_ns, @@ -81,31 +70,25 @@ export function getTupleTests( writesToChild, })); - const storedTuples = putTuples( - saver, - [ - { - tuple: generatedParentTuple, - writes: writesToParent, - newVersions: { animals: 1 }, - }, - { - tuple: generatedChildTuple, - writes: writesToChild, - newVersions: { animals: 2 }, - }, - ], - config - ); + const storedTuples = putTuples(checkpointer, [ + { + tuple: generatedParentTuple, + writes: writesToParent, + newVersions: { animals: 1 }, + }, + { + tuple: generatedChildTuple, + writes: writesToChild, + newVersions: { animals: 2 }, + }, + ]); parentTuple = (await storedTuples.next()).value; childTuple = (await storedTuples.next()).value; - latestTuple = await saver.getTuple( - mergeConfigs(config, { - configurable: { checkpoint_ns, checkpoint_id: undefined }, - }) - ); + latestTuple = await checkpointer.getTuple({ + configurable: { thread_id, checkpoint_ns }, + }); }); describe("success cases", () => { @@ -236,33 +219,29 @@ export function getTupleTests( describe("failure cases", () => { it("should return undefined if the checkpoint_id is not found", async () => { - const configWithInvalidCheckpointId = mergeConfigs( - initializerConfig, - { - configurable: { - thread_id: uuid6(-3), - checkpoint_ns, - checkpoint_id: uuid6(-3), - }, - } - ); - const checkpointTuple = await saver.getTuple( + const configWithInvalidCheckpointId = { + configurable: { + thread_id: uuid6(-3), + checkpoint_ns, + checkpoint_id: uuid6(-3), + }, + }; + const checkpointTuple = await checkpointer.getTuple( configWithInvalidCheckpointId ); expect(checkpointTuple).toBeUndefined(); }); it("should return undefined if the thread_id is undefined", async () => { - const missingThreadIdConfig: RunnableConfig = { - ...initializerConfig, - configurable: Object.fromEntries( - Object.entries(initializerConfig.configurable ?? {}).filter( - ([key]) => key !== "thread_id" - ) - ), + const missingThreadIdConfig = { + configurable: { + checkpoint_ns, + }, }; - expect(await saver.getTuple(missingThreadIdConfig)).toBeUndefined(); + expect( + await checkpointer.getTuple(missingThreadIdConfig) + ).toBeUndefined(); }); }); }); diff --git a/libs/checkpoint-validation/src/spec/index.ts b/libs/checkpoint-validation/src/spec/index.ts index ccce443d..646408f3 100644 --- a/libs/checkpoint-validation/src/spec/index.ts +++ b/libs/checkpoint-validation/src/spec/index.ts @@ -1,9 +1,9 @@ import { type BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; -import { CheckpointSaverTestInitializer, TestTypeFilter } from "../types.js"; +import { CheckpointerTestInitializer, TestTypeFilter } from "../types.js"; import { putTests } from "./put.js"; -import { putWritesTests } from "./putWrites.js"; -import { getTupleTests } from "./getTuple.js"; +import { putWritesTests } from "./put_writes.js"; +import { getTupleTests } from "./get_tuple.js"; import { listTests } from "./list.js"; const testTypeMap = { @@ -14,12 +14,15 @@ const testTypeMap = { }; /** - * Kicks off a test suite to validate that the provided checkpoint saver conforms to the specification for classes that extend @see BaseCheckpointSaver. - * @param initializer A @see CheckpointSaverTestInitializer, providing methods for setup and cleanup of the test, and for creation of the saver instance being tested. + * Kicks off a test suite to validate that the provided checkpointer conforms to the specification for classes that + * extend @see BaseCheckpointSaver. + * + * @param initializer A @see CheckpointerTestInitializer, providing methods for setup and cleanup of the test, + * and for creation of the checkpointer instance being tested. * @param filters If specified, only the test suites in this list will be executed. */ export function specTest( - initializer: CheckpointSaverTestInitializer, + initializer: CheckpointerTestInitializer, filters?: TestTypeFilter[] ) { beforeAll(async () => { @@ -30,7 +33,7 @@ export function specTest( await initializer.afterAll?.(); }); - describe(initializer.saverName, () => { + describe(initializer.checkpointerName, () => { if (!filters || filters.length === 0) { putTests(initializer); putWritesTests(initializer); diff --git a/libs/checkpoint-validation/src/spec/list.ts b/libs/checkpoint-validation/src/spec/list.ts index a38992d6..d9f63097 100644 --- a/libs/checkpoint-validation/src/spec/list.ts +++ b/libs/checkpoint-validation/src/spec/list.ts @@ -4,10 +4,14 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; -import { CheckpointSaverTestInitializer } from "../types.js"; -import { generateTuplePairs } from "./data.js"; -import { putTuples, toArray, toMap } from "./util.js"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointerTestInitializer } from "../types.js"; +import { + generateTuplePairs, + putTuples, + toArray, + toMap, +} from "../test_utils.js"; interface ListTestCase { description: string; @@ -20,15 +24,15 @@ interface ListTestCase { } /** - * Exercises the `list` method of the CheckpointSaver. + * Exercises the `list` method of the checkpointer. * - * IMPORTANT NOTE: This test relies on the `getTuple` method of the saver functioning properly. If you have failures in - * `getTuple`, you should fix them before addressing the failures in this test. + * IMPORTANT NOTE: This test relies on the `getTuple` method of the checkpointer functioning properly. If you have + * failures in `getTuple`, you should fix them before addressing the failures in this test. * - * @param initializer the initializer for the CheckpointSaver + * @param initializer the initializer for the checkpointer */ export function listTests( - initializer: CheckpointSaverTestInitializer + initializer: CheckpointerTestInitializer ) { const invalidThreadId = uuid6(-3); @@ -38,7 +42,7 @@ export function listTests( tuple: CheckpointTuple; writes: { writes: PendingWrite[]; taskId: string }[]; newVersions: Record; - }[] = Array.from(generateTuplePairs({ configurable: {} }, 2, namespaces)); + }[] = Array.from(generateTuplePairs(2, namespaces)); const argumentRanges = setupArgumentRanges( generatedTuples.map(({ tuple }) => tuple), @@ -52,34 +56,21 @@ export function listTests( ) ); - describe(`${initializer.saverName}#list`, () => { - let saver: T; - let initializerConfig: RunnableConfig; - + describe(`${initializer.checkpointerName}#list`, () => { + let checkpointer: T; const storedTuples: Map = new Map(); beforeAll(async () => { - const baseConfig = { - configurable: {}, - }; - initializerConfig = mergeConfigs( - baseConfig, - await initializer.configure?.(baseConfig) - ); - saver = await initializer.createSaver(initializerConfig); + checkpointer = await initializer.createCheckpointer(); // put all the tuples - for await (const tuple of putTuples( - saver, - generatedTuples, - initializerConfig - )) { + for await (const tuple of putTuples(checkpointer, generatedTuples)) { storedTuples.set(tuple.checkpoint.id, tuple); } }); afterAll(async () => { - await initializer.destroySaver?.(saver, initializerConfig); + await initializer.destroyCheckpointer?.(checkpointer); }); // can't reference argumentCombinations directly here because it isn't built at the time this is evaluated. @@ -95,7 +86,7 @@ export function listTests( expectedCheckpointIds, }: ListTestCase) => { const actualTuplesArray = await toArray( - saver.list( + checkpointer.list( { configurable: { thread_id, checkpoint_ns } }, { limit, before, filter } ) @@ -128,14 +119,14 @@ export function listTests( // TODO: MongoDBSaver and SQLiteSaver don't return pendingWrites on list, so we need to special case them // see: https://github.com/langchain-ai/langgraphjs/issues/589 // see: https://github.com/langchain-ai/langgraphjs/issues/590 - const saverIncludesPendingWritesOnList = - initializer.saverName !== + const checkpointerIncludesPendingWritesOnList = + initializer.checkpointerName !== "@langchain/langgraph-checkpoint-mongodb" && - initializer.saverName !== + initializer.checkpointerName !== "@langchain/langgraph-checkpoint-sqlite"; const expectedTuple = expectedTuplesMap.get(key); - if (!saverIncludesPendingWritesOnList) { + if (!checkpointerIncludesPendingWritesOnList) { delete expectedTuple?.pendingWrites; } @@ -180,7 +171,8 @@ export function listTests( filter: // TODO: MongoDBSaver support for filter is broken and can't be fixed without a breaking change // see: https://github.com/langchain-ai/langgraphjs/issues/581 - initializer.saverName === "@langchain/langgraph-checkpoint-mongodb" + initializer.checkpointerName === + "@langchain/langgraph-checkpoint-mongodb" ? [undefined] : [undefined, {}, { source: "input" }, { source: "loop" }], }; diff --git a/libs/checkpoint-validation/src/spec/put.ts b/libs/checkpoint-validation/src/spec/put.ts index d2c16bfd..5ecc343d 100644 --- a/libs/checkpoint-validation/src/spec/put.ts +++ b/libs/checkpoint-validation/src/spec/put.ts @@ -5,45 +5,34 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; -import { CheckpointSaverTestInitializer } from "../types.js"; -import { initialCheckpointTuple } from "./data.js"; -import { putTuples } from "./util.js"; -import { it_skipForSomeModules } from "../testUtils.js"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointerTestInitializer } from "../types.js"; +import { + initialCheckpointTuple, + it_skipForSomeModules, + putTuples, +} from "../test_utils.js"; export function putTests( - initializer: CheckpointSaverTestInitializer + initializer: CheckpointerTestInitializer ) { - describe(`${initializer.saverName}#put`, () => { - let saver: T; - let initializerConfig: RunnableConfig; + describe(`${initializer.checkpointerName}#put`, () => { + let checkpointer: T; let thread_id: string; let checkpoint_id1: string; beforeEach(async () => { thread_id = uuid6(-3); checkpoint_id1 = uuid6(-3); - - const baseConfig = { - configurable: { - thread_id, - }, - }; - - initializerConfig = mergeConfigs( - baseConfig, - await initializer.configure?.(baseConfig) - ); - saver = await initializer.createSaver(initializerConfig); + checkpointer = await initializer.createCheckpointer(); }); afterEach(async () => { - await initializer.destroySaver?.(saver, initializerConfig); + await initializer.destroyCheckpointer?.(checkpointer); }); describe.each(["root", "child"])("namespace: %s", (namespace) => { const checkpoint_ns = namespace === "root" ? "" : namespace; - let configArgument: RunnableConfig; let checkpointStoredWithoutIdInConfig: Checkpoint; let metadataStoredWithoutIdInConfig: CheckpointMetadata | undefined; @@ -56,51 +45,49 @@ export function putTests( checkpoint: checkpointStoredWithoutIdInConfig, metadata: metadataStoredWithoutIdInConfig, } = initialCheckpointTuple({ - config: initializerConfig, + thread_id, checkpoint_id: checkpoint_id1, checkpoint_ns, })); - configArgument = mergeConfigs(initializerConfig, { - configurable: { checkpoint_ns }, - }); - // validate assumptions - the test checkpoints must not already exist - const existingCheckpoint1 = await saver.get( - mergeConfigs(configArgument, { - configurable: { - checkpoint_id: checkpoint_id1, - }, - }) - ); + const existingCheckpoint1 = await checkpointer.get({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpoint_id1, + }, + }); - const existingCheckpoint2 = await saver.get( - mergeConfigs(configArgument, { - configurable: { - checkpoint_id: checkpoint_id1, - }, - }) - ); + const existingCheckpoint2 = await checkpointer.get({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpoint_id1, + }, + }); expect(existingCheckpoint1).toBeUndefined(); expect(existingCheckpoint2).toBeUndefined(); // set up // call put without the `checkpoint_id` in the config - basicPutReturnedConfig = await saver.put( - mergeConfigs(configArgument, { + basicPutReturnedConfig = await checkpointer.put( + { configurable: { + thread_id, + checkpoint_ns, // adding this to ensure that additional fields are not stored in the checkpoint tuple canary: "tweet", }, - }), + }, checkpointStoredWithoutIdInConfig, metadataStoredWithoutIdInConfig!, {} ); - basicPutRoundTripTuple = await saver.getTuple( - mergeConfigs(configArgument, basicPutReturnedConfig) + basicPutRoundTripTuple = await checkpointer.getTuple( + basicPutReturnedConfig ); }); @@ -157,14 +144,11 @@ export function putTests( describe("failure cases", () => { it("should fail if config.configurable is missing", async () => { - const missingConfigurableConfig: RunnableConfig = { - ...configArgument, - configurable: undefined, - }; + const missingConfigurableConfig: RunnableConfig = {}; await expect( async () => - await saver.put( + await checkpointer.put( missingConfigurableConfig, checkpointStoredWithoutIdInConfig, metadataStoredWithoutIdInConfig!, @@ -175,17 +159,14 @@ export function putTests( it("should fail if the thread_id is missing", async () => { const missingThreadIdConfig: RunnableConfig = { - ...configArgument, - configurable: Object.fromEntries( - Object.entries(configArgument.configurable ?? {}).filter( - ([key]) => key !== "thread_id" - ) - ), + configurable: { + checkpoint_ns, + }, }; await expect( async () => - await saver.put( + await checkpointer.put( missingThreadIdConfig, checkpointStoredWithoutIdInConfig, metadataStoredWithoutIdInConfig!, @@ -196,7 +177,7 @@ export function putTests( }); }); - it_skipForSomeModules(initializer.saverName, { + it_skipForSomeModules(initializer.checkpointerName, { // TODO: MemorySaver throws instead of defaulting to empty namespace // see: https://github.com/langchain-ai/langgraphjs/issues/591 MemorySaver: "TODO: throws instead of defaulting to empty namespace", @@ -208,21 +189,16 @@ export function putTests( "should default to empty namespace if the checkpoint namespace is missing from config.configurable", async () => { const missingNamespaceConfig: RunnableConfig = { - ...initializerConfig, - configurable: Object.fromEntries( - Object.entries(initializerConfig.configurable ?? {}).filter( - ([key]) => key !== "checkpoint_ns" - ) - ), + configurable: { thread_id }, }; const { checkpoint, metadata } = initialCheckpointTuple({ - config: missingNamespaceConfig, + thread_id, checkpoint_id: checkpoint_id1, checkpoint_ns: "", }); - const returnedConfig = await saver.put( + const returnedConfig = await checkpointer.put( missingNamespaceConfig, checkpoint, metadata!, @@ -236,8 +212,8 @@ export function putTests( } ); - it_skipForSomeModules(initializer.saverName, { - // TODO: all of the savers below store full channel_values on every put, rather than storing deltas + it_skipForSomeModules(initializer.checkpointerName, { + // TODO: all of the checkpointers below store full channel_values on every put, rather than storing deltas // see: https://github.com/langchain-ai/langgraphjs/issues/593 // see: https://github.com/langchain-ai/langgraphjs/issues/594 // see: https://github.com/langchain-ai/langgraphjs/issues/595 @@ -256,7 +232,7 @@ export function putTests( const generatedPuts = newVersions.map((newVersions) => ({ tuple: initialCheckpointTuple({ - config: initializerConfig, + thread_id, checkpoint_id: uuid6(-3), checkpoint_ns: "", channel_values: { @@ -269,11 +245,7 @@ export function putTests( })); const storedTuples: CheckpointTuple[] = []; - for await (const tuple of putTuples( - saver, - generatedPuts, - initializerConfig - )) { + for await (const tuple of putTuples(checkpointer, generatedPuts)) { storedTuples.push(tuple); } diff --git a/libs/checkpoint-validation/src/spec/putWrites.ts b/libs/checkpoint-validation/src/spec/put_writes.ts similarity index 54% rename from libs/checkpoint-validation/src/spec/putWrites.ts rename to libs/checkpoint-validation/src/spec/put_writes.ts index 43f179d2..f6ed22e9 100644 --- a/libs/checkpoint-validation/src/spec/putWrites.ts +++ b/libs/checkpoint-validation/src/spec/put_writes.ts @@ -5,16 +5,15 @@ import { uuid6, type BaseCheckpointSaver, } from "@langchain/langgraph-checkpoint"; -import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; -import { CheckpointSaverTestInitializer } from "../types.js"; -import { initialCheckpointTuple } from "./data.js"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointerTestInitializer } from "../types.js"; +import { initialCheckpointTuple } from "../test_utils.js"; export function putWritesTests( - initializer: CheckpointSaverTestInitializer + initializer: CheckpointerTestInitializer ) { - describe(`${initializer.saverName}#putWrites`, () => { - let saver: T; - let initializerConfig: RunnableConfig; + describe(`${initializer.checkpointerName}#putWrites`, () => { + let checkpointer: T; let thread_id: string; let checkpoint_id: string; @@ -22,25 +21,15 @@ export function putWritesTests( thread_id = uuid6(-3); checkpoint_id = uuid6(-3); - const baseConfig = { - configurable: { - thread_id, - }, - }; - initializerConfig = mergeConfigs( - baseConfig, - await initializer.configure?.(baseConfig) - ); - saver = await initializer.createSaver(initializerConfig); + checkpointer = await initializer.createCheckpointer(); }); afterEach(async () => { - await initializer.destroySaver?.(saver, initializerConfig); + await initializer.destroyCheckpointer?.(checkpointer); }); describe.each(["root", "child"])("namespace: %s", (namespace) => { const checkpoint_ns = namespace === "root" ? "" : namespace; - let configArgument: RunnableConfig; let checkpoint: Checkpoint; let metadata: CheckpointMetadata | undefined; @@ -50,52 +39,52 @@ export function putWritesTests( beforeEach(async () => { ({ checkpoint, metadata } = initialCheckpointTuple({ - config: initializerConfig, - checkpoint_id, + thread_id, checkpoint_ns, + checkpoint_id, })); - configArgument = mergeConfigs(initializerConfig, { - configurable: { checkpoint_ns }, + // ensure the test checkpoint does not already exist + const existingCheckpoint = await checkpointer.get({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id, + }, }); + expect(existingCheckpoint).toBeUndefined(); // our test checkpoint should not exist yet - // ensure the test checkpoint does not already exist - const existingCheckpoint = await saver.get( - mergeConfigs(configArgument, { + returnedConfig = await checkpointer.put( + { configurable: { - checkpoint_id, + thread_id, + checkpoint_ns, }, - }) - ); - expect(existingCheckpoint).toBeUndefined(); // our test checkpoint should not exist yet - - returnedConfig = await saver.put( - configArgument, + }, checkpoint, metadata!, {} /* not sure what to do about newVersions, as it's unused */ ); - await saver.putWrites( - mergeConfigs(configArgument, returnedConfig), + await checkpointer.putWrites( + returnedConfig, [["animals", "dog"]], "pet_task" ); - savedCheckpointTuple = await saver.getTuple( - mergeConfigs(configArgument, returnedConfig) - ); + savedCheckpointTuple = await checkpointer.getTuple(returnedConfig); // fail here if `put` or `getTuple` is broken so we don't get a bunch of noise from the actual test cases below expect(savedCheckpointTuple).not.toBeUndefined(); expect(savedCheckpointTuple?.checkpoint).toEqual(checkpoint); expect(savedCheckpointTuple?.metadata).toEqual(metadata); - expect(savedCheckpointTuple?.config).toEqual( - expect.objectContaining( - // allow the saver to add additional fields to the config - mergeConfigs(configArgument, { configurable: { checkpoint_id } }) - ) - ); + expect(savedCheckpointTuple?.config).toEqual({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id, + }, + }); }); it("should store writes to the checkpoint", async () => { @@ -107,18 +96,16 @@ export function putWritesTests( describe("failure cases", () => { it("should fail if the thread_id is missing", async () => { - const missingThreadIdConfig: RunnableConfig = { - ...configArgument, - configurable: Object.fromEntries( - Object.entries(configArgument.configurable ?? {}).filter( - ([key]) => key !== "thread_id" - ) - ), + const missingThreadIdConfig = { + configurable: { + checkpoint_ns, + checkpoint_id, + }, }; await expect( async () => - await saver.putWrites( + await checkpointer.putWrites( missingThreadIdConfig, [["animals", "dog"]], "pet_task" @@ -128,17 +115,15 @@ export function putWritesTests( it("should fail if the checkpoint_id is missing", async () => { const missingCheckpointIdConfig: RunnableConfig = { - ...configArgument, - configurable: Object.fromEntries( - Object.entries(configArgument.configurable ?? {}).filter( - ([key]) => key !== "checkpoint_id" - ) - ), + configurable: { + thread_id, + checkpoint_ns, + }, }; await expect( async () => - await saver.putWrites( + await checkpointer.putWrites( missingCheckpointIdConfig, [["animals", "dog"]], "pet_task" diff --git a/libs/checkpoint-validation/src/spec/util.ts b/libs/checkpoint-validation/src/spec/util.ts deleted file mode 100644 index 36f0dab7..00000000 --- a/libs/checkpoint-validation/src/spec/util.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; -import { - BaseCheckpointSaver, - CheckpointTuple, - PendingWrite, -} from "@langchain/langgraph-checkpoint"; - -export async function* putTuples( - saver: BaseCheckpointSaver, - generatedTuples: { - tuple: CheckpointTuple; - writes: { writes: PendingWrite[]; taskId: string }[]; - newVersions: Record; - }[], - initializerConfig: RunnableConfig -): AsyncGenerator { - for (const generated of generatedTuples) { - const { thread_id, checkpoint_ns } = generated.tuple.config - .configurable as { thread_id: string; checkpoint_ns: string }; - - const checkpoint_id = generated.tuple.parentConfig?.configurable - ?.checkpoint_id as string | undefined; - - const config = mergeConfigs(initializerConfig, { - configurable: { - thread_id, - checkpoint_ns, - checkpoint_id, - }, - }); - - const existingTuple = await saver.getTuple( - mergeConfigs(initializerConfig, generated.tuple.config) - ); - - expect(existingTuple).toBeUndefined(); - - const newConfig = await saver.put( - config, - generated.tuple.checkpoint, - generated.tuple.metadata!, - generated.newVersions - ); - - for (const write of generated.writes) { - await saver.putWrites(newConfig, write.writes, write.taskId); - } - - const expectedTuple = await saver.getTuple(newConfig); - - expect(expectedTuple).not.toBeUndefined(); - - if (expectedTuple) { - yield expectedTuple; - } - } -} - -export async function toArray( - generator: AsyncGenerator -): Promise { - const result = []; - for await (const item of generator) { - result.push(item); - } - return result; -} - -export function toMap(tuples: CheckpointTuple[]): Map { - const result = new Map(); - for (const item of tuples) { - const key = item.checkpoint.id; - result.set(key, item); - } - return result; -} diff --git a/libs/checkpoint-validation/src/testUtils.ts b/libs/checkpoint-validation/src/testUtils.ts deleted file mode 100644 index af2d661b..00000000 --- a/libs/checkpoint-validation/src/testUtils.ts +++ /dev/null @@ -1,55 +0,0 @@ -// to make the type signature of the skipOnModules function a bit more readable -export type SaverName = string; -export type WhySkipped = string; - -/** - * Conditionally skips a test for a specific checkpoint saver implementation. When the test is skipped, - * the reason for skipping is provided. - * - * @param saverName - The name of the current module being tested (as passed via the `name` argument in the top-level suite entrypoint). - * @param skippedSavers - A list of modules for which the test should be skipped. - * @returns A function that can be used in place of the Jest @see it function and conditionally skips the test for the provided module. - */ -export function it_skipForSomeModules( - saverName: string, - skippedSavers: Record -): typeof it | typeof it.skip { - const skipReason = skippedSavers[saverName]; - - if (skipReason) { - const skip = ( - name: string, - test: jest.ProvidesCallback | undefined, - timeout?: number - ) => { - it.skip(`[because ${skipReason}] ${name}`, test, timeout); - }; - skip.prototype = it.skip.prototype; - return skip as typeof it.skip; - } - - return it; -} - -export function it_skipIfNot( - saverName: string, - ...savers: SaverName[] -): typeof it | typeof it.skip { - if (!savers.includes(saverName)) { - const skip = ( - name: string, - test: jest.ProvidesCallback | undefined, - timeout?: number - ) => { - it.skip( - `[only passes for "${savers.join('", "')}"] ${name}`, - test, - timeout - ); - }; - skip.prototype = it.skip.prototype; - return skip as typeof it.skip; - } - - return it; -} diff --git a/libs/checkpoint-validation/src/spec/data.ts b/libs/checkpoint-validation/src/test_utils.ts similarity index 59% rename from libs/checkpoint-validation/src/spec/data.ts rename to libs/checkpoint-validation/src/test_utils.ts index cbc92819..60b6896d 100644 --- a/libs/checkpoint-validation/src/spec/data.ts +++ b/libs/checkpoint-validation/src/test_utils.ts @@ -1,5 +1,5 @@ -import { mergeConfigs, RunnableConfig } from "@langchain/core/runnables"; import { + BaseCheckpointSaver, ChannelVersions, CheckpointPendingWrite, PendingWrite, @@ -9,24 +9,74 @@ import { type CheckpointTuple, } from "@langchain/langgraph-checkpoint"; +// to make the type signature of the skipOnModules function a bit more readable +export type CheckpointerName = string; +export type WhySkipped = string; + +/** + * Conditionally skips a test for a specific checkpointer implementation. When the test is skipped, the reason for + * skipping is provided. + * + * @param checkpointerName - The name of the current module being tested (as passed via the `name` argument in the top-level suite entrypoint). + * @param skippedCheckpointers - A list of modules for which the test should be skipped. + * @returns A function that can be used in place of the Jest @see it function and conditionally skips the test for the provided module. + */ +export function it_skipForSomeModules( + checkpointerName: string, + skippedCheckpointers: Record +): typeof it | typeof it.skip { + const skipReason = skippedCheckpointers[checkpointerName]; + + if (skipReason) { + const skip = ( + name: string, + test: jest.ProvidesCallback | undefined, + timeout?: number + ) => { + it.skip(`[because ${skipReason}] ${name}`, test, timeout); + }; + skip.prototype = it.skip.prototype; + return skip as typeof it.skip; + } + + return it; +} + +export function it_skipIfNot( + checkpointerName: string, + ...checkpointers: CheckpointerName[] +): typeof it | typeof it.skip { + if (!checkpointers.includes(checkpointerName)) { + const skip = ( + name: string, + test: jest.ProvidesCallback | undefined, + timeout?: number + ) => { + it.skip( + `[only passes for "${checkpointers.join('", "')}"] ${name}`, + test, + timeout + ); + }; + skip.prototype = it.skip.prototype; + return skip as typeof it.skip; + } + + return it; +} export interface InitialCheckpointTupleConfig { - config: RunnableConfig; + thread_id: string; checkpoint_id: string; - checkpoint_ns?: string; + checkpoint_ns: string; channel_values?: Record; channel_versions?: ChannelVersions; } export function initialCheckpointTuple({ - config, + thread_id, checkpoint_id, checkpoint_ns, channel_values = {}, }: InitialCheckpointTupleConfig): CheckpointTuple { - if (checkpoint_ns === undefined) { - // eslint-disable-next-line no-param-reassign - checkpoint_ns = config.configurable?.checkpoint_ns; - } - if (checkpoint_ns === undefined) { throw new Error("checkpoint_ns is required"); } @@ -35,13 +85,16 @@ export function initialCheckpointTuple({ Object.keys(channel_values).map((key) => [key, 1]) ); + const config = { + configurable: { + thread_id, + checkpoint_id, + checkpoint_ns, + }, + }; + return { - config: mergeConfigs(config, { - configurable: { - checkpoint_id, - checkpoint_ns, - }, - }), + config, checkpoint: { v: 1, ts: new Date().toISOString(), @@ -49,7 +102,7 @@ export function initialCheckpointTuple({ channel_values, channel_versions, versions_seen: { - // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + // this is meant to be opaque to checkpointers, so we just stuff dummy data in here to make sure it's stored and retrieved "": { someChannel: 1, }, @@ -67,17 +120,17 @@ export function initialCheckpointTuple({ } export interface ParentAndChildCheckpointTuplesWithWritesConfig { - config: RunnableConfig; + thread_id: string; parentCheckpointId: string; childCheckpointId: string; - checkpoint_ns?: string; + checkpoint_ns: string; initialChannelValues?: Record; writesToParent?: { taskId: string; writes: PendingWrite[] }[]; writesToChild?: { taskId: string; writes: PendingWrite[] }[]; } export function parentAndChildCheckpointTuplesWithWrites({ - config, + thread_id, parentCheckpointId, childCheckpointId, checkpoint_ns, @@ -88,11 +141,6 @@ export function parentAndChildCheckpointTuplesWithWrites({ parent: CheckpointTuple; child: CheckpointTuple; } { - if (checkpoint_ns === undefined) { - // eslint-disable-next-line no-param-reassign - checkpoint_ns = config.configurable?.checkpoint_ns; - } - if (checkpoint_ns === undefined) { throw new Error("checkpoint_ns is required"); } @@ -159,7 +207,7 @@ export function parentAndChildCheckpointTuplesWithWrites({ channel_values: initialChannelValues, channel_versions: parentChannelVersions, versions_seen: { - // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + // this is meant to be opaque to checkpointers, so we just stuff dummy data in here to make sure it's stored and retrieved "": { someChannel: 1, }, @@ -172,12 +220,13 @@ export function parentAndChildCheckpointTuplesWithWrites({ writes: null, parents: {}, }, - config: mergeConfigs(config, { + config: { configurable: { + thread_id, checkpoint_ns, checkpoint_id: parentCheckpointId, }, - }), + }, parentConfig: undefined, pendingWrites: parentPendingWrites, }, @@ -189,7 +238,7 @@ export function parentAndChildCheckpointTuplesWithWrites({ channel_values: childChannelValues, channel_versions: childChannelVersions, versions_seen: { - // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved + // this is meant to be opaque to checkpointers, so we just stuff dummy data in here to make sure it's stored and retrieved "": { someChannel: 1, }, @@ -200,34 +249,32 @@ export function parentAndChildCheckpointTuplesWithWrites({ source: "loop", step: 0, writes: { - // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved someNode: parentPendingWrites, }, parents: { - // I think this is meant to be opaque to checkpoint savers, so I'm just stuffing the data in here to make sure it's stored and retrieved - // I think this is roughly what it'd look like if it were generated by the pregel loop, though - checkpoint_ns: parentCheckpointId, + [checkpoint_ns]: parentCheckpointId, }, }, - config: mergeConfigs(config, { + config: { configurable: { + thread_id, checkpoint_ns, checkpoint_id: childCheckpointId, }, - }), - parentConfig: mergeConfigs(config, { + }, + parentConfig: { configurable: { + thread_id, checkpoint_ns, checkpoint_id: parentCheckpointId, }, - }), + }, pendingWrites: childPendingWrites, }, }; } export function* generateTuplePairs( - config: RunnableConfig, countPerNamespace: number, namespaces: string[] ): Generator<{ @@ -258,12 +305,8 @@ export function* generateTuplePairs( }; const { parent, child } = parentAndChildCheckpointTuplesWithWrites({ - config: mergeConfigs(config, { - configurable: { - thread_id, - checkpoint_ns, - }, - }), + thread_id, + checkpoint_ns, parentCheckpointId, childCheckpointId, initialChannelValues, @@ -288,3 +331,70 @@ export function* generateTuplePairs( } } } + +export async function* putTuples( + checkpointer: BaseCheckpointSaver, + generatedTuples: { + tuple: CheckpointTuple; + writes: { writes: PendingWrite[]; taskId: string }[]; + newVersions: Record; + }[] +): AsyncGenerator { + for (const generated of generatedTuples) { + const { thread_id, checkpoint_ns } = generated.tuple.config + .configurable as { thread_id: string; checkpoint_ns: string }; + + const checkpoint_id = generated.tuple.parentConfig?.configurable + ?.checkpoint_id as string | undefined; + + const config = { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id, + }, + }; + + const existingTuple = await checkpointer.getTuple(generated.tuple.config); + + expect(existingTuple).toBeUndefined(); + + const newConfig = await checkpointer.put( + config, + generated.tuple.checkpoint, + generated.tuple.metadata!, + generated.newVersions + ); + + for (const write of generated.writes) { + await checkpointer.putWrites(newConfig, write.writes, write.taskId); + } + + const expectedTuple = await checkpointer.getTuple(newConfig); + + expect(expectedTuple).not.toBeUndefined(); + + if (expectedTuple) { + yield expectedTuple; + } + } +} + +export async function toArray( + generator: AsyncGenerator +): Promise { + const result = []; + for await (const item of generator) { + result.push(item); + } + return result; +} + +export function toMap(tuples: CheckpointTuple[]): Map { + const result = new Map(); + for (const item of tuples) { + const key = item.checkpoint.id; + result.set(key, item); + } + return result; +} diff --git a/libs/checkpoint-validation/src/tests/memory.spec.ts b/libs/checkpoint-validation/src/tests/memory.spec.ts index 90ea4dca..cbb6775d 100644 --- a/libs/checkpoint-validation/src/tests/memory.spec.ts +++ b/libs/checkpoint-validation/src/tests/memory.spec.ts @@ -1,4 +1,4 @@ import { specTest } from "../spec/index.js"; -import { initializer } from "./memoryInitializer.js"; +import { initializer } from "./memory_initializer.js"; specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/memoryInitializer.ts b/libs/checkpoint-validation/src/tests/memoryInitializer.ts deleted file mode 100644 index c634fdfe..00000000 --- a/libs/checkpoint-validation/src/tests/memoryInitializer.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MemorySaver } from "@langchain/langgraph-checkpoint"; - -export const initializer = { - saverName: "MemorySaver", - createSaver: () => new MemorySaver(), -}; - -export default initializer; diff --git a/libs/checkpoint-validation/src/tests/memory_initializer.ts b/libs/checkpoint-validation/src/tests/memory_initializer.ts new file mode 100644 index 00000000..8a8f7dc2 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/memory_initializer.ts @@ -0,0 +1,9 @@ +import { MemorySaver } from "@langchain/langgraph-checkpoint"; +import { CheckpointerTestInitializer } from "../types.js"; + +export const initializer: CheckpointerTestInitializer = { + checkpointerName: "MemorySaver", + createCheckpointer: () => new MemorySaver(), +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/tests/mongodb.spec.ts b/libs/checkpoint-validation/src/tests/mongodb.spec.ts index 2135c4ff..8a235cfc 100644 --- a/libs/checkpoint-validation/src/tests/mongodb.spec.ts +++ b/libs/checkpoint-validation/src/tests/mongodb.spec.ts @@ -1,9 +1,9 @@ import { specTest } from "../spec/index.js"; -import { initializer } from "./mongoInitializer.js"; -import { isCI, osHasSupportedContainerRuntime } from "./utils.js"; +import { initializer } from "./mongodb_initializer.js"; +import { isSkippedCIEnvironment } from "./utils.js"; -if (osHasSupportedContainerRuntime() || !isCI()) { - specTest(initializer); +if (isSkippedCIEnvironment()) { + it.skip(`${initializer.checkpointerName} skipped in CI because no container runtime is available`, () => {}); } else { - it.skip(`${initializer.saverName} skipped in CI because no container runtime is available`, () => {}); + specTest(initializer); } diff --git a/libs/checkpoint-validation/src/tests/mongoInitializer.ts b/libs/checkpoint-validation/src/tests/mongodb_initializer.ts similarity index 84% rename from libs/checkpoint-validation/src/tests/mongoInitializer.ts rename to libs/checkpoint-validation/src/tests/mongodb_initializer.ts index 40f14480..0a600278 100644 --- a/libs/checkpoint-validation/src/tests/mongoInitializer.ts +++ b/libs/checkpoint-validation/src/tests/mongodb_initializer.ts @@ -8,7 +8,7 @@ import { // eslint-disable-next-line import/no-extraneous-dependencies import { MongoClient } from "mongodb"; -import type { CheckpointSaverTestInitializer } from "../types.js"; +import type { CheckpointerTestInitializer } from "../types.js"; const dbName = "test_db"; @@ -17,8 +17,8 @@ const container = new MongoDBContainer("mongo:6.0.1"); let startedContainer: StartedMongoDBContainer; let client: MongoClient | undefined; -export const initializer: CheckpointSaverTestInitializer = { - saverName: "@langchain/langgraph-checkpoint-mongodb", +export const initializer: CheckpointerTestInitializer = { + checkpointerName: "@langchain/langgraph-checkpoint-mongodb", async beforeAll() { startedContainer = await container.start(); @@ -30,7 +30,7 @@ export const initializer: CheckpointSaverTestInitializer = { beforeAllTimeout: 300_000, // five minutes, to pull docker container - async createSaver() { + async createCheckpointer() { // ensure fresh database for each test const db = await client!.db(dbName); await db.dropDatabase(); diff --git a/libs/checkpoint-validation/src/tests/postgres.spec.ts b/libs/checkpoint-validation/src/tests/postgres.spec.ts index ca357de5..f2cff9d1 100644 --- a/libs/checkpoint-validation/src/tests/postgres.spec.ts +++ b/libs/checkpoint-validation/src/tests/postgres.spec.ts @@ -1,9 +1,9 @@ import { specTest } from "../spec/index.js"; -import { initializer } from "./postgresInitializer.js"; -import { isCI, osHasSupportedContainerRuntime } from "./utils.js"; +import { initializer } from "./postgres_initializer.js"; +import { isSkippedCIEnvironment } from "./utils.js"; -if (osHasSupportedContainerRuntime() || !isCI()) { - specTest(initializer); +if (isSkippedCIEnvironment()) { + it.skip(`${initializer.checkpointerName} skipped in CI because no container runtime is available`, () => {}); } else { - it.skip(`${initializer.saverName} skipped in CI because no container runtime is available`, () => {}); + specTest(initializer); } diff --git a/libs/checkpoint-validation/src/tests/postgresInitializer.ts b/libs/checkpoint-validation/src/tests/postgres_initializer.ts similarity index 75% rename from libs/checkpoint-validation/src/tests/postgresInitializer.ts rename to libs/checkpoint-validation/src/tests/postgres_initializer.ts index a0e12848..7ab466b4 100644 --- a/libs/checkpoint-validation/src/tests/postgresInitializer.ts +++ b/libs/checkpoint-validation/src/tests/postgres_initializer.ts @@ -10,7 +10,7 @@ import { // eslint-disable-next-line import/no-extraneous-dependencies import pg from "pg"; -import type { CheckpointSaverTestInitializer } from "../types.js"; +import type { CheckpointerTestInitializer } from "../types.js"; const dbName = "test_db"; @@ -22,8 +22,8 @@ const container = new PostgreSqlContainer("postgres:16.2") let startedContainer: StartedPostgreSqlContainer; let client: pg.Pool | undefined; -export const initializer: CheckpointSaverTestInitializer = { - saverName: "@langchain/langgraph-checkpoint-postgres", +export const initializer: CheckpointerTestInitializer = { + checkpointerName: "@langchain/langgraph-checkpoint-postgres", async beforeAll() { startedContainer = await container.start(); @@ -35,7 +35,7 @@ export const initializer: CheckpointSaverTestInitializer = { await startedContainer.stop(); }, - async createSaver() { + async createCheckpointer() { client = new pg.Pool({ connectionString: startedContainer.getConnectionUri(), }); @@ -49,13 +49,13 @@ export const initializer: CheckpointSaverTestInitializer = { url.username = startedContainer.getUsername(); url.password = startedContainer.getPassword(); - const saver = PostgresSaver.fromConnString(url.toString()); - await saver.setup(); - return saver; + const checkpointer = PostgresSaver.fromConnString(url.toString()); + await checkpointer.setup(); + return checkpointer; }, - async destroySaver(saver) { - await saver.end(); + async destroyCheckpointer(checkpointer: PostgresSaver) { + await checkpointer.end(); await client?.query(`DROP DATABASE ${dbName}`); await client?.end(); }, diff --git a/libs/checkpoint-validation/src/tests/sqlite.spec.ts b/libs/checkpoint-validation/src/tests/sqlite.spec.ts index e3c25022..96f6687b 100644 --- a/libs/checkpoint-validation/src/tests/sqlite.spec.ts +++ b/libs/checkpoint-validation/src/tests/sqlite.spec.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { specTest } from "../spec/index.js"; -import { initializer } from "./sqliteInitializer.js"; +import { initializer } from "./sqlite_initializer.js"; specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/sqliteInitializer.ts b/libs/checkpoint-validation/src/tests/sqliteInitializer.ts deleted file mode 100644 index 8fe6b751..00000000 --- a/libs/checkpoint-validation/src/tests/sqliteInitializer.ts +++ /dev/null @@ -1,17 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite"; -import { CheckpointSaverTestInitializer } from "../types.js"; - -export const initializer: CheckpointSaverTestInitializer = { - saverName: "@langchain/langgraph-checkpoint-sqlite", - - async createSaver() { - return SqliteSaver.fromConnString(":memory:"); - }, - - destroySaver(saver) { - saver.db.close(); - }, -}; - -export default initializer; diff --git a/libs/checkpoint-validation/src/tests/sqlite_initializer.ts b/libs/checkpoint-validation/src/tests/sqlite_initializer.ts new file mode 100644 index 00000000..b526c242 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/sqlite_initializer.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite"; +import { CheckpointerTestInitializer } from "../types.js"; + +export const initializer: CheckpointerTestInitializer = { + checkpointerName: "@langchain/langgraph-checkpoint-sqlite", + + async createCheckpointer() { + return SqliteSaver.fromConnString(":memory:"); + }, + + async destroyCheckpointer(checkpointer: SqliteSaver) { + await checkpointer.db.close(); + }, +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/tests/utils.ts b/libs/checkpoint-validation/src/tests/utils.ts index 32c8871d..d4139513 100644 --- a/libs/checkpoint-validation/src/tests/utils.ts +++ b/libs/checkpoint-validation/src/tests/utils.ts @@ -8,7 +8,7 @@ function isWindows() { return platform() === "win32"; } -export function isCI() { +function isCI() { // eslint-disable-next-line no-process-env return (process.env.CI ?? "").toLowerCase() === "true"; } @@ -28,6 +28,6 @@ export function isCI() { * * */ -export function osHasSupportedContainerRuntime() { - return !isWindows() && !isMSeriesMac(); +export function isSkippedCIEnvironment() { + return isCI() && (isWindows() || isMSeriesMac()); } diff --git a/libs/checkpoint-validation/src/types.ts b/libs/checkpoint-validation/src/types.ts index 548457c0..b875b34b 100644 --- a/libs/checkpoint-validation/src/types.ts +++ b/libs/checkpoint-validation/src/types.ts @@ -1,17 +1,16 @@ -import { RunnableConfig } from "@langchain/core/runnables"; import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; import { z } from "zod"; -export interface CheckpointSaverTestInitializer< - CheckpointSaverT extends BaseCheckpointSaver +export interface CheckpointerTestInitializer< + CheckpointerT extends BaseCheckpointSaver > { /** - * The name of the checkpoint saver being tested. This will be used to identify the saver in test output. + * The name of the checkpointer being tested. This will be used to identify the checkpointer in test output. */ - saverName: string; + checkpointerName: string; /** - * Called once before any tests are run. Use this to perform any setup that your checkpoint saver may require, like + * Called once before any tests are run. Use this to perform any setup that your checkpoint checkpointer may require, like * starting docker containers, etc. */ beforeAll?(): void | Promise; @@ -25,49 +24,29 @@ export interface CheckpointSaverTestInitializer< beforeAllTimeout?: number; /** - * Called once after all tests are run. Use this to perform any infrastructure cleanup that your checkpoint saver may + * Called once after all tests are run. Use this to perform any infrastructure cleanup that your checkpointer may * require, like tearing down docker containers, etc. */ afterAll?(): void | Promise; /** - * Called before each set of validations is run, prior to calling @see createSaver. Use this to modify the @see - * RunnableConfig that will be used during the test, used to include any additional configuration that your - * checkpoint saver may require. + * Called before each set of validations is run. The checkpointer returned will be used during test execution. * - * @param config The @see RunnableConfig that will be used during the test. - * @returns an instance of @see RunnableConfig (or a promise that resolves to one) to be merged with the original - * config for use during the test execution. + * @returns A new checkpointer, or promise that resolves to a new checkpointer. */ - configure?(config: RunnableConfig): RunnableConfig | Promise; + createCheckpointer(): CheckpointerT | Promise; /** - * Called before each set of validations is run, after @see configure has been called. The checkpoint saver returned - * will be used during test execution. + * Called after each set of validations is run. Use this to clean up any resources that your checkpointer may + * have been using. This should include cleaning up any state that the checkpointer wrote during the tests that just ran. * - * @param config The @see RunnableConfig that will be used during the test. Can be used for constructing the saver, - * if required. - * @returns A new saver, or promise that resolves to a new saver. + * @param checkpointer The @see BaseCheckpointSaver that was used during the test. */ - createSaver( - config: RunnableConfig - ): CheckpointSaverT | Promise; - - /** - * Called after each set of validations is run. Use this to clean up any resources that your checkpoint saver may - * have been using. This should include cleaning up any state that the saver wrote during the tests that just ran. - * - * @param saver The @see BaseCheckpointSaver that was used during the test. - * @param config The @see RunnableConfig that was used during the test. - */ - destroySaver?( - saver: CheckpointSaverT, - config: RunnableConfig - ): void | Promise; + destroyCheckpointer?(checkpointer: CheckpointerT): void | Promise; } -export const checkpointSaverTestInitializerSchema = z.object({ - saverName: z.string(), +export const checkpointerTestInitializerSchema = z.object({ + checkpointerName: z.string(), beforeAll: z .function() .returns(z.void().or(z.promise(z.void()))) @@ -77,24 +56,16 @@ export const checkpointSaverTestInitializerSchema = z.object({ .function() .returns(z.void().or(z.promise(z.void()))) .optional(), - configure: z - .function() - .args(z.custom()) - .returns( - z.custom().or(z.promise(z.custom())) - ) - .optional(), - createSaver: z + createCheckpointer: z .function() - .args(z.custom()) .returns( z .custom() .or(z.promise(z.custom())) ), - destroySaver: z + destroyCheckpointer: z .function() - .args(z.custom(), z.custom()) + .args(z.custom()) .returns(z.void().or(z.promise(z.void()))) .optional(), }); @@ -102,6 +73,6 @@ export const checkpointSaverTestInitializerSchema = z.object({ export type TestTypeFilter = "getTuple" | "list" | "put" | "putWrites"; export type GlobalThis = typeof globalThis & { - __langgraph_checkpoint_validation_initializer?: CheckpointSaverTestInitializer; + __langgraph_checkpoint_validation_initializer?: CheckpointerTestInitializer; __langgraph_checkpoint_validation_filters?: TestTypeFilter[]; }; From 81f13e0f26f5467d833f8ec2001236c185e5916a Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Mon, 21 Oct 2024 17:37:22 +1300 Subject: [PATCH 6/7] Clean up CLI arg parsing & passing --- .../bin/{jest.config.cjs => jest.config.js} | 16 +-- libs/checkpoint-validation/src/cli.ts | 110 ++---------------- libs/checkpoint-validation/src/parse_args.ts | 103 ++++++++++++++++ libs/checkpoint-validation/src/runner.ts | 14 +-- libs/checkpoint-validation/src/types.ts | 22 +++- libs/checkpoint-validation/tsconfig.cjs.json | 5 +- 6 files changed, 150 insertions(+), 120 deletions(-) rename libs/checkpoint-validation/bin/{jest.config.cjs => jest.config.js} (57%) create mode 100644 libs/checkpoint-validation/src/parse_args.ts diff --git a/libs/checkpoint-validation/bin/jest.config.cjs b/libs/checkpoint-validation/bin/jest.config.js similarity index 57% rename from libs/checkpoint-validation/bin/jest.config.cjs rename to libs/checkpoint-validation/bin/jest.config.js index 98e95f92..825aad2b 100644 --- a/libs/checkpoint-validation/bin/jest.config.cjs +++ b/libs/checkpoint-validation/bin/jest.config.js @@ -1,20 +1,22 @@ // This is the Jest config used by the test harness when being executed via the CLI. // For the Jest config for the tests in this project, see the `jest.config.cjs` in the root of the package workspace. -const path = require("path"); +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "../dist/parse_args.js"; + +const args = await parseArgs(process.argv.slice(2)); /** @type {import('ts-jest').JestConfigWithTsJest} */ -const config = { +export default { preset: "ts-jest/presets/default-esm", - rootDir: path.resolve(__dirname, "..", "dist"), + rootDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"), testEnvironment: "node", testMatch: ["/runner.js"], transform: { - "^.+\\.(ts|js)x?$": ["@swc/jest"], + "^.+\\.[jt]sx?$": ["@swc/jest"], }, moduleNameMapper: { "^(\\.{1,2}/.*)\\.[jt]sx?$": "$1", }, - maxWorkers: "50%", + globals: args, }; - -module.exports = config; diff --git a/libs/checkpoint-validation/src/cli.ts b/libs/checkpoint-validation/src/cli.ts index 299d669b..5d39edc3 100644 --- a/libs/checkpoint-validation/src/cli.ts +++ b/libs/checkpoint-validation/src/cli.ts @@ -1,109 +1,23 @@ import { dirname, resolve as pathResolve } from "node:path"; import { fileURLToPath } from "node:url"; import { runCLI } from "@jest/core"; -import yargs, { ArgumentsCamelCase } from "yargs"; -import { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; -import { - CheckpointerTestInitializer, - checkpointerTestInitializerSchema, - GlobalThis, - TestTypeFilter, -} from "./types.js"; -import { dynamicImport, resolveImportPath } from "./import_utils.js"; // make it so we can import/require .ts files import "@swc-node/register/esm-register"; +import { parseArgs } from "./parse_args.js"; export async function main() { const moduleDirname = dirname(fileURLToPath(import.meta.url)); - const builder = yargs(); - await builder - .command( - "* [filters..]", - "Validate a checkpointer", - { - builder: (args) => { - return args - .positional("initializerImportPath", { - type: "string", - describe: - "The import path of the CheckpointSaverTestInitializer for the checkpointer (passed to 'import()'). " + - "Must be the default export.", - demandOption: true, - }) - .positional("filters", { - array: true, - choices: ["getTuple", "put", "putWrites", "list"], - default: [], - describe: - "Only run the specified suites. Valid values are 'getTuple', 'put', 'putWrites', and 'list'", - demandOption: false, - }); - }, - handler: async ( - argv: ArgumentsCamelCase<{ - initializerImportPath: string; - filters: string[]; - }> - ) => { - const { initializerImportPath, filters } = argv; - - let resolvedImportPath; - - try { - resolvedImportPath = resolveImportPath(initializerImportPath); - } catch (e) { - console.error( - `Failed to resolve import path '${initializerImportPath}': ${e}` - ); - process.exit(1); - } - - let initializerExport: unknown; - try { - initializerExport = await dynamicImport(resolvedImportPath); - } catch (e) { - console.error( - `Failed to import initializer from import path '${initializerImportPath}' (resolved to '${resolvedImportPath}'): ${e}` - ); - process.exit(1); - } - - let initializer: CheckpointerTestInitializer; - try { - initializer = checkpointerTestInitializerSchema.parse( - (initializerExport as { default?: unknown }).default ?? - initializerExport - ); - ( - globalThis as GlobalThis - ).__langgraph_checkpoint_validation_initializer = initializer; - ( - globalThis as GlobalThis - ).__langgraph_checkpoint_validation_filters = - filters as TestTypeFilter[]; - } catch (e) { - console.error( - `Initializer imported from '${initializerImportPath}' does not conform to the expected schema. Make sure " + - "it is the default export, and that implements the CheckpointSaverTestInitializer interface. Error: ${e}` - ); - process.exit(1); - } - - await runCLI( - { - _: [], - $0: "", - }, - [pathResolve(moduleDirname, "..", "bin", "jest.config.cjs")] - ); - }, - } - ) - .help() - .alias("h", "help") - .wrap(builder.terminalWidth()) - .strict() - .parseAsync(process.argv.slice(2)); + // parse here to check for errors before running Jest + await parseArgs(process.argv.slice(2)); + + await runCLI( + { + _: [], + $0: "", + runInBand: true, + }, + [pathResolve(moduleDirname, "..", "bin", "jest.config.js")] + ); } diff --git a/libs/checkpoint-validation/src/parse_args.ts b/libs/checkpoint-validation/src/parse_args.ts new file mode 100644 index 00000000..53908d02 --- /dev/null +++ b/libs/checkpoint-validation/src/parse_args.ts @@ -0,0 +1,103 @@ +import yargs from "yargs"; +import { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { + CheckpointerTestInitializer, + checkpointerTestInitializerSchema, + isTestTypeFilter, + isTestTypeFilterArray, + TestTypeFilter, + testTypeFilters, +} from "./types.js"; +import { dynamicImport, resolveImportPath } from "./import_utils.js"; + +// We have to Symbol.for here instead of unique symbols because jest gives each test file a unique module cache, so +// these symbols get created once when the jest config is evaluated, and again when runner.ts executes, making it +// impossible for runner.ts to use them as global keys. +const symbolPrefix = "langgraph-checkpoint-validation"; +export const initializerSymbol = Symbol.for(`${symbolPrefix}-initializer`); +export const filtersSymbol = Symbol.for(`${symbolPrefix}-filters`); + +export type ParsedArgs< + CheckpointerT extends BaseCheckpointSaver = BaseCheckpointSaver +> = { + [initializerSymbol]: CheckpointerTestInitializer; + [filtersSymbol]: TestTypeFilter[]; +}; + +const builder = yargs() + .command("* [filters..]", "Validate a checkpointer") + .positional("initializerImportPath", { + type: "string", + describe: + "The import path of the CheckpointSaverTestInitializer for the checkpointer (passed to 'import()'). " + + "Must be the default export.", + demandOption: true, + }) + .positional("filters", { + array: true, + choices: ["getTuple", "put", "putWrites", "list"], + default: [], + describe: + "Only run the specified suites. Valid values are 'getTuple', 'put', 'putWrites', and 'list'", + demandOption: false, + }) + .help() + .alias("h", "help") + .wrap(yargs().terminalWidth()) + .strict(); + +export async function parseArgs( + argv: string[] +): Promise> { + const { initializerImportPath, filters } = await builder.parse(argv); + + let resolvedImportPath; + + try { + resolvedImportPath = resolveImportPath(initializerImportPath); + } catch (e) { + console.error( + `Failed to resolve import path '${initializerImportPath}': ${e}` + ); + process.exit(1); + } + + let initializerExport: unknown; + try { + initializerExport = await dynamicImport(resolvedImportPath); + } catch (e) { + console.error( + `Failed to import initializer from import path '${initializerImportPath}' (resolved to '${resolvedImportPath}'): ${e}` + ); + process.exit(1); + } + + let initializer: CheckpointerTestInitializer; + try { + initializer = checkpointerTestInitializerSchema.parse( + (initializerExport as { default?: unknown }).default ?? initializerExport + ) as CheckpointerTestInitializer; + } catch (e) { + console.error( + `Initializer imported from '${initializerImportPath}' does not conform to the expected schema. Make sure " + + "it is the default export, and that implements the CheckpointSaverTestInitializer interface. Error: ${e}` + ); + process.exit(1); + } + + if (!isTestTypeFilterArray(filters)) { + console.error( + `Invalid filters: '${filters + .filter((f) => !isTestTypeFilter(f)) + .join("', '")}'. Expected only values from '${testTypeFilters.join( + "', '" + )}'` + ); + process.exit(1); + } + + return { + [initializerSymbol]: initializer, + [filtersSymbol]: filters, + }; +} diff --git a/libs/checkpoint-validation/src/runner.ts b/libs/checkpoint-validation/src/runner.ts index 908e18e7..4655f3f8 100644 --- a/libs/checkpoint-validation/src/runner.ts +++ b/libs/checkpoint-validation/src/runner.ts @@ -2,19 +2,17 @@ // Jest test file because unfortunately there's no good way to just pass Jest a test definition function and tell it to // run it. import { specTest } from "./spec/index.js"; -import type { GlobalThis } from "./types.js"; +import { ParsedArgs, filtersSymbol, initializerSymbol } from "./parse_args.js"; // passing via global is ugly, but there's no good alternative for handling the dynamic import here -const initializer = (globalThis as GlobalThis) - .__langgraph_checkpoint_validation_initializer; +const initializer = (globalThis as typeof globalThis & ParsedArgs)[ + initializerSymbol +]; if (!initializer) { - throw new Error( - "expected global '__langgraph_checkpoint_validation_initializer' is not set" - ); + throw new Error("Test configuration error: initializer is not set."); } -const filters = (globalThis as GlobalThis) - .__langgraph_checkpoint_validation_filters; +const filters = (globalThis as typeof globalThis & ParsedArgs)[filtersSymbol]; specTest(initializer, filters); diff --git a/libs/checkpoint-validation/src/types.ts b/libs/checkpoint-validation/src/types.ts index b875b34b..c055f299 100644 --- a/libs/checkpoint-validation/src/types.ts +++ b/libs/checkpoint-validation/src/types.ts @@ -70,9 +70,21 @@ export const checkpointerTestInitializerSchema = z.object({ .optional(), }); -export type TestTypeFilter = "getTuple" | "list" | "put" | "putWrites"; +export const testTypeFilters = [ + "getTuple", + "list", + "put", + "putWrites", +] as const; -export type GlobalThis = typeof globalThis & { - __langgraph_checkpoint_validation_initializer?: CheckpointerTestInitializer; - __langgraph_checkpoint_validation_filters?: TestTypeFilter[]; -}; +export type TestTypeFilter = (typeof testTypeFilters)[number]; + +export function isTestTypeFilter(value: string): value is TestTypeFilter { + return testTypeFilters.includes(value as TestTypeFilter); +} + +export function isTestTypeFilterArray( + value: string[] +): value is TestTypeFilter[] { + return value.every(isTestTypeFilter); +} diff --git a/libs/checkpoint-validation/tsconfig.cjs.json b/libs/checkpoint-validation/tsconfig.cjs.json index ca674d22..a07594ee 100644 --- a/libs/checkpoint-validation/tsconfig.cjs.json +++ b/libs/checkpoint-validation/tsconfig.cjs.json @@ -11,7 +11,8 @@ "docs", "**/tests", "src/cli.ts", - "src/importUtils.ts", - "src/runner.ts" + "src/import_utils.ts", + "src/runner.ts", + "src/parse_args.ts" ] } From 0ea882488b6eac698e4f9a0450b193d71b0577a2 Mon Sep 17 00:00:00 2001 From: Ben Burns <803016+benjamincburns@users.noreply.github.com> Date: Mon, 21 Oct 2024 17:59:39 +1300 Subject: [PATCH 7/7] bump yarn.lock --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 369c157e..e8d385ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1816,7 +1816,7 @@ __metadata: "@langchain/core": ">=0.2.31 <0.4.0" "@langchain/langgraph-checkpoint": ~0.0.6 bin: - validate-saver: ./bin/cli.js + validate-checkpointer: ./bin/cli.js languageName: unknown linkType: soft