@effect-agent/storage-sqlite 0.1.0-beta.35 → 0.1.0-beta.37
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/index.d.mts +7 -3
- package/dist/index.mjs +368 -101
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/migrations.ts +31 -1
- package/src/sqlite-conversation-store.ts +2 -3
- package/src/sqlite-journal.ts +13 -27
- package/src/sqlite-ledger.ts +6 -5
- package/src/sqlite-schedule-store.ts +363 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-agent/storage-sqlite",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.37",
|
|
4
4
|
"exports": {
|
|
5
5
|
".": {
|
|
6
6
|
"types": "./dist/index.d.mts",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
}
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@effect-agent/session": "0.1.0-beta.
|
|
11
|
+
"@effect-agent/session": "0.1.0-beta.37",
|
|
12
12
|
"@effect/platform-node": "4.0.0-rc.111",
|
|
13
13
|
"@effect/sql-sqlite-node": "4.0.0-rc.111",
|
|
14
14
|
"effect": "4.0.0-rc.111"
|
package/src/index.ts
CHANGED
|
@@ -2,5 +2,6 @@ export * from "./errors.ts";
|
|
|
2
2
|
export * from "./migrations.ts";
|
|
3
3
|
export * from "./sqlite-conversation-store.ts";
|
|
4
4
|
export * from "./sqlite-ledger.ts";
|
|
5
|
+
export * from "./sqlite-schedule-store.ts";
|
|
5
6
|
export * from "./sqlite-storage-config.ts";
|
|
6
7
|
export * from "./sqlite-storage-failpoint.ts";
|
package/src/migrations.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { SqliteMigrator } from "@effect/sql-sqlite-node";
|
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
4
4
|
|
|
5
|
-
export const CurrentSqliteStorageVersion =
|
|
5
|
+
export const CurrentSqliteStorageVersion = 5;
|
|
6
6
|
|
|
7
7
|
export const sqliteMigrations = SqliteMigrator.fromRecord({
|
|
8
8
|
"1_current_persistent_conversation_foundation": Effect.gen(function* () {
|
|
@@ -272,4 +272,34 @@ export const sqliteMigrations = SqliteMigrator.fromRecord({
|
|
|
272
272
|
|
|
273
273
|
yield* sql`PRAGMA user_version = 4`.withoutTransform;
|
|
274
274
|
}),
|
|
275
|
+
"5_durable_schedules": Effect.gen(function* () {
|
|
276
|
+
const sql = yield* SqlClient.SqlClient;
|
|
277
|
+
|
|
278
|
+
// record_json is authoritative. The remaining columns support owner keyset paging and
|
|
279
|
+
// deadline queries without decoding unrelated future schedules.
|
|
280
|
+
yield* sql`
|
|
281
|
+
CREATE TABLE effect_agent_schedules (
|
|
282
|
+
tenant_id TEXT NOT NULL,
|
|
283
|
+
owner_id TEXT NOT NULL,
|
|
284
|
+
schedule_id TEXT NOT NULL,
|
|
285
|
+
deadline_at_millis INTEGER,
|
|
286
|
+
record_json TEXT NOT NULL,
|
|
287
|
+
PRIMARY KEY (tenant_id, owner_id, schedule_id)
|
|
288
|
+
)
|
|
289
|
+
`.withoutTransform;
|
|
290
|
+
|
|
291
|
+
yield* sql`
|
|
292
|
+
CREATE INDEX effect_agent_schedules_deadline
|
|
293
|
+
ON effect_agent_schedules (deadline_at_millis, tenant_id, owner_id, schedule_id)
|
|
294
|
+
WHERE deadline_at_millis IS NOT NULL
|
|
295
|
+
`.withoutTransform;
|
|
296
|
+
|
|
297
|
+
yield* sql`
|
|
298
|
+
CREATE INDEX effect_agent_schedules_owner_deadline
|
|
299
|
+
ON effect_agent_schedules (tenant_id, owner_id, deadline_at_millis, schedule_id)
|
|
300
|
+
WHERE deadline_at_millis IS NOT NULL
|
|
301
|
+
`.withoutTransform;
|
|
302
|
+
|
|
303
|
+
yield* sql`PRAGMA user_version = 5`.withoutTransform;
|
|
304
|
+
}),
|
|
275
305
|
});
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
Schema,
|
|
42
42
|
Stream,
|
|
43
43
|
} from "effect";
|
|
44
|
-
import * as SqlClientService from "effect/unstable/sql/SqlClient";
|
|
44
|
+
import type * as SqlClientService from "effect/unstable/sql/SqlClient";
|
|
45
45
|
|
|
46
46
|
import {
|
|
47
47
|
type SqliteStorageCompatibilityError,
|
|
@@ -471,9 +471,8 @@ const decodeStartupPayloads = Effect.fn("SqliteConversationStore.decodeStartupPa
|
|
|
471
471
|
const makeServices = Effect.fn("SqliteConversationStore.makeServices")(function* () {
|
|
472
472
|
const config = yield* SqliteStorageConfig;
|
|
473
473
|
const failpoint = yield* SqliteStorageFailpoint;
|
|
474
|
-
const sql = yield* SqlClientService.SqlClient;
|
|
475
474
|
const crypto = yield* Crypto.Crypto;
|
|
476
|
-
const journal = yield* initializeSqliteJournal(
|
|
475
|
+
const journal = yield* initializeSqliteJournal();
|
|
477
476
|
if (config.verifyOnOpen) {
|
|
478
477
|
yield* decodeStartupPayloads(journal, crypto);
|
|
479
478
|
}
|
package/src/sqlite-journal.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Effect, Exit, Schema } from "effect";
|
|
|
4
4
|
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
5
5
|
import type { SqlError } from "effect/unstable/sql/SqlError";
|
|
6
6
|
|
|
7
|
+
import type { SqliteStorageFailpointError } from "./errors.ts";
|
|
7
8
|
import {
|
|
8
9
|
SqliteAppendConflict,
|
|
9
10
|
SqliteCheckpointConflict,
|
|
@@ -11,11 +12,11 @@ import {
|
|
|
11
12
|
SqliteStorageCompatibilityError,
|
|
12
13
|
SqliteStorageCorruptionError,
|
|
13
14
|
SqliteStorageError,
|
|
14
|
-
SqliteStorageFailpointError,
|
|
15
15
|
SqliteWriteContention,
|
|
16
|
-
type SqliteStorageFailpointLocation,
|
|
17
16
|
} from "./errors.ts";
|
|
18
17
|
import { CurrentSqliteStorageVersion, sqliteMigrations } from "./migrations.ts";
|
|
18
|
+
import { SqliteStorageConfig } from "./sqlite-storage-config.ts";
|
|
19
|
+
import { SqliteStorageFailpoint } from "./sqlite-storage-failpoint.ts";
|
|
19
20
|
|
|
20
21
|
const BoundedStoredText = Schema.String.check(Schema.isMaxLength(16 * 1024 * 1024));
|
|
21
22
|
const BoundedIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(1024));
|
|
@@ -138,12 +139,6 @@ type CheckpointError =
|
|
|
138
139
|
| SqliteStorageCorruptionError
|
|
139
140
|
| SqliteStorageError
|
|
140
141
|
| SqliteWriteContention;
|
|
141
|
-
type SqliteJournalFailpoint = (
|
|
142
|
-
location: SqliteStorageFailpointLocation,
|
|
143
|
-
) => Effect.Effect<void, SqliteStorageFailpointError>;
|
|
144
|
-
|
|
145
|
-
const noFailpoint: SqliteJournalFailpoint = () => Effect.void;
|
|
146
|
-
|
|
147
142
|
const storageError =
|
|
148
143
|
(operation: string) =>
|
|
149
144
|
(error: SqlError): SqliteStorageError =>
|
|
@@ -195,16 +190,15 @@ export const decodeSingleRow = Effect.fn("SqliteJournal.decodeSingleRow")(
|
|
|
195
190
|
),
|
|
196
191
|
);
|
|
197
192
|
|
|
198
|
-
const
|
|
199
|
-
sql
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
) {
|
|
193
|
+
export const initializeSqliteJournal = Effect.fn("SqliteJournal.initialize")(function* () {
|
|
194
|
+
const sql = yield* SqlClient.SqlClient;
|
|
195
|
+
const { hit: failpoint } = yield* SqliteStorageFailpoint;
|
|
196
|
+
const { busyTimeout } = yield* SqliteStorageConfig;
|
|
203
197
|
yield* sql`PRAGMA foreign_keys = ON`.pipe(Effect.mapError(storageError("enable foreign keys")));
|
|
204
198
|
// PRAGMA statements do not accept bound parameters; the value is a schema-validated
|
|
205
199
|
// non-negative integer, never caller-controlled text.
|
|
206
200
|
yield* sql
|
|
207
|
-
.unsafe(`PRAGMA busy_timeout = ${
|
|
201
|
+
.unsafe(`PRAGMA busy_timeout = ${busyTimeout}`)
|
|
208
202
|
.pipe(Effect.mapError(storageError("configure busy timeout")));
|
|
209
203
|
const journalModeRows = yield* sql<Record<string, unknown>>`PRAGMA journal_mode`.pipe(
|
|
210
204
|
Effect.mapError(storageError("read journal mode")),
|
|
@@ -272,9 +266,6 @@ const ensureCurrentStorage = Effect.fn("SqliteJournal.ensureCurrentStorage")(fun
|
|
|
272
266
|
}
|
|
273
267
|
|
|
274
268
|
yield* SqliteMigrator.run({ loader: sqliteMigrations }).pipe(
|
|
275
|
-
// SqliteMigrator depends on the generic client supplied by this adapter.
|
|
276
|
-
// The concrete Node client is kept at the outer Layer boundary.
|
|
277
|
-
Effect.provideService(SqlClient.SqlClient, sql),
|
|
278
269
|
Effect.mapError((error) =>
|
|
279
270
|
SqliteStorageError.make({
|
|
280
271
|
cause: error,
|
|
@@ -300,7 +291,8 @@ const ensureCurrentStorage = Effect.fn("SqliteJournal.ensureCurrentStorage")(fun
|
|
|
300
291
|
'effect_agent_settlement_reservations',
|
|
301
292
|
'effect_agent_abort_intents',
|
|
302
293
|
'effect_agent_approval_decisions',
|
|
303
|
-
'effect_agent_unknown_resolutions'
|
|
294
|
+
'effect_agent_unknown_resolutions',
|
|
295
|
+
'effect_agent_schedules'
|
|
304
296
|
)
|
|
305
297
|
ORDER BY name
|
|
306
298
|
`.pipe(Effect.mapError(storageError("verify storage tables")));
|
|
@@ -310,7 +302,7 @@ const ensureCurrentStorage = Effect.fn("SqliteJournal.ensureCurrentStorage")(fun
|
|
|
310
302
|
"required_tables",
|
|
311
303
|
requiredRows,
|
|
312
304
|
);
|
|
313
|
-
if (required.length !==
|
|
305
|
+
if (required.length !== 12) {
|
|
314
306
|
return yield* SqliteStorageCompatibilityError.make({
|
|
315
307
|
actualVersion: CurrentSqliteStorageVersion,
|
|
316
308
|
supportedVersion: CurrentSqliteStorageVersion,
|
|
@@ -319,10 +311,6 @@ const ensureCurrentStorage = Effect.fn("SqliteJournal.ensureCurrentStorage")(fun
|
|
|
319
311
|
});
|
|
320
312
|
}
|
|
321
313
|
|
|
322
|
-
return makeJournal(sql, failpoint);
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
const makeJournal = (sql: SqlClient.SqlClient, failpoint: SqliteJournalFailpoint) => {
|
|
326
314
|
const classifyWriteFailure =
|
|
327
315
|
(operation: string) =>
|
|
328
316
|
(error: SqlError): SqliteStorageError | SqliteWriteContention =>
|
|
@@ -1079,8 +1067,6 @@ const makeJournal = (sql: SqlClient.SqlClient, failpoint: SqliteJournalFailpoint
|
|
|
1079
1067
|
scanStoredPayloads,
|
|
1080
1068
|
withWriteTransaction,
|
|
1081
1069
|
} as const;
|
|
1082
|
-
};
|
|
1083
|
-
|
|
1084
|
-
export type SqliteJournal = ReturnType<typeof makeJournal>;
|
|
1070
|
+
});
|
|
1085
1071
|
|
|
1086
|
-
export
|
|
1072
|
+
export type SqliteJournal = Effect.Success<ReturnType<typeof initializeSqliteJournal>>;
|
package/src/sqlite-ledger.ts
CHANGED
|
@@ -320,7 +320,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
|
|
|
320
320
|
const failpoint = yield* SqliteStorageFailpoint;
|
|
321
321
|
const sql = yield* SqlClientService.SqlClient;
|
|
322
322
|
const crypto = yield* Crypto.Crypto;
|
|
323
|
-
const journal = yield* initializeSqliteJournal(
|
|
323
|
+
const journal = yield* initializeSqliteJournal();
|
|
324
324
|
|
|
325
325
|
const hitFailpoint = (
|
|
326
326
|
location: SqliteStorageFailpointLocation,
|
|
@@ -1146,14 +1146,15 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
|
|
|
1146
1146
|
if (heads.length === 0) return Option.none<Claim>();
|
|
1147
1147
|
const head = heads[0];
|
|
1148
1148
|
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1149
|
+
// Unknown work is claimable only for a durably requested abort: the coordinator
|
|
1150
|
+
// cleans up children and settles without replaying ordinary Tools. Read the intent
|
|
1151
|
+
// in this claim transaction; retain uncertainty evidence and all ownership fencing.
|
|
1152
1152
|
if (
|
|
1153
1153
|
head.state === "joining" ||
|
|
1154
1154
|
head.state === "joined" ||
|
|
1155
1155
|
head.state === "suspended" ||
|
|
1156
|
-
head.state === "unknown"
|
|
1156
|
+
(head.state === "unknown" &&
|
|
1157
|
+
Option.isNone(yield* readAbortIntent(operation, head.submission_id)))
|
|
1157
1158
|
) {
|
|
1158
1159
|
return Option.none<Claim>();
|
|
1159
1160
|
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ScheduleCapacityError,
|
|
3
|
+
ScheduleDueCursor,
|
|
4
|
+
defaultSchedulingLimits,
|
|
5
|
+
scheduleUsesCapacity,
|
|
6
|
+
ScheduleChange,
|
|
7
|
+
ScheduleConflict,
|
|
8
|
+
ScheduleFailpoint,
|
|
9
|
+
applyScheduleChange,
|
|
10
|
+
ScheduleKey,
|
|
11
|
+
ScheduleId,
|
|
12
|
+
ScheduleInstant,
|
|
13
|
+
ScheduleNotFound,
|
|
14
|
+
ScheduleOwner,
|
|
15
|
+
type SchedulePage,
|
|
16
|
+
SchedulePageRequest,
|
|
17
|
+
ScheduleRecord,
|
|
18
|
+
ScheduleStorageError,
|
|
19
|
+
ScheduleStore,
|
|
20
|
+
scheduleDeadline,
|
|
21
|
+
} from "@effect-agent/session";
|
|
22
|
+
import { Effect, Layer, Result, Schema } from "effect";
|
|
23
|
+
import * as SqlClientService from "effect/unstable/sql/SqlClient";
|
|
24
|
+
|
|
25
|
+
import type { SqliteStorageInitializationError } from "./sqlite-conversation-store.ts";
|
|
26
|
+
import { initializeSqliteJournal } from "./sqlite-journal.ts";
|
|
27
|
+
import type { SqliteStorageConfig } from "./sqlite-storage-config.ts";
|
|
28
|
+
import type { SqliteStorageFailpoint } from "./sqlite-storage-failpoint.ts";
|
|
29
|
+
|
|
30
|
+
// Configuration and the immutable pending envelope may each carry the canonical input. Leave
|
|
31
|
+
// room for JSON escaping and bounded status while rejecting an unreadable oversized row.
|
|
32
|
+
const StoredScheduleJson = Schema.String.check(Schema.isMaxLength(16 * 1024 * 1024));
|
|
33
|
+
const StoredDeadline = Schema.NullOr(ScheduleInstant);
|
|
34
|
+
|
|
35
|
+
class ScheduleRow extends Schema.Class<ScheduleRow>("@effect-agent/storage-sqlite/ScheduleRow")({
|
|
36
|
+
tenant_id: ScheduleOwner.fields.tenantId,
|
|
37
|
+
owner_id: ScheduleOwner.fields.ownerId,
|
|
38
|
+
schedule_id: ScheduleId,
|
|
39
|
+
deadline_at_millis: StoredDeadline,
|
|
40
|
+
record_json: StoredScheduleJson,
|
|
41
|
+
}) {}
|
|
42
|
+
|
|
43
|
+
const ScheduleDueRow = Schema.Struct({
|
|
44
|
+
tenant_id: ScheduleOwner.fields.tenantId,
|
|
45
|
+
owner_id: ScheduleOwner.fields.ownerId,
|
|
46
|
+
schedule_id: ScheduleId,
|
|
47
|
+
deadline_at_millis: ScheduleInstant,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
class ScheduleCountRow extends Schema.Class<ScheduleCountRow>(
|
|
51
|
+
"@effect-agent/storage-sqlite/ScheduleCountRow",
|
|
52
|
+
)({
|
|
53
|
+
schedule_count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
54
|
+
}) {}
|
|
55
|
+
|
|
56
|
+
class ScheduleDeadlineRow extends Schema.Class<ScheduleDeadlineRow>(
|
|
57
|
+
"@effect-agent/storage-sqlite/ScheduleDeadlineRow",
|
|
58
|
+
)({
|
|
59
|
+
deadline_at_millis: StoredDeadline,
|
|
60
|
+
}) {}
|
|
61
|
+
|
|
62
|
+
const unavailable = (operation: string): ScheduleStorageError =>
|
|
63
|
+
ScheduleStorageError.make({ operation, reason: "unavailable" });
|
|
64
|
+
|
|
65
|
+
const corrupt = (operation: string): ScheduleStorageError =>
|
|
66
|
+
ScheduleStorageError.make({ operation, reason: "corrupt" });
|
|
67
|
+
|
|
68
|
+
const decodeRows = Effect.fn("SqliteScheduleStore.decodeRows")(function* <A, I>(
|
|
69
|
+
schema: Schema.Codec<A, I, never>,
|
|
70
|
+
rows: ReadonlyArray<unknown>,
|
|
71
|
+
operation: string,
|
|
72
|
+
): Effect.fn.Return<A, ScheduleStorageError> {
|
|
73
|
+
return yield* Schema.decodeUnknownEffect(schema)(rows).pipe(
|
|
74
|
+
Effect.mapError(() => corrupt(operation)),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const decodeRecord = Effect.fn("SqliteScheduleStore.decodeRecord")(function* (
|
|
79
|
+
row: ScheduleRow,
|
|
80
|
+
): Effect.fn.Return<ScheduleRecord, ScheduleStorageError> {
|
|
81
|
+
const record = yield* Schema.decodeEffect(Schema.fromJsonString(ScheduleRecord))(
|
|
82
|
+
row.record_json,
|
|
83
|
+
).pipe(Effect.mapError(() => corrupt("decode schedule")));
|
|
84
|
+
if (
|
|
85
|
+
record.owner.tenantId !== row.tenant_id ||
|
|
86
|
+
record.owner.ownerId !== row.owner_id ||
|
|
87
|
+
record.scheduleId !== row.schedule_id ||
|
|
88
|
+
scheduleDeadline(record) !== row.deadline_at_millis
|
|
89
|
+
) {
|
|
90
|
+
return yield* corrupt("decode schedule identity");
|
|
91
|
+
}
|
|
92
|
+
return record;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const encodeRecord = Effect.fn("SqliteScheduleStore.encodeRecord")(function* (
|
|
96
|
+
record: ScheduleRecord,
|
|
97
|
+
): Effect.fn.Return<string, ScheduleStorageError> {
|
|
98
|
+
return yield* Schema.encodeEffect(Schema.fromJsonString(ScheduleRecord))(record).pipe(
|
|
99
|
+
Effect.mapError(() => corrupt("encode schedule")),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const decodeInput = Effect.fn("SqliteScheduleStore.decodeInput")(function* <A, I>(
|
|
104
|
+
operation: string,
|
|
105
|
+
schema: Schema.Codec<A, I, never>,
|
|
106
|
+
value: unknown,
|
|
107
|
+
): Effect.fn.Return<A, ScheduleStorageError> {
|
|
108
|
+
return yield* Schema.decodeUnknownEffect(schema)(value).pipe(
|
|
109
|
+
Effect.mapError(() => corrupt(operation)),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const makeScheduleStore = Effect.gen(function* () {
|
|
114
|
+
const sql = yield* SqlClientService.SqlClient;
|
|
115
|
+
const scheduleFailpoint = yield* ScheduleFailpoint;
|
|
116
|
+
|
|
117
|
+
yield* initializeSqliteJournal();
|
|
118
|
+
|
|
119
|
+
const readRows = Effect.fn("SqliteScheduleStore.readRows")(function* (
|
|
120
|
+
key: ScheduleKey,
|
|
121
|
+
operation: string,
|
|
122
|
+
): Effect.fn.Return<ReadonlyArray<ScheduleRow>, ScheduleStorageError> {
|
|
123
|
+
const rows = yield* sql<Record<string, unknown>>`
|
|
124
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
125
|
+
FROM effect_agent_schedules
|
|
126
|
+
WHERE tenant_id = ${key.owner.tenantId}
|
|
127
|
+
AND owner_id = ${key.owner.ownerId}
|
|
128
|
+
AND schedule_id = ${key.scheduleId}
|
|
129
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
130
|
+
return yield* decodeRows(Schema.Array(ScheduleRow), rows, operation);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const readOne = Effect.fn("SqliteScheduleStore.readOne")(function* (
|
|
134
|
+
key: ScheduleKey,
|
|
135
|
+
operation: string,
|
|
136
|
+
): Effect.fn.Return<ScheduleRecord | null, ScheduleStorageError> {
|
|
137
|
+
const rows = yield* readRows(key, operation);
|
|
138
|
+
if (rows.length === 0) return null;
|
|
139
|
+
if (rows.length !== 1) return yield* corrupt(operation);
|
|
140
|
+
return yield* decodeRecord(rows[0]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const insert: ScheduleStore["Service"]["insert"] = Effect.fn("SqliteScheduleStore.insert")(
|
|
144
|
+
function* (record, ownerLimit) {
|
|
145
|
+
const operation = "insert schedule";
|
|
146
|
+
const canonical = yield* decodeInput(operation, ScheduleRecord, record);
|
|
147
|
+
const recordJson = yield* encodeRecord(canonical);
|
|
148
|
+
const result = yield* sql
|
|
149
|
+
.withTransaction(
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const existing = yield* readOne(canonical, operation);
|
|
152
|
+
if (existing !== null) {
|
|
153
|
+
if (existing.creationFingerprint === canonical.creationFingerprint) {
|
|
154
|
+
return { record: existing, inserted: false } as const;
|
|
155
|
+
}
|
|
156
|
+
return yield* ScheduleConflict.make({
|
|
157
|
+
reason: "creation",
|
|
158
|
+
key: { owner: canonical.owner, scheduleId: canonical.scheduleId },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const rawCounts = yield* sql<Record<string, unknown>>`
|
|
162
|
+
SELECT COUNT(*) AS schedule_count
|
|
163
|
+
FROM effect_agent_schedules
|
|
164
|
+
WHERE tenant_id = ${canonical.owner.tenantId}
|
|
165
|
+
AND owner_id = ${canonical.owner.ownerId}
|
|
166
|
+
AND (json_extract(record_json, '$.pending') IS NOT NULL OR
|
|
167
|
+
(json_extract(record_json, '$.state') != 'cancelled' AND json_extract(record_json, '$.nextAtMillis') IS NOT NULL))
|
|
168
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
169
|
+
const counts = yield* decodeRows(Schema.Array(ScheduleCountRow), rawCounts, operation);
|
|
170
|
+
if (counts.length !== 1) return yield* corrupt(operation);
|
|
171
|
+
if (counts[0].schedule_count >= ownerLimit) {
|
|
172
|
+
return yield* ScheduleCapacityError.make({ limit: ownerLimit });
|
|
173
|
+
}
|
|
174
|
+
yield* scheduleFailpoint.hit("schedule:insert:before");
|
|
175
|
+
yield* sql`
|
|
176
|
+
INSERT INTO effect_agent_schedules (
|
|
177
|
+
tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
178
|
+
) VALUES (
|
|
179
|
+
${canonical.owner.tenantId},
|
|
180
|
+
${canonical.owner.ownerId},
|
|
181
|
+
${canonical.scheduleId},
|
|
182
|
+
${scheduleDeadline(canonical)},
|
|
183
|
+
${recordJson}
|
|
184
|
+
)
|
|
185
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
186
|
+
return { record: canonical, inserted: true } as const;
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
.pipe(Effect.catchTag("SqlError", () => Effect.fail(unavailable(operation))));
|
|
190
|
+
if (result.inserted) yield* scheduleFailpoint.hit("schedule:insert:after");
|
|
191
|
+
return result.record;
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const get: ScheduleStore["Service"]["get"] = Effect.fn("SqliteScheduleStore.get")(
|
|
196
|
+
function* (key) {
|
|
197
|
+
const decodedKey = yield* decodeInput("get schedule", ScheduleKey, key);
|
|
198
|
+
return yield* readOne(decodedKey, "get schedule");
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const list: ScheduleStore["Service"]["list"] = Effect.fn("SqliteScheduleStore.list")(function* (
|
|
203
|
+
request: SchedulePageRequest,
|
|
204
|
+
): Effect.fn.Return<SchedulePage, ScheduleStorageError> {
|
|
205
|
+
const operation = "list schedules";
|
|
206
|
+
const decodedRequest = yield* decodeInput(operation, SchedulePageRequest, request);
|
|
207
|
+
const rows =
|
|
208
|
+
decodedRequest.after === undefined
|
|
209
|
+
? yield* sql<Record<string, unknown>>`
|
|
210
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
211
|
+
FROM effect_agent_schedules
|
|
212
|
+
WHERE tenant_id = ${decodedRequest.owner.tenantId}
|
|
213
|
+
AND owner_id = ${decodedRequest.owner.ownerId}
|
|
214
|
+
ORDER BY schedule_id
|
|
215
|
+
LIMIT ${decodedRequest.limit + 1}
|
|
216
|
+
`.pipe(Effect.mapError(() => unavailable(operation)))
|
|
217
|
+
: yield* sql<Record<string, unknown>>`
|
|
218
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis, record_json
|
|
219
|
+
FROM effect_agent_schedules
|
|
220
|
+
WHERE tenant_id = ${decodedRequest.owner.tenantId}
|
|
221
|
+
AND owner_id = ${decodedRequest.owner.ownerId}
|
|
222
|
+
AND schedule_id > ${decodedRequest.after}
|
|
223
|
+
ORDER BY schedule_id
|
|
224
|
+
LIMIT ${decodedRequest.limit + 1}
|
|
225
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
226
|
+
const decoded = yield* decodeRows(Schema.Array(ScheduleRow), rows, operation);
|
|
227
|
+
const records = yield* Effect.forEach(decoded, decodeRecord);
|
|
228
|
+
const hasNext = records.length > decodedRequest.limit;
|
|
229
|
+
const items = hasNext ? records.slice(0, decodedRequest.limit) : records;
|
|
230
|
+
return { items, next: hasNext ? (items.at(-1)?.scheduleId ?? null) : null };
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const change: ScheduleStore["Service"]["change"] = Effect.fn("SqliteScheduleStore.change")(
|
|
234
|
+
function* (key, change, ownerLimit = defaultSchedulingLimits.maxSchedulesPerOwner) {
|
|
235
|
+
const operation = "change schedule";
|
|
236
|
+
const decodedKey = yield* decodeInput(operation, ScheduleKey, key);
|
|
237
|
+
const decodedChange = yield* decodeInput(operation, ScheduleChange, change);
|
|
238
|
+
const result = yield* sql
|
|
239
|
+
.withTransaction(
|
|
240
|
+
Effect.gen(function* () {
|
|
241
|
+
const current = yield* readOne(decodedKey, operation);
|
|
242
|
+
if (current === null) return yield* ScheduleNotFound.make({ key: decodedKey });
|
|
243
|
+
const transition = applyScheduleChange(current, decodedChange);
|
|
244
|
+
if (Result.isFailure(transition)) return yield* transition.failure;
|
|
245
|
+
const next = transition.success;
|
|
246
|
+
if (!scheduleUsesCapacity(current) && scheduleUsesCapacity(next)) {
|
|
247
|
+
const rawCounts = yield* sql<Record<string, unknown>>`
|
|
248
|
+
SELECT COUNT(*) AS schedule_count FROM effect_agent_schedules
|
|
249
|
+
WHERE tenant_id = ${key.owner.tenantId} AND owner_id = ${key.owner.ownerId}
|
|
250
|
+
AND (json_extract(record_json, '$.pending') IS NOT NULL OR
|
|
251
|
+
(json_extract(record_json, '$.state') != 'cancelled' AND json_extract(record_json, '$.nextAtMillis') IS NOT NULL))
|
|
252
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
253
|
+
const counts = yield* decodeRows(
|
|
254
|
+
Schema.Array(ScheduleCountRow),
|
|
255
|
+
rawCounts,
|
|
256
|
+
operation,
|
|
257
|
+
);
|
|
258
|
+
if (counts.length !== 1) return yield* corrupt(operation);
|
|
259
|
+
if (counts[0].schedule_count >= ownerLimit)
|
|
260
|
+
return yield* ScheduleCapacityError.make({ limit: ownerLimit });
|
|
261
|
+
}
|
|
262
|
+
if (next === current) return { record: current, changed: false } as const;
|
|
263
|
+
const recordJson = yield* encodeRecord(next);
|
|
264
|
+
yield* scheduleFailpoint.hit(`schedule:${decodedChange._tag.toLowerCase()}:before`);
|
|
265
|
+
yield* sql`
|
|
266
|
+
UPDATE effect_agent_schedules
|
|
267
|
+
SET deadline_at_millis = ${scheduleDeadline(next)}, record_json = ${recordJson}
|
|
268
|
+
WHERE tenant_id = ${decodedKey.owner.tenantId}
|
|
269
|
+
AND owner_id = ${decodedKey.owner.ownerId}
|
|
270
|
+
AND schedule_id = ${decodedKey.scheduleId}
|
|
271
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
272
|
+
return { record: next, changed: true } as const;
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
.pipe(Effect.catchTag("SqlError", () => Effect.fail(unavailable(operation))));
|
|
276
|
+
if (result.changed) {
|
|
277
|
+
yield* scheduleFailpoint.hit(`schedule:${decodedChange._tag.toLowerCase()}:after`);
|
|
278
|
+
}
|
|
279
|
+
return result.record;
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const due: ScheduleStore["Service"]["due"] = Effect.fn("SqliteScheduleStore.due")(function* (
|
|
284
|
+
nowMillis,
|
|
285
|
+
limit,
|
|
286
|
+
owner?: ScheduleOwner,
|
|
287
|
+
after?: ScheduleDueCursor,
|
|
288
|
+
) {
|
|
289
|
+
const operation = "query due schedules";
|
|
290
|
+
const decodedOwner =
|
|
291
|
+
owner === undefined ? undefined : yield* decodeInput(operation, ScheduleOwner, owner);
|
|
292
|
+
const cursor =
|
|
293
|
+
after === undefined
|
|
294
|
+
? undefined
|
|
295
|
+
: yield* Schema.decodeUnknownEffect(ScheduleDueCursor)(after).pipe(
|
|
296
|
+
Effect.mapError(() => corrupt(operation)),
|
|
297
|
+
);
|
|
298
|
+
const continuation =
|
|
299
|
+
cursor === undefined
|
|
300
|
+
? sql`1 = 1`
|
|
301
|
+
: sql`
|
|
302
|
+
(deadline_at_millis, tenant_id, owner_id, schedule_id) >
|
|
303
|
+
(${cursor.deadlineAtMillis}, ${cursor.owner.tenantId}, ${cursor.owner.ownerId}, ${cursor.scheduleId})`;
|
|
304
|
+
const rows =
|
|
305
|
+
decodedOwner === undefined
|
|
306
|
+
? yield* sql<Record<string, unknown>>`
|
|
307
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis
|
|
308
|
+
FROM effect_agent_schedules
|
|
309
|
+
WHERE deadline_at_millis <= ${nowMillis} AND ${continuation}
|
|
310
|
+
ORDER BY deadline_at_millis, tenant_id, owner_id, schedule_id
|
|
311
|
+
LIMIT ${limit}
|
|
312
|
+
`.pipe(Effect.mapError(() => unavailable(operation)))
|
|
313
|
+
: yield* sql<Record<string, unknown>>`
|
|
314
|
+
SELECT tenant_id, owner_id, schedule_id, deadline_at_millis
|
|
315
|
+
FROM effect_agent_schedules
|
|
316
|
+
WHERE tenant_id = ${decodedOwner.tenantId}
|
|
317
|
+
AND owner_id = ${decodedOwner.ownerId}
|
|
318
|
+
AND deadline_at_millis <= ${nowMillis} AND ${continuation}
|
|
319
|
+
ORDER BY deadline_at_millis, schedule_id
|
|
320
|
+
LIMIT ${limit}
|
|
321
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
322
|
+
const decoded = yield* decodeRows(Schema.Array(ScheduleDueRow), rows, operation);
|
|
323
|
+
return decoded.map((row) => ({
|
|
324
|
+
owner: { tenantId: row.tenant_id, ownerId: row.owner_id },
|
|
325
|
+
scheduleId: row.schedule_id,
|
|
326
|
+
deadlineAtMillis: row.deadline_at_millis,
|
|
327
|
+
}));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const nextDeadline: ScheduleStore["Service"]["nextDeadline"] = Effect.fn(
|
|
331
|
+
"SqliteScheduleStore.nextDeadline",
|
|
332
|
+
)(function* (owner?: ScheduleOwner) {
|
|
333
|
+
const operation = "query next schedule deadline";
|
|
334
|
+
const decodedOwner =
|
|
335
|
+
owner === undefined ? undefined : yield* decodeInput(operation, ScheduleOwner, owner);
|
|
336
|
+
const rows =
|
|
337
|
+
decodedOwner === undefined
|
|
338
|
+
? yield* sql<Record<string, unknown>>`
|
|
339
|
+
SELECT MIN(deadline_at_millis) AS deadline_at_millis
|
|
340
|
+
FROM effect_agent_schedules
|
|
341
|
+
WHERE deadline_at_millis IS NOT NULL
|
|
342
|
+
`.pipe(Effect.mapError(() => unavailable(operation)))
|
|
343
|
+
: yield* sql<Record<string, unknown>>`
|
|
344
|
+
SELECT MIN(deadline_at_millis) AS deadline_at_millis
|
|
345
|
+
FROM effect_agent_schedules
|
|
346
|
+
WHERE tenant_id = ${decodedOwner.tenantId}
|
|
347
|
+
AND owner_id = ${decodedOwner.ownerId}
|
|
348
|
+
AND deadline_at_millis IS NOT NULL
|
|
349
|
+
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
350
|
+
const decoded = yield* decodeRows(Schema.Array(ScheduleDeadlineRow), rows, operation);
|
|
351
|
+
if (decoded.length !== 1) return yield* corrupt(operation);
|
|
352
|
+
return decoded[0].deadline_at_millis;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return ScheduleStore.of({ insert, get, list, change, due, nextDeadline });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/** SQLite implementation of the atomic ScheduleStore port. */
|
|
359
|
+
export const scheduleStoreLayer: Layer.Layer<
|
|
360
|
+
ScheduleStore,
|
|
361
|
+
SqliteStorageInitializationError,
|
|
362
|
+
SqliteStorageConfig | SqliteStorageFailpoint | SqlClientService.SqlClient
|
|
363
|
+
> = Layer.effect(ScheduleStore)(makeScheduleStore);
|