@qwickapps/qwickbrain-proxy 1.0.2 → 1.0.3
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/.claude/engineering/bugs/BUG-qwickbrain-proxy-cache-and-design.md +840 -0
- package/.github/workflows/publish.yml +13 -0
- package/CHANGELOG.md +17 -0
- package/dist/db/schema.d.ts +63 -6
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +17 -2
- package/dist/db/schema.js.map +1 -1
- package/dist/lib/__tests__/cache-manager.test.js +146 -83
- package/dist/lib/__tests__/cache-manager.test.js.map +1 -1
- package/dist/lib/__tests__/proxy-server.test.js +16 -44
- package/dist/lib/__tests__/proxy-server.test.js.map +1 -1
- package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts +2 -0
- package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts.map +1 -0
- package/dist/lib/__tests__/sse-invalidation-listener.test.js +245 -0
- package/dist/lib/__tests__/sse-invalidation-listener.test.js.map +1 -0
- package/dist/lib/__tests__/write-queue-manager.test.d.ts +2 -0
- package/dist/lib/__tests__/write-queue-manager.test.d.ts.map +1 -0
- package/dist/lib/__tests__/write-queue-manager.test.js +291 -0
- package/dist/lib/__tests__/write-queue-manager.test.js.map +1 -0
- package/dist/lib/cache-manager.d.ts +35 -6
- package/dist/lib/cache-manager.d.ts.map +1 -1
- package/dist/lib/cache-manager.js +154 -41
- package/dist/lib/cache-manager.js.map +1 -1
- package/dist/lib/proxy-server.d.ts +5 -0
- package/dist/lib/proxy-server.d.ts.map +1 -1
- package/dist/lib/proxy-server.js +150 -84
- package/dist/lib/proxy-server.js.map +1 -1
- package/dist/lib/qwickbrain-client.d.ts +4 -0
- package/dist/lib/qwickbrain-client.d.ts.map +1 -1
- package/dist/lib/qwickbrain-client.js +131 -2
- package/dist/lib/qwickbrain-client.js.map +1 -1
- package/dist/lib/sse-invalidation-listener.d.ts +27 -0
- package/dist/lib/sse-invalidation-listener.d.ts.map +1 -0
- package/dist/lib/sse-invalidation-listener.js +145 -0
- package/dist/lib/sse-invalidation-listener.js.map +1 -0
- package/dist/lib/tools.d.ts +21 -0
- package/dist/lib/tools.d.ts.map +1 -0
- package/dist/lib/tools.js +488 -0
- package/dist/lib/tools.js.map +1 -0
- package/dist/lib/write-queue-manager.d.ts +88 -0
- package/dist/lib/write-queue-manager.d.ts.map +1 -0
- package/dist/lib/write-queue-manager.js +191 -0
- package/dist/lib/write-queue-manager.js.map +1 -0
- package/dist/types/config.d.ts +7 -42
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +1 -6
- package/dist/types/config.js.map +1 -1
- package/drizzle/0002_lru_cache_migration.sql +94 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -2
- package/scripts/rebuild-sqlite.sh +26 -0
- package/src/db/schema.ts +17 -2
- package/src/lib/__tests__/cache-manager.test.ts +180 -90
- package/src/lib/__tests__/proxy-server.test.ts +16 -51
- package/src/lib/__tests__/sse-invalidation-listener.test.ts +326 -0
- package/src/lib/__tests__/write-queue-manager.test.ts +383 -0
- package/src/lib/cache-manager.ts +198 -46
- package/src/lib/proxy-server.ts +190 -86
- package/src/lib/qwickbrain-client.ts +142 -2
- package/src/lib/sse-invalidation-listener.ts +171 -0
- package/src/lib/tools.ts +500 -0
- package/src/lib/write-queue-manager.ts +271 -0
- package/src/types/config.ts +1 -6
package/src/lib/cache-manager.ts
CHANGED
|
@@ -1,30 +1,103 @@
|
|
|
1
|
-
import { eq, and,
|
|
1
|
+
import { eq, and, sql, desc } from 'drizzle-orm';
|
|
2
2
|
import type { DB } from '../db/client.js';
|
|
3
3
|
import { documents, memories } from '../db/schema.js';
|
|
4
4
|
import type { Config } from '../types/config.js';
|
|
5
5
|
|
|
6
|
+
// Critical document types - never evicted, not counted toward storage limit
|
|
7
|
+
const CRITICAL_DOC_TYPES = ['workflow', 'rule', 'agent', 'template'];
|
|
8
|
+
|
|
6
9
|
interface CachedItem<T> {
|
|
7
10
|
data: T;
|
|
8
11
|
cachedAt: Date;
|
|
9
|
-
expiresAt: Date;
|
|
10
12
|
age: number; // seconds
|
|
11
|
-
isExpired: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export class CacheManager {
|
|
16
|
+
private maxDynamicCacheSize: number; // Storage limit for dynamic tier only (bytes)
|
|
17
|
+
|
|
15
18
|
constructor(
|
|
16
19
|
private db: DB,
|
|
17
20
|
private config: Config['cache']
|
|
18
|
-
) {
|
|
21
|
+
) {
|
|
22
|
+
// Default to 100MB if not configured
|
|
23
|
+
this.maxDynamicCacheSize = config.maxCacheSizeBytes || 100 * 1024 * 1024;
|
|
24
|
+
}
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Get current size of dynamic tier cache (excludes critical tier)
|
|
28
|
+
*/
|
|
29
|
+
private async getDynamicCacheSize(): Promise<number> {
|
|
30
|
+
const result = await this.db
|
|
31
|
+
.select({ total: sql<number>`COALESCE(sum(${documents.sizeBytes}), 0) + COALESCE((SELECT sum(${memories.sizeBytes}) FROM ${memories}), 0)` })
|
|
32
|
+
.from(documents)
|
|
33
|
+
.where(eq(documents.isCritical, false));
|
|
34
|
+
|
|
35
|
+
return result[0]?.total || 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Evict LRU entries from dynamic tier to free up space
|
|
40
|
+
* NEVER touches critical tier
|
|
41
|
+
*/
|
|
42
|
+
private async evictLRU(bytesToFree: number): Promise<void> {
|
|
43
|
+
let freed = 0;
|
|
44
|
+
|
|
45
|
+
// Evict from documents (dynamic tier only)
|
|
46
|
+
const docCandidates = await this.db
|
|
47
|
+
.select()
|
|
48
|
+
.from(documents)
|
|
49
|
+
.where(eq(documents.isCritical, false))
|
|
50
|
+
.orderBy(documents.lastAccessedAt) // ASC = oldest first
|
|
51
|
+
.limit(100);
|
|
52
|
+
|
|
53
|
+
for (const doc of docCandidates) {
|
|
54
|
+
if (freed >= bytesToFree) break;
|
|
55
|
+
|
|
56
|
+
await this.db.delete(documents).where(eq(documents.id, doc.id));
|
|
57
|
+
freed += doc.sizeBytes;
|
|
58
|
+
console.error(`LRU evicted document: ${doc.docType}:${doc.name} (${doc.sizeBytes} bytes)`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Evict from memories if needed
|
|
62
|
+
if (freed < bytesToFree) {
|
|
63
|
+
const memCandidates = await this.db
|
|
64
|
+
.select()
|
|
65
|
+
.from(memories)
|
|
66
|
+
.orderBy(memories.lastAccessedAt) // ASC = oldest first
|
|
67
|
+
.limit(100);
|
|
26
68
|
|
|
27
|
-
|
|
69
|
+
for (const mem of memCandidates) {
|
|
70
|
+
if (freed >= bytesToFree) break;
|
|
71
|
+
|
|
72
|
+
await this.db.delete(memories).where(eq(memories.id, mem.id));
|
|
73
|
+
freed += mem.sizeBytes;
|
|
74
|
+
console.error(`LRU evicted memory: ${mem.name} (${mem.sizeBytes} bytes)`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.error(`LRU eviction complete: freed ${freed} bytes`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ensure sufficient cache space
|
|
83
|
+
* Critical items bypass this check
|
|
84
|
+
*/
|
|
85
|
+
private async ensureCacheSize(requiredBytes: number, isCritical: boolean): Promise<void> {
|
|
86
|
+
// Critical files bypass storage limit check
|
|
87
|
+
if (isCritical) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Only count dynamic tier toward storage limit
|
|
92
|
+
const currentSize = await this.getDynamicCacheSize();
|
|
93
|
+
if (currentSize + requiredBytes <= this.maxDynamicCacheSize) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Evict LRU entries from dynamic tier only
|
|
98
|
+
const toEvict = currentSize + requiredBytes - this.maxDynamicCacheSize;
|
|
99
|
+
console.error(`Cache size limit reached: ${currentSize} + ${requiredBytes} > ${this.maxDynamicCacheSize}, evicting ${toEvict} bytes`);
|
|
100
|
+
await this.evictLRU(toEvict);
|
|
28
101
|
}
|
|
29
102
|
|
|
30
103
|
async getDocument(docType: string, name: string, project?: string): Promise<CachedItem<any> | null> {
|
|
@@ -46,10 +119,13 @@ export class CacheManager {
|
|
|
46
119
|
return null;
|
|
47
120
|
}
|
|
48
121
|
|
|
122
|
+
// Update last accessed timestamp for LRU tracking
|
|
49
123
|
const now = new Date();
|
|
124
|
+
await this.db.update(documents)
|
|
125
|
+
.set({ lastAccessedAt: now })
|
|
126
|
+
.where(eq(documents.id, cached.id));
|
|
127
|
+
|
|
50
128
|
const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
|
|
51
|
-
// Fix: Compare timestamp values explicitly to avoid Date comparison issues
|
|
52
|
-
const isExpired = now.getTime() > cached.expiresAt.getTime();
|
|
53
129
|
|
|
54
130
|
return {
|
|
55
131
|
data: {
|
|
@@ -60,9 +136,7 @@ export class CacheManager {
|
|
|
60
136
|
metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
|
|
61
137
|
},
|
|
62
138
|
cachedAt: cached.cachedAt,
|
|
63
|
-
expiresAt: cached.expiresAt,
|
|
64
139
|
age,
|
|
65
|
-
isExpired,
|
|
66
140
|
};
|
|
67
141
|
}
|
|
68
142
|
|
|
@@ -74,12 +148,12 @@ export class CacheManager {
|
|
|
74
148
|
metadata?: Record<string, unknown>
|
|
75
149
|
): Promise<void> {
|
|
76
150
|
const now = new Date();
|
|
77
|
-
const ttl = this.getTTL('get_document');
|
|
78
|
-
const expiresAt = new Date(now.getTime() + ttl * 1000);
|
|
79
|
-
|
|
80
|
-
// Use empty string instead of null for project to make unique constraint work
|
|
81
|
-
// SQLite treats NULL as distinct values in unique constraints
|
|
82
151
|
const projectValue = project || '';
|
|
152
|
+
const isCritical = CRITICAL_DOC_TYPES.includes(docType);
|
|
153
|
+
const sizeBytes = Buffer.byteLength(content, 'utf8');
|
|
154
|
+
|
|
155
|
+
// Ensure space available (skips check if critical)
|
|
156
|
+
await this.ensureCacheSize(sizeBytes, isCritical);
|
|
83
157
|
|
|
84
158
|
await this.db
|
|
85
159
|
.insert(documents)
|
|
@@ -90,7 +164,9 @@ export class CacheManager {
|
|
|
90
164
|
content,
|
|
91
165
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
92
166
|
cachedAt: now,
|
|
93
|
-
|
|
167
|
+
lastAccessedAt: now,
|
|
168
|
+
isCritical,
|
|
169
|
+
sizeBytes,
|
|
94
170
|
synced: true,
|
|
95
171
|
})
|
|
96
172
|
.onConflictDoUpdate({
|
|
@@ -98,8 +174,8 @@ export class CacheManager {
|
|
|
98
174
|
set: {
|
|
99
175
|
content,
|
|
100
176
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
101
|
-
|
|
102
|
-
|
|
177
|
+
lastAccessedAt: now,
|
|
178
|
+
sizeBytes,
|
|
103
179
|
synced: true,
|
|
104
180
|
},
|
|
105
181
|
});
|
|
@@ -123,10 +199,13 @@ export class CacheManager {
|
|
|
123
199
|
return null;
|
|
124
200
|
}
|
|
125
201
|
|
|
202
|
+
// Update last accessed timestamp for LRU tracking
|
|
126
203
|
const now = new Date();
|
|
204
|
+
await this.db.update(memories)
|
|
205
|
+
.set({ lastAccessedAt: now })
|
|
206
|
+
.where(eq(memories.id, cached.id));
|
|
207
|
+
|
|
127
208
|
const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
|
|
128
|
-
// Fix: Compare timestamp values explicitly to avoid Date comparison issues
|
|
129
|
-
const isExpired = now.getTime() > cached.expiresAt.getTime();
|
|
130
209
|
|
|
131
210
|
return {
|
|
132
211
|
data: {
|
|
@@ -136,9 +215,7 @@ export class CacheManager {
|
|
|
136
215
|
metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
|
|
137
216
|
},
|
|
138
217
|
cachedAt: cached.cachedAt,
|
|
139
|
-
expiresAt: cached.expiresAt,
|
|
140
218
|
age,
|
|
141
|
-
isExpired,
|
|
142
219
|
};
|
|
143
220
|
}
|
|
144
221
|
|
|
@@ -149,11 +226,11 @@ export class CacheManager {
|
|
|
149
226
|
metadata?: Record<string, unknown>
|
|
150
227
|
): Promise<void> {
|
|
151
228
|
const now = new Date();
|
|
152
|
-
const ttl = this.getTTL('get_memory');
|
|
153
|
-
const expiresAt = new Date(now.getTime() + ttl * 1000);
|
|
154
|
-
|
|
155
|
-
// Use empty string instead of null for project to make unique constraint work
|
|
156
229
|
const projectValue = project || '';
|
|
230
|
+
const sizeBytes = Buffer.byteLength(content, 'utf8');
|
|
231
|
+
|
|
232
|
+
// Memories are always dynamic tier (not critical)
|
|
233
|
+
await this.ensureCacheSize(sizeBytes, false);
|
|
157
234
|
|
|
158
235
|
await this.db
|
|
159
236
|
.insert(memories)
|
|
@@ -163,7 +240,8 @@ export class CacheManager {
|
|
|
163
240
|
content,
|
|
164
241
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
165
242
|
cachedAt: now,
|
|
166
|
-
|
|
243
|
+
lastAccessedAt: now,
|
|
244
|
+
sizeBytes,
|
|
167
245
|
synced: true,
|
|
168
246
|
})
|
|
169
247
|
.onConflictDoUpdate({
|
|
@@ -171,31 +249,105 @@ export class CacheManager {
|
|
|
171
249
|
set: {
|
|
172
250
|
content,
|
|
173
251
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
174
|
-
|
|
175
|
-
|
|
252
|
+
lastAccessedAt: now,
|
|
253
|
+
sizeBytes,
|
|
176
254
|
synced: true,
|
|
177
255
|
},
|
|
178
256
|
});
|
|
179
257
|
}
|
|
180
258
|
|
|
181
|
-
|
|
182
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Invalidate a document from cache (for SSE-based cache invalidation)
|
|
261
|
+
*/
|
|
262
|
+
async invalidateDocument(docType: string, name: string, project?: string): Promise<void> {
|
|
263
|
+
const projectValue = project || '';
|
|
183
264
|
|
|
184
|
-
|
|
185
|
-
const deletedDocs = await this.db
|
|
265
|
+
await this.db
|
|
186
266
|
.delete(documents)
|
|
187
|
-
.where(
|
|
188
|
-
|
|
267
|
+
.where(
|
|
268
|
+
and(
|
|
269
|
+
eq(documents.docType, docType),
|
|
270
|
+
eq(documents.name, name),
|
|
271
|
+
eq(documents.project, projectValue)
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
console.error(`Cache invalidated: ${docType}:${name}`);
|
|
276
|
+
}
|
|
189
277
|
|
|
190
|
-
|
|
191
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Invalidate a memory from cache (for SSE-based cache invalidation)
|
|
280
|
+
*/
|
|
281
|
+
async invalidateMemory(name: string, project?: string): Promise<void> {
|
|
282
|
+
const projectValue = project || '';
|
|
283
|
+
|
|
284
|
+
await this.db
|
|
192
285
|
.delete(memories)
|
|
193
|
-
.where(
|
|
194
|
-
|
|
286
|
+
.where(
|
|
287
|
+
and(
|
|
288
|
+
eq(memories.name, name),
|
|
289
|
+
eq(memories.project, projectValue)
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
console.error(`Cache invalidated: memory:${name}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get cache statistics
|
|
298
|
+
*/
|
|
299
|
+
async getCacheStats(): Promise<{
|
|
300
|
+
criticalSize: number;
|
|
301
|
+
criticalCount: number;
|
|
302
|
+
dynamicSize: number;
|
|
303
|
+
dynamicCount: number;
|
|
304
|
+
totalSize: number;
|
|
305
|
+
totalCount: number;
|
|
306
|
+
memorySize: number;
|
|
307
|
+
memoryCount: number;
|
|
308
|
+
}> {
|
|
309
|
+
// Critical documents
|
|
310
|
+
const criticalResult = await this.db
|
|
311
|
+
.select({
|
|
312
|
+
size: sql<number>`COALESCE(sum(${documents.sizeBytes}), 0)`,
|
|
313
|
+
count: sql<number>`count(*)`
|
|
314
|
+
})
|
|
315
|
+
.from(documents)
|
|
316
|
+
.where(eq(documents.isCritical, true));
|
|
317
|
+
|
|
318
|
+
// Dynamic documents
|
|
319
|
+
const dynamicResult = await this.db
|
|
320
|
+
.select({
|
|
321
|
+
size: sql<number>`COALESCE(sum(${documents.sizeBytes}), 0)`,
|
|
322
|
+
count: sql<number>`count(*)`
|
|
323
|
+
})
|
|
324
|
+
.from(documents)
|
|
325
|
+
.where(eq(documents.isCritical, false));
|
|
326
|
+
|
|
327
|
+
// Memories
|
|
328
|
+
const memoryResult = await this.db
|
|
329
|
+
.select({
|
|
330
|
+
size: sql<number>`COALESCE(sum(${memories.sizeBytes}), 0)`,
|
|
331
|
+
count: sql<number>`count(*)`
|
|
332
|
+
})
|
|
333
|
+
.from(memories);
|
|
334
|
+
|
|
335
|
+
const criticalSize = criticalResult[0]?.size || 0;
|
|
336
|
+
const criticalCount = criticalResult[0]?.count || 0;
|
|
337
|
+
const dynamicSize = dynamicResult[0]?.size || 0;
|
|
338
|
+
const dynamicCount = dynamicResult[0]?.count || 0;
|
|
339
|
+
const memorySize = memoryResult[0]?.size || 0;
|
|
340
|
+
const memoryCount = memoryResult[0]?.count || 0;
|
|
195
341
|
|
|
196
342
|
return {
|
|
197
|
-
|
|
198
|
-
|
|
343
|
+
criticalSize,
|
|
344
|
+
criticalCount,
|
|
345
|
+
dynamicSize: dynamicSize + memorySize,
|
|
346
|
+
dynamicCount: dynamicCount + memoryCount,
|
|
347
|
+
totalSize: criticalSize + dynamicSize + memorySize,
|
|
348
|
+
totalCount: criticalCount + dynamicCount + memoryCount,
|
|
349
|
+
memorySize,
|
|
350
|
+
memoryCount,
|
|
199
351
|
};
|
|
200
352
|
}
|
|
201
353
|
}
|