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..d0e9a8b1 --- /dev/null +++ b/libs/checkpoint-validation/README.md @@ -0,0 +1,96 @@ +# @langchain/langgraph-checkpoint-validation + +The checkpointer validation tool is used to validate that custom checkpointer implementations conform to LangGraph's requirements. LangGraph uses [checkpointers](https://langchain-ai.github.io/langgraphjs/concepts/persistence/#checkpointer-libraries) for persisting workflow state, providing the ability to "rewind" your workflow to some earlier point in time, and continue execution from there. + +The overall process for using this tool is as follows: + +1. Write your custom checkpointer implementation. +2. Add a file to your project that defines a [`CheckpointerTestInitializer`](./src/types.ts) as its default export. +3. Run the checkpointer validation tool to test your checkpointer and determine whether it meets LangGraph's requirements. +4. Iterate on your custom checkpointer as required, until tests pass. + +The tool can be executed from the terminal as a CLI, or you can use it as a library to integrate it into your test suite. + +## Writing a CheckpointerTestInitializer + +The `CheckpointerTestInitializer` interface ([example](./src/tests/postgres_initializer.ts)) is used by the test harness to create instances of your custom checkpointer, and any infrastructure that it requires for testing purposes. + +If you intend to execute the tool via the CLI, your `CheckpointerTestInitializer` **must** be the default export of the module in which it is defined. + +**Synchronous vs Asynchronous initializer functions**: You may return promises from any functions defined in your `CheckpointerTestInitializer` according to your needs and the test harness will behave accordingly. + +**IMPORTANT**: You must take care to write your `CheckpointerTestInitializer` such that instances of your custom checkpointer are isolated from one another with respect to persisted state, or else some tests (particularly the ones that exercise the `list` method) will fail. That is, state written by one instance of your checkpointer MUST NOT be readable by another instance of your checkpointer. That said, there will only ever be one instance of your checkpointer live at any given time, so **you may use shared storage, provided it is cleared when your checkpointer is created or destroyed.** The structure of the `CheckpointerTestInitializer` interface should make this relatively easy to achieve, per the sections below. + + +### (Required) `checkpointerName`: Define a name for your checkpointer + +`CheckpointerTestInitializer` requires you to define a `checkpointerName` field (of type `string`) for use in the test output. + +### `beforeAll`: Set up required infrastructure + +If your checkpointer requires some external infrastructure to be provisioned, you may wish to provision this via the **optional** `beforeAll` function. This function executes exactly once, at the very start of the testing lifecycle. If defined, it is the first function that will be called from your `CheckpointerTestInitializer`. + +**Timeout duration**: If your `beforeAll` function may take longer than 10 seconds to execute, you can assign a custom timeout duration (as milliseconds) to the optional `beforeAllTimeout` field of your `CheckpointerTestInitializer`. + +**State isolation note**: Depending on the cost/performance/requirements of your checkpointer infrastructure, it **may** make more sense for you to provision it during the `createCheckpointer` step, so you can provide each checkpointer instance with its own isolated storage backend. However as mentioned above, you may also provision a single shared storage backend, provided you clear any stored data during the `createCheckpointer` or `destroyCheckpointer` step. + +### `afterAll`: Tear down required infrastructure + +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) `createCheckpointer`: Construct your checkpointer + +`CheckpointerTestInitializer` requires you to define a `createCheckpointer()` function that returns an instance of your custom checkpointer. + +**State isolation note:** If you're provisioning storage during this step, make sure that it is "fresh" storage for each instance of your checkpointer. Otherwise if you are using a shared storage setup, be sure to clear it either in this function, or in the `destroyCheckpointer` function (described in the section below). + +### `destroyCheckpointer`: Destroy your checkpointer + +If your custom checkpointer requires an explicit teardown step (for example, to clean up database connections), you can define this in the **optional** `destroyCheckpointer(checkpointer: CheckpointerT)` function. + +**State isolation note:** If you are using a shared storage setup, be sure to clear it either in this function, or in the `createCheckpointer` function (described in the section above). + +## CLI usage + +You may use this tool's CLI either via `npx`, `yarn dlx`, or by installing globally and executing it via the `validate-checkpointer` command. + +The only required argument to the tool is the import path for your `CheckpointerTestInitializer`. Relative paths must begin with a leading `./` (or `.\`, for Windows), otherwise the path will be interpreted as a module name rather than a relative path. + +You may optionally pass one or more test filters as positional arguments after the import path argument (separated by spaces). Valid values are `getTuple`, `list`, `put`, and `putWrites`. If present, only the test suites specified in the filter list will be executed. This is useful for working through smaller sets of test failures as you're validating your checkpointer. + +TypeScript imports **are** supported, so you may pass a path directly to your TypeScript source file. + +### NPX & Yarn execution + +NPX: + +```bash +npx @langchain/langgraph-checkpoint-validation ./src/my_initializer.ts +``` + +Yarn: + +```bash +yarn dlx @langchain/langgraph-checkpoint-validation ./src/my_initializer.ts +``` + +### Global install + +NPM: + +```bash +npm install -g @langchain/langgraph-checkpoint-validation +validate-checkpointer ./src/my_initializer.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(MyCheckpointerInitializer); +``` 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.js b/libs/checkpoint-validation/bin/jest.config.js new file mode 100644 index 00000000..825aad2b --- /dev/null +++ b/libs/checkpoint-validation/bin/jest.config.js @@ -0,0 +1,22 @@ +// This is the Jest config used by the test harness when being executed via the CLI. +// For the Jest config for the tests in this project, see the `jest.config.cjs` in the root of the package workspace. +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "../dist/parse_args.js"; + +const args = await parseArgs(process.argv.slice(2)); + +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + rootDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"), + testEnvironment: "node", + testMatch: ["/runner.js"], + transform: { + "^.+\\.[jt]sx?$": ["@swc/jest"], + }, + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.[jt]sx?$": "$1", + }, + globals: args, +}; 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..2d20e22e --- /dev/null +++ b/libs/checkpoint-validation/package.json @@ -0,0 +1,107 @@ +{ + "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/jest": "^29.5.13", + "@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-checkpointer": "./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..5d39edc3 --- /dev/null +++ b/libs/checkpoint-validation/src/cli.ts @@ -0,0 +1,23 @@ +import { dirname, resolve as pathResolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCLI } from "@jest/core"; + +// make it so we can import/require .ts files +import "@swc-node/register/esm-register"; +import { parseArgs } from "./parse_args.js"; + +export async function main() { + const moduleDirname = dirname(fileURLToPath(import.meta.url)); + + // parse here to check for errors before running Jest + await parseArgs(process.argv.slice(2)); + + await runCLI( + { + _: [], + $0: "", + runInBand: true, + }, + [pathResolve(moduleDirname, "..", "bin", "jest.config.js")] + ); +} diff --git a/libs/checkpoint-validation/src/import_utils.ts b/libs/checkpoint-validation/src/import_utils.ts new file mode 100644 index 00000000..2b428340 --- /dev/null +++ b/libs/checkpoint-validation/src/import_utils.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..538cf8a9 --- /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 { CheckpointerTestInitializer } from "./types.js"; + +export { CheckpointerTestInitializer as CheckpointSaverTestInitializer } from "./types.js"; +export { + getTupleTests, + listTests, + putTests, + putWritesTests, + specTest, +} from "./spec/index.js"; + +export function validate( + initializer: CheckpointerTestInitializer +) { + specTest(initializer); +} diff --git a/libs/checkpoint-validation/src/parse_args.ts b/libs/checkpoint-validation/src/parse_args.ts new file mode 100644 index 00000000..53908d02 --- /dev/null +++ b/libs/checkpoint-validation/src/parse_args.ts @@ -0,0 +1,103 @@ +import yargs from "yargs"; +import { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { + CheckpointerTestInitializer, + checkpointerTestInitializerSchema, + isTestTypeFilter, + isTestTypeFilterArray, + TestTypeFilter, + testTypeFilters, +} from "./types.js"; +import { dynamicImport, resolveImportPath } from "./import_utils.js"; + +// We have to Symbol.for here instead of unique symbols because jest gives each test file a unique module cache, so +// these symbols get created once when the jest config is evaluated, and again when runner.ts executes, making it +// impossible for runner.ts to use them as global keys. +const symbolPrefix = "langgraph-checkpoint-validation"; +export const initializerSymbol = Symbol.for(`${symbolPrefix}-initializer`); +export const filtersSymbol = Symbol.for(`${symbolPrefix}-filters`); + +export type ParsedArgs< + CheckpointerT extends BaseCheckpointSaver = BaseCheckpointSaver +> = { + [initializerSymbol]: CheckpointerTestInitializer; + [filtersSymbol]: TestTypeFilter[]; +}; + +const builder = yargs() + .command("* [filters..]", "Validate a checkpointer") + .positional("initializerImportPath", { + type: "string", + describe: + "The import path of the CheckpointSaverTestInitializer for the checkpointer (passed to 'import()'). " + + "Must be the default export.", + demandOption: true, + }) + .positional("filters", { + array: true, + choices: ["getTuple", "put", "putWrites", "list"], + default: [], + describe: + "Only run the specified suites. Valid values are 'getTuple', 'put', 'putWrites', and 'list'", + demandOption: false, + }) + .help() + .alias("h", "help") + .wrap(yargs().terminalWidth()) + .strict(); + +export async function parseArgs( + argv: string[] +): Promise> { + const { initializerImportPath, filters } = await builder.parse(argv); + + let resolvedImportPath; + + try { + resolvedImportPath = resolveImportPath(initializerImportPath); + } catch (e) { + console.error( + `Failed to resolve import path '${initializerImportPath}': ${e}` + ); + process.exit(1); + } + + let initializerExport: unknown; + try { + initializerExport = await dynamicImport(resolvedImportPath); + } catch (e) { + console.error( + `Failed to import initializer from import path '${initializerImportPath}' (resolved to '${resolvedImportPath}'): ${e}` + ); + process.exit(1); + } + + let initializer: CheckpointerTestInitializer; + try { + initializer = checkpointerTestInitializerSchema.parse( + (initializerExport as { default?: unknown }).default ?? initializerExport + ) as CheckpointerTestInitializer; + } catch (e) { + console.error( + `Initializer imported from '${initializerImportPath}' does not conform to the expected schema. Make sure " + + "it is the default export, and that implements the CheckpointSaverTestInitializer interface. Error: ${e}` + ); + process.exit(1); + } + + if (!isTestTypeFilterArray(filters)) { + console.error( + `Invalid filters: '${filters + .filter((f) => !isTestTypeFilter(f)) + .join("', '")}'. Expected only values from '${testTypeFilters.join( + "', '" + )}'` + ); + process.exit(1); + } + + return { + [initializerSymbol]: initializer, + [filtersSymbol]: filters, + }; +} diff --git a/libs/checkpoint-validation/src/runner.ts b/libs/checkpoint-validation/src/runner.ts new file mode 100644 index 00000000..4655f3f8 --- /dev/null +++ b/libs/checkpoint-validation/src/runner.ts @@ -0,0 +1,18 @@ +// This file is used by the CLI to dynamically execute tests against the user-provided checkpointer. It's written as a +// Jest test file because unfortunately there's no good way to just pass Jest a test definition function and tell it to +// run it. +import { specTest } from "./spec/index.js"; +import { ParsedArgs, filtersSymbol, initializerSymbol } from "./parse_args.js"; + +// passing via global is ugly, but there's no good alternative for handling the dynamic import here +const initializer = (globalThis as typeof globalThis & ParsedArgs)[ + initializerSymbol +]; + +if (!initializer) { + throw new Error("Test configuration error: initializer is not set."); +} + +const filters = (globalThis as typeof globalThis & ParsedArgs)[filtersSymbol]; + +specTest(initializer, filters); diff --git a/libs/checkpoint-validation/src/spec/get_tuple.ts b/libs/checkpoint-validation/src/spec/get_tuple.ts new file mode 100644 index 00000000..be06f54b --- /dev/null +++ b/libs/checkpoint-validation/src/spec/get_tuple.ts @@ -0,0 +1,249 @@ +import { + CheckpointTuple, + PendingWrite, + TASKS, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { CheckpointerTestInitializer } from "../types.js"; +import { + parentAndChildCheckpointTuplesWithWrites, + putTuples, +} from "../test_utils.js"; + +export function getTupleTests( + initializer: CheckpointerTestInitializer +) { + describe(`${initializer.checkpointerName}#getTuple`, () => { + let checkpointer: T; + beforeAll(async () => { + checkpointer = await initializer.createCheckpointer(); + }); + + afterAll(async () => { + await initializer.destroyCheckpointer?.(checkpointer); + }); + + 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 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({ + thread_id, + parentCheckpointId, + childCheckpointId, + checkpoint_ns, + initialChannelValues: { + animals: ["dog"], + }, + writesToParent, + writesToChild, + })); + + const storedTuples = putTuples(checkpointer, [ + { + tuple: generatedParentTuple, + writes: writesToParent, + newVersions: { animals: 1 }, + }, + { + tuple: generatedChildTuple, + writes: writesToChild, + newVersions: { animals: 2 }, + }, + ]); + + parentTuple = (await storedTuples.next()).value; + childTuple = (await storedTuples.next()).value; + + latestTuple = await checkpointer.getTuple({ + configurable: { thread_id, checkpoint_ns }, + }); + }); + + 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 = { + configurable: { + thread_id: uuid6(-3), + checkpoint_ns, + checkpoint_id: uuid6(-3), + }, + }; + const checkpointTuple = await checkpointer.getTuple( + configWithInvalidCheckpointId + ); + expect(checkpointTuple).toBeUndefined(); + }); + + it("should return undefined if the thread_id is undefined", async () => { + const missingThreadIdConfig = { + configurable: { + checkpoint_ns, + }, + }; + + expect( + await checkpointer.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..646408f3 --- /dev/null +++ b/libs/checkpoint-validation/src/spec/index.ts @@ -0,0 +1,50 @@ +import { type BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; + +import { CheckpointerTestInitializer, TestTypeFilter } from "../types.js"; +import { putTests } from "./put.js"; +import { putWritesTests } from "./put_writes.js"; +import { getTupleTests } from "./get_tuple.js"; +import { listTests } from "./list.js"; + +const testTypeMap = { + getTuple: getTupleTests, + list: listTests, + put: putTests, + putWrites: putWritesTests, +}; + +/** + * Kicks off a test suite to validate that the provided checkpointer conforms to the specification for classes that + * extend @see BaseCheckpointSaver. + * + * @param initializer A @see CheckpointerTestInitializer, providing methods for setup and cleanup of the test, + * and for creation of the checkpointer instance being tested. + * @param filters If specified, only the test suites in this list will be executed. + */ +export function specTest( + initializer: CheckpointerTestInitializer, + filters?: TestTypeFilter[] +) { + beforeAll(async () => { + await initializer.beforeAll?.(); + }, initializer.beforeAllTimeout ?? 10000); + + afterAll(async () => { + await initializer.afterAll?.(); + }); + + describe(initializer.checkpointerName, () => { + if (!filters || filters.length === 0) { + putTests(initializer); + putWritesTests(initializer); + getTupleTests(initializer); + listTests(initializer); + } else { + for (const testType of filters) { + testTypeMap[testType](initializer); + } + } + }); +} + +export { getTupleTests, listTests, putTests, putWritesTests }; diff --git a/libs/checkpoint-validation/src/spec/list.ts b/libs/checkpoint-validation/src/spec/list.ts new file mode 100644 index 00000000..d9f63097 --- /dev/null +++ b/libs/checkpoint-validation/src/spec/list.ts @@ -0,0 +1,332 @@ +import { + CheckpointTuple, + PendingWrite, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointerTestInitializer } from "../types.js"; +import { + generateTuplePairs, + putTuples, + toArray, + toMap, +} from "../test_utils.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 checkpointer. + * + * IMPORTANT NOTE: This test relies on the `getTuple` method of the checkpointer functioning properly. If you have + * failures in `getTuple`, you should fix them before addressing the failures in this test. + * + * @param initializer the initializer for the checkpointer + */ +export function listTests( + initializer: CheckpointerTestInitializer +) { + const invalidThreadId = uuid6(-3); + + const namespaces = ["", "child"]; + + const generatedTuples: { + tuple: CheckpointTuple; + writes: { writes: PendingWrite[]; taskId: string }[]; + newVersions: Record; + }[] = Array.from(generateTuplePairs(2, namespaces)); + + const argumentRanges = setupArgumentRanges( + generatedTuples.map(({ tuple }) => tuple), + namespaces + ); + + const argumentCombinations: ListTestCase[] = Array.from( + buildArgumentCombinations( + argumentRanges, + generatedTuples.map(({ tuple }) => tuple) + ) + ); + + describe(`${initializer.checkpointerName}#list`, () => { + let checkpointer: T; + const storedTuples: Map = new Map(); + + beforeAll(async () => { + checkpointer = await initializer.createCheckpointer(); + + // put all the tuples + for await (const tuple of putTuples(checkpointer, generatedTuples)) { + storedTuples.set(tuple.checkpoint.id, tuple); + } + }); + + afterAll(async () => { + await initializer.destroyCheckpointer?.(checkpointer); + }); + + // 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( + checkpointer.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 checkpointerIncludesPendingWritesOnList = + initializer.checkpointerName !== + "@langchain/langgraph-checkpoint-mongodb" && + initializer.checkpointerName !== + "@langchain/langgraph-checkpoint-sqlite"; + + const expectedTuple = expectedTuplesMap.get(key); + if (!checkpointerIncludesPendingWritesOnList) { + 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.checkpointerName === + "@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..5ecc343d --- /dev/null +++ b/libs/checkpoint-validation/src/spec/put.ts @@ -0,0 +1,269 @@ +import { + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointerTestInitializer } from "../types.js"; +import { + initialCheckpointTuple, + it_skipForSomeModules, + putTuples, +} from "../test_utils.js"; + +export function putTests( + initializer: CheckpointerTestInitializer +) { + describe(`${initializer.checkpointerName}#put`, () => { + let checkpointer: T; + let thread_id: string; + let checkpoint_id1: string; + + beforeEach(async () => { + thread_id = uuid6(-3); + checkpoint_id1 = uuid6(-3); + checkpointer = await initializer.createCheckpointer(); + }); + + afterEach(async () => { + await initializer.destroyCheckpointer?.(checkpointer); + }); + + describe.each(["root", "child"])("namespace: %s", (namespace) => { + const checkpoint_ns = namespace === "root" ? "" : namespace; + let checkpointStoredWithoutIdInConfig: Checkpoint; + let metadataStoredWithoutIdInConfig: CheckpointMetadata | undefined; + + describe("success cases", () => { + let basicPutReturnedConfig: RunnableConfig; + let basicPutRoundTripTuple: CheckpointTuple | undefined; + + beforeEach(async () => { + ({ + checkpoint: checkpointStoredWithoutIdInConfig, + metadata: metadataStoredWithoutIdInConfig, + } = initialCheckpointTuple({ + thread_id, + checkpoint_id: checkpoint_id1, + checkpoint_ns, + })); + + // validate assumptions - the test checkpoints must not already exist + const existingCheckpoint1 = await checkpointer.get({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpoint_id1, + }, + }); + + const existingCheckpoint2 = await checkpointer.get({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: checkpoint_id1, + }, + }); + + expect(existingCheckpoint1).toBeUndefined(); + expect(existingCheckpoint2).toBeUndefined(); + + // set up + // call put without the `checkpoint_id` in the config + basicPutReturnedConfig = await checkpointer.put( + { + configurable: { + thread_id, + checkpoint_ns, + // adding this to ensure that additional fields are not stored in the checkpoint tuple + canary: "tweet", + }, + }, + checkpointStoredWithoutIdInConfig, + metadataStoredWithoutIdInConfig!, + {} + ); + + basicPutRoundTripTuple = await checkpointer.getTuple( + basicPutReturnedConfig + ); + }); + + 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 + ); + }); + + 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 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 = {}; + + await expect( + async () => + await checkpointer.put( + missingConfigurableConfig, + checkpointStoredWithoutIdInConfig, + metadataStoredWithoutIdInConfig!, + {} + ) + ).rejects.toThrow(); + }); + + it("should fail if the thread_id is missing", async () => { + const missingThreadIdConfig: RunnableConfig = { + configurable: { + checkpoint_ns, + }, + }; + + await expect( + async () => + await checkpointer.put( + missingThreadIdConfig, + checkpointStoredWithoutIdInConfig, + metadataStoredWithoutIdInConfig!, + {} + ) + ).rejects.toThrow(); + }); + }); + }); + + it_skipForSomeModules(initializer.checkpointerName, { + // TODO: MemorySaver throws instead of defaulting to empty namespace + // see: https://github.com/langchain-ai/langgraphjs/issues/591 + MemorySaver: "TODO: throws instead of defaulting to empty namespace", + // 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 = { + configurable: { thread_id }, + }; + + const { checkpoint, metadata } = initialCheckpointTuple({ + thread_id, + checkpoint_id: checkpoint_id1, + checkpoint_ns: "", + }); + + const returnedConfig = await checkpointer.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.checkpointerName, { + // TODO: all of the checkpointers below store full channel_values on every put, rather than storing deltas + // see: https://github.com/langchain-ai/langgraphjs/issues/593 + // see: https://github.com/langchain-ai/langgraphjs/issues/594 + // see: https://github.com/langchain-ai/langgraphjs/issues/595 + 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({ + thread_id, + checkpoint_id: uuid6(-3), + checkpoint_ns: "", + channel_values: { + foo: "bar", + baz: "qux", + }, + }), + writes: [], + newVersions, + })); + + const storedTuples: CheckpointTuple[] = []; + for await (const tuple of putTuples(checkpointer, generatedPuts)) { + 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/put_writes.ts b/libs/checkpoint-validation/src/spec/put_writes.ts new file mode 100644 index 00000000..f6ed22e9 --- /dev/null +++ b/libs/checkpoint-validation/src/spec/put_writes.ts @@ -0,0 +1,136 @@ +import { + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + uuid6, + type BaseCheckpointSaver, +} from "@langchain/langgraph-checkpoint"; +import { RunnableConfig } from "@langchain/core/runnables"; +import { CheckpointerTestInitializer } from "../types.js"; +import { initialCheckpointTuple } from "../test_utils.js"; + +export function putWritesTests( + initializer: CheckpointerTestInitializer +) { + describe(`${initializer.checkpointerName}#putWrites`, () => { + let checkpointer: T; + let thread_id: string; + let checkpoint_id: string; + + beforeEach(async () => { + thread_id = uuid6(-3); + checkpoint_id = uuid6(-3); + + checkpointer = await initializer.createCheckpointer(); + }); + + afterEach(async () => { + await initializer.destroyCheckpointer?.(checkpointer); + }); + + describe.each(["root", "child"])("namespace: %s", (namespace) => { + const checkpoint_ns = namespace === "root" ? "" : namespace; + let checkpoint: Checkpoint; + let metadata: CheckpointMetadata | undefined; + + describe("success cases", () => { + let returnedConfig!: RunnableConfig; + let savedCheckpointTuple: CheckpointTuple | undefined; + + beforeEach(async () => { + ({ checkpoint, metadata } = initialCheckpointTuple({ + thread_id, + checkpoint_ns, + checkpoint_id, + })); + + // ensure the test checkpoint does not already exist + const existingCheckpoint = await checkpointer.get({ + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id, + }, + }); + expect(existingCheckpoint).toBeUndefined(); // our test checkpoint should not exist yet + + returnedConfig = await checkpointer.put( + { + configurable: { + thread_id, + checkpoint_ns, + }, + }, + checkpoint, + metadata!, + {} /* not sure what to do about newVersions, as it's unused */ + ); + + await checkpointer.putWrites( + returnedConfig, + [["animals", "dog"]], + "pet_task" + ); + + savedCheckpointTuple = await checkpointer.getTuple(returnedConfig); + + // fail here if `put` or `getTuple` is broken so we don't get a bunch of noise from the actual test cases below + expect(savedCheckpointTuple).not.toBeUndefined(); + expect(savedCheckpointTuple?.checkpoint).toEqual(checkpoint); + expect(savedCheckpointTuple?.metadata).toEqual(metadata); + expect(savedCheckpointTuple?.config).toEqual({ + configurable: { + thread_id, + checkpoint_ns, + 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 = { + configurable: { + checkpoint_ns, + checkpoint_id, + }, + }; + + await expect( + async () => + await checkpointer.putWrites( + missingThreadIdConfig, + [["animals", "dog"]], + "pet_task" + ) + ).rejects.toThrow(); + }); + + it("should fail if the checkpoint_id is missing", async () => { + const missingCheckpointIdConfig: RunnableConfig = { + configurable: { + thread_id, + checkpoint_ns, + }, + }; + + await expect( + async () => + await checkpointer.putWrites( + missingCheckpointIdConfig, + [["animals", "dog"]], + "pet_task" + ) + ).rejects.toThrow(); + }); + }); + }); + }); +} diff --git a/libs/checkpoint-validation/src/test_utils.ts b/libs/checkpoint-validation/src/test_utils.ts new file mode 100644 index 00000000..60b6896d --- /dev/null +++ b/libs/checkpoint-validation/src/test_utils.ts @@ -0,0 +1,400 @@ +import { + BaseCheckpointSaver, + ChannelVersions, + CheckpointPendingWrite, + PendingWrite, + SendProtocol, + TASKS, + uuid6, + type CheckpointTuple, +} from "@langchain/langgraph-checkpoint"; + +// to make the type signature of the skipOnModules function a bit more readable +export type CheckpointerName = string; +export type WhySkipped = string; + +/** + * Conditionally skips a test for a specific checkpointer implementation. When the test is skipped, the reason for + * skipping is provided. + * + * @param checkpointerName - The name of the current module being tested (as passed via the `name` argument in the top-level suite entrypoint). + * @param skippedCheckpointers - A list of modules for which the test should be skipped. + * @returns A function that can be used in place of the Jest @see it function and conditionally skips the test for the provided module. + */ +export function it_skipForSomeModules( + checkpointerName: string, + skippedCheckpointers: Record +): typeof it | typeof it.skip { + const skipReason = skippedCheckpointers[checkpointerName]; + + if (skipReason) { + const skip = ( + name: string, + test: jest.ProvidesCallback | undefined, + timeout?: number + ) => { + it.skip(`[because ${skipReason}] ${name}`, test, timeout); + }; + skip.prototype = it.skip.prototype; + return skip as typeof it.skip; + } + + return it; +} + +export function it_skipIfNot( + checkpointerName: string, + ...checkpointers: CheckpointerName[] +): typeof it | typeof it.skip { + if (!checkpointers.includes(checkpointerName)) { + const skip = ( + name: string, + test: jest.ProvidesCallback | undefined, + timeout?: number + ) => { + it.skip( + `[only passes for "${checkpointers.join('", "')}"] ${name}`, + test, + timeout + ); + }; + skip.prototype = it.skip.prototype; + return skip as typeof it.skip; + } + + return it; +} +export interface InitialCheckpointTupleConfig { + thread_id: string; + checkpoint_id: string; + checkpoint_ns: string; + channel_values?: Record; + channel_versions?: ChannelVersions; +} +export function initialCheckpointTuple({ + thread_id, + checkpoint_id, + checkpoint_ns, + channel_values = {}, +}: InitialCheckpointTupleConfig): CheckpointTuple { + if (checkpoint_ns === undefined) { + throw new Error("checkpoint_ns is required"); + } + + const channel_versions = Object.fromEntries( + Object.keys(channel_values).map((key) => [key, 1]) + ); + + const config = { + configurable: { + thread_id, + checkpoint_id, + checkpoint_ns, + }, + }; + + return { + config, + checkpoint: { + v: 1, + ts: new Date().toISOString(), + id: checkpoint_id, + channel_values, + channel_versions, + versions_seen: { + // this is meant to be opaque to checkpointers, so we just stuff dummy data in here to make sure it's stored and retrieved + "": { + someChannel: 1, + }, + }, + pending_sends: [], + }, + + metadata: { + source: "input", + step: -1, + writes: null, + parents: {}, + }, + }; +} + +export interface ParentAndChildCheckpointTuplesWithWritesConfig { + thread_id: string; + parentCheckpointId: string; + childCheckpointId: string; + checkpoint_ns: string; + initialChannelValues?: Record; + writesToParent?: { taskId: string; writes: PendingWrite[] }[]; + writesToChild?: { taskId: string; writes: PendingWrite[] }[]; +} + +export function parentAndChildCheckpointTuplesWithWrites({ + thread_id, + parentCheckpointId, + childCheckpointId, + checkpoint_ns, + initialChannelValues = {}, + writesToParent = [], + writesToChild = [], +}: ParentAndChildCheckpointTuplesWithWritesConfig): { + parent: CheckpointTuple; + child: CheckpointTuple; +} { + 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: { + // this is meant to be opaque to checkpointers, so we just stuff dummy data in here to make sure it's stored and retrieved + "": { + someChannel: 1, + }, + }, + pending_sends: [], + }, + metadata: { + source: "input", + step: -1, + writes: null, + parents: {}, + }, + config: { + configurable: { + thread_id, + 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: { + // this is meant to be opaque to checkpointers, so we just stuff dummy data in here to make sure it's stored and retrieved + "": { + someChannel: 1, + }, + }, + pending_sends, + }, + metadata: { + source: "loop", + step: 0, + writes: { + someNode: parentPendingWrites, + }, + parents: { + [checkpoint_ns]: parentCheckpointId, + }, + }, + config: { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: childCheckpointId, + }, + }, + parentConfig: { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id: parentCheckpointId, + }, + }, + pendingWrites: childPendingWrites, + }, + }; +} + +export function* generateTuplePairs( + 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({ + 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, + }; + } + } +} + +export async function* putTuples( + checkpointer: BaseCheckpointSaver, + generatedTuples: { + tuple: CheckpointTuple; + writes: { writes: PendingWrite[]; taskId: string }[]; + newVersions: Record; + }[] +): AsyncGenerator { + for (const generated of generatedTuples) { + const { thread_id, checkpoint_ns } = generated.tuple.config + .configurable as { thread_id: string; checkpoint_ns: string }; + + const checkpoint_id = generated.tuple.parentConfig?.configurable + ?.checkpoint_id as string | undefined; + + const config = { + configurable: { + thread_id, + checkpoint_ns, + checkpoint_id, + }, + }; + + const existingTuple = await checkpointer.getTuple(generated.tuple.config); + + expect(existingTuple).toBeUndefined(); + + const newConfig = await checkpointer.put( + config, + generated.tuple.checkpoint, + generated.tuple.metadata!, + generated.newVersions + ); + + for (const write of generated.writes) { + await checkpointer.putWrites(newConfig, write.writes, write.taskId); + } + + const expectedTuple = await checkpointer.getTuple(newConfig); + + expect(expectedTuple).not.toBeUndefined(); + + if (expectedTuple) { + yield expectedTuple; + } + } +} + +export async function toArray( + generator: AsyncGenerator +): Promise { + const result = []; + for await (const item of generator) { + result.push(item); + } + return result; +} + +export function toMap(tuples: CheckpointTuple[]): Map { + const result = new Map(); + for (const item of tuples) { + const key = item.checkpoint.id; + result.set(key, item); + } + return result; +} diff --git a/libs/checkpoint-validation/src/tests/memory.spec.ts b/libs/checkpoint-validation/src/tests/memory.spec.ts new file mode 100644 index 00000000..cbb6775d --- /dev/null +++ b/libs/checkpoint-validation/src/tests/memory.spec.ts @@ -0,0 +1,4 @@ +import { specTest } from "../spec/index.js"; +import { initializer } from "./memory_initializer.js"; + +specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/memory_initializer.ts b/libs/checkpoint-validation/src/tests/memory_initializer.ts new file mode 100644 index 00000000..8a8f7dc2 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/memory_initializer.ts @@ -0,0 +1,9 @@ +import { MemorySaver } from "@langchain/langgraph-checkpoint"; +import { CheckpointerTestInitializer } from "../types.js"; + +export const initializer: CheckpointerTestInitializer = { + checkpointerName: "MemorySaver", + createCheckpointer: () => new MemorySaver(), +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/tests/mongodb.spec.ts b/libs/checkpoint-validation/src/tests/mongodb.spec.ts new file mode 100644 index 00000000..8a235cfc --- /dev/null +++ b/libs/checkpoint-validation/src/tests/mongodb.spec.ts @@ -0,0 +1,9 @@ +import { specTest } from "../spec/index.js"; +import { initializer } from "./mongodb_initializer.js"; +import { isSkippedCIEnvironment } from "./utils.js"; + +if (isSkippedCIEnvironment()) { + it.skip(`${initializer.checkpointerName} skipped in CI because no container runtime is available`, () => {}); +} else { + specTest(initializer); +} diff --git a/libs/checkpoint-validation/src/tests/mongodb_initializer.ts b/libs/checkpoint-validation/src/tests/mongodb_initializer.ts new file mode 100644 index 00000000..0a600278 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/mongodb_initializer.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 { CheckpointerTestInitializer } 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: CheckpointerTestInitializer = { + checkpointerName: "@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 createCheckpointer() { + // 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/postgres.spec.ts b/libs/checkpoint-validation/src/tests/postgres.spec.ts new file mode 100644 index 00000000..f2cff9d1 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/postgres.spec.ts @@ -0,0 +1,9 @@ +import { specTest } from "../spec/index.js"; +import { initializer } from "./postgres_initializer.js"; +import { isSkippedCIEnvironment } from "./utils.js"; + +if (isSkippedCIEnvironment()) { + it.skip(`${initializer.checkpointerName} skipped in CI because no container runtime is available`, () => {}); +} else { + specTest(initializer); +} diff --git a/libs/checkpoint-validation/src/tests/postgres_initializer.ts b/libs/checkpoint-validation/src/tests/postgres_initializer.ts new file mode 100644 index 00000000..7ab466b4 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/postgres_initializer.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 { CheckpointerTestInitializer } 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: CheckpointerTestInitializer = { + checkpointerName: "@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 createCheckpointer() { + 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 checkpointer = PostgresSaver.fromConnString(url.toString()); + await checkpointer.setup(); + return checkpointer; + }, + + async destroyCheckpointer(checkpointer: PostgresSaver) { + await checkpointer.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..96f6687b --- /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 "./sqlite_initializer.js"; + +specTest(initializer); diff --git a/libs/checkpoint-validation/src/tests/sqlite_initializer.ts b/libs/checkpoint-validation/src/tests/sqlite_initializer.ts new file mode 100644 index 00000000..b526c242 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/sqlite_initializer.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite"; +import { CheckpointerTestInitializer } from "../types.js"; + +export const initializer: CheckpointerTestInitializer = { + checkpointerName: "@langchain/langgraph-checkpoint-sqlite", + + async createCheckpointer() { + return SqliteSaver.fromConnString(":memory:"); + }, + + async destroyCheckpointer(checkpointer: SqliteSaver) { + await checkpointer.db.close(); + }, +}; + +export default initializer; diff --git a/libs/checkpoint-validation/src/tests/utils.ts b/libs/checkpoint-validation/src/tests/utils.ts new file mode 100644 index 00000000..d4139513 --- /dev/null +++ b/libs/checkpoint-validation/src/tests/utils.ts @@ -0,0 +1,33 @@ +import { platform, arch } from "node:os"; + +function isMSeriesMac() { + return platform() === "darwin" && arch() === "arm64"; +} + +function isWindows() { + return platform() === "win32"; +} + +function isCI() { + // eslint-disable-next-line no-process-env + return (process.env.CI ?? "").toLowerCase() === "true"; +} + +/** + * GitHub Actions doesn't support containers on m-series macOS due to a lack of hypervisor support for nested + * virtualization. + * + * For details, see https://github.com/actions/runner-images/issues/9460#issuecomment-1981203045 + * + * GitHub actions also doesn't support Linux containers on Windows, and may never do so. This is in part due to Docker + * Desktop licensing restrictions, and the complexity of setting up Moby or similar without Docker Desktop. + * Unfortunately, TestContainers doesn't support windows containers, so we can't run the tests on Windows either. + * + * For details, see https://github.com/actions/runner/issues/904 and + * https://java.testcontainers.org/supported_docker_environment/windows/#windows-container-on-windows-wcow + * + * + */ +export function isSkippedCIEnvironment() { + return isCI() && (isWindows() || isMSeriesMac()); +} diff --git a/libs/checkpoint-validation/src/types.ts b/libs/checkpoint-validation/src/types.ts new file mode 100644 index 00000000..c055f299 --- /dev/null +++ b/libs/checkpoint-validation/src/types.ts @@ -0,0 +1,90 @@ +import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint"; +import { z } from "zod"; + +export interface CheckpointerTestInitializer< + CheckpointerT extends BaseCheckpointSaver +> { + /** + * The name of the checkpointer being tested. This will be used to identify the checkpointer in test output. + */ + checkpointerName: string; + + /** + * Called once before any tests are run. Use this to perform any setup that your checkpoint checkpointer may require, like + * starting docker containers, etc. + */ + beforeAll?(): void | Promise; + + /** + * 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 checkpointer may + * require, like tearing down docker containers, etc. + */ + afterAll?(): void | Promise; + + /** + * Called before each set of validations is run. The checkpointer returned will be used during test execution. + * + * @returns A new checkpointer, or promise that resolves to a new checkpointer. + */ + createCheckpointer(): CheckpointerT | Promise; + + /** + * Called after each set of validations is run. Use this to clean up any resources that your checkpointer may + * have been using. This should include cleaning up any state that the checkpointer wrote during the tests that just ran. + * + * @param checkpointer The @see BaseCheckpointSaver that was used during the test. + */ + destroyCheckpointer?(checkpointer: CheckpointerT): void | Promise; +} + +export const checkpointerTestInitializerSchema = z.object({ + checkpointerName: 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(), + createCheckpointer: z + .function() + .returns( + z + .custom() + .or(z.promise(z.custom())) + ), + destroyCheckpointer: z + .function() + .args(z.custom()) + .returns(z.void().or(z.promise(z.void()))) + .optional(), +}); + +export const testTypeFilters = [ + "getTuple", + "list", + "put", + "putWrites", +] as const; + +export type TestTypeFilter = (typeof testTypeFilters)[number]; + +export function isTestTypeFilter(value: string): value is TestTypeFilter { + return testTypeFilters.includes(value as TestTypeFilter); +} + +export function isTestTypeFilterArray( + value: string[] +): value is TestTypeFilter[] { + return value.every(isTestTypeFilter); +} diff --git a/libs/checkpoint-validation/tsconfig.cjs.json b/libs/checkpoint-validation/tsconfig.cjs.json new file mode 100644 index 00000000..a07594ee --- /dev/null +++ b/libs/checkpoint-validation/tsconfig.cjs.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": [ + "node_modules", + "dist", + "docs", + "**/tests", + "src/cli.ts", + "src/import_utils.ts", + "src/runner.ts", + "src/parse_args.ts" + ] +} diff --git a/libs/checkpoint-validation/tsconfig.json b/libs/checkpoint-validation/tsconfig.json new file mode 100644 index 00000000..60f0e6b9 --- /dev/null +++ b/libs/checkpoint-validation/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@tsconfig/recommended", + "compilerOptions": { + "outDir": "../dist", + "rootDir": "./src", + "target": "ES2021", + "lib": ["ES2021", "ES2022.Object", "DOM"], + "types": ["node", "jest"], + "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..e8d385ea 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,58 @@ __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/jest": ^29.5.13 + "@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-checkpointer: ./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 +1998,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 +2299,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 +2821,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 +3013,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 +3067,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 +3398,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" @@ -3225,6 +3495,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.13": + version: 29.5.13 + resolution: "@types/jest@npm:29.5.13" + dependencies: + expect: ^29.0.0 + pretty-format: ^29.0.0 + checksum: 875ac23c2398cdcf22aa56c6ba24560f11d2afda226d4fa23936322dde6202f9fdbd2b91602af51c27ecba223d9fc3c1e33c9df7e47b3bf0e2aefc6baf13ce53 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.12": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -3344,6 +3624,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 +4065,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 +4196,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 +4214,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 +4230,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 +4411,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 +4562,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 +4586,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 +4619,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 +4980,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 +5024,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 +5081,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 +5105,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 +5590,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 +5777,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 +6799,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" @@ -6373,7 +6882,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.7.0": +"expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: @@ -6779,6 +7288,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 +7394,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 +7410,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 +7536,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 +7742,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 +7809,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 +8158,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 +8252,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 +9144,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 +9244,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 +9477,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 +9609,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 +9678,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 +9701,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 +10153,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 +10631,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 @@ -10183,7 +10775,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.7.0": +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: @@ -10201,6 +10793,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 +10827,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 +10992,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 +11018,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 +11292,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 +11476,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 +11494,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 +11738,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 +11771,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 +11799,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 +11943,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 +11952,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 +12074,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 +12091,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 +12116,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 +12152,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 +12200,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 +12355,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 +12474,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 +12641,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 +12755,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 +13057,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 +13116,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"