@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-agent/storage-sqlite",
3
- "version": "0.1.0-beta.35",
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.35",
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 = 4;
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(sql, failpoint.hit, config.busyTimeout);
475
+ const journal = yield* initializeSqliteJournal();
477
476
  if (config.verifyOnOpen) {
478
477
  yield* decodeStartupPayloads(journal, crypto);
479
478
  }
@@ -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 ensureCurrentStorage = Effect.fn("SqliteJournal.ensureCurrentStorage")(function* (
199
- sql: SqlClient.SqlClient,
200
- failpoint: SqliteJournalFailpoint = noFailpoint,
201
- busyTimeoutMillis = 5_000,
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 = ${busyTimeoutMillis}`)
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 !== 11) {
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 const initializeSqliteJournal = ensureCurrentStorage;
1072
+ export type SqliteJournal = Effect.Success<ReturnType<typeof initializeSqliteJournal>>;
@@ -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(sql, failpoint.hit, config.busyTimeout);
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
- // A joining/joined head is host-owned and a suspended/unknown head is durably
1150
- // blocked (DUR-017); the lane produces no claim and later ready work is never
1151
- // skipped past the blocked head (DUR-004).
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);