@effect-agent/storage-sqlite 0.1.0-beta.8 → 0.1.0-beta.81
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-D9xFxCw-.mjs +998 -0
- package/dist/sqlite-journal-D9xFxCw-.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} +857 -157
- package/src/SqliteSubscriptionStore.ts +59 -0
- package/src/{sqlite-conversation-store.ts → SqliteThreadStore.ts} +448 -288
- 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} +752 -239
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ScheduleCapacityError,
|
|
3
|
+
ScheduleDueCursor,
|
|
4
|
+
defaultSchedulingLimits,
|
|
5
|
+
ScheduleChange,
|
|
6
|
+
ScheduleConflict,
|
|
7
|
+
ScheduleFailpoint,
|
|
8
|
+
ScheduleKey,
|
|
9
|
+
ScheduleId,
|
|
10
|
+
ScheduleInstant,
|
|
11
|
+
ScheduleNotFound,
|
|
12
|
+
ScheduleOwner,
|
|
13
|
+
type SchedulePage,
|
|
14
|
+
SchedulePageRequest,
|
|
15
|
+
ScheduleRecord,
|
|
16
|
+
ScheduleStorageError,
|
|
17
|
+
ScheduleStore,
|
|
18
|
+
} from "@effect-agent/thread/Schedule";
|
|
19
|
+
import {
|
|
20
|
+
scheduleUsesCapacity,
|
|
21
|
+
applyScheduleChange,
|
|
22
|
+
scheduleDeadline,
|
|
23
|
+
} from "@effect-agent/thread/ScheduleTransition";
|
|
24
|
+
import { Effect, Layer, Result, Schema } from "effect";
|
|
25
|
+
import * as SqlClientService from "effect/unstable/sql/SqlClient";
|
|
26
|
+
|
|
27
|
+
import { initializeSqliteJournal } from "./internal/sqlite-journal.ts";
|
|
28
|
+
import type { SqliteStorageConfig } from "./SqliteStorageConfig.ts";
|
|
29
|
+
import type { SqliteStorageFailpoint } from "./SqliteStorageFailpoint.ts";
|
|
30
|
+
import type { SqliteStorageInitializationError } from "./SqliteThreadStore.ts";
|
|
31
|
+
|
|
32
|
+
// Configuration and the immutable pending envelope may each carry the canonical input. Leave
|
|
33
|
+
// room for JSON escaping and bounded status while rejecting an unreadable oversized row.
|
|
34
|
+
const StoredScheduleJson = Schema.String.check(Schema.isMaxLength(16 * 1024 * 1024));
|
|
35
|
+
const StoredDeadline = Schema.NullOr(ScheduleInstant);
|
|
36
|
+
|
|
37
|
+
class ScheduleRow extends Schema.Class<ScheduleRow>("@effect-agent/storage-sqlite/ScheduleRow")({
|
|
38
|
+
tenant_id: ScheduleOwner.fields.tenantId,
|
|
39
|
+
owner_id: ScheduleOwner.fields.ownerId,
|
|
40
|
+
schedule_id: ScheduleId,
|
|
41
|
+
deadline_at_millis: StoredDeadline,
|
|
42
|
+
record_json: StoredScheduleJson,
|
|
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-sqlite/ScheduleCountRow",
|
|
54
|
+
)({
|
|
55
|
+
schedule_count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
56
|
+
}) {}
|
|
57
|
+
|
|
58
|
+
class ScheduleDeadlineRow extends Schema.Class<ScheduleDeadlineRow>(
|
|
59
|
+
"@effect-agent/storage-sqlite/ScheduleDeadlineRow",
|
|
60
|
+
)({
|
|
61
|
+
deadline_at_millis: StoredDeadline,
|
|
62
|
+
}) {}
|
|
63
|
+
|
|
64
|
+
const unavailable = (operation: string): ScheduleStorageError =>
|
|
65
|
+
ScheduleStorageError.make({ operation, reason: "unavailable" });
|
|
66
|
+
|
|
67
|
+
const corrupt = (operation: string): ScheduleStorageError =>
|
|
68
|
+
ScheduleStorageError.make({ operation, reason: "corrupt" });
|
|
69
|
+
|
|
70
|
+
const decodeRows = Effect.fn("SqliteScheduleStore.decodeRows")(function* <A, I>(
|
|
71
|
+
schema: Schema.Codec<A, I, never>,
|
|
72
|
+
rows: ReadonlyArray<unknown>,
|
|
73
|
+
operation: string,
|
|
74
|
+
): Effect.fn.Return<A, ScheduleStorageError> {
|
|
75
|
+
return yield* Schema.decodeUnknownEffect(schema)(rows).pipe(
|
|
76
|
+
Effect.mapError(() => corrupt(operation)),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const decodeRecord = Effect.fn("SqliteScheduleStore.decodeRecord")(function* (
|
|
81
|
+
row: ScheduleRow,
|
|
82
|
+
): Effect.fn.Return<ScheduleRecord, ScheduleStorageError> {
|
|
83
|
+
const record = yield* Schema.decodeEffect(Schema.fromJsonString(ScheduleRecord))(
|
|
84
|
+
row.record_json,
|
|
85
|
+
).pipe(Effect.mapError(() => corrupt("decode schedule")));
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
record.owner.tenantId !== row.tenant_id ||
|
|
89
|
+
record.owner.ownerId !== row.owner_id ||
|
|
90
|
+
record.scheduleId !== row.schedule_id ||
|
|
91
|
+
scheduleDeadline(record) !== row.deadline_at_millis
|
|
92
|
+
) {
|
|
93
|
+
return yield* corrupt("decode schedule identity");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return record;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const encodeRecord = Effect.fn("SqliteScheduleStore.encodeRecord")(function* (
|
|
100
|
+
record: ScheduleRecord,
|
|
101
|
+
): Effect.fn.Return<string, ScheduleStorageError> {
|
|
102
|
+
return yield* Schema.encodeEffect(Schema.fromJsonString(ScheduleRecord))(record).pipe(
|
|
103
|
+
Effect.mapError(() => corrupt("encode schedule")),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const decodeInput = Effect.fn("SqliteScheduleStore.decodeInput")(function* <A, I>(
|
|
108
|
+
operation: string,
|
|
109
|
+
schema: Schema.Codec<A, I, never>,
|
|
110
|
+
value: unknown,
|
|
111
|
+
): Effect.fn.Return<A, ScheduleStorageError> {
|
|
112
|
+
return yield* Schema.decodeUnknownEffect(schema)(value).pipe(
|
|
113
|
+
Effect.mapError(() => corrupt(operation)),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const makeScheduleStore = Effect.gen(function* () {
|
|
118
|
+
const sql = yield* SqlClientService.SqlClient;
|
|
119
|
+
const scheduleFailpoint = yield* ScheduleFailpoint;
|
|
120
|
+
|
|
121
|
+
yield* initializeSqliteJournal();
|
|
122
|
+
|
|
123
|
+
const readRows = Effect.fn("SqliteScheduleStore.readRows")(function* (
|
|
124
|
+
key: ScheduleKey,
|
|
125
|
+
operation: string,
|
|
126
|
+
): Effect.fn.Return<ReadonlyArray<ScheduleRow>, ScheduleStorageError> {
|
|
127
|
+
const rows = yield* sql<Record<string, unknown>>`
|
|
128
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
129
|
+
FROM effect_agent_schedules
|
|
130
|
+
WHERE tenant_id = ${key.owner.tenantId}
|
|
131
|
+
AND owner_id = ${key.owner.ownerId}
|
|
132
|
+
AND schedule_id = ${key.scheduleId}
|
|
133
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
134
|
+
|
|
135
|
+
return yield* decodeRows(Schema.Array(ScheduleRow), rows, operation);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const readOne = Effect.fn("SqliteScheduleStore.readOne")(function* (
|
|
139
|
+
key: ScheduleKey,
|
|
140
|
+
operation: string,
|
|
141
|
+
): Effect.fn.Return<ScheduleRecord | null, ScheduleStorageError> {
|
|
142
|
+
const rows = yield* readRows(key, operation);
|
|
143
|
+
|
|
144
|
+
if (rows.length === 0) return null;
|
|
145
|
+
if (rows.length !== 1) return yield* corrupt(operation);
|
|
146
|
+
|
|
147
|
+
return yield* decodeRecord(rows[0]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const insert: ScheduleStore["Service"]["insert"] = Effect.fn("SqliteScheduleStore.insert")(
|
|
151
|
+
function* (record, ownerLimit) {
|
|
152
|
+
const operation = "insert schedule";
|
|
153
|
+
const canonical = yield* decodeInput(operation, ScheduleRecord, record);
|
|
154
|
+
const recordJson = yield* encodeRecord(canonical);
|
|
155
|
+
|
|
156
|
+
const result = yield* sql
|
|
157
|
+
.withTransaction(
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const existing = yield* readOne(canonical, operation);
|
|
160
|
+
|
|
161
|
+
if (existing !== null) {
|
|
162
|
+
if (existing.creationFingerprint === canonical.creationFingerprint) {
|
|
163
|
+
return { record: existing, inserted: false } as const;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return yield* ScheduleConflict.make({
|
|
167
|
+
reason: "creation",
|
|
168
|
+
key: { owner: canonical.owner, scheduleId: canonical.scheduleId },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rawCounts = yield* sql<Record<string, unknown>>`
|
|
173
|
+
SELECT COUNT(*) AS schedule_count
|
|
174
|
+
FROM effect_agent_schedules
|
|
175
|
+
WHERE tenant_id = ${canonical.owner.tenantId}
|
|
176
|
+
AND owner_id = ${canonical.owner.ownerId}
|
|
177
|
+
AND (json_extract(record_json, '$.pending') IS NOT NULL OR
|
|
178
|
+
(json_extract(record_json, '$.state') != 'cancelled' AND json_extract(record_json, '$.nextAtMillis') IS NOT NULL))
|
|
179
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
180
|
+
|
|
181
|
+
const counts = yield* decodeRows(Schema.Array(ScheduleCountRow), rawCounts, operation);
|
|
182
|
+
|
|
183
|
+
if (counts.length !== 1) return yield* corrupt(operation);
|
|
184
|
+
if (counts[0].schedule_count >= ownerLimit) {
|
|
185
|
+
return yield* ScheduleCapacityError.make({ limit: ownerLimit });
|
|
186
|
+
}
|
|
187
|
+
yield* scheduleFailpoint.hit("schedule:insert:before");
|
|
188
|
+
yield* sql`
|
|
189
|
+
INSERT INTO effect_agent_schedules (
|
|
190
|
+
tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
191
|
+
) VALUES (
|
|
192
|
+
${canonical.owner.tenantId},
|
|
193
|
+
${canonical.owner.ownerId},
|
|
194
|
+
${canonical.scheduleId},
|
|
195
|
+
${scheduleDeadline(canonical)},
|
|
196
|
+
${recordJson}
|
|
197
|
+
)
|
|
198
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
199
|
+
|
|
200
|
+
return { record: canonical, inserted: true } as const;
|
|
201
|
+
}),
|
|
202
|
+
)
|
|
203
|
+
.pipe(Effect.catchTag("SqlError", () => Effect.fail(unavailable(operation))));
|
|
204
|
+
|
|
205
|
+
if (result.inserted) yield* scheduleFailpoint.hit("schedule:insert:after");
|
|
206
|
+
|
|
207
|
+
return result.record;
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const get: ScheduleStore["Service"]["get"] = Effect.fn("SqliteScheduleStore.get")(
|
|
212
|
+
function* (key) {
|
|
213
|
+
const decodedKey = yield* decodeInput("get schedule", ScheduleKey, key);
|
|
214
|
+
|
|
215
|
+
return yield* readOne(decodedKey, "get schedule");
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const list: ScheduleStore["Service"]["list"] = Effect.fn("SqliteScheduleStore.list")(function* (
|
|
220
|
+
request: SchedulePageRequest,
|
|
221
|
+
): Effect.fn.Return<SchedulePage, ScheduleStorageError> {
|
|
222
|
+
const operation = "list schedules";
|
|
223
|
+
const decodedRequest = yield* decodeInput(operation, SchedulePageRequest, request);
|
|
224
|
+
|
|
225
|
+
const rows =
|
|
226
|
+
decodedRequest.after === undefined
|
|
227
|
+
? yield* sql<Record<string, unknown>>`
|
|
228
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
229
|
+
FROM effect_agent_schedules
|
|
230
|
+
WHERE tenant_id = ${decodedRequest.owner.tenantId}
|
|
231
|
+
AND owner_id = ${decodedRequest.owner.ownerId}
|
|
232
|
+
ORDER BY schedule_id
|
|
233
|
+
LIMIT ${decodedRequest.limit + 1}
|
|
234
|
+
`.pipe(Effect.mapError(() => unavailable(operation)))
|
|
235
|
+
: yield* sql<Record<string, unknown>>`
|
|
236
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
237
|
+
FROM effect_agent_schedules
|
|
238
|
+
WHERE tenant_id = ${decodedRequest.owner.tenantId}
|
|
239
|
+
AND owner_id = ${decodedRequest.owner.ownerId}
|
|
240
|
+
AND schedule_id > ${decodedRequest.after}
|
|
241
|
+
ORDER BY schedule_id
|
|
242
|
+
LIMIT ${decodedRequest.limit + 1}
|
|
243
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
244
|
+
|
|
245
|
+
const decoded = yield* decodeRows(Schema.Array(ScheduleRow), rows, operation);
|
|
246
|
+
const records = yield* Effect.forEach(decoded, decodeRecord);
|
|
247
|
+
const hasNext = records.length > decodedRequest.limit;
|
|
248
|
+
const items = hasNext ? records.slice(0, decodedRequest.limit) : records;
|
|
249
|
+
|
|
250
|
+
return { items, next: hasNext ? (items.at(-1)?.scheduleId ?? null) : null };
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const change: ScheduleStore["Service"]["change"] = Effect.fn("SqliteScheduleStore.change")(
|
|
254
|
+
function* (key, change, ownerLimit = defaultSchedulingLimits.maxSchedulesPerOwner) {
|
|
255
|
+
const operation = "change schedule";
|
|
256
|
+
const decodedKey = yield* decodeInput(operation, ScheduleKey, key);
|
|
257
|
+
const decodedChange = yield* decodeInput(operation, ScheduleChange, change);
|
|
258
|
+
|
|
259
|
+
const result = yield* sql
|
|
260
|
+
.withTransaction(
|
|
261
|
+
Effect.gen(function* () {
|
|
262
|
+
const current = yield* readOne(decodedKey, operation);
|
|
263
|
+
|
|
264
|
+
if (current === null) return yield* ScheduleNotFound.make({ key: decodedKey });
|
|
265
|
+
const transition = applyScheduleChange(current, decodedChange);
|
|
266
|
+
|
|
267
|
+
if (Result.isFailure(transition)) return yield* transition.failure;
|
|
268
|
+
const next = transition.success;
|
|
269
|
+
|
|
270
|
+
if (!scheduleUsesCapacity(current) && scheduleUsesCapacity(next)) {
|
|
271
|
+
const rawCounts = yield* sql<Record<string, unknown>>`
|
|
272
|
+
SELECT COUNT(*) AS schedule_count FROM effect_agent_schedules
|
|
273
|
+
WHERE tenant_id = ${key.owner.tenantId} AND owner_id = ${key.owner.ownerId}
|
|
274
|
+
AND (json_extract(record_json, '$.pending') IS NOT NULL OR
|
|
275
|
+
(json_extract(record_json, '$.state') != 'cancelled' AND json_extract(record_json, '$.nextAtMillis') IS NOT NULL))
|
|
276
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
277
|
+
|
|
278
|
+
const counts = yield* decodeRows(
|
|
279
|
+
Schema.Array(ScheduleCountRow),
|
|
280
|
+
rawCounts,
|
|
281
|
+
operation,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
if (counts.length !== 1) return yield* corrupt(operation);
|
|
285
|
+
if (counts[0].schedule_count >= ownerLimit)
|
|
286
|
+
return yield* ScheduleCapacityError.make({ limit: ownerLimit });
|
|
287
|
+
}
|
|
288
|
+
if (next === current) return { record: current, changed: false } as const;
|
|
289
|
+
const recordJson = yield* encodeRecord(next);
|
|
290
|
+
|
|
291
|
+
yield* scheduleFailpoint.hit(`schedule:${decodedChange._tag.toLowerCase()}:before`);
|
|
292
|
+
yield* sql`
|
|
293
|
+
UPDATE effect_agent_schedules
|
|
294
|
+
SET deadline_at_millis = ${scheduleDeadline(next)}, record_json = ${recordJson}
|
|
295
|
+
WHERE tenant_id = ${decodedKey.owner.tenantId}
|
|
296
|
+
AND owner_id = ${decodedKey.owner.ownerId}
|
|
297
|
+
AND schedule_id = ${decodedKey.scheduleId}
|
|
298
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
299
|
+
|
|
300
|
+
return { record: next, changed: true } as const;
|
|
301
|
+
}),
|
|
302
|
+
)
|
|
303
|
+
.pipe(Effect.catchTag("SqlError", () => Effect.fail(unavailable(operation))));
|
|
304
|
+
|
|
305
|
+
if (result.changed) {
|
|
306
|
+
yield* scheduleFailpoint.hit(`schedule:${decodedChange._tag.toLowerCase()}:after`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return result.record;
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const due: ScheduleStore["Service"]["due"] = Effect.fn("SqliteScheduleStore.due")(function* (
|
|
314
|
+
nowMillis,
|
|
315
|
+
limit,
|
|
316
|
+
owner?: ScheduleOwner,
|
|
317
|
+
after?: ScheduleDueCursor,
|
|
318
|
+
) {
|
|
319
|
+
const operation = "query due schedules";
|
|
320
|
+
|
|
321
|
+
const decodedOwner =
|
|
322
|
+
owner === undefined ? undefined : yield* decodeInput(operation, ScheduleOwner, owner);
|
|
323
|
+
|
|
324
|
+
const cursor =
|
|
325
|
+
after === undefined
|
|
326
|
+
? undefined
|
|
327
|
+
: yield* Schema.decodeUnknownEffect(ScheduleDueCursor)(after).pipe(
|
|
328
|
+
Effect.mapError(() => corrupt(operation)),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const continuation =
|
|
332
|
+
cursor === undefined
|
|
333
|
+
? sql`1 = 1`
|
|
334
|
+
: sql`
|
|
335
|
+
(deadline_at_millis, tenant_id, owner_id, schedule_id) >
|
|
336
|
+
(${cursor.deadlineAtMillis}, ${cursor.owner.tenantId}, ${cursor.owner.ownerId}, ${cursor.scheduleId})`;
|
|
337
|
+
|
|
338
|
+
const rows =
|
|
339
|
+
decodedOwner === undefined
|
|
340
|
+
? yield* sql<Record<string, unknown>>`
|
|
341
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis
|
|
342
|
+
FROM effect_agent_schedules
|
|
343
|
+
WHERE deadline_at_millis <= ${nowMillis} AND ${continuation}
|
|
344
|
+
ORDER BY deadline_at_millis, tenant_id, owner_id, schedule_id
|
|
345
|
+
LIMIT ${limit}
|
|
346
|
+
`.pipe(Effect.mapError(() => unavailable(operation)))
|
|
347
|
+
: yield* sql<Record<string, unknown>>`
|
|
348
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis
|
|
349
|
+
FROM effect_agent_schedules
|
|
350
|
+
WHERE tenant_id = ${decodedOwner.tenantId}
|
|
351
|
+
AND owner_id = ${decodedOwner.ownerId}
|
|
352
|
+
AND deadline_at_millis <= ${nowMillis} AND ${continuation}
|
|
353
|
+
ORDER BY deadline_at_millis, schedule_id
|
|
354
|
+
LIMIT ${limit}
|
|
355
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
356
|
+
|
|
357
|
+
const decoded = yield* decodeRows(Schema.Array(ScheduleDueRow), rows, operation);
|
|
358
|
+
|
|
359
|
+
return decoded.map((row) => ({
|
|
360
|
+
owner: { tenantId: row.tenant_id, ownerId: row.owner_id },
|
|
361
|
+
scheduleId: row.schedule_id,
|
|
362
|
+
deadlineAtMillis: row.deadline_at_millis,
|
|
363
|
+
}));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const nextDeadline: ScheduleStore["Service"]["nextDeadline"] = Effect.fn(
|
|
367
|
+
"SqliteScheduleStore.nextDeadline",
|
|
368
|
+
)(function* (owner?: ScheduleOwner) {
|
|
369
|
+
const operation = "query next schedule deadline";
|
|
370
|
+
|
|
371
|
+
const decodedOwner =
|
|
372
|
+
owner === undefined ? undefined : yield* decodeInput(operation, ScheduleOwner, owner);
|
|
373
|
+
|
|
374
|
+
const rows =
|
|
375
|
+
decodedOwner === undefined
|
|
376
|
+
? yield* sql<Record<string, unknown>>`
|
|
377
|
+
SELECT MIN(deadline_at_millis) AS deadline_at_millis
|
|
378
|
+
FROM effect_agent_schedules
|
|
379
|
+
WHERE deadline_at_millis IS NOT NULL
|
|
380
|
+
`.pipe(Effect.mapError(() => unavailable(operation)))
|
|
381
|
+
: yield* sql<Record<string, unknown>>`
|
|
382
|
+
SELECT MIN(deadline_at_millis) AS deadline_at_millis
|
|
383
|
+
FROM effect_agent_schedules
|
|
384
|
+
WHERE tenant_id = ${decodedOwner.tenantId}
|
|
385
|
+
AND owner_id = ${decodedOwner.ownerId}
|
|
386
|
+
AND deadline_at_millis IS NOT NULL
|
|
387
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
388
|
+
|
|
389
|
+
const decoded = yield* decodeRows(Schema.Array(ScheduleDeadlineRow), rows, operation);
|
|
390
|
+
|
|
391
|
+
if (decoded.length !== 1) return yield* corrupt(operation);
|
|
392
|
+
|
|
393
|
+
return decoded[0].deadline_at_millis;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return ScheduleStore.of({ insert, get, list, change, due, nextDeadline });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
/** SQLite implementation of the atomic ScheduleStore port. */
|
|
400
|
+
export const scheduleStoreLayer: Layer.Layer<
|
|
401
|
+
ScheduleStore,
|
|
402
|
+
SqliteStorageInitializationError,
|
|
403
|
+
SqliteStorageConfig | SqliteStorageFailpoint | SqlClientService.SqlClient
|
|
404
|
+
> = Layer.effect(ScheduleStore)(makeScheduleStore);
|
|
@@ -19,7 +19,7 @@ export class SqliteStorageConfigValue extends Schema.Class<SqliteStorageConfigVa
|
|
|
19
19
|
* Submission ownership lease duration in milliseconds (D5). The lease is a liveness hint
|
|
20
20
|
* that makes an abandoned claim reclaimable; correctness never depends on it because every
|
|
21
21
|
* canonical append is fenced by producer epoch. Convenience layers default this to
|
|
22
|
-
* `DEFAULT_OWNERSHIP_LEASE_DURATION` from `@effect-agent/
|
|
22
|
+
* `DEFAULT_OWNERSHIP_LEASE_DURATION` from `@effect-agent/thread`.
|
|
23
23
|
*/
|
|
24
24
|
ownershipLeaseDuration: OwnershipLeaseMillis,
|
|
25
25
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CanonicalSequence, ProducerEpoch } from "@effect-agent/
|
|
1
|
+
import { CanonicalSequence, ProducerEpoch } from "@effect-agent/thread/Records";
|
|
2
2
|
import { Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
/** The SQLite file uses a private-development format this adapter cannot read. */
|
|
@@ -60,7 +60,7 @@ export class SqliteAppendConflict extends Schema.TaggedError<SqliteAppendConflic
|
|
|
60
60
|
) {}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* A producer epoch does not match the
|
|
63
|
+
* A producer epoch does not match the Thread's current writer registration. Appends
|
|
64
64
|
* require the exact registered epoch, so both older and newer unregistered epochs are fenced;
|
|
65
65
|
* a newer epoch takes over by materializing first.
|
|
66
66
|
*/
|
|
@@ -96,6 +96,10 @@ export class SqliteCheckpointConflict extends Schema.TaggedError<SqliteCheckpoin
|
|
|
96
96
|
) {}
|
|
97
97
|
|
|
98
98
|
export const SqliteStorageFailpointLocation = Schema.Literals([
|
|
99
|
+
"upgrade:before-mutation",
|
|
100
|
+
"upgrade:after-mutation",
|
|
101
|
+
"upgrade:before-version",
|
|
102
|
+
"upgrade:after-version",
|
|
99
103
|
"materialize:before",
|
|
100
104
|
"materialize:after",
|
|
101
105
|
"append:before",
|
|
@@ -103,9 +107,11 @@ export const SqliteStorageFailpointLocation = Schema.Literals([
|
|
|
103
107
|
"append:after-record-insert",
|
|
104
108
|
"append:after-tail-update",
|
|
105
109
|
"append:after",
|
|
106
|
-
"export:after-
|
|
110
|
+
"export:after-thread-read",
|
|
107
111
|
"save-checkpoint:before",
|
|
108
112
|
"save-checkpoint:after",
|
|
113
|
+
"save-recovery-checkpoint:before",
|
|
114
|
+
"save-recovery-checkpoint:after",
|
|
109
115
|
"ledger:admit:before",
|
|
110
116
|
"ledger:admit:after",
|
|
111
117
|
"ledger:mark-ready:before",
|
|
@@ -149,6 +155,7 @@ export const SqliteStorageFailpointLocation = Schema.Literals([
|
|
|
149
155
|
"ledger:child-settled:before",
|
|
150
156
|
"ledger:child-settled:after",
|
|
151
157
|
]);
|
|
158
|
+
|
|
152
159
|
export type SqliteStorageFailpointLocation = typeof SqliteStorageFailpointLocation.Type;
|
|
153
160
|
|
|
154
161
|
/** Deterministic test-only fault or pause injected at a SQLite operation boundary. */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
SqliteStorageFailpointError,
|
|
5
|
+
SqliteStorageFailpointLocation,
|
|
6
|
+
} from "./SqliteStorageError.ts";
|
|
7
|
+
|
|
8
|
+
export type SqliteStorageFailpointHandler = (
|
|
9
|
+
location: SqliteStorageFailpointLocation,
|
|
10
|
+
) => Effect.Effect<void, SqliteStorageFailpointError>;
|
|
11
|
+
|
|
12
|
+
const noFailpoint: SqliteStorageFailpointHandler = () => Effect.void;
|
|
13
|
+
|
|
14
|
+
/** Explicit fault-injection authority used at SQLite operation boundaries. */
|
|
15
|
+
export class SqliteStorageFailpoint extends Context.Service<
|
|
16
|
+
SqliteStorageFailpoint,
|
|
17
|
+
{
|
|
18
|
+
readonly hit: SqliteStorageFailpointHandler;
|
|
19
|
+
}
|
|
20
|
+
>()("@effect-agent/storage-sqlite/SqliteStorageFailpoint") {
|
|
21
|
+
/** Production default: no fault injection. */
|
|
22
|
+
static readonly layer = Layer.succeed(this)({ hit: noFailpoint });
|
|
23
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Context, Effect, Layer, Ref } from "effect";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
) => Effect.Effect<void, SqliteStorageFailpointError>;
|
|
3
|
+
import {
|
|
4
|
+
SqliteStorageFailpoint,
|
|
5
|
+
type SqliteStorageFailpointHandler,
|
|
6
|
+
} from "./SqliteStorageFailpoint.ts";
|
|
8
7
|
|
|
9
8
|
const noFailpoint: SqliteStorageFailpointHandler = () => Effect.void;
|
|
10
9
|
|
|
@@ -15,22 +14,12 @@ export class SqliteStorageFailpointTestControl extends Context.Service<
|
|
|
15
14
|
readonly clear: Effect.Effect<void>;
|
|
16
15
|
readonly setHandler: (handler: SqliteStorageFailpointHandler) => Effect.Effect<void>;
|
|
17
16
|
}
|
|
18
|
-
>()("@effect-agent/storage-sqlite/SqliteStorageFailpointTestControl") {
|
|
19
|
-
|
|
20
|
-
/** Explicit fault-injection authority used at SQLite operation boundaries. */
|
|
21
|
-
export class SqliteStorageFailpoint extends Context.Service<
|
|
22
|
-
SqliteStorageFailpoint,
|
|
23
|
-
{
|
|
24
|
-
readonly hit: SqliteStorageFailpointHandler;
|
|
25
|
-
}
|
|
26
|
-
>()("@effect-agent/storage-sqlite/SqliteStorageFailpoint") {
|
|
27
|
-
/** Production default: no fault injection. */
|
|
28
|
-
static readonly layer = Layer.succeed(this)({ hit: noFailpoint });
|
|
29
|
-
|
|
17
|
+
>()("@effect-agent/storage-sqlite/SqliteStorageFailpointTestControl") {
|
|
30
18
|
/** Reusable test Layer with a control service backed by the same handler Ref. */
|
|
31
|
-
static readonly
|
|
19
|
+
static readonly layer = Layer.effectContext(
|
|
32
20
|
Effect.gen(function* () {
|
|
33
21
|
const handler = yield* Ref.make<SqliteStorageFailpointHandler>(noFailpoint);
|
|
22
|
+
|
|
34
23
|
return Context.make(
|
|
35
24
|
SqliteStorageFailpoint,
|
|
36
25
|
SqliteStorageFailpoint.of({
|