@effect-agent/storage-cloudflare 0.1.0-beta.12 → 0.1.0-beta.120

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-DjpuLHmX.d.mts +101 -0
  14. package/dist/DoStorageError.d.mts +2 -0
  15. package/dist/DoStorageError.mjs +167 -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-DJwZzqel.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 +1939 -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 +499 -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 +5521 -0
  40. package/dist/PortProtocol.mjs +206 -0
  41. package/dist/PortProtocol.mjs.map +1 -0
  42. package/dist/PortRouting.d.mts +86 -0
  43. package/dist/PortRouting.mjs +461 -0
  44. package/dist/PortRouting.mjs.map +1 -0
  45. package/dist/do-journal-D65Iuln6.mjs +1119 -0
  46. package/dist/do-journal-D65Iuln6.mjs.map +1 -0
  47. package/dist/index.d.mts +14 -1525
  48. package/dist/index.mjs +14 -3695
  49. package/dist/migrations-Br6QqMIf.mjs +306 -0
  50. package/dist/migrations-Br6QqMIf.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} +13 -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} +1119 -262
  61. package/src/DoSubscriptionStore.ts +329 -0
  62. package/src/DoThreadStore.ts +1064 -0
  63. package/src/MemoryProtocol.ts +351 -0
  64. package/src/{port-protocol.ts → PortProtocol.ts} +103 -38
  65. package/src/{routing.ts → PortRouting.ts} +440 -264
  66. package/src/index.ts +13 -32
  67. package/src/internal/do-journal.ts +1789 -0
  68. package/src/internal/message-delivery-schema.ts +24 -0
  69. package/src/{migrations.ts → internal/migrations.ts} +72 -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
package/src/do-journal.ts DELETED
@@ -1,1078 +0,0 @@
1
- import { CanonicalSequence, ProducerEpoch } from "@effect-agent/session";
2
- import { SqliteMigrator } from "@effect/sql-sqlite-do";
3
- import { Effect, Schema } from "effect";
4
- import * as SqlClient from "effect/unstable/sql/SqlClient";
5
- import { SqlError } from "effect/unstable/sql/SqlError";
6
-
7
- import {
8
- DoAppendConflict,
9
- DoCheckpointConflict,
10
- DoFenceRejected,
11
- DoStorageCompatibilityError,
12
- DoStorageCorruptionError,
13
- DoStorageError,
14
- DoStorageFailpointError,
15
- DoValueBoundExceeded,
16
- type DoStorageFailpointLocation,
17
- } from "./errors.ts";
18
- import { CurrentDoStorageVersion, doMigrations } from "./migrations.ts";
19
-
20
- /**
21
- * Static schema ceiling for stored text columns. Writes are bounded in BYTES by the
22
- * configured `maxStoredValueBytes` (always ≤ 2,000,000); UTF-8 byte length is never smaller
23
- * than UTF-16 string length, so any value that passed the byte bound also passes this
24
- * decode-side character ceiling.
25
- */
26
- const BoundedStoredText = Schema.String.check(Schema.isMaxLength(2_000_000));
27
- const BoundedIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(1024));
28
- const MAX_RECORDS_PER_CONVERSATION = 65_536;
29
- const MAX_IDENTIFIER_LENGTH = 1_024;
30
- /** Durable Object SQL storage allows at most 100 bound parameters per statement. */
31
- const MAX_BOUND_PARAMETERS = 100;
32
-
33
- const storedTextBytes = (value: string): number => new TextEncoder().encode(value).byteLength;
34
-
35
- const chunked = <A>(values: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> => {
36
- const chunks: Array<ReadonlyArray<A>> = [];
37
- for (let index = 0; index < values.length; index += size) {
38
- chunks.push(values.slice(index, index + size));
39
- }
40
- return chunks;
41
- };
42
-
43
- class DoMetaRow extends Schema.Class<DoMetaRow>("DoMetaRow")({
44
- value: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
45
- }) {}
46
-
47
- class DoNameRow extends Schema.Class<DoNameRow>("DoNameRow")({
48
- name: BoundedIdentifier,
49
- }) {}
50
-
51
- class ConversationRow extends Schema.Class<ConversationRow>("ConversationRow")({
52
- conversation_id: BoundedIdentifier,
53
- created_at: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
54
- producer_epoch: ProducerEpoch,
55
- tail_digest: BoundedStoredText,
56
- tail_sequence: CanonicalSequence,
57
- }) {}
58
-
59
- class BatchRow extends Schema.Class<BatchRow>("BatchRow")({
60
- batch_digest: BoundedStoredText,
61
- batch_id: BoundedIdentifier,
62
- batch_json: BoundedStoredText,
63
- conversation_id: BoundedIdentifier,
64
- first_sequence: CanonicalSequence,
65
- last_sequence: CanonicalSequence,
66
- tail_digest: BoundedStoredText,
67
- }) {}
68
-
69
- class RecordRow extends Schema.Class<RecordRow>("RecordRow")({
70
- batch_id: BoundedIdentifier,
71
- conversation_id: BoundedIdentifier,
72
- record_id: BoundedIdentifier,
73
- record_json: BoundedStoredText,
74
- sequence: CanonicalSequence,
75
- }) {}
76
-
77
- class CheckpointRow extends Schema.Class<CheckpointRow>("CheckpointRow")({
78
- checkpoint_json: BoundedStoredText,
79
- conversation_id: BoundedIdentifier,
80
- tail_digest: BoundedStoredText,
81
- through_sequence: CanonicalSequence,
82
- }) {}
83
-
84
- export class RawRecord extends Schema.Class<RawRecord>(
85
- "@effect-agent/storage-cloudflare/RawRecord",
86
- )({
87
- recordId: BoundedIdentifier,
88
- recordJson: BoundedStoredText,
89
- }) {}
90
-
91
- export class RawAppendRequest extends Schema.Class<RawAppendRequest>(
92
- "@effect-agent/storage-cloudflare/RawAppendRequest",
93
- )({
94
- batchDigest: BoundedStoredText,
95
- batchId: BoundedIdentifier,
96
- batchJson: BoundedStoredText,
97
- conversationId: BoundedIdentifier,
98
- expectedTailDigest: BoundedStoredText,
99
- expectedTailSequence: CanonicalSequence,
100
- producerEpoch: ProducerEpoch,
101
- records: Schema.NonEmptyArray(RawRecord).check(Schema.isMaxLength(256)),
102
- tailDigest: BoundedStoredText,
103
- }) {}
104
-
105
- export class RawAppendResult extends Schema.Class<RawAppendResult>(
106
- "@effect-agent/storage-cloudflare/RawAppendResult",
107
- )({
108
- firstSequence: CanonicalSequence,
109
- lastSequence: CanonicalSequence,
110
- replayed: Schema.Boolean,
111
- tailDigest: BoundedStoredText,
112
- }) {}
113
-
114
- export class RawReadRequest extends Schema.Class<RawReadRequest>(
115
- "@effect-agent/storage-cloudflare/RawReadRequest",
116
- )({
117
- conversationId: BoundedIdentifier,
118
- fromSequenceExclusive: CanonicalSequence,
119
- limit: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(1_024)),
120
- }) {}
121
-
122
- export class RawCheckpoint extends Schema.Class<RawCheckpoint>(
123
- "@effect-agent/storage-cloudflare/RawCheckpoint",
124
- )({
125
- checkpointJson: BoundedStoredText,
126
- conversationId: BoundedIdentifier,
127
- tailDigest: BoundedStoredText,
128
- throughSequence: CanonicalSequence,
129
- }) {}
130
-
131
- export class RawConversationExport extends Schema.Class<RawConversationExport>(
132
- "@effect-agent/storage-cloudflare/RawConversationExport",
133
- )({
134
- batches: Schema.Array(BatchRow),
135
- checkpoints: Schema.Array(CheckpointRow),
136
- conversation: ConversationRow,
137
- records: Schema.Array(RecordRow),
138
- }) {}
139
-
140
- type AppendError =
141
- | DoAppendConflict
142
- | DoFenceRejected
143
- | DoStorageCorruptionError
144
- | DoStorageError
145
- | DoStorageFailpointError
146
- | DoValueBoundExceeded;
147
-
148
- type CheckpointError =
149
- | DoCheckpointConflict
150
- | DoStorageCorruptionError
151
- | DoStorageError
152
- | DoValueBoundExceeded;
153
-
154
- type DoJournalFailpoint = (
155
- location: DoStorageFailpointLocation,
156
- ) => Effect.Effect<void, DoStorageFailpointError>;
157
-
158
- const noFailpoint: DoJournalFailpoint = () => Effect.void;
159
-
160
- const storageError =
161
- (operation: string) =>
162
- (error: SqlError): DoStorageError =>
163
- DoStorageError.make({
164
- cause: error,
165
- operation,
166
- message: error.message,
167
- });
168
-
169
- /** Decode raw Durable Object SQLite rows against a Schema, reporting failures as typed corruption. */
170
- export const decodeRows = Effect.fn("DoJournal.decodeRows")(
171
- <A, I>(
172
- schema: Schema.Codec<ReadonlyArray<A>, ReadonlyArray<I>>,
173
- table: string,
174
- rowKey: string,
175
- rows: unknown,
176
- ): Effect.Effect<ReadonlyArray<A>, DoStorageCorruptionError> =>
177
- Schema.decodeUnknownEffect(schema)(rows).pipe(
178
- Effect.mapError((error) =>
179
- DoStorageCorruptionError.make({
180
- table,
181
- rowKey,
182
- message: String(error),
183
- }),
184
- ),
185
- ),
186
- );
187
-
188
- /** Decode exactly one raw row against a Schema, reporting failures as typed corruption. */
189
- export const decodeSingleRow = Effect.fn("DoJournal.decodeSingleRow")(
190
- <A, I>(
191
- schema: Schema.Codec<ReadonlyArray<A>, ReadonlyArray<I>>,
192
- table: string,
193
- rowKey: string,
194
- rows: unknown,
195
- ): Effect.Effect<A, DoStorageCorruptionError> =>
196
- decodeRows(schema, table, rowKey, rows).pipe(
197
- Effect.flatMap((decoded) =>
198
- decoded.length === 1
199
- ? Effect.succeed(decoded[0])
200
- : Effect.fail(
201
- DoStorageCorruptionError.make({
202
- table,
203
- rowKey,
204
- message: `Expected exactly one row but found ${decoded.length}.`,
205
- }),
206
- ),
207
- ),
208
- ),
209
- );
210
-
211
- const REQUIRED_TABLES = [
212
- "effect_agent_abort_intents",
213
- "effect_agent_approval_decisions",
214
- "effect_agent_attempts",
215
- "effect_agent_canonical_batches",
216
- "effect_agent_canonical_records",
217
- "effect_agent_checkpoints",
218
- "effect_agent_child_reservations",
219
- "effect_agent_child_settlements",
220
- "effect_agent_conversations",
221
- "effect_agent_meta",
222
- "effect_agent_settlement_reservations",
223
- "effect_agent_submission_ownership",
224
- "effect_agent_submissions",
225
- "effect_agent_unknown_resolutions",
226
- ] as const;
227
-
228
- /**
229
- * Exact-or-fresh storage gate (DEPLOY-008) over `effect_agent_meta` instead of
230
- * `PRAGMA user_version` (unverified on Durable Object SQL storage; a meta table is portable
231
- * regardless). No WAL check (Durable Object storage owns durability and confirms writes
232
- * through output gates) and no busy timeout (a Durable Object has exactly one writer): the
233
- * Node machinery those served has no DC analogue and is deliberately absent.
234
- */
235
- const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(function* (
236
- sql: SqlClient.SqlClient,
237
- failpoint: DoJournalFailpoint = noFailpoint,
238
- maxStoredValueBytes: number,
239
- ) {
240
- const metaTableRows = yield* sql<Record<string, unknown>>`
241
- SELECT name
242
- FROM sqlite_master
243
- WHERE type = 'table'
244
- AND name = 'effect_agent_meta'
245
- `.pipe(Effect.mapError(storageError("read storage version table")));
246
- const metaTables = yield* decodeRows(
247
- Schema.Array(DoNameRow),
248
- "sqlite_master",
249
- "effect_agent_meta",
250
- metaTableRows,
251
- );
252
-
253
- if (metaTables.length === 0) {
254
- const existingRows = yield* sql<Record<string, unknown>>`
255
- SELECT name
256
- FROM sqlite_master
257
- WHERE type = 'table'
258
- AND name LIKE 'effect_agent_%'
259
- ORDER BY name
260
- `.pipe(Effect.mapError(storageError("inspect unversioned storage")));
261
- const existing = yield* decodeRows(
262
- Schema.Array(DoNameRow),
263
- "sqlite_master",
264
- "effect_agent_%",
265
- existingRows,
266
- );
267
-
268
- if (existing.length > 0) {
269
- return yield* DoStorageCompatibilityError.make({
270
- actualVersion: 0,
271
- supportedVersion: CurrentDoStorageVersion,
272
- message:
273
- "The Durable Object contains unversioned Effect Agent tables. Reset the development namespace explicitly; refusing to mutate ambiguous stored data.",
274
- });
275
- }
276
-
277
- yield* SqliteMigrator.run({ loader: doMigrations }).pipe(
278
- // SqliteMigrator depends on the generic client supplied by this adapter. The concrete
279
- // Durable Object client is kept at the outer Layer boundary.
280
- Effect.provideService(SqlClient.SqlClient, sql),
281
- Effect.mapError((error) =>
282
- DoStorageError.make({
283
- cause: error,
284
- operation: "initialize current storage",
285
- message: error.message,
286
- }),
287
- ),
288
- );
289
- } else {
290
- const versionRows = yield* sql<Record<string, unknown>>`
291
- SELECT value
292
- FROM effect_agent_meta
293
- WHERE key = 'storage_version'
294
- `.pipe(Effect.mapError(storageError("read storage version")));
295
- const version = yield* decodeSingleRow(
296
- Schema.Array(DoMetaRow),
297
- "effect_agent_meta",
298
- "storage_version",
299
- versionRows,
300
- );
301
-
302
- // The storage version must match EXACTLY. Older private-development versions fail
303
- // closed with reset guidance rather than being migrated, and newer versions fail closed
304
- // rather than being decoded incorrectly (DEPLOY-008).
305
- if (version.value !== String(CurrentDoStorageVersion)) {
306
- const actualVersion = Number.parseInt(version.value, 10);
307
- return yield* DoStorageCompatibilityError.make({
308
- actualVersion: Number.isSafeInteger(actualVersion) ? actualVersion : -1,
309
- supportedVersion: CurrentDoStorageVersion,
310
- message:
311
- `The Durable Object uses private-development storage version ${version.value}; ` +
312
- `this build supports exactly version ${CurrentDoStorageVersion}. ` +
313
- "Replace the development namespace explicitly; automatic stored-data migrations are not provided during private development.",
314
- });
315
- }
316
- }
317
-
318
- const requiredRows = yield* sql<Record<string, unknown>>`
319
- SELECT name
320
- FROM sqlite_master
321
- WHERE type = 'table'
322
- AND name IN ${sql.in([...REQUIRED_TABLES])}
323
- ORDER BY name
324
- `.pipe(Effect.mapError(storageError("verify storage tables")));
325
- const required = yield* decodeRows(
326
- Schema.Array(DoNameRow),
327
- "sqlite_master",
328
- "required_tables",
329
- requiredRows,
330
- );
331
- if (required.length !== REQUIRED_TABLES.length) {
332
- return yield* DoStorageCompatibilityError.make({
333
- actualVersion: CurrentDoStorageVersion,
334
- supportedVersion: CurrentDoStorageVersion,
335
- message:
336
- "The Durable Object claims the current format but is missing required tables. Reset the corrupt private-development data.",
337
- });
338
- }
339
-
340
- return makeJournal(sql, failpoint, maxStoredValueBytes);
341
- });
342
-
343
- const makeJournal = (
344
- sql: SqlClient.SqlClient,
345
- failpoint: DoJournalFailpoint,
346
- maxStoredValueBytes: number,
347
- ) => {
348
- /** Typed pre-write refusal for any single value over the configured byte bound. */
349
- const checkValueBound = (
350
- operation: string,
351
- value: string,
352
- ): Effect.Effect<void, DoValueBoundExceeded> => {
353
- const actualBytes = storedTextBytes(value);
354
- return actualBytes > maxStoredValueBytes
355
- ? Effect.fail(
356
- DoValueBoundExceeded.make({
357
- actualBytes,
358
- maxBytes: maxStoredValueBytes,
359
- operation,
360
- }),
361
- )
362
- : Effect.void;
363
- };
364
-
365
- /**
366
- * Runs one journal write transaction on the Durable Object storage-backed
367
- * `withTransaction` (`ctx.storage.transaction()` under the hood). Within one Durable
368
- * Object there is exactly ONE writer, so the Node `BEGIN IMMEDIATE` + busy-retry +
369
- * `SqliteWriteContention` machinery has no analogue here and is deliberately absent.
370
- * Ownership-token and epoch checks still run INSIDE the transaction, so fencing atomicity
371
- * (DUR-006) is preserved identically.
372
- *
373
- * Journal write transactions are always top level: the Durable Object client rejects
374
- * nested transactions, so new journal operations must not wrap this helper inside another
375
- * transaction.
376
- */
377
- const withWriteTransaction =
378
- (operation: string) =>
379
- <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E | DoStorageError> =>
380
- sql.withTransaction(effect).pipe(
381
- Effect.mapError((error) =>
382
- error instanceof SqlError ? storageError(operation)(error) : error,
383
- ),
384
- Effect.withSpan("DoJournal.withWriteTransaction", { attributes: { operation } }),
385
- );
386
-
387
- const materialize = Effect.fn("DoJournal.materialize")(function* (
388
- conversationId: string,
389
- createdAt: string,
390
- emptyTailDigest: string,
391
- producerEpoch: ProducerEpoch,
392
- ): Effect.fn.Return<
393
- void,
394
- DoFenceRejected | DoStorageCorruptionError | DoStorageError | DoValueBoundExceeded
395
- > {
396
- if (conversationId.length > MAX_IDENTIFIER_LENGTH) {
397
- return yield* DoStorageError.make({
398
- operation: "materialize conversation",
399
- message: "Conversation identity exceeds the Durable Object storage bounds.",
400
- });
401
- }
402
- yield* checkValueBound("materialize conversation", emptyTailDigest);
403
- yield* withWriteTransaction("materialize transaction")(
404
- Effect.gen(function* () {
405
- const existingRows = yield* sql<Record<string, unknown>>`
406
- SELECT
407
- conversation_id,
408
- created_at,
409
- tail_sequence,
410
- tail_digest,
411
- producer_epoch
412
- FROM effect_agent_conversations
413
- WHERE conversation_id = ${conversationId}
414
- `.pipe(Effect.mapError(storageError("read materialized conversation")));
415
- const existing = yield* decodeRows(
416
- Schema.Array(ConversationRow),
417
- "effect_agent_conversations",
418
- conversationId,
419
- existingRows,
420
- );
421
- if (existing.length > 1) {
422
- return yield* DoStorageCorruptionError.make({
423
- table: "effect_agent_conversations",
424
- rowKey: conversationId,
425
- message: "A conversation primary key returned more than one row.",
426
- });
427
- }
428
- if (existing.length === 0) {
429
- yield* sql`
430
- INSERT INTO effect_agent_conversations (
431
- conversation_id,
432
- created_at,
433
- tail_sequence,
434
- tail_digest,
435
- producer_epoch
436
- ) VALUES (
437
- ${conversationId},
438
- ${createdAt},
439
- 0,
440
- ${emptyTailDigest},
441
- ${producerEpoch}
442
- )
443
- `.pipe(Effect.mapError(storageError("materialize conversation")));
444
- return;
445
- }
446
- if (producerEpoch < existing[0].producer_epoch) {
447
- return yield* DoFenceRejected.make({
448
- producerEpoch,
449
- actualEpoch: existing[0].producer_epoch,
450
- message: `Producer epoch ${producerEpoch} is stale; current epoch is ${existing[0].producer_epoch}.`,
451
- });
452
- }
453
- if (producerEpoch > existing[0].producer_epoch) {
454
- yield* sql`
455
- UPDATE effect_agent_conversations
456
- SET producer_epoch = ${producerEpoch}
457
- WHERE conversation_id = ${conversationId}
458
- `.pipe(Effect.mapError(storageError("advance materialization epoch")));
459
- }
460
- }),
461
- );
462
- });
463
-
464
- const getConversation = Effect.fn("DoJournal.getConversation")(function* (
465
- conversationId: string,
466
- ) {
467
- const rows = yield* sql<Record<string, unknown>>`
468
- SELECT
469
- conversation_id,
470
- created_at,
471
- tail_sequence,
472
- tail_digest,
473
- producer_epoch
474
- FROM effect_agent_conversations
475
- WHERE conversation_id = ${conversationId}
476
- `.pipe(Effect.mapError(storageError("read conversation")));
477
- return yield* decodeRows(
478
- Schema.Array(ConversationRow),
479
- "effect_agent_conversations",
480
- conversationId,
481
- rows,
482
- );
483
- });
484
-
485
- const append = Effect.fn("DoJournal.append")(function* (
486
- request: RawAppendRequest,
487
- ): Effect.fn.Return<RawAppendResult, AppendError> {
488
- if (
489
- request.conversationId.length > MAX_IDENTIFIER_LENGTH ||
490
- request.batchId.length > MAX_IDENTIFIER_LENGTH ||
491
- request.records.some((record) => record.recordId.length > MAX_IDENTIFIER_LENGTH)
492
- ) {
493
- return yield* DoStorageError.make({
494
- operation: "append canonical batch",
495
- message: "Canonical identifiers exceed the Durable Object storage bounds.",
496
- });
497
- }
498
- // The platform's ~2 MB per-value limit, enforced typed BEFORE any write (plan §1.2).
499
- yield* checkValueBound("append canonical batch", request.batchJson);
500
- yield* checkValueBound("append canonical batch", request.batchDigest);
501
- yield* checkValueBound("append canonical batch", request.tailDigest);
502
- yield* Effect.forEach(
503
- request.records,
504
- (record) => checkValueBound("append canonical record", record.recordJson),
505
- { discard: true },
506
- );
507
- return yield* withWriteTransaction("append transaction")(
508
- Effect.gen(function* () {
509
- const recordIds = request.records.map((record) => record.recordId);
510
- if (new Set(recordIds).size !== recordIds.length) {
511
- return yield* DoAppendConflict.make({
512
- message: `Batch ${request.batchId} contains duplicate canonical record IDs.`,
513
- reason: "record-identity",
514
- });
515
- }
516
-
517
- const conversationRows = yield* sql<Record<string, unknown>>`
518
- SELECT
519
- conversation_id,
520
- created_at,
521
- tail_sequence,
522
- tail_digest,
523
- producer_epoch
524
- FROM effect_agent_conversations
525
- WHERE conversation_id = ${request.conversationId}
526
- `.pipe(Effect.mapError(storageError("read append tail")));
527
- const conversation = yield* decodeSingleRow(
528
- Schema.Array(ConversationRow),
529
- "effect_agent_conversations",
530
- request.conversationId,
531
- conversationRows,
532
- );
533
-
534
- if (request.producerEpoch !== conversation.producer_epoch) {
535
- return yield* DoFenceRejected.make({
536
- producerEpoch: request.producerEpoch,
537
- actualEpoch: conversation.producer_epoch,
538
- message: `Producer epoch ${request.producerEpoch} is not the current epoch ${conversation.producer_epoch}.`,
539
- });
540
- }
541
-
542
- const batchRows = yield* sql<Record<string, unknown>>`
543
- SELECT
544
- conversation_id,
545
- batch_id,
546
- first_sequence,
547
- last_sequence,
548
- batch_digest,
549
- tail_digest,
550
- batch_json
551
- FROM effect_agent_canonical_batches
552
- WHERE conversation_id = ${request.conversationId}
553
- AND batch_id = ${request.batchId}
554
- `.pipe(Effect.mapError(storageError("read idempotent batch")));
555
- const batches = yield* decodeRows(
556
- Schema.Array(BatchRow),
557
- "effect_agent_canonical_batches",
558
- `${request.conversationId}/${request.batchId}`,
559
- batchRows,
560
- );
561
-
562
- if (batches.length > 1) {
563
- return yield* DoStorageCorruptionError.make({
564
- table: "effect_agent_canonical_batches",
565
- rowKey: `${request.conversationId}/${request.batchId}`,
566
- message: "A canonical batch primary key returned more than one row.",
567
- });
568
- }
569
- if (batches.length === 1) {
570
- const existing = batches[0];
571
- if (existing.batch_digest !== request.batchDigest) {
572
- return yield* DoAppendConflict.make({
573
- message: `Batch ${request.batchId} already exists with different canonical content.`,
574
- reason: "batch-digest",
575
- });
576
- }
577
- return RawAppendResult.make({
578
- firstSequence: existing.first_sequence,
579
- lastSequence: existing.last_sequence,
580
- replayed: true,
581
- tailDigest: existing.tail_digest,
582
- });
583
- }
584
-
585
- if (
586
- request.expectedTailSequence !== conversation.tail_sequence ||
587
- request.expectedTailDigest !== conversation.tail_digest
588
- ) {
589
- return yield* DoAppendConflict.make({
590
- message:
591
- `Expected tail ${request.expectedTailSequence}/${request.expectedTailDigest} ` +
592
- `but found ${conversation.tail_sequence}/${conversation.tail_digest}.`,
593
- reason: "tail",
594
- actualTailSequence: conversation.tail_sequence,
595
- actualTailDigest: conversation.tail_digest,
596
- });
597
- }
598
- if (conversation.tail_sequence + request.records.length > MAX_RECORDS_PER_CONVERSATION) {
599
- return yield* DoStorageError.make({
600
- operation: "append canonical batch",
601
- message: `Conversation record limit ${MAX_RECORDS_PER_CONVERSATION} would be exceeded.`,
602
- });
603
- }
604
-
605
- // Chunked to respect the Durable Object platform's 100-bound-parameter statement
606
- // limit: a batch may carry up to 256 records.
607
- const existingRecords: Array<RecordRow> = [];
608
- for (const chunk of chunked(recordIds, MAX_BOUND_PARAMETERS - 10)) {
609
- const existingRecordRows = yield* sql<Record<string, unknown>>`
610
- SELECT
611
- conversation_id,
612
- sequence,
613
- record_id,
614
- batch_id,
615
- record_json
616
- FROM effect_agent_canonical_records
617
- WHERE conversation_id = ${request.conversationId}
618
- AND record_id IN ${sql.in([...chunk])}
619
- ORDER BY sequence
620
- `.pipe(Effect.mapError(storageError("check canonical record identities")));
621
- existingRecords.push(
622
- ...(yield* decodeRows(
623
- Schema.Array(RecordRow),
624
- "effect_agent_canonical_records",
625
- `${request.conversationId}/record_ids`,
626
- existingRecordRows,
627
- )),
628
- );
629
- }
630
- if (existingRecords.length > 0) {
631
- return yield* DoAppendConflict.make({
632
- message: `Canonical record ID ${existingRecords[0].record_id} already exists.`,
633
- reason: "record-identity",
634
- });
635
- }
636
-
637
- const firstSequence = yield* Schema.decodeUnknownEffect(CanonicalSequence)(
638
- conversation.tail_sequence + 1,
639
- ).pipe(
640
- Effect.mapError((error) =>
641
- DoStorageError.make({
642
- cause: error,
643
- operation: "append canonical batch",
644
- message: error.message,
645
- }),
646
- ),
647
- );
648
- const lastSequence = yield* Schema.decodeUnknownEffect(CanonicalSequence)(
649
- firstSequence + request.records.length - 1,
650
- ).pipe(
651
- Effect.mapError((error) =>
652
- DoStorageError.make({
653
- cause: error,
654
- operation: "append canonical batch",
655
- message: error.message,
656
- }),
657
- ),
658
- );
659
-
660
- yield* sql`
661
- INSERT INTO effect_agent_canonical_batches (
662
- conversation_id,
663
- batch_id,
664
- first_sequence,
665
- last_sequence,
666
- batch_digest,
667
- tail_digest,
668
- batch_json
669
- ) VALUES (
670
- ${request.conversationId},
671
- ${request.batchId},
672
- ${firstSequence},
673
- ${lastSequence},
674
- ${request.batchDigest},
675
- ${request.tailDigest},
676
- ${request.batchJson}
677
- )
678
- `.pipe(Effect.mapError(storageError("insert canonical batch")));
679
- yield* failpoint("append:after-batch-insert");
680
-
681
- yield* Effect.forEach(
682
- request.records,
683
- (record, index) =>
684
- Effect.gen(function* () {
685
- yield* sql`
686
- INSERT INTO effect_agent_canonical_records (
687
- conversation_id,
688
- sequence,
689
- record_id,
690
- batch_id,
691
- record_json
692
- ) VALUES (
693
- ${request.conversationId},
694
- ${firstSequence + index},
695
- ${record.recordId},
696
- ${request.batchId},
697
- ${record.recordJson}
698
- )
699
- `.pipe(Effect.mapError(storageError("insert canonical record")));
700
- yield* failpoint("append:after-record-insert");
701
- }),
702
- { discard: true },
703
- );
704
-
705
- yield* sql`
706
- UPDATE effect_agent_conversations
707
- SET
708
- tail_sequence = ${lastSequence},
709
- tail_digest = ${request.tailDigest},
710
- producer_epoch = ${request.producerEpoch}
711
- WHERE conversation_id = ${request.conversationId}
712
- `.pipe(Effect.mapError(storageError("advance conversation tail")));
713
- yield* failpoint("append:after-tail-update");
714
-
715
- return RawAppendResult.make({
716
- firstSequence,
717
- lastSequence,
718
- replayed: false,
719
- tailDigest: request.tailDigest,
720
- });
721
- }),
722
- );
723
- });
724
-
725
- const read = Effect.fn("DoJournal.read")(function* (request: RawReadRequest) {
726
- const rows = yield* sql<Record<string, unknown>>`
727
- SELECT
728
- conversation_id,
729
- sequence,
730
- record_id,
731
- batch_id,
732
- record_json
733
- FROM effect_agent_canonical_records
734
- WHERE conversation_id = ${request.conversationId}
735
- AND sequence > ${request.fromSequenceExclusive}
736
- ORDER BY sequence
737
- LIMIT ${request.limit}
738
- `.pipe(Effect.mapError(storageError("read canonical records")));
739
- return yield* decodeRows(
740
- Schema.Array(RecordRow),
741
- "effect_agent_canonical_records",
742
- `${request.conversationId}>${request.fromSequenceExclusive}`,
743
- rows,
744
- );
745
- });
746
-
747
- const exportConversation = Effect.fn("DoJournal.exportConversation")(function* (
748
- conversationId: string,
749
- ) {
750
- return yield* sql
751
- .withTransaction(
752
- Effect.gen(function* () {
753
- const conversationRows = yield* sql<Record<string, unknown>>`
754
- SELECT
755
- conversation_id,
756
- created_at,
757
- tail_sequence,
758
- tail_digest,
759
- producer_epoch
760
- FROM effect_agent_conversations
761
- WHERE conversation_id = ${conversationId}
762
- `.pipe(Effect.mapError(storageError("export conversation")));
763
- const conversation = yield* decodeSingleRow(
764
- Schema.Array(ConversationRow),
765
- "effect_agent_conversations",
766
- conversationId,
767
- conversationRows,
768
- );
769
- yield* failpoint("export:after-conversation-read");
770
- const batchRows = yield* sql<Record<string, unknown>>`
771
- SELECT
772
- conversation_id,
773
- batch_id,
774
- first_sequence,
775
- last_sequence,
776
- batch_digest,
777
- tail_digest,
778
- batch_json
779
- FROM effect_agent_canonical_batches
780
- WHERE conversation_id = ${conversationId}
781
- ORDER BY first_sequence
782
- `.pipe(Effect.mapError(storageError("export canonical batches")));
783
- const recordRows = yield* sql<Record<string, unknown>>`
784
- SELECT
785
- conversation_id,
786
- sequence,
787
- record_id,
788
- batch_id,
789
- record_json
790
- FROM effect_agent_canonical_records
791
- WHERE conversation_id = ${conversationId}
792
- ORDER BY sequence
793
- `.pipe(Effect.mapError(storageError("export canonical records")));
794
- const checkpointRows = yield* sql<Record<string, unknown>>`
795
- SELECT
796
- conversation_id,
797
- through_sequence,
798
- tail_digest,
799
- checkpoint_json
800
- FROM effect_agent_checkpoints
801
- WHERE conversation_id = ${conversationId}
802
- ORDER BY through_sequence
803
- `.pipe(Effect.mapError(storageError("export checkpoints")));
804
-
805
- return RawConversationExport.make({
806
- conversation,
807
- batches: yield* decodeRows(
808
- Schema.Array(BatchRow),
809
- "effect_agent_canonical_batches",
810
- conversationId,
811
- batchRows,
812
- ),
813
- records: yield* decodeRows(
814
- Schema.Array(RecordRow),
815
- "effect_agent_canonical_records",
816
- conversationId,
817
- recordRows,
818
- ),
819
- checkpoints: yield* decodeRows(
820
- Schema.Array(CheckpointRow),
821
- "effect_agent_checkpoints",
822
- conversationId,
823
- checkpointRows,
824
- ),
825
- });
826
- }),
827
- )
828
- .pipe(
829
- Effect.catchTag("SqlError", (error) =>
830
- Effect.fail(storageError("export transaction")(error)),
831
- ),
832
- );
833
- });
834
-
835
- const saveCheckpoint = Effect.fn("DoJournal.saveCheckpoint")(function* (
836
- checkpoint: RawCheckpoint,
837
- ): Effect.fn.Return<void, CheckpointError> {
838
- if (checkpoint.conversationId.length > MAX_IDENTIFIER_LENGTH) {
839
- return yield* DoStorageError.make({
840
- operation: "save checkpoint",
841
- message: "Checkpoint identity exceeds the Durable Object storage bounds.",
842
- });
843
- }
844
- yield* checkValueBound("save checkpoint", checkpoint.checkpointJson);
845
- yield* withWriteTransaction("checkpoint transaction")(
846
- Effect.gen(function* () {
847
- const conversationRows = yield* sql<Record<string, unknown>>`
848
- SELECT
849
- conversation_id,
850
- created_at,
851
- tail_sequence,
852
- tail_digest,
853
- producer_epoch
854
- FROM effect_agent_conversations
855
- WHERE conversation_id = ${checkpoint.conversationId}
856
- `.pipe(Effect.mapError(storageError("read checkpoint tail")));
857
- const conversation = yield* decodeSingleRow(
858
- Schema.Array(ConversationRow),
859
- "effect_agent_conversations",
860
- checkpoint.conversationId,
861
- conversationRows,
862
- );
863
- if (checkpoint.throughSequence > conversation.tail_sequence) {
864
- return yield* DoCheckpointConflict.make({
865
- message:
866
- `Checkpoint sequence ${checkpoint.throughSequence} is after canonical tail ` +
867
- `${conversation.tail_sequence}.`,
868
- });
869
- }
870
-
871
- const checkpointRows = yield* sql<Record<string, unknown>>`
872
- SELECT
873
- conversation_id,
874
- through_sequence,
875
- tail_digest,
876
- checkpoint_json
877
- FROM effect_agent_checkpoints
878
- WHERE conversation_id = ${checkpoint.conversationId}
879
- AND through_sequence = ${checkpoint.throughSequence}
880
- `.pipe(Effect.mapError(storageError("read idempotent checkpoint")));
881
- const existing = yield* decodeRows(
882
- Schema.Array(CheckpointRow),
883
- "effect_agent_checkpoints",
884
- `${checkpoint.conversationId}/${checkpoint.throughSequence}`,
885
- checkpointRows,
886
- );
887
- if (existing.length > 1) {
888
- return yield* DoStorageCorruptionError.make({
889
- table: "effect_agent_checkpoints",
890
- rowKey: `${checkpoint.conversationId}/${checkpoint.throughSequence}`,
891
- message: "A checkpoint primary key returned more than one row.",
892
- });
893
- }
894
- if (existing.length === 1) {
895
- if (
896
- existing[0].tail_digest !== checkpoint.tailDigest ||
897
- existing[0].checkpoint_json !== checkpoint.checkpointJson
898
- ) {
899
- return yield* DoCheckpointConflict.make({
900
- message: "A different checkpoint already exists at this canonical sequence.",
901
- });
902
- }
903
- return;
904
- }
905
-
906
- yield* sql`
907
- INSERT INTO effect_agent_checkpoints (
908
- conversation_id,
909
- through_sequence,
910
- tail_digest,
911
- checkpoint_json
912
- ) VALUES (
913
- ${checkpoint.conversationId},
914
- ${checkpoint.throughSequence},
915
- ${checkpoint.tailDigest},
916
- ${checkpoint.checkpointJson}
917
- )
918
- `.pipe(Effect.mapError(storageError("insert checkpoint")));
919
- }),
920
- );
921
- });
922
-
923
- const loadCheckpoint = Effect.fn("DoJournal.loadCheckpoint")(function* (
924
- conversationId: string,
925
- atOrBeforeSequence: CanonicalSequence,
926
- ) {
927
- const rows = yield* sql<Record<string, unknown>>`
928
- SELECT
929
- conversation_id,
930
- through_sequence,
931
- tail_digest,
932
- checkpoint_json
933
- FROM effect_agent_checkpoints
934
- WHERE conversation_id = ${conversationId}
935
- AND through_sequence <= ${atOrBeforeSequence}
936
- ORDER BY through_sequence DESC
937
- LIMIT 1
938
- `.pipe(Effect.mapError(storageError("load checkpoint")));
939
- return yield* decodeRows(
940
- Schema.Array(CheckpointRow),
941
- "effect_agent_checkpoints",
942
- `${conversationId}<=${atOrBeforeSequence}`,
943
- rows,
944
- );
945
- });
946
-
947
- const getTailDigestAt = Effect.fn("DoJournal.getTailDigestAt")(function* (
948
- conversationId: string,
949
- sequence: CanonicalSequence,
950
- ) {
951
- if (sequence === 0) {
952
- const conversations = yield* getConversation(conversationId);
953
- return conversations.length === 0
954
- ? []
955
- : [conversations[0].tail_sequence === 0 ? conversations[0].tail_digest : undefined].filter(
956
- (value): value is string => value !== undefined,
957
- );
958
- }
959
- const rows = yield* sql<Record<string, unknown>>`
960
- SELECT
961
- conversation_id,
962
- batch_id,
963
- first_sequence,
964
- last_sequence,
965
- batch_digest,
966
- tail_digest,
967
- batch_json
968
- FROM effect_agent_canonical_batches
969
- WHERE conversation_id = ${conversationId}
970
- AND last_sequence = ${sequence}
971
- `.pipe(Effect.mapError(storageError("read canonical digest at sequence")));
972
- const batches = yield* decodeRows(
973
- Schema.Array(BatchRow),
974
- "effect_agent_canonical_batches",
975
- `${conversationId}/${sequence}`,
976
- rows,
977
- );
978
- return batches.map((batch) => batch.tail_digest);
979
- });
980
-
981
- const scanStoredPayloads = Effect.fn("DoJournal.scanStoredPayloads")(function* () {
982
- return yield* sql
983
- .withTransaction(
984
- Effect.gen(function* () {
985
- const conversations = yield* sql<Record<string, unknown>>`
986
- SELECT
987
- conversation_id,
988
- created_at,
989
- tail_sequence,
990
- tail_digest,
991
- producer_epoch
992
- FROM effect_agent_conversations
993
- ORDER BY conversation_id
994
- `.pipe(Effect.mapError(storageError("scan conversations")));
995
- const batches = yield* sql<Record<string, unknown>>`
996
- SELECT
997
- conversation_id,
998
- batch_id,
999
- first_sequence,
1000
- last_sequence,
1001
- batch_digest,
1002
- tail_digest,
1003
- batch_json
1004
- FROM effect_agent_canonical_batches
1005
- ORDER BY conversation_id, first_sequence
1006
- `.pipe(Effect.mapError(storageError("scan canonical batches")));
1007
- const records = yield* sql<Record<string, unknown>>`
1008
- SELECT
1009
- conversation_id,
1010
- sequence,
1011
- record_id,
1012
- batch_id,
1013
- record_json
1014
- FROM effect_agent_canonical_records
1015
- ORDER BY conversation_id, sequence
1016
- `.pipe(Effect.mapError(storageError("scan canonical records")));
1017
- const checkpoints = yield* sql<Record<string, unknown>>`
1018
- SELECT
1019
- conversation_id,
1020
- through_sequence,
1021
- tail_digest,
1022
- checkpoint_json
1023
- FROM effect_agent_checkpoints
1024
- ORDER BY conversation_id, through_sequence
1025
- `.pipe(Effect.mapError(storageError("scan checkpoints")));
1026
- return {
1027
- conversations: yield* decodeRows(
1028
- Schema.Array(ConversationRow),
1029
- "effect_agent_conversations",
1030
- "startup_scan",
1031
- conversations,
1032
- ),
1033
- batches: yield* decodeRows(
1034
- Schema.Array(BatchRow),
1035
- "effect_agent_canonical_batches",
1036
- "startup_scan",
1037
- batches,
1038
- ),
1039
- records: yield* decodeRows(
1040
- Schema.Array(RecordRow),
1041
- "effect_agent_canonical_records",
1042
- "startup_scan",
1043
- records,
1044
- ),
1045
- checkpoints: yield* decodeRows(
1046
- Schema.Array(CheckpointRow),
1047
- "effect_agent_checkpoints",
1048
- "startup_scan",
1049
- checkpoints,
1050
- ),
1051
- };
1052
- }),
1053
- )
1054
- .pipe(
1055
- Effect.catchTag("SqlError", (error) =>
1056
- Effect.fail(storageError("startup scan transaction")(error)),
1057
- ),
1058
- );
1059
- });
1060
-
1061
- return {
1062
- append,
1063
- checkValueBound,
1064
- exportConversation,
1065
- getConversation,
1066
- getTailDigestAt,
1067
- loadCheckpoint,
1068
- materialize,
1069
- read,
1070
- saveCheckpoint,
1071
- scanStoredPayloads,
1072
- withWriteTransaction,
1073
- } as const;
1074
- };
1075
-
1076
- export type DoJournal = ReturnType<typeof makeJournal>;
1077
-
1078
- export const initializeDoJournal = ensureCurrentStorage;