@effect-agent/storage-cloudflare 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.
Files changed (73) hide show
  1. package/dist/DoMemoryStore.d.mts +29 -0
  2. package/dist/DoMemoryStore.mjs +55 -0
  3. package/dist/DoMemoryStore.mjs.map +1 -0
  4. package/dist/DoMessageDeliveryStore.d.mts +17 -0
  5. package/dist/DoMessageDeliveryStore.mjs +32 -0
  6. package/dist/DoMessageDeliveryStore.mjs.map +1 -0
  7. package/dist/DoScheduleStore.d.mts +36 -0
  8. package/dist/DoScheduleStore.mjs +388 -0
  9. package/dist/DoScheduleStore.mjs.map +1 -0
  10. package/dist/DoStorageConfig.d.mts +45 -0
  11. package/dist/DoStorageConfig.mjs +52 -0
  12. package/dist/DoStorageConfig.mjs.map +1 -0
  13. package/dist/DoStorageError-Cmhvl4TQ.d.mts +98 -0
  14. package/dist/DoStorageError.d.mts +2 -0
  15. package/dist/DoStorageError.mjs +164 -0
  16. package/dist/DoStorageError.mjs.map +1 -0
  17. package/dist/DoStorageFailpoint.d.mts +17 -0
  18. package/dist/DoStorageFailpoint.mjs +14 -0
  19. package/dist/DoStorageFailpoint.mjs.map +1 -0
  20. package/dist/DoStorageFailpointTesting.d.mts +34 -0
  21. package/dist/DoStorageFailpointTesting.mjs +35 -0
  22. package/dist/DoStorageFailpointTesting.mjs.map +1 -0
  23. package/dist/DoStorageVersion-x5OOurvZ.d.mts +12 -0
  24. package/dist/DoStorageVersion.d.mts +2 -0
  25. package/dist/DoStorageVersion.mjs +8 -0
  26. package/dist/DoStorageVersion.mjs.map +1 -0
  27. package/dist/DoSubmissionLedger.d.mts +23 -0
  28. package/dist/DoSubmissionLedger.mjs +1849 -0
  29. package/dist/DoSubmissionLedger.mjs.map +1 -0
  30. package/dist/DoSubscriptionStore.d.mts +25 -0
  31. package/dist/DoSubscriptionStore.mjs +183 -0
  32. package/dist/DoSubscriptionStore.mjs.map +1 -0
  33. package/dist/DoThreadStore.d.mts +59 -0
  34. package/dist/DoThreadStore.mjs +465 -0
  35. package/dist/DoThreadStore.mjs.map +1 -0
  36. package/dist/MemoryProtocol.d.mts +38160 -0
  37. package/dist/MemoryProtocol.mjs +219 -0
  38. package/dist/MemoryProtocol.mjs.map +1 -0
  39. package/dist/PortProtocol.d.mts +3804 -0
  40. package/dist/PortProtocol.mjs +176 -0
  41. package/dist/PortProtocol.mjs.map +1 -0
  42. package/dist/PortRouting.d.mts +83 -0
  43. package/dist/PortRouting.mjs +410 -0
  44. package/dist/PortRouting.mjs.map +1 -0
  45. package/dist/do-journal-dJ01KSKf.mjs +991 -0
  46. package/dist/do-journal-dJ01KSKf.mjs.map +1 -0
  47. package/dist/index.d.mts +14 -1525
  48. package/dist/index.mjs +14 -3695
  49. package/dist/migrations-Ba_kOrSx.mjs +287 -0
  50. package/dist/migrations-Ba_kOrSx.mjs.map +1 -0
  51. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  52. package/package.json +1 -45
  53. package/src/DoMemoryStore.ts +56 -0
  54. package/src/DoMessageDeliveryStore.ts +53 -0
  55. package/src/DoScheduleStore.ts +622 -0
  56. package/src/{errors.ts → DoStorageError.ts} +10 -3
  57. package/src/DoStorageFailpoint.ts +20 -0
  58. package/src/{do-storage-failpoint.ts → DoStorageFailpointTesting.ts} +5 -18
  59. package/src/DoStorageVersion.ts +2 -0
  60. package/src/{do-ledger.ts → DoSubmissionLedger.ts} +924 -225
  61. package/src/DoSubscriptionStore.ts +329 -0
  62. package/src/DoThreadStore.ts +1012 -0
  63. package/src/MemoryProtocol.ts +351 -0
  64. package/src/{port-protocol.ts → PortProtocol.ts} +45 -38
  65. package/src/{routing.ts → PortRouting.ts} +292 -264
  66. package/src/index.ts +13 -32
  67. package/src/{do-journal.ts → internal/do-journal.ts} +718 -234
  68. package/src/internal/message-delivery-schema.ts +24 -0
  69. package/src/{migrations.ts → internal/migrations.ts} +51 -35
  70. package/src/internal/recovery-checkpoint-schema.ts +17 -0
  71. package/dist/index.mjs.map +0 -1
  72. package/src/do-conversation-store.ts +0 -850
  73. /package/src/{do-storage-config.ts → DoStorageConfig.ts} +0 -0
@@ -0,0 +1,622 @@
1
+ import { Context, Effect, Layer, Result, Schema } from "effect";
2
+ import {
3
+ ScheduleCapacityError,
4
+ ScheduleDueCursor,
5
+ defaultSchedulingLimits,
6
+ ScheduleChange,
7
+ ScheduleConflict,
8
+ ScheduleFailpoint,
9
+ type ScheduleFailpointError,
10
+ ScheduleKey,
11
+ ScheduleId,
12
+ ScheduleInstant,
13
+ ScheduleNotFound,
14
+ ScheduleOwner,
15
+ type SchedulePage,
16
+ SchedulePageRequest,
17
+ ScheduleRecord,
18
+ ScheduleStorageError,
19
+ ScheduleStore,
20
+ } from "effect-agent/schedule";
21
+ import {
22
+ applyScheduleChange,
23
+ scheduleUsesCapacity,
24
+ scheduleDeadline,
25
+ } from "effect-agent/schedule-transition";
26
+ import { upgradeV2Schedules } from "effect-agent/sql-storage-v2-upgrade";
27
+ import * as SqlClientService from "effect/unstable/sql/SqlClient";
28
+
29
+ const CURRENT_SCHEDULE_STORE_VERSION = 3;
30
+ const MAX_STORED_SCHEDULE_BYTES = 1_900_000;
31
+
32
+ const StoredScheduleJson = Schema.String.check(Schema.isMaxLength(MAX_STORED_SCHEDULE_BYTES));
33
+ const StoredDeadline = Schema.NullOr(ScheduleInstant);
34
+
35
+ class ScheduleRow extends Schema.Class<ScheduleRow>("@effect-agent/storage-cloudflare/ScheduleRow")(
36
+ {
37
+ tenant_id: ScheduleOwner.fields.tenantId,
38
+ owner_id: ScheduleOwner.fields.ownerId,
39
+ schedule_id: ScheduleId,
40
+ deadline_at_millis: StoredDeadline,
41
+ record_json: StoredScheduleJson,
42
+ },
43
+ ) {}
44
+
45
+ const ScheduleDueRow = Schema.Struct({
46
+ tenant_id: ScheduleOwner.fields.tenantId,
47
+ owner_id: ScheduleOwner.fields.ownerId,
48
+ schedule_id: ScheduleId,
49
+ deadline_at_millis: ScheduleInstant,
50
+ });
51
+
52
+ class ScheduleCountRow extends Schema.Class<ScheduleCountRow>(
53
+ "@effect-agent/storage-cloudflare/ScheduleCountRow",
54
+ )({
55
+ schedule_count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
56
+ }) {}
57
+
58
+ class ScheduleDeadlineRow extends Schema.Class<ScheduleDeadlineRow>(
59
+ "@effect-agent/storage-cloudflare/ScheduleDeadlineRow",
60
+ )({
61
+ deadline_at_millis: StoredDeadline,
62
+ }) {}
63
+
64
+ class ScheduleStoreStateRow extends Schema.Class<ScheduleStoreStateRow>(
65
+ "@effect-agent/storage-cloudflare/ScheduleStoreStateRow",
66
+ )({
67
+ storage_version: Schema.Int.check(Schema.isGreaterThan(0)),
68
+ alarm_generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
69
+ }) {}
70
+
71
+ class ScheduleTableNameRow extends Schema.Class<ScheduleTableNameRow>(
72
+ "@effect-agent/storage-cloudflare/ScheduleTableNameRow",
73
+ )({
74
+ name: Schema.String,
75
+ }) {}
76
+
77
+ export interface DoScheduleAlarmReplacement {
78
+ readonly deadlineAtMillis: number | null;
79
+ /** Included in every logical alarm payload so equal-time replacement stays distinguishable. */
80
+ readonly generation: number;
81
+ }
82
+
83
+ export type DoScheduleReplaceAlarm = (
84
+ replacement: DoScheduleAlarmReplacement,
85
+ ) => Effect.Effect<void, ScheduleStorageError>;
86
+
87
+ /**
88
+ * Platform-owned transaction boundary for schedule SQL and logical alarm mutation. The callback
89
+ * and its `replaceAlarm` capability belong to one fiber and must not escape or fork.
90
+ */
91
+ export class DoScheduleTransaction extends Context.Service<
92
+ DoScheduleTransaction,
93
+ {
94
+ readonly run: <A, E, R>(
95
+ body: (replaceAlarm: DoScheduleReplaceAlarm) => Effect.Effect<A, E, R>,
96
+ ) => Effect.Effect<A, E | ScheduleStorageError, R>;
97
+ }
98
+ >()("@effect-agent/storage-cloudflare/DoScheduleTransaction") {}
99
+
100
+ /** Platform driver operations that use the same schedule-state and alarm transaction. */
101
+ export class DoScheduleAlarmControl extends Context.Service<
102
+ DoScheduleAlarmControl,
103
+ {
104
+ /** Establish a future recovery wake before cross-Object admission starts. */
105
+ readonly prearm: (
106
+ deadlineAtMillis: number,
107
+ ) => Effect.Effect<void, ScheduleStorageError | ScheduleFailpointError>;
108
+ /** Replace or cancel the wake from the object's authoritative indexed deadline. */
109
+ readonly reconcile: Effect.Effect<void, ScheduleStorageError | ScheduleFailpointError>;
110
+ }
111
+ >()("@effect-agent/storage-cloudflare/DoScheduleAlarmControl") {}
112
+
113
+ const unavailable = (operation: string): ScheduleStorageError =>
114
+ ScheduleStorageError.make({ operation, reason: "unavailable" });
115
+
116
+ const corrupt = (operation: string): ScheduleStorageError =>
117
+ ScheduleStorageError.make({ operation, reason: "corrupt" });
118
+
119
+ const decodeRows = Effect.fn("DoScheduleStore.decodeRows")(function* <A, I, R>(
120
+ schema: Schema.Codec<A, I, R>,
121
+ rows: ReadonlyArray<unknown>,
122
+ operation: string,
123
+ ): Effect.fn.Return<A, ScheduleStorageError, R> {
124
+ return yield* Schema.decodeUnknownEffect(schema)(rows).pipe(
125
+ Effect.mapError(() => corrupt(operation)),
126
+ );
127
+ });
128
+
129
+ const decodeBoundary = <A, I, R>(
130
+ schema: Schema.Codec<A, I, R>,
131
+ value: unknown,
132
+ operation: string,
133
+ ): Effect.Effect<A, ScheduleStorageError, R> =>
134
+ Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => corrupt(operation)));
135
+
136
+ const decodeRecord = Effect.fn("DoScheduleStore.decodeRecord")(function* (
137
+ row: ScheduleRow,
138
+ ): Effect.fn.Return<ScheduleRecord, ScheduleStorageError> {
139
+ const record = yield* Schema.decodeEffect(Schema.fromJsonString(ScheduleRecord))(
140
+ row.record_json,
141
+ ).pipe(Effect.mapError(() => corrupt("decode schedule")));
142
+
143
+ if (
144
+ record.owner.tenantId !== row.tenant_id ||
145
+ record.owner.ownerId !== row.owner_id ||
146
+ record.scheduleId !== row.schedule_id ||
147
+ scheduleDeadline(record) !== row.deadline_at_millis
148
+ ) {
149
+ return yield* corrupt("decode schedule index");
150
+ }
151
+
152
+ return record;
153
+ });
154
+
155
+ const encodeRecord = Effect.fn("DoScheduleStore.encodeRecord")(function* (
156
+ record: ScheduleRecord,
157
+ ): Effect.fn.Return<string, ScheduleStorageError> {
158
+ const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(ScheduleRecord))(record).pipe(
159
+ Effect.mapError(() => corrupt("encode schedule")),
160
+ );
161
+
162
+ return yield* Schema.decodeEffect(StoredScheduleJson)(encoded).pipe(
163
+ Effect.mapError(() => corrupt("encode schedule bounds")),
164
+ );
165
+ });
166
+
167
+ const initializeScheduleStore = Effect.fn("DoScheduleStore.initialize")(function* () {
168
+ const sql = yield* SqlClientService.SqlClient;
169
+ const operation = "initialize schedule store";
170
+
171
+ const rawTables = yield* sql<Record<string, unknown>>`
172
+ SELECT name
173
+ FROM sqlite_master
174
+ WHERE type = 'table'
175
+ AND name IN (
176
+ 'effect_agent_schedule_store_state',
177
+ 'effect_agent_schedules'
178
+ )
179
+ ORDER BY name
180
+ `.pipe(Effect.mapError(() => unavailable(operation)));
181
+
182
+ const tables = yield* decodeRows(Schema.Array(ScheduleTableNameRow), rawTables, operation);
183
+ const hasState = tables.some((row) => row.name === "effect_agent_schedule_store_state");
184
+ const hasSchedules = tables.some((row) => row.name === "effect_agent_schedules");
185
+
186
+ if (!hasState) {
187
+ if (hasSchedules) return yield* corrupt(operation);
188
+ yield* sql
189
+ .withTransaction(
190
+ Effect.gen(function* () {
191
+ yield* sql`
192
+ CREATE TABLE effect_agent_schedule_store_state (
193
+ singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1),
194
+ storage_version INTEGER NOT NULL,
195
+ alarm_generation INTEGER NOT NULL
196
+ )
197
+ `.withoutTransform;
198
+ yield* sql`
199
+ CREATE TABLE effect_agent_schedules (
200
+ tenant_id TEXT NOT NULL,
201
+ owner_id TEXT NOT NULL,
202
+ schedule_id TEXT NOT NULL,
203
+ deadline_at_millis INTEGER,
204
+ record_json TEXT NOT NULL,
205
+ PRIMARY KEY (tenant_id, owner_id, schedule_id)
206
+ )
207
+ `.withoutTransform;
208
+ yield* sql`
209
+ CREATE INDEX effect_agent_schedules_deadline
210
+ ON effect_agent_schedules (deadline_at_millis, tenant_id, owner_id, schedule_id)
211
+ WHERE deadline_at_millis IS NOT NULL
212
+ `.withoutTransform;
213
+ yield* sql`
214
+ CREATE INDEX effect_agent_schedules_owner_deadline
215
+ ON effect_agent_schedules (tenant_id, owner_id, deadline_at_millis, schedule_id)
216
+ WHERE deadline_at_millis IS NOT NULL
217
+ `.withoutTransform;
218
+ yield* sql`
219
+ INSERT INTO effect_agent_schedule_store_state (
220
+ singleton, storage_version, alarm_generation
221
+ ) VALUES (1, ${CURRENT_SCHEDULE_STORE_VERSION}, 0)
222
+ `.withoutTransform;
223
+ }),
224
+ )
225
+ .pipe(Effect.mapError(() => unavailable(operation)));
226
+
227
+ return;
228
+ }
229
+
230
+ if (!hasSchedules) return yield* corrupt(operation);
231
+
232
+ const rawState = yield* sql<Record<string, unknown>>`
233
+ SELECT storage_version, alarm_generation
234
+ FROM effect_agent_schedule_store_state
235
+ WHERE singleton = 1
236
+ `.pipe(Effect.mapError(() => unavailable(operation)));
237
+
238
+ const state = yield* decodeRows(Schema.Array(ScheduleStoreStateRow), rawState, operation);
239
+
240
+ if (state.length === 1 && state[0].storage_version === 2) {
241
+ const failpoint = yield* ScheduleFailpoint;
242
+
243
+ yield* sql
244
+ .withTransaction(
245
+ Effect.gen(function* () {
246
+ const current = yield* sql<{
247
+ storage_version: number;
248
+ }>`SELECT storage_version FROM effect_agent_schedule_store_state WHERE singleton=1`;
249
+
250
+ if (current.length === 1 && current[0].storage_version === 3) return;
251
+ if (current.length !== 1 || current[0].storage_version !== 2)
252
+ return yield* corrupt("schedule version changed during upgrade");
253
+ yield* upgradeV2Schedules(MAX_STORED_SCHEDULE_BYTES);
254
+ yield* failpoint.hit("upgrade:before-version");
255
+ yield* sql`UPDATE effect_agent_schedule_store_state SET storage_version=3 WHERE singleton=1`;
256
+ yield* failpoint.hit("upgrade:after-version");
257
+ }),
258
+ )
259
+ .pipe(
260
+ Effect.catchTag("StorageUpgradeError", (error) =>
261
+ ScheduleStorageError.make({
262
+ reason: "corrupt",
263
+ operation: "upgrade v2 schedules",
264
+ cause: error,
265
+ }),
266
+ ),
267
+ Effect.catchTag("SqlError", () => unavailable("upgrade v2 schedules")),
268
+ Effect.catchTag("ScheduleFailpointError", () =>
269
+ unavailable("upgrade v2 schedules failpoint"),
270
+ ),
271
+ );
272
+
273
+ return;
274
+ }
275
+
276
+ if (state.length !== 1 || state[0].storage_version !== CURRENT_SCHEDULE_STORE_VERSION) {
277
+ return yield* corrupt(
278
+ state.length === 1
279
+ ? `${operation}: incompatible storage version ${state[0].storage_version}; expected ${CURRENT_SCHEDULE_STORE_VERSION}`
280
+ : `${operation}: invalid storage version row`,
281
+ );
282
+ }
283
+ });
284
+
285
+ const makeServices = Effect.gen(function* () {
286
+ const sql = yield* SqlClientService.SqlClient;
287
+ const transactions = yield* DoScheduleTransaction;
288
+ const scheduleFailpoint = yield* ScheduleFailpoint;
289
+
290
+ yield* initializeScheduleStore();
291
+
292
+ const readRows = Effect.fn("DoScheduleStore.readRows")(function* (
293
+ key: ScheduleKey,
294
+ operation: string,
295
+ ): Effect.fn.Return<ReadonlyArray<ScheduleRow>, ScheduleStorageError> {
296
+ const rows = yield* sql<Record<string, unknown>>`
297
+ SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
298
+ FROM effect_agent_schedules
299
+ WHERE tenant_id = ${key.owner.tenantId}
300
+ AND owner_id = ${key.owner.ownerId}
301
+ AND schedule_id = ${key.scheduleId}
302
+ `.pipe(Effect.mapError(() => unavailable(operation)));
303
+
304
+ return yield* decodeRows(Schema.Array(ScheduleRow), rows, operation);
305
+ });
306
+
307
+ const readOne = Effect.fn("DoScheduleStore.readOne")(function* (
308
+ key: ScheduleKey,
309
+ operation: string,
310
+ ): Effect.fn.Return<ScheduleRecord | null, ScheduleStorageError> {
311
+ const rows = yield* readRows(key, operation);
312
+
313
+ if (rows.length === 0) return null;
314
+ if (rows.length !== 1) return yield* corrupt(operation);
315
+
316
+ return yield* decodeRecord(rows[0]);
317
+ });
318
+
319
+ const readNextDeadline = Effect.fn("DoScheduleStore.readNextDeadline")(function* (
320
+ owner: ScheduleOwner | undefined,
321
+ operation: string,
322
+ ): Effect.fn.Return<number | null, ScheduleStorageError> {
323
+ const rows =
324
+ owner === undefined
325
+ ? yield* sql<Record<string, unknown>>`
326
+ SELECT MIN(deadline_at_millis) AS deadline_at_millis
327
+ FROM effect_agent_schedules
328
+ WHERE deadline_at_millis IS NOT NULL
329
+ `.pipe(Effect.mapError(() => unavailable(operation)))
330
+ : yield* sql<Record<string, unknown>>`
331
+ SELECT MIN(deadline_at_millis) AS deadline_at_millis
332
+ FROM effect_agent_schedules
333
+ WHERE tenant_id = ${owner.tenantId}
334
+ AND owner_id = ${owner.ownerId}
335
+ AND deadline_at_millis IS NOT NULL
336
+ `.pipe(Effect.mapError(() => unavailable(operation)));
337
+
338
+ const decoded = yield* decodeRows(Schema.Array(ScheduleDeadlineRow), rows, operation);
339
+
340
+ if (decoded.length !== 1) return yield* corrupt(operation);
341
+
342
+ return decoded[0].deadline_at_millis;
343
+ });
344
+
345
+ const replaceAlarm = Effect.fn("DoScheduleStore.replaceAlarm")(function* (
346
+ replace: DoScheduleReplaceAlarm,
347
+ deadlineAtMillis: number | null,
348
+ operation: string,
349
+ ) {
350
+ const rawState = yield* sql<Record<string, unknown>>`
351
+ UPDATE effect_agent_schedule_store_state
352
+ SET alarm_generation = alarm_generation + 1
353
+ WHERE singleton = 1
354
+ RETURNING storage_version, alarm_generation
355
+ `.pipe(Effect.mapError(() => unavailable(operation)));
356
+
357
+ const state = yield* decodeRows(Schema.Array(ScheduleStoreStateRow), rawState, operation);
358
+
359
+ if (state.length !== 1 || state[0].storage_version !== CURRENT_SCHEDULE_STORE_VERSION) {
360
+ return yield* corrupt(operation);
361
+ }
362
+ yield* scheduleFailpoint.hit("schedule:alarm:before");
363
+ yield* replace({ deadlineAtMillis, generation: state[0].alarm_generation });
364
+ yield* scheduleFailpoint.hit("schedule:alarm:after");
365
+ });
366
+
367
+ const insert: ScheduleStore["Service"]["insert"] = Effect.fn("DoScheduleStore.insert")(
368
+ function* (record, ownerLimit) {
369
+ const operation = "insert schedule";
370
+ const canonical = yield* decodeBoundary(ScheduleRecord, record, operation);
371
+ const recordJson = yield* encodeRecord(canonical);
372
+
373
+ const result = yield* transactions.run((replace) =>
374
+ Effect.gen(function* () {
375
+ const existing = yield* readOne(canonical, operation);
376
+
377
+ if (existing !== null) {
378
+ if (existing.creationFingerprint === canonical.creationFingerprint) {
379
+ return { record: existing, inserted: false } as const;
380
+ }
381
+
382
+ return yield* ScheduleConflict.make({ reason: "creation", key: canonical });
383
+ }
384
+
385
+ const rawCounts = yield* sql<Record<string, unknown>>`
386
+ SELECT COUNT(*) AS schedule_count
387
+ FROM effect_agent_schedules
388
+ WHERE tenant_id = ${canonical.owner.tenantId}
389
+ AND owner_id = ${canonical.owner.ownerId}
390
+ AND (json_extract(record_json, '$.pending') IS NOT NULL OR
391
+ (json_extract(record_json, '$.state') != 'cancelled' AND json_extract(record_json, '$.nextAtMillis') IS NOT NULL))
392
+ `.pipe(Effect.mapError(() => unavailable(operation)));
393
+
394
+ const counts = yield* decodeRows(Schema.Array(ScheduleCountRow), rawCounts, operation);
395
+
396
+ if (counts.length !== 1) return yield* corrupt(operation);
397
+ if (counts[0].schedule_count >= ownerLimit) {
398
+ return yield* ScheduleCapacityError.make({ limit: ownerLimit });
399
+ }
400
+ yield* scheduleFailpoint.hit("schedule:insert:before");
401
+ yield* sql`
402
+ INSERT INTO effect_agent_schedules (
403
+ tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
404
+ ) VALUES (
405
+ ${canonical.owner.tenantId},
406
+ ${canonical.owner.ownerId},
407
+ ${canonical.scheduleId},
408
+ ${scheduleDeadline(canonical)},
409
+ ${recordJson}
410
+ )
411
+ `.pipe(Effect.mapError(() => unavailable(operation)));
412
+ const deadline = yield* readNextDeadline(undefined, operation);
413
+
414
+ yield* replaceAlarm(replace, deadline, operation);
415
+
416
+ return { record: canonical, inserted: true } as const;
417
+ }),
418
+ );
419
+
420
+ if (result.inserted) yield* scheduleFailpoint.hit("schedule:insert:after");
421
+
422
+ return result.record;
423
+ },
424
+ );
425
+
426
+ const get: ScheduleStore["Service"]["get"] = Effect.fn("DoScheduleStore.get")(function* (key) {
427
+ const canonical = yield* decodeBoundary(ScheduleKey, key, "get schedule");
428
+
429
+ return yield* readOne(canonical, "get schedule");
430
+ });
431
+
432
+ const list: ScheduleStore["Service"]["list"] = Effect.fn("DoScheduleStore.list")(function* (
433
+ requestValue: SchedulePageRequest,
434
+ ): Effect.fn.Return<SchedulePage, ScheduleStorageError> {
435
+ const operation = "list schedules";
436
+ const request = yield* decodeBoundary(SchedulePageRequest, requestValue, operation);
437
+
438
+ const rows =
439
+ request.after === undefined
440
+ ? yield* sql<Record<string, unknown>>`
441
+ SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
442
+ FROM effect_agent_schedules
443
+ WHERE tenant_id = ${request.owner.tenantId}
444
+ AND owner_id = ${request.owner.ownerId}
445
+ ORDER BY schedule_id
446
+ LIMIT ${request.limit + 1}
447
+ `.pipe(Effect.mapError(() => unavailable(operation)))
448
+ : yield* sql<Record<string, unknown>>`
449
+ SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
450
+ FROM effect_agent_schedules
451
+ WHERE tenant_id = ${request.owner.tenantId}
452
+ AND owner_id = ${request.owner.ownerId}
453
+ AND schedule_id > ${request.after}
454
+ ORDER BY schedule_id
455
+ LIMIT ${request.limit + 1}
456
+ `.pipe(Effect.mapError(() => unavailable(operation)));
457
+
458
+ const decoded = yield* decodeRows(Schema.Array(ScheduleRow), rows, operation);
459
+ const records = yield* Effect.forEach(decoded, decodeRecord);
460
+ const hasNext = records.length > request.limit;
461
+ const items = hasNext ? records.slice(0, request.limit) : records;
462
+
463
+ return { items, next: hasNext ? (items.at(-1)?.scheduleId ?? null) : null };
464
+ });
465
+
466
+ const change: ScheduleStore["Service"]["change"] = Effect.fn("DoScheduleStore.change")(function* (
467
+ key,
468
+ change,
469
+ ownerLimit = defaultSchedulingLimits.maxSchedulesPerOwner,
470
+ ) {
471
+ const operation = "change schedule";
472
+ const canonicalKey = yield* decodeBoundary(ScheduleKey, key, operation);
473
+ const canonicalChange = yield* decodeBoundary(ScheduleChange, change, operation);
474
+
475
+ const result = yield* transactions.run((replace) =>
476
+ Effect.gen(function* () {
477
+ const current = yield* readOne(canonicalKey, operation);
478
+
479
+ if (current === null) return yield* ScheduleNotFound.make({ key: canonicalKey });
480
+ const transition = applyScheduleChange(current, canonicalChange);
481
+
482
+ if (Result.isFailure(transition)) return yield* transition.failure;
483
+ const next = transition.success;
484
+
485
+ if (!scheduleUsesCapacity(current) && scheduleUsesCapacity(next)) {
486
+ const rawCounts = yield* sql<Record<string, unknown>>`
487
+ SELECT COUNT(*) AS schedule_count FROM effect_agent_schedules
488
+ WHERE tenant_id = ${key.owner.tenantId} AND owner_id = ${key.owner.ownerId}
489
+ AND (json_extract(record_json, '$.pending') IS NOT NULL OR
490
+ (json_extract(record_json, '$.state') != 'cancelled' AND json_extract(record_json, '$.nextAtMillis') IS NOT NULL))
491
+ `.pipe(Effect.mapError(() => unavailable(operation)));
492
+
493
+ const counts = yield* decodeRows(Schema.Array(ScheduleCountRow), rawCounts, operation);
494
+
495
+ if (counts.length !== 1) return yield* corrupt(operation);
496
+ if (counts[0].schedule_count >= ownerLimit)
497
+ return yield* ScheduleCapacityError.make({ limit: ownerLimit });
498
+ }
499
+ if (next === current) return { record: current, changed: false } as const;
500
+ const recordJson = yield* encodeRecord(next);
501
+
502
+ yield* scheduleFailpoint.hit(`schedule:${canonicalChange._tag.toLowerCase()}:before`);
503
+ yield* sql`
504
+ UPDATE effect_agent_schedules
505
+ SET deadline_at_millis = ${scheduleDeadline(next)}, record_json = ${recordJson}
506
+ WHERE tenant_id = ${canonicalKey.owner.tenantId}
507
+ AND owner_id = ${canonicalKey.owner.ownerId}
508
+ AND schedule_id = ${canonicalKey.scheduleId}
509
+ `.pipe(Effect.mapError(() => unavailable(operation)));
510
+ const deadline = yield* readNextDeadline(undefined, operation);
511
+
512
+ yield* replaceAlarm(replace, deadline, operation);
513
+
514
+ return { record: next, changed: true } as const;
515
+ }),
516
+ );
517
+
518
+ if (result.changed) {
519
+ yield* scheduleFailpoint.hit(`schedule:${canonicalChange._tag.toLowerCase()}:after`);
520
+ }
521
+
522
+ return result.record;
523
+ });
524
+
525
+ const due: ScheduleStore["Service"]["due"] = Effect.fn("DoScheduleStore.due")(function* (
526
+ nowMillis,
527
+ limit,
528
+ owner?: ScheduleOwner,
529
+ after?: ScheduleDueCursor,
530
+ ) {
531
+ const operation = "query due schedules";
532
+
533
+ const cursor =
534
+ after === undefined
535
+ ? undefined
536
+ : yield* Schema.decodeEffect(ScheduleDueCursor)(after).pipe(
537
+ Effect.mapError(() => corrupt(operation)),
538
+ );
539
+
540
+ const continuation =
541
+ cursor === undefined
542
+ ? sql`1 = 1`
543
+ : sql`
544
+ (deadline_at_millis, tenant_id, owner_id, schedule_id) >
545
+ (${cursor.deadlineAtMillis}, ${cursor.owner.tenantId}, ${cursor.owner.ownerId}, ${cursor.scheduleId})`;
546
+
547
+ const rows =
548
+ owner === undefined
549
+ ? yield* sql<Record<string, unknown>>`
550
+ SELECT tenant_id, owner_id, schedule_id, deadline_at_millis
551
+ FROM effect_agent_schedules
552
+ WHERE deadline_at_millis <= ${nowMillis} AND ${continuation}
553
+ ORDER BY deadline_at_millis, tenant_id, owner_id, schedule_id
554
+ LIMIT ${limit}
555
+ `.pipe(Effect.mapError(() => unavailable(operation)))
556
+ : yield* sql<Record<string, unknown>>`
557
+ SELECT tenant_id, owner_id, schedule_id, deadline_at_millis
558
+ FROM effect_agent_schedules
559
+ WHERE tenant_id = ${owner.tenantId}
560
+ AND owner_id = ${owner.ownerId}
561
+ AND deadline_at_millis <= ${nowMillis} AND ${continuation}
562
+ ORDER BY deadline_at_millis, schedule_id
563
+ LIMIT ${limit}
564
+ `.pipe(Effect.mapError(() => unavailable(operation)));
565
+
566
+ const decoded = yield* decodeRows(Schema.Array(ScheduleDueRow), rows, operation);
567
+
568
+ return decoded.map((row) => ({
569
+ owner: { tenantId: row.tenant_id, ownerId: row.owner_id },
570
+ scheduleId: row.schedule_id,
571
+ deadlineAtMillis: row.deadline_at_millis,
572
+ }));
573
+ });
574
+
575
+ const nextDeadline: ScheduleStore["Service"]["nextDeadline"] = Effect.fn(
576
+ "DoScheduleStore.nextDeadline",
577
+ )(function* (owner?: ScheduleOwner) {
578
+ const operation = "query next schedule deadline";
579
+
580
+ return yield* readNextDeadline(owner, operation);
581
+ });
582
+
583
+ const prearm = Effect.fn("DoScheduleStore.prearm")(function* (
584
+ deadlineAtMillis: number,
585
+ ): Effect.fn.Return<void, ScheduleStorageError | ScheduleFailpointError> {
586
+ yield* decodeBoundary(ScheduleInstant, deadlineAtMillis, "pre-arm schedule recovery");
587
+ yield* transactions.run((replace) =>
588
+ replaceAlarm(replace, deadlineAtMillis, "pre-arm schedule recovery"),
589
+ );
590
+ yield* scheduleFailpoint.hit("schedule:prearm:after");
591
+ });
592
+
593
+ const reconcile = Effect.gen(function* () {
594
+ yield* transactions.run((replace) =>
595
+ Effect.gen(function* () {
596
+ const deadline = yield* readNextDeadline(undefined, "reconcile schedule alarm");
597
+
598
+ yield* replaceAlarm(replace, deadline, "reconcile schedule alarm");
599
+ }),
600
+ );
601
+ yield* scheduleFailpoint.hit("schedule:reconcile:after");
602
+ });
603
+
604
+ return Context.make(ScheduleStore, {
605
+ insert,
606
+ get,
607
+ list,
608
+ change,
609
+ due,
610
+ nextDeadline,
611
+ }).pipe(Context.add(DoScheduleAlarmControl, { prearm, reconcile }));
612
+ });
613
+
614
+ /**
615
+ * Durable Object SQLite ScheduleStore. Platform code supplies the one transaction owner that
616
+ * combines these SQL mutations with its logical and native alarm lifecycle.
617
+ */
618
+ export const scheduleStoreLayer: Layer.Layer<
619
+ ScheduleStore | DoScheduleAlarmControl,
620
+ ScheduleStorageError,
621
+ SqlClientService.SqlClient | DoScheduleTransaction
622
+ > = Layer.effectContext(makeServices);
@@ -1,5 +1,5 @@
1
- import { CanonicalSequence, ProducerEpoch } from "@effect-agent/session";
2
1
  import { Schema } from "effect";
2
+ import { CanonicalSequence, ProducerEpoch } from "effect-agent/records";
3
3
 
4
4
  /** The Durable Object's SQLite storage uses a private-development format this adapter cannot read. */
5
5
  export class DoStorageCompatibilityError extends Schema.TaggedError<DoStorageCompatibilityError>()(
@@ -75,7 +75,7 @@ export class DoAppendConflict extends Schema.TaggedError<DoAppendConflict>()("Do
75
75
  }) {}
76
76
 
77
77
  /**
78
- * A producer epoch does not match the Conversation's current writer registration. Appends
78
+ * A producer epoch does not match the Thread's current writer registration. Appends
79
79
  * require the exact registered epoch, so both older and newer unregistered epochs are fenced;
80
80
  * a newer epoch takes over by materializing first.
81
81
  */
@@ -102,6 +102,10 @@ export class DoCheckpointConflict extends Schema.TaggedError<DoCheckpointConflic
102
102
  * locations. There is intentionally no Cloudflare-only location.
103
103
  */
104
104
  export const DoStorageFailpointLocation = Schema.Literals([
105
+ "upgrade:before-mutation",
106
+ "upgrade:after-mutation",
107
+ "upgrade:before-version",
108
+ "upgrade:after-version",
105
109
  "materialize:before",
106
110
  "materialize:after",
107
111
  "append:before",
@@ -109,9 +113,11 @@ export const DoStorageFailpointLocation = Schema.Literals([
109
113
  "append:after-record-insert",
110
114
  "append:after-tail-update",
111
115
  "append:after",
112
- "export:after-conversation-read",
116
+ "export:after-thread-read",
113
117
  "save-checkpoint:before",
114
118
  "save-checkpoint:after",
119
+ "save-recovery-checkpoint:before",
120
+ "save-recovery-checkpoint:after",
115
121
  "ledger:admit:before",
116
122
  "ledger:admit:after",
117
123
  "ledger:mark-ready:before",
@@ -155,6 +161,7 @@ export const DoStorageFailpointLocation = Schema.Literals([
155
161
  "ledger:child-settled:before",
156
162
  "ledger:child-settled:after",
157
163
  ]);
164
+
158
165
  export type DoStorageFailpointLocation = typeof DoStorageFailpointLocation.Type;
159
166
 
160
167
  /** Deterministic test-only fault or pause injected at a Durable Object storage boundary. */
@@ -0,0 +1,20 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ import type { DoStorageFailpointError, DoStorageFailpointLocation } from "./DoStorageError.ts";
4
+
5
+ export type DoStorageFailpointHandler = (
6
+ location: DoStorageFailpointLocation,
7
+ ) => Effect.Effect<void, DoStorageFailpointError>;
8
+
9
+ const noFailpoint: DoStorageFailpointHandler = () => Effect.void;
10
+
11
+ /** Explicit fault-injection authority used at Durable Object storage operation boundaries. */
12
+ export class DoStorageFailpoint extends Context.Service<
13
+ DoStorageFailpoint,
14
+ {
15
+ readonly hit: DoStorageFailpointHandler;
16
+ }
17
+ >()("@effect-agent/storage-cloudflare/DoStorageFailpoint") {
18
+ /** Production default: no fault injection. */
19
+ static readonly layer = Layer.succeed(this)({ hit: noFailpoint });
20
+ }