@polpo-ai/node 0.15.45 → 0.15.47

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.
@@ -0,0 +1,45 @@
1
+ import { type CompleteScheduleRunInput, type CreateScheduleInput, type CreateScheduleRunInput, type Schedule, type ScheduleFilter, type ScheduleLease, type ScheduleMutationOptions, type ScheduleOperationalStatePatch, type ScheduleRun, type ScheduleRunFilter, type ScheduleStore, type UpdateScheduleInput } from "@polpo-ai/core/scheduling";
2
+ interface SQLiteScheduleStoreOptions {
3
+ now?: () => Date | string;
4
+ createId?: (kind: "schedule" | "schedule-run") => string;
5
+ }
6
+ /**
7
+ * Durable local ScheduleStore backed by SQLite.
8
+ *
9
+ * The optional native dependency is loaded only by `open()`, so importing the
10
+ * rest of @polpo-ai/node remains safe in environments without better-sqlite3.
11
+ */
12
+ export declare class SQLiteScheduleStore implements ScheduleStore {
13
+ private readonly db;
14
+ private readonly nowProvider;
15
+ private readonly createId;
16
+ private constructor();
17
+ static open(path: string, options?: SQLiteScheduleStoreOptions): Promise<SQLiteScheduleStore>;
18
+ create(input: CreateScheduleInput): Promise<Schedule>;
19
+ list(filter?: ScheduleFilter): Promise<Schedule[]>;
20
+ get(id: string): Promise<Schedule | null>;
21
+ update(id: string, patch: UpdateScheduleInput, options?: ScheduleMutationOptions): Promise<Schedule>;
22
+ markDeleted(id: string, options?: ScheduleMutationOptions): Promise<void>;
23
+ updateOperationalState(id: string, patch: ScheduleOperationalStatePatch, options?: ScheduleMutationOptions): Promise<Schedule>;
24
+ createRun(input: CreateScheduleRunInput): Promise<ScheduleRun>;
25
+ getRun(id: string): Promise<ScheduleRun | null>;
26
+ listRuns(filter?: ScheduleRunFilter): Promise<ScheduleRun[]>;
27
+ claimRun(id: string, lease: ScheduleLease): Promise<ScheduleRun | null>;
28
+ renewLease(id: string, lease: ScheduleLease): Promise<boolean>;
29
+ startRun(id: string, lease: ScheduleLease): Promise<ScheduleRun>;
30
+ releaseRun(id: string, lease: ScheduleLease): Promise<ScheduleRun>;
31
+ completeRun(id: string, input: CompleteScheduleRunInput): Promise<ScheduleRun>;
32
+ countActiveRuns(scheduleId: string): Promise<number>;
33
+ close(): void;
34
+ private insertSchedule;
35
+ private insertRun;
36
+ private casRun;
37
+ private casClaimRun;
38
+ private requireSchedule;
39
+ private requireRun;
40
+ private getRunByIdempotencyKey;
41
+ private requireOwnedRun;
42
+ private now;
43
+ }
44
+ export {};
45
+ //# sourceMappingURL=sqlite-schedule-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-schedule-store.d.ts","sourceRoot":"","sources":["../../src/scheduling/sqlite-schedule-store.ts"],"names":[],"mappings":"AAIA,OAAO,EAcL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,uBAAuB,EAC5B,KAAK,6BAA6B,EAClC,KAAK,WAAW,EAChB,KAAK,iBAAiB,EAGtB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AAEnC,UAAU,0BAA0B;IAClC,GAAG,CAAC,EAAE,MAAM,IAAI,GAAG,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,GAAG,cAAc,KAAK,MAAM,CAAC;CAC1D;AAoED;;;;;GAKG;AACH,qBAAa,mBAAoB,YAAW,aAAa;IAKrD,OAAO,CAAC,QAAQ,CAAC,EAAE;IAJrB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgD;IAEzE,OAAO;WAQM,IAAI,CACf,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,0BAA+B,GACvC,OAAO,CAAC,mBAAmB,CAAC;IAyBzB,MAAM,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC;IA+BrD,IAAI,CAAC,MAAM,GAAE,cAAmB,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IActD,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAOzC,MAAM,CACV,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,mBAAmB,EAC1B,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,QAAQ,CAAC;IAqDd,WAAW,CACf,EAAE,EAAE,MAAM,EACV,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,IAAI,CAAC;IAkCV,sBAAsB,CAC1B,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,6BAA6B,EACpC,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,QAAQ,CAAC;IAuCd,SAAS,CAAC,KAAK,EAAE,sBAAsB,GAAG,OAAO,CAAC,WAAW,CAAC;IA4C9D,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAO/C,QAAQ,CAAC,MAAM,GAAE,iBAAsB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAiBhE,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAsCvE,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC;IAuB9D,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,WAAW,CAAC;IA8BhE,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,WAAW,CAAC;IAkBlE,WAAW,CACf,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,WAAW,CAAC;IA+BjB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAW1D,KAAK,IAAI,IAAI;IAIb,OAAO,CAAC,cAAc;IAgBtB,OAAO,CAAC,SAAS;IAsBjB,OAAO,CAAC,MAAM;IA4Bd,OAAO,CAAC,WAAW;IA6CnB,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,sBAAsB;IAO9B,OAAO,CAAC,eAAe;IAiBvB,OAAO,CAAC,GAAG;CASZ"}
@@ -0,0 +1,611 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { nanoid } from "nanoid";
4
+ import { ScheduleConflictError, ScheduleInvalidStateError, ScheduleNotFoundError, assertSameScheduleOccurrence, assertScheduleRunStatusTransition, assertScheduleStatusTransition, isTerminalScheduleRunStatus, normalizeCompleteScheduleRunInput, normalizeCreateScheduleInput, normalizeCreateScheduleRunInput, normalizeScheduleLease, normalizeScheduleOperationalStatePatch, normalizeUpdateScheduleInput, } from "@polpo-ai/core/scheduling";
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS polpo_schedules_v2 (
7
+ id TEXT PRIMARY KEY,
8
+ status TEXT NOT NULL,
9
+ surface TEXT NOT NULL,
10
+ revision INTEGER NOT NULL,
11
+ created_at TEXT NOT NULL,
12
+ updated_at TEXT NOT NULL,
13
+ data TEXT NOT NULL
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS polpo_schedules_v2_status_idx
17
+ ON polpo_schedules_v2 (status, updated_at);
18
+ CREATE INDEX IF NOT EXISTS polpo_schedules_v2_surface_idx
19
+ ON polpo_schedules_v2 (surface, created_at);
20
+
21
+ CREATE TABLE IF NOT EXISTS polpo_schedule_runs_v2 (
22
+ id TEXT PRIMARY KEY,
23
+ schedule_id TEXT NOT NULL,
24
+ idempotency_key TEXT NOT NULL UNIQUE,
25
+ status TEXT NOT NULL,
26
+ attempts INTEGER NOT NULL,
27
+ occurrence_at TEXT NOT NULL,
28
+ lease_owner TEXT,
29
+ lease_token TEXT,
30
+ lease_expires_at TEXT,
31
+ created_at TEXT NOT NULL,
32
+ updated_at TEXT NOT NULL,
33
+ data TEXT NOT NULL,
34
+ FOREIGN KEY (schedule_id) REFERENCES polpo_schedules_v2(id)
35
+ );
36
+
37
+ CREATE INDEX IF NOT EXISTS polpo_schedule_runs_v2_schedule_idx
38
+ ON polpo_schedule_runs_v2 (schedule_id, occurrence_at DESC);
39
+ CREATE INDEX IF NOT EXISTS polpo_schedule_runs_v2_status_idx
40
+ ON polpo_schedule_runs_v2 (status, occurrence_at);
41
+ CREATE INDEX IF NOT EXISTS polpo_schedule_runs_v2_lease_idx
42
+ ON polpo_schedule_runs_v2 (lease_expires_at)
43
+ WHERE lease_expires_at IS NOT NULL;
44
+ `;
45
+ /**
46
+ * Durable local ScheduleStore backed by SQLite.
47
+ *
48
+ * The optional native dependency is loaded only by `open()`, so importing the
49
+ * rest of @polpo-ai/node remains safe in environments without better-sqlite3.
50
+ */
51
+ export class SQLiteScheduleStore {
52
+ db;
53
+ nowProvider;
54
+ createId;
55
+ constructor(db, options) {
56
+ this.db = db;
57
+ this.nowProvider = options.now ?? (() => new Date());
58
+ this.createId = options.createId ?? ((kind) => `${kind}-${nanoid()}`);
59
+ }
60
+ static async open(path, options = {}) {
61
+ if (typeof path !== "string" || !path.trim()) {
62
+ throw new Error("SQLite schedule store path must be a non-empty string");
63
+ }
64
+ if (path !== ":memory:") {
65
+ await mkdir(dirname(path), { recursive: true });
66
+ }
67
+ let Database;
68
+ try {
69
+ Database = (await import("better-sqlite3")).default;
70
+ }
71
+ catch (error) {
72
+ throw new Error("SQLite schedules require the optional better-sqlite3 dependency", { cause: error });
73
+ }
74
+ const db = new Database(path);
75
+ db.pragma("foreign_keys = ON");
76
+ db.pragma("busy_timeout = 5000");
77
+ if (path !== ":memory:")
78
+ db.pragma("journal_mode = WAL");
79
+ db.exec(SCHEMA);
80
+ return new SQLiteScheduleStore(db, options);
81
+ }
82
+ async create(input) {
83
+ const now = this.now();
84
+ const normalized = normalizeCreateScheduleInput(input, { now });
85
+ const id = normalized.id ?? this.createId("schedule");
86
+ const schedule = {
87
+ id,
88
+ ...(normalized.name === undefined ? {} : { name: normalized.name }),
89
+ ...(normalized.description === undefined
90
+ ? {}
91
+ : { description: normalized.description }),
92
+ timing: normalized.timing,
93
+ invocation: normalized.invocation,
94
+ status: normalized.status,
95
+ policy: normalized.policy,
96
+ metadata: normalized.metadata,
97
+ createdAt: now.toISOString(),
98
+ updatedAt: now.toISOString(),
99
+ revision: 1,
100
+ };
101
+ try {
102
+ this.insertSchedule(schedule);
103
+ }
104
+ catch (error) {
105
+ if (isUniqueConstraintError(error)) {
106
+ throw new ScheduleConflictError(`Schedule "${id}" already exists`);
107
+ }
108
+ throw error;
109
+ }
110
+ return clone(schedule);
111
+ }
112
+ async list(filter = {}) {
113
+ const statuses = statusSet(filter.status);
114
+ return this.db.prepare("SELECT * FROM polpo_schedules_v2 ORDER BY created_at ASC, id ASC").all()
115
+ .map((row) => decodeSchedule(row))
116
+ .filter((schedule) => (filter.includeDeleted || schedule.status !== "deleted")
117
+ && (!statuses || statuses.has(schedule.status))
118
+ && (!filter.surface || schedule.invocation.surface === filter.surface))
119
+ .map(clone);
120
+ }
121
+ async get(id) {
122
+ const row = this.db.prepare("SELECT * FROM polpo_schedules_v2 WHERE id = ?").get(id);
123
+ return row ? clone(decodeSchedule(row)) : null;
124
+ }
125
+ async update(id, patch, options = {}) {
126
+ return this.db.transaction(() => {
127
+ const existing = this.requireSchedule(id);
128
+ assertExpectedRevision(existing, options.expectedRevision);
129
+ if (existing.status === "deleted") {
130
+ throw new ScheduleInvalidStateError(`Schedule "${id}" is deleted`);
131
+ }
132
+ const now = this.now();
133
+ const normalized = normalizeUpdateScheduleInput(patch, { now });
134
+ const status = normalized.status ?? existing.status;
135
+ try {
136
+ assertScheduleStatusTransition(existing.status, status);
137
+ }
138
+ catch (error) {
139
+ throw new ScheduleInvalidStateError(errorMessage(error));
140
+ }
141
+ const updated = {
142
+ ...existing,
143
+ ...(normalized.timing === undefined ? {} : { timing: normalized.timing }),
144
+ ...(normalized.invocation === undefined
145
+ ? {}
146
+ : { invocation: normalized.invocation }),
147
+ ...(normalized.status === undefined ? {} : { status: normalized.status }),
148
+ ...(normalized.policy === undefined
149
+ ? {}
150
+ : { policy: { ...existing.policy, ...normalized.policy } }),
151
+ ...(normalized.metadata === undefined
152
+ ? {}
153
+ : { metadata: normalized.metadata }),
154
+ revision: existing.revision + 1,
155
+ updatedAt: now.toISOString(),
156
+ };
157
+ applyNullableString(updated, "name", normalized.name);
158
+ applyNullableString(updated, "description", normalized.description);
159
+ const changes = this.db.prepare(`
160
+ UPDATE polpo_schedules_v2
161
+ SET status = ?, surface = ?, revision = ?, updated_at = ?, data = ?
162
+ WHERE id = ? AND revision = ?
163
+ `).run(updated.status, updated.invocation.surface, updated.revision, updated.updatedAt, JSON.stringify(updated), id, existing.revision).changes;
164
+ if (changes !== 1) {
165
+ throw new ScheduleConflictError(`Schedule "${id}" revision conflict`);
166
+ }
167
+ return clone(updated);
168
+ })();
169
+ }
170
+ async markDeleted(id, options = {}) {
171
+ this.db.transaction(() => {
172
+ const existing = this.requireSchedule(id);
173
+ assertExpectedRevision(existing, options.expectedRevision);
174
+ if (existing.status === "deleted")
175
+ return;
176
+ try {
177
+ assertScheduleStatusTransition(existing.status, "deleted");
178
+ }
179
+ catch (error) {
180
+ throw new ScheduleInvalidStateError(errorMessage(error));
181
+ }
182
+ const deleted = {
183
+ ...existing,
184
+ status: "deleted",
185
+ revision: existing.revision + 1,
186
+ updatedAt: this.now().toISOString(),
187
+ };
188
+ const changes = this.db.prepare(`
189
+ UPDATE polpo_schedules_v2
190
+ SET status = ?, revision = ?, updated_at = ?, data = ?
191
+ WHERE id = ? AND revision = ?
192
+ `).run(deleted.status, deleted.revision, deleted.updatedAt, JSON.stringify(deleted), id, existing.revision).changes;
193
+ if (changes !== 1) {
194
+ throw new ScheduleConflictError(`Schedule "${id}" revision conflict`);
195
+ }
196
+ })();
197
+ }
198
+ async updateOperationalState(id, patch, options = {}) {
199
+ return this.db.transaction(() => {
200
+ const existing = this.requireSchedule(id);
201
+ assertExpectedRevision(existing, options.expectedRevision);
202
+ const normalized = normalizeScheduleOperationalStatePatch(patch);
203
+ const updated = {
204
+ ...existing,
205
+ revision: existing.revision + 1,
206
+ updatedAt: this.now().toISOString(),
207
+ };
208
+ applyOperationalField(updated, "nextOccurrenceAt", normalized.nextOccurrenceAt);
209
+ applyOperationalField(updated, "lastOccurrenceAt", normalized.lastOccurrenceAt);
210
+ applyOperationalField(updated, "driver", normalized.driver);
211
+ const changes = this.db.prepare(`
212
+ UPDATE polpo_schedules_v2
213
+ SET revision = ?, updated_at = ?, data = ?
214
+ WHERE id = ? AND revision = ?
215
+ `).run(updated.revision, updated.updatedAt, JSON.stringify(updated), id, existing.revision).changes;
216
+ if (changes !== 1) {
217
+ throw new ScheduleConflictError(`Schedule "${id}" revision conflict`);
218
+ }
219
+ return clone(updated);
220
+ })();
221
+ }
222
+ async createRun(input) {
223
+ return this.db.transaction(() => {
224
+ const normalized = normalizeCreateScheduleRunInput(input);
225
+ const duplicate = this.getRunByIdempotencyKey(normalized.idempotencyKey);
226
+ if (duplicate) {
227
+ assertSameScheduleOccurrence(duplicate, normalized);
228
+ return clone(duplicate);
229
+ }
230
+ const schedule = this.requireSchedule(normalized.scheduleId);
231
+ if (schedule.status !== "active") {
232
+ throw new ScheduleInvalidStateError(`Schedule "${schedule.id}" is not active`);
233
+ }
234
+ const now = this.now().toISOString();
235
+ const run = {
236
+ id: normalized.id ?? this.createId("schedule-run"),
237
+ scheduleId: normalized.scheduleId,
238
+ occurrenceAt: normalized.occurrenceAt,
239
+ triggerId: normalized.triggerId,
240
+ idempotencyKey: normalized.idempotencyKey,
241
+ status: "pending",
242
+ attempts: 0,
243
+ references: {},
244
+ createdAt: now,
245
+ updatedAt: now,
246
+ };
247
+ try {
248
+ this.insertRun(run);
249
+ }
250
+ catch (error) {
251
+ if (!isUniqueConstraintError(error))
252
+ throw error;
253
+ const racedDuplicate = this.getRunByIdempotencyKey(run.idempotencyKey);
254
+ if (racedDuplicate) {
255
+ assertSameScheduleOccurrence(racedDuplicate, normalized);
256
+ return clone(racedDuplicate);
257
+ }
258
+ throw new ScheduleConflictError(`Schedule run "${run.id}" already exists`);
259
+ }
260
+ return clone(run);
261
+ })();
262
+ }
263
+ async getRun(id) {
264
+ const row = this.db.prepare("SELECT * FROM polpo_schedule_runs_v2 WHERE id = ?").get(id);
265
+ return row ? clone(decodeRun(row)) : null;
266
+ }
267
+ async listRuns(filter = {}) {
268
+ const statuses = runStatusSet(filter.status);
269
+ const limit = normalizeLimit(filter.limit);
270
+ const order = normalizeRunOrder(filter.order);
271
+ return this.db.prepare(`
272
+ SELECT * FROM polpo_schedule_runs_v2
273
+ ORDER BY occurrence_at ${order}, created_at ${order}, id ${order}
274
+ `).all()
275
+ .map((row) => decodeRun(row))
276
+ .filter((run) => (!filter.scheduleId || run.scheduleId === filter.scheduleId)
277
+ && (!statuses || statuses.has(run.status)))
278
+ .slice(0, limit)
279
+ .map(clone);
280
+ }
281
+ async claimRun(id, lease) {
282
+ return this.db.transaction(() => {
283
+ const now = this.now();
284
+ const normalizedLease = normalizeScheduleLease(lease, now);
285
+ const run = this.requireRun(id);
286
+ if (isTerminalScheduleRunStatus(run.status))
287
+ return null;
288
+ const schedule = this.requireSchedule(run.scheduleId);
289
+ if (schedule.status !== "active")
290
+ return null;
291
+ if ((run.status === "claimed" || run.status === "running")
292
+ && run.lease
293
+ && Date.parse(run.lease.expiresAt) > now.getTime()) {
294
+ return null;
295
+ }
296
+ if (!["pending", "claimed", "running"].includes(run.status))
297
+ return null;
298
+ try {
299
+ assertScheduleRunStatusTransition(run.status, "claimed");
300
+ }
301
+ catch (error) {
302
+ throw new ScheduleInvalidStateError(errorMessage(error));
303
+ }
304
+ const claimed = {
305
+ ...run,
306
+ status: "claimed",
307
+ attempts: run.attempts + 1,
308
+ lease: normalizedLease,
309
+ updatedAt: now.toISOString(),
310
+ };
311
+ if (!this.casClaimRun(run, claimed, schedule.policy.maxConcurrency, now))
312
+ return null;
313
+ return clone(claimed);
314
+ })();
315
+ }
316
+ async renewLease(id, lease) {
317
+ return this.db.transaction(() => {
318
+ const now = this.now();
319
+ const normalizedLease = normalizeScheduleLease(lease, now);
320
+ const run = this.requireRun(id);
321
+ if (!activeRun(run)
322
+ || !run.lease
323
+ || !leasesMatch(run.lease, normalizedLease)
324
+ || Date.parse(run.lease.expiresAt) <= now.getTime()
325
+ || Date.parse(normalizedLease.expiresAt) < Date.parse(run.lease.expiresAt)) {
326
+ return false;
327
+ }
328
+ const renewed = {
329
+ ...run,
330
+ lease: normalizedLease,
331
+ updatedAt: now.toISOString(),
332
+ };
333
+ return this.casRun(run, renewed);
334
+ })();
335
+ }
336
+ async startRun(id, lease) {
337
+ return this.db.transaction(() => {
338
+ const now = this.now();
339
+ const normalizedLease = normalizeScheduleLease(lease, now);
340
+ const run = this.requireOwnedRun(id, normalizedLease, now);
341
+ const schedule = this.requireSchedule(run.scheduleId);
342
+ if (schedule.status !== "active") {
343
+ throw new ScheduleInvalidStateError(`Schedule "${schedule.id}" is not active`);
344
+ }
345
+ if (run.status !== "claimed") {
346
+ throw new ScheduleInvalidStateError(`Schedule run "${id}" must be claimed before it can start`);
347
+ }
348
+ const started = {
349
+ ...run,
350
+ status: "running",
351
+ lease: normalizedLease,
352
+ startedAt: run.startedAt ?? now.toISOString(),
353
+ updatedAt: now.toISOString(),
354
+ };
355
+ if (!this.casRun(run, started)) {
356
+ throw new ScheduleConflictError(`Schedule run "${id}" changed while starting`);
357
+ }
358
+ return clone(started);
359
+ })();
360
+ }
361
+ async releaseRun(id, lease) {
362
+ return this.db.transaction(() => {
363
+ const now = this.now();
364
+ const normalizedLease = normalizeScheduleLease(lease, now);
365
+ const run = this.requireOwnedRun(id, normalizedLease, now);
366
+ const released = {
367
+ ...run,
368
+ status: "pending",
369
+ updatedAt: now.toISOString(),
370
+ };
371
+ delete released.lease;
372
+ if (!this.casRun(run, released)) {
373
+ throw new ScheduleConflictError(`Schedule run "${id}" changed while releasing`);
374
+ }
375
+ return clone(released);
376
+ })();
377
+ }
378
+ async completeRun(id, input) {
379
+ return this.db.transaction(() => {
380
+ const now = this.now();
381
+ const completion = normalizeCompleteScheduleRunInput(input, now);
382
+ const run = this.requireRun(id);
383
+ if (isTerminalScheduleRunStatus(run.status)) {
384
+ throw new ScheduleConflictError(`Schedule run "${id}" is already terminal`);
385
+ }
386
+ const owned = this.requireOwnedRun(id, completion.lease, now);
387
+ try {
388
+ assertScheduleRunStatusTransition(owned.status, completion.status);
389
+ }
390
+ catch (error) {
391
+ throw new ScheduleInvalidStateError(errorMessage(error));
392
+ }
393
+ const completed = {
394
+ ...owned,
395
+ status: completion.status,
396
+ references: completion.references,
397
+ ...(completion.result === undefined ? {} : { result: completion.result }),
398
+ ...(completion.error === undefined ? {} : { error: completion.error }),
399
+ completedAt: now.toISOString(),
400
+ updatedAt: now.toISOString(),
401
+ };
402
+ delete completed.lease;
403
+ if (!this.casRun(owned, completed)) {
404
+ throw new ScheduleConflictError(`Schedule run "${id}" changed while completing`);
405
+ }
406
+ return clone(completed);
407
+ })();
408
+ }
409
+ async countActiveRuns(scheduleId) {
410
+ const row = this.db.prepare(`
411
+ SELECT COUNT(*) AS count
412
+ FROM polpo_schedule_runs_v2
413
+ WHERE schedule_id = ?
414
+ AND status IN ('claimed', 'running')
415
+ AND lease_expires_at > ?
416
+ `).get(scheduleId, this.now().toISOString());
417
+ return Number(row.count);
418
+ }
419
+ close() {
420
+ this.db.close();
421
+ }
422
+ insertSchedule(schedule) {
423
+ this.db.prepare(`
424
+ INSERT INTO polpo_schedules_v2 (
425
+ id, status, surface, revision, created_at, updated_at, data
426
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
427
+ `).run(schedule.id, schedule.status, schedule.invocation.surface, schedule.revision, schedule.createdAt, schedule.updatedAt, JSON.stringify(schedule));
428
+ }
429
+ insertRun(run) {
430
+ this.db.prepare(`
431
+ INSERT INTO polpo_schedule_runs_v2 (
432
+ id, schedule_id, idempotency_key, status, attempts, occurrence_at,
433
+ lease_owner, lease_token, lease_expires_at, created_at, updated_at, data
434
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
435
+ `).run(run.id, run.scheduleId, run.idempotencyKey, run.status, run.attempts, run.occurrenceAt, run.lease?.owner ?? null, run.lease?.token ?? null, run.lease?.expiresAt ?? null, run.createdAt, run.updatedAt, JSON.stringify(run));
436
+ }
437
+ casRun(previous, next) {
438
+ const changes = this.db.prepare(`
439
+ UPDATE polpo_schedule_runs_v2
440
+ SET status = ?, attempts = ?, lease_owner = ?, lease_token = ?,
441
+ lease_expires_at = ?, updated_at = ?, data = ?
442
+ WHERE id = ? AND status = ? AND attempts = ? AND updated_at = ?
443
+ AND COALESCE(lease_owner, '') = ?
444
+ AND COALESCE(lease_token, '') = ?
445
+ AND COALESCE(lease_expires_at, '') = ?
446
+ `).run(next.status, next.attempts, next.lease?.owner ?? null, next.lease?.token ?? null, next.lease?.expiresAt ?? null, next.updatedAt, JSON.stringify(next), previous.id, previous.status, previous.attempts, previous.updatedAt, previous.lease?.owner ?? "", previous.lease?.token ?? "", previous.lease?.expiresAt ?? "").changes;
447
+ return changes === 1;
448
+ }
449
+ casClaimRun(previous, next, maxConcurrency, now) {
450
+ const changes = this.db.prepare(`
451
+ UPDATE polpo_schedule_runs_v2
452
+ SET status = ?, attempts = ?, lease_owner = ?, lease_token = ?,
453
+ lease_expires_at = ?, updated_at = ?, data = ?
454
+ WHERE id = ? AND status = ? AND attempts = ? AND updated_at = ?
455
+ AND COALESCE(lease_owner, '') = ?
456
+ AND COALESCE(lease_token, '') = ?
457
+ AND COALESCE(lease_expires_at, '') = ?
458
+ AND (
459
+ SELECT COUNT(*)
460
+ FROM polpo_schedule_runs_v2 AS active
461
+ WHERE active.schedule_id = ?
462
+ AND active.id <> ?
463
+ AND active.status IN ('claimed', 'running')
464
+ AND active.lease_expires_at > ?
465
+ ) < ?
466
+ `).run(next.status, next.attempts, next.lease?.owner ?? null, next.lease?.token ?? null, next.lease?.expiresAt ?? null, next.updatedAt, JSON.stringify(next), previous.id, previous.status, previous.attempts, previous.updatedAt, previous.lease?.owner ?? "", previous.lease?.token ?? "", previous.lease?.expiresAt ?? "", previous.scheduleId, previous.id, now.toISOString(), maxConcurrency).changes;
467
+ return changes === 1;
468
+ }
469
+ requireSchedule(id) {
470
+ const row = this.db.prepare("SELECT * FROM polpo_schedules_v2 WHERE id = ?").get(id);
471
+ if (!row)
472
+ throw new ScheduleNotFoundError("Schedule", id);
473
+ return decodeSchedule(row);
474
+ }
475
+ requireRun(id) {
476
+ const row = this.db.prepare("SELECT * FROM polpo_schedule_runs_v2 WHERE id = ?").get(id);
477
+ if (!row)
478
+ throw new ScheduleNotFoundError("Schedule run", id);
479
+ return decodeRun(row);
480
+ }
481
+ getRunByIdempotencyKey(key) {
482
+ const row = this.db.prepare("SELECT * FROM polpo_schedule_runs_v2 WHERE idempotency_key = ?").get(key);
483
+ return row ? decodeRun(row) : null;
484
+ }
485
+ requireOwnedRun(id, lease, now) {
486
+ const run = this.requireRun(id);
487
+ if (!activeRun(run) || !run.lease || !leasesMatch(run.lease, lease)) {
488
+ throw new ScheduleConflictError(`Schedule run "${id}" is not owned by this lease`);
489
+ }
490
+ if (Date.parse(run.lease.expiresAt) <= now.getTime()) {
491
+ throw new ScheduleConflictError(`Schedule run "${id}" lease has expired`);
492
+ }
493
+ return run;
494
+ }
495
+ now() {
496
+ const now = new Date(this.nowProvider());
497
+ if (!Number.isFinite(now.getTime())) {
498
+ throw new ScheduleInvalidStateError("SQLite schedule store clock returned an invalid date");
499
+ }
500
+ return now;
501
+ }
502
+ }
503
+ function decodeSchedule(row) {
504
+ const schedule = parseRecord(row.data, `schedule "${row.id}"`);
505
+ if (schedule.id !== row.id
506
+ || schedule.status !== row.status
507
+ || schedule.invocation?.surface !== row.surface
508
+ || schedule.revision !== row.revision
509
+ || schedule.createdAt !== row.created_at
510
+ || schedule.updatedAt !== row.updated_at) {
511
+ throw new ScheduleInvalidStateError(`Persisted schedule "${row.id}" does not match its index columns`);
512
+ }
513
+ return schedule;
514
+ }
515
+ function decodeRun(row) {
516
+ const run = parseRecord(row.data, `schedule run "${row.id}"`);
517
+ if (run.id !== row.id
518
+ || run.scheduleId !== row.schedule_id
519
+ || run.idempotencyKey !== row.idempotency_key
520
+ || run.status !== row.status
521
+ || run.attempts !== row.attempts
522
+ || run.occurrenceAt !== row.occurrence_at
523
+ || run.createdAt !== row.created_at
524
+ || run.updatedAt !== row.updated_at
525
+ || (run.lease?.owner ?? null) !== row.lease_owner
526
+ || (run.lease?.token ?? null) !== row.lease_token
527
+ || (run.lease?.expiresAt ?? null) !== row.lease_expires_at) {
528
+ throw new ScheduleInvalidStateError(`Persisted schedule run "${row.id}" does not match its index columns`);
529
+ }
530
+ return run;
531
+ }
532
+ function parseRecord(raw, label) {
533
+ try {
534
+ const parsed = JSON.parse(raw);
535
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
536
+ throw new Error("expected an object");
537
+ }
538
+ return parsed;
539
+ }
540
+ catch (error) {
541
+ throw new ScheduleInvalidStateError(`Failed to decode persisted ${label}: ${errorMessage(error)}`);
542
+ }
543
+ }
544
+ function assertExpectedRevision(schedule, expected) {
545
+ if (expected === undefined)
546
+ return;
547
+ if (!Number.isInteger(expected) || expected < 1) {
548
+ throw new Error("Schedule expectedRevision must be a positive integer");
549
+ }
550
+ if (schedule.revision !== expected) {
551
+ throw new ScheduleConflictError(`Schedule "${schedule.id}" revision conflict: expected ${expected}, found ${schedule.revision}`);
552
+ }
553
+ }
554
+ function applyNullableString(schedule, key, value) {
555
+ if (value === undefined)
556
+ return;
557
+ if (value === null)
558
+ delete schedule[key];
559
+ else
560
+ schedule[key] = value;
561
+ }
562
+ function applyOperationalField(schedule, key, value) {
563
+ if (value === undefined)
564
+ return;
565
+ if (value === null)
566
+ delete schedule[key];
567
+ else
568
+ schedule[key] = value;
569
+ }
570
+ function statusSet(status) {
571
+ if (status === undefined)
572
+ return undefined;
573
+ return new Set(Array.isArray(status) ? status : [status]);
574
+ }
575
+ function runStatusSet(status) {
576
+ if (status === undefined)
577
+ return undefined;
578
+ return new Set(Array.isArray(status) ? status : [status]);
579
+ }
580
+ function normalizeLimit(limit) {
581
+ if (limit === undefined)
582
+ return Number.POSITIVE_INFINITY;
583
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1_000) {
584
+ throw new Error("Schedule run list limit must be an integer between 1 and 1000");
585
+ }
586
+ return limit;
587
+ }
588
+ function normalizeRunOrder(order) {
589
+ if (order === undefined || order === "desc")
590
+ return "DESC";
591
+ if (order === "asc")
592
+ return "ASC";
593
+ throw new Error('Schedule run order must be "asc" or "desc"');
594
+ }
595
+ function activeRun(run) {
596
+ return run.status === "claimed" || run.status === "running";
597
+ }
598
+ function leasesMatch(a, b) {
599
+ return a.owner === b.owner && a.token === b.token;
600
+ }
601
+ function isUniqueConstraintError(error) {
602
+ return error instanceof Error
603
+ && /UNIQUE constraint failed|PRIMARY KEY constraint failed/i.test(error.message);
604
+ }
605
+ function clone(value) {
606
+ return structuredClone(value);
607
+ }
608
+ function errorMessage(error) {
609
+ return error instanceof Error ? error.message : String(error);
610
+ }
611
+ //# sourceMappingURL=sqlite-schedule-store.js.map