@effect-agent/storage-sqlite 0.1.0-beta.63 → 0.1.0-beta.65

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.
@@ -28,6 +28,9 @@ import {
28
28
  FencedAppendRequest,
29
29
  LoadCheckpointRequest,
30
30
  SaveCheckpointRequest,
31
+ SaveRecoveryCheckpointRequest,
32
+ MAX_THREAD_EXPORT_RECORDS,
33
+ type ThreadRecoveryCheckpoints,
31
34
  } from "@effect-agent/thread/ThreadStore";
32
35
  import { NodeCrypto } from "@effect/platform-node";
33
36
  import { SqliteClient } from "@effect/sql-sqlite-node";
@@ -292,9 +295,9 @@ const groupByKey = <A>(
292
295
  };
293
296
 
294
297
  /**
295
- * Opt-in full integrity audit (`verifyOnOpen`). Every stored payload is decoded, re-encoded,
296
- * and re-digested against the canonical chain. Routine opens skip this scan: per-operation
297
- * Schema decoding plus the digest chain already fail clearly on corrupt rows.
298
+ * Opt-in integrity audit (`verifyOnOpen`) of canonical payloads, their digest chains and generic
299
+ * projection checkpoints. Disposable recovery checkpoints are validated when loaded. Routine opens
300
+ * skip this scan: per-operation Schema decoding fails clearly on corrupt canonical rows.
298
301
  */
299
302
  const decodeStartupPayloads = Effect.fn("SqliteThreadStore.decodeStartupPayloads")(function* (
300
303
  journal: SqliteJournal,
@@ -687,7 +690,7 @@ const makeServices = Effect.fn("SqliteThreadStore.makeServices")(function* () {
687
690
 
688
691
  const records = yield* Effect.forEach(exported.records, decodeEnvelope);
689
692
 
690
- if (records.length > 65_536) {
693
+ if (records.length > MAX_THREAD_EXPORT_RECORDS) {
691
694
  return yield* ThreadStoreError.make({
692
695
  operation: "decode thread export",
693
696
  message: "The thread exceeds the current export record limit.",
@@ -831,6 +834,92 @@ const makeServices = Effect.fn("SqliteThreadStore.makeServices")(function* () {
831
834
  },
832
835
  );
833
836
 
837
+ const saveRecoveryCheckpoint: ThreadRecoveryCheckpoints["save"] = Effect.fn(
838
+ "SqliteThreadStore.saveRecoveryCheckpoint",
839
+ )(function* (request) {
840
+ const validated = yield* Schema.decodeUnknownEffect(
841
+ Schema.toType(SaveRecoveryCheckpointRequest),
842
+ )(request).pipe(
843
+ Effect.mapError((error) => schemaStoreError("validate recovery checkpoint", error)),
844
+ );
845
+
846
+ const checkpointJson = yield* encodeCheckpoint(validated.checkpoint);
847
+
848
+ yield* journal
849
+ .saveRecoveryCheckpoint(validated, checkpointJson)
850
+ .pipe(
851
+ Effect.mapError((error) =>
852
+ error._tag === "CheckpointRejected" ||
853
+ error._tag === "FenceRejected" ||
854
+ error._tag === "ThreadNotMaterialized"
855
+ ? error
856
+ : storeError("save recovery checkpoint", error),
857
+ ),
858
+ );
859
+ });
860
+
861
+ const loadRecoveryCheckpoint: ThreadRecoveryCheckpoints["load"] = Effect.fn(
862
+ "SqliteThreadStore.loadRecoveryCheckpoint",
863
+ )(function* (request) {
864
+ const validated = yield* Schema.decodeUnknownEffect(Schema.toType(LoadCheckpointRequest))(
865
+ request,
866
+ ).pipe(
867
+ Effect.mapError((error) => schemaStoreError("validate recovery checkpoint lookup", error)),
868
+ );
869
+
870
+ const thread = yield* requireThread(journal, validated.threadId);
871
+
872
+ const corrupt = () =>
873
+ CheckpointRejected.make({ threadId: validated.threadId, reason: "corrupt" });
874
+
875
+ const rows = yield* journal
876
+ .loadRecoveryCheckpoint(validated.threadId)
877
+ .pipe(
878
+ Effect.mapError((error) =>
879
+ error._tag === "SqliteStorageCorruptionError"
880
+ ? corrupt()
881
+ : storeError("load recovery checkpoint", error),
882
+ ),
883
+ );
884
+
885
+ if (rows.length === 0) return Option.none();
886
+ if (rows.length !== 1) return yield* corrupt();
887
+ const row = rows[0];
888
+
889
+ const checkpoint = yield* Schema.decodeEffect(Schema.fromJsonString(ThreadCheckpoint))(
890
+ row.checkpoint_json,
891
+ ).pipe(Effect.mapError(corrupt));
892
+
893
+ if (
894
+ row.thread_id !== validated.threadId ||
895
+ checkpoint.threadId !== row.thread_id ||
896
+ checkpoint.throughSequence !== row.through_sequence ||
897
+ checkpoint.tailDigest !== row.tail_digest
898
+ )
899
+ return yield* corrupt();
900
+ if (checkpoint.throughSequence > thread.tail_sequence)
901
+ return yield* CheckpointRejected.make({
902
+ threadId: validated.threadId,
903
+ reason: "ahead-of-tail",
904
+ });
905
+ if (checkpoint.throughSequence > (validated.atOrBeforeSequence ?? thread.tail_sequence))
906
+ return Option.none();
907
+
908
+ const canonicalDigest = yield* tailDigestAt(
909
+ journal,
910
+ checkpoint.threadId,
911
+ checkpoint.throughSequence,
912
+ );
913
+
914
+ if (canonicalDigest !== checkpoint.tailDigest)
915
+ return yield* CheckpointRejected.make({
916
+ threadId: validated.threadId,
917
+ reason: "digest-mismatch",
918
+ });
919
+
920
+ return Option.some(checkpoint);
921
+ });
922
+
834
923
  const threadStore = ThreadStore.of({
835
924
  append,
836
925
  export: exportThread,
@@ -839,6 +928,7 @@ const makeServices = Effect.fn("SqliteThreadStore.makeServices")(function* () {
839
928
  observe,
840
929
  read,
841
930
  checkpoints: { save: saveCheckpoint, load: loadCheckpoint },
931
+ recoveryCheckpoints: { save: saveRecoveryCheckpoint, load: loadRecoveryCheckpoint },
842
932
  });
843
933
 
844
934
  return Context.make(ThreadStore, threadStore);
@@ -3,8 +3,9 @@ import { Effect } from "effect";
3
3
  import * as SqlClient from "effect/unstable/sql/SqlClient";
4
4
 
5
5
  import { createMessageDeliveryTables } from "./message-delivery-schema.ts";
6
+ import { createRecoveryCheckpointTable } from "./recovery-checkpoint-schema.ts";
6
7
 
7
- export const CurrentSqliteStorageVersion = 9;
8
+ export const CurrentSqliteStorageVersion = 10;
8
9
 
9
10
  /** Initialize empty storage with the complete current schema. */
10
11
  export const sqliteMigrations = SqliteMigrator.fromRecord({
@@ -336,6 +337,7 @@ export const sqliteMigrations = SqliteMigrator.fromRecord({
336
337
  yield* sql`CREATE INDEX effect_agent_subscription_deliveries_registration ON effect_agent_subscription_deliveries (tenant_id, source_address, owner_id, subscription_id, delivery_key)`
337
338
  .withoutTransform;
338
339
  yield* createMessageDeliveryTables;
339
- yield* sql`PRAGMA user_version = 9`.withoutTransform;
340
+ yield* createRecoveryCheckpointTable;
341
+ yield* sql`PRAGMA user_version = 10`.withoutTransform;
340
342
  }),
341
343
  });
@@ -0,0 +1,17 @@
1
+ import { Effect } from "effect";
2
+ import * as SqlClient from "effect/unstable/sql/SqlClient";
3
+
4
+ /** One disposable recovery snapshot per Thread, independent of generic projections. */
5
+ export const createRecoveryCheckpointTable = Effect.gen(function* () {
6
+ const sql = yield* SqlClient.SqlClient;
7
+
8
+ yield* sql`
9
+ CREATE TABLE effect_agent_recovery_checkpoints (
10
+ thread_id TEXT PRIMARY KEY NOT NULL,
11
+ through_sequence INTEGER NOT NULL,
12
+ tail_digest TEXT NOT NULL,
13
+ checkpoint_json TEXT NOT NULL,
14
+ FOREIGN KEY (thread_id) REFERENCES effect_agent_threads(thread_id) ON DELETE RESTRICT
15
+ )
16
+ `.withoutTransform;
17
+ });
@@ -1,3 +1,4 @@
1
+ import { EMPTY_TAIL_DIGEST } from "@effect-agent/thread/Digest";
1
2
  import { CanonicalSequence, ProducerEpoch } from "@effect-agent/thread/Records";
2
3
  import { ScheduleFailpoint, ScheduleFailpointError } from "@effect-agent/thread/Schedule";
3
4
  import {
@@ -9,6 +10,13 @@ import {
9
10
  SubscriptionFailpoint,
10
11
  SubscriptionFailpointError,
11
12
  } from "@effect-agent/thread/Subscription";
13
+ import {
14
+ MAX_THREAD_EXPORT_RECORDS,
15
+ CheckpointRejected,
16
+ FenceRejected,
17
+ ThreadNotMaterialized,
18
+ type SaveRecoveryCheckpointRequest,
19
+ } from "@effect-agent/thread/ThreadStore";
12
20
  import { NodeCrypto } from "@effect/platform-node";
13
21
  import { SqliteMigrator } from "@effect/sql-sqlite-node";
14
22
  import { Effect, Exit, Schema } from "effect";
@@ -30,11 +38,13 @@ import {
30
38
  import { SqliteStorageFailpoint } from "../SqliteStorageFailpoint.ts";
31
39
  import { createMessageDeliveryTables } from "./message-delivery-schema.ts";
32
40
  import { CurrentSqliteStorageVersion, sqliteMigrations } from "./migrations.ts";
41
+ import { createRecoveryCheckpointTable } from "./recovery-checkpoint-schema.ts";
33
42
 
34
43
  const BoundedStoredText = Schema.String.check(Schema.isMaxLength(16 * 1024 * 1024));
35
44
  const BoundedIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(1024));
36
45
  const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
37
- const MAX_RECORDS_PER_THREAD = 65_536;
46
+ const MAX_RECORDS_PER_THREAD = MAX_THREAD_EXPORT_RECORDS;
47
+ const ZERO_SEQUENCE = Schema.decodeSync(CanonicalSequence)(0);
38
48
  const MAX_STORED_TEXT_BYTES = 16 * 1024 * 1024;
39
49
  const MAX_IDENTIFIER_LENGTH = 1_024;
40
50
 
@@ -370,10 +380,32 @@ const predecessorColumns = {
370
380
  ],
371
381
  } as const;
372
382
 
373
- const checkPredecessorLayout = Effect.fn("SqliteJournal.checkPredecessorLayout")(function* () {
383
+ const checkPredecessorLayout = Effect.fn("SqliteJournal.checkPredecessorLayout")(function* (
384
+ version: 8 | 9,
385
+ ) {
374
386
  const sql = yield* SqlClient.SqlClient;
375
387
 
376
- for (const [table, expected] of Object.entries(predecessorColumns)) {
388
+ const expectedColumns =
389
+ version === 8
390
+ ? predecessorColumns
391
+ : {
392
+ ...predecessorColumns,
393
+ effect_agent_submissions: [
394
+ ...predecessorColumns.effect_agent_submissions,
395
+ "worker_admission_json",
396
+ "message_admission_json",
397
+ ],
398
+ effect_agent_message_deliveries: [
399
+ "owner_thread_id",
400
+ "message_id",
401
+ "version",
402
+ "state",
403
+ "deadline_at_millis",
404
+ "record_json",
405
+ ],
406
+ };
407
+
408
+ for (const [table, expected] of Object.entries(expectedColumns)) {
377
409
  const columns = yield* decodeRows(
378
410
  Schema.Array(Schema.Struct({ name: BoundedIdentifier })),
379
411
  table,
@@ -385,9 +417,9 @@ const checkPredecessorLayout = Effect.fn("SqliteJournal.checkPredecessorLayout")
385
417
 
386
418
  if (columns.length !== names.size || columns.some((column) => !names.has(column.name)))
387
419
  return yield* SqliteStorageCompatibilityError.make({
388
- actualVersion: 8,
420
+ actualVersion: version,
389
421
  supportedVersion: CurrentSqliteStorageVersion,
390
- message: `The v8 ${table} columns do not match the supported predecessor; no upgrade was committed.`,
422
+ message: `The v${version} ${table} columns do not match the supported predecessor; no upgrade was committed.`,
391
423
  });
392
424
  }
393
425
  });
@@ -439,6 +471,7 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
439
471
  version.user_version !== 0 &&
440
472
  version.user_version !== 7 &&
441
473
  version.user_version !== 8 &&
474
+ version.user_version !== 9 &&
442
475
  version.user_version !== CurrentSqliteStorageVersion
443
476
  ) {
444
477
  return yield* SqliteStorageCompatibilityError.make({
@@ -447,11 +480,11 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
447
480
  message:
448
481
  `The SQLite file uses unsupported storage version ${version.user_version}; ` +
449
482
  `this build supports exactly version ${CurrentSqliteStorageVersion}. ` +
450
- "Only supported v7 and v8 can be upgraded automatically. Keep the original file and use a compatible library version.",
483
+ "Only supported v7, v8 and v9 can be upgraded automatically. Keep the original file and use a compatible library version.",
451
484
  });
452
485
  }
453
486
 
454
- if (version.user_version === 7 || version.user_version === 8) {
487
+ if (version.user_version === 7 || version.user_version === 8 || version.user_version === 9) {
455
488
  yield* sql
456
489
  .withTransaction(
457
490
  Effect.gen(function* () {
@@ -461,7 +494,9 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
461
494
  return;
462
495
  if (
463
496
  current.length !== 1 ||
464
- (current[0].user_version !== 7 && current[0].user_version !== 8)
497
+ (current[0].user_version !== 7 &&
498
+ current[0].user_version !== 8 &&
499
+ current[0].user_version !== 9)
465
500
  )
466
501
  return yield* SqliteStorageCompatibilityError.make({
467
502
  actualVersion: -1,
@@ -486,6 +521,16 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
486
521
  "The predecessor store is missing required tables; no upgrade was committed.",
487
522
  });
488
523
 
524
+ const recoveryTables =
525
+ yield* sql`SELECT name FROM sqlite_master WHERE type='table' AND name='effect_agent_recovery_checkpoints'`;
526
+
527
+ if (recoveryTables.length !== 0)
528
+ return yield* SqliteStorageCompatibilityError.make({
529
+ actualVersion: current[0].user_version,
530
+ supportedVersion: CurrentSqliteStorageVersion,
531
+ message:
532
+ "The predecessor already contains recovery checkpoint storage; refusing ambiguous data without mutation.",
533
+ });
489
534
  if (current[0].user_version === 7) {
490
535
  yield* checkV2ThreadLayout();
491
536
  for (const statement of [
@@ -516,18 +561,24 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
516
561
  }),
517
562
  );
518
563
  }
519
- if (current[0].user_version === 8) yield* checkPredecessorLayout();
520
- yield* failpoint("upgrade:before-mutation");
521
- yield* sql`ALTER TABLE effect_agent_submissions ADD COLUMN worker_admission_json TEXT`;
522
- yield* failpoint("upgrade:after-mutation");
523
- yield* failpoint("upgrade:before-mutation");
524
- yield* sql`ALTER TABLE effect_agent_submissions ADD COLUMN message_admission_json TEXT`;
525
- yield* failpoint("upgrade:after-mutation");
564
+ if (current[0].user_version === 8 || current[0].user_version === 9)
565
+ yield* checkPredecessorLayout(current[0].user_version);
566
+ if (current[0].user_version !== 9) {
567
+ yield* failpoint("upgrade:before-mutation");
568
+ yield* sql`ALTER TABLE effect_agent_submissions ADD COLUMN worker_admission_json TEXT`;
569
+ yield* failpoint("upgrade:after-mutation");
570
+ yield* failpoint("upgrade:before-mutation");
571
+ yield* sql`ALTER TABLE effect_agent_submissions ADD COLUMN message_admission_json TEXT`;
572
+ yield* failpoint("upgrade:after-mutation");
573
+ yield* failpoint("upgrade:before-mutation");
574
+ yield* createMessageDeliveryTables;
575
+ yield* failpoint("upgrade:after-mutation");
576
+ }
526
577
  yield* failpoint("upgrade:before-mutation");
527
- yield* createMessageDeliveryTables;
578
+ yield* createRecoveryCheckpointTable;
528
579
  yield* failpoint("upgrade:after-mutation");
529
580
  yield* failpoint("upgrade:before-version");
530
- yield* sql`PRAGMA user_version = 9`;
581
+ yield* sql`PRAGMA user_version = 10`;
531
582
  yield* failpoint("upgrade:after-version");
532
583
  }),
533
584
  )
@@ -618,7 +669,8 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
618
669
  'effect_agent_approval_decisions',
619
670
  'effect_agent_unknown_resolutions',
620
671
  'effect_agent_schedules',
621
- 'effect_agent_message_deliveries'
672
+ 'effect_agent_message_deliveries',
673
+ 'effect_agent_recovery_checkpoints'
622
674
  )
623
675
  ORDER BY name
624
676
  `.pipe(Effect.mapError(storageError("verify storage tables")));
@@ -630,7 +682,7 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
630
682
  requiredRows,
631
683
  );
632
684
 
633
- if (required.length !== 13) {
685
+ if (required.length !== 14) {
634
686
  return yield* SqliteStorageCompatibilityError.make({
635
687
  actualVersion: CurrentSqliteStorageVersion,
636
688
  supportedVersion: CurrentSqliteStorageVersion,
@@ -1119,27 +1171,52 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
1119
1171
 
1120
1172
  yield* failpoint("export:after-thread-read");
1121
1173
 
1122
- const recordRows = yield* sql<Record<string, unknown>>`
1123
- SELECT
1124
- thread_id,
1125
- sequence,
1126
- record_id,
1127
- batch_id,
1128
- record_json
1129
- FROM effect_agent_canonical_records
1130
- WHERE thread_id = ${threadId}
1131
- ORDER BY sequence
1132
- `.pipe(Effect.mapError(storageError("export canonical records")));
1174
+ if (thread.tail_sequence > MAX_RECORDS_PER_THREAD)
1175
+ return yield* SqliteStorageError.make({
1176
+ operation: "export thread",
1177
+ message: "The thread exceeds the current export record limit.",
1178
+ });
1179
+ const records: Array<RecordRow> = [];
1180
+ let afterSequence = ZERO_SEQUENCE;
1133
1181
 
1134
- return RawThreadExport.make({
1135
- thread,
1136
- records: yield* decodeRows(
1137
- Schema.Array(RecordRow),
1138
- "effect_agent_canonical_records",
1182
+ while (afterSequence < thread.tail_sequence) {
1183
+ const limit = Math.min(1_024, thread.tail_sequence - afterSequence);
1184
+
1185
+ const request = RawReadRequest.make({
1139
1186
  threadId,
1140
- recordRows,
1141
- ),
1142
- });
1187
+ fromSequenceExclusive: afterSequence,
1188
+ limit,
1189
+ });
1190
+
1191
+ const page = yield* read(request);
1192
+
1193
+ if (
1194
+ page.length !== limit ||
1195
+ page.some((record, index) => record.sequence !== afterSequence + index + 1)
1196
+ ) {
1197
+ return yield* SqliteStorageCorruptionError.make({
1198
+ table: "effect_agent_canonical_records",
1199
+ rowKey: threadId,
1200
+ message: "The exported canonical prefix is not contiguous through its captured tail.",
1201
+ });
1202
+ }
1203
+ records.push(...page);
1204
+ afterSequence = page[page.length - 1].sequence;
1205
+ }
1206
+
1207
+ const beyondTail =
1208
+ yield* sql`SELECT sequence FROM effect_agent_canonical_records WHERE thread_id=${threadId} AND sequence > ${thread.tail_sequence} LIMIT 1`.pipe(
1209
+ Effect.mapError(storageError("verify export tail")),
1210
+ );
1211
+
1212
+ if (beyondTail.length !== 0)
1213
+ return yield* SqliteStorageCorruptionError.make({
1214
+ table: "effect_agent_canonical_records",
1215
+ rowKey: threadId,
1216
+ message: "Canonical records exist beyond the captured thread tail.",
1217
+ });
1218
+
1219
+ return RawThreadExport.make({ thread, records });
1143
1220
  }),
1144
1221
  );
1145
1222
  });
@@ -1239,6 +1316,83 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
1239
1316
  );
1240
1317
  });
1241
1318
 
1319
+ const saveRecoveryCheckpoint = Effect.fn("SqliteJournal.saveRecoveryCheckpoint")(function* (
1320
+ request: SaveRecoveryCheckpointRequest,
1321
+ checkpointJson: string,
1322
+ ) {
1323
+ const { checkpoint } = request;
1324
+
1325
+ if (
1326
+ checkpoint.threadId.length > MAX_IDENTIFIER_LENGTH ||
1327
+ storedTextBytes(checkpointJson) > MAX_STORED_TEXT_BYTES
1328
+ ) {
1329
+ return yield* SqliteStorageError.make({
1330
+ operation: "save recovery checkpoint",
1331
+ message: "Checkpoint identity or encoded JSON exceeds the SQLite storage bounds.",
1332
+ });
1333
+ }
1334
+ yield* withWriteTransaction("recovery checkpoint transaction")(
1335
+ Effect.gen(function* () {
1336
+ const threads = yield* getThread(checkpoint.threadId);
1337
+ const thread = threads[0];
1338
+
1339
+ if (thread === undefined)
1340
+ return yield* ThreadNotMaterialized.make({ threadId: checkpoint.threadId });
1341
+ if (request.producerEpoch !== thread.producer_epoch)
1342
+ return yield* FenceRejected.make({
1343
+ threadId: checkpoint.threadId,
1344
+ actualEpoch: thread.producer_epoch,
1345
+ attemptedEpoch: request.producerEpoch,
1346
+ });
1347
+ if (checkpoint.throughSequence > thread.tail_sequence)
1348
+ return yield* CheckpointRejected.make({
1349
+ threadId: checkpoint.threadId,
1350
+ reason: "ahead-of-tail",
1351
+ });
1352
+
1353
+ const digests =
1354
+ checkpoint.throughSequence === 0
1355
+ ? [EMPTY_TAIL_DIGEST]
1356
+ : yield* getTailDigestAt(checkpoint.threadId, checkpoint.throughSequence);
1357
+
1358
+ if (digests.length !== 1 || digests[0] !== checkpoint.tailDigest)
1359
+ return yield* CheckpointRejected.make({
1360
+ threadId: checkpoint.threadId,
1361
+ reason: "digest-mismatch",
1362
+ });
1363
+
1364
+ yield* failpoint("save-recovery-checkpoint:before");
1365
+ yield* sql`
1366
+ INSERT INTO effect_agent_recovery_checkpoints (thread_id, through_sequence, tail_digest, checkpoint_json)
1367
+ VALUES (${checkpoint.threadId}, ${checkpoint.throughSequence}, ${checkpoint.tailDigest}, ${checkpointJson})
1368
+ ON CONFLICT (thread_id) DO UPDATE SET
1369
+ through_sequence = excluded.through_sequence,
1370
+ tail_digest = excluded.tail_digest,
1371
+ checkpoint_json = excluded.checkpoint_json
1372
+ WHERE excluded.through_sequence >= effect_agent_recovery_checkpoints.through_sequence
1373
+ `.pipe(Effect.mapError(storageError("save recovery checkpoint")));
1374
+ }),
1375
+ );
1376
+ yield* failpoint("save-recovery-checkpoint:after");
1377
+ });
1378
+
1379
+ const loadRecoveryCheckpoint = Effect.fn("SqliteJournal.loadRecoveryCheckpoint")(function* (
1380
+ threadId: string,
1381
+ ) {
1382
+ const rows = yield* sql<Record<string, unknown>>`
1383
+ SELECT thread_id, through_sequence, tail_digest, checkpoint_json
1384
+ FROM effect_agent_recovery_checkpoints
1385
+ WHERE thread_id = ${threadId}
1386
+ `.pipe(Effect.mapError(storageError("load recovery checkpoint")));
1387
+
1388
+ return yield* decodeRows(
1389
+ Schema.Array(CheckpointRow),
1390
+ "effect_agent_recovery_checkpoints",
1391
+ threadId,
1392
+ rows,
1393
+ );
1394
+ });
1395
+
1242
1396
  const loadCheckpoint = Effect.fn("SqliteJournal.loadCheckpoint")(function* (
1243
1397
  threadId: string,
1244
1398
  atOrBeforeSequence: CanonicalSequence,
@@ -1386,6 +1540,8 @@ export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(fun
1386
1540
  getThread,
1387
1541
  getTailDigestAt,
1388
1542
  loadCheckpoint,
1543
+ loadRecoveryCheckpoint,
1544
+ saveRecoveryCheckpoint,
1389
1545
  materialize,
1390
1546
  read,
1391
1547
  saveCheckpoint,
@@ -1 +0,0 @@
1
- {"version":3,"file":"migrations-dnTR0RKq.mjs","names":[],"sources":["../src/internal/message-delivery-schema.ts","../src/internal/migrations.ts"],"sourcesContent":["import { Effect } from \"effect\";\nimport * as SqlClient from \"effect/unstable/sql/SqlClient\";\n\n/** Additive storage owned by this adapter; creation participates in its version transaction. */\nexport const createMessageDeliveryTables = Effect.gen(function* () {\n const sql = yield* SqlClient.SqlClient;\n\n yield* sql`\n CREATE TABLE effect_agent_message_deliveries (\n owner_thread_id TEXT NOT NULL,\n message_id TEXT NOT NULL,\n version INTEGER NOT NULL,\n state TEXT NOT NULL,\n deadline_at_millis INTEGER,\n record_json TEXT NOT NULL,\n PRIMARY KEY (owner_thread_id, message_id)\n )\n `.withoutTransform;\n yield* sql`\n CREATE INDEX effect_agent_message_deliveries_due\n ON effect_agent_message_deliveries (deadline_at_millis, owner_thread_id, message_id)\n WHERE deadline_at_millis IS NOT NULL\n `.withoutTransform;\n});\n","import { SqliteMigrator } from \"@effect/sql-sqlite-node\";\nimport { Effect } from \"effect\";\nimport * as SqlClient from \"effect/unstable/sql/SqlClient\";\n\nimport { createMessageDeliveryTables } from \"./message-delivery-schema.ts\";\n\nexport const CurrentSqliteStorageVersion = 9;\n\n/** Initialize empty storage with the complete current schema. */\nexport const sqliteMigrations = SqliteMigrator.fromRecord({\n \"1_current_thread_storage\": Effect.gen(function* () {\n const sql = yield* SqlClient.SqlClient;\n\n yield* sql`\n CREATE TABLE effect_agent_threads (\n thread_id TEXT PRIMARY KEY NOT NULL,\n created_at TEXT NOT NULL,\n tail_sequence INTEGER NOT NULL,\n tail_digest TEXT NOT NULL,\n producer_epoch INTEGER NOT NULL\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_canonical_batches (\n thread_id TEXT NOT NULL,\n batch_id TEXT NOT NULL,\n first_sequence INTEGER NOT NULL,\n last_sequence INTEGER NOT NULL,\n batch_digest TEXT NOT NULL,\n tail_digest TEXT NOT NULL,\n batch_json TEXT NOT NULL,\n PRIMARY KEY (thread_id, batch_id),\n FOREIGN KEY (thread_id)\n REFERENCES effect_agent_threads(thread_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_canonical_records (\n thread_id TEXT NOT NULL,\n sequence INTEGER NOT NULL,\n record_id TEXT NOT NULL,\n batch_id TEXT NOT NULL,\n record_json TEXT NOT NULL,\n PRIMARY KEY (thread_id, sequence),\n UNIQUE (thread_id, record_id),\n FOREIGN KEY (thread_id, batch_id)\n REFERENCES effect_agent_canonical_batches(thread_id, batch_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE INDEX effect_agent_canonical_records_batch\n ON effect_agent_canonical_records (thread_id, batch_id, sequence)\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_checkpoints (\n thread_id TEXT NOT NULL,\n through_sequence INTEGER NOT NULL,\n tail_digest TEXT NOT NULL,\n checkpoint_json TEXT NOT NULL,\n PRIMARY KEY (thread_id, through_sequence),\n FOREIGN KEY (thread_id)\n REFERENCES effect_agent_threads(thread_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n // Admission rows exist before Thread materialization (durability §4), so\n // thread_id intentionally carries no foreign key into effect_agent_threads.\n yield* sql`\n CREATE TABLE effect_agent_submissions (\n submission_id TEXT PRIMARY KEY NOT NULL,\n thread_id TEXT NOT NULL,\n queue_sequence INTEGER NOT NULL,\n principal TEXT NOT NULL,\n idempotency_key TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n agent_digests_json TEXT NOT NULL,\n deployment_id TEXT NOT NULL,\n input_json TEXT NOT NULL,\n input_digest TEXT NOT NULL,\n receipt_id TEXT NOT NULL,\n state TEXT NOT NULL,\n settled_outcome TEXT,\n created_at TEXT NOT NULL,\n ready_at TEXT,\n input_applied_record_id TEXT,\n input_applied_sequence INTEGER,\n joined_host_submission_id TEXT,\n suspended_reason_json TEXT,\n suspended_at TEXT,\n unknown_reason TEXT,\n unknown_tool_call_ids_json TEXT,\n parent_submission_id TEXT,\n parent_tool_call_id TEXT,\n admission_group TEXT,\n admission_fence_json TEXT,\n worker_admission_json TEXT,\n message_admission_json TEXT,\n UNIQUE (thread_id, principal, idempotency_key),\n UNIQUE (thread_id, queue_sequence)\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE INDEX effect_agent_submissions_group\n ON effect_agent_submissions (thread_id, admission_group, state)\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_submission_ownership (\n submission_id TEXT PRIMARY KEY NOT NULL,\n attempt_id TEXT NOT NULL,\n ownership_token TEXT NOT NULL,\n producer_epoch INTEGER NOT NULL,\n owner_producer_id TEXT NOT NULL,\n lease_expires_at TEXT NOT NULL,\n FOREIGN KEY (submission_id)\n REFERENCES effect_agent_submissions(submission_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_attempts (\n attempt_id TEXT PRIMARY KEY NOT NULL,\n submission_id TEXT NOT NULL,\n thread_id TEXT NOT NULL,\n owner_producer_id TEXT NOT NULL,\n producer_epoch INTEGER NOT NULL,\n claimed_at TEXT NOT NULL,\n FOREIGN KEY (submission_id)\n REFERENCES effect_agent_submissions(submission_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_settlement_reservations (\n submission_id TEXT PRIMARY KEY NOT NULL,\n settlement_id TEXT NOT NULL,\n outcome TEXT NOT NULL,\n record_id TEXT NOT NULL,\n record_json TEXT NOT NULL,\n record_digest TEXT NOT NULL,\n reserved_at TEXT NOT NULL,\n finalized_at TEXT,\n FOREIGN KEY (submission_id)\n REFERENCES effect_agent_submissions(submission_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_abort_intents (\n submission_id TEXT PRIMARY KEY NOT NULL,\n author TEXT NOT NULL,\n reason TEXT NOT NULL,\n requested_at TEXT NOT NULL,\n canonical_record_id TEXT,\n FOREIGN KEY (submission_id)\n REFERENCES effect_agent_submissions(submission_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE INDEX effect_agent_submissions_joined_host\n ON effect_agent_submissions (joined_host_submission_id)\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_approval_decisions (\n submission_id TEXT NOT NULL,\n tool_call_id TEXT NOT NULL,\n decision TEXT NOT NULL,\n resolver TEXT NOT NULL,\n reason TEXT NOT NULL,\n decided_at TEXT NOT NULL,\n PRIMARY KEY (submission_id, tool_call_id),\n FOREIGN KEY (submission_id)\n REFERENCES effect_agent_submissions(submission_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_unknown_resolutions (\n submission_id TEXT NOT NULL,\n tool_call_id TEXT NOT NULL,\n author TEXT NOT NULL,\n reason TEXT NOT NULL,\n resolution_json TEXT NOT NULL,\n resolved_at TEXT NOT NULL,\n PRIMARY KEY (submission_id, tool_call_id),\n FOREIGN KEY (submission_id)\n REFERENCES effect_agent_submissions(submission_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n // Durable attached children (spec §12, SUB-004): a child Submission records its immutable\n // parent linkage at admission; the parent-side index serves the recovery attachment view.\n yield* sql`\n CREATE INDEX effect_agent_submissions_parent\n ON effect_agent_submissions (parent_submission_id)\n `.withoutTransform;\n\n // Parent-owned child budget reservations (spec §12 steps 2 and 6, SUB-010): generic\n // opaque-payload state-machine rows (D8) — allocation and accounting are Schema-encoded\n // JSON documents the adapter never interprets; status moves\n // reserved → releasePending → released, applied exactly once.\n yield* sql`\n CREATE TABLE effect_agent_child_reservations (\n reservation_id TEXT PRIMARY KEY NOT NULL,\n parent_submission_id TEXT NOT NULL,\n parent_tool_call_id TEXT NOT NULL,\n child_submission_id TEXT,\n status TEXT NOT NULL,\n allocation_json TEXT NOT NULL,\n allocation_digest TEXT NOT NULL,\n accounting_json TEXT,\n reserved_at TEXT NOT NULL,\n release_began_at TEXT,\n released_at TEXT,\n UNIQUE (parent_submission_id, parent_tool_call_id),\n FOREIGN KEY (parent_submission_id)\n REFERENCES effect_agent_submissions(submission_id)\n ON DELETE RESTRICT\n )\n `.withoutTransform;\n\n // record_json is authoritative. The remaining columns support owner keyset paging and\n // deadline queries without decoding unrelated future schedules.\n yield* sql`\n CREATE TABLE effect_agent_schedules (\n tenant_id TEXT NOT NULL,\n owner_id TEXT NOT NULL,\n schedule_id TEXT NOT NULL,\n deadline_at_millis INTEGER,\n record_json TEXT NOT NULL,\n PRIMARY KEY (tenant_id, owner_id, schedule_id)\n )\n `.withoutTransform;\n\n yield* sql`\n CREATE INDEX effect_agent_schedules_deadline\n ON effect_agent_schedules (deadline_at_millis, tenant_id, owner_id, schedule_id)\n WHERE deadline_at_millis IS NOT NULL\n `.withoutTransform;\n\n yield* sql`\n CREATE INDEX effect_agent_schedules_owner_deadline\n ON effect_agent_schedules (tenant_id, owner_id, deadline_at_millis, schedule_id)\n WHERE deadline_at_millis IS NOT NULL\n `.withoutTransform;\n\n yield* sql`\n CREATE TABLE effect_agent_subscription_sequences (\n tenant_id TEXT NOT NULL,\n source_address TEXT NOT NULL,\n sequence INTEGER NOT NULL,\n event_scan_cursor TEXT NOT NULL,\n delivery_scan_cursor TEXT NOT NULL,\n recovery_scan_cursor INTEGER NOT NULL,\n PRIMARY KEY (tenant_id, source_address)\n )\n `.withoutTransform;\n yield* sql`\n CREATE TABLE effect_agent_subscriptions (\n tenant_id TEXT NOT NULL,\n source_address TEXT NOT NULL,\n owner_id TEXT NOT NULL,\n subscription_id TEXT NOT NULL,\n ordinal INTEGER NOT NULL,\n source_name TEXT NOT NULL,\n source_version TEXT NOT NULL,\n matching_key TEXT NOT NULL,\n state TEXT NOT NULL,\n expires_at_millis INTEGER,\n recovery_at_millis INTEGER,\n recovery_present INTEGER NOT NULL DEFAULT 0,\n record_json TEXT NOT NULL,\n PRIMARY KEY (tenant_id, source_address, owner_id, subscription_id),\n UNIQUE (tenant_id, source_address, ordinal)\n )\n `.withoutTransform;\n yield* sql`CREATE INDEX effect_agent_subscriptions_owner ON effect_agent_subscriptions (tenant_id, source_address, owner_id, ordinal)`\n .withoutTransform;\n yield* sql`CREATE INDEX effect_agent_subscriptions_candidates ON effect_agent_subscriptions (tenant_id, source_address, source_name, source_version, matching_key, ordinal)`\n .withoutTransform;\n yield* sql`CREATE INDEX effect_agent_subscriptions_recovery ON effect_agent_subscriptions (tenant_id, source_address, recovery_at_millis, ordinal) WHERE recovery_at_millis IS NOT NULL`\n .withoutTransform;\n yield* sql`\n CREATE TABLE effect_agent_subscription_events (\n tenant_id TEXT NOT NULL,\n source_address TEXT NOT NULL,\n event_id TEXT NOT NULL,\n source_name TEXT NOT NULL,\n source_version TEXT NOT NULL,\n matching_key TEXT NOT NULL,\n payload_digest TEXT NOT NULL,\n cutoff INTEGER NOT NULL,\n cursor INTEGER NOT NULL,\n routing_complete INTEGER NOT NULL,\n tombstone INTEGER NOT NULL DEFAULT 0,\n next_attempt_at_millis INTEGER NOT NULL,\n record_json TEXT NOT NULL,\n PRIMARY KEY (tenant_id, source_address, event_id)\n )\n `.withoutTransform;\n yield* sql`CREATE INDEX effect_agent_subscription_events_pending ON effect_agent_subscription_events (tenant_id, source_address, routing_complete, next_attempt_at_millis, event_id)`\n .withoutTransform;\n yield* sql`\n CREATE TABLE effect_agent_subscription_deliveries (\n tenant_id TEXT NOT NULL,\n source_address TEXT NOT NULL,\n owner_id TEXT NOT NULL,\n subscription_id TEXT NOT NULL,\n event_id TEXT NOT NULL,\n delivery_key TEXT NOT NULL,\n state TEXT NOT NULL,\n next_attempt_at_millis INTEGER NOT NULL,\n record_json TEXT NOT NULL,\n PRIMARY KEY (tenant_id, source_address, owner_id, subscription_id, event_id),\n UNIQUE (tenant_id, source_address, delivery_key)\n )\n `.withoutTransform;\n yield* sql`CREATE INDEX effect_agent_subscription_deliveries_pending ON effect_agent_subscription_deliveries (tenant_id, source_address, state, next_attempt_at_millis, delivery_key)`\n .withoutTransform;\n yield* sql`CREATE INDEX effect_agent_subscription_deliveries_registration ON effect_agent_subscription_deliveries (tenant_id, source_address, owner_id, subscription_id, delivery_key)`\n .withoutTransform;\n yield* createMessageDeliveryTables;\n yield* sql`PRAGMA user_version = 9`.withoutTransform;\n }),\n});\n"],"mappings":";;;;;AAIA,MAAa,8BAA8B,OAAO,IAAI,aAAa;CACjE,MAAM,MAAM,OAAO,UAAU;CAE7B,OAAO,GAAG;;;;;;;;;;IAUR;CACF,OAAO,GAAG;;;;IAIR;AACJ,CAAC;;;ACjBD,MAAa,8BAA8B;;AAG3C,MAAa,mBAAmB,eAAe,WAAW,EACxD,4BAA4B,OAAO,IAAI,aAAa;CAClD,MAAM,MAAM,OAAO,UAAU;CAE7B,OAAO,GAAG;;;;;;;;MAQR;CAEF,OAAO,GAAG;;;;;;;;;;;;;;MAcR;CAEF,OAAO,GAAG;;;;;;;;;;;;;MAaR;CAEF,OAAO,GAAG;;;MAGR;CAEF,OAAO,GAAG;;;;;;;;;;;MAWR;CAIF,OAAO,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAiCR;CAEF,OAAO,GAAG;;;MAGR;CAEF,OAAO,GAAG;;;;;;;;;;;;MAYR;CAEF,OAAO,GAAG;;;;;;;;;;;;MAYR;CAEF,OAAO,GAAG;;;;;;;;;;;;;;MAcR;CAEF,OAAO,GAAG;;;;;;;;;;;MAWR;CAEF,OAAO,GAAG;;;MAGR;CAEF,OAAO,GAAG;;;;;;;;;;;;;MAaR;CAEF,OAAO,GAAG;;;;;;;;;;;;;MAaR;CAIF,OAAO,GAAG;;;MAGR;CAMF,OAAO,GAAG;;;;;;;;;;;;;;;;;;MAkBR;CAIF,OAAO,GAAG;;;;;;;;;MASR;CAEF,OAAO,GAAG;;;;MAIR;CAEF,OAAO,GAAG;;;;MAIR;CAEF,OAAO,GAAG;;;;;;;;;;MAUR;CACF,OAAO,GAAG;;;;;;;;;;;;;;;;;;MAkBR;CACF,OAAO,GAAG,6HACP;CACH,OAAO,GAAG,mKACP;CACH,OAAO,GAAG,+KACP;CACH,OAAO,GAAG;;;;;;;;;;;;;;;;;MAiBR;CACF,OAAO,GAAG,4KACP;CACH,OAAO,GAAG;;;;;;;;;;;;;;MAcR;CACF,OAAO,GAAG,6KACP;CACH,OAAO,GAAG,8KACP;CACH,OAAO;CACP,OAAO,GAAG,0BAA0B;AACtC,CAAC,EACH,CAAC"}