Skip to content

Commit d75b736

Browse files
committed
test(storage): lock in SqlStorageAdapter cache-token migration
Five tests over an in-memory SQLite adapter covering the migration landed in the previous commit: - Round-trip: storeMessage writes cacheReadTokens + cacheCreationTokens via the new INSERT columns; getMessage hydrates them back onto message.usage. - Sentinel: a message without cache tokens leaves the fields undefined on the round-trip (preserves "not reported" vs "zero hits"). - Aggregation: getConversationTokenUsage SUMs cache tokens across multiple messages alongside prompt/completion/total. - Sentinel-aggregate: when no message reported cache, the aggregate leaves cache fields undefined. - Idempotent migration: closing and re-opening an in-memory DB exercises the ALTER TABLE branch that swallows the duplicate-column error; the re-opened adapter still persists + reads cache tokens. Matches the SqlStorageMemoryArchive test pattern so CI stays filesystem-free.
1 parent 560c38d commit d75b736

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* @fileoverview End-to-end tests for SqlStorageAdapter's cache-token
3+
* persistence path, covering the 2026-04-18 migration that added
4+
* cacheReadTokens + cacheCreationTokens columns, the idempotent ALTER
5+
* TABLE back-compat migration, the INSERT/SELECT round-trip, and the
6+
* getConversationTokenUsage SUM aggregation.
7+
*
8+
* Uses an in-memory SQLite adapter via @framers/sql-storage-adapter,
9+
* matching the SqlStorageMemoryArchive test pattern so CI stays
10+
* filesystem-free.
11+
*/
12+
13+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14+
import { SqlStorageAdapter } from '../SqlStorageAdapter.js';
15+
16+
async function freshAdapter(): Promise<SqlStorageAdapter> {
17+
const adapter = new SqlStorageAdapter({
18+
filePath: ':memory:',
19+
priority: ['better-sqlite3', 'sqljs'],
20+
quiet: true,
21+
});
22+
await adapter.initialize();
23+
return adapter;
24+
}
25+
26+
describe('SqlStorageAdapter — cache-token persistence', () => {
27+
let adapter: SqlStorageAdapter;
28+
29+
beforeEach(async () => {
30+
adapter = await freshAdapter();
31+
await adapter.createConversation({
32+
id: 'conv-1',
33+
userId: 'user-1',
34+
createdAt: 1,
35+
lastActivity: 1,
36+
});
37+
});
38+
39+
afterEach(async () => {
40+
await adapter.close?.();
41+
});
42+
43+
it('persists cacheReadTokens + cacheCreationTokens on saveMessage and hydrates them back', async () => {
44+
await adapter.storeMessage({
45+
id: 'msg-1',
46+
conversationId: 'conv-1',
47+
role: 'assistant',
48+
content: 'hello',
49+
timestamp: 10,
50+
model: 'claude-sonnet-4-6',
51+
usage: {
52+
promptTokens: 100,
53+
completionTokens: 20,
54+
totalTokens: 120,
55+
cacheReadTokens: 80,
56+
cacheCreationTokens: 15,
57+
},
58+
});
59+
60+
const msg = await adapter.getMessage('msg-1');
61+
expect(msg).toBeTruthy();
62+
expect(msg!.usage).toEqual({
63+
promptTokens: 100,
64+
completionTokens: 20,
65+
totalTokens: 120,
66+
cacheReadTokens: 80,
67+
cacheCreationTokens: 15,
68+
});
69+
});
70+
71+
it('leaves cache fields undefined when the message did not report them', async () => {
72+
await adapter.storeMessage({
73+
id: 'msg-2',
74+
conversationId: 'conv-1',
75+
role: 'assistant',
76+
content: 'hi',
77+
timestamp: 11,
78+
usage: {
79+
promptTokens: 50,
80+
completionTokens: 10,
81+
totalTokens: 60,
82+
},
83+
});
84+
85+
const msg = await adapter.getMessage('msg-2');
86+
expect(msg!.usage?.cacheReadTokens).toBeUndefined();
87+
expect(msg!.usage?.cacheCreationTokens).toBeUndefined();
88+
});
89+
90+
it('SUMs cache tokens across a conversation in getConversationTokenUsage', async () => {
91+
await adapter.storeMessage({
92+
id: 'msg-3',
93+
conversationId: 'conv-1',
94+
role: 'assistant',
95+
content: 'a',
96+
timestamp: 20,
97+
usage: {
98+
promptTokens: 200,
99+
completionTokens: 30,
100+
totalTokens: 230,
101+
cacheReadTokens: 150,
102+
cacheCreationTokens: 40,
103+
},
104+
});
105+
await adapter.storeMessage({
106+
id: 'msg-4',
107+
conversationId: 'conv-1',
108+
role: 'assistant',
109+
content: 'b',
110+
timestamp: 21,
111+
usage: {
112+
promptTokens: 100,
113+
completionTokens: 10,
114+
totalTokens: 110,
115+
cacheReadTokens: 90,
116+
},
117+
});
118+
119+
const agg = await adapter.getConversationTokenUsage('conv-1');
120+
expect(agg.promptTokens).toBe(300);
121+
expect(agg.completionTokens).toBe(40);
122+
expect(agg.totalTokens).toBe(340);
123+
expect(agg.cacheReadTokens).toBe(240);
124+
expect(agg.cacheCreationTokens).toBe(40);
125+
});
126+
127+
it('keeps aggregate cache fields undefined when no message reported cache', async () => {
128+
await adapter.storeMessage({
129+
id: 'msg-5',
130+
conversationId: 'conv-1',
131+
role: 'assistant',
132+
content: 'opencache-silent',
133+
timestamp: 30,
134+
usage: { promptTokens: 40, completionTokens: 5, totalTokens: 45 },
135+
});
136+
137+
const agg = await adapter.getConversationTokenUsage('conv-1');
138+
expect(agg.promptTokens).toBe(40);
139+
expect(agg.cacheReadTokens).toBeUndefined();
140+
expect(agg.cacheCreationTokens).toBeUndefined();
141+
});
142+
143+
it('tolerates re-initialize() without losing the migration (idempotent ALTER TABLE)', async () => {
144+
// First saveMessage runs the migration path on a fresh DB.
145+
await adapter.storeMessage({
146+
id: 'msg-6',
147+
conversationId: 'conv-1',
148+
role: 'assistant',
149+
content: 'first',
150+
timestamp: 40,
151+
usage: {
152+
promptTokens: 10,
153+
completionTokens: 2,
154+
totalTokens: 12,
155+
cacheReadTokens: 5,
156+
},
157+
});
158+
159+
// Close + re-open the SAME adapter config; the second initialize
160+
// hits the ALTER TABLE branch expecting duplicate-column and
161+
// catching silently.
162+
await adapter.close?.();
163+
adapter = await freshAdapter();
164+
await adapter.createConversation({
165+
id: 'conv-1',
166+
userId: 'user-1',
167+
createdAt: 1,
168+
lastActivity: 1,
169+
});
170+
await adapter.storeMessage({
171+
id: 'msg-7',
172+
conversationId: 'conv-1',
173+
role: 'assistant',
174+
content: 'second',
175+
timestamp: 41,
176+
usage: {
177+
promptTokens: 20,
178+
completionTokens: 4,
179+
totalTokens: 24,
180+
cacheReadTokens: 12,
181+
},
182+
});
183+
184+
const msg = await adapter.getMessage('msg-7');
185+
expect(msg!.usage?.cacheReadTokens).toBe(12);
186+
});
187+
});

0 commit comments

Comments
 (0)