@effect-agent/storage-sqlite 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 (63) hide show
  1. package/dist/SqliteActivityStore.d.mts +14 -0
  2. package/dist/SqliteActivityStore.mjs +310 -0
  3. package/dist/SqliteActivityStore.mjs.map +1 -0
  4. package/dist/SqliteMessageDeliveryStore.d.mts +14 -0
  5. package/dist/SqliteMessageDeliveryStore.mjs +24 -0
  6. package/dist/SqliteMessageDeliveryStore.mjs.map +1 -0
  7. package/dist/SqliteScheduleStore.d.mts +14 -0
  8. package/dist/SqliteScheduleStore.mjs +255 -0
  9. package/dist/SqliteScheduleStore.mjs.map +1 -0
  10. package/dist/SqliteStorageConfig.d.mts +34 -0
  11. package/dist/SqliteStorageConfig.mjs +39 -0
  12. package/dist/SqliteStorageConfig.mjs.map +1 -0
  13. package/dist/SqliteStorageError-DVWeaWLm.d.mts +86 -0
  14. package/dist/SqliteStorageError.d.mts +2 -0
  15. package/dist/SqliteStorageError.mjs +150 -0
  16. package/dist/SqliteStorageError.mjs.map +1 -0
  17. package/dist/SqliteStorageFailpoint.d.mts +17 -0
  18. package/dist/SqliteStorageFailpoint.mjs +14 -0
  19. package/dist/SqliteStorageFailpoint.mjs.map +1 -0
  20. package/dist/SqliteStorageFailpointTesting.d.mts +14 -0
  21. package/dist/SqliteStorageFailpointTesting.mjs +19 -0
  22. package/dist/SqliteStorageFailpointTesting.mjs.map +1 -0
  23. package/dist/SqliteStorageVersion-BBmy73KE.d.mts +11 -0
  24. package/dist/SqliteStorageVersion.d.mts +2 -0
  25. package/dist/SqliteStorageVersion.mjs +8 -0
  26. package/dist/SqliteStorageVersion.mjs.map +1 -0
  27. package/dist/SqliteSubmissionLedger.d.mts +23 -0
  28. package/dist/SqliteSubmissionLedger.mjs +1866 -0
  29. package/dist/SqliteSubmissionLedger.mjs.map +1 -0
  30. package/dist/SqliteSubscriptionStore.d.mts +13 -0
  31. package/dist/SqliteSubscriptionStore.mjs +28 -0
  32. package/dist/SqliteSubscriptionStore.mjs.map +1 -0
  33. package/dist/SqliteThreadStore.d.mts +49 -0
  34. package/dist/SqliteThreadStore.mjs +464 -0
  35. package/dist/SqliteThreadStore.mjs.map +1 -0
  36. package/dist/index.d.mts +11 -193
  37. package/dist/index.mjs +11 -3082
  38. package/dist/migrations-BmgwzT68.mjs +365 -0
  39. package/dist/migrations-BmgwzT68.mjs.map +1 -0
  40. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  41. package/dist/sqlite-journal-DE4MN8uP.mjs +1106 -0
  42. package/dist/sqlite-journal-DE4MN8uP.mjs.map +1 -0
  43. package/package.json +1 -41
  44. package/src/SqliteActivityStore.ts +556 -0
  45. package/src/SqliteMessageDeliveryStore.ts +42 -0
  46. package/src/SqliteScheduleStore.ts +404 -0
  47. package/src/{sqlite-storage-config.ts → SqliteStorageConfig.ts} +1 -1
  48. package/src/{errors.ts → SqliteStorageError.ts} +10 -3
  49. package/src/SqliteStorageFailpoint.ts +23 -0
  50. package/src/{sqlite-storage-failpoint.ts → SqliteStorageFailpointTesting.ts} +7 -18
  51. package/src/SqliteStorageVersion.ts +2 -0
  52. package/src/{sqlite-ledger.ts → SqliteSubmissionLedger.ts} +1105 -240
  53. package/src/SqliteSubscriptionStore.ts +55 -0
  54. package/src/SqliteThreadStore.ts +1004 -0
  55. package/src/index.ts +10 -6
  56. package/src/internal/message-delivery-schema.ts +24 -0
  57. package/src/internal/migrations.ts +373 -0
  58. package/src/internal/recovery-checkpoint-schema.ts +17 -0
  59. package/src/internal/sqlite-journal.ts +1792 -0
  60. package/dist/index.mjs.map +0 -1
  61. package/src/migrations.ts +0 -275
  62. package/src/sqlite-conversation-store.ts +0 -841
  63. package/src/sqlite-journal.ts +0 -1086
@@ -1,1086 +0,0 @@
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
- /**
381
- * Runs a read-only snapshot under a deferred transaction. Effect's SQLite client now
382
- * starts every writable-client `withTransaction` with `BEGIN IMMEDIATE`, which is the
383
- * right default for mutations but would make exports take the write lock and block a
384
- * concurrent append. Reserving the connection and beginning explicitly preserves the
385
- * adapter's snapshot-with-concurrent-writer contract. As with the write helper, a failed
386
- * `BEGIN` is reported directly because there is no transaction to roll back.
387
- */
388
- const withReadTransaction =
389
- (operation: string) =>
390
- <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E | SqliteStorageError> =>
391
- Effect.uninterruptibleMask((restore) =>
392
- Effect.scoped(
393
- Effect.gen(function* () {
394
- const connection = yield* sql.reserve.pipe(Effect.mapError(storageError(operation)));
395
- yield* connection
396
- .executeUnprepared("BEGIN", [], undefined)
397
- .pipe(Effect.mapError(storageError(operation)));
398
- const exit = yield* restore(
399
- Effect.provideService(effect, sql.transactionService, [connection, 0] as const),
400
- ).pipe(Effect.exit);
401
- if (Exit.isSuccess(exit)) {
402
- yield* connection
403
- .executeUnprepared("COMMIT", [], undefined)
404
- .pipe(Effect.mapError(storageError(operation)));
405
- return exit.value;
406
- }
407
- yield* Effect.orDie(connection.executeUnprepared("ROLLBACK", [], undefined));
408
- return yield* exit;
409
- }),
410
- ).pipe(Effect.withSpan("SqliteJournal.withReadTransaction", { attributes: { operation } })),
411
- );
412
-
413
- const materialize = Effect.fn("SqliteJournal.materialize")(function* (
414
- conversationId: string,
415
- createdAt: string,
416
- emptyTailDigest: string,
417
- producerEpoch: ProducerEpoch,
418
- ): Effect.fn.Return<
419
- void,
420
- SqliteFenceRejected | SqliteStorageCorruptionError | SqliteStorageError | SqliteWriteContention
421
- > {
422
- if (
423
- conversationId.length > MAX_IDENTIFIER_LENGTH ||
424
- storedTextBytes(emptyTailDigest) > MAX_STORED_TEXT_BYTES
425
- ) {
426
- return yield* SqliteStorageError.make({
427
- operation: "materialize conversation",
428
- message: "Conversation identity or initial digest exceeds the SQLite storage bounds.",
429
- });
430
- }
431
- yield* withWriteTransaction("materialize transaction")(
432
- Effect.gen(function* () {
433
- const existingRows = yield* sql<Record<string, unknown>>`
434
- SELECT
435
- conversation_id,
436
- created_at,
437
- tail_sequence,
438
- tail_digest,
439
- producer_epoch
440
- FROM effect_agent_conversations
441
- WHERE conversation_id = ${conversationId}
442
- `.pipe(Effect.mapError(storageError("read materialized conversation")));
443
- const existing = yield* decodeRows(
444
- Schema.Array(ConversationRow),
445
- "effect_agent_conversations",
446
- conversationId,
447
- existingRows,
448
- );
449
- if (existing.length > 1) {
450
- return yield* SqliteStorageCorruptionError.make({
451
- table: "effect_agent_conversations",
452
- rowKey: conversationId,
453
- message: "A conversation primary key returned more than one row.",
454
- });
455
- }
456
- if (existing.length === 0) {
457
- yield* sql`
458
- INSERT INTO effect_agent_conversations (
459
- conversation_id,
460
- created_at,
461
- tail_sequence,
462
- tail_digest,
463
- producer_epoch
464
- ) VALUES (
465
- ${conversationId},
466
- ${createdAt},
467
- 0,
468
- ${emptyTailDigest},
469
- ${producerEpoch}
470
- )
471
- `.pipe(Effect.mapError(storageError("materialize conversation")));
472
- return;
473
- }
474
- if (producerEpoch < existing[0].producer_epoch) {
475
- return yield* SqliteFenceRejected.make({
476
- producerEpoch,
477
- actualEpoch: existing[0].producer_epoch,
478
- message: `Producer epoch ${producerEpoch} is stale; current epoch is ${existing[0].producer_epoch}.`,
479
- });
480
- }
481
- if (producerEpoch > existing[0].producer_epoch) {
482
- yield* sql`
483
- UPDATE effect_agent_conversations
484
- SET producer_epoch = ${producerEpoch}
485
- WHERE conversation_id = ${conversationId}
486
- `.pipe(Effect.mapError(storageError("advance materialization epoch")));
487
- }
488
- }),
489
- );
490
- });
491
-
492
- const getConversation = Effect.fn("SqliteJournal.getConversation")(function* (
493
- conversationId: string,
494
- ) {
495
- const rows = yield* sql<Record<string, unknown>>`
496
- SELECT
497
- conversation_id,
498
- created_at,
499
- tail_sequence,
500
- tail_digest,
501
- producer_epoch
502
- FROM effect_agent_conversations
503
- WHERE conversation_id = ${conversationId}
504
- `.pipe(Effect.mapError(storageError("read conversation")));
505
- return yield* decodeRows(
506
- Schema.Array(ConversationRow),
507
- "effect_agent_conversations",
508
- conversationId,
509
- rows,
510
- );
511
- });
512
-
513
- const append = Effect.fn("SqliteJournal.append")(function* (
514
- request: RawAppendRequest,
515
- ): Effect.fn.Return<RawAppendResult, AppendError> {
516
- if (
517
- request.conversationId.length > MAX_IDENTIFIER_LENGTH ||
518
- request.batchId.length > MAX_IDENTIFIER_LENGTH ||
519
- storedTextBytes(request.batchJson) > MAX_STORED_TEXT_BYTES ||
520
- storedTextBytes(request.batchDigest) > MAX_STORED_TEXT_BYTES ||
521
- storedTextBytes(request.tailDigest) > MAX_STORED_TEXT_BYTES ||
522
- request.records.some(
523
- (record) =>
524
- record.recordId.length > MAX_IDENTIFIER_LENGTH ||
525
- storedTextBytes(record.recordJson) > MAX_STORED_TEXT_BYTES,
526
- )
527
- ) {
528
- return yield* SqliteStorageError.make({
529
- operation: "append canonical batch",
530
- message: "Canonical identifiers or encoded JSON exceed the SQLite storage bounds.",
531
- });
532
- }
533
- return yield* withWriteTransaction("append transaction")(
534
- Effect.gen(function* () {
535
- const recordIds = request.records.map((record) => record.recordId);
536
- if (new Set(recordIds).size !== recordIds.length) {
537
- return yield* SqliteAppendConflict.make({
538
- message: `Batch ${request.batchId} contains duplicate canonical record IDs.`,
539
- reason: "record-identity",
540
- });
541
- }
542
-
543
- const conversationRows = yield* sql<Record<string, unknown>>`
544
- SELECT
545
- conversation_id,
546
- created_at,
547
- tail_sequence,
548
- tail_digest,
549
- producer_epoch
550
- FROM effect_agent_conversations
551
- WHERE conversation_id = ${request.conversationId}
552
- `.pipe(Effect.mapError(storageError("read append tail")));
553
- const conversation = yield* decodeSingleRow(
554
- Schema.Array(ConversationRow),
555
- "effect_agent_conversations",
556
- request.conversationId,
557
- conversationRows,
558
- );
559
-
560
- if (request.producerEpoch !== conversation.producer_epoch) {
561
- return yield* SqliteFenceRejected.make({
562
- producerEpoch: request.producerEpoch,
563
- actualEpoch: conversation.producer_epoch,
564
- message: `Producer epoch ${request.producerEpoch} is not the current epoch ${conversation.producer_epoch}.`,
565
- });
566
- }
567
-
568
- const batchRows = yield* sql<Record<string, unknown>>`
569
- SELECT
570
- conversation_id,
571
- batch_id,
572
- first_sequence,
573
- last_sequence,
574
- batch_digest,
575
- tail_digest,
576
- batch_json
577
- FROM effect_agent_canonical_batches
578
- WHERE conversation_id = ${request.conversationId}
579
- AND batch_id = ${request.batchId}
580
- `.pipe(Effect.mapError(storageError("read idempotent batch")));
581
- const batches = yield* decodeRows(
582
- Schema.Array(BatchRow),
583
- "effect_agent_canonical_batches",
584
- `${request.conversationId}/${request.batchId}`,
585
- batchRows,
586
- );
587
-
588
- if (batches.length > 1) {
589
- return yield* SqliteStorageCorruptionError.make({
590
- table: "effect_agent_canonical_batches",
591
- rowKey: `${request.conversationId}/${request.batchId}`,
592
- message: "A canonical batch primary key returned more than one row.",
593
- });
594
- }
595
- if (batches.length === 1) {
596
- const existing = batches[0];
597
- if (existing.batch_digest !== request.batchDigest) {
598
- return yield* SqliteAppendConflict.make({
599
- message: `Batch ${request.batchId} already exists with different canonical content.`,
600
- reason: "batch-digest",
601
- });
602
- }
603
- return RawAppendResult.make({
604
- firstSequence: existing.first_sequence,
605
- lastSequence: existing.last_sequence,
606
- replayed: true,
607
- tailDigest: existing.tail_digest,
608
- });
609
- }
610
-
611
- if (
612
- request.expectedTailSequence !== conversation.tail_sequence ||
613
- request.expectedTailDigest !== conversation.tail_digest
614
- ) {
615
- return yield* SqliteAppendConflict.make({
616
- message:
617
- `Expected tail ${request.expectedTailSequence}/${request.expectedTailDigest} ` +
618
- `but found ${conversation.tail_sequence}/${conversation.tail_digest}.`,
619
- reason: "tail",
620
- actualTailSequence: conversation.tail_sequence,
621
- actualTailDigest: conversation.tail_digest,
622
- });
623
- }
624
- if (conversation.tail_sequence + request.records.length > MAX_RECORDS_PER_CONVERSATION) {
625
- return yield* SqliteStorageError.make({
626
- operation: "append canonical batch",
627
- message: `Conversation record limit ${MAX_RECORDS_PER_CONVERSATION} would be exceeded.`,
628
- });
629
- }
630
-
631
- const existingRecordRows = yield* sql<Record<string, unknown>>`
632
- SELECT
633
- conversation_id,
634
- sequence,
635
- record_id,
636
- batch_id,
637
- record_json
638
- FROM effect_agent_canonical_records
639
- WHERE conversation_id = ${request.conversationId}
640
- AND record_id IN ${sql.in(recordIds)}
641
- ORDER BY sequence
642
- `.pipe(Effect.mapError(storageError("check canonical record identities")));
643
- const existingRecords = yield* decodeRows(
644
- Schema.Array(RecordRow),
645
- "effect_agent_canonical_records",
646
- `${request.conversationId}/record_ids`,
647
- existingRecordRows,
648
- );
649
- if (existingRecords.length > 0) {
650
- return yield* SqliteAppendConflict.make({
651
- message: `Canonical record ID ${existingRecords[0].record_id} already exists.`,
652
- reason: "record-identity",
653
- });
654
- }
655
-
656
- const firstSequence = yield* Schema.decodeUnknownEffect(CanonicalSequence)(
657
- conversation.tail_sequence + 1,
658
- ).pipe(
659
- Effect.mapError((error) =>
660
- SqliteStorageError.make({
661
- cause: error,
662
- operation: "append canonical batch",
663
- message: error.message,
664
- }),
665
- ),
666
- );
667
- const lastSequence = yield* Schema.decodeUnknownEffect(CanonicalSequence)(
668
- firstSequence + request.records.length - 1,
669
- ).pipe(
670
- Effect.mapError((error) =>
671
- SqliteStorageError.make({
672
- cause: error,
673
- operation: "append canonical batch",
674
- message: error.message,
675
- }),
676
- ),
677
- );
678
-
679
- yield* sql`
680
- INSERT INTO effect_agent_canonical_batches (
681
- conversation_id,
682
- batch_id,
683
- first_sequence,
684
- last_sequence,
685
- batch_digest,
686
- tail_digest,
687
- batch_json
688
- ) VALUES (
689
- ${request.conversationId},
690
- ${request.batchId},
691
- ${firstSequence},
692
- ${lastSequence},
693
- ${request.batchDigest},
694
- ${request.tailDigest},
695
- ${request.batchJson}
696
- )
697
- `.pipe(Effect.mapError(storageError("insert canonical batch")));
698
- yield* failpoint("append:after-batch-insert");
699
-
700
- yield* Effect.forEach(
701
- request.records,
702
- (record, index) =>
703
- Effect.gen(function* () {
704
- yield* sql`
705
- INSERT INTO effect_agent_canonical_records (
706
- conversation_id,
707
- sequence,
708
- record_id,
709
- batch_id,
710
- record_json
711
- ) VALUES (
712
- ${request.conversationId},
713
- ${firstSequence + index},
714
- ${record.recordId},
715
- ${request.batchId},
716
- ${record.recordJson}
717
- )
718
- `.pipe(Effect.mapError(storageError("insert canonical record")));
719
- yield* failpoint("append:after-record-insert");
720
- }),
721
- { discard: true },
722
- );
723
-
724
- yield* sql`
725
- UPDATE effect_agent_conversations
726
- SET
727
- tail_sequence = ${lastSequence},
728
- tail_digest = ${request.tailDigest},
729
- producer_epoch = ${request.producerEpoch}
730
- WHERE conversation_id = ${request.conversationId}
731
- `.pipe(Effect.mapError(storageError("advance conversation tail")));
732
- yield* failpoint("append:after-tail-update");
733
-
734
- return RawAppendResult.make({
735
- firstSequence,
736
- lastSequence,
737
- replayed: false,
738
- tailDigest: request.tailDigest,
739
- });
740
- }),
741
- );
742
- });
743
-
744
- const read = Effect.fn("SqliteJournal.read")(function* (request: RawReadRequest) {
745
- const rows = yield* sql<Record<string, unknown>>`
746
- SELECT
747
- conversation_id,
748
- sequence,
749
- record_id,
750
- batch_id,
751
- record_json
752
- FROM effect_agent_canonical_records
753
- WHERE conversation_id = ${request.conversationId}
754
- AND sequence > ${request.fromSequenceExclusive}
755
- ORDER BY sequence
756
- LIMIT ${request.limit}
757
- `.pipe(Effect.mapError(storageError("read canonical records")));
758
- return yield* decodeRows(
759
- Schema.Array(RecordRow),
760
- "effect_agent_canonical_records",
761
- `${request.conversationId}>${request.fromSequenceExclusive}`,
762
- rows,
763
- );
764
- });
765
-
766
- const exportConversation = Effect.fn("SqliteJournal.exportConversation")(function* (
767
- conversationId: string,
768
- ) {
769
- return yield* withReadTransaction("export transaction")(
770
- Effect.gen(function* () {
771
- const conversationRows = yield* sql<Record<string, unknown>>`
772
- SELECT
773
- conversation_id,
774
- created_at,
775
- tail_sequence,
776
- tail_digest,
777
- producer_epoch
778
- FROM effect_agent_conversations
779
- WHERE conversation_id = ${conversationId}
780
- `.pipe(Effect.mapError(storageError("export conversation")));
781
- const conversation = yield* decodeSingleRow(
782
- Schema.Array(ConversationRow),
783
- "effect_agent_conversations",
784
- conversationId,
785
- conversationRows,
786
- );
787
- yield* failpoint("export:after-conversation-read");
788
- const batchRows = yield* sql<Record<string, unknown>>`
789
- SELECT
790
- conversation_id,
791
- batch_id,
792
- first_sequence,
793
- last_sequence,
794
- batch_digest,
795
- tail_digest,
796
- batch_json
797
- FROM effect_agent_canonical_batches
798
- WHERE conversation_id = ${conversationId}
799
- ORDER BY first_sequence
800
- `.pipe(Effect.mapError(storageError("export canonical batches")));
801
- const recordRows = yield* sql<Record<string, unknown>>`
802
- SELECT
803
- conversation_id,
804
- sequence,
805
- record_id,
806
- batch_id,
807
- record_json
808
- FROM effect_agent_canonical_records
809
- WHERE conversation_id = ${conversationId}
810
- ORDER BY sequence
811
- `.pipe(Effect.mapError(storageError("export canonical records")));
812
- const checkpointRows = yield* sql<Record<string, unknown>>`
813
- SELECT
814
- conversation_id,
815
- through_sequence,
816
- tail_digest,
817
- checkpoint_json
818
- FROM effect_agent_checkpoints
819
- WHERE conversation_id = ${conversationId}
820
- ORDER BY through_sequence
821
- `.pipe(Effect.mapError(storageError("export checkpoints")));
822
-
823
- return RawConversationExport.make({
824
- conversation,
825
- batches: yield* decodeRows(
826
- Schema.Array(BatchRow),
827
- "effect_agent_canonical_batches",
828
- conversationId,
829
- batchRows,
830
- ),
831
- records: yield* decodeRows(
832
- Schema.Array(RecordRow),
833
- "effect_agent_canonical_records",
834
- conversationId,
835
- recordRows,
836
- ),
837
- checkpoints: yield* decodeRows(
838
- Schema.Array(CheckpointRow),
839
- "effect_agent_checkpoints",
840
- conversationId,
841
- checkpointRows,
842
- ),
843
- });
844
- }),
845
- );
846
- });
847
-
848
- const saveCheckpoint = Effect.fn("SqliteJournal.saveCheckpoint")(function* (
849
- checkpoint: RawCheckpoint,
850
- ): Effect.fn.Return<void, CheckpointError> {
851
- if (
852
- checkpoint.conversationId.length > MAX_IDENTIFIER_LENGTH ||
853
- storedTextBytes(checkpoint.checkpointJson) > MAX_STORED_TEXT_BYTES
854
- ) {
855
- return yield* SqliteStorageError.make({
856
- operation: "save checkpoint",
857
- message: "Checkpoint identity or encoded JSON exceeds the SQLite storage bounds.",
858
- });
859
- }
860
- yield* withWriteTransaction("checkpoint transaction")(
861
- Effect.gen(function* () {
862
- const conversationRows = yield* sql<Record<string, unknown>>`
863
- SELECT
864
- conversation_id,
865
- created_at,
866
- tail_sequence,
867
- tail_digest,
868
- producer_epoch
869
- FROM effect_agent_conversations
870
- WHERE conversation_id = ${checkpoint.conversationId}
871
- `.pipe(Effect.mapError(storageError("read checkpoint tail")));
872
- const conversation = yield* decodeSingleRow(
873
- Schema.Array(ConversationRow),
874
- "effect_agent_conversations",
875
- checkpoint.conversationId,
876
- conversationRows,
877
- );
878
- if (checkpoint.throughSequence > conversation.tail_sequence) {
879
- return yield* SqliteCheckpointConflict.make({
880
- message:
881
- `Checkpoint sequence ${checkpoint.throughSequence} is after canonical tail ` +
882
- `${conversation.tail_sequence}.`,
883
- });
884
- }
885
-
886
- const checkpointRows = yield* sql<Record<string, unknown>>`
887
- SELECT
888
- conversation_id,
889
- through_sequence,
890
- tail_digest,
891
- checkpoint_json
892
- FROM effect_agent_checkpoints
893
- WHERE conversation_id = ${checkpoint.conversationId}
894
- AND through_sequence = ${checkpoint.throughSequence}
895
- `.pipe(Effect.mapError(storageError("read idempotent checkpoint")));
896
- const existing = yield* decodeRows(
897
- Schema.Array(CheckpointRow),
898
- "effect_agent_checkpoints",
899
- `${checkpoint.conversationId}/${checkpoint.throughSequence}`,
900
- checkpointRows,
901
- );
902
- if (existing.length > 1) {
903
- return yield* SqliteStorageCorruptionError.make({
904
- table: "effect_agent_checkpoints",
905
- rowKey: `${checkpoint.conversationId}/${checkpoint.throughSequence}`,
906
- message: "A checkpoint primary key returned more than one row.",
907
- });
908
- }
909
- if (existing.length === 1) {
910
- if (
911
- existing[0].tail_digest !== checkpoint.tailDigest ||
912
- existing[0].checkpoint_json !== checkpoint.checkpointJson
913
- ) {
914
- return yield* SqliteCheckpointConflict.make({
915
- message: "A different checkpoint already exists at this canonical sequence.",
916
- });
917
- }
918
- return;
919
- }
920
-
921
- yield* sql`
922
- INSERT INTO effect_agent_checkpoints (
923
- conversation_id,
924
- through_sequence,
925
- tail_digest,
926
- checkpoint_json
927
- ) VALUES (
928
- ${checkpoint.conversationId},
929
- ${checkpoint.throughSequence},
930
- ${checkpoint.tailDigest},
931
- ${checkpoint.checkpointJson}
932
- )
933
- `.pipe(Effect.mapError(storageError("insert checkpoint")));
934
- }),
935
- );
936
- });
937
-
938
- const loadCheckpoint = Effect.fn("SqliteJournal.loadCheckpoint")(function* (
939
- conversationId: string,
940
- atOrBeforeSequence: CanonicalSequence,
941
- ) {
942
- const rows = yield* sql<Record<string, unknown>>`
943
- SELECT
944
- conversation_id,
945
- through_sequence,
946
- tail_digest,
947
- checkpoint_json
948
- FROM effect_agent_checkpoints
949
- WHERE conversation_id = ${conversationId}
950
- AND through_sequence <= ${atOrBeforeSequence}
951
- ORDER BY through_sequence DESC
952
- LIMIT 1
953
- `.pipe(Effect.mapError(storageError("load checkpoint")));
954
- return yield* decodeRows(
955
- Schema.Array(CheckpointRow),
956
- "effect_agent_checkpoints",
957
- `${conversationId}<=${atOrBeforeSequence}`,
958
- rows,
959
- );
960
- });
961
-
962
- const getTailDigestAt = Effect.fn("SqliteJournal.getTailDigestAt")(function* (
963
- conversationId: string,
964
- sequence: CanonicalSequence,
965
- ) {
966
- if (sequence === 0) {
967
- const conversations = yield* getConversation(conversationId);
968
- return conversations.length === 0
969
- ? []
970
- : [conversations[0].tail_sequence === 0 ? conversations[0].tail_digest : undefined].filter(
971
- (value): value is string => value !== undefined,
972
- );
973
- }
974
- const rows = yield* sql<Record<string, unknown>>`
975
- SELECT
976
- conversation_id,
977
- batch_id,
978
- first_sequence,
979
- last_sequence,
980
- batch_digest,
981
- tail_digest,
982
- batch_json
983
- FROM effect_agent_canonical_batches
984
- WHERE conversation_id = ${conversationId}
985
- AND last_sequence = ${sequence}
986
- `.pipe(Effect.mapError(storageError("read canonical digest at sequence")));
987
- const batches = yield* decodeRows(
988
- Schema.Array(BatchRow),
989
- "effect_agent_canonical_batches",
990
- `${conversationId}/${sequence}`,
991
- rows,
992
- );
993
- return batches.map((batch) => batch.tail_digest);
994
- });
995
-
996
- const scanStoredPayloads = Effect.fn("SqliteJournal.scanStoredPayloads")(function* () {
997
- return yield* withReadTransaction("startup scan transaction")(
998
- Effect.gen(function* () {
999
- const conversations = yield* sql<Record<string, unknown>>`
1000
- SELECT
1001
- conversation_id,
1002
- created_at,
1003
- tail_sequence,
1004
- tail_digest,
1005
- producer_epoch
1006
- FROM effect_agent_conversations
1007
- ORDER BY conversation_id
1008
- `.pipe(Effect.mapError(storageError("scan conversations")));
1009
- const batches = yield* sql<Record<string, unknown>>`
1010
- SELECT
1011
- conversation_id,
1012
- batch_id,
1013
- first_sequence,
1014
- last_sequence,
1015
- batch_digest,
1016
- tail_digest,
1017
- batch_json
1018
- FROM effect_agent_canonical_batches
1019
- ORDER BY conversation_id, first_sequence
1020
- `.pipe(Effect.mapError(storageError("scan canonical batches")));
1021
- const records = yield* sql<Record<string, unknown>>`
1022
- SELECT
1023
- conversation_id,
1024
- sequence,
1025
- record_id,
1026
- batch_id,
1027
- record_json
1028
- FROM effect_agent_canonical_records
1029
- ORDER BY conversation_id, sequence
1030
- `.pipe(Effect.mapError(storageError("scan canonical records")));
1031
- const checkpoints = yield* sql<Record<string, unknown>>`
1032
- SELECT
1033
- conversation_id,
1034
- through_sequence,
1035
- tail_digest,
1036
- checkpoint_json
1037
- FROM effect_agent_checkpoints
1038
- ORDER BY conversation_id, through_sequence
1039
- `.pipe(Effect.mapError(storageError("scan checkpoints")));
1040
- return {
1041
- conversations: yield* decodeRows(
1042
- Schema.Array(ConversationRow),
1043
- "effect_agent_conversations",
1044
- "startup_scan",
1045
- conversations,
1046
- ),
1047
- batches: yield* decodeRows(
1048
- Schema.Array(BatchRow),
1049
- "effect_agent_canonical_batches",
1050
- "startup_scan",
1051
- batches,
1052
- ),
1053
- records: yield* decodeRows(
1054
- Schema.Array(RecordRow),
1055
- "effect_agent_canonical_records",
1056
- "startup_scan",
1057
- records,
1058
- ),
1059
- checkpoints: yield* decodeRows(
1060
- Schema.Array(CheckpointRow),
1061
- "effect_agent_checkpoints",
1062
- "startup_scan",
1063
- checkpoints,
1064
- ),
1065
- };
1066
- }),
1067
- );
1068
- });
1069
-
1070
- return {
1071
- append,
1072
- exportConversation,
1073
- getConversation,
1074
- getTailDigestAt,
1075
- loadCheckpoint,
1076
- materialize,
1077
- read,
1078
- saveCheckpoint,
1079
- scanStoredPayloads,
1080
- withWriteTransaction,
1081
- } as const;
1082
- };
1083
-
1084
- export type SqliteJournal = ReturnType<typeof makeJournal>;
1085
-
1086
- export const initializeSqliteJournal = ensureCurrentStorage;