@effect-agent/storage-cloudflare 0.1.0-beta.38 → 0.1.0-beta.40

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.
@@ -6,17 +6,18 @@ import {
6
6
  CanonicalRecordEnvelope,
7
7
  CanonicalSequence,
8
8
  CheckpointRejected,
9
- ConversationCheckpoint,
10
- ConversationExport,
11
- ConversationExportRequest,
12
- ConversationMaterialization,
13
- ConversationNotMaterialized,
14
- ConversationObservation,
15
- ConversationRead,
16
- ConversationStore,
17
- ConversationStoreError,
18
- ConversationTail,
19
- ConversationTailRequest,
9
+ ThreadCheckpoint,
10
+ ThreadExport,
11
+ ThreadExportRequest,
12
+ ThreadMaterialization,
13
+ ThreadNotMaterialized,
14
+ ThreadObservation,
15
+ ThreadRead,
16
+ ThreadStore,
17
+ type ThreadCheckpoints,
18
+ ThreadStoreError,
19
+ ThreadTail,
20
+ ThreadTailRequest,
20
21
  DEFAULT_OWNERSHIP_LEASE_DURATION,
21
22
  digestCanonicalBatch,
22
23
  Digest,
@@ -26,7 +27,7 @@ import {
26
27
  LoadCheckpointRequest,
27
28
  ObservationOffset,
28
29
  SaveCheckpointRequest,
29
- } from "@effect-agent/session";
30
+ } from "@effect-agent/thread";
30
31
  import { BrowserCrypto } from "@effect/platform-browser";
31
32
  import { SqliteClient } from "@effect/sql-sqlite-do";
32
33
  import {
@@ -76,7 +77,7 @@ export interface DoStorageOptions {
76
77
  readonly observationPollInterval?: number | undefined;
77
78
  /**
78
79
  * Submission ownership lease duration in milliseconds (D5). Defaults to
79
- * `DEFAULT_OWNERSHIP_LEASE_DURATION` from `@effect-agent/session`.
80
+ * `DEFAULT_OWNERSHIP_LEASE_DURATION` from `@effect-agent/thread`.
80
81
  */
81
82
  readonly ownershipLeaseDuration?: number | undefined;
82
83
  /**
@@ -107,27 +108,27 @@ const isDoAppendConflict = Schema.is(DoAppendConflict);
107
108
  const isDoCheckpointConflict = Schema.is(DoCheckpointConflict);
108
109
 
109
110
  const storeError = (operation: string, error: { readonly message: string }) =>
110
- ConversationStoreError.make({
111
+ ThreadStoreError.make({
111
112
  cause: error,
112
113
  operation,
113
114
  message: error.message,
114
115
  });
115
116
 
116
117
  const schemaStoreError = (operation: string, error: { readonly message: string }) =>
117
- ConversationStoreError.make({
118
+ ThreadStoreError.make({
118
119
  cause: error,
119
120
  operation,
120
121
  message: error.message,
121
122
  });
122
123
 
123
124
  const makeOffset = Effect.fn(function* (
124
- conversationId: ConversationMaterialization["conversationId"],
125
+ threadId: ThreadMaterialization["threadId"],
125
126
  sequence: number,
126
- ): Effect.fn.Return<ObservationOffset, ConversationStoreError> {
127
+ ): Effect.fn.Return<ObservationOffset, ThreadStoreError> {
127
128
  return yield* Schema.decodeUnknownEffect(CanonicalSequence)(sequence).pipe(
128
129
  Effect.flatMap((validatedSequence) =>
129
130
  Schema.decodeUnknownEffect(ObservationOffset)(
130
- `${DO_OFFSET_PREFIX}${encodeURIComponent(conversationId)}:${validatedSequence}`,
131
+ `${DO_OFFSET_PREFIX}${encodeURIComponent(threadId)}:${validatedSequence}`,
131
132
  ),
132
133
  ),
133
134
  Effect.mapError((error) => schemaStoreError("encode observation offset", error)),
@@ -135,24 +136,23 @@ const makeOffset = Effect.fn(function* (
135
136
  });
136
137
 
137
138
  const parseOffset = Effect.fn(function* (
138
- conversationId: ConversationMaterialization["conversationId"],
139
+ threadId: ThreadMaterialization["threadId"],
139
140
  offset: ObservationOffset | undefined,
140
- ): Effect.fn.Return<CanonicalSequence, ConversationStoreError> {
141
+ ): Effect.fn.Return<CanonicalSequence, ThreadStoreError> {
141
142
  if (offset === undefined) return ZERO_CANONICAL_SEQUENCE;
142
143
  const text = yield* Schema.decodeUnknownEffect(OffsetText)(offset).pipe(
143
144
  Effect.mapError((error) => schemaStoreError("decode observation offset", error)),
144
145
  );
145
- const conversationPrefix = `${DO_OFFSET_PREFIX}${encodeURIComponent(conversationId)}:`;
146
- if (!text.startsWith(conversationPrefix)) {
147
- return yield* ConversationStoreError.make({
146
+ const threadPrefix = `${DO_OFFSET_PREFIX}${encodeURIComponent(threadId)}:`;
147
+ if (!text.startsWith(threadPrefix)) {
148
+ return yield* ThreadStoreError.make({
148
149
  operation: "decode observation offset",
149
- message:
150
- "The observation offset belongs to a different adapter, storage version, or Conversation.",
150
+ message: "The observation offset belongs to a different adapter, storage version, or Thread.",
151
151
  });
152
152
  }
153
- const sequenceText = text.slice(conversationPrefix.length);
153
+ const sequenceText = text.slice(threadPrefix.length);
154
154
  if (!/^(0|[1-9][0-9]*)$/.test(sequenceText)) {
155
- return yield* ConversationStoreError.make({
155
+ return yield* ThreadStoreError.make({
156
156
  operation: "decode observation offset",
157
157
  message: "The observation offset is malformed.",
158
158
  });
@@ -162,19 +162,16 @@ const parseOffset = Effect.fn(function* (
162
162
  );
163
163
  });
164
164
 
165
- const mapFence = (
166
- conversationId: ConversationMaterialization["conversationId"],
167
- error: DoFenceRejected,
168
- ) =>
165
+ const mapFence = (threadId: ThreadMaterialization["threadId"], error: DoFenceRejected) =>
169
166
  FenceRejected.make({
170
- conversationId,
167
+ threadId,
171
168
  actualEpoch: error.actualEpoch,
172
169
  attemptedEpoch: error.producerEpoch,
173
170
  });
174
171
 
175
172
  const encodeCanonicalRecord = Effect.fn(function* (
176
173
  record: CanonicalRecord,
177
- ): Effect.fn.Return<string, ConversationStoreError> {
174
+ ): Effect.fn.Return<string, ThreadStoreError> {
178
175
  return yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalRecord))(record).pipe(
179
176
  Effect.mapError((error) => schemaStoreError("encode canonical record", error)),
180
177
  );
@@ -182,23 +179,23 @@ const encodeCanonicalRecord = Effect.fn(function* (
182
179
 
183
180
  const encodeCanonicalBatch = Effect.fn(function* (
184
181
  batch: CanonicalBatch,
185
- ): Effect.fn.Return<string, ConversationStoreError> {
182
+ ): Effect.fn.Return<string, ThreadStoreError> {
186
183
  return yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalBatch))(batch).pipe(
187
184
  Effect.mapError((error) => schemaStoreError("encode canonical batch", error)),
188
185
  );
189
186
  });
190
187
 
191
188
  const encodeCheckpoint = Effect.fn(function* (
192
- checkpoint: ConversationCheckpoint,
193
- ): Effect.fn.Return<string, ConversationStoreError> {
194
- return yield* Schema.encodeEffect(Schema.fromJsonString(ConversationCheckpoint))(checkpoint).pipe(
189
+ checkpoint: ThreadCheckpoint,
190
+ ): Effect.fn.Return<string, ThreadStoreError> {
191
+ return yield* Schema.encodeEffect(Schema.fromJsonString(ThreadCheckpoint))(checkpoint).pipe(
195
192
  Effect.mapError((error) => schemaStoreError("encode checkpoint", error)),
196
193
  );
197
194
  });
198
195
 
199
196
  const decodeEnvelope = Effect.fn(function* (row: {
200
197
  readonly batch_id: string;
201
- readonly conversation_id: string;
198
+ readonly thread_id: string;
202
199
  readonly record_json: string;
203
200
  readonly sequence: CanonicalSequence;
204
201
  }) {
@@ -206,23 +203,21 @@ const decodeEnvelope = Effect.fn(function* (row: {
206
203
  row.record_json,
207
204
  ).pipe(
208
205
  Effect.mapError((error) =>
209
- ConversationStoreError.make({
206
+ ThreadStoreError.make({
210
207
  operation: "decode canonical record",
211
208
  message: error.message,
212
209
  }),
213
210
  ),
214
211
  );
215
- const conversationId = yield* Schema.decodeUnknownEffect(
216
- CanonicalRecordEnvelope.fields.conversationId,
217
- )(row.conversation_id).pipe(
218
- Effect.mapError((error) => schemaStoreError("decode conversation identity", error)),
219
- );
220
- const offset = yield* makeOffset(conversationId, row.sequence);
212
+ const threadId = yield* Schema.decodeUnknownEffect(CanonicalRecordEnvelope.fields.threadId)(
213
+ row.thread_id,
214
+ ).pipe(Effect.mapError((error) => schemaStoreError("decode thread identity", error)));
215
+ const offset = yield* makeOffset(threadId, row.sequence);
221
216
  const batchId = yield* Schema.decodeUnknownEffect(CanonicalRecordEnvelope.fields.batchId)(
222
217
  row.batch_id,
223
218
  ).pipe(Effect.mapError((error) => schemaStoreError("decode batch identity", error)));
224
219
  return CanonicalRecordEnvelope.make({
225
- conversationId,
220
+ threadId,
226
221
  batchId,
227
222
  sequence: row.sequence,
228
223
  offset,
@@ -232,37 +227,37 @@ const decodeEnvelope = Effect.fn(function* (row: {
232
227
 
233
228
  const decodeCheckpoint = Effect.fn(function* (
234
229
  checkpointJson: string,
235
- ): Effect.fn.Return<ConversationCheckpoint, ConversationStoreError> {
236
- return yield* Schema.decodeEffect(Schema.fromJsonString(ConversationCheckpoint))(
237
- checkpointJson,
238
- ).pipe(Effect.mapError((error) => schemaStoreError("decode checkpoint", error)));
230
+ ): Effect.fn.Return<ThreadCheckpoint, ThreadStoreError> {
231
+ return yield* Schema.decodeEffect(Schema.fromJsonString(ThreadCheckpoint))(checkpointJson).pipe(
232
+ Effect.mapError((error) => schemaStoreError("decode checkpoint", error)),
233
+ );
239
234
  });
240
235
 
241
- const requireConversation = Effect.fn("DoConversationStore.requireConversation")(function* (
236
+ const requireThread = Effect.fn("DoThreadStore.requireThread")(function* (
242
237
  journal: DoJournal,
243
- conversationId: ConversationMaterialization["conversationId"],
238
+ threadId: ThreadMaterialization["threadId"],
244
239
  ) {
245
240
  const rows = yield* journal
246
- .getConversation(conversationId)
247
- .pipe(Effect.mapError((error) => storeError("read conversation", error)));
241
+ .getThread(threadId)
242
+ .pipe(Effect.mapError((error) => storeError("read thread", error)));
248
243
  if (rows.length === 0) {
249
- return yield* ConversationNotMaterialized.make({ conversationId });
244
+ return yield* ThreadNotMaterialized.make({ threadId });
250
245
  }
251
246
  return rows[0];
252
247
  });
253
248
 
254
- const tailDigestAt = Effect.fn("DoConversationStore.tailDigestAt")(function* (
249
+ const tailDigestAt = Effect.fn("DoThreadStore.tailDigestAt")(function* (
255
250
  journal: DoJournal,
256
- conversationId: ConversationMaterialization["conversationId"],
251
+ threadId: ThreadMaterialization["threadId"],
257
252
  sequence: CanonicalSequence,
258
253
  ) {
259
254
  if (sequence === 0) return EMPTY_TAIL_DIGEST;
260
255
  const digests = yield* journal
261
- .getTailDigestAt(conversationId, sequence)
256
+ .getTailDigestAt(threadId, sequence)
262
257
  .pipe(Effect.mapError((error) => storeError("read checkpoint digest", error)));
263
258
  if (digests.length !== 1) {
264
259
  return yield* CheckpointRejected.make({
265
- conversationId,
260
+ threadId,
266
261
  reason: "digest-mismatch",
267
262
  });
268
263
  }
@@ -292,7 +287,7 @@ const groupByKey = <A>(
292
287
  * and re-digested against the canonical chain. Routine opens skip this scan: per-operation
293
288
  * Schema decoding plus the digest chain already fail clearly on corrupt rows.
294
289
  */
295
- const decodeStartupPayloads = Effect.fn("DoConversationStore.decodeStartupPayloads")(function* (
290
+ const decodeStartupPayloads = Effect.fn("DoThreadStore.decodeStartupPayloads")(function* (
296
291
  journal: DoJournal,
297
292
  crypto: Crypto.Crypto,
298
293
  ) {
@@ -303,7 +298,7 @@ const decodeStartupPayloads = Effect.fn("DoConversationStore.decodeStartupPayloa
303
298
  Effect.mapError((error) =>
304
299
  DoStorageCorruptionError.make({
305
300
  table: "effect_agent_canonical_batches",
306
- rowKey: `${batch.conversation_id}/${batch.batch_id}`,
301
+ rowKey: `${batch.thread_id}/${batch.batch_id}`,
307
302
  message: error.message,
308
303
  }),
309
304
  ),
@@ -315,46 +310,41 @@ const decodeStartupPayloads = Effect.fn("DoConversationStore.decodeStartupPayloa
315
310
  Effect.mapError((error) =>
316
311
  DoStorageCorruptionError.make({
317
312
  table: "effect_agent_canonical_records",
318
- rowKey: `${record.conversation_id}/${record.sequence}`,
313
+ rowKey: `${record.thread_id}/${record.sequence}`,
319
314
  message: error.message,
320
315
  }),
321
316
  ),
322
317
  ),
323
318
  );
324
319
  const checkpoints = yield* Effect.forEach(stored.checkpoints, (checkpoint) =>
325
- Schema.decodeEffect(Schema.fromJsonString(ConversationCheckpoint))(
326
- checkpoint.checkpoint_json,
327
- ).pipe(
320
+ Schema.decodeEffect(Schema.fromJsonString(ThreadCheckpoint))(checkpoint.checkpoint_json).pipe(
328
321
  Effect.map((decoded) => ({ decoded, row: checkpoint })),
329
322
  Effect.mapError((error) =>
330
323
  DoStorageCorruptionError.make({
331
324
  table: "effect_agent_checkpoints",
332
- rowKey: `${checkpoint.conversation_id}/${checkpoint.through_sequence}`,
325
+ rowKey: `${checkpoint.thread_id}/${checkpoint.through_sequence}`,
333
326
  message: error.message,
334
327
  }),
335
328
  ),
336
329
  ),
337
330
  );
338
331
 
339
- const batchesByConversation = groupByKey(batches, ({ row }) => row.conversation_id);
340
- const recordsByConversation = groupByKey(records, ({ row }) => row.conversation_id);
341
- const checkpointsByConversation = groupByKey(checkpoints, ({ row }) => row.conversation_id);
342
- const materializedIds = new Set(
343
- stored.conversations.map((conversation) => conversation.conversation_id),
344
- );
332
+ const batchesByThread = groupByKey(batches, ({ row }) => row.thread_id);
333
+ const recordsByThread = groupByKey(records, ({ row }) => row.thread_id);
334
+ const checkpointsByThread = groupByKey(checkpoints, ({ row }) => row.thread_id);
335
+ const materializedIds = new Set(stored.threads.map((thread) => thread.thread_id));
345
336
 
346
- for (const conversation of stored.conversations) {
347
- const conversationBatches = batchesByConversation.get(conversation.conversation_id) ?? [];
348
- const conversationRecords = recordsByConversation.get(conversation.conversation_id) ?? [];
349
- const conversationCheckpoints =
350
- checkpointsByConversation.get(conversation.conversation_id) ?? [];
351
- const recordsByBatch = groupByKey(conversationRecords, ({ row }) => row.batch_id);
337
+ for (const thread of stored.threads) {
338
+ const threadBatches = batchesByThread.get(thread.thread_id) ?? [];
339
+ const threadRecords = recordsByThread.get(thread.thread_id) ?? [];
340
+ const threadCheckpoints = checkpointsByThread.get(thread.thread_id) ?? [];
341
+ const recordsByBatch = groupByKey(threadRecords, ({ row }) => row.batch_id);
352
342
  let previousDigest = EMPTY_TAIL_DIGEST;
353
343
  let expectedSequence = 1;
354
344
  const tailDigests = new Map<number, string>([[0, EMPTY_TAIL_DIGEST]]);
355
345
 
356
- for (const { decoded: canonicalBatch, row: batchRow } of conversationBatches) {
357
- const key = `${batchRow.conversation_id}/${batchRow.batch_id}`;
346
+ for (const { decoded: canonicalBatch, row: batchRow } of threadBatches) {
347
+ const key = `${batchRow.thread_id}/${batchRow.batch_id}`;
358
348
  if (
359
349
  canonicalBatch.batchId !== batchRow.batch_id ||
360
350
  batchRow.first_sequence !== expectedSequence ||
@@ -437,27 +427,27 @@ const decodeStartupPayloads = Effect.fn("DoConversationStore.decodeStartupPayloa
437
427
  }
438
428
 
439
429
  if (
440
- conversationRecords.length !== conversation.tail_sequence ||
441
- conversation.tail_sequence !== expectedSequence - 1 ||
442
- conversation.tail_digest !== previousDigest
430
+ threadRecords.length !== thread.tail_sequence ||
431
+ thread.tail_sequence !== expectedSequence - 1 ||
432
+ thread.tail_digest !== previousDigest
443
433
  ) {
444
434
  return yield* DoStorageCorruptionError.make({
445
- table: "effect_agent_conversations",
446
- rowKey: conversation.conversation_id,
447
- message: "Conversation tail does not match its canonical batch chain.",
435
+ table: "effect_agent_threads",
436
+ rowKey: thread.thread_id,
437
+ message: "Thread tail does not match its canonical batch chain.",
448
438
  });
449
439
  }
450
440
 
451
- for (const checkpoint of conversationCheckpoints) {
441
+ for (const checkpoint of threadCheckpoints) {
452
442
  if (
453
- checkpoint.decoded.conversationId !== conversation.conversation_id ||
443
+ checkpoint.decoded.threadId !== thread.thread_id ||
454
444
  checkpoint.decoded.throughSequence !== checkpoint.row.through_sequence ||
455
445
  checkpoint.decoded.tailDigest !== checkpoint.row.tail_digest ||
456
446
  tailDigests.get(checkpoint.row.through_sequence) !== checkpoint.row.tail_digest
457
447
  ) {
458
448
  return yield* DoStorageCorruptionError.make({
459
449
  table: "effect_agent_checkpoints",
460
- rowKey: `${conversation.conversation_id}/${checkpoint.row.through_sequence}`,
450
+ rowKey: `${thread.thread_id}/${checkpoint.row.through_sequence}`,
461
451
  message: "Checkpoint identity or digest is not bound to a canonical batch tail.",
462
452
  });
463
453
  }
@@ -465,19 +455,19 @@ const decodeStartupPayloads = Effect.fn("DoConversationStore.decodeStartupPayloa
465
455
  }
466
456
 
467
457
  if (
468
- batches.some(({ row }) => !materializedIds.has(row.conversation_id)) ||
469
- records.some(({ row }) => !materializedIds.has(row.conversation_id)) ||
470
- checkpoints.some(({ row }) => !materializedIds.has(row.conversation_id))
458
+ batches.some(({ row }) => !materializedIds.has(row.thread_id)) ||
459
+ records.some(({ row }) => !materializedIds.has(row.thread_id)) ||
460
+ checkpoints.some(({ row }) => !materializedIds.has(row.thread_id))
471
461
  ) {
472
462
  return yield* DoStorageCorruptionError.make({
473
- table: "effect_agent_conversations",
463
+ table: "effect_agent_threads",
474
464
  rowKey: "startup_scan",
475
- message: "Canonical rows exist without a materialized Conversation.",
465
+ message: "Canonical rows exist without a materialized Thread.",
476
466
  });
477
467
  }
478
468
  });
479
469
 
480
- const makeServices = Effect.fn("DoConversationStore.makeServices")(function* () {
470
+ const makeServices = Effect.fn("DoThreadStore.makeServices")(function* () {
481
471
  const config = yield* DoStorageConfig;
482
472
  const failpoint = yield* DoStorageFailpoint;
483
473
  const sql = yield* SqlClientService.SqlClient;
@@ -490,142 +480,135 @@ const makeServices = Effect.fn("DoConversationStore.makeServices")(function* ()
490
480
  const provideCrypto = <A, E>(effect: Effect.Effect<A, E, Crypto.Crypto>) =>
491
481
  Effect.provideService(effect, Crypto.Crypto, crypto);
492
482
  const hitFailpoint = Effect.fn(
493
- (location: DoStorageFailpointLocation): Effect.Effect<void, ConversationStoreError> =>
483
+ (location: DoStorageFailpointLocation): Effect.Effect<void, ThreadStoreError> =>
494
484
  failpoint
495
485
  .hit(location)
496
486
  .pipe(Effect.mapError((error) => storeError(`storage failpoint ${location}`, error))),
497
487
  );
498
488
 
499
- const materialize: ConversationStore["Service"]["materialize"] = Effect.fn(
500
- "DoConversationStore.materialize",
501
- )(function* (request: ConversationMaterialization) {
502
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationMaterialization))(
503
- request,
504
- ).pipe(Effect.mapError((error) => schemaStoreError("validate materialization", error)));
505
- const now = yield* Clock.currentTimeMillis;
506
- yield* hitFailpoint("materialize:before");
507
- yield* journal
508
- .materialize(
509
- validated.conversationId,
510
- new Date(now).toISOString(),
511
- EMPTY_TAIL_DIGEST,
512
- validated.producerEpoch,
513
- )
514
- .pipe(
515
- Effect.mapError((error) =>
516
- error._tag === "DoFenceRejected"
517
- ? mapFence(validated.conversationId, error)
518
- : storeError("materialize conversation", error),
519
- ),
520
- );
521
- yield* hitFailpoint("materialize:after");
522
- });
523
-
524
- const append: ConversationStore["Service"]["append"] = Effect.fn("DoConversationStore.append")(
525
- function* (request: FencedAppendRequest) {
526
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(FencedAppendRequest))(
489
+ const materialize: ThreadStore["Service"]["materialize"] = Effect.fn("DoThreadStore.materialize")(
490
+ function* (request: ThreadMaterialization) {
491
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadMaterialization))(
527
492
  request,
528
- ).pipe(Effect.mapError((error) => schemaStoreError("validate canonical append", error)));
529
- yield* requireConversation(journal, validated.conversationId);
530
- const tailDigest = yield* provideCrypto(
531
- digestCanonicalBatch(validated.expectedTailDigest, validated.batch),
532
- ).pipe(Effect.mapError((error) => storeError("digest canonical append", error)));
533
- const batchJson = yield* encodeCanonicalBatch(validated.batch);
534
- const rawRecords = yield* Effect.forEach(validated.batch.records, (record) =>
535
- encodeCanonicalRecord(record).pipe(
536
- Effect.map((recordJson) => ({
537
- recordId: record.recordId,
538
- recordJson,
539
- })),
540
- ),
541
- );
542
- const rawRequest = yield* Schema.decodeUnknownEffect(RawAppendRequest)({
543
- conversationId: validated.conversationId,
544
- batchId: validated.batch.batchId,
545
- batchDigest: tailDigest,
546
- batchJson,
547
- expectedTailSequence: validated.expectedTailSequence,
548
- expectedTailDigest: validated.expectedTailDigest,
549
- producerEpoch: validated.producerEpoch,
550
- records: rawRecords,
551
- tailDigest,
552
- }).pipe(Effect.mapError((error) => schemaStoreError("encode canonical append", error)));
553
- yield* hitFailpoint("append:before");
554
- const result = yield* journal.append(rawRequest).pipe(
555
- Effect.mapError((error) => {
556
- if (isDoFenceRejected(error)) {
557
- return mapFence(validated.conversationId, error);
558
- }
559
- if (isDoAppendConflict(error)) {
560
- return error.actualTailSequence !== undefined && isDigest(error.actualTailDigest)
561
- ? AppendConflict.make({
562
- conversationId: validated.conversationId,
563
- batchId: validated.batch.batchId,
564
- reason: error.reason,
565
- actualTailSequence: error.actualTailSequence,
566
- actualTailDigest: error.actualTailDigest,
567
- })
568
- : AppendConflict.make({
569
- conversationId: validated.conversationId,
570
- batchId: validated.batch.batchId,
571
- reason: error.reason,
572
- });
573
- }
574
- return storeError("append canonical batch", error);
575
- }),
576
- Effect.flatMap((result) =>
577
- Schema.decodeUnknownEffect(AppendResult)(result).pipe(
578
- Effect.mapError((error) => schemaStoreError("decode append result", error)),
493
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate materialization", error)));
494
+ const now = yield* Clock.currentTimeMillis;
495
+ yield* hitFailpoint("materialize:before");
496
+ yield* journal
497
+ .materialize(
498
+ validated.threadId,
499
+ new Date(now).toISOString(),
500
+ EMPTY_TAIL_DIGEST,
501
+ validated.producerEpoch,
502
+ )
503
+ .pipe(
504
+ Effect.mapError((error) =>
505
+ error._tag === "DoFenceRejected"
506
+ ? mapFence(validated.threadId, error)
507
+ : storeError("materialize thread", error),
579
508
  ),
580
- ),
581
- );
582
- yield* hitFailpoint("append:after");
583
- return result;
509
+ );
510
+ yield* hitFailpoint("materialize:after");
584
511
  },
585
512
  );
586
513
 
587
- const loadRecords = Effect.fn("DoConversationStore.loadRecords")(function* (
588
- request: RawReadRequest,
514
+ const append: ThreadStore["Service"]["append"] = Effect.fn("DoThreadStore.append")(function* (
515
+ request: FencedAppendRequest,
589
516
  ) {
517
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(FencedAppendRequest))(
518
+ request,
519
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate canonical append", error)));
520
+ yield* requireThread(journal, validated.threadId);
521
+ const tailDigest = yield* provideCrypto(
522
+ digestCanonicalBatch(validated.expectedTailDigest, validated.batch),
523
+ ).pipe(Effect.mapError((error) => storeError("digest canonical append", error)));
524
+ const batchJson = yield* encodeCanonicalBatch(validated.batch);
525
+ const rawRecords = yield* Effect.forEach(validated.batch.records, (record) =>
526
+ encodeCanonicalRecord(record).pipe(
527
+ Effect.map((recordJson) => ({
528
+ recordId: record.recordId,
529
+ recordJson,
530
+ })),
531
+ ),
532
+ );
533
+ const rawRequest = yield* Schema.decodeUnknownEffect(RawAppendRequest)({
534
+ threadId: validated.threadId,
535
+ batchId: validated.batch.batchId,
536
+ batchDigest: tailDigest,
537
+ batchJson,
538
+ expectedTailSequence: validated.expectedTailSequence,
539
+ expectedTailDigest: validated.expectedTailDigest,
540
+ producerEpoch: validated.producerEpoch,
541
+ records: rawRecords,
542
+ tailDigest,
543
+ }).pipe(Effect.mapError((error) => schemaStoreError("encode canonical append", error)));
544
+ yield* hitFailpoint("append:before");
545
+ const result = yield* journal.append(rawRequest).pipe(
546
+ Effect.mapError((error) => {
547
+ if (isDoFenceRejected(error)) {
548
+ return mapFence(validated.threadId, error);
549
+ }
550
+ if (isDoAppendConflict(error)) {
551
+ return error.actualTailSequence !== undefined && isDigest(error.actualTailDigest)
552
+ ? AppendConflict.make({
553
+ threadId: validated.threadId,
554
+ batchId: validated.batch.batchId,
555
+ reason: error.reason,
556
+ actualTailSequence: error.actualTailSequence,
557
+ actualTailDigest: error.actualTailDigest,
558
+ })
559
+ : AppendConflict.make({
560
+ threadId: validated.threadId,
561
+ batchId: validated.batch.batchId,
562
+ reason: error.reason,
563
+ });
564
+ }
565
+ return storeError("append canonical batch", error);
566
+ }),
567
+ Effect.flatMap((result) =>
568
+ Schema.decodeUnknownEffect(AppendResult)(result).pipe(
569
+ Effect.mapError((error) => schemaStoreError("decode append result", error)),
570
+ ),
571
+ ),
572
+ );
573
+ yield* hitFailpoint("append:after");
574
+ return result;
575
+ });
576
+
577
+ const loadRecords = Effect.fn("DoThreadStore.loadRecords")(function* (request: RawReadRequest) {
590
578
  const rows = yield* journal
591
579
  .read(request)
592
580
  .pipe(Effect.mapError((error) => storeError("read canonical records", error)));
593
581
  return yield* Effect.forEach(rows, decodeEnvelope);
594
582
  });
595
583
 
596
- const readEffect = Effect.fn("DoConversationStore.read")(function* (request: ConversationRead) {
597
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationRead))(
598
- request,
599
- ).pipe(Effect.mapError((error) => schemaStoreError("validate conversation read", error)));
600
- yield* requireConversation(journal, validated.conversationId);
584
+ const readEffect = Effect.fn("DoThreadStore.read")(function* (request: ThreadRead) {
585
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadRead))(request).pipe(
586
+ Effect.mapError((error) => schemaStoreError("validate thread read", error)),
587
+ );
588
+ yield* requireThread(journal, validated.threadId);
601
589
  const records = yield* loadRecords(
602
590
  RawReadRequest.make({
603
- conversationId: validated.conversationId,
591
+ threadId: validated.threadId,
604
592
  fromSequenceExclusive: validated.afterSequence ?? ZERO_CANONICAL_SEQUENCE,
605
593
  limit: validated.limit,
606
594
  }),
607
595
  );
608
596
  return Stream.fromIterable(records);
609
597
  });
610
- const read: ConversationStore["Service"]["read"] = (request) =>
611
- Stream.unwrap(readEffect(request));
598
+ const read: ThreadStore["Service"]["read"] = (request) => Stream.unwrap(readEffect(request));
612
599
 
613
- const observeEffect = Effect.fn("DoConversationStore.observe")(function* (
614
- request: ConversationObservation,
615
- ) {
616
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationObservation))(
600
+ const observeEffect = Effect.fn("DoThreadStore.observe")(function* (request: ThreadObservation) {
601
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadObservation))(
617
602
  request,
618
- ).pipe(
619
- Effect.mapError((error) => schemaStoreError("validate conversation observation", error)),
620
- );
621
- yield* requireConversation(journal, validated.conversationId);
622
- const initialSequence = yield* parseOffset(validated.conversationId, validated.afterOffset);
603
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate thread observation", error)));
604
+ yield* requireThread(journal, validated.threadId);
605
+ const initialSequence = yield* parseOffset(validated.threadId, validated.afterOffset);
623
606
  const cursor = yield* Ref.make(initialSequence);
624
- const poll = Effect.fn("DoConversationStore.observePoll")(function* () {
607
+ const poll = Effect.fn("DoThreadStore.observePoll")(function* () {
625
608
  const fromSequenceExclusive = yield* Ref.get(cursor);
626
609
  const records = yield* loadRecords(
627
610
  RawReadRequest.make({
628
- conversationId: validated.conversationId,
611
+ threadId: validated.threadId,
629
612
  fromSequenceExclusive,
630
613
  limit: 1_024,
631
614
  }),
@@ -639,163 +622,159 @@ const makeServices = Effect.fn("DoConversationStore.makeServices")(function* ()
639
622
  });
640
623
  return Stream.fromIterableEffectRepeat(poll());
641
624
  });
642
- const observe: ConversationStore["Service"]["observe"] = (request) =>
625
+ const observe: ThreadStore["Service"]["observe"] = (request) =>
643
626
  Stream.unwrap(observeEffect(request));
644
627
 
645
- const exportConversation: ConversationStore["Service"]["export"] = Effect.fn(
646
- "DoConversationStore.export",
647
- )(function* (request: ConversationExportRequest) {
648
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationExportRequest))(
649
- request,
650
- ).pipe(Effect.mapError((error) => schemaStoreError("validate conversation export", error)));
651
- yield* requireConversation(journal, validated.conversationId);
652
- const exported = yield* journal
653
- .exportConversation(validated.conversationId)
654
- .pipe(Effect.mapError((error) => storeError("export conversation", error)));
655
- const records = yield* Effect.forEach(exported.records, decodeEnvelope);
656
- if (records.length > 65_536) {
657
- return yield* ConversationStoreError.make({
658
- operation: "decode conversation export",
659
- message: "The conversation exceeds the current export record limit.",
628
+ const exportThread: ThreadStore["Service"]["export"] = Effect.fn("DoThreadStore.export")(
629
+ function* (request: ThreadExportRequest) {
630
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadExportRequest))(
631
+ request,
632
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate thread export", error)));
633
+ yield* requireThread(journal, validated.threadId);
634
+ const exported = yield* journal
635
+ .exportThread(validated.threadId)
636
+ .pipe(Effect.mapError((error) => storeError("export thread", error)));
637
+ const records = yield* Effect.forEach(exported.records, decodeEnvelope);
638
+ if (records.length > 65_536) {
639
+ return yield* ThreadStoreError.make({
640
+ operation: "decode thread export",
641
+ message: "The thread exceeds the current export record limit.",
642
+ });
643
+ }
644
+ const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(
645
+ exported.thread.tail_digest,
646
+ ).pipe(Effect.mapError((error) => schemaStoreError("decode export tail digest", error)));
647
+ return ThreadExport.make({
648
+ format: "effect-agent/thread@1",
649
+ threadId: validated.threadId,
650
+ tailSequence: exported.thread.tail_sequence,
651
+ tailDigest,
652
+ records,
660
653
  });
661
- }
662
- const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(
663
- exported.conversation.tail_digest,
664
- ).pipe(Effect.mapError((error) => schemaStoreError("decode export tail digest", error)));
665
- return ConversationExport.make({
666
- format: "effect-agent/conversation@1",
667
- conversationId: validated.conversationId,
668
- tailSequence: exported.conversation.tail_sequence,
669
- tailDigest,
670
- records,
671
- });
672
- });
673
-
674
- const inspectTail: ConversationStore["Service"]["inspectTail"] = Effect.fn(
675
- "DoConversationStore.inspectTail",
676
- )(function* (request: ConversationTailRequest) {
677
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationTailRequest))(
678
- request,
679
- ).pipe(Effect.mapError((error) => schemaStoreError("validate tail inspection", error)));
680
- const conversation = yield* requireConversation(journal, validated.conversationId);
681
- const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(conversation.tail_digest).pipe(
682
- Effect.mapError((error) => schemaStoreError("decode tail digest", error)),
683
- );
684
- return ConversationTail.make({
685
- conversationId: validated.conversationId,
686
- tailSequence: conversation.tail_sequence,
687
- tailDigest,
688
- producerEpoch: conversation.producer_epoch,
689
- });
690
- });
654
+ },
655
+ );
691
656
 
692
- const saveCheckpoint: ConversationStore["Service"]["saveCheckpoint"] = Effect.fn(
693
- "DoConversationStore.saveCheckpoint",
694
- )(function* (request: SaveCheckpointRequest) {
695
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SaveCheckpointRequest))(
696
- request,
697
- ).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint", error)));
698
- const conversation = yield* requireConversation(journal, validated.checkpoint.conversationId);
699
- if (validated.checkpoint.throughSequence > conversation.tail_sequence) {
700
- return yield* CheckpointRejected.make({
701
- conversationId: validated.checkpoint.conversationId,
702
- reason: "ahead-of-tail",
703
- });
704
- }
705
- const canonicalDigest = yield* tailDigestAt(
706
- journal,
707
- validated.checkpoint.conversationId,
708
- validated.checkpoint.throughSequence,
709
- );
710
- if (canonicalDigest !== validated.checkpoint.tailDigest) {
711
- return yield* CheckpointRejected.make({
712
- conversationId: validated.checkpoint.conversationId,
713
- reason: "digest-mismatch",
657
+ const inspectTail: ThreadStore["Service"]["inspectTail"] = Effect.fn("DoThreadStore.inspectTail")(
658
+ function* (request: ThreadTailRequest) {
659
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadTailRequest))(
660
+ request,
661
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate tail inspection", error)));
662
+ const thread = yield* requireThread(journal, validated.threadId);
663
+ const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(thread.tail_digest).pipe(
664
+ Effect.mapError((error) => schemaStoreError("decode tail digest", error)),
665
+ );
666
+ return ThreadTail.make({
667
+ threadId: validated.threadId,
668
+ tailSequence: thread.tail_sequence,
669
+ tailDigest,
670
+ producerEpoch: thread.producer_epoch,
714
671
  });
715
- }
716
- const checkpointJson = yield* encodeCheckpoint(validated.checkpoint);
717
- const raw = RawCheckpoint.make({
718
- conversationId: validated.checkpoint.conversationId,
719
- throughSequence: validated.checkpoint.throughSequence,
720
- tailDigest: validated.checkpoint.tailDigest,
721
- checkpointJson,
722
- });
723
- yield* hitFailpoint("save-checkpoint:before");
724
- yield* journal.saveCheckpoint(raw).pipe(
725
- Effect.mapError((error) =>
726
- isDoCheckpointConflict(error)
727
- ? CheckpointRejected.make({
728
- conversationId: validated.checkpoint.conversationId,
729
- reason: "digest-mismatch",
730
- })
731
- : storeError("save checkpoint", error),
732
- ),
733
- );
734
- yield* hitFailpoint("save-checkpoint:after");
735
- });
672
+ },
673
+ );
736
674
 
737
- const loadCheckpoint: ConversationStore["Service"]["loadCheckpoint"] = Effect.fn(
738
- "DoConversationStore.loadCheckpoint",
739
- )(function* (request: LoadCheckpointRequest) {
740
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(LoadCheckpointRequest))(
741
- request,
742
- ).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint lookup", error)));
743
- const conversation = yield* requireConversation(journal, validated.conversationId);
744
- const rows = yield* journal
745
- .loadCheckpoint(
746
- validated.conversationId,
747
- validated.atOrBeforeSequence ?? conversation.tail_sequence,
748
- )
749
- .pipe(Effect.mapError((error) => storeError("load checkpoint", error)));
750
- if (rows.length === 0) return Option.none();
751
- if (rows.length !== 1) {
752
- return yield* ConversationStoreError.make({
753
- operation: "load checkpoint",
754
- message: `Expected at most one checkpoint row but found ${rows.length}.`,
755
- });
756
- }
757
- const checkpoint = yield* decodeCheckpoint(rows[0].checkpoint_json);
758
- const canonicalDigest = yield* tailDigestAt(
759
- journal,
760
- checkpoint.conversationId,
761
- checkpoint.throughSequence,
762
- );
763
- if (canonicalDigest !== checkpoint.tailDigest) {
764
- return yield* CheckpointRejected.make({
765
- conversationId: checkpoint.conversationId,
766
- reason: "digest-mismatch",
675
+ const saveCheckpoint: ThreadCheckpoints["save"] = Effect.fn("DoThreadStore.saveCheckpoint")(
676
+ function* (request: SaveCheckpointRequest) {
677
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SaveCheckpointRequest))(
678
+ request,
679
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint", error)));
680
+ const thread = yield* requireThread(journal, validated.checkpoint.threadId);
681
+ if (validated.checkpoint.throughSequence > thread.tail_sequence) {
682
+ return yield* CheckpointRejected.make({
683
+ threadId: validated.checkpoint.threadId,
684
+ reason: "ahead-of-tail",
685
+ });
686
+ }
687
+ const canonicalDigest = yield* tailDigestAt(
688
+ journal,
689
+ validated.checkpoint.threadId,
690
+ validated.checkpoint.throughSequence,
691
+ );
692
+ if (canonicalDigest !== validated.checkpoint.tailDigest) {
693
+ return yield* CheckpointRejected.make({
694
+ threadId: validated.checkpoint.threadId,
695
+ reason: "digest-mismatch",
696
+ });
697
+ }
698
+ const checkpointJson = yield* encodeCheckpoint(validated.checkpoint);
699
+ const raw = RawCheckpoint.make({
700
+ threadId: validated.checkpoint.threadId,
701
+ throughSequence: validated.checkpoint.throughSequence,
702
+ tailDigest: validated.checkpoint.tailDigest,
703
+ checkpointJson,
767
704
  });
768
- }
769
- return Option.some(checkpoint);
770
- });
705
+ yield* hitFailpoint("save-checkpoint:before");
706
+ yield* journal.saveCheckpoint(raw).pipe(
707
+ Effect.mapError((error) =>
708
+ isDoCheckpointConflict(error)
709
+ ? CheckpointRejected.make({
710
+ threadId: validated.checkpoint.threadId,
711
+ reason: "digest-mismatch",
712
+ })
713
+ : storeError("save checkpoint", error),
714
+ ),
715
+ );
716
+ yield* hitFailpoint("save-checkpoint:after");
717
+ },
718
+ );
719
+
720
+ const loadCheckpoint: ThreadCheckpoints["load"] = Effect.fn("DoThreadStore.loadCheckpoint")(
721
+ function* (request: LoadCheckpointRequest) {
722
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(LoadCheckpointRequest))(
723
+ request,
724
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint lookup", error)));
725
+ const thread = yield* requireThread(journal, validated.threadId);
726
+ const rows = yield* journal
727
+ .loadCheckpoint(validated.threadId, validated.atOrBeforeSequence ?? thread.tail_sequence)
728
+ .pipe(Effect.mapError((error) => storeError("load checkpoint", error)));
729
+ if (rows.length === 0) return Option.none();
730
+ if (rows.length !== 1) {
731
+ return yield* ThreadStoreError.make({
732
+ operation: "load checkpoint",
733
+ message: `Expected at most one checkpoint row but found ${rows.length}.`,
734
+ });
735
+ }
736
+ const checkpoint = yield* decodeCheckpoint(rows[0].checkpoint_json);
737
+ const canonicalDigest = yield* tailDigestAt(
738
+ journal,
739
+ checkpoint.threadId,
740
+ checkpoint.throughSequence,
741
+ );
742
+ if (canonicalDigest !== checkpoint.tailDigest) {
743
+ return yield* CheckpointRejected.make({
744
+ threadId: checkpoint.threadId,
745
+ reason: "digest-mismatch",
746
+ });
747
+ }
748
+ return Option.some(checkpoint);
749
+ },
750
+ );
771
751
 
772
- const conversationStore = ConversationStore.of({
752
+ const threadStore = ThreadStore.of({
773
753
  append,
774
- export: exportConversation,
754
+ export: exportThread,
775
755
  inspectTail,
776
- loadCheckpoint,
777
756
  materialize,
778
757
  observe,
779
758
  read,
780
- saveCheckpoint,
759
+ checkpoints: { save: saveCheckpoint, load: loadCheckpoint },
781
760
  });
782
761
 
783
- return Context.make(ConversationStore, conversationStore);
762
+ return Context.make(ThreadStore, threadStore);
784
763
  });
785
764
 
786
765
  /**
787
- * Durable Object Conversation Store implementation with configuration, failpoint, SQL, and
766
+ * Durable Object Thread Store implementation with configuration, failpoint, SQL, and
788
767
  * Crypto authority kept visible in its input channel.
789
768
  */
790
- export const conversationStoreLayer: Layer.Layer<
791
- ConversationStore,
769
+ export const threadStoreLayer: Layer.Layer<
770
+ ThreadStore,
792
771
  DoStorageInitializationError,
793
772
  DoStorageConfig | DoStorageFailpoint | SqlClientService.SqlClient | Crypto.Crypto
794
773
  > = Layer.effectContext(makeServices());
795
774
 
796
775
  /**
797
776
  * Validated Durable Object storage configuration Layer with the documented defaults applied.
798
- * Shared by the ConversationStore and SubmissionLedger convenience layers so their defaults
777
+ * Shared by the ThreadStore and SubmissionLedger convenience layers so their defaults
799
778
  * cannot drift.
800
779
  */
801
780
  export const storageConfigLayer = (
@@ -828,17 +807,17 @@ export const storageFailpointLayer = (
828
807
  : Layer.succeed(DoStorageFailpoint)({ hit: options.failpoint });
829
808
 
830
809
  /**
831
- * A composition-root convenience Layer for canonical Conversations inside one Durable Object,
810
+ * A composition-root convenience Layer for canonical Threads inside one Durable Object,
832
811
  * built over `ctx.storage`. Durable accepted work is served by the separate SubmissionLedger
833
812
  * port; point both at the SAME `ctx.storage` so claims fence the same producer epochs
834
813
  * (ADR-0011 D7's "same file" rule, transposed to one object's private database).
835
814
  */
836
815
  export const layer = (
837
816
  options: DoStorageOptions,
838
- ): Layer.Layer<ConversationStore, DoStorageInitializationError> =>
817
+ ): Layer.Layer<ThreadStore, DoStorageInitializationError> =>
839
818
  Layer.unwrap(
840
819
  Effect.map(DoStorageConfig, (config) =>
841
- conversationStoreLayer.pipe(
820
+ threadStoreLayer.pipe(
842
821
  Layer.provide(
843
822
  Layer.mergeAll(
844
823
  Layer.succeed(DoStorageConfig)(config),