@effect-agent/storage-sqlite 0.0.1-beta.0

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