@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/scheduling/index.d.ts +4 -0
- package/dist/scheduling/index.d.ts.map +1 -1
- package/dist/scheduling/index.js +3 -0
- package/dist/scheduling/index.js.map +1 -1
- package/dist/scheduling/local-schedule-driver.d.ts +84 -0
- package/dist/scheduling/local-schedule-driver.d.ts.map +1 -0
- package/dist/scheduling/local-schedule-driver.js +412 -0
- package/dist/scheduling/local-schedule-driver.js.map +1 -0
- package/dist/scheduling/schedule-dispatcher.d.ts +7 -0
- package/dist/scheduling/schedule-dispatcher.d.ts.map +1 -0
- package/dist/scheduling/schedule-dispatcher.js +10 -0
- package/dist/scheduling/schedule-dispatcher.js.map +1 -0
- package/dist/scheduling/sqlite-schedule-store.d.ts +45 -0
- package/dist/scheduling/sqlite-schedule-store.d.ts.map +1 -0
- package/dist/scheduling/sqlite-schedule-store.js +611 -0
- package/dist/scheduling/sqlite-schedule-store.js.map +1 -0
- package/dist/server/deps.d.ts +5 -3
- package/dist/server/deps.d.ts.map +1 -1
- package/package.json +12 -8
|
@@ -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
|