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

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 (37) 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.mjs +116 -65
  10. package/dist/DoSubmissionLedger.mjs.map +1 -1
  11. package/dist/{DoThreadStore-DyzOZDXK.mjs → DoThreadStore-CgIZQUNR.mjs} +24 -19
  12. package/dist/DoThreadStore-CgIZQUNR.mjs.map +1 -0
  13. package/dist/DoThreadStore.mjs +1 -1
  14. package/dist/PortProtocol.d.mts +7063 -7063
  15. package/dist/PortRouting.mjs +6 -1
  16. package/dist/PortRouting.mjs.map +1 -1
  17. package/dist/{do-journal-ZMAzzENZ.mjs → do-journal-7P3DUBz0.mjs} +131 -43
  18. package/dist/do-journal-7P3DUBz0.mjs.map +1 -0
  19. package/dist/index.d.mts +1 -1
  20. package/dist/index.mjs +1 -1
  21. package/dist/{migrations-C3N9GN6A.mjs → migrations-CAO_akhD.mjs} +45 -22
  22. package/dist/migrations-CAO_akhD.mjs.map +1 -0
  23. package/dist/{owned-state-DcFh0YNo.mjs → owned-state-B4QhezMp.mjs} +2 -1
  24. package/dist/{owned-state-DcFh0YNo.mjs.map → owned-state-B4QhezMp.mjs.map} +1 -1
  25. package/package.json +1 -1
  26. package/src/DoStorageFailpoint.ts +4 -0
  27. package/src/DoSubmissionLedger.ts +262 -149
  28. package/src/DoThreadStore.ts +18 -44
  29. package/src/PortRouting.ts +10 -2
  30. package/src/internal/do-journal.ts +286 -66
  31. package/src/internal/migrations.ts +52 -10
  32. package/src/internal/owned-state.ts +9 -1
  33. package/src/internal/prompt-checkpoint.ts +6 -3
  34. package/src/internal/storage-span.ts +14 -10
  35. package/dist/DoThreadStore-DyzOZDXK.mjs.map +0 -1
  36. package/dist/do-journal-ZMAzzENZ.mjs.map +0 -1
  37. package/dist/migrations-C3N9GN6A.mjs.map +0 -1
@@ -18,7 +18,6 @@ import { ThreadImport } from "@yielded/agent/thread-import";
18
18
  import {
19
19
  type ThreadExportRequest,
20
20
  AppendConflict,
21
- AppendResult,
22
21
  CheckpointRejected,
23
22
  ThreadCheckpoint,
24
23
  ThreadMaterialization,
@@ -104,6 +103,11 @@ const isDoCheckpointConflict = Schema.is(DoCheckpointConflict);
104
103
  const ThreadCheckpointJson = Schema.fromJsonString(ThreadCheckpoint);
105
104
  const decodeCanonicalRecord = decodeExportRecordJson;
106
105
 
106
+ const decodeThreadMaterializationType = Schema.decodeEffect(Schema.toType(ThreadMaterialization));
107
+ const decodeThreadReadRequestType = Schema.decodeEffect(Schema.toType(ThreadReadRequest));
108
+ const decodeThreadTailRequestType = Schema.decodeEffect(Schema.toType(ThreadTailRequest));
109
+ const decodeStorageConfig = Schema.decodeEffect(DoStorageConfigValue);
110
+
107
111
  const storeError = (
108
112
  operation: string,
109
113
  error: { readonly message: string; readonly diagnostic?: ThreadStoreDiagnostic },
@@ -126,19 +130,6 @@ const schemaStoreError = (operation: string, error: Schema.SchemaError) =>
126
130
  }),
127
131
  });
128
132
 
129
- const makeOffset = (
130
- threadId: ThreadMaterialization["threadId"],
131
- sequence: number,
132
- ): Effect.Effect<ObservationOffset, ThreadStoreError> =>
133
- Schema.decodeEffect(CanonicalSequence)(sequence).pipe(
134
- Effect.flatMap((validatedSequence) =>
135
- Schema.decodeEffect(ObservationOffset)(
136
- `${DO_OFFSET_PREFIX}${encodeURIComponent(threadId)}:${validatedSequence}`,
137
- ),
138
- ),
139
- Effect.mapError((error) => schemaStoreError("encode observation offset", error)),
140
- );
141
-
142
133
  const parseOffset = Effect.fnUntraced(function* (
143
134
  threadId: ThreadMaterialization["threadId"],
144
135
  offset: ObservationOffset | undefined,
@@ -179,8 +170,8 @@ const encodeCheckpoint = (checkpoint: ThreadCheckpoint): Effect.Effect<string, T
179
170
  const envelopes = new WeakMap<object, CanonicalRecordEnvelope>();
180
171
 
181
172
  const decodeEnvelope = Effect.fnUntraced(function* (row: {
182
- readonly batch_id: string;
183
- readonly thread_id: string;
173
+ readonly batch_id: CanonicalRecordEnvelope["batchId"];
174
+ readonly thread_id: CanonicalRecordEnvelope["threadId"];
184
175
  readonly record_json: string;
185
176
  readonly sequence: CanonicalSequence;
186
177
  }) {
@@ -213,15 +204,11 @@ const decodeEnvelope = Effect.fnUntraced(function* (row: {
213
204
  ),
214
205
  );
215
206
 
216
- const threadId = yield* Schema.decodeEffect(CanonicalRecordEnvelope.fields.threadId)(
217
- row.thread_id,
218
- ).pipe(Effect.mapError((error) => schemaStoreError("decode thread identity", error)));
207
+ const { thread_id: threadId, batch_id: batchId } = row;
219
208
 
220
- const offset = yield* makeOffset(threadId, row.sequence);
221
-
222
- const batchId = yield* Schema.decodeEffect(CanonicalRecordEnvelope.fields.batchId)(
223
- row.batch_id,
224
- ).pipe(Effect.mapError((error) => schemaStoreError("decode batch identity", error)));
209
+ const offset = ObservationOffset.make(
210
+ `${DO_OFFSET_PREFIX}${encodeURIComponent(threadId)}:${row.sequence}`,
211
+ );
225
212
 
226
213
  const envelope = CanonicalRecordEnvelope.make(
227
214
  { threadId, batchId, sequence: row.sequence, offset, record },
@@ -317,9 +304,9 @@ const makeServices = Effect.fnUntraced(function* () {
317
304
  const materialize: ThreadStore["Service"]["materialize"] = Effect.fnUntraced(function* (
318
305
  request: ThreadMaterialization,
319
306
  ) {
320
- const validated = yield* Schema.decodeEffect(Schema.toType(ThreadMaterialization))(
321
- request,
322
- ).pipe(Effect.mapError((error) => schemaStoreError("validate materialization", error)));
307
+ const validated = yield* decodeThreadMaterializationType(request).pipe(
308
+ Effect.mapError((error) => schemaStoreError("validate materialization", error)),
309
+ );
323
310
 
324
311
  const now = yield* Clock.currentTimeMillis;
325
312
 
@@ -362,11 +349,6 @@ const makeServices = Effect.fnUntraced(function* () {
362
349
 
363
350
  return storeError("append canonical batch", error);
364
351
  }),
365
- Effect.flatMap((result) =>
366
- Schema.decodeEffect(AppendResult)(result).pipe(
367
- Effect.mapError((error) => schemaStoreError("decode append result", error)),
368
- ),
369
- ),
370
352
  );
371
353
 
372
354
  yield* hitFailpoint("append:after");
@@ -391,7 +373,7 @@ const makeServices = Effect.fnUntraced(function* () {
391
373
  });
392
374
 
393
375
  const readEffect = Effect.fnUntraced(function* (request: ThreadReadRequest) {
394
- const validated = yield* Schema.decodeEffect(Schema.toType(ThreadReadRequest))(request).pipe(
376
+ const validated = yield* decodeThreadReadRequestType(request).pipe(
395
377
  Effect.mapError((error) => schemaStoreError("validate thread read", error)),
396
378
  );
397
379
 
@@ -503,7 +485,7 @@ const makeServices = Effect.fnUntraced(function* () {
503
485
  const inspectTail: ThreadStore["Service"]["inspectTail"] = Effect.fnUntraced(function* (
504
486
  request: ThreadTailRequest,
505
487
  ) {
506
- const validated = yield* Schema.decodeEffect(Schema.toType(ThreadTailRequest))(request).pipe(
488
+ const validated = yield* decodeThreadTailRequestType(request).pipe(
507
489
  Effect.mapError((error) => schemaStoreError("validate tail inspection", error)),
508
490
  );
509
491
 
@@ -602,15 +584,7 @@ const makeServices = Effect.fnUntraced(function* () {
602
584
  snapshot: journal.state.read,
603
585
  tail: (threadId) =>
604
586
  journal.getThread(threadId).pipe(
605
- Effect.flatMap((rows) =>
606
- Schema.decodeEffect(
607
- Schema.UndefinedOr(
608
- Schema.Struct({
609
- tail_sequence: CanonicalSequence,
610
- }),
611
- ),
612
- )(rows[0]),
613
- ),
587
+ Effect.map((rows) => rows[0]),
614
588
  Effect.mapError((error) => storeError("native thread tail", error)),
615
589
  ),
616
590
  }),
@@ -668,7 +642,7 @@ export const storageConfigLayer = (
668
642
  options: DoStorageOptions,
669
643
  ): Layer.Layer<DoStorageConfig, DoStorageError> =>
670
644
  Layer.effect(DoStorageConfig)(
671
- Schema.decodeEffect(DoStorageConfigValue)({
645
+ decodeStorageConfig({
672
646
  observationPollInterval: options.observationPollInterval ?? 25,
673
647
  maxStoredValueBytes: options.maxStoredValueBytes ?? DEFAULT_MAX_STORED_VALUE_BYTES,
674
648
  verifyOnOpen: options.verifyOnOpen ?? false,
@@ -570,9 +570,9 @@ const makeRoutedLedgerServices = Effect.fnUntraced(function* (options: RoutedPor
570
570
  const routed = SubmissionLedger.of({
571
571
  capabilities: local.capabilities,
572
572
 
573
- admit: (request) =>
573
+ admit: (request, admissionOptions) =>
574
574
  options.ownsThread(request.threadId)
575
- ? local.admit(request)
575
+ ? local.admit(request, admissionOptions)
576
576
  : foreignLedgerCall(
577
577
  "ledger admit",
578
578
  request.threadId,
@@ -824,6 +824,7 @@ const makeRoutedLedgerServices = Effect.fnUntraced(function* (options: RoutedPor
824
824
 
825
825
  // The local scan IS the whole worklist: one Thread per Object (durability §5).
826
826
  scanNonterminal: local.scanNonterminal,
827
+ ...(local.listNonterminal === undefined ? {} : { listNonterminal: local.listNonterminal }),
827
828
 
828
829
  readAbortIntent: (request) =>
829
830
  submissionTarget("ledger read abort intent", request.submissionId).pipe(
@@ -1046,6 +1047,13 @@ const makeRoutedStoreServices = Effect.fnUntraced(function* (options: RoutedPort
1046
1047
  ).pipe(Effect.map((reply) => reply.range)),
1047
1048
  },
1048
1049
  work: {
1050
+ isOnlyFreshAdmission: (request) => {
1051
+ const prove = local.work?.isOnlyFreshAdmission;
1052
+
1053
+ return prove !== undefined && options.ownsThread(request.threadId)
1054
+ ? prove(request)
1055
+ : Effect.succeed(false);
1056
+ },
1049
1057
  threads: (request) =>
1050
1058
  Effect.flatMap(CurrentNativeSource, (nativeSource) =>
1051
1059
  nativeSource && local.work !== undefined
@@ -1,3 +1,4 @@
1
+ import { SqliteClient } from "@effect/sql-sqlite-do";
1
2
  import {
2
3
  makeCanonicalStorage,
3
4
  recordBatchId,
@@ -21,16 +22,29 @@ 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
+ Effect,
37
+ Option,
38
+ Predicate,
39
+ References,
40
+ Result,
41
+ Schema,
42
+ Stream,
43
+ Tracer,
44
+ } from "effect";
32
45
  import * as SqlClient from "effect/sql/SqlClient";
33
- import { SqlError } from "effect/sql/SqlError";
46
+ import { classifySqliteError, SqlError } from "effect/sql/SqlError";
47
+ import { CurrentTransformer, type Statement } from "effect/sql/Statement";
34
48
 
35
49
  import {
36
50
  type DoStorageFailpointError,
@@ -41,6 +55,7 @@ import {
41
55
  DoValueBoundExceeded,
42
56
  type DoStorageFailpointLocation,
43
57
  } from "../DoStorageError.ts";
58
+ import { isDefaultDoStorageFailpoint } from "../DoStorageFailpoint.ts";
44
59
  import { ensureDoStorageLayout } from "./migrations.ts";
45
60
  import { ownedState, ownedRows, type OwnedState } from "./owned-state.ts";
46
61
  import {
@@ -68,6 +83,12 @@ const MAX_RECORD_CACHE_ENTRIES = 4_096;
68
83
  const MAX_BOUND_PARAMETERS = 100;
69
84
  const isSqlError = Schema.is(SqlError);
70
85
 
86
+ const isNativeClient = Schema.is(
87
+ Schema.declare<SqliteClient.SqliteClient>((value): value is SqliteClient.SqliteClient =>
88
+ Predicate.hasProperty(value, SqliteClient.TypeId),
89
+ ),
90
+ );
91
+
71
92
  const storedTextBytes = (value: string): number => new TextEncoder().encode(value).byteLength;
72
93
 
73
94
  const chunked = <A>(values: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> => {
@@ -94,8 +115,8 @@ const threadRows = ownedRows(
94
115
  );
95
116
 
96
117
  class RecordRow extends Schema.Class<RecordRow>("RecordRow")({
97
- batch_id: Schema.fromJsonString(BoundedIdentifier),
98
- thread_id: BoundedIdentifier,
118
+ batch_id: Schema.fromJsonString(BatchId.check(Schema.isMaxLength(MAX_IDENTIFIER_LENGTH))),
119
+ thread_id: ThreadId.check(Schema.isMaxLength(MAX_IDENTIFIER_LENGTH)),
99
120
  record_id: Schema.fromJsonString(BoundedIdentifier),
100
121
  record_json: BoundedStoredText,
101
122
  sequence: CanonicalSequence,
@@ -200,14 +221,6 @@ const initializedLifecycleSources = new WeakSet<OwnedState>();
200
221
 
201
222
  export type { RawAppendRequest } from "@yielded/agent-storage-sql/sql-journal";
202
223
 
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
224
  export class RawReadRequest extends Schema.Class<RawReadRequest>(
212
225
  "@effect-agent/storage-cloudflare/RawReadRequest",
213
226
  )({
@@ -317,10 +330,14 @@ const ensureCurrentStorage = Effect.fnUntraced(function* (
317
330
  failpoint: DoJournalFailpoint = noFailpoint,
318
331
  maxStoredValueBytes: number,
319
332
  ) {
320
- yield* ensureDoStorageLayout().pipe(Effect.provideService(SqlClient.SqlClient, sql));
321
-
322
333
  const state = yield* ownedState(sql);
323
334
  const owner = (yield* SqlStorageOwner) ?? state;
335
+
336
+ yield* ensureDoStorageLayout().pipe(
337
+ Effect.provideService(SqlStorageOwner, owner),
338
+ Effect.provideService(SqlClient.SqlClient, sql),
339
+ );
340
+
324
341
  let journal: DoJournal | undefined;
325
342
 
326
343
  const lifecycle = yield* makeSqlLifecyclePublication(undefined, maxStoredValueBytes).pipe(
@@ -366,6 +383,14 @@ const makeJournal = Effect.fnUntraced(function* (
366
383
  const owner = (yield* SqlStorageOwner) ?? state;
367
384
  const progress = yield* SqlStorageProgress;
368
385
 
386
+ const nativeDb =
387
+ isNativeClient(sql) &&
388
+ sql.config.transformQueryNames === undefined &&
389
+ sql.config.transformResultNames === undefined &&
390
+ isDefaultDoStorageFailpoint(failpoint)
391
+ ? sql.config.storage?.sql
392
+ : undefined;
393
+
369
394
  const work = yield* makeSqlThreadWork({
370
395
  read: (body) => state.read(body),
371
396
  write: (body) =>
@@ -478,13 +503,29 @@ const makeJournal = Effect.fnUntraced(function* (
478
503
  <A, E extends { readonly _tag: string }>(
479
504
  effect: Effect.Effect<A, E>,
480
505
  ): 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
- );
506
+ Effect.withFiber((fiber) => {
507
+ const body = state.owner
508
+ .withMutation(owner.transaction(storageResult(effect, expected)))
509
+ .pipe(
510
+ Effect.provideService(SqlClient.SqlClient, sql),
511
+ Effect.mapError((error) =>
512
+ isSqlError(error) ? storageError(operation)(error) : error,
513
+ ),
514
+ );
515
+
516
+ const traced =
517
+ fiber.getRef(References.TracerEnabled) &&
518
+ fiber.getRef(Tracer.Tracer) !== Tracer.nativeTracer;
519
+
520
+ return (
521
+ traced
522
+ ? body.pipe(
523
+ Effect.tapError(annotateStorageError),
524
+ Effect.withSpan("DoJournal.withWriteTransaction", { attributes: { operation } }),
525
+ )
526
+ : body
527
+ ).pipe(Effect.flatMap(Effect.fromResult));
528
+ });
488
529
 
489
530
  const materialize = Effect.fnUntraced(function* (
490
531
  threadId: string,
@@ -620,9 +661,152 @@ const makeJournal = Effect.fnUntraced(function* (
620
661
  return request;
621
662
  });
622
663
 
664
+ // Replay and progress validation precede this body in the same owner transaction.
665
+ // A miss makes no native change; the ordinary path reuses any tentative batch.
666
+ // After advancing the frontier, failures must roll back rather than fall back.
667
+ const appendNativeBody = Effect.fnUntraced(function* (
668
+ request: RawAppendRequest,
669
+ first: CanonicalSequence,
670
+ last: CanonicalSequence,
671
+ batchInserted: boolean,
672
+ ) {
673
+ const db = nativeDb;
674
+
675
+ // Effectful statement transformers and exported SQL spans retain the ordinary path.
676
+ const ordinaryStatement = yield* Effect.withFiber((fiber) =>
677
+ Effect.succeed(
678
+ fiber.getRef(CurrentTransformer) !== undefined ||
679
+ (fiber.getRef(References.TracerEnabled) &&
680
+ fiber.getRef(Tracer.Tracer) !== Tracer.nativeTracer),
681
+ ),
682
+ );
683
+
684
+ if (ordinaryStatement) return false;
685
+ if (
686
+ db === undefined ||
687
+ request.executionWrites.length !== 1 ||
688
+ request.executionWrites[0]?.update.retire ||
689
+ request.executionWrites[0]?.update.facts.length !== 0
690
+ )
691
+ return false;
692
+
693
+ const workPlan = work.prepareClosedPair(
694
+ request.threadId,
695
+ first,
696
+ last,
697
+ request.records.map((record) => record.canonical),
698
+ );
699
+
700
+ if (workPlan === undefined) return false;
701
+
702
+ const native = yield* Effect.suspend(() => {
703
+ const query = (statement: Statement<object>, operation: string) =>
704
+ Result.try({
705
+ try: () => {
706
+ const [text, parameters] = statement.compile();
707
+
708
+ return db.exec(text, ...parameters).toArray();
709
+ },
710
+ catch: (cause) =>
711
+ storageError(operation)(
712
+ SqlError.make({
713
+ reason: classifySqliteError(cause, {
714
+ operation,
715
+ message: "Native canonical statement failed",
716
+ }),
717
+ }),
718
+ ),
719
+ });
720
+
721
+ return Effect.fromResult(
722
+ Result.gen(function* () {
723
+ const captured = physical.cachedRecords(request.threadId, first, request.records);
724
+
725
+ if (captured === undefined) return undefined;
726
+ const physicalRecords = yield* captured;
727
+ const rows = yield* query(workPlan.advance, "advance native work frontier");
728
+
729
+ // A zero-row predicate miss has made no change. A successful update
730
+ // must never fall back: every later refusal rolls back this transaction.
731
+ if (rows.length === 0) return undefined;
732
+ const closed = yield* workPlan.fold();
733
+ const executionPlans = executions.prepareCommit(request);
734
+
735
+ if (!batchInserted)
736
+ yield* query(
737
+ sql`INSERT INTO effect_agent_canonical_batches
738
+ (thread_id, batch_id, first_sequence, last_sequence, producer_id_json)
739
+ VALUES (${request.threadId}, ${JSON.stringify(request.batchId)}, ${first}, ${last}, ${request.producerIdJson})`,
740
+ "insert canonical batch",
741
+ );
742
+
743
+ const inserted = yield* query(
744
+ sql`INSERT INTO effect_agent_canonical_records ${sql.insert(physicalRecords.map((row) => row.columns))}
745
+ ON CONFLICT(thread_id, record_id) DO NOTHING RETURNING 1`,
746
+ "insert canonical records",
747
+ );
748
+
749
+ if (inserted.length !== request.records.length)
750
+ return yield* Result.fail(
751
+ DoAppendConflict.make({
752
+ message: "A canonical record ID already exists.",
753
+ reason: "record-identity",
754
+ }),
755
+ );
756
+ const memberships = physicalRecords.flatMap((row) => row.memberships);
757
+
758
+ for (const group of chunked(memberships, Math.floor(MAX_BOUND_PARAMETERS / 3)))
759
+ yield* query(
760
+ sql`INSERT INTO effect_agent_record_runs ${sql.insert(group)}`,
761
+ "index canonical Run membership",
762
+ );
763
+ for (const pointer of closed.pointers) yield* query(pointer, "index canonical pointers");
764
+
765
+ for (const plan of executionPlans) {
766
+ const changed = yield* query(plan.statement, "commit active execution");
767
+ const accepted = plan.accept(changed);
768
+
769
+ if (Result.isFailure(accepted))
770
+ return yield* Result.fail(
771
+ DoAppendConflict.make({
772
+ reason: accepted.failure.reason,
773
+ message: "Active execution conflicts with the captured commit",
774
+ ...(accepted.failure.actualTailSequence === undefined
775
+ ? {}
776
+ : { actualTailSequence: accepted.failure.actualTailSequence }),
777
+ }),
778
+ );
779
+ }
780
+ for (let index = 0; index < request.records.length; index++) {
781
+ const record = request.records[index]!;
782
+
783
+ recordCache.put(
784
+ RecordRow.make({
785
+ ...physicalRecords[index]!.columns,
786
+ thread_id: request.threadId,
787
+ record_id: record.recordId,
788
+ batch_id: request.batchId,
789
+ record_json: record.recordJson,
790
+ sequence: CanonicalSequence.make(first + index),
791
+ }),
792
+ record.recordBytes,
793
+ );
794
+ }
795
+
796
+ return rows;
797
+ }),
798
+ );
799
+ });
800
+
801
+ if (native === undefined) return false;
802
+ yield* threads.write(Effect.succeed(native));
803
+
804
+ return true;
805
+ });
806
+
623
807
  const appendPrepared = Effect.fnUntraced(function* (
624
808
  request: RawAppendRequest,
625
- ): Effect.fn.Return<RawAppendResult, AppendError> {
809
+ ): Effect.fn.Return<AppendResult, AppendError> {
626
810
  const threadRows = yield* getThread(request.threadId);
627
811
 
628
812
  const thread = threadRows.length === 1 ? threadRows[0] : undefined;
@@ -634,30 +818,62 @@ const makeJournal = Effect.fnUntraced(function* (
634
818
  message: `Expected exactly one row but found ${threadRows.length}.`,
635
819
  });
636
820
 
637
- const prefix = recordCache.prefix(request.threadId, thread.tail_sequence);
821
+ // A fresh canonical batch key and absence of an active receipt prove the two
822
+ // negative replay lookups in the same statement that retains the batch header.
823
+ // Keep the read-first path for stale/overflowed coordinates so exact retries
824
+ // still precede sequence and tail refusals.
825
+ const canInsert =
826
+ request.records.length > 0 &&
827
+ request.expectedTailSequence === thread.tail_sequence &&
828
+ Number.isSafeInteger(thread.tail_sequence + request.records.length);
829
+
830
+ const inserted = canInsert
831
+ ? yield* sql`INSERT INTO effect_agent_canonical_batches
832
+ (thread_id, batch_id, first_sequence, last_sequence, producer_id_json)
833
+ SELECT ${request.threadId}, ${JSON.stringify(request.batchId)},
834
+ ${thread.tail_sequence + 1}, ${thread.tail_sequence + request.records.length},
835
+ ${request.producerIdJson}
836
+ WHERE NOT EXISTS (
837
+ SELECT 1 FROM effect_agent_executions
838
+ WHERE thread_id=${request.threadId} AND last_commit_id=${request.batchId}
839
+ )
840
+ ON CONFLICT(thread_id, batch_id) DO NOTHING RETURNING 1`.pipe(
841
+ Effect.mapError(storageError("insert canonical batch")),
842
+ )
843
+ : [];
638
844
 
639
- const replay = yield* executionConflict(executions.replay(request));
845
+ if (inserted.length === 0) {
846
+ const replay = yield* executionConflict(executions.replay(request));
640
847
 
641
- if (replay !== undefined) return RawAppendResult.make(replay);
848
+ if (replay !== undefined) return AppendResult.make(replay);
642
849
 
643
- const existing =
644
- prefix !== undefined && !prefix.some((row) => row.batch_id === request.batchId)
645
- ? undefined
646
- : yield* archives.replay(request.threadId, request.batchId);
850
+ const prefix = recordCache.prefix(request.threadId, thread.tail_sequence);
647
851
 
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",
852
+ const existing =
853
+ prefix !== undefined && !prefix.some((row) => row.batch_id === request.batchId)
854
+ ? undefined
855
+ : yield* archives.replay(request.threadId, request.batchId);
856
+
857
+ if (existing !== undefined) {
858
+ if (existing.batch_json !== request.batchJson) {
859
+ return yield* DoAppendConflict.make({
860
+ message: `Batch ${request.batchId} already exists with different canonical content.`,
861
+ reason: "batch-content",
862
+ });
863
+ }
864
+
865
+ return AppendResult.make({
866
+ firstSequence: existing.first_sequence,
867
+ lastSequence: existing.last_sequence,
868
+ replayed: true,
653
869
  });
654
870
  }
655
-
656
- return RawAppendResult.make({
657
- firstSequence: existing.first_sequence,
658
- lastSequence: existing.last_sequence,
659
- replayed: true,
660
- });
871
+ if (canInsert)
872
+ return yield* DoStorageCorruptionError.make({
873
+ table: "effect_agent_canonical_batches",
874
+ rowKey: request.threadId,
875
+ message: "Batch insertion conflicted without a durable replay receipt.",
876
+ });
661
877
  }
662
878
 
663
879
  yield* executionConflict(executions.rejectSuperseded(request));
@@ -671,9 +887,11 @@ const makeJournal = Effect.fnUntraced(function* (
671
887
  });
672
888
  }
673
889
 
674
- const firstSequence = yield* Schema.decodeEffect(CanonicalSequence)(
675
- thread.tail_sequence + (request.records.length === 0 ? 0 : 1),
676
- ).pipe(
890
+ const result = yield* Schema.decodeEffect(AppendResult)({
891
+ firstSequence: thread.tail_sequence + (request.records.length === 0 ? 0 : 1),
892
+ lastSequence: thread.tail_sequence + request.records.length,
893
+ replayed: false,
894
+ }).pipe(
677
895
  Effect.mapError((error) =>
678
896
  DoStorageError.make({
679
897
  cause: error,
@@ -683,27 +901,29 @@ const makeJournal = Effect.fnUntraced(function* (
683
901
  ),
684
902
  );
685
903
 
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
- );
904
+ const { firstSequence, lastSequence } = result;
697
905
 
698
906
  yield* executionConflict(executions.validate(request));
699
907
 
700
- const result = RawAppendResult.make({
701
- firstSequence,
702
- lastSequence,
703
- replayed: false,
704
- });
908
+ if (yield* appendNativeBody(request, firstSequence, lastSequence, inserted.length > 0)) {
909
+ yield* progress.committed("canonical").pipe(
910
+ Effect.catchCause((cause) =>
911
+ Effect.failCause(
912
+ Cause.map(cause, (error) =>
913
+ DoStorageError.make({
914
+ operation: "enroll canonical progress",
915
+ message: error.message,
916
+ cause: error,
917
+ }),
918
+ ),
919
+ ),
920
+ ),
921
+ );
705
922
 
706
- if (request.records.length > 0) {
923
+ return result;
924
+ }
925
+
926
+ if (request.records.length > 0 && inserted.length === 0) {
707
927
  yield* sql`
708
928
  INSERT INTO effect_agent_canonical_batches (
709
929
  thread_id,
@@ -760,6 +980,7 @@ const makeJournal = Effect.fnUntraced(function* (
760
980
  recordCache.put(
761
981
  RecordRow.make({
762
982
  ...row,
983
+ thread_id: request.threadId,
763
984
  record_id: record.recordId,
764
985
  batch_id: request.batchId,
765
986
  record_json: record.recordJson,
@@ -874,7 +1095,7 @@ const makeJournal = Effect.fnUntraced(function* (
874
1095
  function* (
875
1096
  request: RawAppendRequest,
876
1097
  observed: ThreadRow,
877
- ): Effect.fn.Return<RawAppendResult, AppendError> {
1098
+ ): Effect.fn.Return<AppendResult, AppendError> {
878
1099
  yield* prepareAppend(request);
879
1100
  // The facade already read this tail. Reject a known stale writer without opening a
880
1101
  // transaction; a matching tail is still checked atomically below. Replays precede tail
@@ -898,10 +1119,9 @@ const makeJournal = Effect.fnUntraced(function* (
898
1119
  }
899
1120
  }
900
1121
 
901
- return yield* withWriteTransaction(
902
- "append transaction",
903
- isAppendContention,
904
- )(appendPrepared(request));
1122
+ // Receipt arbitration may have tentatively inserted a batch. Even typed
1123
+ // contention must roll back before leaving the owner mutation gate.
1124
+ return yield* withWriteTransaction("append transaction")(appendPrepared(request));
905
1125
  },
906
1126
  withStorageSpan("DoJournal.append", isAppendContention),
907
1127
  );