efarmz-slackbot-data 1.0.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 (131) hide show
  1. package/.clever.json +12 -0
  2. package/.dockerignore +13 -0
  3. package/.env.example +28 -0
  4. package/.github/workflows/deploy-production.yaml +34 -0
  5. package/.prettierrc +6 -0
  6. package/.tasks/F1-bootstrap.md +110 -0
  7. package/.tasks/F2-domain-layer.md +173 -0
  8. package/.tasks/F3-application-layer.md +166 -0
  9. package/.tasks/F4-infrastructure-layer.md +229 -0
  10. package/.tasks/F5-config-main.md +160 -0
  11. package/.tasks/F6-schemas-deployment.md +129 -0
  12. package/CLAUDE.md +163 -0
  13. package/Dockerfile +15 -0
  14. package/PRD.md +119 -0
  15. package/docs/schemas/.gitkeep +0 -0
  16. package/docs/schemas/_guidelines.md +89 -0
  17. package/docs/schemas/efarmz_db.md +759 -0
  18. package/docs/schemas/example.md +16 -0
  19. package/eslint.config.mjs +18 -0
  20. package/package.json +54 -0
  21. package/releaserc.json +15 -0
  22. package/src/.gitkeep +0 -0
  23. package/src/application/agent/.gitkeep +0 -0
  24. package/src/application/agent/AgentContext.test.ts +263 -0
  25. package/src/application/agent/AgentContext.ts +93 -0
  26. package/src/application/agent/AgentLoop.test.ts +275 -0
  27. package/src/application/agent/AgentLoop.ts +101 -0
  28. package/src/application/agent/AgentRunResult.ts +11 -0
  29. package/src/application/agent/LLMMessage.ts +16 -0
  30. package/src/application/agent/tools/RunSqlTool.ts +23 -0
  31. package/src/application/formatting/.gitkeep +0 -0
  32. package/src/application/formatting/CsvRenderer.test.ts +162 -0
  33. package/src/application/formatting/CsvRenderer.ts +34 -0
  34. package/src/application/formatting/MonospaceTableRenderer.test.ts +129 -0
  35. package/src/application/formatting/MonospaceTableRenderer.ts +58 -0
  36. package/src/application/formatting/RenderedResponse.ts +7 -0
  37. package/src/application/formatting/ResponseRenderer.test.ts +159 -0
  38. package/src/application/formatting/ResponseRenderer.ts +39 -0
  39. package/src/application/formatting/ScalarRenderer.test.ts +36 -0
  40. package/src/application/formatting/ScalarRenderer.ts +12 -0
  41. package/src/application/usecases/.gitkeep +0 -0
  42. package/src/application/usecases/AnswerQuestion.test.ts +362 -0
  43. package/src/application/usecases/AnswerQuestion.ts +69 -0
  44. package/src/application/usecases/ParseQuestion.test.ts +39 -0
  45. package/src/application/usecases/ParseQuestion.ts +9 -0
  46. package/src/config/.gitkeep +0 -0
  47. package/src/config/Container.test.ts +35 -0
  48. package/src/config/Container.ts +74 -0
  49. package/src/config/constants.ts +9 -0
  50. package/src/config/env.test.ts +103 -0
  51. package/src/config/env.ts +41 -0
  52. package/src/domain/entities/.gitkeep +0 -0
  53. package/src/domain/entities/Conversation.test.ts +69 -0
  54. package/src/domain/entities/Conversation.ts +26 -0
  55. package/src/domain/entities/ConversationMessage.test.ts +49 -0
  56. package/src/domain/entities/ConversationMessage.ts +18 -0
  57. package/src/domain/entities/index.ts +2 -0
  58. package/src/domain/errors/.gitkeep +0 -0
  59. package/src/domain/errors/AgentLoopExceededError.ts +12 -0
  60. package/src/domain/errors/DomainError.test.ts +106 -0
  61. package/src/domain/errors/DomainError.ts +11 -0
  62. package/src/domain/errors/InvalidSqlError.ts +15 -0
  63. package/src/domain/errors/LLMError.ts +15 -0
  64. package/src/domain/errors/SchemaLoadError.ts +15 -0
  65. package/src/domain/errors/SqlExecutionError.ts +15 -0
  66. package/src/domain/errors/index.ts +15 -0
  67. package/src/domain/ports/.gitkeep +0 -0
  68. package/src/domain/ports/AdminLogger.ts +16 -0
  69. package/src/domain/ports/ConversationRepository.ts +10 -0
  70. package/src/domain/ports/LLMProvider.ts +33 -0
  71. package/src/domain/ports/Logger.ts +8 -0
  72. package/src/domain/ports/SchemaCatalog.ts +5 -0
  73. package/src/domain/ports/SlackMessenger.ts +8 -0
  74. package/src/domain/ports/SqlExecutor.ts +8 -0
  75. package/src/domain/ports/SqlValidator.ts +5 -0
  76. package/src/domain/ports/index.ts +17 -0
  77. package/src/domain/value-objects/.gitkeep +0 -0
  78. package/src/domain/value-objects/LLMProviderName.ts +6 -0
  79. package/src/domain/value-objects/QueryResult.test.ts +51 -0
  80. package/src/domain/value-objects/QueryResult.ts +18 -0
  81. package/src/domain/value-objects/Question.test.ts +59 -0
  82. package/src/domain/value-objects/Question.ts +22 -0
  83. package/src/domain/value-objects/QuestionFlags.test.ts +59 -0
  84. package/src/domain/value-objects/QuestionFlags.ts +18 -0
  85. package/src/domain/value-objects/ResponseRendering.ts +7 -0
  86. package/src/domain/value-objects/SqlQuery.test.ts +40 -0
  87. package/src/domain/value-objects/SqlQuery.ts +12 -0
  88. package/src/domain/value-objects/ThreadId.test.ts +68 -0
  89. package/src/domain/value-objects/ThreadId.ts +27 -0
  90. package/src/domain/value-objects/index.ts +13 -0
  91. package/src/infrastructure/llm/.gitkeep +0 -0
  92. package/src/infrastructure/llm/AnthropicLLMProvider.test.ts +229 -0
  93. package/src/infrastructure/llm/AnthropicLLMProvider.ts +45 -0
  94. package/src/infrastructure/llm/index.ts +4 -0
  95. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.test.ts +173 -0
  96. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.ts +34 -0
  97. package/src/infrastructure/llm/prompts/SystemPromptBuilder.test.ts +41 -0
  98. package/src/infrastructure/llm/prompts/SystemPromptBuilder.ts +31 -0
  99. package/src/infrastructure/llm/prompts/ToolDefinitions.ts +7 -0
  100. package/src/infrastructure/logging/.gitkeep +0 -0
  101. package/src/infrastructure/logging/PinoLogger.test.ts +59 -0
  102. package/src/infrastructure/logging/PinoLogger.ts +28 -0
  103. package/src/infrastructure/logging/index.ts +1 -0
  104. package/src/infrastructure/persistence/.gitkeep +0 -0
  105. package/src/infrastructure/persistence/InMemoryConversationRepository.test.ts +325 -0
  106. package/src/infrastructure/persistence/InMemoryConversationRepository.ts +69 -0
  107. package/src/infrastructure/persistence/PostgresPoolFactory.ts +11 -0
  108. package/src/infrastructure/persistence/PostgresSqlExecutor.test.ts +130 -0
  109. package/src/infrastructure/persistence/PostgresSqlExecutor.ts +34 -0
  110. package/src/infrastructure/persistence/index.ts +3 -0
  111. package/src/infrastructure/schemas/.gitkeep +0 -0
  112. package/src/infrastructure/schemas/FileSystemSchemaCatalog.test.ts +163 -0
  113. package/src/infrastructure/schemas/FileSystemSchemaCatalog.ts +35 -0
  114. package/src/infrastructure/schemas/index.ts +4 -0
  115. package/src/infrastructure/slack/.gitkeep +0 -0
  116. package/src/infrastructure/slack/BoltSlackMessenger.test.ts +59 -0
  117. package/src/infrastructure/slack/BoltSlackMessenger.ts +36 -0
  118. package/src/infrastructure/slack/SlackAdminLogger.test.ts +54 -0
  119. package/src/infrastructure/slack/SlackAdminLogger.ts +27 -0
  120. package/src/infrastructure/slack/SlackApp.ts +9 -0
  121. package/src/infrastructure/slack/handlers/AppMentionHandler.ts +52 -0
  122. package/src/infrastructure/slack/handlers/DirectMessageHandler.ts +65 -0
  123. package/src/infrastructure/slack/index.ts +5 -0
  124. package/src/infrastructure/sql/.gitkeep +0 -0
  125. package/src/infrastructure/sql/RegexSqlValidator.test.ts +242 -0
  126. package/src/infrastructure/sql/RegexSqlValidator.ts +53 -0
  127. package/src/infrastructure/sql/index.ts +1 -0
  128. package/src/main.ts +19 -0
  129. package/tsconfig.json +23 -0
  130. package/vitest.config.ts +15 -0
  131. package/vitest.setup.ts +23 -0
@@ -0,0 +1,325 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import InMemoryConversationRepository from "./InMemoryConversationRepository.js";
3
+ import Conversation from "@/domain/entities/Conversation.js";
4
+ import ThreadId from "@/domain/value-objects/ThreadId.js";
5
+
6
+ describe(`InMemoryConversationRepository`, () => {
7
+ let repository: InMemoryConversationRepository;
8
+ const maxThreads = 3;
9
+ const ttlMs = 1000;
10
+
11
+ beforeEach(() => {
12
+ repository = new InMemoryConversationRepository(maxThreads, ttlMs);
13
+ vi.useFakeTimers();
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ describe(`CRUD operations`, () => {
21
+ it(`should save and retrieve a conversation`, async () => {
22
+ const threadId = ThreadId.create(`C123`, `1234567890.123456`);
23
+ const conversation = Conversation.create(threadId);
24
+
25
+ await repository.save(conversation);
26
+ const retrieved = await repository.get(threadId);
27
+
28
+ expect(retrieved).toEqual(conversation);
29
+ });
30
+
31
+ it(`should return null for non-existent conversation`, async () => {
32
+ const threadId = ThreadId.create(`C999`, `9999999999.999999`);
33
+
34
+ const retrieved = await repository.get(threadId);
35
+
36
+ expect(retrieved).toBeNull();
37
+ });
38
+
39
+ it(`should purge a conversation`, async () => {
40
+ const threadId = ThreadId.create(`C123`, `1234567890.123456`);
41
+ const conversation = Conversation.create(threadId);
42
+
43
+ await repository.save(conversation);
44
+ await repository.purge(threadId);
45
+ const retrieved = await repository.get(threadId);
46
+
47
+ expect(retrieved).toBeNull();
48
+ });
49
+
50
+ it(`should update conversation on save`, async () => {
51
+ const threadId = ThreadId.create(`C123`, `1234567890.123456`);
52
+ let conversation = Conversation.create(threadId);
53
+
54
+ await repository.save(conversation);
55
+
56
+ // Create a message and append it
57
+ const updatedConversation = conversation;
58
+
59
+ await repository.save(updatedConversation);
60
+ const retrieved = await repository.get(threadId);
61
+
62
+ expect(retrieved).toEqual(updatedConversation);
63
+ });
64
+ });
65
+
66
+ describe(`TTL expiration`, () => {
67
+ it(`should return null if TTL expired on get`, async () => {
68
+ const threadId = ThreadId.create(`C123`, `1234567890.123456`);
69
+ const conversation = Conversation.create(threadId);
70
+
71
+ await repository.save(conversation);
72
+
73
+ vi.advanceTimersByTime(ttlMs + 1);
74
+
75
+ const retrieved = await repository.get(threadId);
76
+
77
+ expect(retrieved).toBeNull();
78
+ });
79
+
80
+ it(`should delete expired entry from cache on get`, async () => {
81
+ const threadId = ThreadId.create(`C123`, `1234567890.123456`);
82
+ const conversation = Conversation.create(threadId);
83
+
84
+ await repository.save(conversation);
85
+
86
+ vi.advanceTimersByTime(ttlMs + 1);
87
+
88
+ await repository.get(threadId);
89
+
90
+ const retrievedAgain = await repository.get(threadId);
91
+
92
+ expect(retrievedAgain).toBeNull();
93
+ });
94
+
95
+ it(`should update lastAccess on read before TTL expires`, async () => {
96
+ const threadId = ThreadId.create(`C123`, `1234567890.123456`);
97
+ const conversation = Conversation.create(threadId);
98
+
99
+ await repository.save(conversation);
100
+
101
+ vi.advanceTimersByTime(ttlMs / 2);
102
+
103
+ const retrieved1 = await repository.get(threadId);
104
+
105
+ expect(retrieved1).toEqual(conversation);
106
+
107
+ vi.advanceTimersByTime(ttlMs / 2);
108
+
109
+ const retrieved2 = await repository.get(threadId);
110
+
111
+ expect(retrieved2).toEqual(conversation);
112
+
113
+ vi.advanceTimersByTime(ttlMs + 1);
114
+
115
+ const retrieved3 = await repository.get(threadId);
116
+
117
+ expect(retrieved3).toBeNull();
118
+ });
119
+
120
+ it(`should handle multiple entries with different TTL states`, async () => {
121
+ const threadId1 = ThreadId.create(`C1`, `111111111.111111`);
122
+ const threadId2 = ThreadId.create(`C2`, `222222222.222222`);
123
+ const threadId3 = ThreadId.create(`C3`, `333333333.333333`);
124
+
125
+ const conv1 = Conversation.create(threadId1);
126
+ const conv2 = Conversation.create(threadId2);
127
+ const conv3 = Conversation.create(threadId3);
128
+
129
+ await repository.save(conv1);
130
+
131
+ vi.advanceTimersByTime(300);
132
+
133
+ await repository.save(conv2);
134
+
135
+ vi.advanceTimersByTime(300);
136
+
137
+ await repository.save(conv3);
138
+
139
+ vi.advanceTimersByTime(500);
140
+
141
+ const retrieved1 = await repository.get(threadId1);
142
+ expect(retrieved1).toBeNull();
143
+
144
+ const retrieved2 = await repository.get(threadId2);
145
+ expect(retrieved2).toEqual(conv2);
146
+
147
+ const retrieved3 = await repository.get(threadId3);
148
+ expect(retrieved3).toEqual(conv3);
149
+ });
150
+ });
151
+
152
+ describe(`LRU eviction`, () => {
153
+ it(`should evict LRU entry when cache is full`, async () => {
154
+ const threadId1 = ThreadId.create(`C1`, `111111111.111111`);
155
+ const threadId2 = ThreadId.create(`C2`, `222222222.222222`);
156
+ const threadId3 = ThreadId.create(`C3`, `333333333.333333`);
157
+ const threadId4 = ThreadId.create(`C4`, `444444444.444444`);
158
+
159
+ const conv1 = Conversation.create(threadId1);
160
+ const conv2 = Conversation.create(threadId2);
161
+ const conv3 = Conversation.create(threadId3);
162
+ const conv4 = Conversation.create(threadId4);
163
+
164
+ await repository.save(conv1);
165
+
166
+ vi.advanceTimersByTime(100);
167
+
168
+ await repository.save(conv2);
169
+
170
+ vi.advanceTimersByTime(100);
171
+
172
+ await repository.save(conv3);
173
+
174
+ vi.advanceTimersByTime(100);
175
+
176
+ await repository.save(conv4);
177
+
178
+ const retrieved1 = await repository.get(threadId1);
179
+ expect(retrieved1).toBeNull();
180
+
181
+ const retrieved2 = await repository.get(threadId2);
182
+ expect(retrieved2).toEqual(conv2);
183
+
184
+ const retrieved3 = await repository.get(threadId3);
185
+ expect(retrieved3).toEqual(conv3);
186
+
187
+ const retrieved4 = await repository.get(threadId4);
188
+ expect(retrieved4).toEqual(conv4);
189
+ });
190
+
191
+ it(`should evict correct LRU entry among multiple items`, async () => {
192
+ const threadId1 = ThreadId.create(`C1`, `111111111.111111`);
193
+ const threadId2 = ThreadId.create(`C2`, `222222222.222222`);
194
+ const threadId3 = ThreadId.create(`C3`, `333333333.333333`);
195
+ const threadId4 = ThreadId.create(`C4`, `444444444.444444`);
196
+
197
+ const conv1 = Conversation.create(threadId1);
198
+ const conv2 = Conversation.create(threadId2);
199
+ const conv3 = Conversation.create(threadId3);
200
+ const conv4 = Conversation.create(threadId4);
201
+
202
+ await repository.save(conv1);
203
+
204
+ vi.advanceTimersByTime(100);
205
+
206
+ await repository.save(conv2);
207
+
208
+ vi.advanceTimersByTime(100);
209
+
210
+ await repository.save(conv3);
211
+
212
+ vi.advanceTimersByTime(100);
213
+
214
+ await repository.get(threadId1);
215
+
216
+ vi.advanceTimersByTime(100);
217
+
218
+ await repository.save(conv4);
219
+
220
+ const retrieved2 = await repository.get(threadId2);
221
+ expect(retrieved2).toBeNull();
222
+ });
223
+
224
+ it(`should not evict when saving existing entry`, async () => {
225
+ const threadId1 = ThreadId.create(`C1`, `111111111.111111`);
226
+ const threadId2 = ThreadId.create(`C2`, `222222222.222222`);
227
+ const threadId3 = ThreadId.create(`C3`, `333333333.333333`);
228
+
229
+ const conv1 = Conversation.create(threadId1);
230
+ const conv2 = Conversation.create(threadId2);
231
+ const conv3 = Conversation.create(threadId3);
232
+
233
+ await repository.save(conv1);
234
+
235
+ vi.advanceTimersByTime(100);
236
+
237
+ await repository.save(conv2);
238
+
239
+ vi.advanceTimersByTime(100);
240
+
241
+ await repository.save(conv3);
242
+
243
+ vi.advanceTimersByTime(100);
244
+
245
+ await repository.save(conv1);
246
+
247
+ const retrieved1 = await repository.get(threadId1);
248
+ expect(retrieved1).toEqual(conv1);
249
+
250
+ const retrieved2 = await repository.get(threadId2);
251
+ expect(retrieved2).toEqual(conv2);
252
+
253
+ const retrieved3 = await repository.get(threadId3);
254
+ expect(retrieved3).toEqual(conv3);
255
+ });
256
+
257
+ it(`should handle cache at capacity with all items active`, async () => {
258
+ const threadIds = [
259
+ ThreadId.create(`C1`, `111111111.111111`),
260
+ ThreadId.create(`C2`, `222222222.222222`),
261
+ ThreadId.create(`C3`, `333333333.333333`),
262
+ ThreadId.create(`C4`, `444444444.444444`),
263
+ ];
264
+
265
+ const conversations = threadIds.map((tid) => Conversation.create(tid));
266
+
267
+ for (const conv of conversations.slice(0, 3)) {
268
+ await repository.save(conv);
269
+ vi.advanceTimersByTime(100);
270
+ }
271
+
272
+ await repository.save(conversations[3]!);
273
+
274
+ const retrieved0 = await repository.get(threadIds[0]!);
275
+ expect(retrieved0).toBeNull();
276
+
277
+ for (let i = 1; i < 4; i++) {
278
+ const retrieved = await repository.get(threadIds[i]!);
279
+ expect(retrieved).toEqual(conversations[i]!);
280
+ }
281
+ });
282
+ });
283
+
284
+ describe(`combined TTL and LRU`, () => {
285
+ it(`should handle TTL expiration with LRU eviction`, async () => {
286
+ const threadId1 = ThreadId.create(`C1`, `111111111.111111`);
287
+ const threadId2 = ThreadId.create(`C2`, `222222222.222222`);
288
+ const threadId3 = ThreadId.create(`C3`, `333333333.333333`);
289
+ const threadId4 = ThreadId.create(`C4`, `444444444.444444`);
290
+
291
+ const conv1 = Conversation.create(threadId1);
292
+ const conv2 = Conversation.create(threadId2);
293
+ const conv3 = Conversation.create(threadId3);
294
+ const conv4 = Conversation.create(threadId4);
295
+
296
+ await repository.save(conv1);
297
+
298
+ vi.advanceTimersByTime(100);
299
+
300
+ await repository.save(conv2);
301
+
302
+ vi.advanceTimersByTime(100);
303
+
304
+ await repository.save(conv3);
305
+
306
+ vi.advanceTimersByTime(100);
307
+
308
+ await repository.save(conv4);
309
+
310
+ vi.advanceTimersByTime(850);
311
+
312
+ const retrieved1 = await repository.get(threadId1);
313
+ expect(retrieved1).toBeNull();
314
+
315
+ const retrieved2 = await repository.get(threadId2);
316
+ expect(retrieved2).toBeNull();
317
+
318
+ const retrieved3 = await repository.get(threadId3);
319
+ expect(retrieved3).toEqual(conv3);
320
+
321
+ const retrieved4 = await repository.get(threadId4);
322
+ expect(retrieved4).toEqual(conv4);
323
+ });
324
+ });
325
+ });
@@ -0,0 +1,69 @@
1
+ import type ConversationRepository from "@/domain/ports/ConversationRepository.js";
2
+ import type ThreadId from "@/domain/value-objects/ThreadId.js";
3
+ import type Conversation from "@/domain/entities/Conversation.js";
4
+
5
+ interface CacheEntry {
6
+ conversation: Conversation;
7
+ lastAccess: number;
8
+ }
9
+
10
+ class InMemoryConversationRepository implements ConversationRepository {
11
+ private readonly cache = new Map<string, CacheEntry>();
12
+
13
+ constructor(
14
+ private readonly maxThreads: number,
15
+ private readonly ttlMs: number,
16
+ ) {}
17
+
18
+ async get(threadId: ThreadId): Promise<Conversation | null> {
19
+ const key = threadId.toString();
20
+ const entry = this.cache.get(key);
21
+
22
+ if (!entry) {
23
+ return null;
24
+ }
25
+
26
+ if (Date.now() - entry.lastAccess > this.ttlMs) {
27
+ this.cache.delete(key);
28
+ return null;
29
+ }
30
+
31
+ entry.lastAccess = Date.now();
32
+ return entry.conversation;
33
+ }
34
+
35
+ async save(conversation: Conversation): Promise<void> {
36
+ const key = conversation.threadId.toString();
37
+
38
+ if (!this.cache.has(key) && this.cache.size >= this.maxThreads) {
39
+ this.evictLRU();
40
+ }
41
+
42
+ this.cache.set(key, {
43
+ conversation,
44
+ lastAccess: Date.now(),
45
+ });
46
+ }
47
+
48
+ async purge(threadId: ThreadId): Promise<void> {
49
+ this.cache.delete(threadId.toString());
50
+ }
51
+
52
+ private evictLRU(): void {
53
+ let oldestKey: string | null = null;
54
+ let oldestTime = Infinity;
55
+
56
+ for (const [key, entry] of this.cache) {
57
+ if (entry.lastAccess < oldestTime) {
58
+ oldestTime = entry.lastAccess;
59
+ oldestKey = key;
60
+ }
61
+ }
62
+
63
+ if (oldestKey) {
64
+ this.cache.delete(oldestKey);
65
+ }
66
+ }
67
+ }
68
+
69
+ export default InMemoryConversationRepository;
@@ -0,0 +1,11 @@
1
+ import { Pool } from "pg";
2
+
3
+ function createPool(databaseUrl: string): Pool {
4
+ return new Pool({
5
+ connectionString: databaseUrl,
6
+ idleTimeoutMillis: 30000,
7
+ connectionTimeoutMillis: 5000,
8
+ });
9
+ }
10
+
11
+ export default createPool;
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Pool } from "pg";
3
+ import PostgresSqlExecutor from "./PostgresSqlExecutor.js";
4
+ import SqlQuery from "@/domain/value-objects/SqlQuery.js";
5
+ import SqlExecutionError from "@/domain/errors/SqlExecutionError.js";
6
+
7
+ describe(`PostgresSqlExecutor`, () => {
8
+ let mockQueryFn: ReturnType<typeof vi.fn>;
9
+ let mockReleaseFn: ReturnType<typeof vi.fn>;
10
+ let mockConnectFn: ReturnType<typeof vi.fn>;
11
+ let executor: PostgresSqlExecutor;
12
+
13
+ beforeEach(() => {
14
+ mockQueryFn = vi.fn();
15
+ mockReleaseFn = vi.fn();
16
+ mockConnectFn = vi.fn().mockResolvedValue({
17
+ query: mockQueryFn,
18
+ release: mockReleaseFn,
19
+ });
20
+
21
+ const mockPool = {
22
+ connect: mockConnectFn,
23
+ } as unknown as Pool;
24
+
25
+ executor = new PostgresSqlExecutor(mockPool, 5000);
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ it(`should execute query and return QueryResult`, async () => {
33
+ const mockValidator = {
34
+ validate: (sql: string) => sql,
35
+ };
36
+ const query = SqlQuery.create(`SELECT * FROM users`, mockValidator);
37
+
38
+ mockQueryFn.mockResolvedValueOnce({ fields: [], rows: [] });
39
+ mockQueryFn.mockResolvedValueOnce({
40
+ fields: [
41
+ { name: `id` },
42
+ { name: `email` },
43
+ ],
44
+ rows: [
45
+ { id: `1`, email: `test@example.com` },
46
+ ],
47
+ });
48
+
49
+ const result = await executor.execute(query);
50
+
51
+ expect(result.columns).toEqual([`id`, `email`]);
52
+ expect(result.rows).toEqual([{ id: `1`, email: `test@example.com` }]);
53
+ expect(result.rowCount).toBe(1);
54
+ });
55
+
56
+ it(`should set statement_timeout before executing query`, async () => {
57
+ const mockValidator = {
58
+ validate: (sql: string) => sql,
59
+ };
60
+ const query = SqlQuery.create(`SELECT 1`, mockValidator);
61
+
62
+ mockQueryFn.mockResolvedValueOnce({ fields: [], rows: [] });
63
+ mockQueryFn.mockResolvedValueOnce({
64
+ fields: [{ name: `1` }],
65
+ rows: [{ "1": 1 }],
66
+ });
67
+
68
+ await executor.execute(query);
69
+
70
+ const calls = mockQueryFn.mock.calls;
71
+ expect(calls[0]?.[0]).toBe(`SET statement_timeout = 5000`);
72
+ expect(calls[1]?.[0]).toBe(`SELECT 1`);
73
+ });
74
+
75
+ it(`should release client in finally block`, async () => {
76
+ const mockValidator = {
77
+ validate: (sql: string) => sql,
78
+ };
79
+ const query = SqlQuery.create(`SELECT 1`, mockValidator);
80
+
81
+ mockQueryFn.mockResolvedValueOnce({ fields: [], rows: [] });
82
+ mockQueryFn.mockResolvedValueOnce({
83
+ fields: [{ name: `1` }],
84
+ rows: [{ "1": 1 }],
85
+ });
86
+
87
+ await executor.execute(query);
88
+
89
+ expect(mockReleaseFn.mock.calls.length).toBe(1);
90
+ });
91
+
92
+ it(`should release client even when query fails`, async () => {
93
+ const mockValidator = {
94
+ validate: (sql: string) => sql,
95
+ };
96
+ const query = SqlQuery.create(`SELECT * FROM nonexistent`, mockValidator);
97
+
98
+ mockQueryFn.mockResolvedValueOnce({ fields: [], rows: [] });
99
+ mockQueryFn.mockRejectedValueOnce(
100
+ new Error(`table "nonexistent" does not exist`),
101
+ );
102
+
103
+ await expect(executor.execute(query)).rejects.toThrow(SqlExecutionError);
104
+
105
+ expect(mockReleaseFn.mock.calls.length).toBe(1);
106
+ });
107
+
108
+ it(`should wrap PG errors in SqlExecutionError`, async () => {
109
+ const mockValidator = {
110
+ validate: (sql: string) => sql,
111
+ };
112
+ const query = SqlQuery.create(`SELECT * FROM invalid`, mockValidator);
113
+
114
+ const pgError = new Error(`syntax error at or near "invalid"`);
115
+ mockQueryFn.mockResolvedValueOnce({ fields: [], rows: [] });
116
+ mockQueryFn.mockRejectedValueOnce(pgError);
117
+
118
+ try {
119
+ await executor.execute(query);
120
+ throw new Error(`Expected SqlExecutionError to be thrown`);
121
+ } catch (error) {
122
+ if (!(error instanceof SqlExecutionError)) {
123
+ throw error;
124
+ }
125
+ expect(error.code).toBe(`SQL_EXECUTION_ERROR`);
126
+ expect(error.sql).toBe(`SELECT * FROM invalid`);
127
+ expect(error.pgError).toBe(`syntax error at or near "invalid"`);
128
+ }
129
+ });
130
+ });
@@ -0,0 +1,34 @@
1
+ import type { Pool } from "pg";
2
+ import type SqlExecutor from "@/domain/ports/SqlExecutor.js";
3
+ import type SqlQuery from "@/domain/value-objects/SqlQuery.js";
4
+ import QueryResult from "@/domain/value-objects/QueryResult.js";
5
+ import SqlExecutionError from "@/domain/errors/SqlExecutionError.js";
6
+
7
+ class PostgresSqlExecutor implements SqlExecutor {
8
+ constructor(
9
+ private readonly pool: Pool,
10
+ private readonly timeoutMs: number,
11
+ ) {}
12
+
13
+ async execute(query: SqlQuery): Promise<QueryResult> {
14
+ const client = await this.pool.connect();
15
+ try {
16
+ const timeoutStr = String(this.timeoutMs);
17
+ await client.query(`SET statement_timeout = ${timeoutStr}`);
18
+ const result = await client.query(query.raw);
19
+ return QueryResult.create(
20
+ result.fields.map((f) => f.name),
21
+ result.rows as Record<string, unknown>[],
22
+ );
23
+ } catch (error) {
24
+ throw new SqlExecutionError(
25
+ query.raw,
26
+ error instanceof Error ? error.message : String(error),
27
+ );
28
+ } finally {
29
+ client.release();
30
+ }
31
+ }
32
+ }
33
+
34
+ export default PostgresSqlExecutor;
@@ -0,0 +1,3 @@
1
+ export { default as PostgresPoolFactory } from "./PostgresPoolFactory.js";
2
+ export { default as PostgresSqlExecutor } from "./PostgresSqlExecutor.js";
3
+ export { default as InMemoryConversationRepository } from "./InMemoryConversationRepository.js";
File without changes