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