@yielded/agent-storage-cloudflare 0.1.0-beta.174 → 0.1.0-beta.176

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 (41) hide show
  1. package/dist/DoMemoryStore.mjs +1 -1
  2. package/dist/DoMessageDeliveryStore.mjs +1 -1
  3. package/dist/DoStorageFailpoint.d.mts +3 -1
  4. package/dist/DoStorageFailpoint.mjs +7 -2
  5. package/dist/DoStorageFailpoint.mjs.map +1 -1
  6. package/dist/{DoStorageVersion-NKnQU3eR.d.mts → DoStorageVersion-Mc7CXMdN.d.mts} +2 -2
  7. package/dist/DoStorageVersion.d.mts +1 -1
  8. package/dist/DoStorageVersion.mjs +2 -2
  9. package/dist/DoSubmissionLedger.d.mts +1 -1
  10. package/dist/DoSubmissionLedger.mjs +194 -98
  11. package/dist/DoSubmissionLedger.mjs.map +1 -1
  12. package/dist/{DoThreadStore-Bd4fTns3.d.mts → DoThreadStore-BzYJjm4O.d.mts} +18 -8
  13. package/dist/{DoThreadStore-DyzOZDXK.mjs → DoThreadStore-DG0h5J6u.mjs} +25 -19
  14. package/dist/DoThreadStore-DG0h5J6u.mjs.map +1 -0
  15. package/dist/DoThreadStore.d.mts +2 -2
  16. package/dist/DoThreadStore.mjs +3 -2
  17. package/dist/PortProtocol.d.mts +7201 -7201
  18. package/dist/PortRouting.mjs +21 -11
  19. package/dist/PortRouting.mjs.map +1 -1
  20. package/dist/{do-journal-ZMAzzENZ.mjs → do-journal-ySC3eXNv.mjs} +144 -45
  21. package/dist/do-journal-ySC3eXNv.mjs.map +1 -0
  22. package/dist/index.d.mts +2 -2
  23. package/dist/index.mjs +1 -1
  24. package/dist/{migrations-C3N9GN6A.mjs → migrations-CAO_akhD.mjs} +45 -22
  25. package/dist/migrations-CAO_akhD.mjs.map +1 -0
  26. package/dist/{owned-state-DcFh0YNo.mjs → owned-state-BgGOA2Ak.mjs} +4 -3
  27. package/dist/owned-state-BgGOA2Ak.mjs.map +1 -0
  28. package/package.json +1 -1
  29. package/src/DoStorageFailpoint.ts +4 -0
  30. package/src/DoSubmissionLedger.ts +430 -228
  31. package/src/DoThreadStore.ts +25 -44
  32. package/src/PortRouting.ts +57 -31
  33. package/src/internal/do-journal.ts +318 -70
  34. package/src/internal/migrations.ts +52 -10
  35. package/src/internal/owned-state.ts +12 -5
  36. package/src/internal/prompt-checkpoint.ts +6 -3
  37. package/src/internal/storage-span.ts +14 -10
  38. package/dist/DoThreadStore-DyzOZDXK.mjs.map +0 -1
  39. package/dist/do-journal-ZMAzzENZ.mjs.map +0 -1
  40. package/dist/migrations-C3N9GN6A.mjs.map +0 -1
  41. package/dist/owned-state-DcFh0YNo.mjs.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { SqliteClient } from "@effect/sql-sqlite-do";
1
2
  import {
2
3
  makeCanonicalStorage,
3
4
  recordBatchId,
@@ -21,17 +22,33 @@ import {
21
22
  LifecyclePublicationFact,
22
23
  LifecyclePublicationError,
23
24
  } from "@yielded/agent/lifecycle-publication";
24
- import { CanonicalRecord, CanonicalSequence } from "@yielded/agent/records";
25
+ import { BatchId, CanonicalRecord, CanonicalSequence } from "@yielded/agent/records";
25
26
  import { SqlStorageOwner } from "@yielded/agent/sql-memory-store";
26
27
  import {
27
28
  type AppendConflict,
29
+ AppendResult,
28
30
  ThreadStoreDiagnostic,
29
31
  ThreadStoreError,
30
32
  } from "@yielded/agent/thread-store";
31
- import { Cause, Clock, Effect, Option, Schema, Stream } from "effect";
33
+ import {
34
+ Cause,
35
+ Clock,
36
+ Context,
37
+ Effect,
38
+ Layer,
39
+ Option,
40
+ Predicate,
41
+ References,
42
+ Result,
43
+ Schema,
44
+ Stream,
45
+ Tracer,
46
+ } from "effect";
32
47
  import * as SqlClient from "effect/sql/SqlClient";
33
- import { SqlError } from "effect/sql/SqlError";
48
+ import { classifySqliteError, SqlError } from "effect/sql/SqlError";
49
+ import { CurrentTransformer, type Statement } from "effect/sql/Statement";
34
50
 
51
+ import { DoStorageConfig } from "../DoStorageConfig.ts";
35
52
  import {
36
53
  type DoStorageFailpointError,
37
54
  DoAppendConflict,
@@ -41,6 +58,7 @@ import {
41
58
  DoValueBoundExceeded,
42
59
  type DoStorageFailpointLocation,
43
60
  } from "../DoStorageError.ts";
61
+ import { DoStorageFailpoint, isDefaultDoStorageFailpoint } from "../DoStorageFailpoint.ts";
44
62
  import { ensureDoStorageLayout } from "./migrations.ts";
45
63
  import { ownedState, ownedRows, type OwnedState } from "./owned-state.ts";
46
64
  import {
@@ -68,6 +86,12 @@ const MAX_RECORD_CACHE_ENTRIES = 4_096;
68
86
  const MAX_BOUND_PARAMETERS = 100;
69
87
  const isSqlError = Schema.is(SqlError);
70
88
 
89
+ const isNativeClient = Schema.is(
90
+ Schema.declare<SqliteClient.SqliteClient>((value): value is SqliteClient.SqliteClient =>
91
+ Predicate.hasProperty(value, SqliteClient.TypeId),
92
+ ),
93
+ );
94
+
71
95
  const storedTextBytes = (value: string): number => new TextEncoder().encode(value).byteLength;
72
96
 
73
97
  const chunked = <A>(values: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> => {
@@ -94,8 +118,8 @@ const threadRows = ownedRows(
94
118
  );
95
119
 
96
120
  class RecordRow extends Schema.Class<RecordRow>("RecordRow")({
97
- batch_id: Schema.fromJsonString(BoundedIdentifier),
98
- thread_id: BoundedIdentifier,
121
+ batch_id: Schema.fromJsonString(BatchId.check(Schema.isMaxLength(MAX_IDENTIFIER_LENGTH))),
122
+ thread_id: ThreadId.check(Schema.isMaxLength(MAX_IDENTIFIER_LENGTH)),
99
123
  record_id: Schema.fromJsonString(BoundedIdentifier),
100
124
  record_json: BoundedStoredText,
101
125
  sequence: CanonicalSequence,
@@ -200,14 +224,6 @@ const initializedLifecycleSources = new WeakSet<OwnedState>();
200
224
 
201
225
  export type { RawAppendRequest } from "@yielded/agent-storage-sql/sql-journal";
202
226
 
203
- export class RawAppendResult extends Schema.Class<RawAppendResult>(
204
- "@effect-agent/storage-cloudflare/RawAppendResult",
205
- )({
206
- firstSequence: CanonicalSequence,
207
- lastSequence: CanonicalSequence,
208
- replayed: Schema.Boolean,
209
- }) {}
210
-
211
227
  export class RawReadRequest extends Schema.Class<RawReadRequest>(
212
228
  "@effect-agent/storage-cloudflare/RawReadRequest",
213
229
  )({
@@ -317,10 +333,14 @@ const ensureCurrentStorage = Effect.fnUntraced(function* (
317
333
  failpoint: DoJournalFailpoint = noFailpoint,
318
334
  maxStoredValueBytes: number,
319
335
  ) {
320
- yield* ensureDoStorageLayout().pipe(Effect.provideService(SqlClient.SqlClient, sql));
321
-
322
336
  const state = yield* ownedState(sql);
323
337
  const owner = (yield* SqlStorageOwner) ?? state;
338
+
339
+ yield* ensureDoStorageLayout().pipe(
340
+ Effect.provideService(SqlStorageOwner, owner),
341
+ Effect.provideService(SqlClient.SqlClient, sql),
342
+ );
343
+
324
344
  let journal: DoJournal | undefined;
325
345
 
326
346
  const lifecycle = yield* makeSqlLifecyclePublication(undefined, maxStoredValueBytes).pipe(
@@ -366,6 +386,14 @@ const makeJournal = Effect.fnUntraced(function* (
366
386
  const owner = (yield* SqlStorageOwner) ?? state;
367
387
  const progress = yield* SqlStorageProgress;
368
388
 
389
+ const nativeDb =
390
+ isNativeClient(sql) &&
391
+ sql.config.transformQueryNames === undefined &&
392
+ sql.config.transformResultNames === undefined &&
393
+ isDefaultDoStorageFailpoint(failpoint)
394
+ ? sql.config.storage?.sql
395
+ : undefined;
396
+
369
397
  const work = yield* makeSqlThreadWork({
370
398
  read: (body) => state.read(body),
371
399
  write: (body) =>
@@ -473,18 +501,36 @@ const makeJournal = Effect.fnUntraced(function* (
473
501
  * transaction. Expected outcomes may be captured only when every such refusal precedes
474
502
  * mutation; real faults still roll back. Successful preflight reads never authorize a write.
475
503
  */
476
- const withWriteTransaction =
477
- (operation: string, expected: (error: { readonly _tag: string }) => boolean = () => false) =>
478
- <A, E extends { readonly _tag: string }>(
504
+ const withWriteTransaction = (
505
+ operation: string,
506
+ expected?: (error: { readonly _tag: string }) => boolean,
507
+ ) => {
508
+ const transaction = <A, E extends { readonly _tag: string }>(
479
509
  effect: Effect.Effect<A, E>,
480
510
  ): Effect.Effect<A, E | DoStorageError> =>
481
- state.owner.withMutation(owner.transaction(storageResult(effect, expected))).pipe(
482
- Effect.provideService(SqlClient.SqlClient, sql),
483
- Effect.mapError((error) => (isSqlError(error) ? storageError(operation)(error) : error)),
484
- Effect.tapError(annotateStorageError),
485
- Effect.withSpan("DoJournal.withWriteTransaction", { attributes: { operation } }),
486
- Effect.flatMap(Effect.fromResult),
487
- );
511
+ Effect.withFiber((fiber) => {
512
+ const body = state.owner.withMutation(owner.transaction(effect)).pipe(
513
+ Effect.provideService(SqlClient.SqlClient, sql),
514
+ Effect.mapError((error) => (isSqlError(error) ? storageError(operation)(error) : error)),
515
+ );
516
+
517
+ const traced =
518
+ fiber.getRef(References.TracerEnabled) &&
519
+ fiber.getRef(Tracer.Tracer) !== Tracer.nativeTracer;
520
+
521
+ return traced
522
+ ? body.pipe(
523
+ Effect.tapError(annotateStorageError),
524
+ Effect.withSpan("DoJournal.withWriteTransaction", { attributes: { operation } }),
525
+ )
526
+ : body;
527
+ });
528
+
529
+ return <A, E extends { readonly _tag: string }>(effect: Effect.Effect<A, E>) =>
530
+ expected === undefined
531
+ ? transaction(effect)
532
+ : transaction(storageResult(effect, expected)).pipe(Effect.flatMap(Effect.fromResult));
533
+ };
488
534
 
489
535
  const materialize = Effect.fnUntraced(function* (
490
536
  threadId: string,
@@ -620,9 +666,152 @@ const makeJournal = Effect.fnUntraced(function* (
620
666
  return request;
621
667
  });
622
668
 
669
+ // Replay and progress validation precede this body in the same owner transaction.
670
+ // A miss makes no native change; the ordinary path reuses any tentative batch.
671
+ // After advancing the frontier, failures must roll back rather than fall back.
672
+ const appendNativeBody = Effect.fnUntraced(function* (
673
+ request: RawAppendRequest,
674
+ first: CanonicalSequence,
675
+ last: CanonicalSequence,
676
+ batchInserted: boolean,
677
+ ) {
678
+ const db = nativeDb;
679
+
680
+ // Effectful statement transformers and exported SQL spans retain the ordinary path.
681
+ const ordinaryStatement = yield* Effect.withFiber((fiber) =>
682
+ Effect.succeed(
683
+ fiber.getRef(CurrentTransformer) !== undefined ||
684
+ (fiber.getRef(References.TracerEnabled) &&
685
+ fiber.getRef(Tracer.Tracer) !== Tracer.nativeTracer),
686
+ ),
687
+ );
688
+
689
+ if (ordinaryStatement) return false;
690
+ if (
691
+ db === undefined ||
692
+ request.executionWrites.length !== 1 ||
693
+ request.executionWrites[0]?.update.retire ||
694
+ request.executionWrites[0]?.update.facts.length !== 0
695
+ )
696
+ return false;
697
+
698
+ const workPlan = yield* work.prepareClosedPair(
699
+ request.threadId,
700
+ first,
701
+ last,
702
+ request.records.map((record) => record.canonical),
703
+ );
704
+
705
+ if (workPlan === undefined) return false;
706
+
707
+ const native = yield* Effect.suspend(() => {
708
+ const query = (statement: Statement<object>, operation: string) =>
709
+ Result.try({
710
+ try: () => {
711
+ const [text, parameters] = statement.compile();
712
+
713
+ return db.exec(text, ...parameters).toArray();
714
+ },
715
+ catch: (cause) =>
716
+ storageError(operation)(
717
+ SqlError.make({
718
+ reason: classifySqliteError(cause, {
719
+ operation,
720
+ message: "Native canonical statement failed",
721
+ }),
722
+ }),
723
+ ),
724
+ });
725
+
726
+ return Effect.fromResult(
727
+ Result.gen(function* () {
728
+ const captured = physical.cachedRecords(request.threadId, first, request.records);
729
+
730
+ if (captured === undefined) return undefined;
731
+ const physicalRecords = yield* captured;
732
+ const rows = yield* query(workPlan.advance, "advance native work frontier");
733
+
734
+ // A zero-row predicate miss has made no change. A successful update
735
+ // must never fall back: every later refusal rolls back this transaction.
736
+ if (rows.length === 0) return undefined;
737
+ const executionPlans = executions.prepareCommit(request);
738
+
739
+ if (!batchInserted)
740
+ yield* query(
741
+ sql`INSERT INTO effect_agent_canonical_batches
742
+ (thread_id, batch_id, first_sequence, last_sequence, producer_id_json)
743
+ VALUES (${request.threadId}, ${JSON.stringify(request.batchId)}, ${first}, ${last}, ${request.producerIdJson})`,
744
+ "insert canonical batch",
745
+ );
746
+
747
+ const inserted = yield* query(
748
+ sql`INSERT INTO effect_agent_canonical_records ${sql.insert(physicalRecords.map((row) => row.columns))}
749
+ ON CONFLICT(thread_id, record_id) DO NOTHING RETURNING 1`,
750
+ "insert canonical records",
751
+ );
752
+
753
+ if (inserted.length !== request.records.length)
754
+ return yield* Result.fail(
755
+ DoAppendConflict.make({
756
+ message: "A canonical record ID already exists.",
757
+ reason: "record-identity",
758
+ }),
759
+ );
760
+ const memberships = physicalRecords.flatMap((row) => row.memberships);
761
+
762
+ for (const group of chunked(memberships, Math.floor(MAX_BOUND_PARAMETERS / 3)))
763
+ yield* query(
764
+ sql`INSERT INTO effect_agent_record_runs ${sql.insert(group)}`,
765
+ "index canonical Run membership",
766
+ );
767
+ for (const pointer of workPlan.pointers)
768
+ yield* query(pointer, "index canonical pointers");
769
+
770
+ for (const plan of executionPlans) {
771
+ const changed = yield* query(plan.statement, "commit active execution");
772
+ const accepted = plan.accept(changed);
773
+
774
+ if (Result.isFailure(accepted))
775
+ return yield* Result.fail(
776
+ DoAppendConflict.make({
777
+ reason: accepted.failure.reason,
778
+ message: "Active execution conflicts with the captured commit",
779
+ ...(accepted.failure.actualTailSequence === undefined
780
+ ? {}
781
+ : { actualTailSequence: accepted.failure.actualTailSequence }),
782
+ }),
783
+ );
784
+ }
785
+ for (let index = 0; index < request.records.length; index++) {
786
+ const record = request.records[index]!;
787
+
788
+ recordCache.put(
789
+ RecordRow.make({
790
+ ...physicalRecords[index]!.columns,
791
+ thread_id: request.threadId,
792
+ record_id: record.recordId,
793
+ batch_id: request.batchId,
794
+ record_json: record.recordJson,
795
+ sequence: CanonicalSequence.make(first + index),
796
+ }),
797
+ record.recordBytes,
798
+ );
799
+ }
800
+
801
+ return rows;
802
+ }),
803
+ );
804
+ });
805
+
806
+ if (native === undefined) return false;
807
+ yield* threads.write(Effect.succeed(native));
808
+
809
+ return true;
810
+ });
811
+
623
812
  const appendPrepared = Effect.fnUntraced(function* (
624
813
  request: RawAppendRequest,
625
- ): Effect.fn.Return<RawAppendResult, AppendError> {
814
+ ): Effect.fn.Return<AppendResult, AppendError> {
626
815
  const threadRows = yield* getThread(request.threadId);
627
816
 
628
817
  const thread = threadRows.length === 1 ? threadRows[0] : undefined;
@@ -634,30 +823,62 @@ const makeJournal = Effect.fnUntraced(function* (
634
823
  message: `Expected exactly one row but found ${threadRows.length}.`,
635
824
  });
636
825
 
637
- const prefix = recordCache.prefix(request.threadId, thread.tail_sequence);
826
+ // A fresh canonical batch key and absence of an active receipt prove the two
827
+ // negative replay lookups in the same statement that retains the batch header.
828
+ // Keep the read-first path for stale/overflowed coordinates so exact retries
829
+ // still precede sequence and tail refusals.
830
+ const canInsert =
831
+ request.records.length > 0 &&
832
+ request.expectedTailSequence === thread.tail_sequence &&
833
+ Number.isSafeInteger(thread.tail_sequence + request.records.length);
834
+
835
+ const inserted = canInsert
836
+ ? yield* sql`INSERT INTO effect_agent_canonical_batches
837
+ (thread_id, batch_id, first_sequence, last_sequence, producer_id_json)
838
+ SELECT ${request.threadId}, ${JSON.stringify(request.batchId)},
839
+ ${thread.tail_sequence + 1}, ${thread.tail_sequence + request.records.length},
840
+ ${request.producerIdJson}
841
+ WHERE NOT EXISTS (
842
+ SELECT 1 FROM effect_agent_executions
843
+ WHERE thread_id=${request.threadId} AND last_commit_id=${request.batchId}
844
+ )
845
+ ON CONFLICT(thread_id, batch_id) DO NOTHING RETURNING 1`.pipe(
846
+ Effect.mapError(storageError("insert canonical batch")),
847
+ )
848
+ : [];
849
+
850
+ if (inserted.length === 0) {
851
+ const replay = yield* executionConflict(executions.replay(request));
638
852
 
639
- const replay = yield* executionConflict(executions.replay(request));
853
+ if (replay !== undefined) return AppendResult.make(replay);
640
854
 
641
- if (replay !== undefined) return RawAppendResult.make(replay);
855
+ const prefix = recordCache.prefix(request.threadId, thread.tail_sequence);
642
856
 
643
- const existing =
644
- prefix !== undefined && !prefix.some((row) => row.batch_id === request.batchId)
645
- ? undefined
646
- : yield* archives.replay(request.threadId, request.batchId);
857
+ const existing =
858
+ prefix !== undefined && !prefix.some((row) => row.batch_id === request.batchId)
859
+ ? undefined
860
+ : yield* archives.replay(request.threadId, request.batchId);
647
861
 
648
- if (existing !== undefined) {
649
- if (existing.batch_json !== request.batchJson) {
650
- return yield* DoAppendConflict.make({
651
- message: `Batch ${request.batchId} already exists with different canonical content.`,
652
- reason: "batch-content",
862
+ if (existing !== undefined) {
863
+ if (existing.batch_json !== request.batchJson) {
864
+ return yield* DoAppendConflict.make({
865
+ message: `Batch ${request.batchId} already exists with different canonical content.`,
866
+ reason: "batch-content",
867
+ });
868
+ }
869
+
870
+ return AppendResult.make({
871
+ firstSequence: existing.first_sequence,
872
+ lastSequence: existing.last_sequence,
873
+ replayed: true,
653
874
  });
654
875
  }
655
-
656
- return RawAppendResult.make({
657
- firstSequence: existing.first_sequence,
658
- lastSequence: existing.last_sequence,
659
- replayed: true,
660
- });
876
+ if (canInsert)
877
+ return yield* DoStorageCorruptionError.make({
878
+ table: "effect_agent_canonical_batches",
879
+ rowKey: request.threadId,
880
+ message: "Batch insertion conflicted without a durable replay receipt.",
881
+ });
661
882
  }
662
883
 
663
884
  yield* executionConflict(executions.rejectSuperseded(request));
@@ -671,9 +892,11 @@ const makeJournal = Effect.fnUntraced(function* (
671
892
  });
672
893
  }
673
894
 
674
- const firstSequence = yield* Schema.decodeEffect(CanonicalSequence)(
675
- thread.tail_sequence + (request.records.length === 0 ? 0 : 1),
676
- ).pipe(
895
+ const result = yield* Schema.decodeEffect(AppendResult)({
896
+ firstSequence: thread.tail_sequence + (request.records.length === 0 ? 0 : 1),
897
+ lastSequence: thread.tail_sequence + request.records.length,
898
+ replayed: false,
899
+ }).pipe(
677
900
  Effect.mapError((error) =>
678
901
  DoStorageError.make({
679
902
  cause: error,
@@ -683,27 +906,29 @@ const makeJournal = Effect.fnUntraced(function* (
683
906
  ),
684
907
  );
685
908
 
686
- const lastSequence = yield* Schema.decodeEffect(CanonicalSequence)(
687
- thread.tail_sequence + request.records.length,
688
- ).pipe(
689
- Effect.mapError((error) =>
690
- DoStorageError.make({
691
- cause: error,
692
- operation: "append canonical batch",
693
- message: error.message,
694
- }),
695
- ),
696
- );
909
+ const { firstSequence, lastSequence } = result;
697
910
 
698
911
  yield* executionConflict(executions.validate(request));
699
912
 
700
- const result = RawAppendResult.make({
701
- firstSequence,
702
- lastSequence,
703
- replayed: false,
704
- });
913
+ if (yield* appendNativeBody(request, firstSequence, lastSequence, inserted.length > 0)) {
914
+ yield* progress.committed("canonical").pipe(
915
+ Effect.catchCause((cause) =>
916
+ Effect.failCause(
917
+ Cause.map(cause, (error) =>
918
+ DoStorageError.make({
919
+ operation: "enroll canonical progress",
920
+ message: error.message,
921
+ cause: error,
922
+ }),
923
+ ),
924
+ ),
925
+ ),
926
+ );
705
927
 
706
- if (request.records.length > 0) {
928
+ return result;
929
+ }
930
+
931
+ if (request.records.length > 0 && inserted.length === 0) {
707
932
  yield* sql`
708
933
  INSERT INTO effect_agent_canonical_batches (
709
934
  thread_id,
@@ -760,6 +985,7 @@ const makeJournal = Effect.fnUntraced(function* (
760
985
  recordCache.put(
761
986
  RecordRow.make({
762
987
  ...row,
988
+ thread_id: request.threadId,
763
989
  record_id: record.recordId,
764
990
  batch_id: request.batchId,
765
991
  record_json: record.recordJson,
@@ -874,7 +1100,7 @@ const makeJournal = Effect.fnUntraced(function* (
874
1100
  function* (
875
1101
  request: RawAppendRequest,
876
1102
  observed: ThreadRow,
877
- ): Effect.fn.Return<RawAppendResult, AppendError> {
1103
+ ): Effect.fn.Return<AppendResult, AppendError> {
878
1104
  yield* prepareAppend(request);
879
1105
  // The facade already read this tail. Reject a known stale writer without opening a
880
1106
  // transaction; a matching tail is still checked atomically below. Replays precede tail
@@ -898,10 +1124,9 @@ const makeJournal = Effect.fnUntraced(function* (
898
1124
  }
899
1125
  }
900
1126
 
901
- return yield* withWriteTransaction(
902
- "append transaction",
903
- isAppendContention,
904
- )(appendPrepared(request));
1127
+ // Receipt arbitration may have tentatively inserted a batch. Even typed
1128
+ // contention must roll back before leaving the owner mutation gate.
1129
+ return yield* withWriteTransaction("append transaction")(appendPrepared(request));
905
1130
  },
906
1131
  withStorageSpan("DoJournal.append", isAppendContention),
907
1132
  );
@@ -1348,4 +1573,27 @@ const makeJournal = Effect.fnUntraced(function* (
1348
1573
 
1349
1574
  export type DoJournal = Effect.Success<ReturnType<typeof makeJournal>>;
1350
1575
 
1351
- export const initializeDoJournal = ensureCurrentStorage;
1576
+ const InitializedDoJournal = Context.Reference<DoJournal | undefined>(
1577
+ "@effect-agent/storage-cloudflare/InitializedDoJournal",
1578
+ { defaultValue: () => undefined },
1579
+ );
1580
+
1581
+ /** Build once in the common owner, SQL, configuration, failpoint and lifecycle context. */
1582
+ export const initializedDoJournalLayer = Layer.effect(InitializedDoJournal)(
1583
+ Effect.gen(function* () {
1584
+ const sql = yield* SqlClient.SqlClient;
1585
+ const config = yield* DoStorageConfig;
1586
+ const failpoint = yield* DoStorageFailpoint;
1587
+
1588
+ return yield* ensureCurrentStorage(sql, failpoint.hit, config.maxStoredValueBytes);
1589
+ }),
1590
+ );
1591
+
1592
+ export const initializeDoJournal = (
1593
+ sql: SqlClient.SqlClient,
1594
+ failpoint: DoJournalFailpoint,
1595
+ maxStoredValueBytes: number,
1596
+ ) =>
1597
+ Effect.filterOrElse(InitializedDoJournal, Predicate.isNotUndefined, () =>
1598
+ ensureCurrentStorage(sql, failpoint, maxStoredValueBytes),
1599
+ );
@@ -6,7 +6,8 @@ import {
6
6
  import { createSqlThreadWorkTables } from "@yielded/agent-storage-sql/sql-thread-work";
7
7
  import { makeSqliteLayoutInspection } from "@yielded/agent-storage-sql/sqlite-layout-inspection";
8
8
  import { CURRENT_RECORD_FORMAT } from "@yielded/agent/records";
9
- import { Effect, Schema } from "effect";
9
+ import { SqlStorageOwner } from "@yielded/agent/sql-memory-store";
10
+ import { Effect, Option, Schema } from "effect";
10
11
  import * as SqlClient from "effect/sql/SqlClient";
11
12
  import type { SqlError } from "effect/sql/SqlError";
12
13
 
@@ -16,7 +17,7 @@ import {
16
17
  DoStorageError,
17
18
  } from "../DoStorageError.ts";
18
19
 
19
- export const CurrentDoStorageVersion = 27;
20
+ export const CurrentDoStorageVersion = 28;
20
21
 
21
22
  /** Fresh layout only. Unsupported stores are rejected before these statements execute. */
22
23
  const layoutStatements = [
@@ -70,7 +71,6 @@ const layoutStatements = [
70
71
  "CREATE TABLE effect_agent_archive_batches (thread_id TEXT NOT NULL, batch_id TEXT NOT NULL, range_first_sequence INTEGER NOT NULL, batch_json TEXT NOT NULL, PRIMARY KEY (thread_id, batch_id), FOREIGN KEY (thread_id, batch_id) REFERENCES effect_agent_canonical_batches(thread_id, batch_id) ON DELETE RESTRICT, CONSTRAINT effect_agent_archive_batches_range_fkey FOREIGN KEY (thread_id, range_first_sequence) REFERENCES effect_agent_journal_ranges(thread_id, first_sequence) ON DELETE RESTRICT)",
71
72
  "CREATE TABLE effect_agent_archive_records (thread_id TEXT NOT NULL, sequence INTEGER NOT NULL, range_first_sequence INTEGER NOT NULL, record_json TEXT NOT NULL, PRIMARY KEY (thread_id, sequence), FOREIGN KEY (thread_id, sequence) REFERENCES effect_agent_canonical_records(thread_id, sequence) ON DELETE RESTRICT, CONSTRAINT effect_agent_archive_records_range_fkey FOREIGN KEY (thread_id, range_first_sequence) REFERENCES effect_agent_journal_ranges(thread_id, first_sequence) ON DELETE RESTRICT)",
72
73
  "CREATE UNIQUE INDEX effect_agent_canonical_batches_sequence ON effect_agent_canonical_batches(thread_id, first_sequence)",
73
- "CREATE INDEX effect_agent_canonical_batches_last ON effect_agent_canonical_batches(thread_id, last_sequence)",
74
74
  "CREATE TABLE effect_agent_live_child_reservations (reservation_id TEXT PRIMARY KEY NOT NULL, thread_id TEXT NOT NULL)",
75
75
  "CREATE INDEX effect_agent_live_child_reservations_thread ON effect_agent_live_child_reservations(thread_id, reservation_id)",
76
76
  // Compact native positions keep accepted identities out of bounded export cursors.
@@ -141,10 +141,15 @@ const inspection = (version: number, statements: ReadonlyArray<string>) =>
141
141
  // Released predecessors retain the same record format and canonical tables.
142
142
  const addedIndexes = new Set(["effect_agent_message_deliveries_peer_retained"]);
143
143
 
144
- const layout25Statements = layoutObjects(layoutStatements)
144
+ const layout27Statements = [
145
+ ...layoutStatements,
146
+ "CREATE INDEX effect_agent_canonical_batches_last ON effect_agent_canonical_batches(thread_id, last_sequence)",
147
+ ];
148
+
149
+ const layout25Statements = layoutObjects(layout27Statements)
145
150
  .filter(([, name]) => !addedIndexes.has(name))
146
151
  .map(([, , index]) =>
147
- layoutStatements[index]
152
+ layout27Statements[index]
148
153
  .replace(", peer_retained_until_millis INTEGER", "")
149
154
  .replace(
150
155
  "'$.envelope.messageAdmission.peerName') IS NOT NULL",
@@ -154,7 +159,8 @@ const layout25Statements = layoutObjects(layoutStatements)
154
159
 
155
160
  const { decode, readObjects, readHeader } = inspection(CurrentDoStorageVersion, layoutStatements);
156
161
  const layout25 = inspection(25, layout25Statements);
157
- const layout26 = inspection(26, layoutStatements);
162
+ const layout26 = inspection(26, layout27Statements);
163
+ const layout27 = inspection(27, layout27Statements);
158
164
 
159
165
  const inspectStorage = Effect.fnUntraced(function* (
160
166
  allowUpgrade = false,
@@ -185,10 +191,10 @@ const inspectStorage = Effect.fnUntraced(function* (
185
191
 
186
192
  const version = legacy.value;
187
193
 
188
- if (allowUpgrade && (version === 25 || version === 26)) {
194
+ if (allowUpgrade && (version === 25 || version === 26 || version === 27)) {
189
195
  if (version === 25 && objects.some(({ name }) => addedIndexes.has(name)))
190
196
  return yield* incompatible(version, "Mixed layout 25 and 26 objects.");
191
- const predecessor = version === 25 ? layout25 : layout26;
197
+ const predecessor = version === 25 ? layout25 : version === 26 ? layout26 : layout27;
192
198
  const header = yield* predecessor.readHeader(objects, version);
193
199
 
194
200
  yield* predecessor.readManagedTriggers(true);
@@ -209,8 +215,8 @@ export const readDoStorageHeader = Effect.fnUntraced(function* () {
209
215
  return header;
210
216
  });
211
217
 
212
- /** Initialize fresh storage or atomically upgrade the exact released layouts 25 and 26. */
213
- export const ensureDoStorageLayout = Effect.fn("DoStorage.initializeLayout")(function* () {
218
+ /** Initialize fresh storage or atomically upgrade the exact released layouts 25–27. */
219
+ const initializeDoStorageLayout = Effect.fn("DoStorage.initializeLayout")(function* () {
214
220
  const sql = (yield* SqlClient.SqlClient).withoutTransforms();
215
221
 
216
222
  return yield* sql
@@ -244,6 +250,9 @@ export const ensureDoStorageLayout = Effect.fn("DoStorage.initializeLayout")(fun
244
250
  }),
245
251
  ),
246
252
  );
253
+ }
254
+ if (header !== undefined && header.layoutVersion < CurrentDoStorageVersion) {
255
+ yield* sql`DROP INDEX effect_agent_canonical_batches_last`;
247
256
  yield* sql`UPDATE effect_agent_schema SET layout_version=${CurrentDoStorageVersion} WHERE singleton=1`;
248
257
  yield* sql`UPDATE effect_agent_meta SET value=${String(CurrentDoStorageVersion)} WHERE key='storage_version'`;
249
258
 
@@ -269,3 +278,36 @@ export const ensureDoStorageLayout = Effect.fn("DoStorage.initializeLayout")(fun
269
278
  )
270
279
  .pipe(Effect.catchTag("SqlError", storageError));
271
280
  });
281
+
282
+ const initializedLayouts = new WeakMap<object, DoStorageHeader>();
283
+
284
+ /** Share committed inspection within one live Object owner; maintenance invalidates it. */
285
+ export const ensureDoStorageLayout = Effect.fnUntraced(function* () {
286
+ const sql = yield* SqlClient.SqlClient;
287
+ const owner = yield* SqlStorageOwner;
288
+
289
+ // A caller-owned transaction can still roll back after this initializer returns.
290
+ if (owner === undefined || Option.isSome(yield* Effect.serviceOption(sql.transactionService)))
291
+ return yield* initializeDoStorageLayout();
292
+
293
+ const identity = owner.identity ?? owner;
294
+
295
+ return yield* owner.read(
296
+ Effect.gen(function* () {
297
+ const previous = initializedLayouts.get(identity);
298
+
299
+ if (previous !== undefined) return previous;
300
+ const header = yield* initializeDoStorageLayout();
301
+
302
+ const invalidate = () => {
303
+ initializedLayouts.delete(identity);
304
+ owner.invalidators.delete(invalidate);
305
+ };
306
+
307
+ initializedLayouts.set(identity, header);
308
+ owner.invalidators.add(invalidate);
309
+
310
+ return header;
311
+ }),
312
+ );
313
+ });
@@ -156,7 +156,7 @@ export const ownedRows = <A, I>(
156
156
 
157
157
  const decode = (rows: unknown) =>
158
158
  decodeRows(rows).pipe(
159
- Effect.mapError(() =>
159
+ Effect.mapErrorEager(() =>
160
160
  DoStorageCorruptionError.make({
161
161
  table,
162
162
  rowKey: "owned-state",
@@ -200,9 +200,8 @@ export const ownedRows = <A, I>(
200
200
 
201
201
  if (view !== undefined) return Effect.succeed(view.rows);
202
202
 
203
- return query.pipe(
204
- Effect.flatMap(decode),
205
- Effect.map((rows) => {
203
+ return Effect.flatMap(query, (rows) =>
204
+ Effect.mapEager(decode(rows), (rows) => {
206
205
  retain(id, matches, rows, maxRows);
207
206
 
208
207
  return rows;
@@ -277,6 +276,14 @@ export const ownedRows = <A, I>(
277
276
  return rows;
278
277
  });
279
278
 
280
- return { by, byFields, matching, seed, write: apply(false), remove: apply(true) };
279
+ return {
280
+ by,
281
+ byFields,
282
+ matching,
283
+ seed,
284
+ seedMatching: retain,
285
+ write: apply(false),
286
+ remove: apply(true),
287
+ };
281
288
  };
282
289
  };