effect-agent 0.1.0-beta.154 → 0.1.0-beta.156

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.
@@ -3,7 +3,7 @@ import * as SqlClientService from "effect/unstable/sql/SqlClient";
3
3
  import type { SqlError } from "effect/unstable/sql/SqlError";
4
4
 
5
5
  import { utf8ByteLength } from "../core/internal/utf8.ts";
6
- import { MemoryNamespaceAddress } from "../core/MemoryNamespace.ts";
6
+ import { type Any as MemoryNamespace, MemoryNamespaceAddress } from "../core/MemoryNamespace.ts";
7
7
  import {
8
8
  applyMemoryWrite,
9
9
  MemoryDocument,
@@ -14,6 +14,7 @@ import {
14
14
  MemoryReader,
15
15
  MemoryStorageError,
16
16
  MemoryWrite,
17
+ type MemoryWriteError,
17
18
  MemoryWriter,
18
19
  } from "../core/MemoryStore.ts";
19
20
 
@@ -38,6 +39,42 @@ const equivalentContent = Schema.toEquivalence(
38
39
 
39
40
  const equivalentScopes = Schema.toEquivalence(MemoryWrite.Wire.members[0].fields.scopes);
40
41
 
42
+ /** Ordered commands committed atomically by the SQL memory adapter. */
43
+ export const SqlMemoryWriteBatch = Schema.Array(MemoryWrite.Wire).check(Schema.isMaxLength(128));
44
+
45
+ /**
46
+ * SQL-only bulk writing. Results preserve input order, including exact operation replays.
47
+ * Commands see earlier staged revisions; any refusal rolls back the entire batch. Storage
48
+ * limits apply to every intermediate transition. Empty batches succeed without mutation.
49
+ * Supplied by the memory store Layers alongside MemoryWriter's single-command API.
50
+ */
51
+ export class SqlMemoryBatchWriter extends Context.Service<
52
+ SqlMemoryBatchWriter,
53
+ {
54
+ readonly changeMany: <Namespace extends MemoryNamespace>(
55
+ writes: ReadonlyArray<MemoryWrite<Namespace>>,
56
+ ) => Effect.Effect<ReadonlyArray<MemoryDocument<Namespace>>, MemoryWriteError>;
57
+ }
58
+ >()("@effect-agent/thread/SqlMemoryBatchWriter") {}
59
+
60
+ /**
61
+ * An exclusive database owner's transaction gate. All cached services over that database
62
+ * must share this identity, and every failed transaction must run its invalidators before
63
+ * releasing readers. Hosts with independent database writers must leave this unset.
64
+ */
65
+ export interface SqlStorageOwner {
66
+ readonly read: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
67
+ readonly transaction: <A, E, R>(
68
+ effect: Effect.Effect<A, E, R>,
69
+ ) => Effect.Effect<A, E | SqlError, R | SqlClientService.SqlClient>;
70
+ readonly invalidators: Set<() => void>;
71
+ }
72
+
73
+ export const SqlStorageOwner = Context.Reference<SqlStorageOwner | undefined>(
74
+ "@effect-agent/thread/SqlStorageOwner",
75
+ { defaultValue: () => undefined },
76
+ );
77
+
41
78
  /**
42
79
  * Independent adapter limits. Counts and encoded bytes include durable operation receipts.
43
80
  * Bytes count UTF-8 row payloads plus 128 bytes per row, excluding SQLite pages/indexes.
@@ -84,6 +121,80 @@ const UsageRow = Schema.Struct({
84
121
  bytes: Schema.Natural,
85
122
  });
86
123
 
124
+ type DocumentView = { readonly document: MemoryDocument; readonly bytes: number } | null;
125
+ type ReceiptView = { readonly commandJson: string; readonly result: MemoryDocument } | null;
126
+ type MemoryView =
127
+ | { readonly _tag: "Document"; readonly value: DocumentView }
128
+ | { readonly _tag: "Receipt"; readonly value: ReceiptView }
129
+ | { readonly _tag: "Usage"; readonly value: typeof UsageRow.Type };
130
+
131
+ // Demand-loaded views are disposable. The shared owner's gate keeps writes invisible
132
+ // until commit, and its rollback invalidator discards every speculative view together.
133
+ class MemoryViews {
134
+ private readonly entries = new Map<
135
+ string,
136
+ { readonly view: MemoryView; readonly bytes: number }
137
+ >();
138
+ private bytes = 0;
139
+
140
+ readonly clear = () => {
141
+ this.entries.clear();
142
+ this.bytes = 0;
143
+ };
144
+
145
+ get(key: string): MemoryView | undefined {
146
+ const entry = this.entries.get(key);
147
+
148
+ if (entry === undefined) return undefined;
149
+ this.entries.delete(key);
150
+ this.entries.set(key, entry);
151
+
152
+ return entry.view;
153
+ }
154
+
155
+ set(key: string, view: MemoryView): void {
156
+ const previous = this.entries.get(key);
157
+
158
+ if (previous !== undefined) this.bytes -= previous.bytes;
159
+ this.entries.delete(key);
160
+ const bytes = utf8ByteLength(key) + utf8ByteLength(JSON.stringify(view)) + 128;
161
+
162
+ if (bytes > 4 * 1024 * 1024) return;
163
+ this.entries.set(key, { view, bytes });
164
+ this.bytes += bytes;
165
+ while (this.entries.size > 128 || this.bytes > 4 * 1024 * 1024) {
166
+ const oldest = this.entries.entries().next().value;
167
+
168
+ if (oldest === undefined) break;
169
+ this.bytes -= oldest[1].bytes;
170
+ this.entries.delete(oldest[0]);
171
+ }
172
+ }
173
+ }
174
+
175
+ const ownedViews = new WeakMap<SqlStorageOwner, MemoryViews>();
176
+
177
+ const memoryViews = Effect.fn("SqliteMemoryStore.memoryViews")(function* () {
178
+ const owner = yield* SqlStorageOwner;
179
+
180
+ if (owner === undefined) return undefined;
181
+ let views = ownedViews.get(owner);
182
+
183
+ if (views === undefined) {
184
+ views = new MemoryViews();
185
+ ownedViews.set(owner, views);
186
+ owner.invalidators.add(views.clear);
187
+ }
188
+
189
+ return views;
190
+ });
191
+
192
+ const documentViewKey = (key: MemoryKey) =>
193
+ JSON.stringify(["document", key.namespace.address, key.id]);
194
+
195
+ const receiptViewKey = (write: MemoryWrite) =>
196
+ JSON.stringify(["receipt", write.key.namespace.address, write.operationId]);
197
+
87
198
  // These expressions deliberately match the legacy aggregate's logical byte accounting.
88
199
  const documentBytesSql = (row: string) =>
89
200
  `length(CAST(${row}.namespace AS BLOB)) + length(CAST(${row}.source_id AS BLOB)) + length(CAST(${row}.document_json AS BLOB)) + 128`;
@@ -296,6 +407,7 @@ const validateReceiptResult = Effect.fn("SqliteMemoryStore.validateReceiptResult
296
407
 
297
408
  const readUsage = Effect.fn("SqliteMemoryStore.readUsage")(function* () {
298
409
  const sql = yield* SqlClientService.SqlClient;
410
+ const views = yield* memoryViews();
299
411
  const operation = "read memory usage";
300
412
 
301
413
  const rows = yield* query(
@@ -307,6 +419,8 @@ const readUsage = Effect.fn("SqliteMemoryStore.readUsage")(function* () {
307
419
 
308
420
  if (rows.length !== 1) return yield* storageError(operation, "corrupt");
309
421
 
422
+ views?.set("usage", { _tag: "Usage", value: rows[0] });
423
+
310
424
  return rows[0];
311
425
  });
312
426
 
@@ -366,55 +480,56 @@ const initializeMemoryUsage = Effect.fn("SqliteMemoryStore.initializeUsage")(fun
366
480
 
367
481
  const initializeMemorySchema = Effect.fn("SqliteMemoryStore.initialize")(function* () {
368
482
  const sql = yield* SqlClientService.SqlClient;
483
+ const owner = yield* SqlStorageOwner;
484
+ const transaction = owner === undefined ? sql.withTransaction : owner.transaction;
369
485
  const failpoint = yield* MemoryMutationFailpoint;
370
486
 
371
487
  yield* failpoint.hit("memory:initialize:before");
372
- yield* sql
373
- .withTransaction(
374
- Effect.gen(function* () {
375
- yield* sql`
488
+ yield* transaction(
489
+ Effect.gen(function* () {
490
+ yield* sql`
376
491
  CREATE TABLE IF NOT EXISTS effect_agent_memory_metadata (
377
492
  component TEXT PRIMARY KEY NOT NULL,
378
493
  version INTEGER NOT NULL
379
494
  )
380
495
  `;
381
496
 
382
- const metadataRows = yield* sql<Record<string, unknown>>`
497
+ const metadataRows = yield* sql<Record<string, unknown>>`
383
498
  SELECT version
384
499
  FROM effect_agent_memory_metadata
385
500
  WHERE component = ${METADATA_COMPONENT}
386
501
  `;
387
502
 
388
- const metadata = yield* decodeRows(
389
- MemoryMetadataRow,
390
- metadataRows,
391
- "decode memory schema version",
392
- );
393
-
394
- if (metadata.length > 1)
395
- return yield* storageError("decode memory schema version", "corrupt");
396
- const currentVersion = metadata[0]?.version;
397
-
398
- if (currentVersion !== undefined && currentVersion !== STORAGE_VERSION) {
399
- return yield* storageError("initialize memory schema", "incompatible");
400
- }
401
- if (currentVersion === undefined) {
402
- const tableRows = yield* sql<Record<string, unknown>>`
503
+ const metadata = yield* decodeRows(
504
+ MemoryMetadataRow,
505
+ metadataRows,
506
+ "decode memory schema version",
507
+ );
508
+
509
+ if (metadata.length > 1)
510
+ return yield* storageError("decode memory schema version", "corrupt");
511
+ const currentVersion = metadata[0]?.version;
512
+
513
+ if (currentVersion !== undefined && currentVersion !== STORAGE_VERSION) {
514
+ return yield* storageError("initialize memory schema", "incompatible");
515
+ }
516
+ if (currentVersion === undefined) {
517
+ const tableRows = yield* sql<Record<string, unknown>>`
403
518
  SELECT name
404
519
  FROM sqlite_master
405
520
  WHERE type = 'table' AND name IN (${DOCUMENT_TABLE}, ${RECEIPT_TABLE})
406
521
  `;
407
522
 
408
- const existingTables = yield* decodeRows(
409
- MemoryTableRow,
410
- tableRows,
411
- "inspect memory schema",
412
- );
523
+ const existingTables = yield* decodeRows(
524
+ MemoryTableRow,
525
+ tableRows,
526
+ "inspect memory schema",
527
+ );
413
528
 
414
- if (existingTables.length > 0) {
415
- return yield* storageError("initialize memory schema", "incompatible");
416
- }
417
- yield* sql`
529
+ if (existingTables.length > 0) {
530
+ return yield* storageError("initialize memory schema", "incompatible");
531
+ }
532
+ yield* sql`
418
533
  CREATE TABLE effect_agent_memory_documents_v1 (
419
534
  namespace TEXT NOT NULL,
420
535
  source_id TEXT NOT NULL,
@@ -425,7 +540,7 @@ const initializeMemorySchema = Effect.fn("SqliteMemoryStore.initialize")(functio
425
540
  PRIMARY KEY (namespace, source_id)
426
541
  )
427
542
  `;
428
- yield* sql`
543
+ yield* sql`
429
544
  CREATE TABLE effect_agent_memory_receipts_v1 (
430
545
  namespace TEXT NOT NULL,
431
546
  operation_id TEXT NOT NULL,
@@ -436,176 +551,296 @@ const initializeMemorySchema = Effect.fn("SqliteMemoryStore.initialize")(functio
436
551
  PRIMARY KEY (namespace, operation_id)
437
552
  )
438
553
  `;
439
- yield* sql`
554
+ yield* sql`
440
555
  INSERT INTO effect_agent_memory_metadata (component, version)
441
556
  VALUES (${METADATA_COMPONENT}, ${STORAGE_VERSION})
442
557
  `;
443
- }
444
- yield* sql`
558
+ }
559
+ yield* sql`
445
560
  SELECT namespace, source_id, format_version, generation, revision, document_json
446
561
  FROM effect_agent_memory_documents_v1
447
562
  LIMIT 0
448
563
  `;
449
- yield* sql`
564
+ yield* sql`
450
565
  SELECT namespace, operation_id, source_id, format_version, command_json, result_json
451
566
  FROM effect_agent_memory_receipts_v1
452
567
  LIMIT 0
453
568
  `;
454
- yield* initializeMemoryUsage();
455
- }),
456
- )
457
- .pipe(Effect.catchTag("SqlError", () => Effect.fail(storageError("initialize memory schema"))));
569
+ yield* initializeMemoryUsage();
570
+ }),
571
+ ).pipe(Effect.catchTag("SqlError", () => Effect.fail(storageError("initialize memory schema"))));
458
572
  yield* failpoint.hit("memory:initialize:after");
459
573
  });
460
574
 
461
575
  const makeMemoryReader = Effect.fn("SqliteMemoryStore.makeReader")(function* () {
462
576
  const sql = yield* SqlClientService.SqlClient;
577
+ const owner = yield* SqlStorageOwner;
578
+ const views = yield* memoryViews();
579
+
580
+ const readDocuments = Effect.fn("SqliteMemoryStore.readDocuments")(
581
+ function* (
582
+ keys: ReadonlyArray<MemoryKey>,
583
+ operation: string,
584
+ ): Effect.fn.Return<ReadonlyMap<string, DocumentView>, MemoryStorageError> {
585
+ const found = new Map<string, DocumentView>();
586
+ const missing = new Map<string, MemoryKey>();
587
+
588
+ for (const key of keys) {
589
+ const viewKey = documentViewKey(key);
590
+ const cached = views?.get(viewKey);
591
+
592
+ if (cached?._tag === "Document") found.set(viewKey, cached.value);
593
+ else missing.set(viewKey, key);
594
+ }
595
+ const pending = Array.from(missing);
596
+
597
+ // Each exact composite key uses two bindings. No history or unrelated rows are loaded.
598
+ for (let index = 0; index < pending.length; index += 50) {
599
+ const requested = new Map(pending.slice(index, index + 50));
600
+
601
+ const rawRows = yield* query(
602
+ sql<Record<string, unknown>>`
603
+ SELECT namespace, source_id, format_version, generation, revision, document_json,
604
+ length(CAST(namespace AS BLOB)) + length(CAST(source_id AS BLOB)) +
605
+ length(CAST(document_json AS BLOB)) + 128 AS stored_bytes
606
+ FROM effect_agent_memory_documents_v1
607
+ WHERE ${sql.or(
608
+ Array.from(
609
+ requested.values(),
610
+ (key) => sql`(namespace = ${key.namespace.address} AND source_id = ${key.id})`,
611
+ ),
612
+ )}
613
+ `,
614
+ operation,
615
+ );
463
616
 
464
- const readDocument = Effect.fn("SqliteMemoryStore.readDocument")(function* (
465
- key: MemoryKey,
466
- operation: string,
467
- ): Effect.fn.Return<
468
- { readonly document: MemoryDocument; readonly bytes: number } | null,
469
- MemoryStorageError
470
- > {
471
- const rawRows = yield* query(
472
- sql<Record<string, unknown>>`
473
- SELECT namespace, source_id, format_version, generation, revision, document_json,
474
- length(CAST(namespace AS BLOB)) + length(CAST(source_id AS BLOB)) +
475
- length(CAST(document_json AS BLOB)) + 128 AS stored_bytes
476
- FROM effect_agent_memory_documents_v1
477
- WHERE namespace = ${key.namespace.address} AND source_id = ${key.id}
478
- `,
479
- operation,
480
- );
617
+ const rows = yield* decodeRows(MemoryDocumentRow, rawRows, operation);
481
618
 
482
- const rows = yield* decodeRows(MemoryDocumentRow, rawRows, operation);
619
+ for (const row of rows) {
620
+ const viewKey = JSON.stringify(["document", row.namespace, row.source_id]);
621
+ const key = requested.get(viewKey);
483
622
 
484
- if (rows.length === 0) return null;
485
- if (rows.length !== 1) return yield* storageError(operation, "corrupt");
486
- const row = rows[0];
623
+ if (key === undefined || found.has(viewKey))
624
+ return yield* storageError(operation, "corrupt");
625
+ if (row.format_version !== STORAGE_VERSION)
626
+ return yield* storageError(operation, "incompatible");
487
627
 
488
- if (row.format_version !== STORAGE_VERSION) {
489
- return yield* storageError(operation, "incompatible");
490
- }
491
- const stored = yield* decodeVersionedJson(StoredMemoryResult, row.document_json, operation);
492
- const document = stored.value;
628
+ const stored = yield* decodeVersionedJson(
629
+ StoredMemoryResult,
630
+ row.document_json,
631
+ operation,
632
+ );
493
633
 
494
- yield* validateDocument(document, key, operation);
495
- if (
496
- row.namespace !== key.namespace.address ||
497
- row.source_id !== key.id ||
498
- row.generation !== document.generation ||
499
- row.revision !== document.source.revision
500
- ) {
501
- return yield* storageError(operation, "corrupt");
502
- }
634
+ const document = stored.value;
503
635
 
504
- return { document, bytes: row.stored_bytes };
505
- });
636
+ yield* validateDocument(document, key, operation);
637
+ if (row.generation !== document.generation || row.revision !== document.source.revision)
638
+ return yield* storageError(operation, "corrupt");
639
+ found.set(viewKey, { document, bytes: row.stored_bytes });
640
+ }
641
+
642
+ for (const viewKey of requested.keys()) {
643
+ const value = found.get(viewKey) ?? null;
644
+
645
+ found.set(viewKey, value);
646
+ views?.set(viewKey, { _tag: "Document", value });
647
+ }
648
+ }
649
+
650
+ return found;
651
+ },
652
+ (effect) => (owner === undefined ? effect : owner.read(effect)),
653
+ );
506
654
 
507
655
  const get = Effect.fn("SqliteMemoryStore.get")(function* (key: MemoryKey) {
508
656
  const decodedKey = yield* decodeInput(MemoryKey.Wire, key, "get memory document");
509
657
 
510
- return (yield* readDocument(decodedKey, "get memory document"))?.document ?? null;
658
+ const documents = yield* readDocuments([decodedKey], "get memory document");
659
+
660
+ return documents.get(documentViewKey(decodedKey))?.document ?? null;
511
661
  });
512
662
 
513
- return { get, readDocument };
663
+ return { get, readDocuments };
514
664
  });
515
665
 
516
666
  const makeMemoryServices = Effect.fn("SqliteMemoryStore.make")(function* () {
517
667
  const sql = yield* SqlClientService.SqlClient;
518
- const usage = readUsage().pipe(Effect.provideService(SqlClientService.SqlClient, sql));
668
+ const owner = yield* SqlStorageOwner;
669
+ const views = yield* memoryViews();
670
+
671
+ const usage = readUsage().pipe(
672
+ Effect.provideService(SqlClientService.SqlClient, sql),
673
+ Effect.provideService(SqlStorageOwner, owner),
674
+ );
675
+
676
+ const transaction = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
677
+ owner === undefined
678
+ ? sql.withTransaction(effect)
679
+ : owner.transaction(effect).pipe(Effect.provideService(SqlClientService.SqlClient, sql));
680
+
519
681
  const failpoint = yield* MemoryMutationFailpoint;
520
682
  const limits = yield* decodeInput(Limits, yield* SqlMemoryLimits, "memory storage limits");
521
683
 
522
684
  yield* initializeMemorySchema();
523
- const { get, readDocument } = yield* makeMemoryReader();
685
+ const { get, readDocuments } = yield* makeMemoryReader();
524
686
 
525
- const readReceipt = Effect.fn("SqliteMemoryStore.readReceipt")(function* (
526
- write: MemoryWrite,
687
+ const readReceipts = Effect.fn("SqliteMemoryStore.readReceipts")(function* (
688
+ writes: ReadonlyArray<MemoryWrite>,
527
689
  operation: string,
528
- ) {
529
- const rawRows = yield* query(
530
- sql<Record<string, unknown>>`
531
- SELECT namespace, operation_id, source_id, format_version, command_json, result_json
532
- FROM effect_agent_memory_receipts_v1
533
- WHERE namespace = ${write.key.namespace.address} AND operation_id = ${write.operationId}
534
- `,
535
- operation,
536
- );
537
-
538
- const rows = yield* decodeRows(MemoryReceiptRow, rawRows, operation);
690
+ ): Effect.fn.Return<ReadonlyMap<string, ReceiptView>, MemoryStorageError> {
691
+ const found = new Map<string, ReceiptView>();
692
+ const missing = new Map<string, MemoryWrite>();
539
693
 
540
- if (rows.length === 0) return null;
541
- if (rows.length !== 1) return yield* storageError(operation, "corrupt");
542
- const row = rows[0];
694
+ for (const write of writes) {
695
+ const viewKey = receiptViewKey(write);
696
+ const cached = views?.get(viewKey);
543
697
 
544
- if (row.format_version !== STORAGE_VERSION) {
545
- return yield* storageError(operation, "incompatible");
698
+ if (cached?._tag === "Receipt") found.set(viewKey, cached.value);
699
+ else missing.set(viewKey, write);
546
700
  }
701
+ const pending = Array.from(missing);
547
702
 
548
- const command = yield* decodeVersionedJson(
549
- StoredMemoryCommand,
550
- row.command_json,
551
- `${operation} command`,
552
- );
703
+ for (let index = 0; index < pending.length; index += 50) {
704
+ const requested = new Map(pending.slice(index, index + 50));
553
705
 
554
- const result = yield* decodeVersionedJson(
555
- StoredMemoryResult,
556
- row.result_json,
557
- `${operation} result`,
558
- );
706
+ const rawRows = yield* query(
707
+ sql<Record<string, unknown>>`
708
+ SELECT namespace, operation_id, source_id, format_version, command_json, result_json
709
+ FROM effect_agent_memory_receipts_v1
710
+ WHERE ${sql.or(
711
+ Array.from(
712
+ requested.values(),
713
+ (write) =>
714
+ sql`(namespace = ${write.key.namespace.address} AND operation_id = ${write.operationId})`,
715
+ ),
716
+ )}
717
+ `,
718
+ operation,
719
+ );
720
+
721
+ const rows = yield* decodeRows(MemoryReceiptRow, rawRows, operation);
722
+
723
+ for (const row of rows) {
724
+ const viewKey = JSON.stringify(["receipt", row.namespace, row.operation_id]);
725
+
726
+ if (!requested.has(viewKey) || found.has(viewKey))
727
+ return yield* storageError(operation, "corrupt");
728
+ if (row.format_version !== STORAGE_VERSION)
729
+ return yield* storageError(operation, "incompatible");
730
+
731
+ const command = yield* decodeVersionedJson(
732
+ StoredMemoryCommand,
733
+ row.command_json,
734
+ `${operation} command`,
735
+ );
559
736
 
560
- if (
561
- row.namespace !== command.value.key.namespace.address ||
562
- row.operation_id !== command.value.operationId ||
563
- row.source_id !== command.value.key.id ||
564
- result.value.key.namespace.address !== command.value.key.namespace.address ||
565
- result.value.key.id !== command.value.key.id
566
- ) {
567
- return yield* storageError(operation, "corrupt");
737
+ const result = yield* decodeVersionedJson(
738
+ StoredMemoryResult,
739
+ row.result_json,
740
+ `${operation} result`,
741
+ );
742
+
743
+ if (
744
+ row.namespace !== command.value.key.namespace.address ||
745
+ row.operation_id !== command.value.operationId ||
746
+ row.source_id !== command.value.key.id ||
747
+ result.value.key.namespace.address !== command.value.key.namespace.address ||
748
+ result.value.key.id !== command.value.key.id
749
+ ) {
750
+ return yield* storageError(operation, "corrupt");
751
+ }
752
+ yield* validateDocument(result.value, command.value.key, operation);
753
+ yield* validateReceiptResult(command.value, result.value, operation);
754
+ found.set(viewKey, { commandJson: row.command_json, result: result.value });
755
+ }
756
+
757
+ for (const viewKey of requested.keys()) {
758
+ const value = found.get(viewKey) ?? null;
759
+
760
+ found.set(viewKey, value);
761
+ views?.set(viewKey, { _tag: "Receipt", value });
762
+ }
568
763
  }
569
- yield* validateDocument(result.value, command.value.key, operation);
570
- yield* validateReceiptResult(command.value, result.value, operation);
571
764
 
572
- return { commandJson: row.command_json, result: result.value };
765
+ return found;
573
766
  });
574
767
 
575
- const change = Effect.fn("SqliteMemoryStore.change")(function* (write: MemoryWrite) {
768
+ const changeMany = Effect.fn("SqliteMemoryStore.changeMany")(function* (
769
+ writes: ReadonlyArray<MemoryWrite>,
770
+ ) {
576
771
  const operation = "change memory document";
577
- const decodedWrite = yield* decodeInput(MemoryWrite.Wire, write, operation);
772
+ const decodedWrites = yield* decodeInput(SqlMemoryWriteBatch, writes, operation);
578
773
 
579
- const commandJson = yield* encodeJson(
580
- StoredMemoryCommand,
581
- StoredMemoryCommand.make({ version: STORAGE_VERSION, value: decodedWrite }),
582
- "encode memory command",
774
+ if (decodedWrites.length === 0) return [];
775
+
776
+ const commands = yield* Effect.forEach(decodedWrites, (write) =>
777
+ encodeJson(
778
+ StoredMemoryCommand,
779
+ StoredMemoryCommand.make({ version: STORAGE_VERSION, value: write }),
780
+ "encode memory command",
781
+ ).pipe(Effect.map((commandJson) => ({ write, commandJson }))),
583
782
  );
584
783
 
585
784
  yield* failpoint.hit("memory:change:before");
586
785
 
587
786
  // The adapter must serialize before reading receipts and state. Node uses BEGIN
588
787
  // IMMEDIATE; Durable Objects use the driver's storage-backed transaction and permit.
589
- const transactionResult = yield* sql
590
- .withTransaction(
591
- Effect.gen(function* () {
592
- const receipt = yield* readReceipt(decodedWrite, operation);
788
+ const transactionResult = yield* transaction(
789
+ Effect.gen(function* () {
790
+ const retainedReceipts = yield* readReceipts(decodedWrites, operation);
791
+
792
+ const retainedDocuments = yield* readDocuments(
793
+ decodedWrites
794
+ .filter((write) => retainedReceipts.get(receiptViewKey(write)) === null)
795
+ .map((write) => write.key),
796
+ operation,
797
+ );
798
+
799
+ const documents = new Map<
800
+ string,
801
+ {
802
+ readonly document: MemoryDocument;
803
+ readonly bytes: number;
804
+ readonly documentJson: string;
805
+ }
806
+ >();
807
+
808
+ const receipts = new Map<
809
+ string,
810
+ {
811
+ readonly write: MemoryWrite;
812
+ readonly commandJson: string;
813
+ readonly resultJson: string;
814
+ readonly result: MemoryDocument;
815
+ }
816
+ >();
817
+
818
+ const results: Array<MemoryDocument> = [];
819
+ let projectedUsage: typeof UsageRow.Type | undefined;
820
+
821
+ for (const { write, commandJson } of commands) {
822
+ const receiptKey = receiptViewKey(write);
823
+ const receipt = receipts.get(receiptKey) ?? retainedReceipts.get(receiptKey) ?? null;
593
824
 
594
825
  if (receipt !== null) {
595
826
  if (receipt.commandJson !== commandJson) {
596
827
  return yield* MemoryOperationConflict.make({
597
- key: decodedWrite.key,
598
- operationId: decodedWrite.operationId,
828
+ key: write.key,
829
+ operationId: write.operationId,
599
830
  });
600
831
  }
601
-
602
- return { document: receipt.result, changed: false } as const;
832
+ results.push(receipt.result);
833
+ continue;
603
834
  }
604
835
 
605
- const currentRow = yield* readDocument(decodedWrite.key, operation);
836
+ const documentKey = documentViewKey(write.key);
837
+
838
+ const currentRow =
839
+ documents.get(documentKey) ?? retainedDocuments.get(documentKey) ?? null;
840
+
606
841
  const current = currentRow?.document ?? null;
607
842
  const modifiedAt = yield* Clock.currentTimeMillis;
608
- const next = yield* applyMemoryWrite(current, decodedWrite, modifiedAt);
843
+ const next = yield* applyMemoryWrite(current, write, modifiedAt);
609
844
 
610
845
  const resultJson = yield* encodeJson(
611
846
  StoredMemoryResult,
@@ -623,17 +858,16 @@ const makeMemoryServices = Effect.fn("SqliteMemoryStore.make")(function* () {
623
858
  const documentBytes = identityBytes + resultBytes + 128;
624
859
 
625
860
  const receiptBytes =
626
- identityBytes +
627
- bytes(decodedWrite.operationId) +
628
- bytes(commandJson) +
629
- resultBytes +
630
- 128;
861
+ identityBytes + bytes(write.operationId) + bytes(commandJson) + resultBytes + 128;
631
862
 
632
863
  if (Math.max(documentBytes, receiptBytes) > limits.maxRowBytes) {
633
864
  return yield* storageError("memory row byte limit", "invalid-input");
634
865
  }
635
866
 
636
- const used = yield* usage;
867
+ const cachedUsage = views?.get("usage");
868
+
869
+ const used =
870
+ projectedUsage ?? (cachedUsage?._tag === "Usage" ? cachedUsage.value : yield* usage);
637
871
 
638
872
  if (used.bytes < (currentRow?.bytes ?? 0) || (current !== null && used.documents === 0)) {
639
873
  return yield* storageError("read memory usage", "corrupt");
@@ -641,14 +875,14 @@ const makeMemoryServices = Effect.fn("SqliteMemoryStore.make")(function* () {
641
875
 
642
876
  const maxReceipts =
643
877
  limits.maxReceipts -
644
- (decodedWrite._tag === "Put" ? (limits.reservedWithdrawalReceipts ?? 0) : 0);
878
+ (write._tag === "Put" ? (limits.reservedWithdrawalReceipts ?? 0) : 0);
645
879
 
646
880
  const maxStorageBytes =
647
881
  limits.maxStorageBytes -
648
- (decodedWrite._tag === "Put" ? (limits.reservedWithdrawalBytes ?? 0) : 0);
882
+ (write._tag === "Put" ? (limits.reservedWithdrawalBytes ?? 0) : 0);
649
883
 
650
- // Subtract the persisted old row before adding its replacement. Subtraction
651
- // comparisons avoid overflowing safe integer budgets near unbounded defaults.
884
+ // Check every staged transition, including intermediate document sizes and
885
+ // receipts. A later shrinking command cannot evade the earlier write's limits.
652
886
  if (
653
887
  used.documents > limits.maxDocuments - (current === null ? 1 : 0) ||
654
888
  used.receipts >= maxReceipts ||
@@ -657,74 +891,114 @@ const makeMemoryServices = Effect.fn("SqliteMemoryStore.make")(function* () {
657
891
  return yield* storageError("memory storage limit", "invalid-input");
658
892
  }
659
893
 
660
- if (current === null) {
661
- yield* sql`
662
- INSERT INTO effect_agent_memory_documents_v1 (
663
- namespace, source_id, format_version, generation, revision, document_json
664
- ) VALUES (
665
- ${next.key.namespace.address}, ${next.key.id}, ${STORAGE_VERSION},
666
- ${next.generation}, ${next.source.revision}, ${documentJson}
667
- )
668
- `;
669
- } else {
670
- yield* sql`
671
- UPDATE effect_agent_memory_documents_v1
672
- SET format_version = ${STORAGE_VERSION},
673
- generation = ${next.generation},
674
- revision = ${next.source.revision},
675
- document_json = ${documentJson}
676
- WHERE namespace = ${next.key.namespace.address}
677
- AND source_id = ${next.key.id}
678
- AND generation = ${current.generation}
679
- AND revision = ${current.source.revision}
680
- `;
681
- }
894
+ projectedUsage = {
895
+ documents: used.documents + (current === null ? 1 : 0),
896
+ receipts: used.receipts + 1,
897
+ bytes: used.bytes - (currentRow?.bytes ?? 0) + documentBytes + receiptBytes,
898
+ };
899
+ documents.set(documentKey, { document: next, bytes: documentBytes, documentJson });
900
+ receipts.set(receiptKey, { write, commandJson, resultJson, result: next });
901
+ results.push(next);
902
+ }
903
+
904
+ if (projectedUsage === undefined) return { documents: results, changed: false };
905
+
906
+ // Both persisted rows have six bound columns: sixteen rows stay below the DO
907
+ // driver's 100-parameter limit. The surrounding transaction owns all revision
908
+ // checks, and only the final row for each key needs physical writeback.
909
+ const documentRows = Array.from(documents.values(), ({ document, documentJson }) => ({
910
+ namespace: document.key.namespace.address,
911
+ source_id: document.key.id,
912
+ format_version: STORAGE_VERSION,
913
+ generation: document.generation,
914
+ revision: document.source.revision,
915
+ document_json: documentJson,
916
+ }));
917
+
918
+ for (let index = 0; index < documentRows.length; index += 16) {
919
+ const chunk = documentRows.slice(index, index + 16);
682
920
 
683
921
  const changedRows = yield* sql<Record<string, unknown>>`
684
- SELECT changes() AS changed
685
- `;
922
+ INSERT INTO effect_agent_memory_documents_v1 ${sql.insert(chunk)}
923
+ ON CONFLICT (namespace, source_id) DO UPDATE SET
924
+ format_version = excluded.format_version,
925
+ generation = excluded.generation,
926
+ revision = excluded.revision,
927
+ document_json = excluded.document_json
928
+ RETURNING 1 AS changed
929
+ `;
686
930
 
687
931
  const changed = yield* decodeRows(MemoryChangeCountRow, changedRows, operation);
688
932
 
689
- if (changed.length !== 1 || changed[0].changed !== 1) {
933
+ if (changed.length !== chunk.length || changed.some((row) => row.changed !== 1)) {
690
934
  return yield* storageError(operation, "corrupt");
691
935
  }
692
- yield* failpoint.hit("memory:change:after-state");
936
+ }
937
+ yield* failpoint.hit("memory:change:after-state");
938
+
939
+ const receiptRows = Array.from(receipts.values(), ({ write, commandJson, resultJson }) => ({
940
+ namespace: write.key.namespace.address,
941
+ operation_id: write.operationId,
942
+ source_id: write.key.id,
943
+ format_version: STORAGE_VERSION,
944
+ command_json: commandJson,
945
+ result_json: resultJson,
946
+ }));
947
+
948
+ for (let index = 0; index < receiptRows.length; index += 16) {
693
949
  yield* sql`
694
- INSERT INTO effect_agent_memory_receipts_v1 (
695
- namespace, operation_id, source_id, format_version, command_json, result_json
696
- ) VALUES (
697
- ${decodedWrite.key.namespace.address}, ${decodedWrite.operationId}, ${decodedWrite.key.id},
698
- ${STORAGE_VERSION}, ${commandJson}, ${resultJson}
699
- )
700
- `;
701
- yield* failpoint.hit("memory:change:after-receipt");
702
-
703
- return { document: next, changed: true } as const;
704
- }),
705
- )
706
- .pipe(Effect.catchTag("SqlError", () => Effect.fail(storageError(operation))));
950
+ INSERT INTO effect_agent_memory_receipts_v1 ${sql.insert(receiptRows.slice(index, index + 16))}
951
+ `;
952
+ }
953
+ yield* failpoint.hit("memory:change:after-receipt");
954
+
955
+ for (const [key, { document, bytes }] of documents)
956
+ views?.set(key, { _tag: "Document", value: { document, bytes } });
957
+ for (const [key, { commandJson, result }] of receipts)
958
+ views?.set(key, { _tag: "Receipt", value: { commandJson, result } });
959
+ views?.set("usage", { _tag: "Usage", value: projectedUsage });
960
+
961
+ return { documents: results, changed: true };
962
+ }),
963
+ ).pipe(Effect.catchTag("SqlError", () => Effect.fail(storageError(operation))));
707
964
 
708
965
  if (transactionResult.changed) yield* failpoint.hit("memory:change:after");
709
966
 
710
- return transactionResult.document;
967
+ return transactionResult.documents;
968
+ });
969
+
970
+ const change = Effect.fn("SqliteMemoryStore.change")(function* (write: MemoryWrite) {
971
+ return (yield* changeMany([write]))[0];
972
+ });
973
+
974
+ const batchWriter = SqlMemoryBatchWriter.of({
975
+ changeMany: Effect.fn("SqlMemoryBatchWriter.changeMany")(function* <
976
+ Namespace extends MemoryNamespace,
977
+ >(writes: ReadonlyArray<MemoryWrite<Namespace>>) {
978
+ const documents = yield* changeMany(writes);
979
+
980
+ return yield* Effect.forEach(documents, (document, index) =>
981
+ MemoryDocument.restore(writes[index].key.namespace, document),
982
+ );
983
+ }),
711
984
  });
712
985
 
713
986
  return Context.make(MemoryReader, MemoryReader.fromAdapter({ get })).pipe(
714
987
  Context.add(MemoryWriter, MemoryWriter.fromAdapter({ change })),
988
+ Context.add(SqlMemoryBatchWriter, batchWriter),
715
989
  );
716
990
  });
717
991
 
718
992
  /** SQLite memory ports with the mutation failpoint kept injectable for recovery tests. */
719
993
  export const memoryStoreLayerWithFailpoints: Layer.Layer<
720
- MemoryReader | MemoryWriter,
994
+ MemoryReader | MemoryWriter | SqlMemoryBatchWriter,
721
995
  SqliteMemoryInitializationError,
722
996
  SqlClientService.SqlClient | MemoryMutationFailpoint
723
997
  > = Layer.effectContext(makeMemoryServices());
724
998
 
725
- /** SQLite memory reader and writer with the production no-op mutation failpoint. */
999
+ /** SQLite memory reader, single writer and batch writer with the production no-op failpoint. */
726
1000
  export const memoryStoreLayer: Layer.Layer<
727
- MemoryReader | MemoryWriter,
1001
+ MemoryReader | MemoryWriter | SqlMemoryBatchWriter,
728
1002
  SqliteMemoryInitializationError,
729
1003
  SqlClientService.SqlClient
730
1004
  > = memoryStoreLayerWithFailpoints.pipe(Layer.provide(MemoryMutationFailpoint.layer));