swarm-mail 0.1.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.
- package/README.md +201 -0
- package/package.json +28 -0
- package/src/adapter.ts +306 -0
- package/src/index.ts +57 -0
- package/src/pglite.ts +189 -0
- package/src/streams/agent-mail.test.ts +777 -0
- package/src/streams/agent-mail.ts +535 -0
- package/src/streams/debug.test.ts +500 -0
- package/src/streams/debug.ts +727 -0
- package/src/streams/effect/ask.integration.test.ts +314 -0
- package/src/streams/effect/ask.ts +202 -0
- package/src/streams/effect/cursor.integration.test.ts +418 -0
- package/src/streams/effect/cursor.ts +288 -0
- package/src/streams/effect/deferred.test.ts +357 -0
- package/src/streams/effect/deferred.ts +445 -0
- package/src/streams/effect/index.ts +17 -0
- package/src/streams/effect/layers.ts +73 -0
- package/src/streams/effect/lock.test.ts +385 -0
- package/src/streams/effect/lock.ts +399 -0
- package/src/streams/effect/mailbox.test.ts +260 -0
- package/src/streams/effect/mailbox.ts +318 -0
- package/src/streams/events.test.ts +924 -0
- package/src/streams/events.ts +329 -0
- package/src/streams/index.test.ts +229 -0
- package/src/streams/index.ts +578 -0
- package/src/streams/migrations.test.ts +359 -0
- package/src/streams/migrations.ts +362 -0
- package/src/streams/projections.test.ts +611 -0
- package/src/streams/projections.ts +564 -0
- package/src/streams/store.integration.test.ts +658 -0
- package/src/streams/store.ts +1129 -0
- package/src/streams/swarm-mail.ts +552 -0
- package/src/types/adapter.ts +392 -0
- package/src/types/database.ts +127 -0
- package/src/types/index.ts +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableCursor Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for Effect-TS cursor service with checkpointing
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
import type { AgentRegisteredEvent } from "../events";
|
|
9
|
+
import { DurableCursor, DurableCursorLayer, type CursorConfig } from "./cursor";
|
|
10
|
+
import {
|
|
11
|
+
appendEvent,
|
|
12
|
+
closeDatabase,
|
|
13
|
+
createEvent,
|
|
14
|
+
resetDatabase,
|
|
15
|
+
} from "../index";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Test Utilities
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const TEST_PROJECT = "/tmp/cursor-test";
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
await resetDatabase(TEST_PROJECT);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await closeDatabase(TEST_PROJECT);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function cleanup() {
|
|
32
|
+
await closeDatabase(TEST_PROJECT);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Helper to run Effect programs with DurableCursor service
|
|
37
|
+
*/
|
|
38
|
+
async function runWithCursor<A, E>(
|
|
39
|
+
effect: Effect.Effect<A, E, DurableCursor>,
|
|
40
|
+
): Promise<A> {
|
|
41
|
+
return Effect.runPromise(Effect.provide(effect, DurableCursorLayer));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Tests
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
describe("DurableCursor", () => {
|
|
49
|
+
describe("create", () => {
|
|
50
|
+
it("creates a cursor with initial position 0", async () => {
|
|
51
|
+
await cleanup();
|
|
52
|
+
|
|
53
|
+
const program = Effect.gen(function* () {
|
|
54
|
+
const service = yield* DurableCursor;
|
|
55
|
+
const cursor = yield* service.create({
|
|
56
|
+
stream: "test-stream",
|
|
57
|
+
checkpoint: "test-checkpoint",
|
|
58
|
+
projectPath: TEST_PROJECT,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const position = yield* cursor.getPosition();
|
|
62
|
+
return position;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const position = await runWithCursor(program);
|
|
66
|
+
expect(position).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("resumes from last checkpoint position", async () => {
|
|
70
|
+
await cleanup();
|
|
71
|
+
|
|
72
|
+
// First cursor - commit at sequence 5
|
|
73
|
+
const program1 = Effect.gen(function* () {
|
|
74
|
+
const service = yield* DurableCursor;
|
|
75
|
+
const cursor = yield* service.create({
|
|
76
|
+
stream: "test-stream",
|
|
77
|
+
checkpoint: "test-checkpoint",
|
|
78
|
+
projectPath: TEST_PROJECT,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
yield* cursor.commit(5);
|
|
82
|
+
return yield* cursor.getPosition();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await runWithCursor(program1);
|
|
86
|
+
|
|
87
|
+
// Second cursor - should resume at 5
|
|
88
|
+
const program2 = Effect.gen(function* () {
|
|
89
|
+
const service = yield* DurableCursor;
|
|
90
|
+
const cursor = yield* service.create({
|
|
91
|
+
stream: "test-stream",
|
|
92
|
+
checkpoint: "test-checkpoint",
|
|
93
|
+
projectPath: TEST_PROJECT,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return yield* cursor.getPosition();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const position = await runWithCursor(program2);
|
|
100
|
+
expect(position).toBe(5);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("supports multiple independent checkpoints", async () => {
|
|
104
|
+
await cleanup();
|
|
105
|
+
|
|
106
|
+
const program = Effect.gen(function* () {
|
|
107
|
+
const service = yield* DurableCursor;
|
|
108
|
+
|
|
109
|
+
const cursor1 = yield* service.create({
|
|
110
|
+
stream: "test-stream",
|
|
111
|
+
checkpoint: "checkpoint-a",
|
|
112
|
+
projectPath: TEST_PROJECT,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const cursor2 = yield* service.create({
|
|
116
|
+
stream: "test-stream",
|
|
117
|
+
checkpoint: "checkpoint-b",
|
|
118
|
+
projectPath: TEST_PROJECT,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
yield* cursor1.commit(10);
|
|
122
|
+
yield* cursor2.commit(20);
|
|
123
|
+
|
|
124
|
+
const pos1 = yield* cursor1.getPosition();
|
|
125
|
+
const pos2 = yield* cursor2.getPosition();
|
|
126
|
+
|
|
127
|
+
return { pos1, pos2 };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = await runWithCursor(program);
|
|
131
|
+
expect(result.pos1).toBe(10);
|
|
132
|
+
expect(result.pos2).toBe(20);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("consume", () => {
|
|
137
|
+
it("consumes events from current position", async () => {
|
|
138
|
+
await cleanup();
|
|
139
|
+
|
|
140
|
+
// Append test events
|
|
141
|
+
const events = [
|
|
142
|
+
createEvent("agent_registered", {
|
|
143
|
+
project_key: "test-project",
|
|
144
|
+
agent_name: "agent-1",
|
|
145
|
+
program: "test",
|
|
146
|
+
model: "test-model",
|
|
147
|
+
}),
|
|
148
|
+
createEvent("agent_registered", {
|
|
149
|
+
project_key: "test-project",
|
|
150
|
+
agent_name: "agent-2",
|
|
151
|
+
program: "test",
|
|
152
|
+
model: "test-model",
|
|
153
|
+
}),
|
|
154
|
+
createEvent("agent_registered", {
|
|
155
|
+
project_key: "test-project",
|
|
156
|
+
agent_name: "agent-3",
|
|
157
|
+
program: "test",
|
|
158
|
+
model: "test-model",
|
|
159
|
+
}),
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const event of events) {
|
|
163
|
+
await appendEvent(event, TEST_PROJECT);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Create cursor and consume outside Effect.gen
|
|
167
|
+
const program = Effect.gen(function* () {
|
|
168
|
+
const service = yield* DurableCursor;
|
|
169
|
+
return yield* service.create({
|
|
170
|
+
stream: "test-stream",
|
|
171
|
+
checkpoint: "test-consumer",
|
|
172
|
+
projectPath: TEST_PROJECT,
|
|
173
|
+
batchSize: 2,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const cursor = await runWithCursor(program);
|
|
178
|
+
const consumed: string[] = [];
|
|
179
|
+
|
|
180
|
+
for await (const msg of cursor.consume<
|
|
181
|
+
AgentRegisteredEvent & { id: number; sequence: number }
|
|
182
|
+
>()) {
|
|
183
|
+
consumed.push(msg.value.agent_name);
|
|
184
|
+
await Effect.runPromise(msg.commit());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
expect(consumed).toHaveLength(3);
|
|
188
|
+
expect(consumed).toEqual(["agent-1", "agent-2", "agent-3"]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("resumes consumption from checkpoint", async () => {
|
|
192
|
+
await cleanup();
|
|
193
|
+
|
|
194
|
+
// Append test events
|
|
195
|
+
const events = [
|
|
196
|
+
createEvent("agent_registered", {
|
|
197
|
+
project_key: "test-project",
|
|
198
|
+
agent_name: "agent-1",
|
|
199
|
+
program: "test",
|
|
200
|
+
model: "test-model",
|
|
201
|
+
}),
|
|
202
|
+
createEvent("agent_registered", {
|
|
203
|
+
project_key: "test-project",
|
|
204
|
+
agent_name: "agent-2",
|
|
205
|
+
program: "test",
|
|
206
|
+
model: "test-model",
|
|
207
|
+
}),
|
|
208
|
+
createEvent("agent_registered", {
|
|
209
|
+
project_key: "test-project",
|
|
210
|
+
agent_name: "agent-3",
|
|
211
|
+
program: "test",
|
|
212
|
+
model: "test-model",
|
|
213
|
+
}),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
for (const event of events) {
|
|
217
|
+
await appendEvent(event, TEST_PROJECT);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// First consumer - consume first event only
|
|
221
|
+
const program1 = Effect.gen(function* () {
|
|
222
|
+
const service = yield* DurableCursor;
|
|
223
|
+
return yield* service.create({
|
|
224
|
+
stream: "test-stream",
|
|
225
|
+
checkpoint: "resume-test",
|
|
226
|
+
projectPath: TEST_PROJECT,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const cursor1 = await runWithCursor(program1);
|
|
231
|
+
const first: string[] = [];
|
|
232
|
+
|
|
233
|
+
for await (const msg of cursor1.consume<
|
|
234
|
+
AgentRegisteredEvent & { id: number; sequence: number }
|
|
235
|
+
>()) {
|
|
236
|
+
first.push(msg.value.agent_name);
|
|
237
|
+
await Effect.runPromise(msg.commit());
|
|
238
|
+
break; // Consume only first event
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
expect(first).toEqual(["agent-1"]);
|
|
242
|
+
|
|
243
|
+
// Second consumer - should resume from checkpoint
|
|
244
|
+
const program2 = Effect.gen(function* () {
|
|
245
|
+
const service = yield* DurableCursor;
|
|
246
|
+
return yield* service.create({
|
|
247
|
+
stream: "test-stream",
|
|
248
|
+
checkpoint: "resume-test",
|
|
249
|
+
projectPath: TEST_PROJECT,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const cursor2 = await runWithCursor(program2);
|
|
254
|
+
const second: string[] = [];
|
|
255
|
+
|
|
256
|
+
for await (const msg of cursor2.consume<
|
|
257
|
+
AgentRegisteredEvent & { id: number; sequence: number }
|
|
258
|
+
>()) {
|
|
259
|
+
second.push(msg.value.agent_name);
|
|
260
|
+
await Effect.runPromise(msg.commit());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
expect(second).toEqual(["agent-2", "agent-3"]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("supports event type filtering", async () => {
|
|
267
|
+
await cleanup();
|
|
268
|
+
|
|
269
|
+
// Append mixed event types
|
|
270
|
+
await appendEvent(
|
|
271
|
+
createEvent("agent_registered", {
|
|
272
|
+
project_key: "test-project",
|
|
273
|
+
agent_name: "agent-1",
|
|
274
|
+
program: "test",
|
|
275
|
+
model: "test-model",
|
|
276
|
+
}),
|
|
277
|
+
TEST_PROJECT,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
await appendEvent(
|
|
281
|
+
createEvent("message_sent", {
|
|
282
|
+
project_key: "test-project",
|
|
283
|
+
from_agent: "agent-1",
|
|
284
|
+
to_agents: ["agent-2"],
|
|
285
|
+
subject: "test",
|
|
286
|
+
body: "test message",
|
|
287
|
+
importance: "normal",
|
|
288
|
+
ack_required: false,
|
|
289
|
+
}),
|
|
290
|
+
TEST_PROJECT,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
await appendEvent(
|
|
294
|
+
createEvent("agent_registered", {
|
|
295
|
+
project_key: "test-project",
|
|
296
|
+
agent_name: "agent-2",
|
|
297
|
+
program: "test",
|
|
298
|
+
model: "test-model",
|
|
299
|
+
}),
|
|
300
|
+
TEST_PROJECT,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const program = Effect.gen(function* () {
|
|
304
|
+
const service = yield* DurableCursor;
|
|
305
|
+
return yield* service.create({
|
|
306
|
+
stream: "test-stream",
|
|
307
|
+
checkpoint: "filter-test",
|
|
308
|
+
projectPath: TEST_PROJECT,
|
|
309
|
+
types: ["agent_registered"],
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const cursor = await runWithCursor(program);
|
|
314
|
+
const types: string[] = [];
|
|
315
|
+
|
|
316
|
+
for await (const msg of cursor.consume()) {
|
|
317
|
+
types.push(msg.value.type);
|
|
318
|
+
await Effect.runPromise(msg.commit());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
expect(types).toEqual(["agent_registered", "agent_registered"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("commits update cursor position", async () => {
|
|
325
|
+
await cleanup();
|
|
326
|
+
|
|
327
|
+
// Append test events
|
|
328
|
+
await appendEvent(
|
|
329
|
+
createEvent("agent_registered", {
|
|
330
|
+
project_key: "test-project",
|
|
331
|
+
agent_name: "agent-1",
|
|
332
|
+
program: "test",
|
|
333
|
+
model: "test-model",
|
|
334
|
+
}),
|
|
335
|
+
TEST_PROJECT,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const program = Effect.gen(function* () {
|
|
339
|
+
const service = yield* DurableCursor;
|
|
340
|
+
return yield* service.create({
|
|
341
|
+
stream: "test-stream",
|
|
342
|
+
checkpoint: "commit-test",
|
|
343
|
+
projectPath: TEST_PROJECT,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const cursor = await runWithCursor(program);
|
|
348
|
+
const initialPos = await Effect.runPromise(cursor.getPosition());
|
|
349
|
+
|
|
350
|
+
let afterCommit = 0;
|
|
351
|
+
let sequence = 0;
|
|
352
|
+
|
|
353
|
+
for await (const msg of cursor.consume()) {
|
|
354
|
+
await Effect.runPromise(msg.commit());
|
|
355
|
+
afterCommit = await Effect.runPromise(cursor.getPosition());
|
|
356
|
+
sequence = msg.sequence;
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
expect(initialPos).toBe(0);
|
|
361
|
+
expect(afterCommit).toBe(sequence);
|
|
362
|
+
expect(afterCommit).toBeGreaterThan(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("handles empty streams gracefully", async () => {
|
|
366
|
+
await cleanup();
|
|
367
|
+
|
|
368
|
+
const program = Effect.gen(function* () {
|
|
369
|
+
const service = yield* DurableCursor;
|
|
370
|
+
return yield* service.create({
|
|
371
|
+
stream: "empty-stream",
|
|
372
|
+
checkpoint: "empty-test",
|
|
373
|
+
projectPath: TEST_PROJECT,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const cursor = await runWithCursor(program);
|
|
378
|
+
const consumed: unknown[] = [];
|
|
379
|
+
|
|
380
|
+
for await (const msg of cursor.consume()) {
|
|
381
|
+
consumed.push(msg);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
expect(consumed).toHaveLength(0);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("commit", () => {
|
|
389
|
+
it("persists position across cursor instances", async () => {
|
|
390
|
+
await cleanup();
|
|
391
|
+
|
|
392
|
+
const config: CursorConfig = {
|
|
393
|
+
stream: "test-stream",
|
|
394
|
+
checkpoint: "persist-test",
|
|
395
|
+
projectPath: TEST_PROJECT,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// First cursor - commit position
|
|
399
|
+
const program1 = Effect.gen(function* () {
|
|
400
|
+
const service = yield* DurableCursor;
|
|
401
|
+
const cursor = yield* service.create(config);
|
|
402
|
+
yield* cursor.commit(42);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await runWithCursor(program1);
|
|
406
|
+
|
|
407
|
+
// Second cursor - verify position persisted
|
|
408
|
+
const program2 = Effect.gen(function* () {
|
|
409
|
+
const service = yield* DurableCursor;
|
|
410
|
+
const cursor = yield* service.create(config);
|
|
411
|
+
return yield* cursor.getPosition();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const position = await runWithCursor(program2);
|
|
415
|
+
expect(position).toBe(42);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableCursor - Positioned event stream consumption with checkpointing
|
|
3
|
+
*
|
|
4
|
+
* Effect-TS service that wraps event stream reading with cursor state management.
|
|
5
|
+
* Enables reliable event processing with resumable position tracking.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const program = Effect.gen(function* () {
|
|
10
|
+
* const cursor = yield* DurableCursor;
|
|
11
|
+
* const consumer = yield* cursor.create({
|
|
12
|
+
* stream: "projects/foo/events",
|
|
13
|
+
* checkpoint: "agents/bar/position"
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* for await (const msg of consumer.consume()) {
|
|
17
|
+
* yield* handleMessage(msg.value);
|
|
18
|
+
* yield* msg.commit();
|
|
19
|
+
* }
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import { Context, Effect, Ref, Stream } from "effect";
|
|
24
|
+
import { getDatabase } from "../index";
|
|
25
|
+
import { readEvents } from "../store";
|
|
26
|
+
import type { AgentEvent } from "../events";
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Types
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Configuration for creating a cursor
|
|
34
|
+
*/
|
|
35
|
+
export interface CursorConfig {
|
|
36
|
+
/** Stream identifier (e.g. "projects/foo/events") */
|
|
37
|
+
readonly stream: string;
|
|
38
|
+
/** Checkpoint identifier (e.g. "agents/bar/position") */
|
|
39
|
+
readonly checkpoint: string;
|
|
40
|
+
/** Project path for database location */
|
|
41
|
+
readonly projectPath?: string;
|
|
42
|
+
/** Batch size for reading events (default: 100) */
|
|
43
|
+
readonly batchSize?: number;
|
|
44
|
+
/** Optional filters for event types */
|
|
45
|
+
readonly types?: AgentEvent["type"][];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A message from the cursor with commit capability
|
|
50
|
+
*/
|
|
51
|
+
export interface CursorMessage<T = unknown> {
|
|
52
|
+
/** The event value */
|
|
53
|
+
readonly value: T;
|
|
54
|
+
/** Event sequence number */
|
|
55
|
+
readonly sequence: number;
|
|
56
|
+
/** Commit this position to the checkpoint */
|
|
57
|
+
readonly commit: () => Effect.Effect<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A cursor instance for consuming events
|
|
62
|
+
*/
|
|
63
|
+
export interface Cursor {
|
|
64
|
+
/** Get current position */
|
|
65
|
+
readonly getPosition: () => Effect.Effect<number>;
|
|
66
|
+
/** Consume events as an async iterable */
|
|
67
|
+
readonly consume: <
|
|
68
|
+
T = AgentEvent & { id: number; sequence: number },
|
|
69
|
+
>() => AsyncIterable<CursorMessage<T>>;
|
|
70
|
+
/** Update checkpoint position */
|
|
71
|
+
readonly commit: (sequence: number) => Effect.Effect<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* DurableCursor service interface
|
|
76
|
+
*/
|
|
77
|
+
export interface DurableCursorService {
|
|
78
|
+
/** Create a new cursor instance */
|
|
79
|
+
readonly create: (config: CursorConfig) => Effect.Effect<Cursor>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Service Definition
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* DurableCursor Context.Tag
|
|
88
|
+
*/
|
|
89
|
+
export class DurableCursor extends Context.Tag("DurableCursor")<
|
|
90
|
+
DurableCursor,
|
|
91
|
+
DurableCursorService
|
|
92
|
+
>() {}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Implementation
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Initialize cursor table schema
|
|
100
|
+
*/
|
|
101
|
+
async function initializeCursorSchema(projectPath?: string): Promise<void> {
|
|
102
|
+
const db = await getDatabase(projectPath);
|
|
103
|
+
await db.exec(`
|
|
104
|
+
CREATE TABLE IF NOT EXISTS cursors (
|
|
105
|
+
id SERIAL PRIMARY KEY,
|
|
106
|
+
stream TEXT NOT NULL,
|
|
107
|
+
checkpoint TEXT NOT NULL,
|
|
108
|
+
position BIGINT NOT NULL DEFAULT 0,
|
|
109
|
+
updated_at BIGINT NOT NULL,
|
|
110
|
+
UNIQUE(stream, checkpoint)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_cursors_stream ON cursors(stream);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_cursors_checkpoint ON cursors(checkpoint);
|
|
115
|
+
`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Load cursor position from database
|
|
120
|
+
*/
|
|
121
|
+
async function loadCursorPosition(
|
|
122
|
+
stream: string,
|
|
123
|
+
checkpoint: string,
|
|
124
|
+
projectPath?: string,
|
|
125
|
+
): Promise<number> {
|
|
126
|
+
await initializeCursorSchema(projectPath);
|
|
127
|
+
const db = await getDatabase(projectPath);
|
|
128
|
+
|
|
129
|
+
const result = await db.query<{ position: string }>(
|
|
130
|
+
`SELECT position FROM cursors WHERE stream = $1 AND checkpoint = $2`,
|
|
131
|
+
[stream, checkpoint],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (result.rows.length === 0) {
|
|
135
|
+
// Initialize cursor at position 0
|
|
136
|
+
await db.query(
|
|
137
|
+
`INSERT INTO cursors (stream, checkpoint, position, updated_at)
|
|
138
|
+
VALUES ($1, $2, 0, $3)
|
|
139
|
+
ON CONFLICT (stream, checkpoint) DO NOTHING`,
|
|
140
|
+
[stream, checkpoint, Date.now()],
|
|
141
|
+
);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return parseInt(result.rows[0]?.position || "0");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Save cursor position to database
|
|
150
|
+
*/
|
|
151
|
+
async function saveCursorPosition(
|
|
152
|
+
stream: string,
|
|
153
|
+
checkpoint: string,
|
|
154
|
+
position: number,
|
|
155
|
+
projectPath?: string,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const db = await getDatabase(projectPath);
|
|
158
|
+
|
|
159
|
+
await db.query(
|
|
160
|
+
`INSERT INTO cursors (stream, checkpoint, position, updated_at)
|
|
161
|
+
VALUES ($1, $2, $3, $4)
|
|
162
|
+
ON CONFLICT (stream, checkpoint)
|
|
163
|
+
DO UPDATE SET position = EXCLUDED.position, updated_at = EXCLUDED.updated_at`,
|
|
164
|
+
[stream, checkpoint, position, Date.now()],
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create cursor implementation
|
|
170
|
+
*/
|
|
171
|
+
function createCursorImpl(config: CursorConfig): Effect.Effect<Cursor> {
|
|
172
|
+
return Effect.gen(function* () {
|
|
173
|
+
// Load initial position from database
|
|
174
|
+
const initialPosition = yield* Effect.promise(() =>
|
|
175
|
+
loadCursorPosition(config.stream, config.checkpoint, config.projectPath),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Create mutable reference for current position
|
|
179
|
+
const positionRef = yield* Ref.make(initialPosition);
|
|
180
|
+
|
|
181
|
+
// Commit function - updates database and reference
|
|
182
|
+
const commitPosition = (sequence: number): Effect.Effect<void> =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
yield* Effect.promise(() =>
|
|
185
|
+
saveCursorPosition(
|
|
186
|
+
config.stream,
|
|
187
|
+
config.checkpoint,
|
|
188
|
+
sequence,
|
|
189
|
+
config.projectPath,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
yield* Ref.set(positionRef, sequence);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Get current position
|
|
196
|
+
const getPosition = (): Effect.Effect<number> => Ref.get(positionRef);
|
|
197
|
+
|
|
198
|
+
// Consume events as async iterable
|
|
199
|
+
const consume = <
|
|
200
|
+
T = AgentEvent & { id: number; sequence: number },
|
|
201
|
+
>(): AsyncIterable<CursorMessage<T>> => {
|
|
202
|
+
const batchSize = config.batchSize ?? 100;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
[Symbol.asyncIterator]() {
|
|
206
|
+
let currentBatch: Array<
|
|
207
|
+
AgentEvent & { id: number; sequence: number }
|
|
208
|
+
> = [];
|
|
209
|
+
let batchIndex = 0;
|
|
210
|
+
let done = false;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
async next(): Promise<IteratorResult<CursorMessage<T>>> {
|
|
214
|
+
// Load next batch if current batch is exhausted
|
|
215
|
+
if (batchIndex >= currentBatch.length && !done) {
|
|
216
|
+
const currentPosition = await Effect.runPromise(
|
|
217
|
+
Ref.get(positionRef),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const events = await readEvents(
|
|
221
|
+
{
|
|
222
|
+
afterSequence: currentPosition,
|
|
223
|
+
limit: batchSize,
|
|
224
|
+
types: config.types,
|
|
225
|
+
},
|
|
226
|
+
config.projectPath,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (events.length === 0) {
|
|
230
|
+
done = true;
|
|
231
|
+
return { done: true, value: undefined };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
currentBatch = events;
|
|
235
|
+
batchIndex = 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Return next message from current batch
|
|
239
|
+
if (batchIndex < currentBatch.length) {
|
|
240
|
+
const event = currentBatch[batchIndex++];
|
|
241
|
+
if (!event) {
|
|
242
|
+
done = true;
|
|
243
|
+
return { done: true, value: undefined };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const message: CursorMessage<T> = {
|
|
247
|
+
value: event as unknown as T,
|
|
248
|
+
sequence: event.sequence,
|
|
249
|
+
commit: () => commitPosition(event.sequence),
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return { done: false, value: message };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
done = true;
|
|
256
|
+
return { done: true, value: undefined };
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
getPosition,
|
|
265
|
+
consume,
|
|
266
|
+
commit: commitPosition,
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ============================================================================
|
|
272
|
+
// Layer
|
|
273
|
+
// ============================================================================
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Live implementation of DurableCursor service
|
|
277
|
+
*/
|
|
278
|
+
export const DurableCursorLive = DurableCursor.of({
|
|
279
|
+
create: createCursorImpl,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Default layer for DurableCursor service
|
|
284
|
+
*/
|
|
285
|
+
export const DurableCursorLayer = Context.make(
|
|
286
|
+
DurableCursor,
|
|
287
|
+
DurableCursorLive,
|
|
288
|
+
);
|