From 78309c4dae09a7379631b329b0a0a3779fb325cf 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] 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 | 108 +++ 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 | 93 ++ libs/checkpoint-validation/src/importUtils.ts | 68 ++ libs/checkpoint-validation/src/index.ts | 18 + libs/checkpoint-validation/src/runner.ts | 24 + libs/checkpoint-validation/src/spec/data.ts | 290 ++++++ .../src/spec/getTuple.ts | 265 ++++++ libs/checkpoint-validation/src/spec/index.ts | 33 + 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 | 100 ++ 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, 3343 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..4546b92f --- /dev/null +++ b/libs/checkpoint-validation/README.md @@ -0,0 +1,108 @@ +# @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. + +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 +``` + +Yarn: + +```bash +yarn global add @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..4bc94852 --- /dev/null +++ b/libs/checkpoint-validation/src/cli.ts @@ -0,0 +1,93 @@ +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, +} from "./types.js"; +import { dynamicImport, resolveImportPath } from "./importUtils.js"; + +// make it so we can import/require .ts files +import "@swc-node/register/esm-register"; + +type GlobalThis = typeof globalThis & { + __langgraph_checkpoint_validation_initializer?: CheckpointSaverTestInitializer; +}; + +export async function main() { + const moduleDirname = dirname(fileURLToPath(import.meta.url)); + + const builder = yargs(); + await builder + .command("* ", "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, + }); + }, + handler: async ( + argv: ArgumentsCamelCase<{ + initializerImportPath: string; + }> + ) => { + const { initializerImportPath } = 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; + } 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..0abcaf62 --- /dev/null +++ b/libs/checkpoint-validation/src/runner.ts @@ -0,0 +1,24 @@ +// 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 { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { specTest } from "./spec/index.js"; +import { CheckpointSaverTestInitializer } from "./types.js"; + +type GlobalThis = typeof globalThis & { + __langgraph_checkpoint_validation_initializer?: CheckpointSaverTestInitializer; +}; + +// 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 as + | CheckpointSaverTestInitializer + | undefined; +if (!initializer) { + throw new Error( + "expected global '__langgraph_checkpoint_validation_initializer' is not set" + ); +} + +specTest(initializer); 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..aa109427 --- /dev/null +++ b/libs/checkpoint-validation/src/spec/index.ts @@ -0,0 +1,33 @@ +import { type BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { describe, beforeAll, afterAll } from "@jest/globals"; + +import { CheckpointSaverTestInitializer } from "../types.js"; +import { putTests } from "./put.js"; +import { putWritesTests } from "./putWrites.js"; +import { getTupleTests } from "./getTuple.js"; +import { listTests } from "./list.js"; + +/** + * 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. + */ +export function specTest( + initializer: CheckpointSaverTestInitializer +) { + beforeAll(async () => { + await initializer.beforeAll?.(); + }, initializer.beforeAllTimeout ?? 10000); + + afterAll(async () => { + await initializer.afterAll?.(); + }); + + describe(initializer.saverName, () => { + putTests(initializer); + putWritesTests(initializer); + getTupleTests(initializer); + listTests(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..17830c38 --- /dev/null +++ b/libs/checkpoint-validation/src/types.ts @@ -0,0 +1,100 @@ +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(), +}); 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"