@kb-labs/adapters 0.5.0

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 (276) hide show
  1. package/.cursorrules +32 -0
  2. package/.github/workflows/ci.yml +13 -0
  3. package/.github/workflows/deploy.yml +28 -0
  4. package/.github/workflows/docker-build.yml +25 -0
  5. package/.github/workflows/drift-check.yml +10 -0
  6. package/.github/workflows/profiles-validate.yml +16 -0
  7. package/.github/workflows/release.yml +8 -0
  8. package/.kb/devkit/agents/devkit-maintainer/context.globs +15 -0
  9. package/.kb/devkit/agents/devkit-maintainer/permissions.yml +17 -0
  10. package/.kb/devkit/agents/devkit-maintainer/prompt.md +28 -0
  11. package/.kb/devkit/agents/devkit-maintainer/runbook.md +31 -0
  12. package/.kb/devkit/agents/docs-crafter/prompt.md +24 -0
  13. package/.kb/devkit/agents/docs-crafter/runbook.md +18 -0
  14. package/.kb/devkit/agents/release-manager/context.globs +7 -0
  15. package/.kb/devkit/agents/release-manager/prompt.md +27 -0
  16. package/.kb/devkit/agents/release-manager/runbook.md +17 -0
  17. package/.kb/devkit/agents/test-generator/context.globs +7 -0
  18. package/.kb/devkit/agents/test-generator/prompt.md +27 -0
  19. package/.kb/devkit/agents/test-generator/runbook.md +18 -0
  20. package/CONTRIBUTING.md +90 -0
  21. package/IMPLEMENTATION_COMPLETE.md +416 -0
  22. package/LICENSE +186 -0
  23. package/README-TEMPLATE.md +179 -0
  24. package/README.md +306 -0
  25. package/docs/DOCUMENTATION.md +74 -0
  26. package/docs/adr/0000-template.md +49 -0
  27. package/docs/adr/0001-architecture-and-repository-layout.md +33 -0
  28. package/docs/adr/0002-plugins-and-extensibility.md +46 -0
  29. package/docs/adr/0003-package-and-module-boundaries.md +37 -0
  30. package/docs/adr/0004-versioning-and-release-policy.md +38 -0
  31. package/docs/adr/0005-use-devkit-for-shared-tooling.md +48 -0
  32. package/docs/adr/0006-adopt-devkit-sync.md +47 -0
  33. package/docs/adr/0007-drift-kit-check.md +72 -0
  34. package/docs/adr/0008-devkit-sync-wrapper-strategy.md +67 -0
  35. package/docs/naming-convention.md +272 -0
  36. package/eslint.config.js +27 -0
  37. package/kb-labs.config.json +5 -0
  38. package/package.json +84 -0
  39. package/package.json.bin +25 -0
  40. package/package.json.lib +30 -0
  41. package/packages/adapters-analytics-duckdb/package.json +54 -0
  42. package/packages/adapters-analytics-duckdb/scripts/migrate-from-jsonl.mjs +253 -0
  43. package/packages/adapters-analytics-duckdb/src/index.ts +380 -0
  44. package/packages/adapters-analytics-duckdb/src/manifest.ts +36 -0
  45. package/packages/adapters-analytics-duckdb/src/schema.ts +161 -0
  46. package/packages/adapters-analytics-duckdb/tsconfig.build.json +15 -0
  47. package/packages/adapters-analytics-duckdb/tsconfig.json +9 -0
  48. package/packages/adapters-analytics-duckdb/tsup.config.ts +9 -0
  49. package/packages/adapters-analytics-file/README.md +32 -0
  50. package/packages/adapters-analytics-file/eslint.config.js +27 -0
  51. package/packages/adapters-analytics-file/package.json +50 -0
  52. package/packages/adapters-analytics-file/src/__tests__/daily-stats.spec.ts +287 -0
  53. package/packages/adapters-analytics-file/src/__tests__/scoped-analytics.test.ts +233 -0
  54. package/packages/adapters-analytics-file/src/index.test.ts +214 -0
  55. package/packages/adapters-analytics-file/src/index.ts +830 -0
  56. package/packages/adapters-analytics-file/src/manifest.ts +45 -0
  57. package/packages/adapters-analytics-file/tsconfig.build.json +15 -0
  58. package/packages/adapters-analytics-file/tsconfig.json +9 -0
  59. package/packages/adapters-analytics-file/tsup.config.ts +9 -0
  60. package/packages/adapters-analytics-sqlite/package.json +55 -0
  61. package/packages/adapters-analytics-sqlite/scripts/migrate-from-jsonl.mjs +194 -0
  62. package/packages/adapters-analytics-sqlite/src/index.ts +460 -0
  63. package/packages/adapters-analytics-sqlite/src/manifest.ts +41 -0
  64. package/packages/adapters-analytics-sqlite/tsconfig.build.json +15 -0
  65. package/packages/adapters-analytics-sqlite/tsconfig.json +9 -0
  66. package/packages/adapters-analytics-sqlite/tsup.config.ts +9 -0
  67. package/packages/adapters-environment-docker/README.md +28 -0
  68. package/packages/adapters-environment-docker/eslint.config.js +5 -0
  69. package/packages/adapters-environment-docker/package.json +49 -0
  70. package/packages/adapters-environment-docker/src/index.test.ts +138 -0
  71. package/packages/adapters-environment-docker/src/index.ts +439 -0
  72. package/packages/adapters-environment-docker/src/manifest.ts +65 -0
  73. package/packages/adapters-environment-docker/tsconfig.build.json +15 -0
  74. package/packages/adapters-environment-docker/tsconfig.json +16 -0
  75. package/packages/adapters-environment-docker/tsup.config.ts +9 -0
  76. package/packages/adapters-eventbus-cache/README.md +242 -0
  77. package/packages/adapters-eventbus-cache/eslint.config.js +27 -0
  78. package/packages/adapters-eventbus-cache/package.json +46 -0
  79. package/packages/adapters-eventbus-cache/src/index.test.ts +235 -0
  80. package/packages/adapters-eventbus-cache/src/index.ts +215 -0
  81. package/packages/adapters-eventbus-cache/src/manifest.ts +50 -0
  82. package/packages/adapters-eventbus-cache/src/types.ts +58 -0
  83. package/packages/adapters-eventbus-cache/tsconfig.build.json +15 -0
  84. package/packages/adapters-eventbus-cache/tsconfig.json +9 -0
  85. package/packages/adapters-eventbus-cache/tsup.config.ts +9 -0
  86. package/packages/adapters-fs/README.md +171 -0
  87. package/packages/adapters-fs/allowed.txt +1 -0
  88. package/packages/adapters-fs/conflict.txt +1 -0
  89. package/packages/adapters-fs/dest.txt +1 -0
  90. package/packages/adapters-fs/eslint.config.js +27 -0
  91. package/packages/adapters-fs/exists.txt +1 -0
  92. package/packages/adapters-fs/not-allowed.txt +1 -0
  93. package/packages/adapters-fs/other.txt +1 -0
  94. package/packages/adapters-fs/package.json +55 -0
  95. package/packages/adapters-fs/public/file1.txt +1 -0
  96. package/packages/adapters-fs/public/file2.txt +1 -0
  97. package/packages/adapters-fs/secret.txt +1 -0
  98. package/packages/adapters-fs/secrets/key.txt +1 -0
  99. package/packages/adapters-fs/src/index.test.ts +243 -0
  100. package/packages/adapters-fs/src/index.ts +258 -0
  101. package/packages/adapters-fs/src/manifest.ts +35 -0
  102. package/packages/adapters-fs/src/secure-storage.test.ts +380 -0
  103. package/packages/adapters-fs/src/secure-storage.ts +268 -0
  104. package/packages/adapters-fs/test.json +1 -0
  105. package/packages/adapters-fs/test.txt +1 -0
  106. package/packages/adapters-fs/test.xyz +1 -0
  107. package/packages/adapters-fs/test1.txt +1 -0
  108. package/packages/adapters-fs/test2.txt +1 -0
  109. package/packages/adapters-fs/tsconfig.build.json +15 -0
  110. package/packages/adapters-fs/tsconfig.json +9 -0
  111. package/packages/adapters-fs/tsup.config.ts +8 -0
  112. package/packages/adapters-fs/vitest.config.ts +19 -0
  113. package/packages/adapters-log-ringbuffer/README.md +228 -0
  114. package/packages/adapters-log-ringbuffer/eslint.config.js +27 -0
  115. package/packages/adapters-log-ringbuffer/package.json +47 -0
  116. package/packages/adapters-log-ringbuffer/src/__tests__/ring-buffer.test.ts +450 -0
  117. package/packages/adapters-log-ringbuffer/src/index.ts +212 -0
  118. package/packages/adapters-log-ringbuffer/src/manifest.ts +30 -0
  119. package/packages/adapters-log-ringbuffer/tsconfig.build.json +15 -0
  120. package/packages/adapters-log-ringbuffer/tsconfig.json +9 -0
  121. package/packages/adapters-log-ringbuffer/tsup.config.ts +9 -0
  122. package/packages/adapters-log-ringbuffer/vitest.config.ts +14 -0
  123. package/packages/adapters-log-sqlite/README.md +396 -0
  124. package/packages/adapters-log-sqlite/eslint.config.js +27 -0
  125. package/packages/adapters-log-sqlite/package.json +49 -0
  126. package/packages/adapters-log-sqlite/src/__tests__/log-persistence.test.ts +718 -0
  127. package/packages/adapters-log-sqlite/src/index.ts +1068 -0
  128. package/packages/adapters-log-sqlite/src/manifest.ts +36 -0
  129. package/packages/adapters-log-sqlite/src/schema.sql +46 -0
  130. package/packages/adapters-log-sqlite/tsconfig.build.json +15 -0
  131. package/packages/adapters-log-sqlite/tsconfig.json +9 -0
  132. package/packages/adapters-log-sqlite/tsup.config.ts +9 -0
  133. package/packages/adapters-log-sqlite/vitest.config.ts +15 -0
  134. package/packages/adapters-mongodb/README.md +147 -0
  135. package/packages/adapters-mongodb/eslint.config.js +27 -0
  136. package/packages/adapters-mongodb/package.json +53 -0
  137. package/packages/adapters-mongodb/src/index.ts +428 -0
  138. package/packages/adapters-mongodb/src/manifest.ts +45 -0
  139. package/packages/adapters-mongodb/src/secure-document.ts +231 -0
  140. package/packages/adapters-mongodb/tsconfig.build.json +15 -0
  141. package/packages/adapters-mongodb/tsconfig.json +9 -0
  142. package/packages/adapters-mongodb/tsup.config.ts +8 -0
  143. package/packages/adapters-openai/README.md +151 -0
  144. package/packages/adapters-openai/embeddings.ts +37 -0
  145. package/packages/adapters-openai/eslint.config.js +26 -0
  146. package/packages/adapters-openai/index.ts +22 -0
  147. package/packages/adapters-openai/package.json +57 -0
  148. package/packages/adapters-openai/src/embeddings-manifest.ts +45 -0
  149. package/packages/adapters-openai/src/embeddings.ts +104 -0
  150. package/packages/adapters-openai/src/index.ts +13 -0
  151. package/packages/adapters-openai/src/llm.ts +304 -0
  152. package/packages/adapters-openai/src/manifest.ts +47 -0
  153. package/packages/adapters-openai/tsconfig.build.json +15 -0
  154. package/packages/adapters-openai/tsconfig.json +9 -0
  155. package/packages/adapters-openai/tsup.config.ts +8 -0
  156. package/packages/adapters-pino/README.md +152 -0
  157. package/packages/adapters-pino/eslint.config.js +27 -0
  158. package/packages/adapters-pino/package.json +49 -0
  159. package/packages/adapters-pino/src/index.test.ts +44 -0
  160. package/packages/adapters-pino/src/index.ts +322 -0
  161. package/packages/adapters-pino/src/log-ring-buffer.ts +142 -0
  162. package/packages/adapters-pino/src/manifest.ts +49 -0
  163. package/packages/adapters-pino/tsconfig.build.json +15 -0
  164. package/packages/adapters-pino/tsconfig.json +9 -0
  165. package/packages/adapters-pino/tsup.config.ts +9 -0
  166. package/packages/adapters-pino-http/README.md +141 -0
  167. package/packages/adapters-pino-http/eslint.config.js +27 -0
  168. package/packages/adapters-pino-http/package.json +46 -0
  169. package/packages/adapters-pino-http/src/index.ts +229 -0
  170. package/packages/adapters-pino-http/tsconfig.build.json +15 -0
  171. package/packages/adapters-pino-http/tsconfig.json +9 -0
  172. package/packages/adapters-pino-http/tsup.config.ts +9 -0
  173. package/packages/adapters-qdrant/README.md +166 -0
  174. package/packages/adapters-qdrant/eslint.config.js +27 -0
  175. package/packages/adapters-qdrant/package.json +49 -0
  176. package/packages/adapters-qdrant/src/index.ts +490 -0
  177. package/packages/adapters-qdrant/src/manifest.ts +54 -0
  178. package/packages/adapters-qdrant/src/retry.ts +204 -0
  179. package/packages/adapters-qdrant/tsconfig.build.json +15 -0
  180. package/packages/adapters-qdrant/tsconfig.json +9 -0
  181. package/packages/adapters-qdrant/tsup.config.ts +9 -0
  182. package/packages/adapters-redis/README.md +159 -0
  183. package/packages/adapters-redis/eslint.config.js +27 -0
  184. package/packages/adapters-redis/package.json +49 -0
  185. package/packages/adapters-redis/src/index.ts +164 -0
  186. package/packages/adapters-redis/src/manifest.ts +49 -0
  187. package/packages/adapters-redis/tsconfig.build.json +15 -0
  188. package/packages/adapters-redis/tsconfig.json +9 -0
  189. package/packages/adapters-redis/tsup.config.ts +9 -0
  190. package/packages/adapters-snapshot-localfs/README.md +10 -0
  191. package/packages/adapters-snapshot-localfs/eslint.config.js +2 -0
  192. package/packages/adapters-snapshot-localfs/package.json +46 -0
  193. package/packages/adapters-snapshot-localfs/src/index.test.ts +40 -0
  194. package/packages/adapters-snapshot-localfs/src/index.ts +292 -0
  195. package/packages/adapters-snapshot-localfs/src/manifest.ts +32 -0
  196. package/packages/adapters-snapshot-localfs/tsconfig.build.json +15 -0
  197. package/packages/adapters-snapshot-localfs/tsconfig.json +16 -0
  198. package/packages/adapters-snapshot-localfs/tsup.config.ts +11 -0
  199. package/packages/adapters-sqlite/README.md +163 -0
  200. package/packages/adapters-sqlite/eslint.config.js +27 -0
  201. package/packages/adapters-sqlite/package.json +54 -0
  202. package/packages/adapters-sqlite/src/index.test.ts +245 -0
  203. package/packages/adapters-sqlite/src/index.ts +382 -0
  204. package/packages/adapters-sqlite/src/manifest.ts +47 -0
  205. package/packages/adapters-sqlite/src/secure-sql.test.ts +290 -0
  206. package/packages/adapters-sqlite/src/secure-sql.ts +281 -0
  207. package/packages/adapters-sqlite/tsconfig.build.json +15 -0
  208. package/packages/adapters-sqlite/tsconfig.json +9 -0
  209. package/packages/adapters-sqlite/tsup.config.ts +8 -0
  210. package/packages/adapters-sqlite/vitest.config.ts +19 -0
  211. package/packages/adapters-transport/README.md +170 -0
  212. package/packages/adapters-transport/eslint.config.js +27 -0
  213. package/packages/adapters-transport/package.json +49 -0
  214. package/packages/adapters-transport/src/__tests__/unix-socket-server.test.ts +550 -0
  215. package/packages/adapters-transport/src/index.ts +101 -0
  216. package/packages/adapters-transport/src/ipc-transport.ts +228 -0
  217. package/packages/adapters-transport/src/transport.ts +224 -0
  218. package/packages/adapters-transport/src/types.ts +92 -0
  219. package/packages/adapters-transport/src/unix-socket-server.ts +193 -0
  220. package/packages/adapters-transport/src/unix-socket-transport.ts +280 -0
  221. package/packages/adapters-transport/tsconfig.build.json +15 -0
  222. package/packages/adapters-transport/tsconfig.json +9 -0
  223. package/packages/adapters-transport/tsup.config.ts +9 -0
  224. package/packages/adapters-vibeproxy/README.md +159 -0
  225. package/packages/adapters-vibeproxy/eslint.config.js +27 -0
  226. package/packages/adapters-vibeproxy/package.json +51 -0
  227. package/packages/adapters-vibeproxy/src/index.ts +13 -0
  228. package/packages/adapters-vibeproxy/src/llm.ts +437 -0
  229. package/packages/adapters-vibeproxy/src/manifest.ts +51 -0
  230. package/packages/adapters-vibeproxy/tsconfig.build.json +15 -0
  231. package/packages/adapters-vibeproxy/tsconfig.json +9 -0
  232. package/packages/adapters-vibeproxy/tsup.config.ts +8 -0
  233. package/packages/adapters-workspace-agent/package.json +46 -0
  234. package/packages/adapters-workspace-agent/src/__tests__/adapter.test.ts +212 -0
  235. package/packages/adapters-workspace-agent/src/index.ts +220 -0
  236. package/packages/adapters-workspace-agent/src/manifest.ts +36 -0
  237. package/packages/adapters-workspace-agent/tsconfig.build.json +15 -0
  238. package/packages/adapters-workspace-agent/tsconfig.json +16 -0
  239. package/packages/adapters-workspace-agent/tsup.config.ts +11 -0
  240. package/packages/adapters-workspace-localfs/README.md +9 -0
  241. package/packages/adapters-workspace-localfs/eslint.config.js +2 -0
  242. package/packages/adapters-workspace-localfs/package.json +46 -0
  243. package/packages/adapters-workspace-localfs/src/index.test.ts +27 -0
  244. package/packages/adapters-workspace-localfs/src/index.ts +172 -0
  245. package/packages/adapters-workspace-localfs/src/manifest.ts +32 -0
  246. package/packages/adapters-workspace-localfs/tsconfig.build.json +15 -0
  247. package/packages/adapters-workspace-localfs/tsconfig.json +16 -0
  248. package/packages/adapters-workspace-localfs/tsup.config.ts +11 -0
  249. package/packages/adapters-workspace-worktree/README.md +9 -0
  250. package/packages/adapters-workspace-worktree/eslint.config.js +2 -0
  251. package/packages/adapters-workspace-worktree/package.json +46 -0
  252. package/packages/adapters-workspace-worktree/src/index.test.ts +38 -0
  253. package/packages/adapters-workspace-worktree/src/index.ts +245 -0
  254. package/packages/adapters-workspace-worktree/src/manifest.ts +38 -0
  255. package/packages/adapters-workspace-worktree/tsconfig.build.json +15 -0
  256. package/packages/adapters-workspace-worktree/tsconfig.json +16 -0
  257. package/packages/adapters-workspace-worktree/tsup.config.ts +11 -0
  258. package/pnpm-workspace.yaml +2800 -0
  259. package/prettierrc.json +1 -0
  260. package/scripts/devkit-sync.mjs +37 -0
  261. package/scripts/hooks/post-push +9 -0
  262. package/scripts/hooks/pre-commit +9 -0
  263. package/scripts/hooks/pre-push +9 -0
  264. package/test-integration.ts +242 -0
  265. package/test.txt +1 -0
  266. package/tsconfig.base.json +6 -0
  267. package/tsconfig.build.json +15 -0
  268. package/tsconfig.json +9 -0
  269. package/tsconfig.paths.json +26 -0
  270. package/tsconfig.tools.json +17 -0
  271. package/tsup.config.bin.ts +34 -0
  272. package/tsup.config.cli.ts +41 -0
  273. package/tsup.config.dual.ts +46 -0
  274. package/tsup.config.ts +36 -0
  275. package/tsup.external.json +103 -0
  276. package/vitest.config.ts +2 -0
@@ -0,0 +1,1068 @@
1
+ /**
2
+ * @module @kb-labs/adapters-log-sqlite
3
+ * SQLite persistence adapter for KB Labs logs.
4
+ *
5
+ * Features:
6
+ * - Automatic schema initialization
7
+ * - Batch writes with auto-flush
8
+ * - Full-text search support (FTS5)
9
+ * - Retention policy support
10
+ * - Cross-process log aggregation
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { createAdapter } from '@kb-labs/adapters-log-sqlite';
15
+ * import { createAdapter as createDB } from '@kb-labs/adapters-sqlite';
16
+ *
17
+ * const db = createDB({ filename: '.kb/data/kb.db' });
18
+ * const persistence = await createAdapter({
19
+ * database: db,
20
+ * batchSize: 100,
21
+ * flushInterval: 5000,
22
+ * });
23
+ *
24
+ * // Write logs
25
+ * await persistence.write({
26
+ * timestamp: Date.now(),
27
+ * level: 'info',
28
+ * message: 'Server started',
29
+ * fields: { port: 3000 },
30
+ * source: 'rest-api',
31
+ * });
32
+ *
33
+ * // Query logs
34
+ * const result = await persistence.query(
35
+ * { level: 'error', from: Date.now() - 3600000 },
36
+ * { limit: 50, offset: 0 }
37
+ * );
38
+ *
39
+ * // Search logs
40
+ * const searchResults = await persistence.search('authentication failed');
41
+ *
42
+ * // Clean up
43
+ * await persistence.close();
44
+ * ```
45
+ */
46
+
47
+ import { readFileSync } from "node:fs";
48
+ import { fileURLToPath } from "node:url";
49
+ import { dirname, join } from "node:path";
50
+ import type {
51
+ ILogPersistence,
52
+ LogPersistenceConfig,
53
+ LogRetentionPolicy,
54
+ LogRecord,
55
+ LogQuery,
56
+ ISQLDatabase,
57
+ } from "@kb-labs/core-platform/adapters";
58
+ import { generateLogId } from "@kb-labs/core-platform/adapters";
59
+
60
+ /** Default retention: 7 days for warn/error/fatal */
61
+ const DEFAULT_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
62
+ /** Default retention: 1 hour for debug/trace */
63
+ const DEFAULT_MAX_AGE_DEBUG = 60 * 60 * 1000;
64
+ /** Default retention: 24 hours for info */
65
+ const DEFAULT_MAX_AGE_INFO = 24 * 60 * 60 * 1000;
66
+ /** Default max DB size: 500 MB */
67
+ const DEFAULT_MAX_SIZE_BYTES = 500 * 1024 * 1024;
68
+ /** Default cleanup interval: 5 minutes */
69
+ const DEFAULT_CLEANUP_INTERVAL = 5 * 60 * 1000;
70
+ /** Batch size for size-based cleanup deletes */
71
+ const SIZE_CLEANUP_BATCH = 10_000;
72
+ /** Check DB size every N retention cycles (not every cycle) */
73
+ const SIZE_CHECK_EVERY_N_CYCLES = 10;
74
+ /** Levels that are filtered by maxAgeDebug */
75
+ const DEBUG_LEVELS = ["debug", "trace"];
76
+ /** Levels that are filtered by maxAgeInfo */
77
+ const INFO_LEVELS = ["info"];
78
+ /** Levels that are filtered by maxAge */
79
+ const IMPORTANT_LEVELS = ["warn", "error", "fatal"];
80
+
81
+ // Re-export manifest
82
+ export { manifest } from "./manifest.js";
83
+
84
+ const __filename = fileURLToPath(import.meta.url);
85
+ const __dirname = dirname(__filename);
86
+
87
+ /**
88
+ * Adapter manifest for SQLite log persistence extension.
89
+ */
90
+
91
+ /**
92
+ * SQLite persistence adapter for logs.
93
+ *
94
+ * Design:
95
+ * - Batched writes for performance (default 100 logs per batch)
96
+ * - Auto-flush on interval (default 5 seconds)
97
+ * - FTS5 full-text search on message field
98
+ * - Composite indexes for common query patterns
99
+ * - Shared database for cross-process aggregation
100
+ */
101
+ export class LogSQLitePersistence implements ILogPersistence {
102
+ private db: ISQLDatabase;
103
+ private tableName: string;
104
+ private batchSize: number;
105
+ private flushInterval: number;
106
+ private writeQueue: LogRecord[] = [];
107
+ private flushTimer: NodeJS.Timeout | null = null;
108
+ private retentionTimer: NodeJS.Timeout | null = null;
109
+ private flushing = false;
110
+ private maxRetries: number;
111
+ private retryBaseDelayMs: number;
112
+ private retryMaxDelayMs: number;
113
+ private maxQueueSize: number;
114
+ private droppedLogs = 0;
115
+ private nextFlushNotBefore = 0;
116
+ private shuttingDown = false;
117
+ private closed = false;
118
+ private closedWarningLogged = false;
119
+ private ftsRebuildInProgress = false;
120
+
121
+ // Retention policy
122
+ private retentionMaxAge: number;
123
+ private retentionMaxAgeDebug: number;
124
+ private retentionMaxAgeInfo: number;
125
+ private retentionMaxSizeBytes: number;
126
+ private retentionCleanupIntervalMs: number;
127
+ private retentionCycleCount = 0;
128
+ private totalWritesSinceLastRetention = 0;
129
+ private lastRetentionDeletedAny = true; // assume dirty on first run
130
+
131
+ constructor(config: LogPersistenceConfig) {
132
+ this.db = config.database;
133
+ this.tableName = config.tableName ?? "logs";
134
+ this.batchSize = config.batchSize ?? 100;
135
+ this.flushInterval = config.flushInterval ?? 5000; // 5 seconds
136
+ const runtime = config as LogPersistenceConfig & {
137
+ retryAttempts?: number;
138
+ retryBaseDelayMs?: number;
139
+ retryMaxDelayMs?: number;
140
+ maxQueueSize?: number;
141
+ };
142
+ this.maxRetries = runtime.retryAttempts ?? 5;
143
+ this.retryBaseDelayMs = runtime.retryBaseDelayMs ?? 50;
144
+ this.retryMaxDelayMs = runtime.retryMaxDelayMs ?? 3000;
145
+ this.maxQueueSize = runtime.maxQueueSize ?? 10_000;
146
+
147
+ // Retention policy (defaults always apply to prevent unbounded growth)
148
+ const retention: LogRetentionPolicy = config.retention ?? {};
149
+ this.retentionMaxAge = retention.maxAge ?? DEFAULT_MAX_AGE;
150
+ this.retentionMaxAgeDebug = retention.maxAgeDebug ?? DEFAULT_MAX_AGE_DEBUG;
151
+ this.retentionMaxAgeInfo = retention.maxAgeInfo ?? DEFAULT_MAX_AGE_INFO;
152
+ this.retentionMaxSizeBytes = retention.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
153
+ this.retentionCleanupIntervalMs = retention.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL;
154
+ }
155
+
156
+ /**
157
+ * Initialize database schema and start auto-flush timer.
158
+ * Must be called before using the adapter.
159
+ */
160
+ async initialize(): Promise<void> {
161
+ // Load and execute schema
162
+ // Try dist/ first (production), then src/ (tests)
163
+ let schemaSQL: string;
164
+ try {
165
+ const distPath = join(__dirname, "schema.sql");
166
+ schemaSQL = readFileSync(distPath, "utf-8");
167
+ } catch (_error) {
168
+ const srcPath = join(__dirname, "../src/schema.sql");
169
+ schemaSQL = readFileSync(srcPath, "utf-8");
170
+ }
171
+
172
+ // Execute schema using SQLite's exec method (handles multiple statements)
173
+ // Remove SQL comments first
174
+ const cleanSQL = schemaSQL
175
+ .split("\n")
176
+ .filter((line) => !line.trim().startsWith("--"))
177
+ .join("\n");
178
+
179
+ // SQLiteAdapter should have exec() method for schema migrations
180
+ // Check if exec() is available (utility method in SQLiteAdapter)
181
+ const hasExec = "exec" in this.db;
182
+ const isFunction = typeof (this.db as any).exec === "function";
183
+
184
+ if (hasExec && isFunction) {
185
+ try {
186
+ await (this.db as any).exec(cleanSQL);
187
+ } catch (error) {
188
+ console.error("[LogSQLitePersistence] Schema execution failed:", error);
189
+ throw error;
190
+ }
191
+ } else {
192
+ // Fallback: execute statements one by one
193
+ const statements = cleanSQL
194
+ .split(";")
195
+ .map((s) => s.trim())
196
+ .filter((s) => s.length > 0);
197
+
198
+ for (const statement of statements) {
199
+
200
+ await this.db.query(statement);
201
+ }
202
+ }
203
+
204
+ // Start auto-flush timer
205
+ this.startFlushTimer();
206
+
207
+ // Start retention cleanup timer
208
+ this.startRetentionTimer();
209
+ }
210
+
211
+ /**
212
+ * Write log record to persistent storage.
213
+ * Logs are queued and flushed in batches.
214
+ * Debug/trace logs are skipped when maxAgeDebug is 0.
215
+ */
216
+ async write(record: LogRecord): Promise<void> {
217
+ if (this.shuttingDown || this.closed) {
218
+ return;
219
+ }
220
+ if (this.shouldSkipLevel(record.level)) {
221
+ return;
222
+ }
223
+ this.enqueueRecords([record]);
224
+
225
+ // Flush if batch is full
226
+ if (this.writeQueue.length >= this.batchSize) {
227
+ await this.flush();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Write multiple log records in batch.
233
+ * More efficient than multiple write() calls.
234
+ * Debug/trace logs are filtered when maxAgeDebug is 0.
235
+ */
236
+ async writeBatch(records: LogRecord[]): Promise<void> {
237
+ if (this.shuttingDown || this.closed) {
238
+ return;
239
+ }
240
+ const filtered = records.filter((r) => !this.shouldSkipLevel(r.level));
241
+ if (filtered.length === 0) {
242
+ return;
243
+ }
244
+ this.enqueueRecords(filtered);
245
+
246
+ // Flush if batch is full
247
+ if (this.writeQueue.length >= this.batchSize) {
248
+ await this.flush();
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Query logs from persistent storage.
254
+ */
255
+ async query(
256
+ query: LogQuery,
257
+ options: {
258
+ limit?: number;
259
+ offset?: number;
260
+ sortBy?: "timestamp" | "level";
261
+ sortOrder?: "asc" | "desc";
262
+ } = {},
263
+ ): Promise<{
264
+ logs: LogRecord[];
265
+ total: number;
266
+ hasMore: boolean;
267
+ }> {
268
+ const limit = options.limit ?? 100;
269
+ const offset = options.offset ?? 0;
270
+ const sortBy = options.sortBy ?? "timestamp";
271
+ const sortOrder = options.sortOrder ?? "desc";
272
+
273
+ // Build WHERE clause
274
+ const conditions: string[] = [];
275
+ const params: unknown[] = [];
276
+
277
+ if (query.level) {
278
+ conditions.push("level = ?");
279
+ params.push(query.level);
280
+ }
281
+
282
+ if (query.from !== undefined) {
283
+ conditions.push("timestamp >= ?");
284
+ params.push(query.from);
285
+ }
286
+
287
+ if (query.to !== undefined) {
288
+ conditions.push("timestamp <= ?");
289
+ params.push(query.to);
290
+ }
291
+
292
+ if (query.source) {
293
+ conditions.push("source = ?");
294
+ params.push(query.source);
295
+ }
296
+
297
+ const whereClause =
298
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
299
+
300
+ // Get total count
301
+ const countQuery = `SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`;
302
+ const countResult = await this.db.query<{ count: number }>(
303
+ countQuery,
304
+ params,
305
+ );
306
+ const total = countResult.rows[0]?.count ?? 0;
307
+
308
+ // Get logs
309
+ const logsQuery = `
310
+ SELECT id, timestamp, level, message, source, fields
311
+ FROM ${this.tableName}
312
+ ${whereClause}
313
+ ORDER BY ${sortBy} ${sortOrder}
314
+ LIMIT ? OFFSET ?
315
+ `;
316
+
317
+ const logsResult = await this.db.query<{
318
+ id: string;
319
+ timestamp: number;
320
+ level: string;
321
+ message: string;
322
+ source: string;
323
+ fields: string | null;
324
+ }>(logsQuery, [...params, limit, offset]);
325
+
326
+ const logs: LogRecord[] = logsResult.rows.map((row) => ({
327
+ id: row.id,
328
+ timestamp: row.timestamp,
329
+ level: row.level as LogRecord["level"],
330
+ message: row.message,
331
+ source: row.source,
332
+ fields: row.fields ? JSON.parse(row.fields) : {},
333
+ }));
334
+
335
+ return {
336
+ logs,
337
+ total,
338
+ hasMore: offset + logs.length < total,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Get single log record by ID.
344
+ */
345
+ async getById(id: string): Promise<LogRecord | null> {
346
+ const query = `
347
+ SELECT id, timestamp, level, message, source, fields
348
+ FROM ${this.tableName}
349
+ WHERE id = ?
350
+ `;
351
+
352
+ const result = await this.db.query<{
353
+ id: string;
354
+ timestamp: number;
355
+ level: string;
356
+ message: string;
357
+ source: string;
358
+ fields: string | null;
359
+ }>(query, [id]);
360
+
361
+ if (result.rows.length === 0) {
362
+ return null;
363
+ }
364
+
365
+ const row = result.rows[0]!;
366
+ return {
367
+ id: row.id,
368
+ timestamp: row.timestamp,
369
+ level: row.level as LogRecord["level"],
370
+ message: row.message,
371
+ source: row.source,
372
+ fields: row.fields ? JSON.parse(row.fields) : {},
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Search logs by text query (full-text search).
378
+ */
379
+ async search(
380
+ searchText: string,
381
+ options: {
382
+ limit?: number;
383
+ offset?: number;
384
+ } = {},
385
+ ): Promise<{
386
+ logs: LogRecord[];
387
+ total: number;
388
+ hasMore: boolean;
389
+ }> {
390
+ const limit = options.limit ?? 100;
391
+ const offset = options.offset ?? 0;
392
+
393
+ const runSearch = async (queryText: string): Promise<{
394
+ logs: LogRecord[];
395
+ total: number;
396
+ hasMore: boolean;
397
+ }> => {
398
+ // Count total matches
399
+ const countQuery = `
400
+ SELECT COUNT(*) as count
401
+ FROM logs_fts
402
+ WHERE logs_fts MATCH ?
403
+ `;
404
+ const countResult = await this.db.query<{ count: number }>(countQuery, [
405
+ queryText,
406
+ ]);
407
+ const total = countResult.rows[0]?.count ?? 0;
408
+
409
+ // Get matching logs
410
+ const searchQuery = `
411
+ SELECT logs.id, logs.timestamp, logs.level, logs.message, logs.source, logs.fields
412
+ FROM logs_fts
413
+ INNER JOIN logs ON logs.rowid = logs_fts.rowid
414
+ WHERE logs_fts MATCH ?
415
+ ORDER BY logs.timestamp DESC
416
+ LIMIT ? OFFSET ?
417
+ `;
418
+
419
+ const searchResult = await this.db.query<{
420
+ id: string;
421
+ timestamp: number;
422
+ level: string;
423
+ message: string;
424
+ source: string;
425
+ fields: string | null;
426
+ }>(searchQuery, [queryText, limit, offset]);
427
+
428
+ const logs: LogRecord[] = searchResult.rows.map((row) => ({
429
+ id: row.id,
430
+ timestamp: row.timestamp,
431
+ level: row.level as LogRecord["level"],
432
+ message: row.message,
433
+ source: row.source,
434
+ fields: row.fields ? JSON.parse(row.fields) : {},
435
+ }));
436
+
437
+ return {
438
+ logs,
439
+ total,
440
+ hasMore: offset + logs.length < total,
441
+ };
442
+ };
443
+
444
+ const searchWithRepair = async (queryText: string): Promise<{
445
+ logs: LogRecord[];
446
+ total: number;
447
+ hasMore: boolean;
448
+ }> => {
449
+ const initial = await runSearch(queryText);
450
+ // Index drift symptom: MATCH count exists but JOIN returns no rows.
451
+ if (initial.total > 0 && initial.logs.length === 0) {
452
+ await this.rebuildFtsIndex();
453
+ return runSearch(queryText);
454
+ }
455
+ return initial;
456
+ };
457
+
458
+ try {
459
+ return await searchWithRepair(searchText);
460
+ } catch (error) {
461
+ // Common UX case: plain text with '-' (e.g. kb-labs) is parsed as FTS syntax.
462
+ // Retry as exact phrase to preserve user-friendly behavior.
463
+ if (this.isFtsSyntaxError(error)) {
464
+ const escapedPhrase = `"${searchText.replace(/"/g, '""')}"`;
465
+ return searchWithRepair(escapedPhrase);
466
+ }
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Delete logs older than specified timestamp.
473
+ */
474
+ async deleteOlderThan(beforeTimestamp: number): Promise<number> {
475
+ const query = `DELETE FROM ${this.tableName} WHERE timestamp < ?`;
476
+ const result = await this.db.query(query, [beforeTimestamp]);
477
+ return result.rowCount ?? 0;
478
+ }
479
+
480
+ /**
481
+ * Delete logs matching specific levels older than specified timestamp.
482
+ */
483
+ async deleteByLevelOlderThan(
484
+ levels: string[],
485
+ beforeTimestamp: number,
486
+ ): Promise<number> {
487
+ if (levels.length === 0) {
488
+ return 0;
489
+ }
490
+ const placeholders = levels.map(() => "?").join(",");
491
+ const query = `DELETE FROM ${this.tableName} WHERE level IN (${placeholders}) AND timestamp < ?`;
492
+ const result = await this.db.query(query, [...levels, beforeTimestamp]);
493
+ return result.rowCount ?? 0;
494
+ }
495
+
496
+ /**
497
+ * Get statistics about stored logs.
498
+ */
499
+ async getStats(): Promise<{
500
+ totalLogs: number;
501
+ oldestTimestamp: number;
502
+ newestTimestamp: number;
503
+ sizeBytes: number;
504
+ }> {
505
+ const statsQuery = `
506
+ SELECT
507
+ COUNT(*) as total,
508
+ COALESCE(MIN(timestamp), 0) as oldest,
509
+ COALESCE(MAX(timestamp), 0) as newest
510
+ FROM ${this.tableName}
511
+ `;
512
+
513
+ const statsResult = await this.db.query<{
514
+ total: number;
515
+ oldest: number;
516
+ newest: number;
517
+ }>(statsQuery);
518
+
519
+ const row = statsResult.rows[0];
520
+
521
+ // Get database file size (SQLite specific)
522
+ let sizeBytes = 0;
523
+ try {
524
+ const sizeQuery = `SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()`;
525
+ const sizeResult = await this.db.query<{ size: number }>(sizeQuery);
526
+ sizeBytes = sizeResult.rows[0]?.size ?? 0;
527
+ } catch {
528
+ // Ignore if pragma not supported
529
+ }
530
+
531
+ return {
532
+ totalLogs: row?.total ?? 0,
533
+ oldestTimestamp: row?.oldest ?? 0,
534
+ newestTimestamp: row?.newest ?? 0,
535
+ sizeBytes,
536
+ };
537
+ }
538
+
539
+ /**
540
+ * Close persistence adapter and flush pending writes.
541
+ */
542
+ async close(): Promise<void> {
543
+ this.shuttingDown = true;
544
+ // Stop flush timer
545
+ if (this.flushTimer) {
546
+ clearInterval(this.flushTimer);
547
+ this.flushTimer = null;
548
+ }
549
+ // Stop retention timer
550
+ if (this.retentionTimer) {
551
+ clearInterval(this.retentionTimer);
552
+ this.retentionTimer = null;
553
+ }
554
+
555
+ // Flush remaining logs
556
+ await this.flush();
557
+ this.closed = true;
558
+ }
559
+
560
+ /**
561
+ * Flush pending logs to database.
562
+ * @private
563
+ */
564
+
565
+ private async flush(): Promise<void> {
566
+ if (this.writeQueue.length === 0 || this.flushing) {
567
+ return;
568
+ }
569
+ if (Date.now() < this.nextFlushNotBefore) {
570
+ return;
571
+ }
572
+
573
+ this.flushing = true;
574
+
575
+ try {
576
+ const batch = this.writeQueue.splice(0, this.writeQueue.length);
577
+ const flushed = await this.flushWithRetry(batch);
578
+ if (!flushed) {
579
+ this.requeueToFront(batch);
580
+ }
581
+ } finally {
582
+ this.flushing = false;
583
+ }
584
+ }
585
+
586
+ private async flushWithRetry(batch: LogRecord[]): Promise<boolean> {
587
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
588
+ try {
589
+ await this.insertBatch(batch);
590
+ return true;
591
+ } catch (error) {
592
+ if (this.handleClosedFlushError(error)) {
593
+ return true;
594
+ }
595
+
596
+ if (!this.isRetryableLockError(error)) {
597
+ this.handleNonRetryableFlushError(batch, error);
598
+ return true;
599
+ }
600
+
601
+ if (attempt >= this.maxRetries) {
602
+ this.handleLockedFlushRetryExhausted(batch, attempt, error);
603
+ return false;
604
+ }
605
+
606
+ const delay = this.computeRetryDelayMs(attempt);
607
+ await this.sleep(delay);
608
+ }
609
+ }
610
+
611
+ return false;
612
+ }
613
+
614
+ private getErrorMessage(error: unknown): string {
615
+ return error instanceof Error ? error.message : String(error);
616
+ }
617
+
618
+ private handleClosedFlushError(error: unknown): boolean {
619
+ if (!this.isClosedConnectionError(error)) {
620
+ return false;
621
+ }
622
+ if (!this.closedWarningLogged && !this.shuttingDown) {
623
+ this.closedWarningLogged = true;
624
+ console.warn(
625
+ "[LogSQLitePersistence] Database connection is closed, skipping persistence writes.",
626
+ );
627
+ }
628
+ return true;
629
+ }
630
+
631
+ private handleNonRetryableFlushError(batch: LogRecord[], error: unknown): void {
632
+ this.droppedLogs += batch.length;
633
+ console.error(
634
+ "[LogSQLitePersistence] Non-retryable flush failure, dropping batch:",
635
+ {
636
+ batchSize: batch.length,
637
+ droppedLogs: this.droppedLogs,
638
+ error: this.getErrorMessage(error),
639
+ },
640
+ );
641
+ }
642
+
643
+ private handleLockedFlushRetryExhausted(
644
+ batch: LogRecord[],
645
+ attempt: number,
646
+ error: unknown,
647
+ ): void {
648
+ const delay = this.computeRetryDelayMs(attempt);
649
+ this.nextFlushNotBefore = Date.now() + delay;
650
+ console.warn(
651
+ "[LogSQLitePersistence] DB locked after retries, keeping batch in queue:",
652
+ {
653
+ batchSize: batch.length,
654
+ retryAttempts: this.maxRetries,
655
+ nextRetryInMs: delay,
656
+ error: this.getErrorMessage(error),
657
+ },
658
+ );
659
+ }
660
+
661
+ private async insertBatch(batch: LogRecord[]): Promise<void> {
662
+ const trx = await this.db.transaction();
663
+
664
+ try {
665
+ const insertQuery = `
666
+ INSERT INTO ${this.tableName} (id, timestamp, level, message, source, fields)
667
+ VALUES (?, ?, ?, ?, ?, ?)
668
+ `;
669
+
670
+ for (const record of batch) {
671
+ if (!record.id) {
672
+ record.id = this.generateId();
673
+ }
674
+
675
+ const params = [
676
+ record.id,
677
+ record.timestamp,
678
+ record.level,
679
+ typeof record.message === "string"
680
+ ? record.message
681
+ : JSON.stringify(record.message),
682
+ record.source,
683
+ record.fields && Object.keys(record.fields).length > 0
684
+ ? JSON.stringify(record.fields)
685
+ : null,
686
+ ];
687
+
688
+ if (params.length !== 6 || params.some((p) => p === undefined)) {
689
+ throw new Error(
690
+ `Invalid parameters: expected 6, got ${params.length}.`,
691
+ );
692
+ }
693
+
694
+
695
+ await trx.query(insertQuery, params);
696
+ }
697
+
698
+ await trx.commit();
699
+ this.totalWritesSinceLastRetention += batch.length;
700
+ } catch (error) {
701
+ try {
702
+ await trx.rollback();
703
+ } catch {
704
+ // ignore rollback errors, original error is more important
705
+ }
706
+ throw error;
707
+ }
708
+ }
709
+
710
+ private enqueueRecords(records: LogRecord[]): void {
711
+ if (records.length === 0) {
712
+ return;
713
+ }
714
+
715
+ this.writeQueue.push(...records);
716
+ if (this.writeQueue.length <= this.maxQueueSize) {
717
+ return;
718
+ }
719
+
720
+ const overflow = this.writeQueue.length - this.maxQueueSize;
721
+ this.writeQueue.splice(0, overflow);
722
+ this.droppedLogs += overflow;
723
+ console.warn("[LogSQLitePersistence] Queue overflow, dropping oldest logs:", {
724
+ dropped: overflow,
725
+ maxQueueSize: this.maxQueueSize,
726
+ droppedLogs: this.droppedLogs,
727
+ });
728
+ }
729
+
730
+ private requeueToFront(batch: LogRecord[]): void {
731
+ this.writeQueue = [...batch, ...this.writeQueue];
732
+ if (this.writeQueue.length > this.maxQueueSize) {
733
+ const overflow = this.writeQueue.length - this.maxQueueSize;
734
+ this.writeQueue.splice(this.maxQueueSize, overflow);
735
+ this.droppedLogs += overflow;
736
+ console.warn(
737
+ "[LogSQLitePersistence] Queue overflow after requeue, dropping newest tail logs:",
738
+ {
739
+ dropped: overflow,
740
+ maxQueueSize: this.maxQueueSize,
741
+ droppedLogs: this.droppedLogs,
742
+ },
743
+ );
744
+ }
745
+ }
746
+
747
+ private isRetryableLockError(error: unknown): boolean {
748
+ const msg = error instanceof Error ? error.message : String(error);
749
+ const normalized = msg.toLowerCase();
750
+ return (
751
+ normalized.includes("database is locked") ||
752
+ normalized.includes("database schema is locked") ||
753
+ normalized.includes("sqlite_busy") ||
754
+ normalized.includes("sqlite_locked")
755
+ );
756
+ }
757
+
758
+ private isClosedConnectionError(error: unknown): boolean {
759
+ const msg = error instanceof Error ? error.message : String(error);
760
+ const normalized = msg.toLowerCase();
761
+ return (
762
+ normalized.includes("database is closed") ||
763
+ normalized.includes("database connection is closed") ||
764
+ normalized.includes("connection is closed")
765
+ );
766
+ }
767
+
768
+ private isFtsSyntaxError(error: unknown): boolean {
769
+ const msg = this.getErrorMessage(error).toLowerCase();
770
+ return (
771
+ msg.includes("no such column") ||
772
+ msg.includes("fts5: syntax error") ||
773
+ msg.includes("malformed match expression")
774
+ );
775
+ }
776
+
777
+ private async rebuildFtsIndex(): Promise<void> {
778
+ if (this.ftsRebuildInProgress) {
779
+ return;
780
+ }
781
+ this.ftsRebuildInProgress = true;
782
+ try {
783
+ await this.db.query(`INSERT INTO logs_fts(logs_fts) VALUES('rebuild')`);
784
+ } catch (error) {
785
+ console.warn("[LogSQLitePersistence] Failed to rebuild FTS index:", error);
786
+ } finally {
787
+ this.ftsRebuildInProgress = false;
788
+ }
789
+ }
790
+
791
+ private computeRetryDelayMs(attempt: number): number {
792
+ const exp = this.retryBaseDelayMs * 2 ** Math.max(0, attempt);
793
+ const capped = Math.min(exp, this.retryMaxDelayMs);
794
+ const jitter = Math.floor(Math.random() * Math.min(50, Math.max(1, capped / 4)));
795
+ return capped + jitter;
796
+ }
797
+
798
+ private async sleep(ms: number): Promise<void> {
799
+ await new Promise((resolve) => {
800
+ setTimeout(resolve, ms);
801
+ });
802
+ }
803
+
804
+ /**
805
+ * Start auto-flush timer.
806
+ * @private
807
+ */
808
+ private startFlushTimer(): void {
809
+ this.flushTimer = setInterval(() => {
810
+ this.flush().catch((error) => {
811
+ console.error(
812
+ "[LogSQLitePersistence] Failed to flush log queue:",
813
+ error,
814
+ );
815
+ });
816
+ }, this.flushInterval);
817
+
818
+ // Don't keep process alive for flush timer
819
+ if (this.flushTimer.unref) {
820
+ this.flushTimer.unref();
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Start periodic retention cleanup timer.
826
+ * Runs cleanup at configured interval to enforce retention policies.
827
+ * @private
828
+ */
829
+ private startRetentionTimer(): void {
830
+ // Run first cleanup after a short delay (don't block startup)
831
+ const initialDelay = Math.min(this.retentionCleanupIntervalMs, 10_000);
832
+ const initialTimer = setTimeout(() => {
833
+ this.runRetention().catch((error) => {
834
+ console.error(
835
+ "[LogSQLitePersistence] Retention cleanup failed:",
836
+ error,
837
+ );
838
+ });
839
+ }, initialDelay);
840
+ if (initialTimer.unref) {
841
+ initialTimer.unref();
842
+ }
843
+
844
+ this.retentionTimer = setInterval(() => {
845
+ this.runRetention().catch((error) => {
846
+ console.error(
847
+ "[LogSQLitePersistence] Retention cleanup failed:",
848
+ error,
849
+ );
850
+ });
851
+ }, this.retentionCleanupIntervalMs);
852
+
853
+ if (this.retentionTimer.unref) {
854
+ this.retentionTimer.unref();
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Run retention cleanup: delete expired logs by level, then enforce size limit.
860
+ *
861
+ * I/O optimization:
862
+ * - Skips TTL cleanup only when: no writes AND last cleanup deleted nothing
863
+ * (meaning DB is already clean — no stale rows remain)
864
+ * - Uses a single combined DELETE with OR instead of 3 separate queries
865
+ * - Size check runs only every N cycles (not every time)
866
+ * @private
867
+ */
868
+ private async runRetention(): Promise<void> {
869
+ if (this.shuttingDown || this.closed) {
870
+ return;
871
+ }
872
+
873
+ this.retentionCycleCount++;
874
+ const hadWrites = this.totalWritesSinceLastRetention > 0;
875
+ this.totalWritesSinceLastRetention = 0;
876
+
877
+ // Run TTL cleanup when there are new writes OR when previous run deleted rows
878
+ // (stale data may still remain). Skip only when DB is confirmed clean.
879
+ const shouldRunTTL = hadWrites || this.lastRetentionDeletedAny;
880
+
881
+ const shouldCheckSize =
882
+ this.retentionMaxSizeBytes > 0 &&
883
+ this.retentionCycleCount % SIZE_CHECK_EVERY_N_CYCLES === 0;
884
+
885
+ if (!shouldRunTTL && !shouldCheckSize) {
886
+ return;
887
+ }
888
+
889
+ try {
890
+ let totalDeleted = 0;
891
+
892
+ // Time-based cleanup: single query with OR conditions
893
+ if (shouldRunTTL) {
894
+ const deleted = await this.deleteExpiredLogs();
895
+ totalDeleted += deleted;
896
+ this.lastRetentionDeletedAny = deleted > 0;
897
+ }
898
+
899
+ // Size-based cleanup: expensive, runs infrequently
900
+ if (shouldCheckSize) {
901
+ const sizeDeleted = await this.enforceSizeLimit();
902
+ totalDeleted += sizeDeleted;
903
+ }
904
+
905
+ if (totalDeleted > 0) {
906
+ console.log(
907
+ `[LogSQLitePersistence] Retention cleanup: deleted ${totalDeleted} logs`,
908
+ );
909
+ }
910
+ } catch (error) {
911
+ // Don't let retention errors crash the process
912
+ if (!this.isClosedConnectionError(error)) {
913
+ console.error(
914
+ "[LogSQLitePersistence] Retention cleanup error:",
915
+ this.getErrorMessage(error),
916
+ );
917
+ }
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Delete expired logs using a single combined query.
923
+ * Combines all level-based TTLs into one DELETE statement to minimize I/O.
924
+ * @private
925
+ */
926
+ private async deleteExpiredLogs(): Promise<number> {
927
+ const now = Date.now();
928
+ const conditions: string[] = [];
929
+ const params: unknown[] = [];
930
+
931
+ // debug/trace TTL
932
+ if (this.retentionMaxAgeDebug > 0) {
933
+ const cutoff = now - this.retentionMaxAgeDebug;
934
+ conditions.push(`(level IN ('debug', 'trace') AND timestamp < ?)`);
935
+ params.push(cutoff);
936
+ }
937
+
938
+ // info TTL
939
+ if (this.retentionMaxAgeInfo > 0) {
940
+ const cutoff = now - this.retentionMaxAgeInfo;
941
+ conditions.push(`(level = 'info' AND timestamp < ?)`);
942
+ params.push(cutoff);
943
+ }
944
+
945
+ // warn/error/fatal TTL
946
+ if (this.retentionMaxAge > 0) {
947
+ const cutoff = now - this.retentionMaxAge;
948
+ conditions.push(`(level IN ('warn', 'error', 'fatal') AND timestamp < ?)`);
949
+ params.push(cutoff);
950
+ }
951
+
952
+ if (conditions.length === 0) {
953
+ return 0;
954
+ }
955
+
956
+ const query = `DELETE FROM ${this.tableName} WHERE ${conditions.join(" OR ")}`;
957
+ const result = await this.db.query(query, params);
958
+ return result.rowCount ?? 0;
959
+ }
960
+
961
+ /**
962
+ * Delete oldest logs in batches until DB size is under maxSizeBytes.
963
+ * @private
964
+ */
965
+ private async enforceSizeLimit(): Promise<number> {
966
+ let totalDeleted = 0;
967
+ let iterations = 0;
968
+ const maxIterations = 100; // Safety valve
969
+
970
+ while (iterations < maxIterations) {
971
+ iterations++;
972
+ const stats = await this.getStats();
973
+ if (stats.sizeBytes <= this.retentionMaxSizeBytes) {
974
+ break;
975
+ }
976
+ if (stats.totalLogs === 0) {
977
+ break;
978
+ }
979
+
980
+ // Delete oldest batch
981
+ const oldestQuery = `
982
+ SELECT MAX(timestamp) as cutoff FROM (
983
+ SELECT timestamp FROM ${this.tableName}
984
+ ORDER BY timestamp ASC
985
+ LIMIT ${SIZE_CLEANUP_BATCH}
986
+ )
987
+ `;
988
+ const cutoffResult = await this.db.query<{ cutoff: number | null }>(oldestQuery);
989
+ const cutoff = cutoffResult.rows[0]?.cutoff;
990
+ if (cutoff == null) {
991
+ break;
992
+ }
993
+
994
+ const deleted = await this.deleteOlderThan(cutoff + 1);
995
+ totalDeleted += deleted;
996
+ if (deleted === 0) {
997
+ break;
998
+ }
999
+ }
1000
+
1001
+ // VACUUM if we deleted a significant amount
1002
+ if (totalDeleted > SIZE_CLEANUP_BATCH) {
1003
+ try {
1004
+ await this.db.query("VACUUM");
1005
+ } catch {
1006
+ // VACUUM can fail if DB is locked by another connection — not critical
1007
+ }
1008
+ }
1009
+
1010
+ return totalDeleted;
1011
+ }
1012
+
1013
+ /**
1014
+ * Check if a log level should be skipped (not persisted).
1015
+ * Debug/trace are skipped when maxAgeDebug is 0.
1016
+ * @private
1017
+ */
1018
+ private shouldSkipLevel(level: string): boolean {
1019
+ if (this.retentionMaxAgeDebug === 0 && DEBUG_LEVELS.includes(level)) {
1020
+ return true;
1021
+ }
1022
+ if (this.retentionMaxAgeInfo === 0 && INFO_LEVELS.includes(level)) {
1023
+ return true;
1024
+ }
1025
+ return false;
1026
+ }
1027
+
1028
+ /**
1029
+ * Generate unique log ID using ULID from core-platform.
1030
+ * Delegates to generateLogId() for consistency across all adapters.
1031
+ * @private
1032
+ */
1033
+ private generateId(): string {
1034
+ return generateLogId();
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Dependencies for SQLite log persistence adapter.
1040
+ * Matches manifest.requires.adapters: [{ id: 'db', alias: 'database' }]
1041
+ */
1042
+ export interface LogPersistenceDeps {
1043
+ database: ISQLDatabase;
1044
+ }
1045
+
1046
+ /**
1047
+ * Factory function for creating SQLite log persistence adapter.
1048
+ * This is the function called by platform initialization.
1049
+ *
1050
+ * @param config - Persistence configuration (can be empty, database comes from deps)
1051
+ * @param deps - Required dependencies (database)
1052
+ * @returns Initialized persistence adapter
1053
+ */
1054
+ export async function createAdapter(
1055
+ config: Omit<LogPersistenceConfig, "database">,
1056
+ deps: LogPersistenceDeps,
1057
+ ): Promise<LogSQLitePersistence> {
1058
+ const fullConfig: LogPersistenceConfig = {
1059
+ ...config,
1060
+ database: deps.database,
1061
+ };
1062
+ const adapter = new LogSQLitePersistence(fullConfig);
1063
+ await adapter.initialize();
1064
+ return adapter;
1065
+ }
1066
+
1067
+ // Default export for convenience
1068
+ export default createAdapter;