@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.
- package/dist/DoMemoryStore.mjs +1 -1
- package/dist/DoMessageDeliveryStore.mjs +1 -1
- package/dist/DoStorageFailpoint.d.mts +3 -1
- package/dist/DoStorageFailpoint.mjs +7 -2
- package/dist/DoStorageFailpoint.mjs.map +1 -1
- package/dist/{DoStorageVersion-NKnQU3eR.d.mts → DoStorageVersion-Mc7CXMdN.d.mts} +2 -2
- package/dist/DoStorageVersion.d.mts +1 -1
- package/dist/DoStorageVersion.mjs +2 -2
- package/dist/DoSubmissionLedger.d.mts +1 -1
- package/dist/DoSubmissionLedger.mjs +194 -98
- package/dist/DoSubmissionLedger.mjs.map +1 -1
- package/dist/{DoThreadStore-Bd4fTns3.d.mts → DoThreadStore-BzYJjm4O.d.mts} +18 -8
- package/dist/{DoThreadStore-DyzOZDXK.mjs → DoThreadStore-DG0h5J6u.mjs} +25 -19
- package/dist/DoThreadStore-DG0h5J6u.mjs.map +1 -0
- package/dist/DoThreadStore.d.mts +2 -2
- package/dist/DoThreadStore.mjs +3 -2
- package/dist/PortProtocol.d.mts +7201 -7201
- package/dist/PortRouting.mjs +21 -11
- package/dist/PortRouting.mjs.map +1 -1
- package/dist/{do-journal-ZMAzzENZ.mjs → do-journal-ySC3eXNv.mjs} +144 -45
- package/dist/do-journal-ySC3eXNv.mjs.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{migrations-C3N9GN6A.mjs → migrations-CAO_akhD.mjs} +45 -22
- package/dist/migrations-CAO_akhD.mjs.map +1 -0
- package/dist/{owned-state-DcFh0YNo.mjs → owned-state-BgGOA2Ak.mjs} +4 -3
- package/dist/owned-state-BgGOA2Ak.mjs.map +1 -0
- package/package.json +1 -1
- package/src/DoStorageFailpoint.ts +4 -0
- package/src/DoSubmissionLedger.ts +430 -228
- package/src/DoThreadStore.ts +25 -44
- package/src/PortRouting.ts +57 -31
- package/src/internal/do-journal.ts +318 -70
- package/src/internal/migrations.ts +52 -10
- package/src/internal/owned-state.ts +12 -5
- package/src/internal/prompt-checkpoint.ts +6 -3
- package/src/internal/storage-span.ts +14 -10
- package/dist/DoThreadStore-DyzOZDXK.mjs.map +0 -1
- package/dist/do-journal-ZMAzzENZ.mjs.map +0 -1
- package/dist/migrations-C3N9GN6A.mjs.map +0 -1
- 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 {
|
|
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(
|
|
98
|
-
thread_id:
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
853
|
+
if (replay !== undefined) return AppendResult.make(replay);
|
|
640
854
|
|
|
641
|
-
|
|
855
|
+
const prefix = recordCache.prefix(request.threadId, thread.tail_sequence);
|
|
642
856
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
675
|
-
thread.tail_sequence + (request.records.length === 0 ? 0 : 1),
|
|
676
|
-
|
|
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 =
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
213
|
-
|
|
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.
|
|
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
|
|
204
|
-
Effect.
|
|
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 {
|
|
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
|
};
|