@effect-agent/storage-cloudflare 0.1.0-beta.8 → 0.1.0-beta.81

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 (73) hide show
  1. package/dist/DoMemoryStore.d.mts +29 -0
  2. package/dist/DoMemoryStore.mjs +55 -0
  3. package/dist/DoMemoryStore.mjs.map +1 -0
  4. package/dist/DoMessageDeliveryStore.d.mts +17 -0
  5. package/dist/DoMessageDeliveryStore.mjs +32 -0
  6. package/dist/DoMessageDeliveryStore.mjs.map +1 -0
  7. package/dist/DoScheduleStore.d.mts +36 -0
  8. package/dist/DoScheduleStore.mjs +388 -0
  9. package/dist/DoScheduleStore.mjs.map +1 -0
  10. package/dist/DoStorageConfig.d.mts +45 -0
  11. package/dist/DoStorageConfig.mjs +52 -0
  12. package/dist/DoStorageConfig.mjs.map +1 -0
  13. package/dist/DoStorageError-Cmhvl4TQ.d.mts +98 -0
  14. package/dist/DoStorageError.d.mts +2 -0
  15. package/dist/DoStorageError.mjs +164 -0
  16. package/dist/DoStorageError.mjs.map +1 -0
  17. package/dist/DoStorageFailpoint.d.mts +17 -0
  18. package/dist/DoStorageFailpoint.mjs +14 -0
  19. package/dist/DoStorageFailpoint.mjs.map +1 -0
  20. package/dist/DoStorageFailpointTesting.d.mts +34 -0
  21. package/dist/DoStorageFailpointTesting.mjs +35 -0
  22. package/dist/DoStorageFailpointTesting.mjs.map +1 -0
  23. package/dist/DoStorageVersion-x5OOurvZ.d.mts +12 -0
  24. package/dist/DoStorageVersion.d.mts +2 -0
  25. package/dist/DoStorageVersion.mjs +8 -0
  26. package/dist/DoStorageVersion.mjs.map +1 -0
  27. package/dist/DoSubmissionLedger.d.mts +23 -0
  28. package/dist/DoSubmissionLedger.mjs +1849 -0
  29. package/dist/DoSubmissionLedger.mjs.map +1 -0
  30. package/dist/DoSubscriptionStore.d.mts +25 -0
  31. package/dist/DoSubscriptionStore.mjs +183 -0
  32. package/dist/DoSubscriptionStore.mjs.map +1 -0
  33. package/dist/DoThreadStore.d.mts +59 -0
  34. package/dist/DoThreadStore.mjs +465 -0
  35. package/dist/DoThreadStore.mjs.map +1 -0
  36. package/dist/MemoryProtocol.d.mts +38160 -0
  37. package/dist/MemoryProtocol.mjs +219 -0
  38. package/dist/MemoryProtocol.mjs.map +1 -0
  39. package/dist/PortProtocol.d.mts +3207 -0
  40. package/dist/PortProtocol.mjs +176 -0
  41. package/dist/PortProtocol.mjs.map +1 -0
  42. package/dist/PortRouting.d.mts +83 -0
  43. package/dist/PortRouting.mjs +410 -0
  44. package/dist/PortRouting.mjs.map +1 -0
  45. package/dist/do-journal-DHqCQeBU.mjs +991 -0
  46. package/dist/do-journal-DHqCQeBU.mjs.map +1 -0
  47. package/dist/index.d.mts +14 -1521
  48. package/dist/index.mjs +14 -3695
  49. package/dist/migrations-Ba_kOrSx.mjs +287 -0
  50. package/dist/migrations-Ba_kOrSx.mjs.map +1 -0
  51. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  52. package/package.json +1 -45
  53. package/src/DoMemoryStore.ts +59 -0
  54. package/src/DoMessageDeliveryStore.ts +53 -0
  55. package/src/DoScheduleStore.ts +622 -0
  56. package/src/{errors.ts → DoStorageError.ts} +10 -3
  57. package/src/DoStorageFailpoint.ts +20 -0
  58. package/src/{do-storage-failpoint.ts → DoStorageFailpointTesting.ts} +5 -18
  59. package/src/DoStorageVersion.ts +2 -0
  60. package/src/{do-ledger.ts → DoSubmissionLedger.ts} +862 -163
  61. package/src/DoSubscriptionStore.ts +329 -0
  62. package/src/DoThreadStore.ts +1014 -0
  63. package/src/MemoryProtocol.ts +355 -0
  64. package/src/{port-protocol.ts → PortProtocol.ts} +44 -37
  65. package/src/{routing.ts → PortRouting.ts} +292 -264
  66. package/src/index.ts +13 -32
  67. package/src/{do-journal.ts → internal/do-journal.ts} +716 -232
  68. package/src/internal/message-delivery-schema.ts +24 -0
  69. package/src/{migrations.ts → internal/migrations.ts} +51 -35
  70. package/src/internal/recovery-checkpoint-schema.ts +17 -0
  71. package/dist/index.mjs.map +0 -1
  72. package/src/do-conversation-store.ts +0 -850
  73. /package/src/{do-storage-config.ts → DoStorageConfig.ts} +0 -0
@@ -0,0 +1,1014 @@
1
+ import { digestCanonicalBatch, EMPTY_TAIL_DIGEST } from "@effect-agent/thread/Digest";
2
+ import {
3
+ CanonicalBatch,
4
+ CanonicalRecord,
5
+ CanonicalRecordEnvelope,
6
+ CanonicalSequence,
7
+ Digest,
8
+ ObservationOffset,
9
+ } from "@effect-agent/thread/Records";
10
+ import { DEFAULT_OWNERSHIP_LEASE_DURATION } from "@effect-agent/thread/SubmissionLedger";
11
+ import {
12
+ AppendConflict,
13
+ AppendResult,
14
+ CheckpointRejected,
15
+ ThreadCheckpoint,
16
+ ThreadExport,
17
+ ThreadExportRequest,
18
+ ThreadMaterialization,
19
+ ThreadNotMaterialized,
20
+ ThreadObservation,
21
+ ThreadRead,
22
+ ThreadStore,
23
+ type ThreadCheckpoints,
24
+ ThreadStoreError,
25
+ ThreadTail,
26
+ ThreadTailRequest,
27
+ FenceRejected,
28
+ FencedAppendRequest,
29
+ LoadCheckpointRequest,
30
+ SaveCheckpointRequest,
31
+ SaveRecoveryCheckpointRequest,
32
+ MAX_THREAD_EXPORT_RECORDS,
33
+ type ThreadRecoveryCheckpoints,
34
+ } from "@effect-agent/thread/ThreadStore";
35
+ import { BrowserCrypto } from "@effect/platform-browser";
36
+ import { SqliteClient } from "@effect/sql-sqlite-do";
37
+ import {
38
+ Clock,
39
+ Context,
40
+ Crypto,
41
+ Duration,
42
+ Effect,
43
+ Layer,
44
+ Option,
45
+ Ref,
46
+ Schema,
47
+ Stream,
48
+ } from "effect";
49
+ import * as SqlClientService from "effect/unstable/sql/SqlClient";
50
+
51
+ import {
52
+ DEFAULT_MAX_STORED_VALUE_BYTES,
53
+ DoStorageConfig,
54
+ DoStorageConfigValue,
55
+ } from "./DoStorageConfig.ts";
56
+ import {
57
+ type DoStorageCompatibilityError,
58
+ DoAppendConflict,
59
+ DoCheckpointConflict,
60
+ DoFenceRejected,
61
+ type DoStorageFailpointLocation,
62
+ DoStorageCorruptionError,
63
+ DoStorageError,
64
+ } from "./DoStorageError.ts";
65
+ import { DoStorageFailpoint, type DoStorageFailpointHandler } from "./DoStorageFailpoint.ts";
66
+ import {
67
+ initializeDoJournal,
68
+ RawAppendRequest,
69
+ RawCheckpoint,
70
+ RawReadRequest,
71
+ type DoJournal,
72
+ } from "./internal/do-journal.ts";
73
+
74
+ /**
75
+ * Convenience-layer construction options. `storage` is the Durable Object's own
76
+ * `ctx.storage` handle, injected as a value (DEPLOY-010: platform bindings enter only
77
+ * through Layers; this package never imports `cloudflare:workers`).
78
+ */
79
+ export interface DoStorageOptions {
80
+ readonly storage: DurableObjectStorage;
81
+ readonly observationPollInterval?: number | undefined;
82
+ /**
83
+ * Submission ownership lease duration in milliseconds (D5). Defaults to
84
+ * `DEFAULT_OWNERSHIP_LEASE_DURATION` from `@effect-agent/thread`.
85
+ */
86
+ readonly ownershipLeaseDuration?: number | undefined;
87
+ /**
88
+ * Maximum bytes for any single stored value; must stay under the platform's 2 MB
89
+ * per-value limit. Defaults to `DEFAULT_MAX_STORED_VALUE_BYTES`.
90
+ */
91
+ readonly maxStoredValueBytes?: number | undefined;
92
+ /**
93
+ * Re-verify every stored payload and digest chain while opening the store. Defaults to
94
+ * off: per-operation Schema decoding and the digest chain already fail clearly on corrupt
95
+ * rows without scanning the whole database on every open.
96
+ */
97
+ readonly verifyOnOpen?: boolean | undefined;
98
+ readonly failpoint?: DoStorageFailpointHandler | undefined;
99
+ }
100
+
101
+ export type DoStorageInitializationError =
102
+ | DoStorageCompatibilityError
103
+ | DoStorageCorruptionError
104
+ | DoStorageError;
105
+
106
+ const OffsetText = Schema.String.check(Schema.isMaxLength(4 * 1024));
107
+ const DO_OFFSET_PREFIX = "effect-agent-do@1:";
108
+ const ZERO_CANONICAL_SEQUENCE = Schema.decodeSync(CanonicalSequence)(0);
109
+ const isDigest = Schema.is(Digest);
110
+ const isDoFenceRejected = Schema.is(DoFenceRejected);
111
+ const isDoAppendConflict = Schema.is(DoAppendConflict);
112
+ const isDoCheckpointConflict = Schema.is(DoCheckpointConflict);
113
+
114
+ const storeError = (operation: string, error: { readonly message: string }) =>
115
+ ThreadStoreError.make({
116
+ cause: error,
117
+ operation,
118
+ message: error.message,
119
+ });
120
+
121
+ const schemaStoreError = (operation: string, error: { readonly message: string }) =>
122
+ ThreadStoreError.make({
123
+ cause: error,
124
+ operation,
125
+ message: error.message,
126
+ });
127
+
128
+ const makeOffset = Effect.fn(function* (
129
+ threadId: ThreadMaterialization["threadId"],
130
+ sequence: number,
131
+ ): Effect.fn.Return<ObservationOffset, ThreadStoreError> {
132
+ return yield* Schema.decodeUnknownEffect(CanonicalSequence)(sequence).pipe(
133
+ Effect.flatMap((validatedSequence) =>
134
+ Schema.decodeUnknownEffect(ObservationOffset)(
135
+ `${DO_OFFSET_PREFIX}${encodeURIComponent(threadId)}:${validatedSequence}`,
136
+ ),
137
+ ),
138
+ Effect.mapError((error) => schemaStoreError("encode observation offset", error)),
139
+ );
140
+ });
141
+
142
+ const parseOffset = Effect.fn(function* (
143
+ threadId: ThreadMaterialization["threadId"],
144
+ offset: ObservationOffset | undefined,
145
+ ): Effect.fn.Return<CanonicalSequence, ThreadStoreError> {
146
+ if (offset === undefined) return ZERO_CANONICAL_SEQUENCE;
147
+
148
+ const text = yield* Schema.decodeUnknownEffect(OffsetText)(offset).pipe(
149
+ Effect.mapError((error) => schemaStoreError("decode observation offset", error)),
150
+ );
151
+
152
+ const threadPrefix = `${DO_OFFSET_PREFIX}${encodeURIComponent(threadId)}:`;
153
+
154
+ if (!text.startsWith(threadPrefix)) {
155
+ return yield* ThreadStoreError.make({
156
+ operation: "decode observation offset",
157
+ message: "The observation offset belongs to a different adapter, storage version, or Thread.",
158
+ });
159
+ }
160
+ const sequenceText = text.slice(threadPrefix.length);
161
+
162
+ if (!/^(0|[1-9][0-9]*)$/.test(sequenceText)) {
163
+ return yield* ThreadStoreError.make({
164
+ operation: "decode observation offset",
165
+ message: "The observation offset is malformed.",
166
+ });
167
+ }
168
+
169
+ return yield* Schema.decodeUnknownEffect(CanonicalSequence)(Number(sequenceText)).pipe(
170
+ Effect.mapError((error) => schemaStoreError("decode observation offset", error)),
171
+ );
172
+ });
173
+
174
+ const mapFence = (threadId: ThreadMaterialization["threadId"], error: DoFenceRejected) =>
175
+ FenceRejected.make({
176
+ threadId,
177
+ actualEpoch: error.actualEpoch,
178
+ attemptedEpoch: error.producerEpoch,
179
+ });
180
+
181
+ const encodeCanonicalRecord = Effect.fn(function* (
182
+ record: CanonicalRecord,
183
+ ): Effect.fn.Return<string, ThreadStoreError> {
184
+ return yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalRecord))(record).pipe(
185
+ Effect.mapError((error) => schemaStoreError("encode canonical record", error)),
186
+ );
187
+ });
188
+
189
+ const encodeCanonicalBatch = Effect.fn(function* (
190
+ batch: CanonicalBatch,
191
+ ): Effect.fn.Return<string, ThreadStoreError> {
192
+ return yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalBatch))(batch).pipe(
193
+ Effect.mapError((error) => schemaStoreError("encode canonical batch", error)),
194
+ );
195
+ });
196
+
197
+ const encodeCheckpoint = Effect.fn(function* (
198
+ checkpoint: ThreadCheckpoint,
199
+ ): Effect.fn.Return<string, ThreadStoreError> {
200
+ return yield* Schema.encodeEffect(Schema.fromJsonString(ThreadCheckpoint))(checkpoint).pipe(
201
+ Effect.mapError((error) => schemaStoreError("encode checkpoint", error)),
202
+ );
203
+ });
204
+
205
+ const decodeEnvelope = Effect.fn(function* (row: {
206
+ readonly batch_id: string;
207
+ readonly thread_id: string;
208
+ readonly record_json: string;
209
+ readonly sequence: CanonicalSequence;
210
+ }) {
211
+ const record = yield* Schema.decodeEffect(Schema.fromJsonString(CanonicalRecord))(
212
+ row.record_json,
213
+ ).pipe(
214
+ Effect.mapError((error) =>
215
+ ThreadStoreError.make({
216
+ operation: "decode canonical record",
217
+ message: error.message,
218
+ }),
219
+ ),
220
+ );
221
+
222
+ const threadId = yield* Schema.decodeUnknownEffect(CanonicalRecordEnvelope.fields.threadId)(
223
+ row.thread_id,
224
+ ).pipe(Effect.mapError((error) => schemaStoreError("decode thread identity", error)));
225
+
226
+ const offset = yield* makeOffset(threadId, row.sequence);
227
+
228
+ const batchId = yield* Schema.decodeUnknownEffect(CanonicalRecordEnvelope.fields.batchId)(
229
+ row.batch_id,
230
+ ).pipe(Effect.mapError((error) => schemaStoreError("decode batch identity", error)));
231
+
232
+ return CanonicalRecordEnvelope.make({
233
+ threadId,
234
+ batchId,
235
+ sequence: row.sequence,
236
+ offset,
237
+ record,
238
+ });
239
+ });
240
+
241
+ const decodeCheckpoint = Effect.fn(function* (
242
+ checkpointJson: string,
243
+ ): Effect.fn.Return<ThreadCheckpoint, ThreadStoreError> {
244
+ return yield* Schema.decodeEffect(Schema.fromJsonString(ThreadCheckpoint))(checkpointJson).pipe(
245
+ Effect.mapError((error) => schemaStoreError("decode checkpoint", error)),
246
+ );
247
+ });
248
+
249
+ const requireThread = Effect.fn("DoThreadStore.requireThread")(function* (
250
+ journal: DoJournal,
251
+ threadId: ThreadMaterialization["threadId"],
252
+ ) {
253
+ const rows = yield* journal
254
+ .getThread(threadId)
255
+ .pipe(Effect.mapError((error) => storeError("read thread", error)));
256
+
257
+ if (rows.length === 0) {
258
+ return yield* ThreadNotMaterialized.make({ threadId });
259
+ }
260
+
261
+ return rows[0];
262
+ });
263
+
264
+ const tailDigestAt = Effect.fn("DoThreadStore.tailDigestAt")(function* (
265
+ journal: DoJournal,
266
+ threadId: ThreadMaterialization["threadId"],
267
+ sequence: CanonicalSequence,
268
+ ) {
269
+ if (sequence === 0) return EMPTY_TAIL_DIGEST;
270
+
271
+ const digests = yield* journal
272
+ .getTailDigestAt(threadId, sequence)
273
+ .pipe(Effect.mapError((error) => storeError("read checkpoint digest", error)));
274
+
275
+ if (digests.length !== 1) {
276
+ return yield* CheckpointRejected.make({
277
+ threadId,
278
+ reason: "digest-mismatch",
279
+ });
280
+ }
281
+
282
+ return yield* Schema.decodeUnknownEffect(Digest)(digests[0]).pipe(
283
+ Effect.mapError((error) => schemaStoreError("decode checkpoint digest", error)),
284
+ );
285
+ });
286
+
287
+ const groupByKey = <A>(
288
+ rows: ReadonlyArray<A>,
289
+ key: (row: A) => string,
290
+ ): ReadonlyMap<string, ReadonlyArray<A>> => {
291
+ const grouped = new Map<string, Array<A>>();
292
+
293
+ for (const row of rows) {
294
+ const existing = grouped.get(key(row));
295
+
296
+ if (existing === undefined) {
297
+ grouped.set(key(row), [row]);
298
+ } else {
299
+ existing.push(row);
300
+ }
301
+ }
302
+
303
+ return grouped;
304
+ };
305
+
306
+ /**
307
+ * Opt-in integrity audit (`verifyOnOpen`) of canonical payloads, their digest chains and generic
308
+ * projection checkpoints. Disposable recovery checkpoints are validated when loaded. Routine opens
309
+ * skip this scan: per-operation Schema decoding fails clearly on corrupt canonical rows.
310
+ */
311
+ const decodeStartupPayloads = Effect.fn("DoThreadStore.decodeStartupPayloads")(function* (
312
+ journal: DoJournal,
313
+ crypto: Crypto.Crypto,
314
+ ) {
315
+ const stored = yield* journal.scanStoredPayloads();
316
+
317
+ const batches = yield* Effect.forEach(stored.batches, (batch) =>
318
+ Schema.decodeEffect(Schema.fromJsonString(CanonicalBatch))(batch.batch_json).pipe(
319
+ Effect.map((decoded) => ({ decoded, row: batch })),
320
+ Effect.mapError((error) =>
321
+ DoStorageCorruptionError.make({
322
+ table: "effect_agent_canonical_batches",
323
+ rowKey: `${batch.thread_id}/${batch.batch_id}`,
324
+ message: error.message,
325
+ }),
326
+ ),
327
+ ),
328
+ );
329
+
330
+ const records = yield* Effect.forEach(stored.records, (record) =>
331
+ Schema.decodeEffect(Schema.fromJsonString(CanonicalRecord))(record.record_json).pipe(
332
+ Effect.map((decoded) => ({ decoded, row: record })),
333
+ Effect.mapError((error) =>
334
+ DoStorageCorruptionError.make({
335
+ table: "effect_agent_canonical_records",
336
+ rowKey: `${record.thread_id}/${record.sequence}`,
337
+ message: error.message,
338
+ }),
339
+ ),
340
+ ),
341
+ );
342
+
343
+ const checkpoints = yield* Effect.forEach(stored.checkpoints, (checkpoint) =>
344
+ Schema.decodeEffect(Schema.fromJsonString(ThreadCheckpoint))(checkpoint.checkpoint_json).pipe(
345
+ Effect.map((decoded) => ({ decoded, row: checkpoint })),
346
+ Effect.mapError((error) =>
347
+ DoStorageCorruptionError.make({
348
+ table: "effect_agent_checkpoints",
349
+ rowKey: `${checkpoint.thread_id}/${checkpoint.through_sequence}`,
350
+ message: error.message,
351
+ }),
352
+ ),
353
+ ),
354
+ );
355
+
356
+ const batchesByThread = groupByKey(batches, ({ row }) => row.thread_id);
357
+ const recordsByThread = groupByKey(records, ({ row }) => row.thread_id);
358
+ const checkpointsByThread = groupByKey(checkpoints, ({ row }) => row.thread_id);
359
+ const materializedIds = new Set(stored.threads.map((thread) => thread.thread_id));
360
+
361
+ for (const thread of stored.threads) {
362
+ const threadBatches = batchesByThread.get(thread.thread_id) ?? [];
363
+ const threadRecords = recordsByThread.get(thread.thread_id) ?? [];
364
+ const threadCheckpoints = checkpointsByThread.get(thread.thread_id) ?? [];
365
+ const recordsByBatch = groupByKey(threadRecords, ({ row }) => row.batch_id);
366
+ let previousDigest = EMPTY_TAIL_DIGEST;
367
+ let expectedSequence = 1;
368
+ const tailDigests = new Map<number, string>([[0, EMPTY_TAIL_DIGEST]]);
369
+
370
+ for (const { decoded: canonicalBatch, row: batchRow } of threadBatches) {
371
+ const key = `${batchRow.thread_id}/${batchRow.batch_id}`;
372
+
373
+ if (
374
+ canonicalBatch.batchId !== batchRow.batch_id ||
375
+ batchRow.first_sequence !== expectedSequence ||
376
+ batchRow.last_sequence !== batchRow.first_sequence + canonicalBatch.records.length - 1
377
+ ) {
378
+ return yield* DoStorageCorruptionError.make({
379
+ table: "effect_agent_canonical_batches",
380
+ rowKey: key,
381
+ message: "Canonical batch identity, sequence, or record count is inconsistent.",
382
+ });
383
+ }
384
+
385
+ const digest = yield* digestCanonicalBatch(previousDigest, canonicalBatch).pipe(
386
+ Effect.provideService(Crypto.Crypto, crypto),
387
+ Effect.mapError((error) =>
388
+ DoStorageCorruptionError.make({
389
+ table: "effect_agent_canonical_batches",
390
+ rowKey: key,
391
+ message: error.message,
392
+ }),
393
+ ),
394
+ );
395
+
396
+ if (batchRow.batch_digest !== digest || batchRow.tail_digest !== digest) {
397
+ return yield* DoStorageCorruptionError.make({
398
+ table: "effect_agent_canonical_batches",
399
+ rowKey: key,
400
+ message: "Canonical batch digest does not match its decoded content and prior tail.",
401
+ });
402
+ }
403
+
404
+ const batchRecords = recordsByBatch.get(batchRow.batch_id) ?? [];
405
+
406
+ if (batchRecords.length !== canonicalBatch.records.length) {
407
+ return yield* DoStorageCorruptionError.make({
408
+ table: "effect_agent_canonical_records",
409
+ rowKey: key,
410
+ message: "Canonical batch and record-table counts differ.",
411
+ });
412
+ }
413
+ for (let index = 0; index < canonicalBatch.records.length; index++) {
414
+ const expectedRecord = canonicalBatch.records[index];
415
+ const storedRecord = batchRecords[index];
416
+
417
+ const expectedJson = yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalRecord))(
418
+ expectedRecord,
419
+ ).pipe(
420
+ Effect.mapError((error) =>
421
+ DoStorageCorruptionError.make({
422
+ table: "effect_agent_canonical_batches",
423
+ rowKey: key,
424
+ message: error.message,
425
+ }),
426
+ ),
427
+ );
428
+
429
+ const storedJson = yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalRecord))(
430
+ storedRecord.decoded,
431
+ ).pipe(
432
+ Effect.mapError((error) =>
433
+ DoStorageCorruptionError.make({
434
+ table: "effect_agent_canonical_records",
435
+ rowKey: `${key}/${storedRecord.row.sequence}`,
436
+ message: error.message,
437
+ }),
438
+ ),
439
+ );
440
+
441
+ if (
442
+ storedRecord.row.sequence !== batchRow.first_sequence + index ||
443
+ storedRecord.row.record_id !== expectedRecord.recordId ||
444
+ expectedJson !== storedJson
445
+ ) {
446
+ return yield* DoStorageCorruptionError.make({
447
+ table: "effect_agent_canonical_records",
448
+ rowKey: `${key}/${storedRecord.row.sequence}`,
449
+ message: "Canonical record identity, sequence, or payload differs from its batch.",
450
+ });
451
+ }
452
+ }
453
+
454
+ previousDigest = digest;
455
+ expectedSequence = batchRow.last_sequence + 1;
456
+ tailDigests.set(batchRow.last_sequence, digest);
457
+ }
458
+
459
+ if (
460
+ threadRecords.length !== thread.tail_sequence ||
461
+ thread.tail_sequence !== expectedSequence - 1 ||
462
+ thread.tail_digest !== previousDigest
463
+ ) {
464
+ return yield* DoStorageCorruptionError.make({
465
+ table: "effect_agent_threads",
466
+ rowKey: thread.thread_id,
467
+ message: "Thread tail does not match its canonical batch chain.",
468
+ });
469
+ }
470
+
471
+ for (const checkpoint of threadCheckpoints) {
472
+ if (
473
+ checkpoint.decoded.threadId !== thread.thread_id ||
474
+ checkpoint.decoded.throughSequence !== checkpoint.row.through_sequence ||
475
+ checkpoint.decoded.tailDigest !== checkpoint.row.tail_digest ||
476
+ tailDigests.get(checkpoint.row.through_sequence) !== checkpoint.row.tail_digest
477
+ ) {
478
+ return yield* DoStorageCorruptionError.make({
479
+ table: "effect_agent_checkpoints",
480
+ rowKey: `${thread.thread_id}/${checkpoint.row.through_sequence}`,
481
+ message: "Checkpoint identity or digest is not bound to a canonical batch tail.",
482
+ });
483
+ }
484
+ }
485
+ }
486
+
487
+ if (
488
+ batches.some(({ row }) => !materializedIds.has(row.thread_id)) ||
489
+ records.some(({ row }) => !materializedIds.has(row.thread_id)) ||
490
+ checkpoints.some(({ row }) => !materializedIds.has(row.thread_id))
491
+ ) {
492
+ return yield* DoStorageCorruptionError.make({
493
+ table: "effect_agent_threads",
494
+ rowKey: "startup_scan",
495
+ message: "Canonical rows exist without a materialized Thread.",
496
+ });
497
+ }
498
+ });
499
+
500
+ const makeServices = Effect.fn("DoThreadStore.makeServices")(function* () {
501
+ const config = yield* DoStorageConfig;
502
+ const failpoint = yield* DoStorageFailpoint;
503
+ const sql = yield* SqlClientService.SqlClient;
504
+ const crypto = yield* Crypto.Crypto;
505
+ const journal = yield* initializeDoJournal(sql, failpoint.hit, config.maxStoredValueBytes);
506
+
507
+ if (config.verifyOnOpen) {
508
+ yield* decodeStartupPayloads(journal, crypto);
509
+ }
510
+
511
+ const provideCrypto = <A, E>(effect: Effect.Effect<A, E, Crypto.Crypto>) =>
512
+ Effect.provideService(effect, Crypto.Crypto, crypto);
513
+
514
+ const hitFailpoint = Effect.fn(
515
+ (location: DoStorageFailpointLocation): Effect.Effect<void, ThreadStoreError> =>
516
+ failpoint
517
+ .hit(location)
518
+ .pipe(Effect.mapError((error) => storeError(`storage failpoint ${location}`, error))),
519
+ );
520
+
521
+ const materialize: ThreadStore["Service"]["materialize"] = Effect.fn("DoThreadStore.materialize")(
522
+ function* (request: ThreadMaterialization) {
523
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadMaterialization))(
524
+ request,
525
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate materialization", error)));
526
+
527
+ const now = yield* Clock.currentTimeMillis;
528
+
529
+ yield* hitFailpoint("materialize:before");
530
+ yield* journal
531
+ .materialize(
532
+ validated.threadId,
533
+ new Date(now).toISOString(),
534
+ EMPTY_TAIL_DIGEST,
535
+ validated.producerEpoch,
536
+ )
537
+ .pipe(
538
+ Effect.mapError((error) =>
539
+ error._tag === "DoFenceRejected"
540
+ ? mapFence(validated.threadId, error)
541
+ : storeError("materialize thread", error),
542
+ ),
543
+ );
544
+ yield* hitFailpoint("materialize:after");
545
+ },
546
+ );
547
+
548
+ const append: ThreadStore["Service"]["append"] = Effect.fn("DoThreadStore.append")(function* (
549
+ request: FencedAppendRequest,
550
+ ) {
551
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(FencedAppendRequest))(
552
+ request,
553
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate canonical append", error)));
554
+
555
+ yield* requireThread(journal, validated.threadId);
556
+
557
+ const tailDigest = yield* provideCrypto(
558
+ digestCanonicalBatch(validated.expectedTailDigest, validated.batch),
559
+ ).pipe(Effect.mapError((error) => storeError("digest canonical append", error)));
560
+
561
+ const batchJson = yield* encodeCanonicalBatch(validated.batch);
562
+
563
+ const rawRecords = yield* Effect.forEach(validated.batch.records, (record) =>
564
+ encodeCanonicalRecord(record).pipe(
565
+ Effect.map((recordJson) => ({
566
+ recordId: record.recordId,
567
+ recordJson,
568
+ })),
569
+ ),
570
+ );
571
+
572
+ const rawRequest = yield* Schema.decodeUnknownEffect(RawAppendRequest)({
573
+ threadId: validated.threadId,
574
+ batchId: validated.batch.batchId,
575
+ batchDigest: tailDigest,
576
+ batchJson,
577
+ expectedTailSequence: validated.expectedTailSequence,
578
+ expectedTailDigest: validated.expectedTailDigest,
579
+ producerEpoch: validated.producerEpoch,
580
+ records: rawRecords,
581
+ tailDigest,
582
+ }).pipe(Effect.mapError((error) => schemaStoreError("encode canonical append", error)));
583
+
584
+ yield* hitFailpoint("append:before");
585
+
586
+ const result = yield* journal.append(rawRequest).pipe(
587
+ Effect.mapError((error) => {
588
+ if (isDoFenceRejected(error)) {
589
+ return mapFence(validated.threadId, error);
590
+ }
591
+ if (isDoAppendConflict(error)) {
592
+ return error.actualTailSequence !== undefined && isDigest(error.actualTailDigest)
593
+ ? AppendConflict.make({
594
+ threadId: validated.threadId,
595
+ batchId: validated.batch.batchId,
596
+ reason: error.reason,
597
+ actualTailSequence: error.actualTailSequence,
598
+ actualTailDigest: error.actualTailDigest,
599
+ })
600
+ : AppendConflict.make({
601
+ threadId: validated.threadId,
602
+ batchId: validated.batch.batchId,
603
+ reason: error.reason,
604
+ });
605
+ }
606
+
607
+ return storeError("append canonical batch", error);
608
+ }),
609
+ Effect.flatMap((result) =>
610
+ Schema.decodeUnknownEffect(AppendResult)(result).pipe(
611
+ Effect.mapError((error) => schemaStoreError("decode append result", error)),
612
+ ),
613
+ ),
614
+ );
615
+
616
+ yield* hitFailpoint("append:after");
617
+
618
+ return result;
619
+ });
620
+
621
+ const loadRecords = Effect.fn("DoThreadStore.loadRecords")(function* (request: RawReadRequest) {
622
+ const result = yield* journal
623
+ .read(request)
624
+ .pipe(Effect.mapError((error) => storeError("read canonical records", error)));
625
+
626
+ return {
627
+ count: result.count,
628
+ records: result.records.pipe(
629
+ Stream.mapError((error) => storeError("read canonical records", error)),
630
+ Stream.mapEffect(decodeEnvelope),
631
+ ),
632
+ };
633
+ });
634
+
635
+ const readEffect = Effect.fn("DoThreadStore.read")(function* (request: ThreadRead) {
636
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadRead))(request).pipe(
637
+ Effect.mapError((error) => schemaStoreError("validate thread read", error)),
638
+ );
639
+
640
+ yield* requireThread(journal, validated.threadId);
641
+
642
+ const result = yield* loadRecords(
643
+ RawReadRequest.make({
644
+ threadId: validated.threadId,
645
+ fromSequenceExclusive: validated.afterSequence ?? ZERO_CANONICAL_SEQUENCE,
646
+ limit: validated.limit,
647
+ }),
648
+ );
649
+
650
+ return result.records;
651
+ });
652
+
653
+ const read: ThreadStore["Service"]["read"] = (request) => Stream.unwrap(readEffect(request));
654
+
655
+ const observeEffect = Effect.fn("DoThreadStore.observe")(function* (request: ThreadObservation) {
656
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadObservation))(
657
+ request,
658
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate thread observation", error)));
659
+
660
+ yield* requireThread(journal, validated.threadId);
661
+ const initialSequence = yield* parseOffset(validated.threadId, validated.afterOffset);
662
+ const cursor = yield* Ref.make(initialSequence);
663
+
664
+ const poll = Effect.fn("DoThreadStore.observePoll")(function* () {
665
+ const fromSequenceExclusive = yield* Ref.get(cursor);
666
+
667
+ const result = yield* loadRecords(
668
+ RawReadRequest.make({
669
+ threadId: validated.threadId,
670
+ fromSequenceExclusive,
671
+ limit: 1_024,
672
+ }),
673
+ );
674
+
675
+ if (result.count === 0) {
676
+ yield* Effect.sleep(config.observationPollInterval);
677
+
678
+ return Stream.empty;
679
+ }
680
+
681
+ return result.records.pipe(Stream.tap((record) => Ref.set(cursor, record.sequence)));
682
+ });
683
+
684
+ return Stream.fromEffectRepeat(poll()).pipe(Stream.flatten);
685
+ });
686
+
687
+ const observe: ThreadStore["Service"]["observe"] = (request) =>
688
+ Stream.unwrap(observeEffect(request));
689
+
690
+ const exportThread: ThreadStore["Service"]["export"] = Effect.fn("DoThreadStore.export")(
691
+ function* (request: ThreadExportRequest) {
692
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadExportRequest))(
693
+ request,
694
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate thread export", error)));
695
+
696
+ yield* requireThread(journal, validated.threadId);
697
+
698
+ const exported = yield* journal
699
+ .exportThread(validated.threadId)
700
+ .pipe(Effect.mapError((error) => storeError("export thread", error)));
701
+
702
+ const records = yield* Effect.forEach(exported.records, decodeEnvelope);
703
+
704
+ if (records.length > MAX_THREAD_EXPORT_RECORDS) {
705
+ return yield* ThreadStoreError.make({
706
+ operation: "decode thread export",
707
+ message: "The thread exceeds the current export record limit.",
708
+ });
709
+ }
710
+
711
+ const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(
712
+ exported.thread.tail_digest,
713
+ ).pipe(Effect.mapError((error) => schemaStoreError("decode export tail digest", error)));
714
+
715
+ return ThreadExport.make({
716
+ format: "effect-agent/thread@1",
717
+ threadId: validated.threadId,
718
+ tailSequence: exported.thread.tail_sequence,
719
+ tailDigest,
720
+ records,
721
+ });
722
+ },
723
+ );
724
+
725
+ const inspectTail: ThreadStore["Service"]["inspectTail"] = Effect.fn("DoThreadStore.inspectTail")(
726
+ function* (request: ThreadTailRequest) {
727
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ThreadTailRequest))(
728
+ request,
729
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate tail inspection", error)));
730
+
731
+ const thread = yield* requireThread(journal, validated.threadId);
732
+
733
+ const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(thread.tail_digest).pipe(
734
+ Effect.mapError((error) => schemaStoreError("decode tail digest", error)),
735
+ );
736
+
737
+ return ThreadTail.make({
738
+ threadId: validated.threadId,
739
+ tailSequence: thread.tail_sequence,
740
+ tailDigest,
741
+ producerEpoch: thread.producer_epoch,
742
+ });
743
+ },
744
+ );
745
+
746
+ const saveCheckpoint: ThreadCheckpoints["save"] = Effect.fn("DoThreadStore.saveCheckpoint")(
747
+ function* (request: SaveCheckpointRequest) {
748
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SaveCheckpointRequest))(
749
+ request,
750
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint", error)));
751
+
752
+ const thread = yield* requireThread(journal, validated.checkpoint.threadId);
753
+
754
+ if (validated.checkpoint.throughSequence > thread.tail_sequence) {
755
+ return yield* CheckpointRejected.make({
756
+ threadId: validated.checkpoint.threadId,
757
+ reason: "ahead-of-tail",
758
+ });
759
+ }
760
+
761
+ const canonicalDigest = yield* tailDigestAt(
762
+ journal,
763
+ validated.checkpoint.threadId,
764
+ validated.checkpoint.throughSequence,
765
+ );
766
+
767
+ if (canonicalDigest !== validated.checkpoint.tailDigest) {
768
+ return yield* CheckpointRejected.make({
769
+ threadId: validated.checkpoint.threadId,
770
+ reason: "digest-mismatch",
771
+ });
772
+ }
773
+ const checkpointJson = yield* encodeCheckpoint(validated.checkpoint);
774
+
775
+ const raw = RawCheckpoint.make({
776
+ threadId: validated.checkpoint.threadId,
777
+ throughSequence: validated.checkpoint.throughSequence,
778
+ tailDigest: validated.checkpoint.tailDigest,
779
+ checkpointJson,
780
+ });
781
+
782
+ yield* hitFailpoint("save-checkpoint:before");
783
+ yield* journal.saveCheckpoint(raw).pipe(
784
+ Effect.mapError((error) =>
785
+ isDoCheckpointConflict(error)
786
+ ? CheckpointRejected.make({
787
+ threadId: validated.checkpoint.threadId,
788
+ reason: "digest-mismatch",
789
+ })
790
+ : storeError("save checkpoint", error),
791
+ ),
792
+ );
793
+ yield* hitFailpoint("save-checkpoint:after");
794
+ },
795
+ );
796
+
797
+ const loadCheckpoint: ThreadCheckpoints["load"] = Effect.fn("DoThreadStore.loadCheckpoint")(
798
+ function* (request: LoadCheckpointRequest) {
799
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(LoadCheckpointRequest))(
800
+ request,
801
+ ).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint lookup", error)));
802
+
803
+ const thread = yield* requireThread(journal, validated.threadId);
804
+
805
+ const rows = yield* journal
806
+ .loadCheckpoint(validated.threadId, validated.atOrBeforeSequence ?? thread.tail_sequence)
807
+ .pipe(Effect.mapError((error) => storeError("load checkpoint", error)));
808
+
809
+ if (rows.length === 0) return Option.none();
810
+ if (rows.length !== 1) {
811
+ return yield* ThreadStoreError.make({
812
+ operation: "load checkpoint",
813
+ message: `Expected at most one checkpoint row but found ${rows.length}.`,
814
+ });
815
+ }
816
+ const row = rows[0];
817
+ const checkpoint = yield* decodeCheckpoint(row.checkpoint_json);
818
+
819
+ if (
820
+ row.thread_id !== validated.threadId ||
821
+ checkpoint.threadId !== row.thread_id ||
822
+ checkpoint.throughSequence !== row.through_sequence ||
823
+ checkpoint.tailDigest !== row.tail_digest
824
+ ) {
825
+ return yield* ThreadStoreError.make({
826
+ operation: "load checkpoint",
827
+ message: "Stored checkpoint metadata does not match its canonical row.",
828
+ });
829
+ }
830
+
831
+ const canonicalDigest = yield* tailDigestAt(
832
+ journal,
833
+ checkpoint.threadId,
834
+ checkpoint.throughSequence,
835
+ );
836
+
837
+ if (canonicalDigest !== checkpoint.tailDigest) {
838
+ return yield* CheckpointRejected.make({
839
+ threadId: checkpoint.threadId,
840
+ reason: "digest-mismatch",
841
+ });
842
+ }
843
+
844
+ return Option.some(checkpoint);
845
+ },
846
+ );
847
+
848
+ const saveRecoveryCheckpoint: ThreadRecoveryCheckpoints["save"] = Effect.fn(
849
+ "DoThreadStore.saveRecoveryCheckpoint",
850
+ )(function* (request) {
851
+ const validated = yield* Schema.decodeUnknownEffect(
852
+ Schema.toType(SaveRecoveryCheckpointRequest),
853
+ )(request).pipe(
854
+ Effect.mapError((error) => schemaStoreError("validate recovery checkpoint", error)),
855
+ );
856
+
857
+ const checkpointJson = yield* encodeCheckpoint(validated.checkpoint);
858
+
859
+ yield* journal
860
+ .saveRecoveryCheckpoint(validated, checkpointJson)
861
+ .pipe(
862
+ Effect.mapError((error) =>
863
+ error._tag === "CheckpointRejected" ||
864
+ error._tag === "FenceRejected" ||
865
+ error._tag === "ThreadNotMaterialized"
866
+ ? error
867
+ : storeError("save recovery checkpoint", error),
868
+ ),
869
+ );
870
+ });
871
+
872
+ const loadRecoveryCheckpoint: ThreadRecoveryCheckpoints["load"] = Effect.fn(
873
+ "DoThreadStore.loadRecoveryCheckpoint",
874
+ )(function* (request) {
875
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(LoadCheckpointRequest))(
876
+ request,
877
+ ).pipe(
878
+ Effect.mapError((error) => schemaStoreError("validate recovery checkpoint lookup", error)),
879
+ );
880
+
881
+ const thread = yield* requireThread(journal, validated.threadId);
882
+
883
+ const corrupt = () =>
884
+ CheckpointRejected.make({ threadId: validated.threadId, reason: "corrupt" });
885
+
886
+ const rows = yield* journal
887
+ .loadRecoveryCheckpoint(validated.threadId)
888
+ .pipe(
889
+ Effect.mapError((error) =>
890
+ error._tag === "DoStorageCorruptionError"
891
+ ? corrupt()
892
+ : storeError("load recovery checkpoint", error),
893
+ ),
894
+ );
895
+
896
+ if (rows.length === 0) return Option.none();
897
+ if (rows.length !== 1) return yield* corrupt();
898
+ const row = rows[0];
899
+
900
+ const checkpoint = yield* Schema.decodeEffect(Schema.fromJsonString(ThreadCheckpoint))(
901
+ row.checkpoint_json,
902
+ ).pipe(Effect.mapError(corrupt));
903
+
904
+ if (
905
+ row.thread_id !== validated.threadId ||
906
+ checkpoint.threadId !== row.thread_id ||
907
+ checkpoint.throughSequence !== row.through_sequence ||
908
+ checkpoint.tailDigest !== row.tail_digest
909
+ )
910
+ return yield* corrupt();
911
+ if (checkpoint.throughSequence > thread.tail_sequence)
912
+ return yield* CheckpointRejected.make({
913
+ threadId: validated.threadId,
914
+ reason: "ahead-of-tail",
915
+ });
916
+ if (checkpoint.throughSequence > (validated.atOrBeforeSequence ?? thread.tail_sequence))
917
+ return Option.none();
918
+
919
+ const canonicalDigest = yield* tailDigestAt(
920
+ journal,
921
+ checkpoint.threadId,
922
+ checkpoint.throughSequence,
923
+ );
924
+
925
+ if (canonicalDigest !== checkpoint.tailDigest)
926
+ return yield* CheckpointRejected.make({
927
+ threadId: validated.threadId,
928
+ reason: "digest-mismatch",
929
+ });
930
+
931
+ return Option.some(checkpoint);
932
+ });
933
+
934
+ const threadStore = ThreadStore.of({
935
+ append,
936
+ export: exportThread,
937
+ inspectTail,
938
+ materialize,
939
+ observe,
940
+ read,
941
+ checkpoints: { save: saveCheckpoint, load: loadCheckpoint },
942
+ recoveryCheckpoints: { save: saveRecoveryCheckpoint, load: loadRecoveryCheckpoint },
943
+ });
944
+
945
+ return Context.make(ThreadStore, threadStore);
946
+ });
947
+
948
+ /**
949
+ * Durable Object Thread Store implementation with configuration, failpoint, SQL, and
950
+ * Crypto authority kept visible in its input channel.
951
+ */
952
+ export const threadStoreLayer: Layer.Layer<
953
+ ThreadStore,
954
+ DoStorageInitializationError,
955
+ DoStorageConfig | DoStorageFailpoint | SqlClientService.SqlClient | Crypto.Crypto
956
+ > = Layer.effectContext(makeServices());
957
+
958
+ /**
959
+ * Validated Durable Object storage configuration Layer with the documented defaults applied.
960
+ * Shared by the ThreadStore and SubmissionLedger convenience layers so their defaults
961
+ * cannot drift.
962
+ */
963
+ export const storageConfigLayer = (
964
+ options: DoStorageOptions,
965
+ ): Layer.Layer<DoStorageConfig, DoStorageError> =>
966
+ Layer.effect(DoStorageConfig)(
967
+ Schema.decodeUnknownEffect(DoStorageConfigValue)({
968
+ observationPollInterval: options.observationPollInterval ?? 25,
969
+ ownershipLeaseDuration:
970
+ options.ownershipLeaseDuration ?? Duration.toMillis(DEFAULT_OWNERSHIP_LEASE_DURATION),
971
+ maxStoredValueBytes: options.maxStoredValueBytes ?? DEFAULT_MAX_STORED_VALUE_BYTES,
972
+ verifyOnOpen: options.verifyOnOpen ?? false,
973
+ }).pipe(
974
+ Effect.mapError((error) =>
975
+ DoStorageError.make({
976
+ cause: error,
977
+ operation: "configure Durable Object storage",
978
+ message: error.message,
979
+ }),
980
+ ),
981
+ ),
982
+ );
983
+
984
+ /** The failpoint Layer selected by convenience options: explicit handler or the no-op default. */
985
+ export const storageFailpointLayer = (
986
+ options: DoStorageOptions,
987
+ ): Layer.Layer<DoStorageFailpoint> =>
988
+ options.failpoint === undefined
989
+ ? DoStorageFailpoint.layer
990
+ : Layer.succeed(DoStorageFailpoint)({ hit: options.failpoint });
991
+
992
+ /**
993
+ * A composition-root convenience Layer for canonical Threads inside one Durable Object,
994
+ * built over `ctx.storage`. Durable accepted work is served by the separate SubmissionLedger
995
+ * port; point both at the SAME `ctx.storage` so claims fence the same producer epochs
996
+ * (ADR-0011 D7's "same file" rule, transposed to one object's private database).
997
+ */
998
+ export const layer = (
999
+ options: DoStorageOptions,
1000
+ ): Layer.Layer<ThreadStore, DoStorageInitializationError> =>
1001
+ Layer.unwrap(
1002
+ Effect.map(DoStorageConfig, (config) =>
1003
+ threadStoreLayer.pipe(
1004
+ Layer.provide(
1005
+ Layer.mergeAll(
1006
+ Layer.succeed(DoStorageConfig)(config),
1007
+ storageFailpointLayer(options),
1008
+ SqliteClient.layer({ storage: options.storage }),
1009
+ BrowserCrypto.layer,
1010
+ ),
1011
+ ),
1012
+ ),
1013
+ ),
1014
+ ).pipe(Layer.provide(storageConfigLayer(options)));