@ixo/sqlite-saver 1.0.4
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.
- package/.eslintrc.js +9 -0
- package/.prettierignore +3 -0
- package/.prettierrc.js +4 -0
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +25 -0
- package/README.md +0 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/001_add_created_at_to_messages.d.ts +8 -0
- package/dist/migrations/001_add_created_at_to_messages.d.ts.map +1 -0
- package/dist/migrations/001_add_created_at_to_messages.js +32 -0
- package/dist/migrations/001_add_created_at_to_messages.js.map +1 -0
- package/dist/tests/agent-with-checkpoiner.test.d.ts +2 -0
- package/dist/tests/agent-with-checkpoiner.test.d.ts.map +1 -0
- package/dist/tests/agent-with-checkpoiner.test.js +206 -0
- package/dist/tests/agent-with-checkpoiner.test.js.map +1 -0
- package/dist/tests/checkpointer.test.d.ts +2 -0
- package/dist/tests/checkpointer.test.d.ts.map +1 -0
- package/dist/tests/checkpointer.test.js +426 -0
- package/dist/tests/checkpointer.test.js.map +1 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +284 -0
- package/dist/utils.js.map +1 -0
- package/jest.config.js +6 -0
- package/package.json +41 -0
- package/src/index.ts +929 -0
- package/src/migrations/001_add_created_at_to_messages.ts +48 -0
- package/src/tests/agent-with-checkpoiner.test.ts +264 -0
- package/src/tests/checkpointer.test.ts +628 -0
- package/src/utils.ts +358 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Database as DatabaseType } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration 001: Add created_at column to messages table
|
|
5
|
+
*
|
|
6
|
+
* This migration adds the created_at column to the messages table
|
|
7
|
+
* if it doesn't already exist. It also creates the index that depends on it.
|
|
8
|
+
*/
|
|
9
|
+
export default {
|
|
10
|
+
version: 1,
|
|
11
|
+
name: 'add_created_at_to_messages',
|
|
12
|
+
up: (db: DatabaseType) => {
|
|
13
|
+
// Check if column exists using PRAGMA
|
|
14
|
+
const columns = db
|
|
15
|
+
.prepare(`SELECT name FROM pragma_table_info('messages') WHERE name = ?`)
|
|
16
|
+
.all('created_at') as Array<{ name: string }>;
|
|
17
|
+
|
|
18
|
+
if (columns.length === 0) {
|
|
19
|
+
// Column doesn't exist, add it
|
|
20
|
+
db.exec(`
|
|
21
|
+
ALTER TABLE messages
|
|
22
|
+
ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
23
|
+
`);
|
|
24
|
+
|
|
25
|
+
// Update any existing rows that might have NULL (safety measure)
|
|
26
|
+
db.exec(`
|
|
27
|
+
UPDATE messages
|
|
28
|
+
SET created_at = CURRENT_TIMESTAMP
|
|
29
|
+
WHERE created_at IS NULL;
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Create index if it doesn't exist
|
|
34
|
+
const indexExists = db
|
|
35
|
+
.prepare(
|
|
36
|
+
`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_messages_thread_created'`,
|
|
37
|
+
)
|
|
38
|
+
.get() as { name: string } | undefined;
|
|
39
|
+
|
|
40
|
+
if (!indexExists) {
|
|
41
|
+
db.exec(`
|
|
42
|
+
CREATE INDEX idx_messages_thread_created
|
|
43
|
+
ON messages(thread_id, created_at);
|
|
44
|
+
`);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseLanguageModelCallOptions,
|
|
3
|
+
BaseLanguageModelInput,
|
|
4
|
+
} from '@langchain/core/language_models/base';
|
|
5
|
+
import {
|
|
6
|
+
BaseChatModel,
|
|
7
|
+
BaseChatModelCallOptions,
|
|
8
|
+
BindToolsInput,
|
|
9
|
+
} from '@langchain/core/language_models/chat_models';
|
|
10
|
+
import {
|
|
11
|
+
AIMessageChunk,
|
|
12
|
+
BaseMessage,
|
|
13
|
+
HumanMessage,
|
|
14
|
+
MessageStructure,
|
|
15
|
+
} from '@langchain/core/messages';
|
|
16
|
+
import { ChatGeneration, ChatResult } from '@langchain/core/outputs';
|
|
17
|
+
import Database from 'better-sqlite3';
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import { createAgent } from 'langchain';
|
|
20
|
+
import { SqliteSaver } from '../index';
|
|
21
|
+
|
|
22
|
+
import { Runnable, RunnableLambda } from '@langchain/core/runnables';
|
|
23
|
+
import { MessagesZodMeta } from '@langchain/langgraph';
|
|
24
|
+
import { registry } from '@langchain/langgraph/zod';
|
|
25
|
+
import { z } from 'zod/v4-mini';
|
|
26
|
+
|
|
27
|
+
const State = z.object({
|
|
28
|
+
messages: z
|
|
29
|
+
.array(z.custom<BaseMessage>())
|
|
30
|
+
.register(registry, MessagesZodMeta),
|
|
31
|
+
extraField: z.number(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Mock Chat Model for testing without API calls
|
|
35
|
+
class MockChatModel extends BaseChatModel {
|
|
36
|
+
responses: string[];
|
|
37
|
+
callCount: number = 0;
|
|
38
|
+
|
|
39
|
+
constructor(responses: string[] = []) {
|
|
40
|
+
super({ cache: undefined });
|
|
41
|
+
this.responses = responses;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async _generate(
|
|
45
|
+
_messages: any[],
|
|
46
|
+
_options?: BaseLanguageModelCallOptions | undefined,
|
|
47
|
+
): Promise<ChatResult> {
|
|
48
|
+
const response = this.responses[this.callCount % this.responses.length];
|
|
49
|
+
this.callCount++;
|
|
50
|
+
|
|
51
|
+
const message = new AIMessageChunk(response ?? '');
|
|
52
|
+
const generation: ChatGeneration = {
|
|
53
|
+
message,
|
|
54
|
+
text: response ?? '',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
generations: [generation],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_llmType(): string {
|
|
63
|
+
return 'mock';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
override async invoke(_input: any, _config?: any): Promise<AIMessageChunk> {
|
|
67
|
+
const response = this.responses[this.callCount % this.responses.length];
|
|
68
|
+
this.callCount++;
|
|
69
|
+
return new AIMessageChunk(response ?? '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override bindTools(
|
|
73
|
+
_tools: BindToolsInput[],
|
|
74
|
+
_kwargs?: Partial<BaseChatModelCallOptions> | undefined,
|
|
75
|
+
): Runnable<
|
|
76
|
+
BaseLanguageModelInput,
|
|
77
|
+
AIMessageChunk<MessageStructure>,
|
|
78
|
+
BaseChatModelCallOptions
|
|
79
|
+
> {
|
|
80
|
+
return new RunnableLambda({
|
|
81
|
+
func: async (input: BaseLanguageModelInput) => {
|
|
82
|
+
return new AIMessageChunk(input as string);
|
|
83
|
+
},
|
|
84
|
+
}) as Runnable<
|
|
85
|
+
BaseLanguageModelInput,
|
|
86
|
+
AIMessageChunk<MessageStructure>,
|
|
87
|
+
BaseChatModelCallOptions
|
|
88
|
+
>;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('createAgent with SQLiteSaver Integration Tests', () => {
|
|
93
|
+
let dbPath: string;
|
|
94
|
+
let db: Database.Database;
|
|
95
|
+
let checkpointer: SqliteSaver;
|
|
96
|
+
let mockLlm: MockChatModel;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
// Create temporary database
|
|
100
|
+
dbPath = `./test_checkpoint_${Date.now()}.db`;
|
|
101
|
+
db = new Database(dbPath);
|
|
102
|
+
checkpointer = new SqliteSaver(db);
|
|
103
|
+
|
|
104
|
+
// Mock LLM with predefined responses
|
|
105
|
+
mockLlm = new MockChatModel([
|
|
106
|
+
"I'll check the weather for you.",
|
|
107
|
+
'The weather is sunny!',
|
|
108
|
+
'Is there anything else?',
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
// Cleanup
|
|
114
|
+
db.close();
|
|
115
|
+
if (fs.existsSync(dbPath)) {
|
|
116
|
+
fs.unlinkSync(dbPath);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should persist agent state across multiple invocations', async () => {
|
|
121
|
+
const agent = createAgent({
|
|
122
|
+
model: mockLlm,
|
|
123
|
+
tools: [],
|
|
124
|
+
checkpointer,
|
|
125
|
+
stateSchema: State,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const threadId = 'test_thread_1';
|
|
129
|
+
const config = { configurable: { thread_id: threadId } };
|
|
130
|
+
|
|
131
|
+
// First invocation
|
|
132
|
+
const result1 = await agent.invoke(
|
|
133
|
+
{
|
|
134
|
+
messages: [new HumanMessage("What's the weather in NYC?")],
|
|
135
|
+
extraField: 1,
|
|
136
|
+
},
|
|
137
|
+
config,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result1).toBeDefined();
|
|
141
|
+
expect(result1.messages).toBeDefined();
|
|
142
|
+
expect(result1.messages.length).toBeGreaterThan(0);
|
|
143
|
+
expect(result1.extraField).toBe(1);
|
|
144
|
+
|
|
145
|
+
// Get initial state
|
|
146
|
+
const state1 = await agent.graph.getState(config);
|
|
147
|
+
|
|
148
|
+
expect(state1).toBeDefined();
|
|
149
|
+
expect(state1.values.messages).toBeDefined();
|
|
150
|
+
const initialMessageCount = state1.values.messages.length;
|
|
151
|
+
|
|
152
|
+
// Second invocation with same thread_id
|
|
153
|
+
const result2 = await agent.invoke(
|
|
154
|
+
{ messages: [new HumanMessage('And in San Francisco?')], extraField: 2 },
|
|
155
|
+
config,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(result2).toBeDefined();
|
|
159
|
+
|
|
160
|
+
// Verify state was persisted (should have more messages)
|
|
161
|
+
const state2 = await agent.graph.getState(config);
|
|
162
|
+
if (!state2) {
|
|
163
|
+
throw new Error('State is undefined');
|
|
164
|
+
}
|
|
165
|
+
expect(state2.values.messages.length).toBeGreaterThan(initialMessageCount);
|
|
166
|
+
expect(state2.values.extraField).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should maintain separate state for different thread IDs', async () => {
|
|
170
|
+
const agent = createAgent({
|
|
171
|
+
model: mockLlm,
|
|
172
|
+
tools: [],
|
|
173
|
+
checkpointer,
|
|
174
|
+
stateSchema: State,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// First thread
|
|
178
|
+
const config1 = { configurable: { thread_id: 'thread_1' } };
|
|
179
|
+
await agent.invoke(
|
|
180
|
+
{ messages: [new HumanMessage('NYC weather?')], extraField: 77 },
|
|
181
|
+
config1,
|
|
182
|
+
);
|
|
183
|
+
const state1 = await agent.graph.getState(config1);
|
|
184
|
+
if (!state1) {
|
|
185
|
+
throw new Error('State is undefined');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Second thread
|
|
189
|
+
const config2 = { configurable: { thread_id: 'thread_2' } };
|
|
190
|
+
await agent.invoke(
|
|
191
|
+
{ messages: [new HumanMessage('London weather?')], extraField: 88 },
|
|
192
|
+
config2,
|
|
193
|
+
);
|
|
194
|
+
const state2 = await agent.graph.getState(config2);
|
|
195
|
+
if (!state2) {
|
|
196
|
+
throw new Error('State is undefined');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Both threads should exist independently
|
|
200
|
+
expect(state1).toBeDefined();
|
|
201
|
+
expect(state2).toBeDefined();
|
|
202
|
+
expect(state1.values.messages.length).toBeGreaterThanOrEqual(1);
|
|
203
|
+
expect(state2.values.messages.length).toBeGreaterThanOrEqual(1);
|
|
204
|
+
expect(state1.values.extraField).toBe(77);
|
|
205
|
+
expect(state2.values.extraField).toBe(88);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should persist data across agent instance recreation', async () => {
|
|
209
|
+
const threadId = 'persistence_test';
|
|
210
|
+
const config = { configurable: { thread_id: threadId } };
|
|
211
|
+
|
|
212
|
+
// First agent instance
|
|
213
|
+
let agent1 = createAgent({
|
|
214
|
+
model: mockLlm,
|
|
215
|
+
tools: [],
|
|
216
|
+
stateSchema: State,
|
|
217
|
+
checkpointer,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await agent1.invoke(
|
|
221
|
+
{ messages: [new HumanMessage('First conversation')], extraField: 113 },
|
|
222
|
+
config,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const state1 = await agent1.graph.getState(config);
|
|
226
|
+
if (!state1) {
|
|
227
|
+
throw new Error('State is undefined');
|
|
228
|
+
}
|
|
229
|
+
const msgCount1 = state1.values.messages.length;
|
|
230
|
+
|
|
231
|
+
// Second agent instance (simulating restart)
|
|
232
|
+
const mockLlm2 = new MockChatModel([
|
|
233
|
+
"I'll help you.",
|
|
234
|
+
'Sure thing!',
|
|
235
|
+
'Done!',
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
agent1 = createAgent({
|
|
239
|
+
model: mockLlm2,
|
|
240
|
+
tools: [],
|
|
241
|
+
stateSchema: State,
|
|
242
|
+
checkpointer,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// New agent should recover previous state
|
|
246
|
+
const state2 = await agent1.graph.getState(config);
|
|
247
|
+
if (!state2) {
|
|
248
|
+
throw new Error('State is undefined');
|
|
249
|
+
}
|
|
250
|
+
expect(state2.values.messages.length).toBe(msgCount1);
|
|
251
|
+
|
|
252
|
+
// Continue conversation
|
|
253
|
+
await agent1.invoke(
|
|
254
|
+
{ messages: [new HumanMessage('Second conversation')], extraField: 114 },
|
|
255
|
+
config,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const state3 = await agent1.graph.getState(config);
|
|
259
|
+
if (!state3) {
|
|
260
|
+
throw new Error('State is undefined');
|
|
261
|
+
}
|
|
262
|
+
expect(state3.values.messages.length).toBeGreaterThan(msgCount1);
|
|
263
|
+
});
|
|
264
|
+
});
|