@effect-agent/storage-sqlite 0.1.0-beta.9 → 0.1.0-beta.91
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/SqliteActivityStore.d.mts +14 -0
- package/dist/SqliteActivityStore.mjs +310 -0
- package/dist/SqliteActivityStore.mjs.map +1 -0
- package/dist/SqliteMessageDeliveryStore.d.mts +14 -0
- package/dist/SqliteMessageDeliveryStore.mjs +24 -0
- package/dist/SqliteMessageDeliveryStore.mjs.map +1 -0
- package/dist/SqliteScheduleStore.d.mts +14 -0
- package/dist/SqliteScheduleStore.mjs +255 -0
- package/dist/SqliteScheduleStore.mjs.map +1 -0
- package/dist/SqliteStorageConfig.d.mts +34 -0
- package/dist/SqliteStorageConfig.mjs +39 -0
- package/dist/SqliteStorageConfig.mjs.map +1 -0
- package/dist/SqliteStorageError-DVWeaWLm.d.mts +86 -0
- package/dist/SqliteStorageError.d.mts +2 -0
- package/dist/SqliteStorageError.mjs +150 -0
- package/dist/SqliteStorageError.mjs.map +1 -0
- package/dist/SqliteStorageFailpoint.d.mts +17 -0
- package/dist/SqliteStorageFailpoint.mjs +14 -0
- package/dist/SqliteStorageFailpoint.mjs.map +1 -0
- package/dist/SqliteStorageFailpointTesting.d.mts +15 -0
- package/dist/SqliteStorageFailpointTesting.mjs +19 -0
- package/dist/SqliteStorageFailpointTesting.mjs.map +1 -0
- package/dist/SqliteStorageVersion-BqmqX0CI.d.mts +11 -0
- package/dist/SqliteStorageVersion.d.mts +2 -0
- package/dist/SqliteStorageVersion.mjs +8 -0
- package/dist/SqliteStorageVersion.mjs.map +1 -0
- package/dist/SqliteSubmissionLedger.d.mts +23 -0
- package/dist/SqliteSubmissionLedger.mjs +1788 -0
- package/dist/SqliteSubmissionLedger.mjs.map +1 -0
- package/dist/SqliteSubscriptionStore.d.mts +13 -0
- package/dist/SqliteSubscriptionStore.mjs +28 -0
- package/dist/SqliteSubscriptionStore.mjs.map +1 -0
- package/dist/SqliteThreadStore.d.mts +49 -0
- package/dist/SqliteThreadStore.mjs +460 -0
- package/dist/SqliteThreadStore.mjs.map +1 -0
- package/dist/index.d.mts +11 -193
- package/dist/index.mjs +11 -3082
- package/dist/migrations-Dy5v0HGe.mjs +346 -0
- package/dist/migrations-Dy5v0HGe.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/sqlite-journal-CBOQDySe.mjs +998 -0
- package/dist/sqlite-journal-CBOQDySe.mjs.map +1 -0
- package/package.json +1 -41
- package/src/SqliteActivityStore.ts +556 -0
- package/src/SqliteMessageDeliveryStore.ts +42 -0
- package/src/SqliteScheduleStore.ts +404 -0
- package/src/{sqlite-storage-config.ts → SqliteStorageConfig.ts} +1 -1
- package/src/{errors.ts → SqliteStorageError.ts} +10 -3
- package/src/SqliteStorageFailpoint.ts +23 -0
- package/src/{sqlite-storage-failpoint.ts → SqliteStorageFailpointTesting.ts} +7 -18
- package/src/SqliteStorageVersion.ts +2 -0
- package/src/{sqlite-ledger.ts → SqliteSubmissionLedger.ts} +912 -212
- package/src/SqliteSubscriptionStore.ts +55 -0
- package/src/SqliteThreadStore.ts +999 -0
- package/src/index.ts +10 -6
- package/src/internal/message-delivery-schema.ts +24 -0
- package/src/{migrations.ts → internal/migrations.ts} +156 -79
- package/src/internal/recovery-checkpoint-schema.ts +17 -0
- package/src/{sqlite-journal.ts → internal/sqlite-journal.ts} +751 -241
- package/dist/index.mjs.map +0 -1
- package/src/sqlite-conversation-store.ts +0 -841
|
@@ -1,841 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AppendConflict,
|
|
3
|
-
AppendResult,
|
|
4
|
-
CanonicalBatch,
|
|
5
|
-
CanonicalRecord,
|
|
6
|
-
CanonicalRecordEnvelope,
|
|
7
|
-
CanonicalSequence,
|
|
8
|
-
CheckpointRejected,
|
|
9
|
-
ConversationCheckpoint,
|
|
10
|
-
ConversationExport,
|
|
11
|
-
ConversationExportRequest,
|
|
12
|
-
ConversationMaterialization,
|
|
13
|
-
ConversationNotMaterialized,
|
|
14
|
-
ConversationObservation,
|
|
15
|
-
ConversationRead,
|
|
16
|
-
ConversationStore,
|
|
17
|
-
ConversationStoreError,
|
|
18
|
-
ConversationTail,
|
|
19
|
-
ConversationTailRequest,
|
|
20
|
-
DEFAULT_OWNERSHIP_LEASE_DURATION,
|
|
21
|
-
digestCanonicalBatch,
|
|
22
|
-
Digest,
|
|
23
|
-
EMPTY_TAIL_DIGEST,
|
|
24
|
-
FenceRejected,
|
|
25
|
-
FencedAppendRequest,
|
|
26
|
-
LoadCheckpointRequest,
|
|
27
|
-
ObservationOffset,
|
|
28
|
-
SaveCheckpointRequest,
|
|
29
|
-
} from "@effect-agent/session";
|
|
30
|
-
import { NodeCrypto } from "@effect/platform-node";
|
|
31
|
-
import { SqliteClient } from "@effect/sql-sqlite-node";
|
|
32
|
-
import {
|
|
33
|
-
Clock,
|
|
34
|
-
Context,
|
|
35
|
-
Crypto,
|
|
36
|
-
Duration,
|
|
37
|
-
Effect,
|
|
38
|
-
Layer,
|
|
39
|
-
Option,
|
|
40
|
-
Ref,
|
|
41
|
-
Schema,
|
|
42
|
-
Stream,
|
|
43
|
-
} from "effect";
|
|
44
|
-
import * as SqlClientService from "effect/unstable/sql/SqlClient";
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
-
SqliteAppendConflict,
|
|
48
|
-
SqliteCheckpointConflict,
|
|
49
|
-
SqliteFenceRejected,
|
|
50
|
-
type SqliteStorageFailpointLocation,
|
|
51
|
-
SqliteStorageCompatibilityError,
|
|
52
|
-
SqliteStorageCorruptionError,
|
|
53
|
-
SqliteStorageError,
|
|
54
|
-
} from "./errors.ts";
|
|
55
|
-
import {
|
|
56
|
-
initializeSqliteJournal,
|
|
57
|
-
RawAppendRequest,
|
|
58
|
-
RawCheckpoint,
|
|
59
|
-
RawReadRequest,
|
|
60
|
-
type SqliteJournal,
|
|
61
|
-
} from "./sqlite-journal.ts";
|
|
62
|
-
import { SqliteStorageConfig, SqliteStorageConfigValue } from "./sqlite-storage-config.ts";
|
|
63
|
-
import {
|
|
64
|
-
SqliteStorageFailpoint,
|
|
65
|
-
type SqliteStorageFailpointHandler,
|
|
66
|
-
} from "./sqlite-storage-failpoint.ts";
|
|
67
|
-
|
|
68
|
-
export interface SqliteStorageOptions {
|
|
69
|
-
readonly filename: string;
|
|
70
|
-
readonly observationPollInterval?: number | undefined;
|
|
71
|
-
/** Bounded SQLITE_BUSY retry window for write-lock acquisition, in milliseconds. */
|
|
72
|
-
readonly busyTimeout?: number | undefined;
|
|
73
|
-
/**
|
|
74
|
-
* Submission ownership lease duration in milliseconds (D5). Defaults to
|
|
75
|
-
* `DEFAULT_OWNERSHIP_LEASE_DURATION` from `@effect-agent/session`.
|
|
76
|
-
*/
|
|
77
|
-
readonly ownershipLeaseDuration?: number | undefined;
|
|
78
|
-
/**
|
|
79
|
-
* Re-verify every stored payload and digest chain while opening the store. Defaults to
|
|
80
|
-
* off: per-operation Schema decoding and the digest chain already fail clearly on corrupt
|
|
81
|
-
* rows without scanning the whole database on every open.
|
|
82
|
-
*/
|
|
83
|
-
readonly verifyOnOpen?: boolean | undefined;
|
|
84
|
-
readonly failpoint?: SqliteStorageFailpointHandler | undefined;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export type SqliteStorageInitializationError =
|
|
88
|
-
| SqliteStorageCompatibilityError
|
|
89
|
-
| SqliteStorageCorruptionError
|
|
90
|
-
| SqliteStorageError;
|
|
91
|
-
|
|
92
|
-
const OffsetText = Schema.String.check(Schema.isMaxLength(4 * 1024));
|
|
93
|
-
const SQLITE_OFFSET_PREFIX = "effect-agent-sqlite@1:";
|
|
94
|
-
const ZERO_CANONICAL_SEQUENCE = Schema.decodeSync(CanonicalSequence)(0);
|
|
95
|
-
const isDigest = Schema.is(Digest);
|
|
96
|
-
|
|
97
|
-
const storeError = (operation: string, error: { readonly message: string }) =>
|
|
98
|
-
ConversationStoreError.make({
|
|
99
|
-
cause: error,
|
|
100
|
-
operation,
|
|
101
|
-
message: error.message,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const schemaStoreError = (operation: string, error: { readonly message: string }) =>
|
|
105
|
-
ConversationStoreError.make({
|
|
106
|
-
cause: error,
|
|
107
|
-
operation,
|
|
108
|
-
message: error.message,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const makeOffset = Effect.fn("SqliteConversationStore.makeOffset")(function* (
|
|
112
|
-
conversationId: ConversationMaterialization["conversationId"],
|
|
113
|
-
sequence: number,
|
|
114
|
-
): Effect.fn.Return<ObservationOffset, ConversationStoreError> {
|
|
115
|
-
return yield* Schema.decodeUnknownEffect(CanonicalSequence)(sequence).pipe(
|
|
116
|
-
Effect.flatMap((validatedSequence) =>
|
|
117
|
-
Schema.decodeUnknownEffect(ObservationOffset)(
|
|
118
|
-
`${SQLITE_OFFSET_PREFIX}${encodeURIComponent(conversationId)}:${validatedSequence}`,
|
|
119
|
-
),
|
|
120
|
-
),
|
|
121
|
-
Effect.mapError((error) => schemaStoreError("encode observation offset", error)),
|
|
122
|
-
);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const parseOffset = Effect.fn("SqliteConversationStore.parseOffset")(function* (
|
|
126
|
-
conversationId: ConversationMaterialization["conversationId"],
|
|
127
|
-
offset: ObservationOffset | undefined,
|
|
128
|
-
): Effect.fn.Return<CanonicalSequence, ConversationStoreError> {
|
|
129
|
-
if (offset === undefined) return ZERO_CANONICAL_SEQUENCE;
|
|
130
|
-
const text = yield* Schema.decodeUnknownEffect(OffsetText)(offset).pipe(
|
|
131
|
-
Effect.mapError((error) => schemaStoreError("decode observation offset", error)),
|
|
132
|
-
);
|
|
133
|
-
const conversationPrefix = `${SQLITE_OFFSET_PREFIX}${encodeURIComponent(conversationId)}:`;
|
|
134
|
-
if (!text.startsWith(conversationPrefix)) {
|
|
135
|
-
return yield* ConversationStoreError.make({
|
|
136
|
-
operation: "decode observation offset",
|
|
137
|
-
message:
|
|
138
|
-
"The observation offset belongs to a different adapter, storage version, or Conversation.",
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
const sequenceText = text.slice(conversationPrefix.length);
|
|
142
|
-
if (!/^(0|[1-9][0-9]*)$/.test(sequenceText)) {
|
|
143
|
-
return yield* ConversationStoreError.make({
|
|
144
|
-
operation: "decode observation offset",
|
|
145
|
-
message: "The observation offset is malformed.",
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
return yield* Schema.decodeUnknownEffect(CanonicalSequence)(Number(sequenceText)).pipe(
|
|
149
|
-
Effect.mapError((error) => schemaStoreError("decode observation offset", error)),
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
const mapFence = (
|
|
154
|
-
conversationId: ConversationMaterialization["conversationId"],
|
|
155
|
-
error: SqliteFenceRejected,
|
|
156
|
-
) =>
|
|
157
|
-
FenceRejected.make({
|
|
158
|
-
conversationId,
|
|
159
|
-
actualEpoch: error.actualEpoch,
|
|
160
|
-
attemptedEpoch: error.producerEpoch,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
const encodeCanonicalRecord = Effect.fn("SqliteConversationStore.encodeCanonicalRecord")(function* (
|
|
164
|
-
record: CanonicalRecord,
|
|
165
|
-
): Effect.fn.Return<string, ConversationStoreError> {
|
|
166
|
-
return yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalRecord))(record).pipe(
|
|
167
|
-
Effect.mapError((error) => schemaStoreError("encode canonical record", error)),
|
|
168
|
-
);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
const encodeCanonicalBatch = Effect.fn("SqliteConversationStore.encodeCanonicalBatch")(function* (
|
|
172
|
-
batch: CanonicalBatch,
|
|
173
|
-
): Effect.fn.Return<string, ConversationStoreError> {
|
|
174
|
-
return yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalBatch))(batch).pipe(
|
|
175
|
-
Effect.mapError((error) => schemaStoreError("encode canonical batch", error)),
|
|
176
|
-
);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
const encodeCheckpoint = Effect.fn("SqliteConversationStore.encodeCheckpoint")(function* (
|
|
180
|
-
checkpoint: ConversationCheckpoint,
|
|
181
|
-
): Effect.fn.Return<string, ConversationStoreError> {
|
|
182
|
-
return yield* Schema.encodeEffect(Schema.fromJsonString(ConversationCheckpoint))(checkpoint).pipe(
|
|
183
|
-
Effect.mapError((error) => schemaStoreError("encode checkpoint", error)),
|
|
184
|
-
);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const decodeEnvelope = Effect.fn("SqliteConversationStore.decodeEnvelope")(function* (row: {
|
|
188
|
-
readonly batch_id: string;
|
|
189
|
-
readonly conversation_id: string;
|
|
190
|
-
readonly record_json: string;
|
|
191
|
-
readonly sequence: CanonicalSequence;
|
|
192
|
-
}) {
|
|
193
|
-
const record = yield* Schema.decodeEffect(Schema.fromJsonString(CanonicalRecord))(
|
|
194
|
-
row.record_json,
|
|
195
|
-
).pipe(
|
|
196
|
-
Effect.mapError((error) =>
|
|
197
|
-
ConversationStoreError.make({
|
|
198
|
-
operation: "decode canonical record",
|
|
199
|
-
message: error.message,
|
|
200
|
-
}),
|
|
201
|
-
),
|
|
202
|
-
);
|
|
203
|
-
const conversationId = yield* Schema.decodeUnknownEffect(
|
|
204
|
-
CanonicalRecordEnvelope.fields.conversationId,
|
|
205
|
-
)(row.conversation_id).pipe(
|
|
206
|
-
Effect.mapError((error) => schemaStoreError("decode conversation identity", error)),
|
|
207
|
-
);
|
|
208
|
-
const offset = yield* makeOffset(conversationId, row.sequence);
|
|
209
|
-
const batchId = yield* Schema.decodeUnknownEffect(CanonicalRecordEnvelope.fields.batchId)(
|
|
210
|
-
row.batch_id,
|
|
211
|
-
).pipe(Effect.mapError((error) => schemaStoreError("decode batch identity", error)));
|
|
212
|
-
return CanonicalRecordEnvelope.make({
|
|
213
|
-
conversationId,
|
|
214
|
-
batchId,
|
|
215
|
-
sequence: row.sequence,
|
|
216
|
-
offset,
|
|
217
|
-
record,
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const decodeCheckpoint = Effect.fn("SqliteConversationStore.decodeCheckpoint")(function* (
|
|
222
|
-
checkpointJson: string,
|
|
223
|
-
): Effect.fn.Return<ConversationCheckpoint, ConversationStoreError> {
|
|
224
|
-
return yield* Schema.decodeEffect(Schema.fromJsonString(ConversationCheckpoint))(
|
|
225
|
-
checkpointJson,
|
|
226
|
-
).pipe(Effect.mapError((error) => schemaStoreError("decode checkpoint", error)));
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const requireConversation = Effect.fn("SqliteConversationStore.requireConversation")(function* (
|
|
230
|
-
journal: SqliteJournal,
|
|
231
|
-
conversationId: ConversationMaterialization["conversationId"],
|
|
232
|
-
) {
|
|
233
|
-
const rows = yield* journal
|
|
234
|
-
.getConversation(conversationId)
|
|
235
|
-
.pipe(Effect.mapError((error) => storeError("read conversation", error)));
|
|
236
|
-
if (rows.length === 0) {
|
|
237
|
-
return yield* ConversationNotMaterialized.make({ conversationId });
|
|
238
|
-
}
|
|
239
|
-
return rows[0];
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const tailDigestAt = Effect.fn("SqliteConversationStore.tailDigestAt")(function* (
|
|
243
|
-
journal: SqliteJournal,
|
|
244
|
-
conversationId: ConversationMaterialization["conversationId"],
|
|
245
|
-
sequence: CanonicalSequence,
|
|
246
|
-
) {
|
|
247
|
-
if (sequence === 0) return EMPTY_TAIL_DIGEST;
|
|
248
|
-
const digests = yield* journal
|
|
249
|
-
.getTailDigestAt(conversationId, sequence)
|
|
250
|
-
.pipe(Effect.mapError((error) => storeError("read checkpoint digest", error)));
|
|
251
|
-
if (digests.length !== 1) {
|
|
252
|
-
return yield* CheckpointRejected.make({
|
|
253
|
-
conversationId,
|
|
254
|
-
reason: "digest-mismatch",
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
return yield* Schema.decodeUnknownEffect(Digest)(digests[0]).pipe(
|
|
258
|
-
Effect.mapError((error) => schemaStoreError("decode checkpoint digest", error)),
|
|
259
|
-
);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
const groupByKey = <A>(
|
|
263
|
-
rows: ReadonlyArray<A>,
|
|
264
|
-
key: (row: A) => string,
|
|
265
|
-
): ReadonlyMap<string, ReadonlyArray<A>> => {
|
|
266
|
-
const grouped = new Map<string, Array<A>>();
|
|
267
|
-
for (const row of rows) {
|
|
268
|
-
const existing = grouped.get(key(row));
|
|
269
|
-
if (existing === undefined) {
|
|
270
|
-
grouped.set(key(row), [row]);
|
|
271
|
-
} else {
|
|
272
|
-
existing.push(row);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return grouped;
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Opt-in full integrity audit (`verifyOnOpen`). Every stored payload is decoded, re-encoded,
|
|
280
|
-
* and re-digested against the canonical chain. Routine opens skip this scan: per-operation
|
|
281
|
-
* Schema decoding plus the digest chain already fail clearly on corrupt rows.
|
|
282
|
-
*/
|
|
283
|
-
const decodeStartupPayloads = Effect.fn("SqliteConversationStore.decodeStartupPayloads")(function* (
|
|
284
|
-
journal: SqliteJournal,
|
|
285
|
-
crypto: Crypto.Crypto,
|
|
286
|
-
) {
|
|
287
|
-
const stored = yield* journal.scanStoredPayloads();
|
|
288
|
-
const batches = yield* Effect.forEach(stored.batches, (batch) =>
|
|
289
|
-
Schema.decodeEffect(Schema.fromJsonString(CanonicalBatch))(batch.batch_json).pipe(
|
|
290
|
-
Effect.map((decoded) => ({ decoded, row: batch })),
|
|
291
|
-
Effect.mapError((error) =>
|
|
292
|
-
SqliteStorageCorruptionError.make({
|
|
293
|
-
table: "effect_agent_canonical_batches",
|
|
294
|
-
rowKey: `${batch.conversation_id}/${batch.batch_id}`,
|
|
295
|
-
message: error.message,
|
|
296
|
-
}),
|
|
297
|
-
),
|
|
298
|
-
),
|
|
299
|
-
);
|
|
300
|
-
const records = yield* Effect.forEach(stored.records, (record) =>
|
|
301
|
-
Schema.decodeEffect(Schema.fromJsonString(CanonicalRecord))(record.record_json).pipe(
|
|
302
|
-
Effect.map((decoded) => ({ decoded, row: record })),
|
|
303
|
-
Effect.mapError((error) =>
|
|
304
|
-
SqliteStorageCorruptionError.make({
|
|
305
|
-
table: "effect_agent_canonical_records",
|
|
306
|
-
rowKey: `${record.conversation_id}/${record.sequence}`,
|
|
307
|
-
message: error.message,
|
|
308
|
-
}),
|
|
309
|
-
),
|
|
310
|
-
),
|
|
311
|
-
);
|
|
312
|
-
const checkpoints = yield* Effect.forEach(stored.checkpoints, (checkpoint) =>
|
|
313
|
-
Schema.decodeEffect(Schema.fromJsonString(ConversationCheckpoint))(
|
|
314
|
-
checkpoint.checkpoint_json,
|
|
315
|
-
).pipe(
|
|
316
|
-
Effect.map((decoded) => ({ decoded, row: checkpoint })),
|
|
317
|
-
Effect.mapError((error) =>
|
|
318
|
-
SqliteStorageCorruptionError.make({
|
|
319
|
-
table: "effect_agent_checkpoints",
|
|
320
|
-
rowKey: `${checkpoint.conversation_id}/${checkpoint.through_sequence}`,
|
|
321
|
-
message: error.message,
|
|
322
|
-
}),
|
|
323
|
-
),
|
|
324
|
-
),
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
const batchesByConversation = groupByKey(batches, ({ row }) => row.conversation_id);
|
|
328
|
-
const recordsByConversation = groupByKey(records, ({ row }) => row.conversation_id);
|
|
329
|
-
const checkpointsByConversation = groupByKey(checkpoints, ({ row }) => row.conversation_id);
|
|
330
|
-
const materializedIds = new Set(
|
|
331
|
-
stored.conversations.map((conversation) => conversation.conversation_id),
|
|
332
|
-
);
|
|
333
|
-
|
|
334
|
-
for (const conversation of stored.conversations) {
|
|
335
|
-
const conversationBatches = batchesByConversation.get(conversation.conversation_id) ?? [];
|
|
336
|
-
const conversationRecords = recordsByConversation.get(conversation.conversation_id) ?? [];
|
|
337
|
-
const conversationCheckpoints =
|
|
338
|
-
checkpointsByConversation.get(conversation.conversation_id) ?? [];
|
|
339
|
-
const recordsByBatch = groupByKey(conversationRecords, ({ row }) => row.batch_id);
|
|
340
|
-
let previousDigest = EMPTY_TAIL_DIGEST;
|
|
341
|
-
let expectedSequence = 1;
|
|
342
|
-
const tailDigests = new Map<number, string>([[0, EMPTY_TAIL_DIGEST]]);
|
|
343
|
-
|
|
344
|
-
for (const { decoded: canonicalBatch, row: batchRow } of conversationBatches) {
|
|
345
|
-
const key = `${batchRow.conversation_id}/${batchRow.batch_id}`;
|
|
346
|
-
if (
|
|
347
|
-
canonicalBatch.batchId !== batchRow.batch_id ||
|
|
348
|
-
batchRow.first_sequence !== expectedSequence ||
|
|
349
|
-
batchRow.last_sequence !== batchRow.first_sequence + canonicalBatch.records.length - 1
|
|
350
|
-
) {
|
|
351
|
-
return yield* SqliteStorageCorruptionError.make({
|
|
352
|
-
table: "effect_agent_canonical_batches",
|
|
353
|
-
rowKey: key,
|
|
354
|
-
message: "Canonical batch identity, sequence, or record count is inconsistent.",
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const digest = yield* digestCanonicalBatch(previousDigest, canonicalBatch).pipe(
|
|
359
|
-
Effect.provideService(Crypto.Crypto, crypto),
|
|
360
|
-
Effect.mapError((error) =>
|
|
361
|
-
SqliteStorageCorruptionError.make({
|
|
362
|
-
table: "effect_agent_canonical_batches",
|
|
363
|
-
rowKey: key,
|
|
364
|
-
message: error.message,
|
|
365
|
-
}),
|
|
366
|
-
),
|
|
367
|
-
);
|
|
368
|
-
if (batchRow.batch_digest !== digest || batchRow.tail_digest !== digest) {
|
|
369
|
-
return yield* SqliteStorageCorruptionError.make({
|
|
370
|
-
table: "effect_agent_canonical_batches",
|
|
371
|
-
rowKey: key,
|
|
372
|
-
message: "Canonical batch digest does not match its decoded content and prior tail.",
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const batchRecords = recordsByBatch.get(batchRow.batch_id) ?? [];
|
|
377
|
-
if (batchRecords.length !== canonicalBatch.records.length) {
|
|
378
|
-
return yield* SqliteStorageCorruptionError.make({
|
|
379
|
-
table: "effect_agent_canonical_records",
|
|
380
|
-
rowKey: key,
|
|
381
|
-
message: "Canonical batch and record-table counts differ.",
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
for (let index = 0; index < canonicalBatch.records.length; index++) {
|
|
385
|
-
const expectedRecord = canonicalBatch.records[index];
|
|
386
|
-
const storedRecord = batchRecords[index];
|
|
387
|
-
const expectedJson = yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalRecord))(
|
|
388
|
-
expectedRecord,
|
|
389
|
-
).pipe(
|
|
390
|
-
Effect.mapError((error) =>
|
|
391
|
-
SqliteStorageCorruptionError.make({
|
|
392
|
-
table: "effect_agent_canonical_batches",
|
|
393
|
-
rowKey: key,
|
|
394
|
-
message: error.message,
|
|
395
|
-
}),
|
|
396
|
-
),
|
|
397
|
-
);
|
|
398
|
-
const storedJson = yield* Schema.encodeEffect(Schema.fromJsonString(CanonicalRecord))(
|
|
399
|
-
storedRecord.decoded,
|
|
400
|
-
).pipe(
|
|
401
|
-
Effect.mapError((error) =>
|
|
402
|
-
SqliteStorageCorruptionError.make({
|
|
403
|
-
table: "effect_agent_canonical_records",
|
|
404
|
-
rowKey: `${key}/${storedRecord.row.sequence}`,
|
|
405
|
-
message: error.message,
|
|
406
|
-
}),
|
|
407
|
-
),
|
|
408
|
-
);
|
|
409
|
-
if (
|
|
410
|
-
storedRecord.row.sequence !== batchRow.first_sequence + index ||
|
|
411
|
-
storedRecord.row.record_id !== expectedRecord.recordId ||
|
|
412
|
-
expectedJson !== storedJson
|
|
413
|
-
) {
|
|
414
|
-
return yield* SqliteStorageCorruptionError.make({
|
|
415
|
-
table: "effect_agent_canonical_records",
|
|
416
|
-
rowKey: `${key}/${storedRecord.row.sequence}`,
|
|
417
|
-
message: "Canonical record identity, sequence, or payload differs from its batch.",
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
previousDigest = digest;
|
|
423
|
-
expectedSequence = batchRow.last_sequence + 1;
|
|
424
|
-
tailDigests.set(batchRow.last_sequence, digest);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (
|
|
428
|
-
conversationRecords.length !== conversation.tail_sequence ||
|
|
429
|
-
conversation.tail_sequence !== expectedSequence - 1 ||
|
|
430
|
-
conversation.tail_digest !== previousDigest
|
|
431
|
-
) {
|
|
432
|
-
return yield* SqliteStorageCorruptionError.make({
|
|
433
|
-
table: "effect_agent_conversations",
|
|
434
|
-
rowKey: conversation.conversation_id,
|
|
435
|
-
message: "Conversation tail does not match its canonical batch chain.",
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
for (const checkpoint of conversationCheckpoints) {
|
|
440
|
-
if (
|
|
441
|
-
checkpoint.decoded.conversationId !== conversation.conversation_id ||
|
|
442
|
-
checkpoint.decoded.throughSequence !== checkpoint.row.through_sequence ||
|
|
443
|
-
checkpoint.decoded.tailDigest !== checkpoint.row.tail_digest ||
|
|
444
|
-
tailDigests.get(checkpoint.row.through_sequence) !== checkpoint.row.tail_digest
|
|
445
|
-
) {
|
|
446
|
-
return yield* SqliteStorageCorruptionError.make({
|
|
447
|
-
table: "effect_agent_checkpoints",
|
|
448
|
-
rowKey: `${conversation.conversation_id}/${checkpoint.row.through_sequence}`,
|
|
449
|
-
message: "Checkpoint identity or digest is not bound to a canonical batch tail.",
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (
|
|
456
|
-
batches.some(({ row }) => !materializedIds.has(row.conversation_id)) ||
|
|
457
|
-
records.some(({ row }) => !materializedIds.has(row.conversation_id)) ||
|
|
458
|
-
checkpoints.some(({ row }) => !materializedIds.has(row.conversation_id))
|
|
459
|
-
) {
|
|
460
|
-
return yield* SqliteStorageCorruptionError.make({
|
|
461
|
-
table: "effect_agent_conversations",
|
|
462
|
-
rowKey: "startup_scan",
|
|
463
|
-
message: "Canonical rows exist without a materialized Conversation.",
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
const makeServices = Effect.fn("SqliteConversationStore.makeServices")(function* () {
|
|
469
|
-
const config = yield* SqliteStorageConfig;
|
|
470
|
-
const failpoint = yield* SqliteStorageFailpoint;
|
|
471
|
-
const sql = yield* SqlClientService.SqlClient;
|
|
472
|
-
const crypto = yield* Crypto.Crypto;
|
|
473
|
-
const journal = yield* initializeSqliteJournal(sql, failpoint.hit, config.busyTimeout);
|
|
474
|
-
if (config.verifyOnOpen) {
|
|
475
|
-
yield* decodeStartupPayloads(journal, crypto);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const provideCrypto = <A, E>(effect: Effect.Effect<A, E, Crypto.Crypto>) =>
|
|
479
|
-
Effect.provideService(effect, Crypto.Crypto, crypto);
|
|
480
|
-
const hitFailpoint = Effect.fn("SqliteConversationStore.hitFailpoint")(
|
|
481
|
-
(location: SqliteStorageFailpointLocation): Effect.Effect<void, ConversationStoreError> =>
|
|
482
|
-
failpoint
|
|
483
|
-
.hit(location)
|
|
484
|
-
.pipe(Effect.mapError((error) => storeError(`storage failpoint ${location}`, error))),
|
|
485
|
-
);
|
|
486
|
-
|
|
487
|
-
const materialize: ConversationStore["Service"]["materialize"] = Effect.fn(
|
|
488
|
-
"SqliteConversationStore.materialize",
|
|
489
|
-
)(function* (request: ConversationMaterialization) {
|
|
490
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationMaterialization))(
|
|
491
|
-
request,
|
|
492
|
-
).pipe(Effect.mapError((error) => schemaStoreError("validate materialization", error)));
|
|
493
|
-
const now = yield* Clock.currentTimeMillis;
|
|
494
|
-
yield* hitFailpoint("materialize:before");
|
|
495
|
-
yield* journal
|
|
496
|
-
.materialize(
|
|
497
|
-
validated.conversationId,
|
|
498
|
-
new Date(now).toISOString(),
|
|
499
|
-
EMPTY_TAIL_DIGEST,
|
|
500
|
-
validated.producerEpoch,
|
|
501
|
-
)
|
|
502
|
-
.pipe(
|
|
503
|
-
Effect.mapError((error) =>
|
|
504
|
-
error._tag === "SqliteFenceRejected"
|
|
505
|
-
? mapFence(validated.conversationId, error)
|
|
506
|
-
: storeError("materialize conversation", error),
|
|
507
|
-
),
|
|
508
|
-
);
|
|
509
|
-
yield* hitFailpoint("materialize:after");
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
const append: ConversationStore["Service"]["append"] = Effect.fn(
|
|
513
|
-
"SqliteConversationStore.append",
|
|
514
|
-
)(function* (request: FencedAppendRequest) {
|
|
515
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(FencedAppendRequest))(
|
|
516
|
-
request,
|
|
517
|
-
).pipe(Effect.mapError((error) => schemaStoreError("validate canonical append", error)));
|
|
518
|
-
yield* requireConversation(journal, validated.conversationId);
|
|
519
|
-
const tailDigest = yield* provideCrypto(
|
|
520
|
-
digestCanonicalBatch(validated.expectedTailDigest, validated.batch),
|
|
521
|
-
).pipe(Effect.mapError((error) => storeError("digest canonical append", error)));
|
|
522
|
-
const batchJson = yield* encodeCanonicalBatch(validated.batch);
|
|
523
|
-
const rawRecords = yield* Effect.forEach(validated.batch.records, (record) =>
|
|
524
|
-
encodeCanonicalRecord(record).pipe(
|
|
525
|
-
Effect.map((recordJson) => ({
|
|
526
|
-
recordId: record.recordId,
|
|
527
|
-
recordJson,
|
|
528
|
-
})),
|
|
529
|
-
),
|
|
530
|
-
);
|
|
531
|
-
const rawRequest = yield* Schema.decodeUnknownEffect(RawAppendRequest)({
|
|
532
|
-
conversationId: validated.conversationId,
|
|
533
|
-
batchId: validated.batch.batchId,
|
|
534
|
-
batchDigest: tailDigest,
|
|
535
|
-
batchJson,
|
|
536
|
-
expectedTailSequence: validated.expectedTailSequence,
|
|
537
|
-
expectedTailDigest: validated.expectedTailDigest,
|
|
538
|
-
producerEpoch: validated.producerEpoch,
|
|
539
|
-
records: rawRecords,
|
|
540
|
-
tailDigest,
|
|
541
|
-
}).pipe(Effect.mapError((error) => schemaStoreError("encode canonical append", error)));
|
|
542
|
-
yield* hitFailpoint("append:before");
|
|
543
|
-
const result = yield* journal.append(rawRequest).pipe(
|
|
544
|
-
Effect.mapError((error) => {
|
|
545
|
-
if (error instanceof SqliteFenceRejected) {
|
|
546
|
-
return mapFence(validated.conversationId, error);
|
|
547
|
-
}
|
|
548
|
-
if (error instanceof SqliteAppendConflict) {
|
|
549
|
-
return error.actualTailSequence !== undefined && isDigest(error.actualTailDigest)
|
|
550
|
-
? AppendConflict.make({
|
|
551
|
-
conversationId: validated.conversationId,
|
|
552
|
-
batchId: validated.batch.batchId,
|
|
553
|
-
reason: error.reason,
|
|
554
|
-
actualTailSequence: error.actualTailSequence,
|
|
555
|
-
actualTailDigest: error.actualTailDigest,
|
|
556
|
-
})
|
|
557
|
-
: AppendConflict.make({
|
|
558
|
-
conversationId: validated.conversationId,
|
|
559
|
-
batchId: validated.batch.batchId,
|
|
560
|
-
reason: error.reason,
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
return storeError("append canonical batch", error);
|
|
564
|
-
}),
|
|
565
|
-
Effect.flatMap((result) =>
|
|
566
|
-
Schema.decodeUnknownEffect(AppendResult)(result).pipe(
|
|
567
|
-
Effect.mapError((error) => schemaStoreError("decode append result", error)),
|
|
568
|
-
),
|
|
569
|
-
),
|
|
570
|
-
);
|
|
571
|
-
yield* hitFailpoint("append:after");
|
|
572
|
-
return result;
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
const loadRecords = Effect.fn("SqliteConversationStore.loadRecords")(function* (
|
|
576
|
-
request: RawReadRequest,
|
|
577
|
-
) {
|
|
578
|
-
const rows = yield* journal
|
|
579
|
-
.read(request)
|
|
580
|
-
.pipe(Effect.mapError((error) => storeError("read canonical records", error)));
|
|
581
|
-
return yield* Effect.forEach(rows, decodeEnvelope);
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
const readEffect = Effect.fn("SqliteConversationStore.read")(function* (
|
|
585
|
-
request: ConversationRead,
|
|
586
|
-
) {
|
|
587
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationRead))(
|
|
588
|
-
request,
|
|
589
|
-
).pipe(Effect.mapError((error) => schemaStoreError("validate conversation read", error)));
|
|
590
|
-
yield* requireConversation(journal, validated.conversationId);
|
|
591
|
-
const records = yield* loadRecords(
|
|
592
|
-
RawReadRequest.make({
|
|
593
|
-
conversationId: validated.conversationId,
|
|
594
|
-
fromSequenceExclusive: validated.afterSequence ?? ZERO_CANONICAL_SEQUENCE,
|
|
595
|
-
limit: validated.limit,
|
|
596
|
-
}),
|
|
597
|
-
);
|
|
598
|
-
return Stream.fromIterable(records);
|
|
599
|
-
});
|
|
600
|
-
const read: ConversationStore["Service"]["read"] = (request) =>
|
|
601
|
-
Stream.unwrap(readEffect(request));
|
|
602
|
-
|
|
603
|
-
const observeEffect = Effect.fn("SqliteConversationStore.observe")(function* (
|
|
604
|
-
request: ConversationObservation,
|
|
605
|
-
) {
|
|
606
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationObservation))(
|
|
607
|
-
request,
|
|
608
|
-
).pipe(
|
|
609
|
-
Effect.mapError((error) => schemaStoreError("validate conversation observation", error)),
|
|
610
|
-
);
|
|
611
|
-
yield* requireConversation(journal, validated.conversationId);
|
|
612
|
-
const initialSequence = yield* parseOffset(validated.conversationId, validated.afterOffset);
|
|
613
|
-
const cursor = yield* Ref.make(initialSequence);
|
|
614
|
-
const poll = Effect.fn("SqliteConversationStore.observePoll")(function* () {
|
|
615
|
-
const fromSequenceExclusive = yield* Ref.get(cursor);
|
|
616
|
-
const records = yield* loadRecords(
|
|
617
|
-
RawReadRequest.make({
|
|
618
|
-
conversationId: validated.conversationId,
|
|
619
|
-
fromSequenceExclusive,
|
|
620
|
-
limit: 1_024,
|
|
621
|
-
}),
|
|
622
|
-
);
|
|
623
|
-
if (records.length === 0) {
|
|
624
|
-
yield* Effect.sleep(config.observationPollInterval);
|
|
625
|
-
return [];
|
|
626
|
-
}
|
|
627
|
-
yield* Ref.set(cursor, records[records.length - 1].sequence);
|
|
628
|
-
return records;
|
|
629
|
-
});
|
|
630
|
-
return Stream.fromIterableEffectRepeat(poll());
|
|
631
|
-
});
|
|
632
|
-
const observe: ConversationStore["Service"]["observe"] = (request) =>
|
|
633
|
-
Stream.unwrap(observeEffect(request));
|
|
634
|
-
|
|
635
|
-
const exportConversation: ConversationStore["Service"]["export"] = Effect.fn(
|
|
636
|
-
"SqliteConversationStore.export",
|
|
637
|
-
)(function* (request: ConversationExportRequest) {
|
|
638
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationExportRequest))(
|
|
639
|
-
request,
|
|
640
|
-
).pipe(Effect.mapError((error) => schemaStoreError("validate conversation export", error)));
|
|
641
|
-
yield* requireConversation(journal, validated.conversationId);
|
|
642
|
-
const exported = yield* journal
|
|
643
|
-
.exportConversation(validated.conversationId)
|
|
644
|
-
.pipe(Effect.mapError((error) => storeError("export conversation", error)));
|
|
645
|
-
const records = yield* Effect.forEach(exported.records, decodeEnvelope);
|
|
646
|
-
if (records.length > 65_536) {
|
|
647
|
-
return yield* ConversationStoreError.make({
|
|
648
|
-
operation: "decode conversation export",
|
|
649
|
-
message: "The conversation exceeds the current export record limit.",
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(
|
|
653
|
-
exported.conversation.tail_digest,
|
|
654
|
-
).pipe(Effect.mapError((error) => schemaStoreError("decode export tail digest", error)));
|
|
655
|
-
return ConversationExport.make({
|
|
656
|
-
format: "effect-agent/conversation@1",
|
|
657
|
-
conversationId: validated.conversationId,
|
|
658
|
-
tailSequence: exported.conversation.tail_sequence,
|
|
659
|
-
tailDigest,
|
|
660
|
-
records,
|
|
661
|
-
});
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
const inspectTail: ConversationStore["Service"]["inspectTail"] = Effect.fn(
|
|
665
|
-
"SqliteConversationStore.inspectTail",
|
|
666
|
-
)(function* (request: ConversationTailRequest) {
|
|
667
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ConversationTailRequest))(
|
|
668
|
-
request,
|
|
669
|
-
).pipe(Effect.mapError((error) => schemaStoreError("validate tail inspection", error)));
|
|
670
|
-
const conversation = yield* requireConversation(journal, validated.conversationId);
|
|
671
|
-
const tailDigest = yield* Schema.decodeUnknownEffect(Digest)(conversation.tail_digest).pipe(
|
|
672
|
-
Effect.mapError((error) => schemaStoreError("decode tail digest", error)),
|
|
673
|
-
);
|
|
674
|
-
return ConversationTail.make({
|
|
675
|
-
conversationId: validated.conversationId,
|
|
676
|
-
tailSequence: conversation.tail_sequence,
|
|
677
|
-
tailDigest,
|
|
678
|
-
producerEpoch: conversation.producer_epoch,
|
|
679
|
-
});
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
const saveCheckpoint: ConversationStore["Service"]["saveCheckpoint"] = Effect.fn(
|
|
683
|
-
"SqliteConversationStore.saveCheckpoint",
|
|
684
|
-
)(function* (request: SaveCheckpointRequest) {
|
|
685
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SaveCheckpointRequest))(
|
|
686
|
-
request,
|
|
687
|
-
).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint", error)));
|
|
688
|
-
const conversation = yield* requireConversation(journal, validated.checkpoint.conversationId);
|
|
689
|
-
if (validated.checkpoint.throughSequence > conversation.tail_sequence) {
|
|
690
|
-
return yield* CheckpointRejected.make({
|
|
691
|
-
conversationId: validated.checkpoint.conversationId,
|
|
692
|
-
reason: "ahead-of-tail",
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
const canonicalDigest = yield* tailDigestAt(
|
|
696
|
-
journal,
|
|
697
|
-
validated.checkpoint.conversationId,
|
|
698
|
-
validated.checkpoint.throughSequence,
|
|
699
|
-
);
|
|
700
|
-
if (canonicalDigest !== validated.checkpoint.tailDigest) {
|
|
701
|
-
return yield* CheckpointRejected.make({
|
|
702
|
-
conversationId: validated.checkpoint.conversationId,
|
|
703
|
-
reason: "digest-mismatch",
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
const checkpointJson = yield* encodeCheckpoint(validated.checkpoint);
|
|
707
|
-
const raw = RawCheckpoint.make({
|
|
708
|
-
conversationId: validated.checkpoint.conversationId,
|
|
709
|
-
throughSequence: validated.checkpoint.throughSequence,
|
|
710
|
-
tailDigest: validated.checkpoint.tailDigest,
|
|
711
|
-
checkpointJson,
|
|
712
|
-
});
|
|
713
|
-
yield* hitFailpoint("save-checkpoint:before");
|
|
714
|
-
yield* journal.saveCheckpoint(raw).pipe(
|
|
715
|
-
Effect.mapError((error) =>
|
|
716
|
-
error instanceof SqliteCheckpointConflict
|
|
717
|
-
? CheckpointRejected.make({
|
|
718
|
-
conversationId: validated.checkpoint.conversationId,
|
|
719
|
-
reason: "digest-mismatch",
|
|
720
|
-
})
|
|
721
|
-
: storeError("save checkpoint", error),
|
|
722
|
-
),
|
|
723
|
-
);
|
|
724
|
-
yield* hitFailpoint("save-checkpoint:after");
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
const loadCheckpoint: ConversationStore["Service"]["loadCheckpoint"] = Effect.fn(
|
|
728
|
-
"SqliteConversationStore.loadCheckpoint",
|
|
729
|
-
)(function* (request: LoadCheckpointRequest) {
|
|
730
|
-
const validated = yield* Schema.decodeUnknownEffect(Schema.toType(LoadCheckpointRequest))(
|
|
731
|
-
request,
|
|
732
|
-
).pipe(Effect.mapError((error) => schemaStoreError("validate checkpoint lookup", error)));
|
|
733
|
-
const conversation = yield* requireConversation(journal, validated.conversationId);
|
|
734
|
-
const rows = yield* journal
|
|
735
|
-
.loadCheckpoint(
|
|
736
|
-
validated.conversationId,
|
|
737
|
-
validated.atOrBeforeSequence ?? conversation.tail_sequence,
|
|
738
|
-
)
|
|
739
|
-
.pipe(Effect.mapError((error) => storeError("load checkpoint", error)));
|
|
740
|
-
if (rows.length === 0) return Option.none();
|
|
741
|
-
if (rows.length !== 1) {
|
|
742
|
-
return yield* ConversationStoreError.make({
|
|
743
|
-
operation: "load checkpoint",
|
|
744
|
-
message: `Expected at most one checkpoint row but found ${rows.length}.`,
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
const checkpoint = yield* decodeCheckpoint(rows[0].checkpoint_json);
|
|
748
|
-
const canonicalDigest = yield* tailDigestAt(
|
|
749
|
-
journal,
|
|
750
|
-
checkpoint.conversationId,
|
|
751
|
-
checkpoint.throughSequence,
|
|
752
|
-
);
|
|
753
|
-
if (canonicalDigest !== checkpoint.tailDigest) {
|
|
754
|
-
return yield* CheckpointRejected.make({
|
|
755
|
-
conversationId: checkpoint.conversationId,
|
|
756
|
-
reason: "digest-mismatch",
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
return Option.some(checkpoint);
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
const conversationStore = ConversationStore.of({
|
|
763
|
-
append,
|
|
764
|
-
export: exportConversation,
|
|
765
|
-
inspectTail,
|
|
766
|
-
loadCheckpoint,
|
|
767
|
-
materialize,
|
|
768
|
-
observe,
|
|
769
|
-
read,
|
|
770
|
-
saveCheckpoint,
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
return Context.make(ConversationStore, conversationStore);
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* SQLite Conversation Store implementation with configuration, failpoint, SQL, and Crypto
|
|
778
|
-
* authority kept visible in its input channel.
|
|
779
|
-
*/
|
|
780
|
-
export const conversationStoreLayer: Layer.Layer<
|
|
781
|
-
ConversationStore,
|
|
782
|
-
SqliteStorageInitializationError,
|
|
783
|
-
SqliteStorageConfig | SqliteStorageFailpoint | SqlClientService.SqlClient | Crypto.Crypto
|
|
784
|
-
> = Layer.effectContext(makeServices());
|
|
785
|
-
|
|
786
|
-
/**
|
|
787
|
-
* Validated SQLite storage configuration Layer with the documented defaults applied. Shared
|
|
788
|
-
* by the ConversationStore and SubmissionLedger convenience layers so their defaults cannot
|
|
789
|
-
* drift.
|
|
790
|
-
*/
|
|
791
|
-
export const storageConfigLayer = (
|
|
792
|
-
options: SqliteStorageOptions,
|
|
793
|
-
): Layer.Layer<SqliteStorageConfig, SqliteStorageError> =>
|
|
794
|
-
Layer.effect(SqliteStorageConfig)(
|
|
795
|
-
Schema.decodeUnknownEffect(SqliteStorageConfigValue)({
|
|
796
|
-
observationPollInterval: options.observationPollInterval ?? 25,
|
|
797
|
-
busyTimeout: options.busyTimeout ?? 5_000,
|
|
798
|
-
ownershipLeaseDuration:
|
|
799
|
-
options.ownershipLeaseDuration ?? Duration.toMillis(DEFAULT_OWNERSHIP_LEASE_DURATION),
|
|
800
|
-
verifyOnOpen: options.verifyOnOpen ?? false,
|
|
801
|
-
}).pipe(
|
|
802
|
-
Effect.mapError((error) =>
|
|
803
|
-
SqliteStorageError.make({
|
|
804
|
-
cause: error,
|
|
805
|
-
operation: "configure SQLite storage",
|
|
806
|
-
message: error.message,
|
|
807
|
-
}),
|
|
808
|
-
),
|
|
809
|
-
),
|
|
810
|
-
);
|
|
811
|
-
|
|
812
|
-
/** The failpoint Layer selected by convenience options: explicit handler or the no-op default. */
|
|
813
|
-
export const storageFailpointLayer = (
|
|
814
|
-
options: SqliteStorageOptions,
|
|
815
|
-
): Layer.Layer<SqliteStorageFailpoint> =>
|
|
816
|
-
options.failpoint === undefined
|
|
817
|
-
? SqliteStorageFailpoint.layer
|
|
818
|
-
: Layer.succeed(SqliteStorageFailpoint)({ hit: options.failpoint });
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* A composition-root convenience Layer for canonical Conversations. Durable accepted work is
|
|
822
|
-
* served by the separate SubmissionLedger port.
|
|
823
|
-
*/
|
|
824
|
-
export const layer = (
|
|
825
|
-
options: SqliteStorageOptions,
|
|
826
|
-
): Layer.Layer<ConversationStore, SqliteStorageInitializationError> => {
|
|
827
|
-
const sqlLayer = SqliteClient.layer({ filename: options.filename });
|
|
828
|
-
return conversationStoreLayer.pipe(
|
|
829
|
-
Layer.provide(
|
|
830
|
-
Layer.mergeAll(
|
|
831
|
-
storageConfigLayer(options),
|
|
832
|
-
storageFailpointLayer(options),
|
|
833
|
-
sqlLayer,
|
|
834
|
-
NodeCrypto.layer,
|
|
835
|
-
),
|
|
836
|
-
),
|
|
837
|
-
);
|
|
838
|
-
};
|
|
839
|
-
|
|
840
|
-
/** Create an adapter-owned resumable observation offset for a known canonical sequence. */
|
|
841
|
-
export const observationOffsetAt = makeOffset;
|