@effect-agent/storage-memory 0.1.0-beta.9 → 0.1.0-beta.90

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 (34) hide show
  1. package/dist/MemoryMessageDeliveryStore.d.mts +15 -0
  2. package/dist/MemoryMessageDeliveryStore.mjs +147 -0
  3. package/dist/MemoryMessageDeliveryStore.mjs.map +1 -0
  4. package/dist/MemoryScheduleStore.d.mts +10 -0
  5. package/dist/MemoryScheduleStore.mjs +169 -0
  6. package/dist/MemoryScheduleStore.mjs.map +1 -0
  7. package/dist/MemorySemanticIndex.d.mts +21 -0
  8. package/dist/MemorySemanticIndex.mjs +222 -0
  9. package/dist/MemorySemanticIndex.mjs.map +1 -0
  10. package/dist/MemorySubmissionLedger.d.mts +24 -0
  11. package/dist/MemorySubmissionLedger.mjs +1058 -0
  12. package/dist/MemorySubmissionLedger.mjs.map +1 -0
  13. package/dist/MemorySubscriptionStore.d.mts +9 -0
  14. package/dist/MemorySubscriptionStore.mjs +849 -0
  15. package/dist/MemorySubscriptionStore.mjs.map +1 -0
  16. package/dist/MemoryThreadStore.d.mts +17 -0
  17. package/dist/MemoryThreadStore.mjs +377 -0
  18. package/dist/MemoryThreadStore.mjs.map +1 -0
  19. package/dist/index.d.mts +7 -30
  20. package/dist/index.mjs +7 -1298
  21. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  22. package/package.json +1 -45
  23. package/src/MemoryMessageDeliveryStore.ts +293 -0
  24. package/src/MemoryScheduleStore.ts +328 -0
  25. package/src/MemorySemanticIndex.ts +357 -0
  26. package/src/{memory-ledger.ts → MemorySubmissionLedger.ts} +478 -86
  27. package/src/MemorySubscriptionStore.ts +1549 -0
  28. package/src/MemoryThreadStore.ts +766 -0
  29. package/src/index.ts +6 -2
  30. package/dist/index.mjs.map +0 -1
  31. package/dist/testing.d.mts +0 -2
  32. package/dist/testing.mjs +0 -2
  33. package/src/memory-storage.ts +0 -614
  34. package/src/testing.ts +0 -10
@@ -0,0 +1,766 @@
1
+ import {
2
+ Context,
3
+ Crypto,
4
+ Effect,
5
+ Encoding,
6
+ Layer,
7
+ Option,
8
+ PubSub,
9
+ Ref,
10
+ Schema,
11
+ Stream,
12
+ } from "effect";
13
+ import { digestCanonicalBatch, EMPTY_TAIL_DIGEST } from "effect-agent/digest";
14
+ import { ThreadId } from "effect-agent/identifiers";
15
+ import {
16
+ type ProducerEpoch,
17
+ type RecordId,
18
+ CanonicalRecordEnvelope,
19
+ CanonicalSequence,
20
+ ObservationOffset,
21
+ type BatchId,
22
+ type Digest,
23
+ } from "effect-agent/records";
24
+ import {
25
+ type ThreadCheckpoint,
26
+ AppendConflict,
27
+ AppendResult,
28
+ CheckpointRejected,
29
+ ThreadExportRequest,
30
+ ThreadExport,
31
+ ThreadMaterialization,
32
+ ThreadNotMaterialized,
33
+ ThreadObservation,
34
+ ThreadRead,
35
+ ThreadStore,
36
+ type ThreadCheckpoints,
37
+ ThreadStoreError,
38
+ ThreadTail,
39
+ ThreadTailRequest,
40
+ FenceRejected,
41
+ FencedAppendRequest,
42
+ LoadCheckpointRequest,
43
+ SaveCheckpointRequest,
44
+ SaveRecoveryCheckpointRequest,
45
+ type ThreadRecoveryCheckpoints,
46
+ MAX_THREAD_EXPORT_RECORDS,
47
+ } from "effect-agent/thread-store";
48
+
49
+ const MAX_THREADS = 256;
50
+ const MAX_RECORDS_PER_THREAD = MAX_THREAD_EXPORT_RECORDS;
51
+ const MAX_CHECKPOINTS_PER_THREAD = 1_024;
52
+
53
+ const ThreadCapacity = Context.Reference<number>(
54
+ "@effect-agent/storage-memory/MemoryThreadStore/ThreadCapacity",
55
+ { defaultValue: () => MAX_THREADS },
56
+ );
57
+
58
+ interface StoredBatch {
59
+ readonly digest: Digest;
60
+ readonly result: AppendResult;
61
+ }
62
+
63
+ interface StoredThread {
64
+ readonly producerEpoch: ProducerEpoch;
65
+ readonly tailSequence: CanonicalSequence;
66
+ readonly tailDigest: Digest;
67
+ readonly records: ReadonlyArray<CanonicalRecordEnvelope>;
68
+ readonly recordIds: ReadonlySet<RecordId>;
69
+ readonly batches: ReadonlyMap<BatchId, StoredBatch>;
70
+ readonly tailDigests: ReadonlyMap<CanonicalSequence, Digest>;
71
+ readonly checkpoints: ReadonlyMap<CanonicalSequence, ThreadCheckpoint>;
72
+ readonly recoveryCheckpoint?: ThreadCheckpoint;
73
+ }
74
+
75
+ interface MemoryState {
76
+ readonly threads: ReadonlyMap<ThreadId, StoredThread>;
77
+ }
78
+
79
+ type AppendDecision =
80
+ | {
81
+ readonly _tag: "failure";
82
+ readonly error: ThreadStoreError | ThreadNotMaterialized | AppendConflict | FenceRejected;
83
+ }
84
+ | {
85
+ readonly _tag: "success";
86
+ readonly result: AppendResult;
87
+ readonly records: ReadonlyArray<CanonicalRecordEnvelope>;
88
+ };
89
+
90
+ type MaterializeDecision =
91
+ | { readonly _tag: "failure"; readonly error: ThreadStoreError | FenceRejected }
92
+ | { readonly _tag: "success" };
93
+
94
+ type CheckpointDecision =
95
+ | {
96
+ readonly _tag: "failure";
97
+ readonly error: ThreadNotMaterialized | ThreadStoreError | CheckpointRejected;
98
+ }
99
+ | { readonly _tag: "success" };
100
+
101
+ const storeError = (operation: string, message: string, cause?: unknown): ThreadStoreError =>
102
+ cause === undefined
103
+ ? ThreadStoreError.make({ operation, message })
104
+ : ThreadStoreError.make({ operation, message, cause });
105
+
106
+ const validate = Effect.fn("MemoryThreadStore.validate")(
107
+ <A, I>(
108
+ schema: Schema.Codec<A, I>,
109
+ operation: string,
110
+ value: unknown,
111
+ ): Effect.Effect<A, ThreadStoreError> =>
112
+ Schema.encodeUnknownEffect(schema)(value).pipe(
113
+ Effect.flatMap(Schema.decodeUnknownEffect(schema)),
114
+ Effect.mapError((error) => storeError(operation, `Invalid ${operation} request`, error)),
115
+ ),
116
+ );
117
+
118
+ const decodeCanonicalSequence = Schema.decodeSync(CanonicalSequence);
119
+ const ZERO_CANONICAL_SEQUENCE = decodeCanonicalSequence(0);
120
+
121
+ const offsetSequence = Effect.fn("MemoryThreadStore.offsetSequence")((
122
+ threadId: ThreadId,
123
+ offset: ObservationOffset | undefined,
124
+ ): Effect.Effect<CanonicalSequence, ThreadStoreError> => {
125
+ if (offset === undefined) return Effect.succeed(ZERO_CANONICAL_SEQUENCE);
126
+ const prefix = `memory:v1:${Encoding.encodeBase64(threadId)}:`;
127
+ const encodedSequence = offset.startsWith(prefix) ? offset.slice(prefix.length) : "";
128
+
129
+ if (!/^\d+$/.test(encodedSequence)) {
130
+ return Effect.fail(storeError("observe", "Malformed observation offset"));
131
+ }
132
+ const sequence = Number(encodedSequence);
133
+
134
+ return Number.isSafeInteger(sequence)
135
+ ? Schema.decodeEffect(CanonicalSequence)(sequence).pipe(
136
+ Effect.mapError(() => storeError("observe", "Malformed observation offset")),
137
+ )
138
+ : Effect.fail(storeError("observe", "Malformed observation offset"));
139
+ });
140
+
141
+ const observationOffset = (threadId: ThreadId, sequence: CanonicalSequence): ObservationOffset =>
142
+ Schema.decodeSync(ObservationOffset)(`memory:v1:${Encoding.encodeBase64(threadId)}:${sequence}`);
143
+
144
+ const findThread = Effect.fn("MemoryThreadStore.findThread")((
145
+ state: MemoryState,
146
+ threadId: ThreadId,
147
+ ): Effect.Effect<StoredThread, ThreadNotMaterialized> => {
148
+ const thread = state.threads.get(threadId);
149
+
150
+ return thread === undefined
151
+ ? Effect.fail(ThreadNotMaterialized.make({ threadId }))
152
+ : Effect.succeed(thread);
153
+ });
154
+
155
+ const CheckpointVersionEnvelope = Schema.Struct({
156
+ checkpoint: Schema.Struct({
157
+ threadId: ThreadId,
158
+ schemaVersion: Schema.Natural,
159
+ }),
160
+ });
161
+
162
+ const validateCheckpointVersion = Effect.fn("MemoryThreadStore.validateCheckpointVersion")(
163
+ function* (value: unknown): Effect.fn.Return<void, ThreadStoreError | CheckpointRejected> {
164
+ const envelope = yield* Schema.decodeUnknownEffect(CheckpointVersionEnvelope)(value).pipe(
165
+ Effect.mapError(() => storeError("saveCheckpoint", "Invalid saveCheckpoint request")),
166
+ );
167
+
168
+ if (envelope.checkpoint.schemaVersion !== 1) {
169
+ return yield* CheckpointRejected.make({
170
+ threadId: envelope.checkpoint.threadId,
171
+ reason: "unsupported-version",
172
+ });
173
+ }
174
+ },
175
+ );
176
+
177
+ const makeThreadStore = Effect.gen(function* () {
178
+ const maxThreads = yield* ThreadCapacity;
179
+ const crypto = yield* Crypto.Crypto;
180
+ const state = yield* Ref.make<MemoryState>({ threads: new Map() });
181
+ const updates = yield* PubSub.sliding<void>(1);
182
+
183
+ yield* Effect.addFinalizer(() => PubSub.shutdown(updates));
184
+
185
+ const materialize: ThreadStore["Service"]["materialize"] = Effect.fn(
186
+ "MemoryThreadStore.materialize",
187
+ )((unvalidated) =>
188
+ Effect.gen(function* () {
189
+ const request = yield* validate(ThreadMaterialization, "materialize", unvalidated);
190
+
191
+ const decision = yield* Ref.modify(
192
+ state,
193
+ (current): readonly [MaterializeDecision, MemoryState] => {
194
+ const existing = current.threads.get(request.threadId);
195
+
196
+ if (existing !== undefined) {
197
+ if (request.producerEpoch < existing.producerEpoch) {
198
+ return [
199
+ {
200
+ _tag: "failure",
201
+ error: FenceRejected.make({
202
+ threadId: request.threadId,
203
+ actualEpoch: existing.producerEpoch,
204
+ attemptedEpoch: request.producerEpoch,
205
+ }),
206
+ },
207
+ current,
208
+ ];
209
+ }
210
+ if (request.producerEpoch === existing.producerEpoch) {
211
+ return [{ _tag: "success" }, current];
212
+ }
213
+ const threads = new Map(current.threads);
214
+
215
+ threads.set(request.threadId, {
216
+ ...existing,
217
+ producerEpoch: request.producerEpoch,
218
+ });
219
+
220
+ return [{ _tag: "success" }, { threads }];
221
+ }
222
+ if (current.threads.size >= maxThreads) {
223
+ return [
224
+ {
225
+ _tag: "failure",
226
+ error: storeError("materialize", `In-memory thread limit ${maxThreads} exceeded`),
227
+ },
228
+ current,
229
+ ];
230
+ }
231
+ const threads = new Map(current.threads);
232
+
233
+ threads.set(request.threadId, {
234
+ producerEpoch: request.producerEpoch,
235
+ tailSequence: ZERO_CANONICAL_SEQUENCE,
236
+ tailDigest: EMPTY_TAIL_DIGEST,
237
+ records: [],
238
+ recordIds: new Set(),
239
+ batches: new Map(),
240
+ tailDigests: new Map([[ZERO_CANONICAL_SEQUENCE, EMPTY_TAIL_DIGEST]]),
241
+ checkpoints: new Map(),
242
+ });
243
+
244
+ return [{ _tag: "success" }, { threads }];
245
+ },
246
+ );
247
+
248
+ if (decision._tag === "failure") return yield* decision.error;
249
+ }),
250
+ );
251
+
252
+ const append: ThreadStore["Service"]["append"] = Effect.fn("MemoryThreadStore.append")(
253
+ (unvalidated) =>
254
+ Effect.gen(function* () {
255
+ const request = yield* validate(FencedAppendRequest, "append", unvalidated);
256
+
257
+ const digest = yield* digestCanonicalBatch(request.expectedTailDigest, request.batch).pipe(
258
+ Effect.provideService(Crypto.Crypto, crypto),
259
+ Effect.mapError((error) => storeError("append", error.message, error)),
260
+ );
261
+
262
+ const decision = yield* Effect.uninterruptible(
263
+ Ref.modify(state, (current): readonly [AppendDecision, MemoryState] => {
264
+ const thread = current.threads.get(request.threadId);
265
+
266
+ if (thread === undefined) {
267
+ return [
268
+ {
269
+ _tag: "failure",
270
+ error: ThreadNotMaterialized.make({
271
+ threadId: request.threadId,
272
+ }),
273
+ },
274
+ current,
275
+ ];
276
+ }
277
+ if (request.producerEpoch !== thread.producerEpoch) {
278
+ return [
279
+ {
280
+ _tag: "failure",
281
+ error: FenceRejected.make({
282
+ threadId: request.threadId,
283
+ actualEpoch: thread.producerEpoch,
284
+ attemptedEpoch: request.producerEpoch,
285
+ }),
286
+ },
287
+ current,
288
+ ];
289
+ }
290
+ const previous = thread.batches.get(request.batch.batchId);
291
+
292
+ if (previous !== undefined) {
293
+ if (previous.digest !== digest) {
294
+ return [
295
+ {
296
+ _tag: "failure",
297
+ error: AppendConflict.make({
298
+ threadId: request.threadId,
299
+ batchId: request.batch.batchId,
300
+ reason: "batch-digest",
301
+ }),
302
+ },
303
+ current,
304
+ ];
305
+ }
306
+
307
+ return [
308
+ {
309
+ _tag: "success",
310
+ result: AppendResult.make({
311
+ firstSequence: previous.result.firstSequence,
312
+ lastSequence: previous.result.lastSequence,
313
+ tailDigest: previous.result.tailDigest,
314
+ replayed: true,
315
+ }),
316
+ records: [],
317
+ },
318
+ current,
319
+ ];
320
+ }
321
+ if (
322
+ request.expectedTailSequence !== thread.tailSequence ||
323
+ request.expectedTailDigest !== thread.tailDigest
324
+ ) {
325
+ return [
326
+ {
327
+ _tag: "failure",
328
+ error: AppendConflict.make({
329
+ threadId: request.threadId,
330
+ batchId: request.batch.batchId,
331
+ reason: "tail",
332
+ actualTailSequence: thread.tailSequence,
333
+ actualTailDigest: thread.tailDigest,
334
+ }),
335
+ },
336
+ current,
337
+ ];
338
+ }
339
+ if (thread.records.length + request.batch.records.length > MAX_RECORDS_PER_THREAD) {
340
+ return [
341
+ {
342
+ _tag: "failure",
343
+ error: storeError(
344
+ "append",
345
+ `In-memory record limit ${MAX_RECORDS_PER_THREAD} exceeded`,
346
+ ),
347
+ },
348
+ current,
349
+ ];
350
+ }
351
+
352
+ const batchRecordIds = new Set<RecordId>();
353
+
354
+ for (const record of request.batch.records) {
355
+ if (thread.recordIds.has(record.recordId) || batchRecordIds.has(record.recordId)) {
356
+ return [
357
+ {
358
+ _tag: "failure",
359
+ error: AppendConflict.make({
360
+ threadId: request.threadId,
361
+ batchId: request.batch.batchId,
362
+ reason: "record-identity",
363
+ }),
364
+ },
365
+ current,
366
+ ];
367
+ }
368
+ batchRecordIds.add(record.recordId);
369
+ }
370
+
371
+ const records = request.batch.records.map((record, index) => {
372
+ const sequence = decodeCanonicalSequence(thread.tailSequence + index + 1);
373
+
374
+ return CanonicalRecordEnvelope.make({
375
+ threadId: request.threadId,
376
+ batchId: request.batch.batchId,
377
+ sequence,
378
+ offset: observationOffset(request.threadId, sequence),
379
+ record,
380
+ });
381
+ });
382
+
383
+ const lastSequence = decodeCanonicalSequence(thread.tailSequence + records.length);
384
+
385
+ const result = AppendResult.make({
386
+ firstSequence: decodeCanonicalSequence(thread.tailSequence + 1),
387
+ lastSequence,
388
+ tailDigest: digest,
389
+ replayed: false,
390
+ });
391
+
392
+ const batches = new Map(thread.batches);
393
+
394
+ batches.set(request.batch.batchId, { digest, result });
395
+ const recordIds = new Set(thread.recordIds);
396
+
397
+ for (const recordId of batchRecordIds) recordIds.add(recordId);
398
+ const tailDigests = new Map(thread.tailDigests);
399
+
400
+ tailDigests.set(lastSequence, digest);
401
+ const threads = new Map(current.threads);
402
+
403
+ threads.set(request.threadId, {
404
+ ...thread,
405
+ tailSequence: lastSequence,
406
+ tailDigest: digest,
407
+ records: [...thread.records, ...records],
408
+ recordIds,
409
+ batches,
410
+ tailDigests,
411
+ });
412
+
413
+ return [{ _tag: "success", result, records }, { threads }];
414
+ }).pipe(
415
+ Effect.tap((decision) =>
416
+ decision._tag === "success" && decision.records.length > 0
417
+ ? PubSub.publish(updates, undefined)
418
+ : Effect.void,
419
+ ),
420
+ ),
421
+ );
422
+
423
+ if (decision._tag === "failure") return yield* decision.error;
424
+
425
+ return decision.result;
426
+ }),
427
+ );
428
+
429
+ const readSnapshot = Effect.fn("MemoryThreadStore.readSnapshot")(
430
+ (threadId: ThreadId, afterSequence: CanonicalSequence | undefined, limit: number) =>
431
+ Ref.get(state).pipe(
432
+ Effect.flatMap((current) => findThread(current, threadId)),
433
+ Effect.map((thread) => {
434
+ // Append assigns gap-free sequences starting at 1, so the exclusive cursor is an index.
435
+ const start = afterSequence ?? ZERO_CANONICAL_SEQUENCE;
436
+
437
+ return thread.records.slice(start, start + limit);
438
+ }),
439
+ ),
440
+ );
441
+
442
+ const read: ThreadStore["Service"]["read"] = (unvalidated) =>
443
+ Stream.unwrap(
444
+ Effect.gen(function* () {
445
+ const request = yield* validate(ThreadRead, "read", unvalidated);
446
+ const records = yield* readSnapshot(request.threadId, request.afterSequence, request.limit);
447
+
448
+ return Stream.fromIterable(records);
449
+ }),
450
+ );
451
+
452
+ const observe: ThreadStore["Service"]["observe"] = (unvalidated) =>
453
+ Stream.unwrap(
454
+ Effect.gen(function* () {
455
+ const request = yield* validate(ThreadObservation, "observe", unvalidated);
456
+ const afterSequence = yield* offsetSequence(request.threadId, request.afterOffset);
457
+
458
+ return Stream.unwrap(
459
+ Effect.gen(function* () {
460
+ const subscription = yield* PubSub.subscribe(updates);
461
+
462
+ const initial = yield* readSnapshot(
463
+ request.threadId,
464
+ afterSequence,
465
+ MAX_RECORDS_PER_THREAD,
466
+ );
467
+
468
+ const highWater =
469
+ initial.length === 0 ? afterSequence : (initial.at(-1)?.sequence ?? afterSequence);
470
+
471
+ const live = Stream.fromEffectRepeat(PubSub.take(subscription)).pipe(
472
+ Stream.mapAccumEffect(
473
+ () => highWater,
474
+ (lastSequence) =>
475
+ readSnapshot(request.threadId, lastSequence, MAX_RECORDS_PER_THREAD).pipe(
476
+ Effect.map(
477
+ (records) => [records.at(-1)?.sequence ?? lastSequence, records] as const,
478
+ ),
479
+ ),
480
+ ),
481
+ );
482
+
483
+ return Stream.fromIterable(initial).pipe(Stream.concat(live));
484
+ }),
485
+ );
486
+ }),
487
+ );
488
+
489
+ const exportThread: ThreadStore["Service"]["export"] = Effect.fn("MemoryThreadStore.export")(
490
+ (unvalidated) =>
491
+ Effect.gen(function* () {
492
+ const request = yield* validate(ThreadExportRequest, "export", unvalidated);
493
+
494
+ const thread = yield* Ref.get(state).pipe(
495
+ Effect.flatMap((current) => findThread(current, request.threadId)),
496
+ );
497
+
498
+ return ThreadExport.make({
499
+ format: "effect-agent/thread@1",
500
+ threadId: request.threadId,
501
+ tailSequence: thread.tailSequence,
502
+ tailDigest: thread.tailDigest,
503
+ records: thread.records,
504
+ });
505
+ }),
506
+ );
507
+
508
+ const inspectTail: ThreadStore["Service"]["inspectTail"] = Effect.fn(
509
+ "MemoryThreadStore.inspectTail",
510
+ )((unvalidated) =>
511
+ Effect.gen(function* () {
512
+ const request = yield* validate(ThreadTailRequest, "inspectTail", unvalidated);
513
+
514
+ const thread = yield* Ref.get(state).pipe(
515
+ Effect.flatMap((current) => findThread(current, request.threadId)),
516
+ );
517
+
518
+ return ThreadTail.make({
519
+ threadId: request.threadId,
520
+ tailSequence: thread.tailSequence,
521
+ tailDigest: thread.tailDigest,
522
+ producerEpoch: thread.producerEpoch,
523
+ });
524
+ }),
525
+ );
526
+
527
+ const saveCheckpoint: ThreadCheckpoints["save"] = Effect.fn("MemoryThreadStore.saveCheckpoint")(
528
+ (unvalidated) =>
529
+ Effect.gen(function* () {
530
+ yield* validateCheckpointVersion(unvalidated);
531
+ const request = yield* validate(SaveCheckpointRequest, "saveCheckpoint", unvalidated);
532
+
533
+ const decision = yield* Ref.modify(
534
+ state,
535
+ (current): readonly [CheckpointDecision, MemoryState] => {
536
+ const checkpoint = request.checkpoint;
537
+ const thread = current.threads.get(checkpoint.threadId);
538
+
539
+ if (thread === undefined) {
540
+ return [
541
+ {
542
+ _tag: "failure",
543
+ error: ThreadNotMaterialized.make({
544
+ threadId: checkpoint.threadId,
545
+ }),
546
+ },
547
+ current,
548
+ ];
549
+ }
550
+ if (checkpoint.throughSequence > thread.tailSequence) {
551
+ return [
552
+ {
553
+ _tag: "failure",
554
+ error: CheckpointRejected.make({
555
+ threadId: checkpoint.threadId,
556
+ reason: "ahead-of-tail",
557
+ }),
558
+ },
559
+ current,
560
+ ];
561
+ }
562
+ if (thread.tailDigests.get(checkpoint.throughSequence) !== checkpoint.tailDigest) {
563
+ return [
564
+ {
565
+ _tag: "failure",
566
+ error: CheckpointRejected.make({
567
+ threadId: checkpoint.threadId,
568
+ reason: "digest-mismatch",
569
+ }),
570
+ },
571
+ current,
572
+ ];
573
+ }
574
+ if (
575
+ !thread.checkpoints.has(checkpoint.throughSequence) &&
576
+ thread.checkpoints.size >= MAX_CHECKPOINTS_PER_THREAD
577
+ ) {
578
+ return [
579
+ {
580
+ _tag: "failure",
581
+ error: storeError(
582
+ "saveCheckpoint",
583
+ `In-memory checkpoint limit ${MAX_CHECKPOINTS_PER_THREAD} exceeded`,
584
+ ),
585
+ },
586
+ current,
587
+ ];
588
+ }
589
+ const checkpoints = new Map(thread.checkpoints);
590
+
591
+ checkpoints.set(checkpoint.throughSequence, checkpoint);
592
+ const threads = new Map(current.threads);
593
+
594
+ threads.set(checkpoint.threadId, { ...thread, checkpoints });
595
+
596
+ return [{ _tag: "success" }, { threads }];
597
+ },
598
+ );
599
+
600
+ if (decision._tag === "failure") return yield* decision.error;
601
+ }),
602
+ );
603
+
604
+ const loadCheckpoint: ThreadCheckpoints["load"] = Effect.fn("MemoryThreadStore.loadCheckpoint")(
605
+ (unvalidated) =>
606
+ Effect.gen(function* () {
607
+ const request = yield* validate(LoadCheckpointRequest, "loadCheckpoint", unvalidated);
608
+
609
+ const thread = yield* Ref.get(state).pipe(
610
+ Effect.flatMap((current) => findThread(current, request.threadId)),
611
+ );
612
+
613
+ const maximum = request.atOrBeforeSequence ?? thread.tailSequence;
614
+ let selected: ThreadCheckpoint | undefined;
615
+
616
+ for (const [sequence, checkpoint] of thread.checkpoints) {
617
+ if (
618
+ sequence <= maximum &&
619
+ (selected === undefined || sequence > selected.throughSequence)
620
+ ) {
621
+ selected = checkpoint;
622
+ }
623
+ }
624
+ if (
625
+ selected !== undefined &&
626
+ thread.tailDigests.get(selected.throughSequence) !== selected.tailDigest
627
+ ) {
628
+ return yield* CheckpointRejected.make({
629
+ threadId: request.threadId,
630
+ reason: "digest-mismatch",
631
+ });
632
+ }
633
+
634
+ return Option.fromNullishOr(selected);
635
+ }),
636
+ );
637
+
638
+ const saveRecoveryCheckpoint: ThreadRecoveryCheckpoints["save"] = Effect.fn(
639
+ "MemoryThreadStore.saveRecoveryCheckpoint",
640
+ )(function* (unvalidated) {
641
+ const request = yield* validate(
642
+ SaveRecoveryCheckpointRequest,
643
+ "saveRecoveryCheckpoint",
644
+ unvalidated,
645
+ );
646
+
647
+ const decision = yield* Ref.modify(
648
+ state,
649
+ (
650
+ current,
651
+ ): readonly [
652
+ CheckpointDecision | { readonly _tag: "failure"; readonly error: FenceRejected },
653
+ MemoryState,
654
+ ] => {
655
+ const checkpoint = request.checkpoint;
656
+ const thread = current.threads.get(checkpoint.threadId);
657
+
658
+ if (thread === undefined)
659
+ return [
660
+ {
661
+ _tag: "failure",
662
+ error: ThreadNotMaterialized.make({ threadId: checkpoint.threadId }),
663
+ },
664
+ current,
665
+ ];
666
+ if (thread.producerEpoch !== request.producerEpoch)
667
+ return [
668
+ {
669
+ _tag: "failure",
670
+ error: FenceRejected.make({
671
+ threadId: checkpoint.threadId,
672
+ actualEpoch: thread.producerEpoch,
673
+ attemptedEpoch: request.producerEpoch,
674
+ }),
675
+ },
676
+ current,
677
+ ];
678
+ if (checkpoint.throughSequence > thread.tailSequence)
679
+ return [
680
+ {
681
+ _tag: "failure",
682
+ error: CheckpointRejected.make({
683
+ threadId: checkpoint.threadId,
684
+ reason: "ahead-of-tail",
685
+ }),
686
+ },
687
+ current,
688
+ ];
689
+ if (thread.tailDigests.get(checkpoint.throughSequence) !== checkpoint.tailDigest)
690
+ return [
691
+ {
692
+ _tag: "failure",
693
+ error: CheckpointRejected.make({
694
+ threadId: checkpoint.threadId,
695
+ reason: "digest-mismatch",
696
+ }),
697
+ },
698
+ current,
699
+ ];
700
+ if ((thread.recoveryCheckpoint?.throughSequence ?? -1) > checkpoint.throughSequence)
701
+ return [{ _tag: "success" }, current];
702
+ const threads = new Map(current.threads);
703
+
704
+ threads.set(checkpoint.threadId, { ...thread, recoveryCheckpoint: checkpoint });
705
+
706
+ return [{ _tag: "success" }, { threads }];
707
+ },
708
+ );
709
+
710
+ if (decision._tag === "failure") return yield* decision.error;
711
+ });
712
+
713
+ const loadRecoveryCheckpoint: ThreadRecoveryCheckpoints["load"] = Effect.fn(
714
+ "MemoryThreadStore.loadRecoveryCheckpoint",
715
+ )(function* (unvalidated) {
716
+ const request = yield* validate(LoadCheckpointRequest, "loadRecoveryCheckpoint", unvalidated);
717
+
718
+ const thread = yield* Ref.get(state).pipe(
719
+ Effect.flatMap((current) => findThread(current, request.threadId)),
720
+ );
721
+
722
+ const checkpoint = thread.recoveryCheckpoint;
723
+
724
+ if (
725
+ checkpoint === undefined ||
726
+ checkpoint.throughSequence > (request.atOrBeforeSequence ?? thread.tailSequence)
727
+ )
728
+ return Option.none();
729
+ if (thread.tailDigests.get(checkpoint.throughSequence) !== checkpoint.tailDigest)
730
+ return yield* CheckpointRejected.make({
731
+ threadId: request.threadId,
732
+ reason: "digest-mismatch",
733
+ });
734
+
735
+ return Option.some(checkpoint);
736
+ });
737
+
738
+ return ThreadStore.of({
739
+ materialize,
740
+ append,
741
+ read,
742
+ observe,
743
+ export: exportThread,
744
+ inspectTail,
745
+ checkpoints: { save: saveCheckpoint, load: loadCheckpoint },
746
+ recoveryCheckpoints: { save: saveRecoveryCheckpoint, load: loadRecoveryCheckpoint },
747
+ });
748
+ });
749
+
750
+ /**
751
+ * In-memory canonical Thread persistence. Durable accepted work is served by the separate
752
+ * SubmissionLedger port; this Layer deliberately provides only the ThreadStore.
753
+ */
754
+ export const MemoryThreadStoreLive = Layer.effect(ThreadStore, makeThreadStore);
755
+
756
+ /** Configure a finite retained Thread capacity. Invalid construction options throw immediately. */
757
+ export const memoryThreadStoreLayer = (options: { readonly maxThreads?: number } = {}) =>
758
+ MemoryThreadStoreLive.pipe(
759
+ Layer.provide(
760
+ Layer.succeed(ThreadCapacity)(
761
+ Schema.decodeSync(
762
+ Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(65_536)),
763
+ )(options.maxThreads ?? MAX_THREADS),
764
+ ),
765
+ ),
766
+ );