@effect-agent/storage-cloudflare 0.1.0-beta.12 → 0.1.0-beta.120
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.d.mts +29 -0
- package/dist/DoMemoryStore.mjs +55 -0
- package/dist/DoMemoryStore.mjs.map +1 -0
- package/dist/DoMessageDeliveryStore.d.mts +17 -0
- package/dist/DoMessageDeliveryStore.mjs +32 -0
- package/dist/DoMessageDeliveryStore.mjs.map +1 -0
- package/dist/DoScheduleStore.d.mts +36 -0
- package/dist/DoScheduleStore.mjs +388 -0
- package/dist/DoScheduleStore.mjs.map +1 -0
- package/dist/DoStorageConfig.d.mts +45 -0
- package/dist/DoStorageConfig.mjs +52 -0
- package/dist/DoStorageConfig.mjs.map +1 -0
- package/dist/DoStorageError-DjpuLHmX.d.mts +101 -0
- package/dist/DoStorageError.d.mts +2 -0
- package/dist/DoStorageError.mjs +167 -0
- package/dist/DoStorageError.mjs.map +1 -0
- package/dist/DoStorageFailpoint.d.mts +17 -0
- package/dist/DoStorageFailpoint.mjs +14 -0
- package/dist/DoStorageFailpoint.mjs.map +1 -0
- package/dist/DoStorageFailpointTesting.d.mts +33 -0
- package/dist/DoStorageFailpointTesting.mjs +35 -0
- package/dist/DoStorageFailpointTesting.mjs.map +1 -0
- package/dist/DoStorageVersion-DJwZzqel.d.mts +12 -0
- package/dist/DoStorageVersion.d.mts +2 -0
- package/dist/DoStorageVersion.mjs +8 -0
- package/dist/DoStorageVersion.mjs.map +1 -0
- package/dist/DoSubmissionLedger.d.mts +23 -0
- package/dist/DoSubmissionLedger.mjs +1939 -0
- package/dist/DoSubmissionLedger.mjs.map +1 -0
- package/dist/DoSubscriptionStore.d.mts +25 -0
- package/dist/DoSubscriptionStore.mjs +183 -0
- package/dist/DoSubscriptionStore.mjs.map +1 -0
- package/dist/DoThreadStore.d.mts +59 -0
- package/dist/DoThreadStore.mjs +499 -0
- package/dist/DoThreadStore.mjs.map +1 -0
- package/dist/MemoryProtocol.d.mts +38160 -0
- package/dist/MemoryProtocol.mjs +219 -0
- package/dist/MemoryProtocol.mjs.map +1 -0
- package/dist/PortProtocol.d.mts +5521 -0
- package/dist/PortProtocol.mjs +206 -0
- package/dist/PortProtocol.mjs.map +1 -0
- package/dist/PortRouting.d.mts +86 -0
- package/dist/PortRouting.mjs +461 -0
- package/dist/PortRouting.mjs.map +1 -0
- package/dist/do-journal-D65Iuln6.mjs +1119 -0
- package/dist/do-journal-D65Iuln6.mjs.map +1 -0
- package/dist/index.d.mts +14 -1525
- package/dist/index.mjs +14 -3695
- package/dist/migrations-Br6QqMIf.mjs +306 -0
- package/dist/migrations-Br6QqMIf.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -45
- package/src/DoMemoryStore.ts +56 -0
- package/src/DoMessageDeliveryStore.ts +53 -0
- package/src/DoScheduleStore.ts +622 -0
- package/src/{errors.ts → DoStorageError.ts} +13 -3
- package/src/DoStorageFailpoint.ts +20 -0
- package/src/{do-storage-failpoint.ts → DoStorageFailpointTesting.ts} +5 -18
- package/src/DoStorageVersion.ts +2 -0
- package/src/{do-ledger.ts → DoSubmissionLedger.ts} +1119 -262
- package/src/DoSubscriptionStore.ts +329 -0
- package/src/DoThreadStore.ts +1064 -0
- package/src/MemoryProtocol.ts +351 -0
- package/src/{port-protocol.ts → PortProtocol.ts} +103 -38
- package/src/{routing.ts → PortRouting.ts} +440 -264
- package/src/index.ts +13 -32
- package/src/internal/do-journal.ts +1789 -0
- package/src/internal/message-delivery-schema.ts +24 -0
- package/src/{migrations.ts → internal/migrations.ts} +72 -35
- package/src/internal/recovery-checkpoint-schema.ts +17 -0
- package/dist/index.mjs.map +0 -1
- package/src/do-conversation-store.ts +0 -850
- package/src/do-journal.ts +0 -1078
- /package/src/{do-storage-config.ts → DoStorageConfig.ts} +0 -0
package/src/do-journal.ts
DELETED
|
@@ -1,1078 +0,0 @@
|
|
|
1
|
-
import { CanonicalSequence, ProducerEpoch } from "@effect-agent/session";
|
|
2
|
-
import { SqliteMigrator } from "@effect/sql-sqlite-do";
|
|
3
|
-
import { Effect, Schema } from "effect";
|
|
4
|
-
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
5
|
-
import { SqlError } from "effect/unstable/sql/SqlError";
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
DoAppendConflict,
|
|
9
|
-
DoCheckpointConflict,
|
|
10
|
-
DoFenceRejected,
|
|
11
|
-
DoStorageCompatibilityError,
|
|
12
|
-
DoStorageCorruptionError,
|
|
13
|
-
DoStorageError,
|
|
14
|
-
DoStorageFailpointError,
|
|
15
|
-
DoValueBoundExceeded,
|
|
16
|
-
type DoStorageFailpointLocation,
|
|
17
|
-
} from "./errors.ts";
|
|
18
|
-
import { CurrentDoStorageVersion, doMigrations } from "./migrations.ts";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Static schema ceiling for stored text columns. Writes are bounded in BYTES by the
|
|
22
|
-
* configured `maxStoredValueBytes` (always ≤ 2,000,000); UTF-8 byte length is never smaller
|
|
23
|
-
* than UTF-16 string length, so any value that passed the byte bound also passes this
|
|
24
|
-
* decode-side character ceiling.
|
|
25
|
-
*/
|
|
26
|
-
const BoundedStoredText = Schema.String.check(Schema.isMaxLength(2_000_000));
|
|
27
|
-
const BoundedIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(1024));
|
|
28
|
-
const MAX_RECORDS_PER_CONVERSATION = 65_536;
|
|
29
|
-
const MAX_IDENTIFIER_LENGTH = 1_024;
|
|
30
|
-
/** Durable Object SQL storage allows at most 100 bound parameters per statement. */
|
|
31
|
-
const MAX_BOUND_PARAMETERS = 100;
|
|
32
|
-
|
|
33
|
-
const storedTextBytes = (value: string): number => new TextEncoder().encode(value).byteLength;
|
|
34
|
-
|
|
35
|
-
const chunked = <A>(values: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> => {
|
|
36
|
-
const chunks: Array<ReadonlyArray<A>> = [];
|
|
37
|
-
for (let index = 0; index < values.length; index += size) {
|
|
38
|
-
chunks.push(values.slice(index, index + size));
|
|
39
|
-
}
|
|
40
|
-
return chunks;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
class DoMetaRow extends Schema.Class<DoMetaRow>("DoMetaRow")({
|
|
44
|
-
value: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
|
|
45
|
-
}) {}
|
|
46
|
-
|
|
47
|
-
class DoNameRow extends Schema.Class<DoNameRow>("DoNameRow")({
|
|
48
|
-
name: BoundedIdentifier,
|
|
49
|
-
}) {}
|
|
50
|
-
|
|
51
|
-
class ConversationRow extends Schema.Class<ConversationRow>("ConversationRow")({
|
|
52
|
-
conversation_id: BoundedIdentifier,
|
|
53
|
-
created_at: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
|
|
54
|
-
producer_epoch: ProducerEpoch,
|
|
55
|
-
tail_digest: BoundedStoredText,
|
|
56
|
-
tail_sequence: CanonicalSequence,
|
|
57
|
-
}) {}
|
|
58
|
-
|
|
59
|
-
class BatchRow extends Schema.Class<BatchRow>("BatchRow")({
|
|
60
|
-
batch_digest: BoundedStoredText,
|
|
61
|
-
batch_id: BoundedIdentifier,
|
|
62
|
-
batch_json: BoundedStoredText,
|
|
63
|
-
conversation_id: BoundedIdentifier,
|
|
64
|
-
first_sequence: CanonicalSequence,
|
|
65
|
-
last_sequence: CanonicalSequence,
|
|
66
|
-
tail_digest: BoundedStoredText,
|
|
67
|
-
}) {}
|
|
68
|
-
|
|
69
|
-
class RecordRow extends Schema.Class<RecordRow>("RecordRow")({
|
|
70
|
-
batch_id: BoundedIdentifier,
|
|
71
|
-
conversation_id: BoundedIdentifier,
|
|
72
|
-
record_id: BoundedIdentifier,
|
|
73
|
-
record_json: BoundedStoredText,
|
|
74
|
-
sequence: CanonicalSequence,
|
|
75
|
-
}) {}
|
|
76
|
-
|
|
77
|
-
class CheckpointRow extends Schema.Class<CheckpointRow>("CheckpointRow")({
|
|
78
|
-
checkpoint_json: BoundedStoredText,
|
|
79
|
-
conversation_id: BoundedIdentifier,
|
|
80
|
-
tail_digest: BoundedStoredText,
|
|
81
|
-
through_sequence: CanonicalSequence,
|
|
82
|
-
}) {}
|
|
83
|
-
|
|
84
|
-
export class RawRecord extends Schema.Class<RawRecord>(
|
|
85
|
-
"@effect-agent/storage-cloudflare/RawRecord",
|
|
86
|
-
)({
|
|
87
|
-
recordId: BoundedIdentifier,
|
|
88
|
-
recordJson: BoundedStoredText,
|
|
89
|
-
}) {}
|
|
90
|
-
|
|
91
|
-
export class RawAppendRequest extends Schema.Class<RawAppendRequest>(
|
|
92
|
-
"@effect-agent/storage-cloudflare/RawAppendRequest",
|
|
93
|
-
)({
|
|
94
|
-
batchDigest: BoundedStoredText,
|
|
95
|
-
batchId: BoundedIdentifier,
|
|
96
|
-
batchJson: BoundedStoredText,
|
|
97
|
-
conversationId: BoundedIdentifier,
|
|
98
|
-
expectedTailDigest: BoundedStoredText,
|
|
99
|
-
expectedTailSequence: CanonicalSequence,
|
|
100
|
-
producerEpoch: ProducerEpoch,
|
|
101
|
-
records: Schema.NonEmptyArray(RawRecord).check(Schema.isMaxLength(256)),
|
|
102
|
-
tailDigest: BoundedStoredText,
|
|
103
|
-
}) {}
|
|
104
|
-
|
|
105
|
-
export class RawAppendResult extends Schema.Class<RawAppendResult>(
|
|
106
|
-
"@effect-agent/storage-cloudflare/RawAppendResult",
|
|
107
|
-
)({
|
|
108
|
-
firstSequence: CanonicalSequence,
|
|
109
|
-
lastSequence: CanonicalSequence,
|
|
110
|
-
replayed: Schema.Boolean,
|
|
111
|
-
tailDigest: BoundedStoredText,
|
|
112
|
-
}) {}
|
|
113
|
-
|
|
114
|
-
export class RawReadRequest extends Schema.Class<RawReadRequest>(
|
|
115
|
-
"@effect-agent/storage-cloudflare/RawReadRequest",
|
|
116
|
-
)({
|
|
117
|
-
conversationId: BoundedIdentifier,
|
|
118
|
-
fromSequenceExclusive: CanonicalSequence,
|
|
119
|
-
limit: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(1_024)),
|
|
120
|
-
}) {}
|
|
121
|
-
|
|
122
|
-
export class RawCheckpoint extends Schema.Class<RawCheckpoint>(
|
|
123
|
-
"@effect-agent/storage-cloudflare/RawCheckpoint",
|
|
124
|
-
)({
|
|
125
|
-
checkpointJson: BoundedStoredText,
|
|
126
|
-
conversationId: BoundedIdentifier,
|
|
127
|
-
tailDigest: BoundedStoredText,
|
|
128
|
-
throughSequence: CanonicalSequence,
|
|
129
|
-
}) {}
|
|
130
|
-
|
|
131
|
-
export class RawConversationExport extends Schema.Class<RawConversationExport>(
|
|
132
|
-
"@effect-agent/storage-cloudflare/RawConversationExport",
|
|
133
|
-
)({
|
|
134
|
-
batches: Schema.Array(BatchRow),
|
|
135
|
-
checkpoints: Schema.Array(CheckpointRow),
|
|
136
|
-
conversation: ConversationRow,
|
|
137
|
-
records: Schema.Array(RecordRow),
|
|
138
|
-
}) {}
|
|
139
|
-
|
|
140
|
-
type AppendError =
|
|
141
|
-
| DoAppendConflict
|
|
142
|
-
| DoFenceRejected
|
|
143
|
-
| DoStorageCorruptionError
|
|
144
|
-
| DoStorageError
|
|
145
|
-
| DoStorageFailpointError
|
|
146
|
-
| DoValueBoundExceeded;
|
|
147
|
-
|
|
148
|
-
type CheckpointError =
|
|
149
|
-
| DoCheckpointConflict
|
|
150
|
-
| DoStorageCorruptionError
|
|
151
|
-
| DoStorageError
|
|
152
|
-
| DoValueBoundExceeded;
|
|
153
|
-
|
|
154
|
-
type DoJournalFailpoint = (
|
|
155
|
-
location: DoStorageFailpointLocation,
|
|
156
|
-
) => Effect.Effect<void, DoStorageFailpointError>;
|
|
157
|
-
|
|
158
|
-
const noFailpoint: DoJournalFailpoint = () => Effect.void;
|
|
159
|
-
|
|
160
|
-
const storageError =
|
|
161
|
-
(operation: string) =>
|
|
162
|
-
(error: SqlError): DoStorageError =>
|
|
163
|
-
DoStorageError.make({
|
|
164
|
-
cause: error,
|
|
165
|
-
operation,
|
|
166
|
-
message: error.message,
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
/** Decode raw Durable Object SQLite rows against a Schema, reporting failures as typed corruption. */
|
|
170
|
-
export const decodeRows = Effect.fn("DoJournal.decodeRows")(
|
|
171
|
-
<A, I>(
|
|
172
|
-
schema: Schema.Codec<ReadonlyArray<A>, ReadonlyArray<I>>,
|
|
173
|
-
table: string,
|
|
174
|
-
rowKey: string,
|
|
175
|
-
rows: unknown,
|
|
176
|
-
): Effect.Effect<ReadonlyArray<A>, DoStorageCorruptionError> =>
|
|
177
|
-
Schema.decodeUnknownEffect(schema)(rows).pipe(
|
|
178
|
-
Effect.mapError((error) =>
|
|
179
|
-
DoStorageCorruptionError.make({
|
|
180
|
-
table,
|
|
181
|
-
rowKey,
|
|
182
|
-
message: String(error),
|
|
183
|
-
}),
|
|
184
|
-
),
|
|
185
|
-
),
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
/** Decode exactly one raw row against a Schema, reporting failures as typed corruption. */
|
|
189
|
-
export const decodeSingleRow = Effect.fn("DoJournal.decodeSingleRow")(
|
|
190
|
-
<A, I>(
|
|
191
|
-
schema: Schema.Codec<ReadonlyArray<A>, ReadonlyArray<I>>,
|
|
192
|
-
table: string,
|
|
193
|
-
rowKey: string,
|
|
194
|
-
rows: unknown,
|
|
195
|
-
): Effect.Effect<A, DoStorageCorruptionError> =>
|
|
196
|
-
decodeRows(schema, table, rowKey, rows).pipe(
|
|
197
|
-
Effect.flatMap((decoded) =>
|
|
198
|
-
decoded.length === 1
|
|
199
|
-
? Effect.succeed(decoded[0])
|
|
200
|
-
: Effect.fail(
|
|
201
|
-
DoStorageCorruptionError.make({
|
|
202
|
-
table,
|
|
203
|
-
rowKey,
|
|
204
|
-
message: `Expected exactly one row but found ${decoded.length}.`,
|
|
205
|
-
}),
|
|
206
|
-
),
|
|
207
|
-
),
|
|
208
|
-
),
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
const REQUIRED_TABLES = [
|
|
212
|
-
"effect_agent_abort_intents",
|
|
213
|
-
"effect_agent_approval_decisions",
|
|
214
|
-
"effect_agent_attempts",
|
|
215
|
-
"effect_agent_canonical_batches",
|
|
216
|
-
"effect_agent_canonical_records",
|
|
217
|
-
"effect_agent_checkpoints",
|
|
218
|
-
"effect_agent_child_reservations",
|
|
219
|
-
"effect_agent_child_settlements",
|
|
220
|
-
"effect_agent_conversations",
|
|
221
|
-
"effect_agent_meta",
|
|
222
|
-
"effect_agent_settlement_reservations",
|
|
223
|
-
"effect_agent_submission_ownership",
|
|
224
|
-
"effect_agent_submissions",
|
|
225
|
-
"effect_agent_unknown_resolutions",
|
|
226
|
-
] as const;
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Exact-or-fresh storage gate (DEPLOY-008) over `effect_agent_meta` instead of
|
|
230
|
-
* `PRAGMA user_version` (unverified on Durable Object SQL storage; a meta table is portable
|
|
231
|
-
* regardless). No WAL check (Durable Object storage owns durability and confirms writes
|
|
232
|
-
* through output gates) and no busy timeout (a Durable Object has exactly one writer): the
|
|
233
|
-
* Node machinery those served has no DC analogue and is deliberately absent.
|
|
234
|
-
*/
|
|
235
|
-
const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(function* (
|
|
236
|
-
sql: SqlClient.SqlClient,
|
|
237
|
-
failpoint: DoJournalFailpoint = noFailpoint,
|
|
238
|
-
maxStoredValueBytes: number,
|
|
239
|
-
) {
|
|
240
|
-
const metaTableRows = yield* sql<Record<string, unknown>>`
|
|
241
|
-
SELECT name
|
|
242
|
-
FROM sqlite_master
|
|
243
|
-
WHERE type = 'table'
|
|
244
|
-
AND name = 'effect_agent_meta'
|
|
245
|
-
`.pipe(Effect.mapError(storageError("read storage version table")));
|
|
246
|
-
const metaTables = yield* decodeRows(
|
|
247
|
-
Schema.Array(DoNameRow),
|
|
248
|
-
"sqlite_master",
|
|
249
|
-
"effect_agent_meta",
|
|
250
|
-
metaTableRows,
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
if (metaTables.length === 0) {
|
|
254
|
-
const existingRows = yield* sql<Record<string, unknown>>`
|
|
255
|
-
SELECT name
|
|
256
|
-
FROM sqlite_master
|
|
257
|
-
WHERE type = 'table'
|
|
258
|
-
AND name LIKE 'effect_agent_%'
|
|
259
|
-
ORDER BY name
|
|
260
|
-
`.pipe(Effect.mapError(storageError("inspect unversioned storage")));
|
|
261
|
-
const existing = yield* decodeRows(
|
|
262
|
-
Schema.Array(DoNameRow),
|
|
263
|
-
"sqlite_master",
|
|
264
|
-
"effect_agent_%",
|
|
265
|
-
existingRows,
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
if (existing.length > 0) {
|
|
269
|
-
return yield* DoStorageCompatibilityError.make({
|
|
270
|
-
actualVersion: 0,
|
|
271
|
-
supportedVersion: CurrentDoStorageVersion,
|
|
272
|
-
message:
|
|
273
|
-
"The Durable Object contains unversioned Effect Agent tables. Reset the development namespace explicitly; refusing to mutate ambiguous stored data.",
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
yield* SqliteMigrator.run({ loader: doMigrations }).pipe(
|
|
278
|
-
// SqliteMigrator depends on the generic client supplied by this adapter. The concrete
|
|
279
|
-
// Durable Object client is kept at the outer Layer boundary.
|
|
280
|
-
Effect.provideService(SqlClient.SqlClient, sql),
|
|
281
|
-
Effect.mapError((error) =>
|
|
282
|
-
DoStorageError.make({
|
|
283
|
-
cause: error,
|
|
284
|
-
operation: "initialize current storage",
|
|
285
|
-
message: error.message,
|
|
286
|
-
}),
|
|
287
|
-
),
|
|
288
|
-
);
|
|
289
|
-
} else {
|
|
290
|
-
const versionRows = yield* sql<Record<string, unknown>>`
|
|
291
|
-
SELECT value
|
|
292
|
-
FROM effect_agent_meta
|
|
293
|
-
WHERE key = 'storage_version'
|
|
294
|
-
`.pipe(Effect.mapError(storageError("read storage version")));
|
|
295
|
-
const version = yield* decodeSingleRow(
|
|
296
|
-
Schema.Array(DoMetaRow),
|
|
297
|
-
"effect_agent_meta",
|
|
298
|
-
"storage_version",
|
|
299
|
-
versionRows,
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
// The storage version must match EXACTLY. Older private-development versions fail
|
|
303
|
-
// closed with reset guidance rather than being migrated, and newer versions fail closed
|
|
304
|
-
// rather than being decoded incorrectly (DEPLOY-008).
|
|
305
|
-
if (version.value !== String(CurrentDoStorageVersion)) {
|
|
306
|
-
const actualVersion = Number.parseInt(version.value, 10);
|
|
307
|
-
return yield* DoStorageCompatibilityError.make({
|
|
308
|
-
actualVersion: Number.isSafeInteger(actualVersion) ? actualVersion : -1,
|
|
309
|
-
supportedVersion: CurrentDoStorageVersion,
|
|
310
|
-
message:
|
|
311
|
-
`The Durable Object uses private-development storage version ${version.value}; ` +
|
|
312
|
-
`this build supports exactly version ${CurrentDoStorageVersion}. ` +
|
|
313
|
-
"Replace the development namespace explicitly; automatic stored-data migrations are not provided during private development.",
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const requiredRows = yield* sql<Record<string, unknown>>`
|
|
319
|
-
SELECT name
|
|
320
|
-
FROM sqlite_master
|
|
321
|
-
WHERE type = 'table'
|
|
322
|
-
AND name IN ${sql.in([...REQUIRED_TABLES])}
|
|
323
|
-
ORDER BY name
|
|
324
|
-
`.pipe(Effect.mapError(storageError("verify storage tables")));
|
|
325
|
-
const required = yield* decodeRows(
|
|
326
|
-
Schema.Array(DoNameRow),
|
|
327
|
-
"sqlite_master",
|
|
328
|
-
"required_tables",
|
|
329
|
-
requiredRows,
|
|
330
|
-
);
|
|
331
|
-
if (required.length !== REQUIRED_TABLES.length) {
|
|
332
|
-
return yield* DoStorageCompatibilityError.make({
|
|
333
|
-
actualVersion: CurrentDoStorageVersion,
|
|
334
|
-
supportedVersion: CurrentDoStorageVersion,
|
|
335
|
-
message:
|
|
336
|
-
"The Durable Object claims the current format but is missing required tables. Reset the corrupt private-development data.",
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return makeJournal(sql, failpoint, maxStoredValueBytes);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
const makeJournal = (
|
|
344
|
-
sql: SqlClient.SqlClient,
|
|
345
|
-
failpoint: DoJournalFailpoint,
|
|
346
|
-
maxStoredValueBytes: number,
|
|
347
|
-
) => {
|
|
348
|
-
/** Typed pre-write refusal for any single value over the configured byte bound. */
|
|
349
|
-
const checkValueBound = (
|
|
350
|
-
operation: string,
|
|
351
|
-
value: string,
|
|
352
|
-
): Effect.Effect<void, DoValueBoundExceeded> => {
|
|
353
|
-
const actualBytes = storedTextBytes(value);
|
|
354
|
-
return actualBytes > maxStoredValueBytes
|
|
355
|
-
? Effect.fail(
|
|
356
|
-
DoValueBoundExceeded.make({
|
|
357
|
-
actualBytes,
|
|
358
|
-
maxBytes: maxStoredValueBytes,
|
|
359
|
-
operation,
|
|
360
|
-
}),
|
|
361
|
-
)
|
|
362
|
-
: Effect.void;
|
|
363
|
-
};
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Runs one journal write transaction on the Durable Object storage-backed
|
|
367
|
-
* `withTransaction` (`ctx.storage.transaction()` under the hood). Within one Durable
|
|
368
|
-
* Object there is exactly ONE writer, so the Node `BEGIN IMMEDIATE` + busy-retry +
|
|
369
|
-
* `SqliteWriteContention` machinery has no analogue here and is deliberately absent.
|
|
370
|
-
* Ownership-token and epoch checks still run INSIDE the transaction, so fencing atomicity
|
|
371
|
-
* (DUR-006) is preserved identically.
|
|
372
|
-
*
|
|
373
|
-
* Journal write transactions are always top level: the Durable Object client rejects
|
|
374
|
-
* nested transactions, so new journal operations must not wrap this helper inside another
|
|
375
|
-
* transaction.
|
|
376
|
-
*/
|
|
377
|
-
const withWriteTransaction =
|
|
378
|
-
(operation: string) =>
|
|
379
|
-
<A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E | DoStorageError> =>
|
|
380
|
-
sql.withTransaction(effect).pipe(
|
|
381
|
-
Effect.mapError((error) =>
|
|
382
|
-
error instanceof SqlError ? storageError(operation)(error) : error,
|
|
383
|
-
),
|
|
384
|
-
Effect.withSpan("DoJournal.withWriteTransaction", { attributes: { operation } }),
|
|
385
|
-
);
|
|
386
|
-
|
|
387
|
-
const materialize = Effect.fn("DoJournal.materialize")(function* (
|
|
388
|
-
conversationId: string,
|
|
389
|
-
createdAt: string,
|
|
390
|
-
emptyTailDigest: string,
|
|
391
|
-
producerEpoch: ProducerEpoch,
|
|
392
|
-
): Effect.fn.Return<
|
|
393
|
-
void,
|
|
394
|
-
DoFenceRejected | DoStorageCorruptionError | DoStorageError | DoValueBoundExceeded
|
|
395
|
-
> {
|
|
396
|
-
if (conversationId.length > MAX_IDENTIFIER_LENGTH) {
|
|
397
|
-
return yield* DoStorageError.make({
|
|
398
|
-
operation: "materialize conversation",
|
|
399
|
-
message: "Conversation identity exceeds the Durable Object storage bounds.",
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
yield* checkValueBound("materialize conversation", emptyTailDigest);
|
|
403
|
-
yield* withWriteTransaction("materialize transaction")(
|
|
404
|
-
Effect.gen(function* () {
|
|
405
|
-
const existingRows = yield* sql<Record<string, unknown>>`
|
|
406
|
-
SELECT
|
|
407
|
-
conversation_id,
|
|
408
|
-
created_at,
|
|
409
|
-
tail_sequence,
|
|
410
|
-
tail_digest,
|
|
411
|
-
producer_epoch
|
|
412
|
-
FROM effect_agent_conversations
|
|
413
|
-
WHERE conversation_id = ${conversationId}
|
|
414
|
-
`.pipe(Effect.mapError(storageError("read materialized conversation")));
|
|
415
|
-
const existing = yield* decodeRows(
|
|
416
|
-
Schema.Array(ConversationRow),
|
|
417
|
-
"effect_agent_conversations",
|
|
418
|
-
conversationId,
|
|
419
|
-
existingRows,
|
|
420
|
-
);
|
|
421
|
-
if (existing.length > 1) {
|
|
422
|
-
return yield* DoStorageCorruptionError.make({
|
|
423
|
-
table: "effect_agent_conversations",
|
|
424
|
-
rowKey: conversationId,
|
|
425
|
-
message: "A conversation primary key returned more than one row.",
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
if (existing.length === 0) {
|
|
429
|
-
yield* sql`
|
|
430
|
-
INSERT INTO effect_agent_conversations (
|
|
431
|
-
conversation_id,
|
|
432
|
-
created_at,
|
|
433
|
-
tail_sequence,
|
|
434
|
-
tail_digest,
|
|
435
|
-
producer_epoch
|
|
436
|
-
) VALUES (
|
|
437
|
-
${conversationId},
|
|
438
|
-
${createdAt},
|
|
439
|
-
0,
|
|
440
|
-
${emptyTailDigest},
|
|
441
|
-
${producerEpoch}
|
|
442
|
-
)
|
|
443
|
-
`.pipe(Effect.mapError(storageError("materialize conversation")));
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
if (producerEpoch < existing[0].producer_epoch) {
|
|
447
|
-
return yield* DoFenceRejected.make({
|
|
448
|
-
producerEpoch,
|
|
449
|
-
actualEpoch: existing[0].producer_epoch,
|
|
450
|
-
message: `Producer epoch ${producerEpoch} is stale; current epoch is ${existing[0].producer_epoch}.`,
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
if (producerEpoch > existing[0].producer_epoch) {
|
|
454
|
-
yield* sql`
|
|
455
|
-
UPDATE effect_agent_conversations
|
|
456
|
-
SET producer_epoch = ${producerEpoch}
|
|
457
|
-
WHERE conversation_id = ${conversationId}
|
|
458
|
-
`.pipe(Effect.mapError(storageError("advance materialization epoch")));
|
|
459
|
-
}
|
|
460
|
-
}),
|
|
461
|
-
);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
const getConversation = Effect.fn("DoJournal.getConversation")(function* (
|
|
465
|
-
conversationId: string,
|
|
466
|
-
) {
|
|
467
|
-
const rows = yield* sql<Record<string, unknown>>`
|
|
468
|
-
SELECT
|
|
469
|
-
conversation_id,
|
|
470
|
-
created_at,
|
|
471
|
-
tail_sequence,
|
|
472
|
-
tail_digest,
|
|
473
|
-
producer_epoch
|
|
474
|
-
FROM effect_agent_conversations
|
|
475
|
-
WHERE conversation_id = ${conversationId}
|
|
476
|
-
`.pipe(Effect.mapError(storageError("read conversation")));
|
|
477
|
-
return yield* decodeRows(
|
|
478
|
-
Schema.Array(ConversationRow),
|
|
479
|
-
"effect_agent_conversations",
|
|
480
|
-
conversationId,
|
|
481
|
-
rows,
|
|
482
|
-
);
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
const append = Effect.fn("DoJournal.append")(function* (
|
|
486
|
-
request: RawAppendRequest,
|
|
487
|
-
): Effect.fn.Return<RawAppendResult, AppendError> {
|
|
488
|
-
if (
|
|
489
|
-
request.conversationId.length > MAX_IDENTIFIER_LENGTH ||
|
|
490
|
-
request.batchId.length > MAX_IDENTIFIER_LENGTH ||
|
|
491
|
-
request.records.some((record) => record.recordId.length > MAX_IDENTIFIER_LENGTH)
|
|
492
|
-
) {
|
|
493
|
-
return yield* DoStorageError.make({
|
|
494
|
-
operation: "append canonical batch",
|
|
495
|
-
message: "Canonical identifiers exceed the Durable Object storage bounds.",
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
// The platform's ~2 MB per-value limit, enforced typed BEFORE any write (plan §1.2).
|
|
499
|
-
yield* checkValueBound("append canonical batch", request.batchJson);
|
|
500
|
-
yield* checkValueBound("append canonical batch", request.batchDigest);
|
|
501
|
-
yield* checkValueBound("append canonical batch", request.tailDigest);
|
|
502
|
-
yield* Effect.forEach(
|
|
503
|
-
request.records,
|
|
504
|
-
(record) => checkValueBound("append canonical record", record.recordJson),
|
|
505
|
-
{ discard: true },
|
|
506
|
-
);
|
|
507
|
-
return yield* withWriteTransaction("append transaction")(
|
|
508
|
-
Effect.gen(function* () {
|
|
509
|
-
const recordIds = request.records.map((record) => record.recordId);
|
|
510
|
-
if (new Set(recordIds).size !== recordIds.length) {
|
|
511
|
-
return yield* DoAppendConflict.make({
|
|
512
|
-
message: `Batch ${request.batchId} contains duplicate canonical record IDs.`,
|
|
513
|
-
reason: "record-identity",
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const conversationRows = yield* sql<Record<string, unknown>>`
|
|
518
|
-
SELECT
|
|
519
|
-
conversation_id,
|
|
520
|
-
created_at,
|
|
521
|
-
tail_sequence,
|
|
522
|
-
tail_digest,
|
|
523
|
-
producer_epoch
|
|
524
|
-
FROM effect_agent_conversations
|
|
525
|
-
WHERE conversation_id = ${request.conversationId}
|
|
526
|
-
`.pipe(Effect.mapError(storageError("read append tail")));
|
|
527
|
-
const conversation = yield* decodeSingleRow(
|
|
528
|
-
Schema.Array(ConversationRow),
|
|
529
|
-
"effect_agent_conversations",
|
|
530
|
-
request.conversationId,
|
|
531
|
-
conversationRows,
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
if (request.producerEpoch !== conversation.producer_epoch) {
|
|
535
|
-
return yield* DoFenceRejected.make({
|
|
536
|
-
producerEpoch: request.producerEpoch,
|
|
537
|
-
actualEpoch: conversation.producer_epoch,
|
|
538
|
-
message: `Producer epoch ${request.producerEpoch} is not the current epoch ${conversation.producer_epoch}.`,
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const batchRows = yield* sql<Record<string, unknown>>`
|
|
543
|
-
SELECT
|
|
544
|
-
conversation_id,
|
|
545
|
-
batch_id,
|
|
546
|
-
first_sequence,
|
|
547
|
-
last_sequence,
|
|
548
|
-
batch_digest,
|
|
549
|
-
tail_digest,
|
|
550
|
-
batch_json
|
|
551
|
-
FROM effect_agent_canonical_batches
|
|
552
|
-
WHERE conversation_id = ${request.conversationId}
|
|
553
|
-
AND batch_id = ${request.batchId}
|
|
554
|
-
`.pipe(Effect.mapError(storageError("read idempotent batch")));
|
|
555
|
-
const batches = yield* decodeRows(
|
|
556
|
-
Schema.Array(BatchRow),
|
|
557
|
-
"effect_agent_canonical_batches",
|
|
558
|
-
`${request.conversationId}/${request.batchId}`,
|
|
559
|
-
batchRows,
|
|
560
|
-
);
|
|
561
|
-
|
|
562
|
-
if (batches.length > 1) {
|
|
563
|
-
return yield* DoStorageCorruptionError.make({
|
|
564
|
-
table: "effect_agent_canonical_batches",
|
|
565
|
-
rowKey: `${request.conversationId}/${request.batchId}`,
|
|
566
|
-
message: "A canonical batch primary key returned more than one row.",
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
if (batches.length === 1) {
|
|
570
|
-
const existing = batches[0];
|
|
571
|
-
if (existing.batch_digest !== request.batchDigest) {
|
|
572
|
-
return yield* DoAppendConflict.make({
|
|
573
|
-
message: `Batch ${request.batchId} already exists with different canonical content.`,
|
|
574
|
-
reason: "batch-digest",
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
return RawAppendResult.make({
|
|
578
|
-
firstSequence: existing.first_sequence,
|
|
579
|
-
lastSequence: existing.last_sequence,
|
|
580
|
-
replayed: true,
|
|
581
|
-
tailDigest: existing.tail_digest,
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (
|
|
586
|
-
request.expectedTailSequence !== conversation.tail_sequence ||
|
|
587
|
-
request.expectedTailDigest !== conversation.tail_digest
|
|
588
|
-
) {
|
|
589
|
-
return yield* DoAppendConflict.make({
|
|
590
|
-
message:
|
|
591
|
-
`Expected tail ${request.expectedTailSequence}/${request.expectedTailDigest} ` +
|
|
592
|
-
`but found ${conversation.tail_sequence}/${conversation.tail_digest}.`,
|
|
593
|
-
reason: "tail",
|
|
594
|
-
actualTailSequence: conversation.tail_sequence,
|
|
595
|
-
actualTailDigest: conversation.tail_digest,
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
if (conversation.tail_sequence + request.records.length > MAX_RECORDS_PER_CONVERSATION) {
|
|
599
|
-
return yield* DoStorageError.make({
|
|
600
|
-
operation: "append canonical batch",
|
|
601
|
-
message: `Conversation record limit ${MAX_RECORDS_PER_CONVERSATION} would be exceeded.`,
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Chunked to respect the Durable Object platform's 100-bound-parameter statement
|
|
606
|
-
// limit: a batch may carry up to 256 records.
|
|
607
|
-
const existingRecords: Array<RecordRow> = [];
|
|
608
|
-
for (const chunk of chunked(recordIds, MAX_BOUND_PARAMETERS - 10)) {
|
|
609
|
-
const existingRecordRows = yield* sql<Record<string, unknown>>`
|
|
610
|
-
SELECT
|
|
611
|
-
conversation_id,
|
|
612
|
-
sequence,
|
|
613
|
-
record_id,
|
|
614
|
-
batch_id,
|
|
615
|
-
record_json
|
|
616
|
-
FROM effect_agent_canonical_records
|
|
617
|
-
WHERE conversation_id = ${request.conversationId}
|
|
618
|
-
AND record_id IN ${sql.in([...chunk])}
|
|
619
|
-
ORDER BY sequence
|
|
620
|
-
`.pipe(Effect.mapError(storageError("check canonical record identities")));
|
|
621
|
-
existingRecords.push(
|
|
622
|
-
...(yield* decodeRows(
|
|
623
|
-
Schema.Array(RecordRow),
|
|
624
|
-
"effect_agent_canonical_records",
|
|
625
|
-
`${request.conversationId}/record_ids`,
|
|
626
|
-
existingRecordRows,
|
|
627
|
-
)),
|
|
628
|
-
);
|
|
629
|
-
}
|
|
630
|
-
if (existingRecords.length > 0) {
|
|
631
|
-
return yield* DoAppendConflict.make({
|
|
632
|
-
message: `Canonical record ID ${existingRecords[0].record_id} already exists.`,
|
|
633
|
-
reason: "record-identity",
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const firstSequence = yield* Schema.decodeUnknownEffect(CanonicalSequence)(
|
|
638
|
-
conversation.tail_sequence + 1,
|
|
639
|
-
).pipe(
|
|
640
|
-
Effect.mapError((error) =>
|
|
641
|
-
DoStorageError.make({
|
|
642
|
-
cause: error,
|
|
643
|
-
operation: "append canonical batch",
|
|
644
|
-
message: error.message,
|
|
645
|
-
}),
|
|
646
|
-
),
|
|
647
|
-
);
|
|
648
|
-
const lastSequence = yield* Schema.decodeUnknownEffect(CanonicalSequence)(
|
|
649
|
-
firstSequence + request.records.length - 1,
|
|
650
|
-
).pipe(
|
|
651
|
-
Effect.mapError((error) =>
|
|
652
|
-
DoStorageError.make({
|
|
653
|
-
cause: error,
|
|
654
|
-
operation: "append canonical batch",
|
|
655
|
-
message: error.message,
|
|
656
|
-
}),
|
|
657
|
-
),
|
|
658
|
-
);
|
|
659
|
-
|
|
660
|
-
yield* sql`
|
|
661
|
-
INSERT INTO effect_agent_canonical_batches (
|
|
662
|
-
conversation_id,
|
|
663
|
-
batch_id,
|
|
664
|
-
first_sequence,
|
|
665
|
-
last_sequence,
|
|
666
|
-
batch_digest,
|
|
667
|
-
tail_digest,
|
|
668
|
-
batch_json
|
|
669
|
-
) VALUES (
|
|
670
|
-
${request.conversationId},
|
|
671
|
-
${request.batchId},
|
|
672
|
-
${firstSequence},
|
|
673
|
-
${lastSequence},
|
|
674
|
-
${request.batchDigest},
|
|
675
|
-
${request.tailDigest},
|
|
676
|
-
${request.batchJson}
|
|
677
|
-
)
|
|
678
|
-
`.pipe(Effect.mapError(storageError("insert canonical batch")));
|
|
679
|
-
yield* failpoint("append:after-batch-insert");
|
|
680
|
-
|
|
681
|
-
yield* Effect.forEach(
|
|
682
|
-
request.records,
|
|
683
|
-
(record, index) =>
|
|
684
|
-
Effect.gen(function* () {
|
|
685
|
-
yield* sql`
|
|
686
|
-
INSERT INTO effect_agent_canonical_records (
|
|
687
|
-
conversation_id,
|
|
688
|
-
sequence,
|
|
689
|
-
record_id,
|
|
690
|
-
batch_id,
|
|
691
|
-
record_json
|
|
692
|
-
) VALUES (
|
|
693
|
-
${request.conversationId},
|
|
694
|
-
${firstSequence + index},
|
|
695
|
-
${record.recordId},
|
|
696
|
-
${request.batchId},
|
|
697
|
-
${record.recordJson}
|
|
698
|
-
)
|
|
699
|
-
`.pipe(Effect.mapError(storageError("insert canonical record")));
|
|
700
|
-
yield* failpoint("append:after-record-insert");
|
|
701
|
-
}),
|
|
702
|
-
{ discard: true },
|
|
703
|
-
);
|
|
704
|
-
|
|
705
|
-
yield* sql`
|
|
706
|
-
UPDATE effect_agent_conversations
|
|
707
|
-
SET
|
|
708
|
-
tail_sequence = ${lastSequence},
|
|
709
|
-
tail_digest = ${request.tailDigest},
|
|
710
|
-
producer_epoch = ${request.producerEpoch}
|
|
711
|
-
WHERE conversation_id = ${request.conversationId}
|
|
712
|
-
`.pipe(Effect.mapError(storageError("advance conversation tail")));
|
|
713
|
-
yield* failpoint("append:after-tail-update");
|
|
714
|
-
|
|
715
|
-
return RawAppendResult.make({
|
|
716
|
-
firstSequence,
|
|
717
|
-
lastSequence,
|
|
718
|
-
replayed: false,
|
|
719
|
-
tailDigest: request.tailDigest,
|
|
720
|
-
});
|
|
721
|
-
}),
|
|
722
|
-
);
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
const read = Effect.fn("DoJournal.read")(function* (request: RawReadRequest) {
|
|
726
|
-
const rows = yield* sql<Record<string, unknown>>`
|
|
727
|
-
SELECT
|
|
728
|
-
conversation_id,
|
|
729
|
-
sequence,
|
|
730
|
-
record_id,
|
|
731
|
-
batch_id,
|
|
732
|
-
record_json
|
|
733
|
-
FROM effect_agent_canonical_records
|
|
734
|
-
WHERE conversation_id = ${request.conversationId}
|
|
735
|
-
AND sequence > ${request.fromSequenceExclusive}
|
|
736
|
-
ORDER BY sequence
|
|
737
|
-
LIMIT ${request.limit}
|
|
738
|
-
`.pipe(Effect.mapError(storageError("read canonical records")));
|
|
739
|
-
return yield* decodeRows(
|
|
740
|
-
Schema.Array(RecordRow),
|
|
741
|
-
"effect_agent_canonical_records",
|
|
742
|
-
`${request.conversationId}>${request.fromSequenceExclusive}`,
|
|
743
|
-
rows,
|
|
744
|
-
);
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
const exportConversation = Effect.fn("DoJournal.exportConversation")(function* (
|
|
748
|
-
conversationId: string,
|
|
749
|
-
) {
|
|
750
|
-
return yield* sql
|
|
751
|
-
.withTransaction(
|
|
752
|
-
Effect.gen(function* () {
|
|
753
|
-
const conversationRows = yield* sql<Record<string, unknown>>`
|
|
754
|
-
SELECT
|
|
755
|
-
conversation_id,
|
|
756
|
-
created_at,
|
|
757
|
-
tail_sequence,
|
|
758
|
-
tail_digest,
|
|
759
|
-
producer_epoch
|
|
760
|
-
FROM effect_agent_conversations
|
|
761
|
-
WHERE conversation_id = ${conversationId}
|
|
762
|
-
`.pipe(Effect.mapError(storageError("export conversation")));
|
|
763
|
-
const conversation = yield* decodeSingleRow(
|
|
764
|
-
Schema.Array(ConversationRow),
|
|
765
|
-
"effect_agent_conversations",
|
|
766
|
-
conversationId,
|
|
767
|
-
conversationRows,
|
|
768
|
-
);
|
|
769
|
-
yield* failpoint("export:after-conversation-read");
|
|
770
|
-
const batchRows = yield* sql<Record<string, unknown>>`
|
|
771
|
-
SELECT
|
|
772
|
-
conversation_id,
|
|
773
|
-
batch_id,
|
|
774
|
-
first_sequence,
|
|
775
|
-
last_sequence,
|
|
776
|
-
batch_digest,
|
|
777
|
-
tail_digest,
|
|
778
|
-
batch_json
|
|
779
|
-
FROM effect_agent_canonical_batches
|
|
780
|
-
WHERE conversation_id = ${conversationId}
|
|
781
|
-
ORDER BY first_sequence
|
|
782
|
-
`.pipe(Effect.mapError(storageError("export canonical batches")));
|
|
783
|
-
const recordRows = yield* sql<Record<string, unknown>>`
|
|
784
|
-
SELECT
|
|
785
|
-
conversation_id,
|
|
786
|
-
sequence,
|
|
787
|
-
record_id,
|
|
788
|
-
batch_id,
|
|
789
|
-
record_json
|
|
790
|
-
FROM effect_agent_canonical_records
|
|
791
|
-
WHERE conversation_id = ${conversationId}
|
|
792
|
-
ORDER BY sequence
|
|
793
|
-
`.pipe(Effect.mapError(storageError("export canonical records")));
|
|
794
|
-
const checkpointRows = yield* sql<Record<string, unknown>>`
|
|
795
|
-
SELECT
|
|
796
|
-
conversation_id,
|
|
797
|
-
through_sequence,
|
|
798
|
-
tail_digest,
|
|
799
|
-
checkpoint_json
|
|
800
|
-
FROM effect_agent_checkpoints
|
|
801
|
-
WHERE conversation_id = ${conversationId}
|
|
802
|
-
ORDER BY through_sequence
|
|
803
|
-
`.pipe(Effect.mapError(storageError("export checkpoints")));
|
|
804
|
-
|
|
805
|
-
return RawConversationExport.make({
|
|
806
|
-
conversation,
|
|
807
|
-
batches: yield* decodeRows(
|
|
808
|
-
Schema.Array(BatchRow),
|
|
809
|
-
"effect_agent_canonical_batches",
|
|
810
|
-
conversationId,
|
|
811
|
-
batchRows,
|
|
812
|
-
),
|
|
813
|
-
records: yield* decodeRows(
|
|
814
|
-
Schema.Array(RecordRow),
|
|
815
|
-
"effect_agent_canonical_records",
|
|
816
|
-
conversationId,
|
|
817
|
-
recordRows,
|
|
818
|
-
),
|
|
819
|
-
checkpoints: yield* decodeRows(
|
|
820
|
-
Schema.Array(CheckpointRow),
|
|
821
|
-
"effect_agent_checkpoints",
|
|
822
|
-
conversationId,
|
|
823
|
-
checkpointRows,
|
|
824
|
-
),
|
|
825
|
-
});
|
|
826
|
-
}),
|
|
827
|
-
)
|
|
828
|
-
.pipe(
|
|
829
|
-
Effect.catchTag("SqlError", (error) =>
|
|
830
|
-
Effect.fail(storageError("export transaction")(error)),
|
|
831
|
-
),
|
|
832
|
-
);
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
const saveCheckpoint = Effect.fn("DoJournal.saveCheckpoint")(function* (
|
|
836
|
-
checkpoint: RawCheckpoint,
|
|
837
|
-
): Effect.fn.Return<void, CheckpointError> {
|
|
838
|
-
if (checkpoint.conversationId.length > MAX_IDENTIFIER_LENGTH) {
|
|
839
|
-
return yield* DoStorageError.make({
|
|
840
|
-
operation: "save checkpoint",
|
|
841
|
-
message: "Checkpoint identity exceeds the Durable Object storage bounds.",
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
yield* checkValueBound("save checkpoint", checkpoint.checkpointJson);
|
|
845
|
-
yield* withWriteTransaction("checkpoint transaction")(
|
|
846
|
-
Effect.gen(function* () {
|
|
847
|
-
const conversationRows = yield* sql<Record<string, unknown>>`
|
|
848
|
-
SELECT
|
|
849
|
-
conversation_id,
|
|
850
|
-
created_at,
|
|
851
|
-
tail_sequence,
|
|
852
|
-
tail_digest,
|
|
853
|
-
producer_epoch
|
|
854
|
-
FROM effect_agent_conversations
|
|
855
|
-
WHERE conversation_id = ${checkpoint.conversationId}
|
|
856
|
-
`.pipe(Effect.mapError(storageError("read checkpoint tail")));
|
|
857
|
-
const conversation = yield* decodeSingleRow(
|
|
858
|
-
Schema.Array(ConversationRow),
|
|
859
|
-
"effect_agent_conversations",
|
|
860
|
-
checkpoint.conversationId,
|
|
861
|
-
conversationRows,
|
|
862
|
-
);
|
|
863
|
-
if (checkpoint.throughSequence > conversation.tail_sequence) {
|
|
864
|
-
return yield* DoCheckpointConflict.make({
|
|
865
|
-
message:
|
|
866
|
-
`Checkpoint sequence ${checkpoint.throughSequence} is after canonical tail ` +
|
|
867
|
-
`${conversation.tail_sequence}.`,
|
|
868
|
-
});
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
const checkpointRows = yield* sql<Record<string, unknown>>`
|
|
872
|
-
SELECT
|
|
873
|
-
conversation_id,
|
|
874
|
-
through_sequence,
|
|
875
|
-
tail_digest,
|
|
876
|
-
checkpoint_json
|
|
877
|
-
FROM effect_agent_checkpoints
|
|
878
|
-
WHERE conversation_id = ${checkpoint.conversationId}
|
|
879
|
-
AND through_sequence = ${checkpoint.throughSequence}
|
|
880
|
-
`.pipe(Effect.mapError(storageError("read idempotent checkpoint")));
|
|
881
|
-
const existing = yield* decodeRows(
|
|
882
|
-
Schema.Array(CheckpointRow),
|
|
883
|
-
"effect_agent_checkpoints",
|
|
884
|
-
`${checkpoint.conversationId}/${checkpoint.throughSequence}`,
|
|
885
|
-
checkpointRows,
|
|
886
|
-
);
|
|
887
|
-
if (existing.length > 1) {
|
|
888
|
-
return yield* DoStorageCorruptionError.make({
|
|
889
|
-
table: "effect_agent_checkpoints",
|
|
890
|
-
rowKey: `${checkpoint.conversationId}/${checkpoint.throughSequence}`,
|
|
891
|
-
message: "A checkpoint primary key returned more than one row.",
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
|
-
if (existing.length === 1) {
|
|
895
|
-
if (
|
|
896
|
-
existing[0].tail_digest !== checkpoint.tailDigest ||
|
|
897
|
-
existing[0].checkpoint_json !== checkpoint.checkpointJson
|
|
898
|
-
) {
|
|
899
|
-
return yield* DoCheckpointConflict.make({
|
|
900
|
-
message: "A different checkpoint already exists at this canonical sequence.",
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
yield* sql`
|
|
907
|
-
INSERT INTO effect_agent_checkpoints (
|
|
908
|
-
conversation_id,
|
|
909
|
-
through_sequence,
|
|
910
|
-
tail_digest,
|
|
911
|
-
checkpoint_json
|
|
912
|
-
) VALUES (
|
|
913
|
-
${checkpoint.conversationId},
|
|
914
|
-
${checkpoint.throughSequence},
|
|
915
|
-
${checkpoint.tailDigest},
|
|
916
|
-
${checkpoint.checkpointJson}
|
|
917
|
-
)
|
|
918
|
-
`.pipe(Effect.mapError(storageError("insert checkpoint")));
|
|
919
|
-
}),
|
|
920
|
-
);
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
const loadCheckpoint = Effect.fn("DoJournal.loadCheckpoint")(function* (
|
|
924
|
-
conversationId: string,
|
|
925
|
-
atOrBeforeSequence: CanonicalSequence,
|
|
926
|
-
) {
|
|
927
|
-
const rows = yield* sql<Record<string, unknown>>`
|
|
928
|
-
SELECT
|
|
929
|
-
conversation_id,
|
|
930
|
-
through_sequence,
|
|
931
|
-
tail_digest,
|
|
932
|
-
checkpoint_json
|
|
933
|
-
FROM effect_agent_checkpoints
|
|
934
|
-
WHERE conversation_id = ${conversationId}
|
|
935
|
-
AND through_sequence <= ${atOrBeforeSequence}
|
|
936
|
-
ORDER BY through_sequence DESC
|
|
937
|
-
LIMIT 1
|
|
938
|
-
`.pipe(Effect.mapError(storageError("load checkpoint")));
|
|
939
|
-
return yield* decodeRows(
|
|
940
|
-
Schema.Array(CheckpointRow),
|
|
941
|
-
"effect_agent_checkpoints",
|
|
942
|
-
`${conversationId}<=${atOrBeforeSequence}`,
|
|
943
|
-
rows,
|
|
944
|
-
);
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
const getTailDigestAt = Effect.fn("DoJournal.getTailDigestAt")(function* (
|
|
948
|
-
conversationId: string,
|
|
949
|
-
sequence: CanonicalSequence,
|
|
950
|
-
) {
|
|
951
|
-
if (sequence === 0) {
|
|
952
|
-
const conversations = yield* getConversation(conversationId);
|
|
953
|
-
return conversations.length === 0
|
|
954
|
-
? []
|
|
955
|
-
: [conversations[0].tail_sequence === 0 ? conversations[0].tail_digest : undefined].filter(
|
|
956
|
-
(value): value is string => value !== undefined,
|
|
957
|
-
);
|
|
958
|
-
}
|
|
959
|
-
const rows = yield* sql<Record<string, unknown>>`
|
|
960
|
-
SELECT
|
|
961
|
-
conversation_id,
|
|
962
|
-
batch_id,
|
|
963
|
-
first_sequence,
|
|
964
|
-
last_sequence,
|
|
965
|
-
batch_digest,
|
|
966
|
-
tail_digest,
|
|
967
|
-
batch_json
|
|
968
|
-
FROM effect_agent_canonical_batches
|
|
969
|
-
WHERE conversation_id = ${conversationId}
|
|
970
|
-
AND last_sequence = ${sequence}
|
|
971
|
-
`.pipe(Effect.mapError(storageError("read canonical digest at sequence")));
|
|
972
|
-
const batches = yield* decodeRows(
|
|
973
|
-
Schema.Array(BatchRow),
|
|
974
|
-
"effect_agent_canonical_batches",
|
|
975
|
-
`${conversationId}/${sequence}`,
|
|
976
|
-
rows,
|
|
977
|
-
);
|
|
978
|
-
return batches.map((batch) => batch.tail_digest);
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
const scanStoredPayloads = Effect.fn("DoJournal.scanStoredPayloads")(function* () {
|
|
982
|
-
return yield* sql
|
|
983
|
-
.withTransaction(
|
|
984
|
-
Effect.gen(function* () {
|
|
985
|
-
const conversations = yield* sql<Record<string, unknown>>`
|
|
986
|
-
SELECT
|
|
987
|
-
conversation_id,
|
|
988
|
-
created_at,
|
|
989
|
-
tail_sequence,
|
|
990
|
-
tail_digest,
|
|
991
|
-
producer_epoch
|
|
992
|
-
FROM effect_agent_conversations
|
|
993
|
-
ORDER BY conversation_id
|
|
994
|
-
`.pipe(Effect.mapError(storageError("scan conversations")));
|
|
995
|
-
const batches = yield* sql<Record<string, unknown>>`
|
|
996
|
-
SELECT
|
|
997
|
-
conversation_id,
|
|
998
|
-
batch_id,
|
|
999
|
-
first_sequence,
|
|
1000
|
-
last_sequence,
|
|
1001
|
-
batch_digest,
|
|
1002
|
-
tail_digest,
|
|
1003
|
-
batch_json
|
|
1004
|
-
FROM effect_agent_canonical_batches
|
|
1005
|
-
ORDER BY conversation_id, first_sequence
|
|
1006
|
-
`.pipe(Effect.mapError(storageError("scan canonical batches")));
|
|
1007
|
-
const records = yield* sql<Record<string, unknown>>`
|
|
1008
|
-
SELECT
|
|
1009
|
-
conversation_id,
|
|
1010
|
-
sequence,
|
|
1011
|
-
record_id,
|
|
1012
|
-
batch_id,
|
|
1013
|
-
record_json
|
|
1014
|
-
FROM effect_agent_canonical_records
|
|
1015
|
-
ORDER BY conversation_id, sequence
|
|
1016
|
-
`.pipe(Effect.mapError(storageError("scan canonical records")));
|
|
1017
|
-
const checkpoints = yield* sql<Record<string, unknown>>`
|
|
1018
|
-
SELECT
|
|
1019
|
-
conversation_id,
|
|
1020
|
-
through_sequence,
|
|
1021
|
-
tail_digest,
|
|
1022
|
-
checkpoint_json
|
|
1023
|
-
FROM effect_agent_checkpoints
|
|
1024
|
-
ORDER BY conversation_id, through_sequence
|
|
1025
|
-
`.pipe(Effect.mapError(storageError("scan checkpoints")));
|
|
1026
|
-
return {
|
|
1027
|
-
conversations: yield* decodeRows(
|
|
1028
|
-
Schema.Array(ConversationRow),
|
|
1029
|
-
"effect_agent_conversations",
|
|
1030
|
-
"startup_scan",
|
|
1031
|
-
conversations,
|
|
1032
|
-
),
|
|
1033
|
-
batches: yield* decodeRows(
|
|
1034
|
-
Schema.Array(BatchRow),
|
|
1035
|
-
"effect_agent_canonical_batches",
|
|
1036
|
-
"startup_scan",
|
|
1037
|
-
batches,
|
|
1038
|
-
),
|
|
1039
|
-
records: yield* decodeRows(
|
|
1040
|
-
Schema.Array(RecordRow),
|
|
1041
|
-
"effect_agent_canonical_records",
|
|
1042
|
-
"startup_scan",
|
|
1043
|
-
records,
|
|
1044
|
-
),
|
|
1045
|
-
checkpoints: yield* decodeRows(
|
|
1046
|
-
Schema.Array(CheckpointRow),
|
|
1047
|
-
"effect_agent_checkpoints",
|
|
1048
|
-
"startup_scan",
|
|
1049
|
-
checkpoints,
|
|
1050
|
-
),
|
|
1051
|
-
};
|
|
1052
|
-
}),
|
|
1053
|
-
)
|
|
1054
|
-
.pipe(
|
|
1055
|
-
Effect.catchTag("SqlError", (error) =>
|
|
1056
|
-
Effect.fail(storageError("startup scan transaction")(error)),
|
|
1057
|
-
),
|
|
1058
|
-
);
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
return {
|
|
1062
|
-
append,
|
|
1063
|
-
checkValueBound,
|
|
1064
|
-
exportConversation,
|
|
1065
|
-
getConversation,
|
|
1066
|
-
getTailDigestAt,
|
|
1067
|
-
loadCheckpoint,
|
|
1068
|
-
materialize,
|
|
1069
|
-
read,
|
|
1070
|
-
saveCheckpoint,
|
|
1071
|
-
scanStoredPayloads,
|
|
1072
|
-
withWriteTransaction,
|
|
1073
|
-
} as const;
|
|
1074
|
-
};
|
|
1075
|
-
|
|
1076
|
-
export type DoJournal = ReturnType<typeof makeJournal>;
|
|
1077
|
-
|
|
1078
|
-
export const initializeDoJournal = ensureCurrentStorage;
|