@effect-agent/storage-cloudflare 0.1.0-beta.11 → 0.1.0-beta.111

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 (74) 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 +33 -0
  21. package/dist/DoStorageFailpointTesting.mjs +35 -0
  22. package/dist/DoStorageFailpointTesting.mjs.map +1 -0
  23. package/dist/DoStorageVersion-Db6fjP-I.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 +1864 -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 +481 -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 +4933 -0
  40. package/dist/PortProtocol.mjs +193 -0
  41. package/dist/PortProtocol.mjs.map +1 -0
  42. package/dist/PortRouting.d.mts +86 -0
  43. package/dist/PortRouting.mjs +439 -0
  44. package/dist/PortRouting.mjs.map +1 -0
  45. package/dist/do-journal-THW8-ZeP.mjs +1041 -0
  46. package/dist/do-journal-THW8-ZeP.mjs.map +1 -0
  47. package/dist/index.d.mts +14 -1525
  48. package/dist/index.mjs +14 -3695
  49. package/dist/migrations-DZKTNU88.mjs +291 -0
  50. package/dist/migrations-DZKTNU88.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 +56 -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} +960 -237
  61. package/src/DoSubscriptionStore.ts +329 -0
  62. package/src/DoThreadStore.ts +1039 -0
  63. package/src/MemoryProtocol.ts +351 -0
  64. package/src/{port-protocol.ts → PortProtocol.ts} +76 -38
  65. package/src/{routing.ts → PortRouting.ts} +374 -264
  66. package/src/index.ts +13 -32
  67. package/src/internal/do-journal.ts +1656 -0
  68. package/src/internal/message-delivery-schema.ts +24 -0
  69. package/src/{migrations.ts → internal/migrations.ts} +55 -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-journal.ts +0 -1078
  74. /package/src/{do-storage-config.ts → DoStorageConfig.ts} +0 -0
@@ -0,0 +1,1656 @@
1
+ import { SqliteMigrator } from "@effect/sql-sqlite-do";
2
+ import { Effect, Schema, Stream } from "effect";
3
+ import { EMPTY_TAIL_DIGEST } from "effect-agent/digest";
4
+ import { CanonicalRecord, CanonicalSequence, ProducerEpoch } from "effect-agent/records";
5
+ import { createMessageDeliveryPendingIndex } from "effect-agent/sql-message-delivery-store";
6
+ import { checkV2ThreadLayout } from "effect-agent/sql-storage-v2-upgrade";
7
+ import {
8
+ createNativeReadIndexes,
9
+ seedNativeReadIndexes,
10
+ indexCanonicalRecord,
11
+ } from "effect-agent/sql-thread-native-reads";
12
+ import {
13
+ MAX_THREAD_EXPORT_RECORDS,
14
+ CheckpointRejected,
15
+ FenceRejected,
16
+ ThreadNotMaterialized,
17
+ type SaveRecoveryCheckpointRequest,
18
+ } from "effect-agent/thread-store";
19
+ import * as SqlClient from "effect/unstable/sql/SqlClient";
20
+ import { SqlError } from "effect/unstable/sql/SqlError";
21
+
22
+ import {
23
+ type DoStorageFailpointError,
24
+ DoAppendConflict,
25
+ DoCheckpointConflict,
26
+ DoFenceRejected,
27
+ DoStorageCompatibilityError,
28
+ DoStorageCorruptionError,
29
+ DoStorageError,
30
+ DoValueBoundExceeded,
31
+ type DoStorageFailpointLocation,
32
+ } from "../DoStorageError.ts";
33
+ import { createMessageDeliveryTables } from "./message-delivery-schema.ts";
34
+ import { CurrentDoStorageVersion, createNonterminalIndex, doMigrations } from "./migrations.ts";
35
+ import { createRecoveryCheckpointTable } from "./recovery-checkpoint-schema.ts";
36
+
37
+ /**
38
+ * Static schema ceiling for stored text columns. Writes are bounded in BYTES by the
39
+ * configured `maxStoredValueBytes` (always ≤ 2,000,000); UTF-8 byte length is never smaller
40
+ * than UTF-16 string length, so any value that passed the byte bound also passes this
41
+ * decode-side character ceiling.
42
+ */
43
+ const BoundedStoredText = Schema.String.check(Schema.isMaxLength(2_000_000));
44
+ const BoundedIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(1024));
45
+ const MAX_RECORDS_PER_THREAD = MAX_THREAD_EXPORT_RECORDS;
46
+ const ZERO_SEQUENCE = Schema.decodeSync(CanonicalSequence)(0);
47
+ const MAX_IDENTIFIER_LENGTH = 1_024;
48
+ const MAX_READ_PAGE_JSON_BYTES = 4 * 1024 * 1024;
49
+ /** Durable Object SQL storage allows at most 100 bound parameters per statement. */
50
+ const MAX_BOUND_PARAMETERS = 100;
51
+ const isSqlError = Schema.is(SqlError);
52
+
53
+ const storedTextBytes = (value: string): number => new TextEncoder().encode(value).byteLength;
54
+
55
+ const chunked = <A>(values: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> => {
56
+ const chunks: Array<ReadonlyArray<A>> = [];
57
+
58
+ for (let index = 0; index < values.length; index += size) {
59
+ chunks.push(values.slice(index, index + size));
60
+ }
61
+
62
+ return chunks;
63
+ };
64
+
65
+ class DoMetaRow extends Schema.Class<DoMetaRow>("DoMetaRow")({
66
+ value: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
67
+ }) {}
68
+
69
+ class DoNameRow extends Schema.Class<DoNameRow>("DoNameRow")({
70
+ name: BoundedIdentifier,
71
+ }) {}
72
+
73
+ class ThreadRow extends Schema.Class<ThreadRow>("ThreadRow")({
74
+ thread_id: BoundedIdentifier,
75
+ created_at: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
76
+ producer_epoch: ProducerEpoch,
77
+ tail_digest: BoundedStoredText,
78
+ tail_sequence: CanonicalSequence,
79
+ }) {}
80
+
81
+ class BatchRow extends Schema.Class<BatchRow>("BatchRow")({
82
+ batch_digest: BoundedStoredText,
83
+ batch_id: BoundedIdentifier,
84
+ batch_json: BoundedStoredText,
85
+ thread_id: BoundedIdentifier,
86
+ first_sequence: CanonicalSequence,
87
+ last_sequence: CanonicalSequence,
88
+ tail_digest: BoundedStoredText,
89
+ }) {}
90
+
91
+ class RecordRow extends Schema.Class<RecordRow>("RecordRow")({
92
+ batch_id: BoundedIdentifier,
93
+ thread_id: BoundedIdentifier,
94
+ record_id: BoundedIdentifier,
95
+ record_json: BoundedStoredText,
96
+ sequence: CanonicalSequence,
97
+ }) {}
98
+
99
+ const ReadPlanRow = Schema.Struct({
100
+ sequence: CanonicalSequence,
101
+ record_json_bytes: Schema.Natural.check(Schema.isLessThanOrEqualTo(MAX_READ_PAGE_JSON_BYTES)),
102
+ });
103
+
104
+ type ReadPage = [typeof ReadPlanRow.Type, ...Array<typeof ReadPlanRow.Type>];
105
+
106
+ class CheckpointRow extends Schema.Class<CheckpointRow>("CheckpointRow")({
107
+ checkpoint_json: BoundedStoredText,
108
+ thread_id: BoundedIdentifier,
109
+ tail_digest: BoundedStoredText,
110
+ through_sequence: CanonicalSequence,
111
+ }) {}
112
+
113
+ export class RawRecord extends Schema.Class<RawRecord>(
114
+ "@effect-agent/storage-cloudflare/RawRecord",
115
+ )({
116
+ recordId: BoundedIdentifier,
117
+ recordJson: BoundedStoredText,
118
+ }) {}
119
+
120
+ export class RawAppendRequest extends Schema.Class<RawAppendRequest>(
121
+ "@effect-agent/storage-cloudflare/RawAppendRequest",
122
+ )({
123
+ batchDigest: BoundedStoredText,
124
+ batchId: BoundedIdentifier,
125
+ batchJson: BoundedStoredText,
126
+ threadId: BoundedIdentifier,
127
+ expectedTailDigest: BoundedStoredText,
128
+ expectedTailSequence: CanonicalSequence,
129
+ producerEpoch: ProducerEpoch,
130
+ records: Schema.NonEmptyArray(RawRecord).check(Schema.isMaxLength(256)),
131
+ tailDigest: BoundedStoredText,
132
+ }) {}
133
+
134
+ export class RawAppendResult extends Schema.Class<RawAppendResult>(
135
+ "@effect-agent/storage-cloudflare/RawAppendResult",
136
+ )({
137
+ firstSequence: CanonicalSequence,
138
+ lastSequence: CanonicalSequence,
139
+ replayed: Schema.Boolean,
140
+ tailDigest: BoundedStoredText,
141
+ }) {}
142
+
143
+ export class RawReadRequest extends Schema.Class<RawReadRequest>(
144
+ "@effect-agent/storage-cloudflare/RawReadRequest",
145
+ )({
146
+ threadId: BoundedIdentifier,
147
+ fromSequenceExclusive: CanonicalSequence,
148
+ limit: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(1_024)),
149
+ }) {}
150
+
151
+ export class RawCheckpoint extends Schema.Class<RawCheckpoint>(
152
+ "@effect-agent/storage-cloudflare/RawCheckpoint",
153
+ )({
154
+ checkpointJson: BoundedStoredText,
155
+ threadId: BoundedIdentifier,
156
+ tailDigest: BoundedStoredText,
157
+ throughSequence: CanonicalSequence,
158
+ }) {}
159
+
160
+ export class RawThreadExport extends Schema.Class<RawThreadExport>(
161
+ "@effect-agent/storage-cloudflare/RawThreadExport",
162
+ )({
163
+ thread: ThreadRow,
164
+ records: Schema.Array(RecordRow),
165
+ }) {}
166
+
167
+ type AppendError =
168
+ | DoAppendConflict
169
+ | DoFenceRejected
170
+ | DoStorageCorruptionError
171
+ | DoStorageError
172
+ | DoStorageFailpointError
173
+ | DoValueBoundExceeded;
174
+
175
+ type CheckpointError =
176
+ | DoCheckpointConflict
177
+ | DoStorageCorruptionError
178
+ | DoStorageError
179
+ | DoValueBoundExceeded;
180
+
181
+ type DoJournalFailpoint = (
182
+ location: DoStorageFailpointLocation,
183
+ ) => Effect.Effect<void, DoStorageFailpointError>;
184
+
185
+ const noFailpoint: DoJournalFailpoint = () => Effect.void;
186
+
187
+ const storageError =
188
+ (operation: string) =>
189
+ (error: SqlError): DoStorageError =>
190
+ DoStorageError.make({
191
+ cause: error,
192
+ operation,
193
+ message: error.message,
194
+ });
195
+
196
+ /** Decode raw Durable Object SQLite rows against a Schema, reporting failures as typed corruption. */
197
+ export const decodeRows = Effect.fn(
198
+ <A, I>(
199
+ schema: Schema.Codec<ReadonlyArray<A>, ReadonlyArray<I>>,
200
+ table: string,
201
+ rowKey: string,
202
+ rows: unknown,
203
+ ): Effect.Effect<ReadonlyArray<A>, DoStorageCorruptionError> =>
204
+ Schema.decodeUnknownEffect(schema)(rows).pipe(
205
+ Effect.mapError((error) =>
206
+ DoStorageCorruptionError.make({
207
+ table,
208
+ rowKey,
209
+ message: String(error),
210
+ }),
211
+ ),
212
+ ),
213
+ );
214
+
215
+ /** Decode exactly one raw row against a Schema, reporting failures as typed corruption. */
216
+ export const decodeSingleRow = Effect.fn(
217
+ <A, I>(
218
+ schema: Schema.Codec<ReadonlyArray<A>, ReadonlyArray<I>>,
219
+ table: string,
220
+ rowKey: string,
221
+ rows: unknown,
222
+ ): Effect.Effect<A, DoStorageCorruptionError> =>
223
+ decodeRows(schema, table, rowKey, rows).pipe(
224
+ Effect.flatMap((decoded) =>
225
+ decoded.length === 1
226
+ ? Effect.succeed(decoded[0])
227
+ : Effect.fail(
228
+ DoStorageCorruptionError.make({
229
+ table,
230
+ rowKey,
231
+ message: `Expected exactly one row but found ${decoded.length}.`,
232
+ }),
233
+ ),
234
+ ),
235
+ ),
236
+ );
237
+
238
+ /** Column inventory of the supported v3 predecessor, independent of physical column order. */
239
+ const predecessorColumns = {
240
+ effect_agent_threads: [
241
+ "thread_id",
242
+ "created_at",
243
+ "tail_sequence",
244
+ "tail_digest",
245
+ "producer_epoch",
246
+ ],
247
+ effect_agent_canonical_batches: [
248
+ "thread_id",
249
+ "batch_id",
250
+ "first_sequence",
251
+ "last_sequence",
252
+ "batch_digest",
253
+ "tail_digest",
254
+ "batch_json",
255
+ ],
256
+ effect_agent_canonical_records: ["thread_id", "sequence", "record_id", "batch_id", "record_json"],
257
+ effect_agent_checkpoints: ["thread_id", "through_sequence", "tail_digest", "checkpoint_json"],
258
+ effect_agent_submissions: [
259
+ "submission_id",
260
+ "thread_id",
261
+ "queue_sequence",
262
+ "principal",
263
+ "idempotency_key",
264
+ "agent_id",
265
+ "agent_digests_json",
266
+ "deployment_id",
267
+ "input_json",
268
+ "input_digest",
269
+ "receipt_id",
270
+ "state",
271
+ "settled_outcome",
272
+ "created_at",
273
+ "ready_at",
274
+ "input_applied_record_id",
275
+ "input_applied_sequence",
276
+ "joined_host_submission_id",
277
+ "suspended_reason_json",
278
+ "suspended_at",
279
+ "unknown_reason",
280
+ "unknown_tool_call_ids_json",
281
+ "parent_submission_id",
282
+ "parent_tool_call_id",
283
+ "admission_group",
284
+ "admission_fence_json",
285
+ ],
286
+ effect_agent_submission_ownership: [
287
+ "submission_id",
288
+ "attempt_id",
289
+ "ownership_token",
290
+ "producer_epoch",
291
+ "owner_producer_id",
292
+ "lease_expires_at",
293
+ ],
294
+ effect_agent_attempts: [
295
+ "attempt_id",
296
+ "submission_id",
297
+ "thread_id",
298
+ "owner_producer_id",
299
+ "producer_epoch",
300
+ "claimed_at",
301
+ ],
302
+ effect_agent_settlement_reservations: [
303
+ "submission_id",
304
+ "settlement_id",
305
+ "outcome",
306
+ "record_id",
307
+ "record_json",
308
+ "record_digest",
309
+ "reserved_at",
310
+ "finalized_at",
311
+ ],
312
+ effect_agent_abort_intents: [
313
+ "submission_id",
314
+ "author",
315
+ "reason",
316
+ "requested_at",
317
+ "canonical_record_id",
318
+ ],
319
+ effect_agent_approval_decisions: [
320
+ "submission_id",
321
+ "tool_call_id",
322
+ "decision",
323
+ "resolver",
324
+ "reason",
325
+ "decided_at",
326
+ ],
327
+ effect_agent_unknown_resolutions: [
328
+ "submission_id",
329
+ "tool_call_id",
330
+ "author",
331
+ "reason",
332
+ "resolution_json",
333
+ "resolved_at",
334
+ ],
335
+ effect_agent_child_reservations: [
336
+ "reservation_id",
337
+ "parent_submission_id",
338
+ "parent_tool_call_id",
339
+ "child_submission_id",
340
+ "status",
341
+ "allocation_json",
342
+ "allocation_digest",
343
+ "accounting_json",
344
+ "reserved_at",
345
+ "release_began_at",
346
+ "released_at",
347
+ ],
348
+ effect_agent_meta: ["key", "value"],
349
+ effect_agent_child_settlements: [
350
+ "parent_submission_id",
351
+ "child_submission_id",
352
+ "child_outcome",
353
+ "recorded_at",
354
+ ],
355
+ } as const;
356
+
357
+ const checkPredecessorLayout = Effect.fn("DoJournal.checkPredecessorLayout")(function* (
358
+ version: 3 | 4 | 5,
359
+ ) {
360
+ const sql = yield* SqlClient.SqlClient;
361
+
362
+ const messageColumns =
363
+ version === 3
364
+ ? predecessorColumns
365
+ : {
366
+ ...predecessorColumns,
367
+ effect_agent_submissions: [
368
+ ...predecessorColumns.effect_agent_submissions,
369
+ "worker_admission_json",
370
+ "message_admission_json",
371
+ ],
372
+ effect_agent_message_deliveries: [
373
+ "owner_thread_id",
374
+ "message_id",
375
+ "version",
376
+ "state",
377
+ "deadline_at_millis",
378
+ "record_json",
379
+ ],
380
+ };
381
+
382
+ const expectedColumns = {
383
+ ...messageColumns,
384
+ ...(version === 5
385
+ ? {
386
+ effect_agent_recovery_checkpoints: [
387
+ "thread_id",
388
+ "through_sequence",
389
+ "tail_digest",
390
+ "checkpoint_json",
391
+ ],
392
+ }
393
+ : {}),
394
+ };
395
+
396
+ for (const [table, expected] of Object.entries(expectedColumns)) {
397
+ const columns = yield* decodeRows(
398
+ Schema.Array(Schema.Struct({ name: BoundedIdentifier })),
399
+ table,
400
+ "schema",
401
+ yield* sql.unsafe(`PRAGMA table_info(${table})`),
402
+ );
403
+
404
+ const names = new Set<string>(expected);
405
+
406
+ if (columns.length !== names.size || columns.some((column) => !names.has(column.name)))
407
+ return yield* DoStorageCompatibilityError.make({
408
+ actualVersion: version,
409
+ supportedVersion: CurrentDoStorageVersion,
410
+ message: `The v${version} ${table} columns do not match the supported predecessor; no upgrade was committed.`,
411
+ });
412
+ }
413
+ });
414
+
415
+ const REQUIRED_TABLES = [
416
+ "effect_agent_abort_intents",
417
+ "effect_agent_approval_decisions",
418
+ "effect_agent_attempts",
419
+ "effect_agent_canonical_batches",
420
+ "effect_agent_canonical_records",
421
+ "effect_agent_checkpoints",
422
+ "effect_agent_child_reservations",
423
+ "effect_agent_child_settlements",
424
+ "effect_agent_threads",
425
+ "effect_agent_meta",
426
+ "effect_agent_settlement_reservations",
427
+ "effect_agent_submission_ownership",
428
+ "effect_agent_submissions",
429
+ "effect_agent_unknown_resolutions",
430
+ ] as const;
431
+
432
+ /**
433
+ * Supported-predecessor or fresh storage gate (DEPLOY-008) over `effect_agent_meta` instead of
434
+ * `PRAGMA user_version` (unverified on Durable Object SQL storage; a meta table is portable
435
+ * regardless). No WAL check (Durable Object storage owns durability and confirms writes
436
+ * through output gates) and no busy timeout (a Durable Object has exactly one writer): the
437
+ * Node machinery those served has no DC analogue and is deliberately absent.
438
+ */
439
+ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(function* (
440
+ sql: SqlClient.SqlClient,
441
+ failpoint: DoJournalFailpoint = noFailpoint,
442
+ maxStoredValueBytes: number,
443
+ ) {
444
+ const metaTableRows = yield* sql<Record<string, unknown>>`
445
+ SELECT name
446
+ FROM sqlite_master
447
+ WHERE type = 'table'
448
+ AND name = 'effect_agent_meta'
449
+ `.pipe(Effect.mapError(storageError("read storage version table")));
450
+
451
+ const metaTables = yield* decodeRows(
452
+ Schema.Array(DoNameRow),
453
+ "sqlite_master",
454
+ "effect_agent_meta",
455
+ metaTableRows,
456
+ );
457
+
458
+ if (metaTables.length === 0) {
459
+ const existingRows = yield* sql<Record<string, unknown>>`
460
+ SELECT name
461
+ FROM sqlite_master
462
+ WHERE type = 'table'
463
+ AND name LIKE 'effect_agent_%'
464
+ ORDER BY name
465
+ `.pipe(Effect.mapError(storageError("inspect unversioned storage")));
466
+
467
+ const existing = yield* decodeRows(
468
+ Schema.Array(DoNameRow),
469
+ "sqlite_master",
470
+ "effect_agent_%",
471
+ existingRows,
472
+ );
473
+
474
+ if (existing.length > 0) {
475
+ return yield* DoStorageCompatibilityError.make({
476
+ actualVersion: 0,
477
+ supportedVersion: CurrentDoStorageVersion,
478
+ message:
479
+ "The Durable Object contains unversioned Effect Agent tables. Refusing to mutate ambiguous stored data; retain it for inspection with its original writer.",
480
+ });
481
+ }
482
+
483
+ yield* SqliteMigrator.run({
484
+ loader: doMigrations,
485
+ // An application can share this SQL client and own its own migration history.
486
+ // Keep bookkeeping outside effect_agent_% so interrupted unversioned schemas
487
+ // still fail the ambiguity check above.
488
+ table: "effect_sql_migrations_agent_threads",
489
+ }).pipe(
490
+ // SqliteMigrator depends on the generic client supplied by this adapter. The concrete
491
+ // Durable Object client is kept at the outer Layer boundary.
492
+ Effect.provideService(SqlClient.SqlClient, sql),
493
+ Effect.mapError((error) =>
494
+ DoStorageError.make({
495
+ cause: error,
496
+ operation: "initialize current storage",
497
+ message: error.message,
498
+ }),
499
+ ),
500
+ );
501
+ } else {
502
+ const versionRows = yield* sql<Record<string, unknown>>`
503
+ SELECT value
504
+ FROM effect_agent_meta
505
+ WHERE key = 'storage_version'
506
+ `.pipe(Effect.mapError(storageError("read storage version")));
507
+
508
+ const version = yield* decodeSingleRow(
509
+ Schema.Array(DoMetaRow),
510
+ "effect_agent_meta",
511
+ "storage_version",
512
+ versionRows,
513
+ );
514
+
515
+ if (
516
+ version.value === "2" ||
517
+ version.value === "3" ||
518
+ version.value === "4" ||
519
+ version.value === "5"
520
+ ) {
521
+ yield* sql
522
+ .withTransaction(
523
+ Effect.gen(function* () {
524
+ const current = yield* sql<{
525
+ value: string;
526
+ }>`SELECT value FROM effect_agent_meta WHERE key='storage_version'`;
527
+
528
+ if (current.length === 1 && current[0].value === String(CurrentDoStorageVersion))
529
+ return;
530
+ if (
531
+ current.length !== 1 ||
532
+ (current[0].value !== "2" &&
533
+ current[0].value !== "3" &&
534
+ current[0].value !== "4" &&
535
+ current[0].value !== "5")
536
+ )
537
+ return yield* DoStorageCompatibilityError.make({
538
+ actualVersion: -1,
539
+ supportedVersion: CurrentDoStorageVersion,
540
+ message: "Storage version changed while acquiring the upgrade transaction.",
541
+ });
542
+
543
+ const required = yield* decodeRows(
544
+ Schema.Array(DoNameRow),
545
+ "sqlite_master",
546
+ "required_tables",
547
+ yield* sql`SELECT name FROM sqlite_master WHERE type='table' AND name IN ${sql.in([...REQUIRED_TABLES])}`,
548
+ );
549
+
550
+ if (required.length !== REQUIRED_TABLES.length)
551
+ return yield* DoStorageCompatibilityError.make({
552
+ actualVersion: Number(current[0].value),
553
+ supportedVersion: CurrentDoStorageVersion,
554
+ message:
555
+ "The predecessor store is missing required tables. Retain the original store for inspection; no upgrade was committed.",
556
+ });
557
+
558
+ const recoveryTables =
559
+ yield* sql`SELECT name FROM sqlite_master WHERE type='table' AND name='effect_agent_recovery_checkpoints'`;
560
+
561
+ if (recoveryTables.length !== (current[0].value === "5" ? 1 : 0))
562
+ return yield* DoStorageCompatibilityError.make({
563
+ actualVersion: Number(current[0].value),
564
+ supportedVersion: CurrentDoStorageVersion,
565
+ message:
566
+ "The predecessor recovery checkpoint storage does not match its version; refusing ambiguous data without mutation.",
567
+ });
568
+
569
+ const indexes =
570
+ yield* sql`SELECT name FROM sqlite_master WHERE name='effect_agent_submissions_nonterminal'`;
571
+
572
+ if (indexes.length !== 0)
573
+ return yield* DoStorageCompatibilityError.make({
574
+ actualVersion: Number(current[0].value),
575
+ supportedVersion: CurrentDoStorageVersion,
576
+ message:
577
+ "The predecessor already contains the nonterminal index; refusing ambiguous storage without mutation.",
578
+ });
579
+ if (current[0].value === "2") {
580
+ yield* checkV2ThreadLayout();
581
+ for (const statement of [
582
+ sql`ALTER TABLE effect_agent_submissions ADD COLUMN admission_group TEXT`,
583
+ sql`ALTER TABLE effect_agent_submissions ADD COLUMN admission_fence_json TEXT`,
584
+ sql`CREATE INDEX effect_agent_submissions_group ON effect_agent_submissions (thread_id, admission_group, state)`,
585
+ ]) {
586
+ yield* failpoint("upgrade:before-mutation");
587
+ yield* statement;
588
+ yield* failpoint("upgrade:after-mutation");
589
+ }
590
+ }
591
+ if (current[0].value === "3") yield* checkPredecessorLayout(3);
592
+ if (current[0].value === "4") yield* checkPredecessorLayout(4);
593
+ if (current[0].value === "5") yield* checkPredecessorLayout(5);
594
+ if (current[0].value === "2" || current[0].value === "3") {
595
+ yield* failpoint("upgrade:before-mutation");
596
+ yield* sql`ALTER TABLE effect_agent_submissions ADD COLUMN worker_admission_json TEXT`;
597
+ yield* failpoint("upgrade:after-mutation");
598
+ yield* failpoint("upgrade:before-mutation");
599
+ yield* sql`ALTER TABLE effect_agent_submissions ADD COLUMN message_admission_json TEXT`;
600
+ yield* failpoint("upgrade:after-mutation");
601
+ yield* failpoint("upgrade:before-mutation");
602
+ yield* createMessageDeliveryTables;
603
+ yield* failpoint("upgrade:after-mutation");
604
+ }
605
+ if (current[0].value !== "5") {
606
+ yield* failpoint("upgrade:before-mutation");
607
+ yield* createRecoveryCheckpointTable;
608
+ yield* failpoint("upgrade:after-mutation");
609
+ }
610
+ yield* failpoint("upgrade:before-mutation");
611
+ yield* createNonterminalIndex;
612
+ yield* failpoint("upgrade:after-mutation");
613
+ yield* failpoint("upgrade:before-version");
614
+ yield* createNativeReadIndexes;
615
+ yield* createMessageDeliveryPendingIndex;
616
+ yield* seedNativeReadIndexes.pipe(
617
+ Effect.catchTag("ThreadStoreError", (error) =>
618
+ DoStorageCorruptionError.make({
619
+ table: "effect_agent_canonical_records",
620
+ rowKey: "upgrade",
621
+ message: error.message,
622
+ }),
623
+ ),
624
+ );
625
+ yield* sql`UPDATE effect_agent_meta SET value='7' WHERE key='storage_version'`;
626
+ yield* failpoint("upgrade:after-version");
627
+ }),
628
+ )
629
+ .pipe(
630
+ Effect.catchTag("DoStorageFailpointError", (error) =>
631
+ DoStorageError.make({
632
+ cause: error,
633
+ operation: "upgrade storage",
634
+ message: error.message,
635
+ }),
636
+ ),
637
+ Effect.catchTag("StorageUpgradeError", (error) =>
638
+ DoStorageCorruptionError.make({
639
+ table: error.table,
640
+ rowKey: error.rowKey,
641
+ message: error.message,
642
+ }),
643
+ ),
644
+ Effect.catchTag("SchemaError", (error) =>
645
+ DoStorageCorruptionError.make({
646
+ table: "effect_agent_canonical_records",
647
+ rowKey: "upgrade",
648
+ message: error.message,
649
+ }),
650
+ ),
651
+ Effect.catchTag("SqlError", storageError("upgrade supported thread storage")),
652
+ );
653
+ } else if (version.value === "6") {
654
+ yield* sql
655
+ .withTransaction(
656
+ Effect.gen(function* () {
657
+ const current = yield* sql<{
658
+ value: string;
659
+ }>`SELECT value FROM effect_agent_meta WHERE key = 'storage_version'`;
660
+
661
+ if (current[0]?.value === "7") return;
662
+ if (current[0]?.value !== "6")
663
+ return yield* DoStorageCompatibilityError.make({
664
+ actualVersion: -1,
665
+ supportedVersion: 7,
666
+ message: "Storage version changed during native index upgrade",
667
+ });
668
+ yield* checkPredecessorLayout(5);
669
+
670
+ const requiredIndex =
671
+ yield* sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'effect_agent_submissions_nonterminal'`;
672
+
673
+ if (requiredIndex.length !== 1)
674
+ return yield* DoStorageCompatibilityError.make({
675
+ actualVersion: 6,
676
+ supportedVersion: 7,
677
+ message: "Predecessor storage is missing its required nonterminal index",
678
+ });
679
+ yield* failpoint("upgrade:before-mutation");
680
+ yield* createNativeReadIndexes;
681
+ yield* createMessageDeliveryPendingIndex;
682
+ yield* seedNativeReadIndexes.pipe(
683
+ Effect.catchTag("ThreadStoreError", (error) =>
684
+ DoStorageCorruptionError.make({
685
+ table: "effect_agent_canonical_records",
686
+ rowKey: "upgrade",
687
+ message: error.message,
688
+ }),
689
+ ),
690
+ );
691
+ yield* failpoint("upgrade:after-mutation");
692
+ yield* failpoint("upgrade:before-version");
693
+ yield* sql`UPDATE effect_agent_meta SET value='7' WHERE key='storage_version'`;
694
+ yield* failpoint("upgrade:after-version");
695
+ }),
696
+ )
697
+ .pipe(
698
+ Effect.mapError((error) =>
699
+ DoStorageError.make({
700
+ operation: "upgrade native indexes",
701
+ message: error.message,
702
+ cause: error,
703
+ }),
704
+ ),
705
+ );
706
+ } else if (version.value !== String(CurrentDoStorageVersion)) {
707
+ const actualVersion = Number.parseInt(version.value, 10);
708
+
709
+ return yield* DoStorageCompatibilityError.make({
710
+ actualVersion: Number.isSafeInteger(actualVersion) ? actualVersion : -1,
711
+ supportedVersion: CurrentDoStorageVersion,
712
+ message:
713
+ `The Durable Object uses unsupported storage version ${version.value}; ` +
714
+ `this build supports exactly version ${CurrentDoStorageVersion}. ` +
715
+ "Only supported v2, v3, v4, v5 and v6 can be upgraded automatically. Keep the original store and use a compatible library version.",
716
+ });
717
+ }
718
+ }
719
+
720
+ const requiredRows = yield* sql<Record<string, unknown>>`
721
+ SELECT name
722
+ FROM sqlite_master
723
+ WHERE (type = 'table'
724
+ AND name IN ${sql.in([...REQUIRED_TABLES, "effect_agent_message_deliveries", "effect_agent_recovery_checkpoints"])}
725
+ ) OR (type = 'index' AND name IN ('effect_agent_submissions_nonterminal', 'effect_agent_records_subtree', 'effect_agent_message_deliveries_pending', 'effect_agent_records_outstanding', 'effect_agent_records_call', 'effect_agent_records_run_input', 'effect_agent_records_worker_input'))
726
+ ORDER BY name
727
+ `.pipe(Effect.mapError(storageError("verify storage tables")));
728
+
729
+ const required = yield* decodeRows(
730
+ Schema.Array(DoNameRow),
731
+ "sqlite_master",
732
+ "required_tables",
733
+ requiredRows,
734
+ );
735
+
736
+ if (required.length !== REQUIRED_TABLES.length + 9) {
737
+ return yield* DoStorageCompatibilityError.make({
738
+ actualVersion: CurrentDoStorageVersion,
739
+ supportedVersion: CurrentDoStorageVersion,
740
+ message:
741
+ "The Durable Object claims the current format but is missing required tables or its nonterminal index. Retain the original store for inspection.",
742
+ });
743
+ }
744
+
745
+ return makeJournal(sql, failpoint, maxStoredValueBytes);
746
+ });
747
+
748
+ const makeJournal = (
749
+ sql: SqlClient.SqlClient,
750
+ failpoint: DoJournalFailpoint,
751
+ maxStoredValueBytes: number,
752
+ ) => {
753
+ /** Typed pre-write refusal for any single value over the configured byte bound. */
754
+ const checkValueBound = (
755
+ operation: string,
756
+ value: string,
757
+ ): Effect.Effect<void, DoValueBoundExceeded> => {
758
+ const actualBytes = storedTextBytes(value);
759
+
760
+ return actualBytes > maxStoredValueBytes
761
+ ? Effect.fail(
762
+ DoValueBoundExceeded.make({
763
+ actualBytes,
764
+ maxBytes: maxStoredValueBytes,
765
+ operation,
766
+ }),
767
+ )
768
+ : Effect.void;
769
+ };
770
+
771
+ /**
772
+ * Runs one journal write transaction on the Durable Object storage-backed
773
+ * `withTransaction` (`ctx.storage.transaction()` under the hood). Within one Durable
774
+ * Object there is exactly ONE writer, so the Node `BEGIN IMMEDIATE` + busy-retry +
775
+ * `SqliteWriteContention` machinery has no analogue here and is deliberately absent.
776
+ * Ownership-token and epoch checks still run INSIDE the transaction, so fencing atomicity
777
+ * (DUR-006) is preserved identically.
778
+ *
779
+ * Journal write transactions are always top level: the Durable Object client rejects
780
+ * nested transactions, so new journal operations must not wrap this helper inside another
781
+ * transaction.
782
+ */
783
+ const withWriteTransaction =
784
+ (operation: string) =>
785
+ <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E | DoStorageError> =>
786
+ sql.withTransaction(effect).pipe(
787
+ Effect.mapError((error) => (isSqlError(error) ? storageError(operation)(error) : error)),
788
+ Effect.withSpan("DoJournal.withWriteTransaction", { attributes: { operation } }),
789
+ );
790
+
791
+ const materialize = Effect.fn("DoJournal.materialize")(function* (
792
+ threadId: string,
793
+ createdAt: string,
794
+ emptyTailDigest: string,
795
+ producerEpoch: ProducerEpoch,
796
+ ): Effect.fn.Return<
797
+ void,
798
+ DoFenceRejected | DoStorageCorruptionError | DoStorageError | DoValueBoundExceeded
799
+ > {
800
+ if (threadId.length > MAX_IDENTIFIER_LENGTH) {
801
+ return yield* DoStorageError.make({
802
+ operation: "materialize thread",
803
+ message: "Thread identity exceeds the Durable Object storage bounds.",
804
+ });
805
+ }
806
+ yield* checkValueBound("materialize thread", emptyTailDigest);
807
+ yield* withWriteTransaction("materialize transaction")(
808
+ Effect.gen(function* () {
809
+ const existingRows = yield* sql<Record<string, unknown>>`
810
+ SELECT
811
+ thread_id,
812
+ created_at,
813
+ tail_sequence,
814
+ tail_digest,
815
+ producer_epoch
816
+ FROM effect_agent_threads
817
+ WHERE thread_id = ${threadId}
818
+ `.pipe(Effect.mapError(storageError("read materialized thread")));
819
+
820
+ const existing = yield* decodeRows(
821
+ Schema.Array(ThreadRow),
822
+ "effect_agent_threads",
823
+ threadId,
824
+ existingRows,
825
+ );
826
+
827
+ if (existing.length > 1) {
828
+ return yield* DoStorageCorruptionError.make({
829
+ table: "effect_agent_threads",
830
+ rowKey: threadId,
831
+ message: "A thread primary key returned more than one row.",
832
+ });
833
+ }
834
+ if (existing.length === 0) {
835
+ yield* sql`
836
+ INSERT INTO effect_agent_threads (
837
+ thread_id,
838
+ created_at,
839
+ tail_sequence,
840
+ tail_digest,
841
+ producer_epoch
842
+ ) VALUES (
843
+ ${threadId},
844
+ ${createdAt},
845
+ 0,
846
+ ${emptyTailDigest},
847
+ ${producerEpoch}
848
+ )
849
+ `.pipe(Effect.mapError(storageError("materialize thread")));
850
+
851
+ return;
852
+ }
853
+ if (producerEpoch < existing[0].producer_epoch) {
854
+ return yield* DoFenceRejected.make({
855
+ producerEpoch,
856
+ actualEpoch: existing[0].producer_epoch,
857
+ message: `Producer epoch ${producerEpoch} is stale; current epoch is ${existing[0].producer_epoch}.`,
858
+ });
859
+ }
860
+ if (producerEpoch > existing[0].producer_epoch) {
861
+ yield* sql`
862
+ UPDATE effect_agent_threads
863
+ SET producer_epoch = ${producerEpoch}
864
+ WHERE thread_id = ${threadId}
865
+ `.pipe(Effect.mapError(storageError("advance materialization epoch")));
866
+ }
867
+ }),
868
+ );
869
+ });
870
+
871
+ const getThread = Effect.fn("DoJournal.getThread")(function* (threadId: string) {
872
+ const rows = yield* sql<Record<string, unknown>>`
873
+ SELECT
874
+ thread_id,
875
+ created_at,
876
+ tail_sequence,
877
+ tail_digest,
878
+ producer_epoch
879
+ FROM effect_agent_threads
880
+ WHERE thread_id = ${threadId}
881
+ `.pipe(Effect.mapError(storageError("read thread")));
882
+
883
+ return yield* decodeRows(Schema.Array(ThreadRow), "effect_agent_threads", threadId, rows);
884
+ });
885
+
886
+ const append = Effect.fn("DoJournal.append")(function* (
887
+ request: RawAppendRequest,
888
+ ): Effect.fn.Return<RawAppendResult, AppendError> {
889
+ if (
890
+ request.threadId.length > MAX_IDENTIFIER_LENGTH ||
891
+ request.batchId.length > MAX_IDENTIFIER_LENGTH ||
892
+ request.records.some((record) => record.recordId.length > MAX_IDENTIFIER_LENGTH)
893
+ ) {
894
+ return yield* DoStorageError.make({
895
+ operation: "append canonical batch",
896
+ message: "Canonical identifiers exceed the Durable Object storage bounds.",
897
+ });
898
+ }
899
+ // The platform's ~2 MB per-value limit, enforced typed BEFORE any write (plan §1.2).
900
+ yield* checkValueBound("append canonical batch", request.batchJson);
901
+ yield* checkValueBound("append canonical batch", request.batchDigest);
902
+ yield* checkValueBound("append canonical batch", request.tailDigest);
903
+ yield* Effect.forEach(
904
+ request.records,
905
+ (record) => checkValueBound("append canonical record", record.recordJson),
906
+ { discard: true },
907
+ );
908
+
909
+ return yield* withWriteTransaction("append transaction")(
910
+ Effect.gen(function* () {
911
+ const recordIds = request.records.map((record) => record.recordId);
912
+
913
+ if (new Set(recordIds).size !== recordIds.length) {
914
+ return yield* DoAppendConflict.make({
915
+ message: `Batch ${request.batchId} contains duplicate canonical record IDs.`,
916
+ reason: "record-identity",
917
+ });
918
+ }
919
+
920
+ const threadRows = yield* sql<Record<string, unknown>>`
921
+ SELECT
922
+ thread_id,
923
+ created_at,
924
+ tail_sequence,
925
+ tail_digest,
926
+ producer_epoch
927
+ FROM effect_agent_threads
928
+ WHERE thread_id = ${request.threadId}
929
+ `.pipe(Effect.mapError(storageError("read append tail")));
930
+
931
+ const thread = yield* decodeSingleRow(
932
+ Schema.Array(ThreadRow),
933
+ "effect_agent_threads",
934
+ request.threadId,
935
+ threadRows,
936
+ );
937
+
938
+ if (request.producerEpoch !== thread.producer_epoch) {
939
+ return yield* DoFenceRejected.make({
940
+ producerEpoch: request.producerEpoch,
941
+ actualEpoch: thread.producer_epoch,
942
+ message: `Producer epoch ${request.producerEpoch} is not the current epoch ${thread.producer_epoch}.`,
943
+ });
944
+ }
945
+
946
+ const batchRows = yield* sql<Record<string, unknown>>`
947
+ SELECT
948
+ thread_id,
949
+ batch_id,
950
+ first_sequence,
951
+ last_sequence,
952
+ batch_digest,
953
+ tail_digest,
954
+ batch_json
955
+ FROM effect_agent_canonical_batches
956
+ WHERE thread_id = ${request.threadId}
957
+ AND batch_id = ${request.batchId}
958
+ `.pipe(Effect.mapError(storageError("read idempotent batch")));
959
+
960
+ const batches = yield* decodeRows(
961
+ Schema.Array(BatchRow),
962
+ "effect_agent_canonical_batches",
963
+ `${request.threadId}/${request.batchId}`,
964
+ batchRows,
965
+ );
966
+
967
+ if (batches.length > 1) {
968
+ return yield* DoStorageCorruptionError.make({
969
+ table: "effect_agent_canonical_batches",
970
+ rowKey: `${request.threadId}/${request.batchId}`,
971
+ message: "A canonical batch primary key returned more than one row.",
972
+ });
973
+ }
974
+ if (batches.length === 1) {
975
+ const existing = batches[0];
976
+
977
+ if (existing.batch_digest !== request.batchDigest) {
978
+ return yield* DoAppendConflict.make({
979
+ message: `Batch ${request.batchId} already exists with different canonical content.`,
980
+ reason: "batch-digest",
981
+ });
982
+ }
983
+
984
+ return RawAppendResult.make({
985
+ firstSequence: existing.first_sequence,
986
+ lastSequence: existing.last_sequence,
987
+ replayed: true,
988
+ tailDigest: existing.tail_digest,
989
+ });
990
+ }
991
+
992
+ if (
993
+ request.expectedTailSequence !== thread.tail_sequence ||
994
+ request.expectedTailDigest !== thread.tail_digest
995
+ ) {
996
+ return yield* DoAppendConflict.make({
997
+ message:
998
+ `Expected tail ${request.expectedTailSequence}/${request.expectedTailDigest} ` +
999
+ `but found ${thread.tail_sequence}/${thread.tail_digest}.`,
1000
+ reason: "tail",
1001
+ actualTailSequence: thread.tail_sequence,
1002
+ actualTailDigest: thread.tail_digest,
1003
+ });
1004
+ }
1005
+ if (thread.tail_sequence + request.records.length > MAX_RECORDS_PER_THREAD) {
1006
+ return yield* DoStorageError.make({
1007
+ operation: "append canonical batch",
1008
+ message: `Thread record limit ${MAX_RECORDS_PER_THREAD} would be exceeded.`,
1009
+ });
1010
+ }
1011
+
1012
+ // Chunked to respect the Durable Object platform's 100-bound-parameter statement
1013
+ // limit: a batch may carry up to 256 records.
1014
+ const existingRecords: Array<RecordRow> = [];
1015
+
1016
+ for (const chunk of chunked(recordIds, MAX_BOUND_PARAMETERS - 10)) {
1017
+ const existingRecordRows = yield* sql<Record<string, unknown>>`
1018
+ SELECT
1019
+ thread_id,
1020
+ sequence,
1021
+ record_id,
1022
+ batch_id,
1023
+ record_json
1024
+ FROM effect_agent_canonical_records
1025
+ WHERE thread_id = ${request.threadId}
1026
+ AND record_id IN ${sql.in([...chunk])}
1027
+ ORDER BY sequence
1028
+ `.pipe(Effect.mapError(storageError("check canonical record identities")));
1029
+
1030
+ existingRecords.push(
1031
+ ...(yield* decodeRows(
1032
+ Schema.Array(RecordRow),
1033
+ "effect_agent_canonical_records",
1034
+ `${request.threadId}/record_ids`,
1035
+ existingRecordRows,
1036
+ )),
1037
+ );
1038
+ }
1039
+ if (existingRecords.length > 0) {
1040
+ return yield* DoAppendConflict.make({
1041
+ message: `Canonical record ID ${existingRecords[0].record_id} already exists.`,
1042
+ reason: "record-identity",
1043
+ });
1044
+ }
1045
+
1046
+ const firstSequence = yield* Schema.decodeEffect(CanonicalSequence)(
1047
+ thread.tail_sequence + 1,
1048
+ ).pipe(
1049
+ Effect.mapError((error) =>
1050
+ DoStorageError.make({
1051
+ cause: error,
1052
+ operation: "append canonical batch",
1053
+ message: error.message,
1054
+ }),
1055
+ ),
1056
+ );
1057
+
1058
+ const lastSequence = yield* Schema.decodeEffect(CanonicalSequence)(
1059
+ firstSequence + request.records.length - 1,
1060
+ ).pipe(
1061
+ Effect.mapError((error) =>
1062
+ DoStorageError.make({
1063
+ cause: error,
1064
+ operation: "append canonical batch",
1065
+ message: error.message,
1066
+ }),
1067
+ ),
1068
+ );
1069
+
1070
+ yield* sql`
1071
+ INSERT INTO effect_agent_canonical_batches (
1072
+ thread_id,
1073
+ batch_id,
1074
+ first_sequence,
1075
+ last_sequence,
1076
+ batch_digest,
1077
+ tail_digest,
1078
+ batch_json
1079
+ ) VALUES (
1080
+ ${request.threadId},
1081
+ ${request.batchId},
1082
+ ${firstSequence},
1083
+ ${lastSequence},
1084
+ ${request.batchDigest},
1085
+ ${request.tailDigest},
1086
+ ${request.batchJson}
1087
+ )
1088
+ `.pipe(Effect.mapError(storageError("insert canonical batch")));
1089
+ yield* failpoint("append:after-batch-insert");
1090
+
1091
+ yield* Effect.forEach(
1092
+ request.records,
1093
+ (record, index) =>
1094
+ Effect.gen(function* () {
1095
+ yield* sql`
1096
+ INSERT INTO effect_agent_canonical_records (
1097
+ thread_id,
1098
+ sequence,
1099
+ record_id,
1100
+ batch_id,
1101
+ record_json
1102
+ ) VALUES (
1103
+ ${request.threadId},
1104
+ ${firstSequence + index},
1105
+ ${record.recordId},
1106
+ ${request.batchId},
1107
+ ${record.recordJson}
1108
+ )
1109
+ `.pipe(Effect.mapError(storageError("insert canonical record")));
1110
+
1111
+ const canonical = yield* Schema.decodeEffect(Schema.fromJsonString(CanonicalRecord))(
1112
+ record.recordJson,
1113
+ ).pipe(
1114
+ Effect.mapError((error) =>
1115
+ DoStorageCorruptionError.make({
1116
+ table: "effect_agent_canonical_records",
1117
+ rowKey: record.recordId,
1118
+ message: error.message,
1119
+ }),
1120
+ ),
1121
+ );
1122
+
1123
+ yield* indexCanonicalRecord(request.threadId, canonical).pipe(
1124
+ Effect.provideService(SqlClient.SqlClient, sql),
1125
+ Effect.mapError(storageError("index canonical record")),
1126
+ );
1127
+ yield* failpoint("append:after-record-insert");
1128
+ }),
1129
+ { discard: true },
1130
+ );
1131
+
1132
+ yield* sql`
1133
+ UPDATE effect_agent_threads
1134
+ SET
1135
+ tail_sequence = ${lastSequence},
1136
+ tail_digest = ${request.tailDigest},
1137
+ producer_epoch = ${request.producerEpoch}
1138
+ WHERE thread_id = ${request.threadId}
1139
+ `.pipe(Effect.mapError(storageError("advance thread tail")));
1140
+ yield* failpoint("append:after-tail-update");
1141
+
1142
+ return RawAppendResult.make({
1143
+ firstSequence,
1144
+ lastSequence,
1145
+ replayed: false,
1146
+ tailDigest: request.tailDigest,
1147
+ });
1148
+ }),
1149
+ );
1150
+ });
1151
+
1152
+ const read = Effect.fnUntraced(function* (request: RawReadRequest) {
1153
+ // Capture membership without retaining payloads. Append-only sequences keep each later
1154
+ // payload query inside this snapshot, even when new records arrive during consumption.
1155
+ const planRows = yield* sql<Record<string, unknown>>`
1156
+ SELECT
1157
+ sequence,
1158
+ length(CAST(record_json AS BLOB)) AS record_json_bytes
1159
+ FROM effect_agent_canonical_records
1160
+ WHERE thread_id = ${request.threadId}
1161
+ AND sequence > ${request.fromSequenceExclusive}
1162
+ ORDER BY sequence
1163
+ LIMIT ${request.limit}
1164
+ `.pipe(Effect.mapError(storageError("read canonical records")));
1165
+
1166
+ const plan = yield* decodeRows(
1167
+ Schema.Array(ReadPlanRow),
1168
+ "effect_agent_canonical_records",
1169
+ `${request.threadId}>${request.fromSequenceExclusive}`,
1170
+ planRows,
1171
+ );
1172
+
1173
+ const mismatch = () =>
1174
+ DoStorageCorruptionError.make({
1175
+ table: "effect_agent_canonical_records",
1176
+ rowKey: `${request.threadId}>${request.fromSequenceExclusive}`,
1177
+ message: "Canonical read membership, sequence, or payload size changed from its read plan.",
1178
+ });
1179
+
1180
+ if (plan.length > request.limit) return yield* mismatch();
1181
+
1182
+ const pages: Array<ReadPage> = [];
1183
+ let page: ReadPage | undefined;
1184
+ let pageBytes = 0;
1185
+ let previousSequence = request.fromSequenceExclusive;
1186
+
1187
+ for (const row of plan) {
1188
+ if (row.sequence !== previousSequence + 1) return yield* mismatch();
1189
+ previousSequence = row.sequence;
1190
+ if (page === undefined || pageBytes + row.record_json_bytes > MAX_READ_PAGE_JSON_BYTES) {
1191
+ page = [row];
1192
+ pages.push(page);
1193
+ pageBytes = row.record_json_bytes;
1194
+ } else {
1195
+ page.push(row);
1196
+ pageBytes += row.record_json_bytes;
1197
+ }
1198
+ }
1199
+
1200
+ const readPage = Effect.fn("DoJournal.readPage")(function* (page: ReadPage) {
1201
+ const rows = yield* sql<Record<string, unknown>>`
1202
+ SELECT thread_id, sequence, record_id, batch_id, record_json
1203
+ FROM effect_agent_canonical_records
1204
+ WHERE thread_id = ${request.threadId}
1205
+ AND sequence >= ${page[0].sequence}
1206
+ AND sequence <= ${page[page.length - 1].sequence}
1207
+ ORDER BY sequence
1208
+ `.pipe(Effect.mapError(storageError("read canonical records")));
1209
+
1210
+ const decoded = yield* decodeRows(
1211
+ Schema.Array(RecordRow),
1212
+ "effect_agent_canonical_records",
1213
+ `${request.threadId}/${page[0].sequence}`,
1214
+ rows,
1215
+ );
1216
+
1217
+ if (
1218
+ decoded.length !== page.length ||
1219
+ decoded.some(
1220
+ (row, index) =>
1221
+ row.thread_id !== request.threadId ||
1222
+ row.sequence !== page[index].sequence ||
1223
+ storedTextBytes(row.record_json) !== page[index].record_json_bytes,
1224
+ )
1225
+ ) {
1226
+ return yield* mismatch();
1227
+ }
1228
+
1229
+ return decoded;
1230
+ });
1231
+
1232
+ return {
1233
+ count: plan.length,
1234
+ records: Stream.fromIterable(pages).pipe(
1235
+ Stream.flatMap((page) => Stream.fromIterableEffect(readPage(page))),
1236
+ ),
1237
+ };
1238
+ });
1239
+
1240
+ const exportThread = Effect.fn("DoJournal.exportThread")(function* (threadId: string) {
1241
+ return yield* sql
1242
+ .withTransaction(
1243
+ Effect.gen(function* () {
1244
+ const threadRows = yield* sql<Record<string, unknown>>`
1245
+ SELECT
1246
+ thread_id,
1247
+ created_at,
1248
+ tail_sequence,
1249
+ tail_digest,
1250
+ producer_epoch
1251
+ FROM effect_agent_threads
1252
+ WHERE thread_id = ${threadId}
1253
+ `.pipe(Effect.mapError(storageError("export thread")));
1254
+
1255
+ const thread = yield* decodeSingleRow(
1256
+ Schema.Array(ThreadRow),
1257
+ "effect_agent_threads",
1258
+ threadId,
1259
+ threadRows,
1260
+ );
1261
+
1262
+ yield* failpoint("export:after-thread-read");
1263
+
1264
+ if (thread.tail_sequence > MAX_RECORDS_PER_THREAD)
1265
+ return yield* DoStorageError.make({
1266
+ operation: "export thread",
1267
+ message: "The thread exceeds the current export record limit.",
1268
+ });
1269
+ const records: Array<RecordRow> = [];
1270
+ let afterSequence = ZERO_SEQUENCE;
1271
+
1272
+ while (afterSequence < thread.tail_sequence) {
1273
+ const limit = Math.min(1_024, thread.tail_sequence - afterSequence);
1274
+
1275
+ const request = RawReadRequest.make({
1276
+ threadId,
1277
+ fromSequenceExclusive: afterSequence,
1278
+ limit,
1279
+ });
1280
+
1281
+ const plan = yield* read(request);
1282
+ const page = yield* Stream.runCollect(plan.records);
1283
+
1284
+ if (
1285
+ page.length !== limit ||
1286
+ page.some((record, index) => record.sequence !== afterSequence + index + 1)
1287
+ ) {
1288
+ return yield* DoStorageCorruptionError.make({
1289
+ table: "effect_agent_canonical_records",
1290
+ rowKey: threadId,
1291
+ message:
1292
+ "The exported canonical prefix is not contiguous through its captured tail.",
1293
+ });
1294
+ }
1295
+ records.push(...page);
1296
+ afterSequence = page[page.length - 1].sequence;
1297
+ }
1298
+
1299
+ const beyondTail =
1300
+ yield* sql`SELECT sequence FROM effect_agent_canonical_records WHERE thread_id=${threadId} AND sequence > ${thread.tail_sequence} LIMIT 1`.pipe(
1301
+ Effect.mapError(storageError("verify export tail")),
1302
+ );
1303
+
1304
+ if (beyondTail.length !== 0)
1305
+ return yield* DoStorageCorruptionError.make({
1306
+ table: "effect_agent_canonical_records",
1307
+ rowKey: threadId,
1308
+ message: "Canonical records exist beyond the captured thread tail.",
1309
+ });
1310
+
1311
+ return RawThreadExport.make({ thread, records });
1312
+ }),
1313
+ )
1314
+ .pipe(
1315
+ Effect.catchTag("SqlError", (error) =>
1316
+ Effect.fail(storageError("export transaction")(error)),
1317
+ ),
1318
+ );
1319
+ });
1320
+
1321
+ const saveCheckpoint = Effect.fn("DoJournal.saveCheckpoint")(function* (
1322
+ checkpoint: RawCheckpoint,
1323
+ ): Effect.fn.Return<void, CheckpointError> {
1324
+ if (checkpoint.threadId.length > MAX_IDENTIFIER_LENGTH) {
1325
+ return yield* DoStorageError.make({
1326
+ operation: "save checkpoint",
1327
+ message: "Checkpoint identity exceeds the Durable Object storage bounds.",
1328
+ });
1329
+ }
1330
+ yield* checkValueBound("save checkpoint", checkpoint.checkpointJson);
1331
+ yield* withWriteTransaction("checkpoint transaction")(
1332
+ Effect.gen(function* () {
1333
+ const threadRows = yield* sql<Record<string, unknown>>`
1334
+ SELECT
1335
+ thread_id,
1336
+ created_at,
1337
+ tail_sequence,
1338
+ tail_digest,
1339
+ producer_epoch
1340
+ FROM effect_agent_threads
1341
+ WHERE thread_id = ${checkpoint.threadId}
1342
+ `.pipe(Effect.mapError(storageError("read checkpoint tail")));
1343
+
1344
+ const thread = yield* decodeSingleRow(
1345
+ Schema.Array(ThreadRow),
1346
+ "effect_agent_threads",
1347
+ checkpoint.threadId,
1348
+ threadRows,
1349
+ );
1350
+
1351
+ if (checkpoint.throughSequence > thread.tail_sequence) {
1352
+ return yield* DoCheckpointConflict.make({
1353
+ message:
1354
+ `Checkpoint sequence ${checkpoint.throughSequence} is after canonical tail ` +
1355
+ `${thread.tail_sequence}.`,
1356
+ });
1357
+ }
1358
+
1359
+ const checkpointRows = yield* sql<Record<string, unknown>>`
1360
+ SELECT
1361
+ thread_id,
1362
+ through_sequence,
1363
+ tail_digest,
1364
+ checkpoint_json
1365
+ FROM effect_agent_checkpoints
1366
+ WHERE thread_id = ${checkpoint.threadId}
1367
+ AND through_sequence = ${checkpoint.throughSequence}
1368
+ `.pipe(Effect.mapError(storageError("read idempotent checkpoint")));
1369
+
1370
+ const existing = yield* decodeRows(
1371
+ Schema.Array(CheckpointRow),
1372
+ "effect_agent_checkpoints",
1373
+ `${checkpoint.threadId}/${checkpoint.throughSequence}`,
1374
+ checkpointRows,
1375
+ );
1376
+
1377
+ if (existing.length > 1) {
1378
+ return yield* DoStorageCorruptionError.make({
1379
+ table: "effect_agent_checkpoints",
1380
+ rowKey: `${checkpoint.threadId}/${checkpoint.throughSequence}`,
1381
+ message: "A checkpoint primary key returned more than one row.",
1382
+ });
1383
+ }
1384
+ if (existing.length === 1) {
1385
+ if (
1386
+ existing[0].tail_digest !== checkpoint.tailDigest ||
1387
+ existing[0].checkpoint_json !== checkpoint.checkpointJson
1388
+ ) {
1389
+ return yield* DoCheckpointConflict.make({
1390
+ message: "A different checkpoint already exists at this canonical sequence.",
1391
+ });
1392
+ }
1393
+
1394
+ return;
1395
+ }
1396
+
1397
+ yield* sql`
1398
+ INSERT INTO effect_agent_checkpoints (
1399
+ thread_id,
1400
+ through_sequence,
1401
+ tail_digest,
1402
+ checkpoint_json
1403
+ ) VALUES (
1404
+ ${checkpoint.threadId},
1405
+ ${checkpoint.throughSequence},
1406
+ ${checkpoint.tailDigest},
1407
+ ${checkpoint.checkpointJson}
1408
+ )
1409
+ `.pipe(Effect.mapError(storageError("insert checkpoint")));
1410
+ }),
1411
+ );
1412
+ });
1413
+
1414
+ const saveRecoveryCheckpoint = Effect.fn("DoJournal.saveRecoveryCheckpoint")(function* (
1415
+ request: SaveRecoveryCheckpointRequest,
1416
+ checkpointJson: string,
1417
+ ) {
1418
+ const { checkpoint } = request;
1419
+
1420
+ if (checkpoint.threadId.length > MAX_IDENTIFIER_LENGTH) {
1421
+ return yield* DoStorageError.make({
1422
+ operation: "save recovery checkpoint",
1423
+ message: "Checkpoint identity exceeds the Durable Object storage bounds.",
1424
+ });
1425
+ }
1426
+ yield* checkValueBound("save recovery checkpoint", checkpointJson);
1427
+ // Keep injected waits outside the storage-backed transaction callback.
1428
+ yield* failpoint("save-recovery-checkpoint:before");
1429
+ yield* withWriteTransaction("recovery checkpoint transaction")(
1430
+ Effect.gen(function* () {
1431
+ const threads = yield* getThread(checkpoint.threadId);
1432
+ const thread = threads[0];
1433
+
1434
+ if (thread === undefined)
1435
+ return yield* ThreadNotMaterialized.make({ threadId: checkpoint.threadId });
1436
+ if (request.producerEpoch !== thread.producer_epoch)
1437
+ return yield* FenceRejected.make({
1438
+ threadId: checkpoint.threadId,
1439
+ actualEpoch: thread.producer_epoch,
1440
+ attemptedEpoch: request.producerEpoch,
1441
+ });
1442
+ if (checkpoint.throughSequence > thread.tail_sequence)
1443
+ return yield* CheckpointRejected.make({
1444
+ threadId: checkpoint.threadId,
1445
+ reason: "ahead-of-tail",
1446
+ });
1447
+
1448
+ const digests =
1449
+ checkpoint.throughSequence === 0
1450
+ ? [EMPTY_TAIL_DIGEST]
1451
+ : yield* getTailDigestAt(checkpoint.threadId, checkpoint.throughSequence);
1452
+
1453
+ if (digests.length !== 1 || digests[0] !== checkpoint.tailDigest)
1454
+ return yield* CheckpointRejected.make({
1455
+ threadId: checkpoint.threadId,
1456
+ reason: "digest-mismatch",
1457
+ });
1458
+
1459
+ yield* sql`
1460
+ INSERT INTO effect_agent_recovery_checkpoints (thread_id, through_sequence, tail_digest, checkpoint_json)
1461
+ VALUES (${checkpoint.threadId}, ${checkpoint.throughSequence}, ${checkpoint.tailDigest}, ${checkpointJson})
1462
+ ON CONFLICT (thread_id) DO UPDATE SET
1463
+ through_sequence = excluded.through_sequence,
1464
+ tail_digest = excluded.tail_digest,
1465
+ checkpoint_json = excluded.checkpoint_json
1466
+ WHERE excluded.through_sequence >= effect_agent_recovery_checkpoints.through_sequence
1467
+ `.pipe(Effect.mapError(storageError("save recovery checkpoint")));
1468
+ }),
1469
+ );
1470
+ yield* failpoint("save-recovery-checkpoint:after");
1471
+ });
1472
+
1473
+ const loadRecoveryCheckpoint = Effect.fn("DoJournal.loadRecoveryCheckpoint")(function* (
1474
+ threadId: string,
1475
+ ) {
1476
+ const rows = yield* sql<Record<string, unknown>>`
1477
+ SELECT thread_id, through_sequence, tail_digest, checkpoint_json
1478
+ FROM effect_agent_recovery_checkpoints
1479
+ WHERE thread_id = ${threadId}
1480
+ `.pipe(Effect.mapError(storageError("load recovery checkpoint")));
1481
+
1482
+ return yield* decodeRows(
1483
+ Schema.Array(CheckpointRow),
1484
+ "effect_agent_recovery_checkpoints",
1485
+ threadId,
1486
+ rows,
1487
+ );
1488
+ });
1489
+
1490
+ const loadCheckpoint = Effect.fn("DoJournal.loadCheckpoint")(function* (
1491
+ threadId: string,
1492
+ atOrBeforeSequence: CanonicalSequence,
1493
+ ) {
1494
+ const rows = yield* sql<Record<string, unknown>>`
1495
+ SELECT
1496
+ thread_id,
1497
+ through_sequence,
1498
+ tail_digest,
1499
+ checkpoint_json
1500
+ FROM effect_agent_checkpoints
1501
+ WHERE thread_id = ${threadId}
1502
+ AND through_sequence <= ${atOrBeforeSequence}
1503
+ ORDER BY through_sequence DESC
1504
+ LIMIT 1
1505
+ `.pipe(Effect.mapError(storageError("load checkpoint")));
1506
+
1507
+ return yield* decodeRows(
1508
+ Schema.Array(CheckpointRow),
1509
+ "effect_agent_checkpoints",
1510
+ `${threadId}<=${atOrBeforeSequence}`,
1511
+ rows,
1512
+ );
1513
+ });
1514
+
1515
+ const getTailDigestAt = Effect.fn("DoJournal.getTailDigestAt")(function* (
1516
+ threadId: string,
1517
+ sequence: CanonicalSequence,
1518
+ ) {
1519
+ if (sequence === 0) {
1520
+ const threads = yield* getThread(threadId);
1521
+
1522
+ return threads.length === 0
1523
+ ? []
1524
+ : [threads[0].tail_sequence === 0 ? threads[0].tail_digest : undefined].filter(
1525
+ (value): value is string => value !== undefined,
1526
+ );
1527
+ }
1528
+
1529
+ const rows = yield* sql<Record<string, unknown>>`
1530
+ SELECT
1531
+ thread_id,
1532
+ batch_id,
1533
+ first_sequence,
1534
+ last_sequence,
1535
+ batch_digest,
1536
+ tail_digest,
1537
+ batch_json
1538
+ FROM effect_agent_canonical_batches
1539
+ WHERE thread_id = ${threadId}
1540
+ AND last_sequence = ${sequence}
1541
+ `.pipe(Effect.mapError(storageError("read canonical digest at sequence")));
1542
+
1543
+ const batches = yield* decodeRows(
1544
+ Schema.Array(BatchRow),
1545
+ "effect_agent_canonical_batches",
1546
+ `${threadId}/${sequence}`,
1547
+ rows,
1548
+ );
1549
+
1550
+ return batches.map((batch) => batch.tail_digest);
1551
+ });
1552
+
1553
+ const scanStoredPayloads = Effect.fn("DoJournal.scanStoredPayloads")(function* () {
1554
+ return yield* sql
1555
+ .withTransaction(
1556
+ Effect.gen(function* () {
1557
+ const threads = yield* sql<Record<string, unknown>>`
1558
+ SELECT
1559
+ thread_id,
1560
+ created_at,
1561
+ tail_sequence,
1562
+ tail_digest,
1563
+ producer_epoch
1564
+ FROM effect_agent_threads
1565
+ ORDER BY thread_id
1566
+ `.pipe(Effect.mapError(storageError("scan threads")));
1567
+
1568
+ const batches = yield* sql<Record<string, unknown>>`
1569
+ SELECT
1570
+ thread_id,
1571
+ batch_id,
1572
+ first_sequence,
1573
+ last_sequence,
1574
+ batch_digest,
1575
+ tail_digest,
1576
+ batch_json
1577
+ FROM effect_agent_canonical_batches
1578
+ ORDER BY thread_id, first_sequence
1579
+ `.pipe(Effect.mapError(storageError("scan canonical batches")));
1580
+
1581
+ const records = yield* sql<Record<string, unknown>>`
1582
+ SELECT
1583
+ thread_id,
1584
+ sequence,
1585
+ record_id,
1586
+ batch_id,
1587
+ record_json
1588
+ FROM effect_agent_canonical_records
1589
+ ORDER BY thread_id, sequence
1590
+ `.pipe(Effect.mapError(storageError("scan canonical records")));
1591
+
1592
+ const checkpoints = yield* sql<Record<string, unknown>>`
1593
+ SELECT
1594
+ thread_id,
1595
+ through_sequence,
1596
+ tail_digest,
1597
+ checkpoint_json
1598
+ FROM effect_agent_checkpoints
1599
+ ORDER BY thread_id, through_sequence
1600
+ `.pipe(Effect.mapError(storageError("scan checkpoints")));
1601
+
1602
+ return {
1603
+ threads: yield* decodeRows(
1604
+ Schema.Array(ThreadRow),
1605
+ "effect_agent_threads",
1606
+ "startup_scan",
1607
+ threads,
1608
+ ),
1609
+ batches: yield* decodeRows(
1610
+ Schema.Array(BatchRow),
1611
+ "effect_agent_canonical_batches",
1612
+ "startup_scan",
1613
+ batches,
1614
+ ),
1615
+ records: yield* decodeRows(
1616
+ Schema.Array(RecordRow),
1617
+ "effect_agent_canonical_records",
1618
+ "startup_scan",
1619
+ records,
1620
+ ),
1621
+ checkpoints: yield* decodeRows(
1622
+ Schema.Array(CheckpointRow),
1623
+ "effect_agent_checkpoints",
1624
+ "startup_scan",
1625
+ checkpoints,
1626
+ ),
1627
+ };
1628
+ }),
1629
+ )
1630
+ .pipe(
1631
+ Effect.catchTag("SqlError", (error) =>
1632
+ Effect.fail(storageError("startup scan transaction")(error)),
1633
+ ),
1634
+ );
1635
+ });
1636
+
1637
+ return {
1638
+ append,
1639
+ checkValueBound,
1640
+ exportThread,
1641
+ getThread,
1642
+ getTailDigestAt,
1643
+ loadCheckpoint,
1644
+ loadRecoveryCheckpoint,
1645
+ saveRecoveryCheckpoint,
1646
+ materialize,
1647
+ read,
1648
+ saveCheckpoint,
1649
+ scanStoredPayloads,
1650
+ withWriteTransaction,
1651
+ } as const;
1652
+ };
1653
+
1654
+ export type DoJournal = ReturnType<typeof makeJournal>;
1655
+
1656
+ export const initializeDoJournal = ensureCurrentStorage;