@mi8y/cds-langgraph-persistence 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # Changesets
2
+
3
+ Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4
+ with multi-package repos, or single-package repos to help you version and publish your code. You can
5
+ find the full documentation for it [in our repository](https://github.com/changesets/changesets).
6
+
7
+ We have a quick list of common questions to get you started engaging with this project in
8
+ [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@mi8y/cds-langgraph-persistence": patch
3
+ ---
4
+
5
+ Initial project setup
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "public",
8
+ "baseBranch": "main",
9
+ "updateInternalDependencies": "patch",
10
+ "ignore": []
11
+ }
@@ -0,0 +1,72 @@
1
+ # Please see the documentation for all configuration options:
2
+ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3
+ # and
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "monthly"
12
+
13
+ groups:
14
+ # Internal LangChain packages
15
+ langchain:
16
+ patterns:
17
+ - "@langchain/*"
18
+ - "langchain"
19
+
20
+ # Vite, Vitest, and build tools
21
+ vite-vitest-turbo:
22
+ patterns:
23
+ - "vite"
24
+ - "vite-*"
25
+ - "vitest"
26
+ - "vitest-*"
27
+ - "@vitest/*"
28
+ - "rollup"
29
+ - "esbuild"
30
+ - "esbuild-*"
31
+ - "turbo"
32
+
33
+ # TypeScript, ESLint, and formatting
34
+ typescript-eslint-prettier:
35
+ patterns:
36
+ - "typescript"
37
+ - "@typescript-eslint/*"
38
+ - "@tsconfig/*"
39
+ - "eslint"
40
+ - "eslint-*"
41
+ - "@eslint/*"
42
+ - "prettier"
43
+
44
+ # SAP
45
+ sap:
46
+ patterns:
47
+ - "@sap/*"
48
+ - "@cap-js/*"
49
+
50
+ # Maintenance
51
+ cli-tools:
52
+ patterns:
53
+ - "@changesets/*"
54
+
55
+ # Type definitions
56
+ types:
57
+ patterns:
58
+ - "@types/*"
59
+
60
+ # Everything else — minor and patch together, major separately
61
+ minor-and-patch:
62
+ patterns:
63
+ - "*"
64
+ update-types:
65
+ - "minor"
66
+ - "patch"
67
+
68
+ major:
69
+ patterns:
70
+ - "*"
71
+ update-types:
72
+ - "major"
@@ -0,0 +1,159 @@
1
+ # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3
+
4
+ name: ci
5
+
6
+ on:
7
+ push:
8
+ branches: ["main"]
9
+ pull_request:
10
+ workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ # If another push to the same PR or branch happens while this workflow is still running,
16
+ # cancel the earlier run in favor of the next run.
17
+ concurrency:
18
+ group: ${{ github.workflow }}-${{ github.ref }}
19
+ cancel-in-progress: true
20
+
21
+ jobs:
22
+ # ===========================================
23
+ # Static checks + Build (all run in parallel)
24
+ # ===========================================
25
+ lint:
26
+ name: check linting
27
+ runs-on: ubuntu-latest
28
+ strategy:
29
+ matrix:
30
+ node-version: [24]
31
+ steps:
32
+ - uses: actions/checkout@v7
33
+
34
+ - name: Install pnpm
35
+ uses: pnpm/action-setup@v6
36
+
37
+ - name: Use Node.js ${{ matrix.node-version }}
38
+ uses: actions/setup-node@v6
39
+ with:
40
+ node-version: ${{ matrix.node-version }}
41
+ cache: "pnpm"
42
+
43
+ - name: Install dependencies
44
+ run: pnpm install --frozen-lockfile
45
+
46
+ - name: Check linting
47
+ run: pnpm lint:check
48
+
49
+ format:
50
+ name: check formatting
51
+ runs-on: ubuntu-latest
52
+ strategy:
53
+ matrix:
54
+ node-version: [24]
55
+ steps:
56
+ - uses: actions/checkout@v7
57
+
58
+ - name: Install pnpm
59
+ uses: pnpm/action-setup@v6
60
+
61
+ - name: Use Node.js ${{ matrix.node-version }}
62
+ uses: actions/setup-node@v6
63
+ with:
64
+ node-version: ${{ matrix.node-version }}
65
+ cache: "pnpm"
66
+
67
+ - name: Install dependencies
68
+ run: pnpm install --frozen-lockfile
69
+
70
+ - name: Check formatting
71
+ run: pnpm format:check
72
+
73
+ build:
74
+ name: build
75
+ runs-on: ubuntu-latest
76
+ strategy:
77
+ matrix:
78
+ node-version: [24]
79
+ steps:
80
+ - uses: actions/checkout@v7
81
+
82
+ - name: Install pnpm
83
+ uses: pnpm/action-setup@v6
84
+
85
+ - name: Use Node.js ${{ matrix.node-version }}
86
+ uses: actions/setup-node@v6
87
+ with:
88
+ node-version: ${{ matrix.node-version }}
89
+ cache: "pnpm"
90
+
91
+ - name: Install dependencies
92
+ run: pnpm install --frozen-lockfile
93
+
94
+ - name: Build
95
+ run: pnpm build
96
+
97
+ - name: Create build archive
98
+ run: tar -czf build-artifacts.tar.gz dist
99
+
100
+ - name: Publish to pkg.pr.new
101
+ if: ${{ github.event_name == 'pull_request' }}
102
+ run: pnpm dlx pkg-pr-new publish './*.tgz'
103
+ continue-on-error: true
104
+
105
+ - name: Upload build artifacts
106
+ uses: actions/upload-artifact@v7
107
+ with:
108
+ name: build-artifacts
109
+ path: build-artifacts.tar.gz
110
+ retention-days: 1
111
+
112
+ # ============================================
113
+ # Stage 2: Tests (require build + all static checks to pass)
114
+ # ============================================
115
+ test:
116
+ name: unit tests
117
+ needs: [lint, format, build]
118
+ runs-on: ubuntu-latest
119
+ permissions:
120
+ contents: read
121
+ code-quality: write
122
+ pull-requests: read
123
+ strategy:
124
+ matrix:
125
+ node-version: [24]
126
+ steps:
127
+ - uses: actions/checkout@v7
128
+
129
+ - name: Install pnpm
130
+ uses: pnpm/action-setup@v6
131
+
132
+ - name: Use Node.js ${{ matrix.node-version }}
133
+ uses: actions/setup-node@v6
134
+ with:
135
+ node-version: ${{ matrix.node-version }}
136
+ cache: "pnpm"
137
+
138
+ - name: Install dependencies
139
+ run: pnpm install --frozen-lockfile
140
+
141
+ - name: Download build artifacts
142
+ uses: actions/download-artifact@v8
143
+ with:
144
+ name: build-artifacts
145
+
146
+ - name: Extract build artifacts
147
+ run: tar -xzf build-artifacts.tar.gz
148
+ shell: bash
149
+
150
+ - name: Test
151
+ run: pnpm test
152
+
153
+ - name: Upload coverage report
154
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
155
+ uses: actions/upload-code-coverage@v1
156
+ with:
157
+ file: coverage/cobertura-coverage.xml
158
+ language: JavaScript
159
+ label: coverage
@@ -0,0 +1,58 @@
1
+ # Enforces Conventional Commits format for pull request titles.
2
+ name: pr title lint
3
+
4
+ permissions:
5
+ pull-requests: read
6
+
7
+ on:
8
+ pull_request:
9
+ types: [opened, edited, synchronize]
10
+
11
+ jobs:
12
+ lint-pr-title:
13
+ name: validate title format
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: "Reject empty scope"
17
+ env:
18
+ PR_TITLE: ${{ github.event.pull_request.title }}
19
+ run: |
20
+ if [[ "$PR_TITLE" =~ ^[a-z]+\(\)[!]?: ]]; then
21
+ echo "::error::PR title has empty scope parentheses: '$PR_TITLE'"
22
+ echo "Either remove the parentheses or provide a scope (e.g., 'fix(langgraph): ...')."
23
+ exit 1
24
+ fi
25
+
26
+ - name: "Validate Conventional Commits format"
27
+ uses: amannn/action-semantic-pull-request@v6
28
+ env:
29
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30
+ with:
31
+ types: |
32
+ feat
33
+ fix
34
+ docs
35
+ style
36
+ refactor
37
+ perf
38
+ test
39
+ build
40
+ ci
41
+ chore
42
+ revert
43
+ release
44
+ hotfix
45
+ scopes: |
46
+ cds-langgraph-persistence
47
+ docs
48
+ examples
49
+ internal
50
+ infra
51
+ deps
52
+ deps-dev
53
+ requireScope: false
54
+ disallowScopes: |
55
+ release
56
+ [A-Z]+
57
+ ignoreLabels: |
58
+ ignore-lint-pr-title
@@ -0,0 +1,50 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ release:
14
+ name: release to npm
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: write
18
+ pull-requests: write
19
+ id-token: write
20
+ strategy:
21
+ matrix:
22
+ node-version: [24]
23
+ steps:
24
+ - name: Checkout Repo
25
+ uses: actions/checkout@v7
26
+
27
+ - name: Install pnpm
28
+ uses: pnpm/action-setup@v6
29
+
30
+ - name: Setup Node.js ${{ matrix.node-version }}
31
+ uses: actions/setup-node@v6
32
+ with:
33
+ node-version: ${{ matrix.node-version }}
34
+ registry-url: "https://registry.npmjs.org"
35
+ package-manager-cache: false
36
+
37
+ - name: Install Dependencies
38
+ run: pnpm install --frozen-lockfile
39
+
40
+ - name: Create Release Pull Request or Publish to npm
41
+ id: changesets
42
+ uses: changesets/action@v1
43
+ with:
44
+ version: pnpm run version-packages
45
+ publish: pnpm run publish-packages
46
+ commit: "chore: version packages"
47
+ title: "chore: version packages"
48
+ env:
49
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50
+ NPM_TOKEN: '' # # Use OIDC for npm authentication
package/AGENTS.md ADDED
@@ -0,0 +1,34 @@
1
+ # CDS Persistence Plugin for LangGraph Checkpoint & Memory
2
+
3
+ This project builds a CDS Plugin for SAP CAP applications to build LangGraph/LangChain/Deep Agents based applications with Checkpoint and Memory persistence. It provides necessary tooling as well as library support for agents built within SAP CAP applications.
4
+
5
+ ## Project Structure
6
+
7
+ /
8
+ ├── package.json
9
+ ├── AGENTS.md <- You are here
10
+ ├── src
11
+ │ ├── checkpoint
12
+ │ │ ├── cds-checkpointer.ts
13
+ │ │ └── index.ts
14
+ │ └── memory
15
+ │ ├── cds-memory.ts
16
+ │ └── index.ts
17
+ ├── tests
18
+ │ ├── checkpoint
19
+ │ └── memory
20
+ └── cds-plugin.js
21
+
22
+ ## Development
23
+
24
+ - Project uses **`pnpm`** as package manager. `npm install -g pnpm` if not already installed.
25
+ - `pnpm install` to install dependencies.
26
+
27
+ ## Testing
28
+
29
+ - Uses Vitest framework and follows `*.test.ts` naming convention.
30
+ - `pnpm test` to run tests
31
+
32
+ ## Contributing
33
+
34
+ Read [CONTRIBUTING.md](CONTRIBUTING.md)
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,33 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ ```sh
6
+ pnpm install
7
+ ```
8
+
9
+ ## Scripts
10
+
11
+ | Script | Description |
12
+ | ------------------- | -------------------------------- |
13
+ | `pnpm test` | Run the test suite |
14
+ | `pnpm lint:check` | Run all linters without writing |
15
+ | `pnpm lint` | Auto-fix lint issues |
16
+ | `pnpm format:check` | Check formatting without writing |
17
+ | `pnpm format` | Format source files |
18
+ | `pnpm changeset` | Wizard-based changeset creation |
19
+
20
+ ## Style Guides
21
+
22
+ - While raising a pull-request, ensure that
23
+ - PR title follows the [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) format.
24
+ - Build is passing - `pnpm build` before committing.
25
+ - All tests are passing - `pnpm test` before committing.
26
+ - Code is formatted - `pnpm format` and linted - `pnpm lint` before committing.
27
+ - Ensure the code is well documented
28
+ - Ensure the code is well tested and test coverage is not reduced.
29
+ - _Optionally_, a changeset `pnpm changeset` is created . If you are unsure, you can skip this step and the maintainers will create a changeset for you.
30
+
31
+ ## Behaviour
32
+
33
+ - The changeset bot will listen for pull requests being opened and pull requests that have been updated, upon which it will then scan through the files for a changeset that has been added. If not, it will comment on the pull request to remind the contributor to add a changeset.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mi8Y Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # CDS Plugin for LangGraph Checkpoint & Memory
package/cds-plugin.js ADDED
File without changes
@@ -0,0 +1,20 @@
1
+ // @ts-check
2
+
3
+ import js from "@eslint/js";
4
+ import { defineConfig } from "eslint/config";
5
+ import tseslint from "typescript-eslint";
6
+ import cds from "@sap/eslint-plugin-cds";
7
+
8
+ export default defineConfig({
9
+ files: ["src/**/*.{js,ts}", "**/*.cds"],
10
+ extends: [
11
+ js.configs.recommended,
12
+ tseslint.configs.recommended,
13
+ cds.configs.recommended,
14
+ {
15
+ rules: {
16
+ "@typescript-eslint/no-unused-expressions": "off", // cds uses expressions for 'column' in queries
17
+ },
18
+ },
19
+ ],
20
+ });
package/index.cds ADDED
@@ -0,0 +1,23 @@
1
+ namespace plugin.langgraph.persistence;
2
+
3
+ entity Checkpoints {
4
+ key id : String(256) not null;
5
+ key namespace : String(256) not null default '';
6
+ key threadId : String(256) not null;
7
+ parentId : String(256);
8
+ type : String(64);
9
+ checkpoint : LargeString not null;
10
+ metadata : LargeString;
11
+ createdAt : Timestamp default $now;
12
+ writes : Composition of many CheckpointWrites
13
+ on writes.checkpoint = $self;
14
+ }
15
+
16
+ entity CheckpointWrites {
17
+ key checkpoint : Association to Checkpoints;
18
+ key taskId : String(256) not null;
19
+ key idx : Integer not null;
20
+ channel : String(256) not null;
21
+ type : String(64);
22
+ value : LargeString;
23
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@mi8y/cds-langgraph-persistence",
3
+ "version": "0.0.1",
4
+ "description": "CDS Plugin for enabling persistence in LangGraph/LangChain/DeepAgents - Checkpoint & Memory",
5
+ "author": "Merbin J Anselm <merbin.j.anselm@mi8y.com>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mi8y/cds-langgraph-persistence.git"
9
+ },
10
+ "license": "MIT",
11
+ "homepage": "https://github.com/mi8y/cds-langgraph-persistence",
12
+ "bugs": {
13
+ "url": "https://github.com/mi8y/cds-langgraph-persistence/issues"
14
+ },
15
+ "main": "./dist/index.cjs",
16
+ "module": "./dist/index.mjs",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.mjs",
22
+ "require": "./dist/index.js"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@langchain/langgraph-checkpoint": "^1.1.3"
27
+ },
28
+ "devDependencies": {
29
+ "@cap-js/cds-typer": "^0.40.0",
30
+ "@cap-js/cds-types": "^0.18.0",
31
+ "@cap-js/sqlite": "^3.0.2",
32
+ "@changesets/cli": "^2.31.0",
33
+ "@eslint/js": "^10.0.1",
34
+ "@langchain/core": "^1.2.1",
35
+ "@langchain/langgraph-checkpoint-validation": "^1.1.0",
36
+ "@sap/eslint-plugin-cds": "^4.2.4",
37
+ "@tsconfig/recommended": "^1.0.13",
38
+ "@vitest/coverage-v8": "^4.1.10",
39
+ "eslint": "^10.6.0",
40
+ "prettier": "^3.9.4",
41
+ "shx": "^0.4.0",
42
+ "typescript": "^6.0.3",
43
+ "typescript-eslint": "^8.62.1",
44
+ "vite": "^8.1.3",
45
+ "vite-plugin-dts": "^5.0.3",
46
+ "vite-tsconfig-paths": "^6.1.1",
47
+ "vitest": "^4.1.10"
48
+ },
49
+ "peerDependencies": {
50
+ "@sap/cds": ">=8"
51
+ },
52
+ "scripts": {
53
+ "dev": "npm run build:cds-types && vite build --watch",
54
+ "build": "shx rm -rf dist && npm run build:cds-types && vite build",
55
+ "build:cds-types": "cds-typer index.cds",
56
+ "test": "vitest run --coverage",
57
+ "lint": "eslint . --fix",
58
+ "lint:check": "eslint . --max-warnings 0",
59
+ "format": "prettier --write \"**/*.{json,ts,js,mjs,cjs,md}\"",
60
+ "format:check": "prettier --check \"**/*.{json,ts,js,mjs,cjs,md}\"",
61
+ "changeset": "changeset",
62
+ "version-packages": "changeset version && pnpm install --no-frozen-lockfile && pnpm run format",
63
+ "publish-packages": "changeset publish",
64
+ "clean": "shx rm -rf node_modules dist"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }
@@ -0,0 +1,3 @@
1
+ allowBuilds:
2
+ tree-sitter: false
3
+ tree-sitter-java: false
@@ -0,0 +1,393 @@
1
+ import {
2
+ Checkpoints,
3
+ CheckpointWrite,
4
+ CheckpointWrites,
5
+ } from "#cds-models/plugin/langgraph/persistence";
6
+ import { type RunnableConfig } from "@langchain/core/runnables";
7
+ import {
8
+ BaseCheckpointSaver,
9
+ ChannelVersions,
10
+ Checkpoint,
11
+ CheckpointListOptions,
12
+ CheckpointMetadata,
13
+ CheckpointTuple,
14
+ copyCheckpoint,
15
+ maxChannelVersion,
16
+ PendingWrite,
17
+ SerializerProtocol,
18
+ TASKS,
19
+ WRITES_IDX_MAP,
20
+ } from "@langchain/langgraph-checkpoint";
21
+
22
+ export class CdsCheckpointSaver extends BaseCheckpointSaver {
23
+ constructor(serde?: SerializerProtocol) {
24
+ super(serde);
25
+ }
26
+
27
+ async getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined> {
28
+ if (!config.configurable) {
29
+ throw new Error(`Empty "config.configurable" supplied`);
30
+ }
31
+
32
+ const checkpointId: string | undefined = config.configurable.checkpoint_id;
33
+ const checkpointNamespace: string = config.configurable.checkpoint_ns ?? "";
34
+ const threadId: string | undefined = config.configurable.thread_id;
35
+ if (!threadId) {
36
+ return undefined;
37
+ }
38
+
39
+ let query = SELECT.one
40
+ .from(Checkpoints)
41
+ .columns((c) => {
42
+ (c.threadId,
43
+ c.namespace,
44
+ c.id,
45
+ c.parentId,
46
+ c.type,
47
+ c.checkpoint,
48
+ c.metadata,
49
+ c.writes((w) => {
50
+ (w.taskId, w.channel, w.type, w.value);
51
+ }));
52
+ })
53
+ .where({
54
+ threadId: threadId,
55
+ namespace: checkpointNamespace,
56
+ ...(checkpointId ? { id: checkpointId } : {}),
57
+ });
58
+
59
+ if (!checkpointId) {
60
+ query = query.orderBy("id desc").limit(1);
61
+ }
62
+
63
+ const resCheckpoint = await query;
64
+ if (!resCheckpoint) {
65
+ return undefined;
66
+ }
67
+
68
+ const checkpoint = (await this.serde.loadsTyped(
69
+ resCheckpoint.type ?? "json",
70
+ resCheckpoint.checkpoint ?? "",
71
+ )) as Checkpoint;
72
+
73
+ const metadata = (await this.serde.loadsTyped(
74
+ resCheckpoint.type ?? "json",
75
+ resCheckpoint.metadata ?? "",
76
+ )) as CheckpointMetadata;
77
+
78
+ const pendingWrites = await Promise.all(
79
+ (resCheckpoint.writes ?? []).map(async (w) => {
80
+ return [
81
+ w.taskId,
82
+ w.channel,
83
+ await this.serde.loadsTyped(w.type ?? "json", w.value ?? ""),
84
+ ] as [string, string, unknown];
85
+ }),
86
+ );
87
+
88
+ if (checkpoint.v < 4 && resCheckpoint.parentId) {
89
+ await this.migratePendingSends(
90
+ checkpoint,
91
+ resCheckpoint.threadId!,
92
+ resCheckpoint.parentId,
93
+ );
94
+ }
95
+
96
+ return {
97
+ checkpoint: checkpoint,
98
+ config: {
99
+ configurable: {
100
+ thread_id: resCheckpoint.threadId,
101
+ checkpoint_ns: resCheckpoint.namespace,
102
+ checkpoint_id: resCheckpoint.id,
103
+ },
104
+ },
105
+ parentConfig: resCheckpoint.parentId
106
+ ? {
107
+ configurable: {
108
+ thread_id: resCheckpoint.threadId,
109
+ checkpoint_ns: resCheckpoint.namespace,
110
+ checkpoint_id: resCheckpoint.parentId,
111
+ },
112
+ }
113
+ : undefined,
114
+ metadata: metadata,
115
+ pendingWrites: pendingWrites,
116
+ };
117
+ }
118
+
119
+ async *list(
120
+ config: RunnableConfig,
121
+ options?: CheckpointListOptions,
122
+ ): AsyncGenerator<CheckpointTuple> {
123
+ const { limit, before, filter } = options ?? {};
124
+
125
+ const threadId: string | undefined = config.configurable?.thread_id;
126
+ const checkpointNamespace: string | undefined =
127
+ config.configurable?.checkpoint_ns;
128
+
129
+ let query = SELECT.from(Checkpoints)
130
+ .columns((c) => {
131
+ (c.threadId,
132
+ c.namespace,
133
+ c.id,
134
+ c.parentId,
135
+ c.type,
136
+ c.checkpoint,
137
+ c.metadata,
138
+ c.writes((w) => {
139
+ (w.taskId, w.channel, w.type, w.value);
140
+ }));
141
+ })
142
+ .orderBy("id desc");
143
+
144
+ if (threadId !== undefined) {
145
+ query = query.where({ threadId: threadId });
146
+ }
147
+
148
+ if (checkpointNamespace !== undefined && checkpointNamespace !== null) {
149
+ query = query.where({ namespace: checkpointNamespace });
150
+ }
151
+
152
+ if (before?.configurable?.checkpoint_id !== undefined) {
153
+ query = query.where({ id: { "<": before.configurable.checkpoint_id } });
154
+ }
155
+
156
+ // CAP CDS does not support native JSON operations - so we filter them in memory after fetching the results
157
+ const sanitizedFilter = Object.fromEntries(
158
+ Object.entries(filter ?? {}).filter(([, value]) => value !== undefined),
159
+ );
160
+ const hasFilter = Object.keys(sanitizedFilter).length > 0;
161
+
162
+ if (limit !== undefined && !hasFilter) {
163
+ query = query.limit(limit);
164
+ }
165
+
166
+ const resCheckpoints = await query;
167
+ if (!resCheckpoints) {
168
+ return;
169
+ }
170
+
171
+ let yielded = 0;
172
+ for (const checkpointState of resCheckpoints) {
173
+ if (limit !== undefined && yielded >= limit) {
174
+ break;
175
+ }
176
+
177
+ const deserializedMetadata = (await this.serde.loadsTyped(
178
+ checkpointState.type ?? "json",
179
+ checkpointState.metadata ?? "",
180
+ )) as CheckpointMetadata & Record<string, unknown>;
181
+
182
+ if (hasFilter) {
183
+ // since not filtered in the query, we filter in memory
184
+ const matchesFilter = Object.entries(sanitizedFilter).every(
185
+ ([key, value]) => deserializedMetadata[key] === value,
186
+ );
187
+ if (!matchesFilter) {
188
+ continue;
189
+ }
190
+ }
191
+
192
+ const checkpoint = (await this.serde.loadsTyped(
193
+ checkpointState.type ?? "json",
194
+ checkpointState.checkpoint ?? "",
195
+ )) as Checkpoint;
196
+
197
+ const pendingWrites = await Promise.all(
198
+ (checkpointState.writes ?? []).map(async (w) => {
199
+ return [
200
+ w.taskId,
201
+ w.channel,
202
+ await this.serde.loadsTyped(w.type ?? "json", w.value ?? ""),
203
+ ] as [string, string, unknown];
204
+ }),
205
+ );
206
+
207
+ if (checkpoint.v < 4 && checkpointState.parentId) {
208
+ await this.migratePendingSends(
209
+ checkpoint,
210
+ checkpointState.threadId!,
211
+ checkpointState.parentId,
212
+ );
213
+ }
214
+
215
+ yield {
216
+ config: {
217
+ configurable: {
218
+ thread_id: checkpointState.threadId,
219
+ checkpoint_ns: checkpointState.namespace,
220
+ checkpoint_id: checkpointState.id,
221
+ },
222
+ },
223
+ checkpoint: checkpoint,
224
+ parentConfig: checkpointState.parentId
225
+ ? {
226
+ configurable: {
227
+ thread_id: checkpointState.threadId,
228
+ checkpoint_ns: checkpointState.namespace,
229
+ checkpoint_id: checkpointState.parentId,
230
+ },
231
+ }
232
+ : undefined,
233
+ metadata: deserializedMetadata,
234
+ pendingWrites: pendingWrites,
235
+ };
236
+
237
+ yielded++;
238
+ }
239
+ }
240
+
241
+ async put(
242
+ config: RunnableConfig,
243
+ checkpoint: Checkpoint,
244
+ metadata: CheckpointMetadata,
245
+ newVersions: ChannelVersions,
246
+ ): Promise<RunnableConfig> {
247
+ if (!config.configurable) {
248
+ throw new Error(`Empty "config.configurable" supplied`);
249
+ }
250
+
251
+ const checkpointNamespace: string = config.configurable.checkpoint_ns ?? "";
252
+ const parentCheckpointId: string | undefined =
253
+ config.configurable.checkpoint_id;
254
+ const threadId: string | undefined = config.configurable.thread_id;
255
+ if (!threadId) {
256
+ throw new Error(
257
+ `Missing "thread_id" field in passed in "config.configurable"`,
258
+ );
259
+ }
260
+
261
+ const newChannelValues = Object.fromEntries(
262
+ Object.entries(checkpoint.channel_values).filter(
263
+ ([key]) => key in newVersions,
264
+ ),
265
+ );
266
+ const newChannelVersions = Object.fromEntries(
267
+ Object.entries(checkpoint.channel_versions).filter(
268
+ ([key]) => key in newVersions,
269
+ ),
270
+ );
271
+
272
+ const filteredCheckpoint = {
273
+ ...checkpoint,
274
+ channel_values: newChannelValues,
275
+ channel_versions: newChannelVersions,
276
+ };
277
+
278
+ const preparedCheckpoint: Partial<Checkpoint> =
279
+ copyCheckpoint(filteredCheckpoint);
280
+
281
+ const [[type1, serializedCheckpoint], [type2, serializedMetadata]] =
282
+ await Promise.all([
283
+ this.serde.dumpsTyped(preparedCheckpoint),
284
+ this.serde.dumpsTyped(metadata),
285
+ ]);
286
+ if (type1 !== type2) {
287
+ throw new Error(
288
+ "Failed to serialized checkpoint and metadata to the same type.",
289
+ );
290
+ }
291
+
292
+ const valueDecoder = new TextDecoder("utf-8");
293
+ await UPSERT.into(Checkpoints).entries({
294
+ id: checkpoint.id,
295
+ namespace: checkpointNamespace,
296
+ threadId: threadId,
297
+ parentId: parentCheckpointId,
298
+ type: type1,
299
+ checkpoint: valueDecoder.decode(serializedCheckpoint),
300
+ metadata: valueDecoder.decode(serializedMetadata),
301
+ });
302
+
303
+ return {
304
+ configurable: {
305
+ thread_id: threadId,
306
+ checkpoint_ns: checkpointNamespace,
307
+ checkpoint_id: checkpoint.id,
308
+ },
309
+ };
310
+ }
311
+
312
+ async putWrites(
313
+ config: RunnableConfig,
314
+ writes: PendingWrite[],
315
+ taskId: string,
316
+ ): Promise<void> {
317
+ if (!config.configurable) {
318
+ throw new Error(`Empty "config.configurable" supplied`);
319
+ }
320
+
321
+ const checkpointNamespace: string = config.configurable.checkpoint_ns ?? "";
322
+ const checkpointId: string | undefined = config.configurable.checkpoint_id;
323
+ if (!checkpointId) {
324
+ throw new Error(
325
+ `Missing "checkpoint_id" field in passed in "config.configurable"`,
326
+ );
327
+ }
328
+ const threadId: string | undefined = config.configurable.thread_id;
329
+ if (!threadId) {
330
+ throw new Error(
331
+ `Missing "thread_id" field in passed in "config.configurable"`,
332
+ );
333
+ }
334
+
335
+ const pendingWrites: CheckpointWrite[] = await Promise.all(
336
+ writes.map(async (write, idx) => {
337
+ const [type, serializedValue] = await this.serde.dumpsTyped(write[1]);
338
+ const valueDecoder = new TextDecoder("utf-8");
339
+ return {
340
+ threadId: threadId,
341
+ checkpoint_id: checkpointId,
342
+ checkpoint_namespace: checkpointNamespace,
343
+ checkpoint_threadId: threadId,
344
+ taskId: taskId,
345
+ // Special channels are stored at fixed negative indices so they
346
+ // never collide with regular per-step writes (whose `idx` is the
347
+ // ordinal within `writes`).
348
+ idx: WRITES_IDX_MAP[write[0]] ?? idx,
349
+ channel: write[0],
350
+ type: type,
351
+ value: valueDecoder.decode(serializedValue),
352
+ };
353
+ }),
354
+ );
355
+
356
+ await UPSERT.into(CheckpointWrites).entries(pendingWrites);
357
+ }
358
+
359
+ async deleteThread(threadId: string): Promise<void> {
360
+ await DELETE.from(CheckpointWrites).where({
361
+ checkpoint_threadId: threadId,
362
+ });
363
+ await DELETE.from(Checkpoints).where({ threadId: threadId });
364
+ }
365
+
366
+ protected async migratePendingSends(
367
+ checkpoint: Checkpoint,
368
+ threadId: string,
369
+ parentId: string,
370
+ ) {
371
+ const parentWrites = await SELECT.from(CheckpointWrites)
372
+ .where({
373
+ checkpoint_threadId: threadId,
374
+ checkpoint_id: parentId,
375
+ channel: TASKS,
376
+ })
377
+ .orderBy("taskId", "idx");
378
+
379
+ if (parentWrites === undefined || parentWrites.length === 0) {
380
+ return;
381
+ }
382
+
383
+ checkpoint.channel_values[TASKS] = await Promise.all(
384
+ parentWrites.map((w) =>
385
+ this.serde.loadsTyped(w.type ?? "json", w.value ?? ""),
386
+ ),
387
+ );
388
+ checkpoint.channel_versions[TASKS] =
389
+ Object.keys(checkpoint.channel_versions).length > 0
390
+ ? maxChannelVersion(...Object.values(checkpoint.channel_versions))
391
+ : this.getNextVersion(undefined);
392
+ }
393
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { CdsCheckpointSaver } from "@/checkpoint/cds-checkpointer";
@@ -0,0 +1,47 @@
1
+ import {
2
+ Checkpoints,
3
+ CheckpointWrites,
4
+ } from "#cds-models/plugin/langgraph/persistence";
5
+ import { CdsCheckpointSaver } from "@/index";
6
+ import {
7
+ CheckpointSaverTestInitializer,
8
+ validate,
9
+ } from "@langchain/langgraph-checkpoint-validation";
10
+ import cds from "@sap/cds";
11
+
12
+ export const cdsCheckpointSaverTestInitializer: CheckpointSaverTestInitializer<CdsCheckpointSaver> =
13
+ {
14
+ checkpointerName: "@langchain/langgraph-checkpoint-cds",
15
+
16
+ async beforeAll() {
17
+ const csn = await cds.load("index.cds").then(cds.minify);
18
+ cds.model = cds.compile.for.nodejs(csn);
19
+
20
+ cds.requires.db = {
21
+ kind: "sqlite",
22
+ impl: "@cap-js/sqlite",
23
+ credentials: { url: ":memory:" },
24
+ };
25
+
26
+ cds.db = await cds.connect.to("db");
27
+
28
+ // @ts-ignore
29
+ await cds.deploy("index.cds", {}).to(cds.db);
30
+ },
31
+
32
+ async afterAll() {
33
+ // @ts-ignore
34
+ await cds.db.disconnect?.();
35
+ },
36
+
37
+ async createCheckpointer() {
38
+ return new CdsCheckpointSaver();
39
+ },
40
+
41
+ async destroyCheckpointer() {
42
+ await DELETE.from(CheckpointWrites);
43
+ await DELETE.from(Checkpoints);
44
+ },
45
+ };
46
+
47
+ validate(cdsCheckpointSaverTestInitializer);
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "@tsconfig/recommended",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "paths": {
6
+ "@/*": ["./src/*"],
7
+ "@sap/cds": ["./node_modules/@cap-js/cds-types"],
8
+ "#cds-models/*": ["./@cds-models/*"]
9
+ }
10
+ },
11
+ "include": ["src/**/*", "tests/**/*"],
12
+ "exclude": ["node_modules", "dist", "@cds-models"]
13
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,50 @@
1
+ /// <reference types="vitest/config" />
2
+ import { defineConfig } from "vite";
3
+ import dts from "vite-plugin-dts";
4
+ import tsconfigPaths from "vite-tsconfig-paths";
5
+ import { builtinModules } from "module";
6
+ import pkg from "./package.json";
7
+
8
+ export default defineConfig({
9
+ plugins: [
10
+ tsconfigPaths(),
11
+ dts({
12
+ insertTypesEntry: true, // Automatically links types in package.json
13
+ include: ["src"],
14
+ }),
15
+ ],
16
+
17
+ // Build Configuration
18
+ build: {
19
+ minify: false, // disable since this is a node library
20
+ lib: {
21
+ entry: "src/index.ts",
22
+ name: "@mi8y/cds-langgraph-persistence",
23
+ fileName: "index",
24
+ formats: ["es", "cjs"],
25
+ },
26
+ sourcemap: true,
27
+ rolldownOptions: {
28
+ external: [
29
+ // ignore dependencies when bundling
30
+ ...builtinModules,
31
+ ...builtinModules.map((m) => `node:${m}`),
32
+ ...Object.keys(pkg.dependencies || {}),
33
+ ...Object.keys(pkg.peerDependencies || {}),
34
+ ],
35
+ },
36
+ },
37
+
38
+ // Testing Configuration (Vitest)
39
+ test: {
40
+ globals: true,
41
+ environment: "node",
42
+ include: ["tests/**/*.ts"],
43
+ coverage: {
44
+ provider: "v8",
45
+ exclude: ["@cds-models/"],
46
+ reporter: ["cobertura"],
47
+ reportsDirectory: "coverage",
48
+ },
49
+ },
50
+ });