@qwickapps/qwickbrain-proxy 1.0.1 → 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.
Files changed (67) hide show
  1. package/.claude/engineering/bugs/BUG-qwickbrain-proxy-cache-and-design.md +840 -0
  2. package/.github/workflows/publish.yml +13 -0
  3. package/CHANGELOG.md +54 -0
  4. package/dist/db/schema.d.ts +63 -6
  5. package/dist/db/schema.d.ts.map +1 -1
  6. package/dist/db/schema.js +17 -2
  7. package/dist/db/schema.js.map +1 -1
  8. package/dist/lib/__tests__/cache-manager.test.js +146 -83
  9. package/dist/lib/__tests__/cache-manager.test.js.map +1 -1
  10. package/dist/lib/__tests__/proxy-server.test.js +16 -44
  11. package/dist/lib/__tests__/proxy-server.test.js.map +1 -1
  12. package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts +2 -0
  13. package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts.map +1 -0
  14. package/dist/lib/__tests__/sse-invalidation-listener.test.js +245 -0
  15. package/dist/lib/__tests__/sse-invalidation-listener.test.js.map +1 -0
  16. package/dist/lib/__tests__/write-queue-manager.test.d.ts +2 -0
  17. package/dist/lib/__tests__/write-queue-manager.test.d.ts.map +1 -0
  18. package/dist/lib/__tests__/write-queue-manager.test.js +291 -0
  19. package/dist/lib/__tests__/write-queue-manager.test.js.map +1 -0
  20. package/dist/lib/cache-manager.d.ts +35 -6
  21. package/dist/lib/cache-manager.d.ts.map +1 -1
  22. package/dist/lib/cache-manager.js +154 -41
  23. package/dist/lib/cache-manager.js.map +1 -1
  24. package/dist/lib/connection-manager.d.ts.map +1 -1
  25. package/dist/lib/connection-manager.js +4 -1
  26. package/dist/lib/connection-manager.js.map +1 -1
  27. package/dist/lib/proxy-server.d.ts +6 -0
  28. package/dist/lib/proxy-server.d.ts.map +1 -1
  29. package/dist/lib/proxy-server.js +182 -87
  30. package/dist/lib/proxy-server.js.map +1 -1
  31. package/dist/lib/qwickbrain-client.d.ts +4 -0
  32. package/dist/lib/qwickbrain-client.d.ts.map +1 -1
  33. package/dist/lib/qwickbrain-client.js +133 -0
  34. package/dist/lib/qwickbrain-client.js.map +1 -1
  35. package/dist/lib/sse-invalidation-listener.d.ts +27 -0
  36. package/dist/lib/sse-invalidation-listener.d.ts.map +1 -0
  37. package/dist/lib/sse-invalidation-listener.js +145 -0
  38. package/dist/lib/sse-invalidation-listener.js.map +1 -0
  39. package/dist/lib/tools.d.ts +21 -0
  40. package/dist/lib/tools.d.ts.map +1 -0
  41. package/dist/lib/tools.js +488 -0
  42. package/dist/lib/tools.js.map +1 -0
  43. package/dist/lib/write-queue-manager.d.ts +88 -0
  44. package/dist/lib/write-queue-manager.d.ts.map +1 -0
  45. package/dist/lib/write-queue-manager.js +191 -0
  46. package/dist/lib/write-queue-manager.js.map +1 -0
  47. package/dist/types/config.d.ts +7 -42
  48. package/dist/types/config.d.ts.map +1 -1
  49. package/dist/types/config.js +1 -6
  50. package/dist/types/config.js.map +1 -1
  51. package/drizzle/0002_lru_cache_migration.sql +94 -0
  52. package/drizzle/meta/_journal.json +7 -0
  53. package/package.json +6 -2
  54. package/scripts/rebuild-sqlite.sh +26 -0
  55. package/src/db/schema.ts +17 -2
  56. package/src/lib/__tests__/cache-manager.test.ts +180 -90
  57. package/src/lib/__tests__/proxy-server.test.ts +16 -51
  58. package/src/lib/__tests__/sse-invalidation-listener.test.ts +326 -0
  59. package/src/lib/__tests__/write-queue-manager.test.ts +383 -0
  60. package/src/lib/cache-manager.ts +198 -46
  61. package/src/lib/connection-manager.ts +4 -1
  62. package/src/lib/proxy-server.ts +222 -90
  63. package/src/lib/qwickbrain-client.ts +145 -1
  64. package/src/lib/sse-invalidation-listener.ts +171 -0
  65. package/src/lib/tools.ts +500 -0
  66. package/src/lib/write-queue-manager.ts +271 -0
  67. package/src/types/config.ts +1 -6
@@ -1,30 +1,103 @@
1
- import { eq, and, lt, lte, sql } from 'drizzle-orm';
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
- private getTTL(operation: string): number {
21
- const ttlMap: Record<string, number> = {
22
- get_workflow: this.config.ttl.workflows,
23
- get_document: this.config.ttl.documents,
24
- get_memory: this.config.ttl.memories,
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
- return ttlMap[operation] || 0;
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
- expiresAt,
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
- cachedAt: now,
102
- expiresAt,
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
- expiresAt,
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
- cachedAt: now,
175
- expiresAt,
252
+ lastAccessedAt: now,
253
+ sizeBytes,
176
254
  synced: true,
177
255
  },
178
256
  });
179
257
  }
180
258
 
181
- async cleanupExpiredEntries(): Promise<{ documentsDeleted: number; memoriesDeleted: number }> {
182
- const now = new Date();
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
- // Delete expired documents (use lte to include items expiring exactly now)
185
- const deletedDocs = await this.db
265
+ await this.db
186
266
  .delete(documents)
187
- .where(lte(documents.expiresAt, now))
188
- .returning({ id: documents.id });
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
- // Delete expired memories (use lte to include items expiring exactly now)
191
- const deletedMems = await this.db
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(lte(memories.expiresAt, now))
194
- .returning({ id: memories.id });
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
- documentsDeleted: deletedDocs.length,
198
- memoriesDeleted: deletedMems.length,
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
  }
@@ -34,7 +34,10 @@ export class ConnectionManager extends EventEmitter {
34
34
 
35
35
  async start(): Promise<void> {
36
36
  this.isStopped = false;
37
- await this.healthCheck();
37
+ // Start health check in background (don't block server startup)
38
+ this.healthCheck().catch(err => {
39
+ console.error('Initial health check error:', err);
40
+ });
38
41
  this.scheduleHealthCheck();
39
42
  }
40
43