@nimbus-sh/loom 0.1.0
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/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/actor.d.ts +222 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +474 -0
- package/dist/callable.d.ts +45 -0
- package/dist/callable.d.ts.map +1 -0
- package/dist/callable.js +60 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +98 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/protocol.d.ts +63 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +45 -0
- package/dist/routing.d.ts +13 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +12 -0
- package/dist/rpc.d.ts +47 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +102 -0
- package/dist/schedules.d.ts +150 -0
- package/dist/schedules.d.ts.map +1 -0
- package/dist/schedules.js +276 -0
- package/package.json +66 -0
- package/src/actor.ts +638 -0
- package/src/callable.ts +76 -0
- package/src/client.ts +153 -0
- package/src/index.ts +19 -0
- package/src/protocol.ts +84 -0
- package/src/routing.ts +19 -0
- package/src/rpc.ts +110 -0
- package/src/schedules.ts +356 -0
package/src/schedules.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schedules.ts — named-method schedules persisted in the actor's own SQLite,
|
|
3
|
+
* dispatched from ONE reason in fabric's timer map.
|
|
4
|
+
*
|
|
5
|
+
* The API shape follows the Agents SDK (`agents` 0.20.1, verified in its
|
|
6
|
+
* shipped dist): `when` is a delay in seconds, a Date, or a cron string;
|
|
7
|
+
* recurrence is either `cron` (recomputed per fire) or `interval` (fixed
|
|
8
|
+
* seconds); rows carry the callback METHOD NAME and a JSON payload, and
|
|
9
|
+
* dispatch resolves the method on the target at fire time. Where the two
|
|
10
|
+
* differ, fabric's mechanism is the reason:
|
|
11
|
+
*
|
|
12
|
+
* - RETRIES ARE DURABLE. The Agents SDK retries a failed callback
|
|
13
|
+
* in-process, sleeping inside the alarm turn (dist `retries.js`, "Full
|
|
14
|
+
* Jitter"); an instance reset mid-backoff loses the remaining attempts.
|
|
15
|
+
* Here a failed attempt writes the backed-off deadline into the row and
|
|
16
|
+
* the alarm re-fires on it — the attempt budget survives the reset.
|
|
17
|
+
* Backoff is `min(maxDelayMs, baseDelayMs * 2**(attempt-1))`, no jitter:
|
|
18
|
+
* the contended resource jitter exists for is a shared endpoint, and one
|
|
19
|
+
* actor's own alarm is not shared.
|
|
20
|
+
* - `alarmInfo` REACHES THE CALLBACK. The platform reports alarm retries
|
|
21
|
+
* (`isRetry`, `retryCount`); the Agents SDK drops the report (zero
|
|
22
|
+
* references in its dist). Fabric's dispatcher forwards it, and every
|
|
23
|
+
* schedule callback receives it in its invocation argument.
|
|
24
|
+
* - NO INTERVAL MUTEX. The Agents SDK guards overlapping interval runs
|
|
25
|
+
* with a `running` flag and a hung-run timeout. Fabric's timer chain
|
|
26
|
+
* already serializes dispatches on the instance, so overlap cannot
|
|
27
|
+
* happen and the flag would be dead state.
|
|
28
|
+
*
|
|
29
|
+
* Times are epoch MILLISECONDS everywhere, matching fabric's timer map (the
|
|
30
|
+
* Agents SDK stores unix seconds).
|
|
31
|
+
*
|
|
32
|
+
* This module is deliberately partyserver-free: it owns rows and dispatch,
|
|
33
|
+
* and the Actor owns the timer-reason wiring around it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { parseCronExpression } from 'cron-schedule';
|
|
37
|
+
import { z } from 'zod/v4';
|
|
38
|
+
import type { TimerAlarmInfo } from '@nimbus-sh/fabric/timers.js';
|
|
39
|
+
|
|
40
|
+
/** Synchronous DO SQLite, as the store uses it. */
|
|
41
|
+
export interface ScheduleSqlExec {
|
|
42
|
+
exec(query: string, ...bindings: Array<string | number | null>): Iterable<unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ScheduleContext {
|
|
46
|
+
storage: { sql: ScheduleSqlExec };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Attempts and backoff for one schedule's callback. Defaults match the
|
|
51
|
+
* Agents SDK's (`maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 3000`,
|
|
52
|
+
* dist `index.js:303-308`).
|
|
53
|
+
*/
|
|
54
|
+
export interface ScheduleRetryPolicy {
|
|
55
|
+
maxAttempts?: number;
|
|
56
|
+
baseDelayMs?: number;
|
|
57
|
+
maxDelayMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const SCHEDULE_RETRY_DEFAULTS: Required<ScheduleRetryPolicy> = {
|
|
61
|
+
maxAttempts: 3,
|
|
62
|
+
baseDelayMs: 100,
|
|
63
|
+
maxDelayMs: 3000,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface ScheduleOptions {
|
|
67
|
+
retry?: ScheduleRetryPolicy;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One schedule, as `getScheduleById`/`listSchedules` return it. */
|
|
71
|
+
export type Schedule<T = unknown> = {
|
|
72
|
+
id: string;
|
|
73
|
+
/** The method name dispatch resolves on the actor at fire time. */
|
|
74
|
+
callback: string;
|
|
75
|
+
payload: T;
|
|
76
|
+
retry?: ScheduleRetryPolicy;
|
|
77
|
+
} & (
|
|
78
|
+
| { type: 'scheduled'; time: number }
|
|
79
|
+
| { type: 'delayed'; time: number; delayInSeconds: number }
|
|
80
|
+
| { type: 'cron'; time: number; cron: string }
|
|
81
|
+
| { type: 'interval'; time: number; intervalSeconds: number }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
export type ScheduleType = Schedule['type'];
|
|
85
|
+
|
|
86
|
+
export interface ScheduleCriteria {
|
|
87
|
+
id?: string;
|
|
88
|
+
type?: ScheduleType;
|
|
89
|
+
/** Bounds on the next fire time. */
|
|
90
|
+
timeRange?: { start?: Date; end?: Date };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The second argument every schedule callback receives. */
|
|
94
|
+
export interface ScheduleInvocation<T = unknown> {
|
|
95
|
+
schedule: Schedule<T>;
|
|
96
|
+
/** 1 on the first try; grows with the durable retry rows. */
|
|
97
|
+
attempt: number;
|
|
98
|
+
/** The platform's alarm-invocation report, when the platform gave one. */
|
|
99
|
+
alarmInfo?: TimerAlarmInfo;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ScheduleDispatchResult {
|
|
103
|
+
ran: number;
|
|
104
|
+
/** The earliest remaining deadline, for the timer handler's re-arm. */
|
|
105
|
+
rearmAt: number | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const RowSchema = z.object({
|
|
109
|
+
id: z.string(),
|
|
110
|
+
callback: z.string(),
|
|
111
|
+
payload: z.string().nullable(),
|
|
112
|
+
type: z.enum(['scheduled', 'delayed', 'cron', 'interval']),
|
|
113
|
+
time: z.number(),
|
|
114
|
+
delay_s: z.number().nullable(),
|
|
115
|
+
cron: z.string().nullable(),
|
|
116
|
+
interval_s: z.number().nullable(),
|
|
117
|
+
attempt: z.number(),
|
|
118
|
+
retry: z.string().nullable(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
type Row = z.infer<typeof RowSchema>;
|
|
122
|
+
|
|
123
|
+
export class ScheduleStore {
|
|
124
|
+
private schemaReady = false;
|
|
125
|
+
|
|
126
|
+
constructor(private readonly ctx: ScheduleContext) {}
|
|
127
|
+
|
|
128
|
+
private ensureSchema(): void {
|
|
129
|
+
if (this.schemaReady) return;
|
|
130
|
+
const sql = this.ctx.storage.sql;
|
|
131
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS loom_schedules (
|
|
132
|
+
id TEXT PRIMARY KEY,
|
|
133
|
+
callback TEXT NOT NULL,
|
|
134
|
+
payload TEXT,
|
|
135
|
+
type TEXT NOT NULL CHECK (type IN ('scheduled', 'delayed', 'cron', 'interval')),
|
|
136
|
+
time INTEGER NOT NULL,
|
|
137
|
+
delay_s INTEGER,
|
|
138
|
+
cron TEXT,
|
|
139
|
+
interval_s INTEGER,
|
|
140
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
141
|
+
retry TEXT,
|
|
142
|
+
created_at INTEGER NOT NULL
|
|
143
|
+
)`);
|
|
144
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_loom_schedules_time ON loom_schedules (time)`);
|
|
145
|
+
this.schemaReady = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Persist one schedule. `when` selects the type: a non-negative number is
|
|
150
|
+
* a delay in seconds, a Date is an absolute fire time, a string is a cron
|
|
151
|
+
* expression (validated here; an invalid one throws now, not at the
|
|
152
|
+
* alarm).
|
|
153
|
+
*/
|
|
154
|
+
create<T>(when: number | Date | string, callback: string, payload?: T, options: ScheduleOptions & { now?: number } = {}): Schedule<T> {
|
|
155
|
+
if (typeof callback !== 'string' || callback === '') {
|
|
156
|
+
throw new Error('loom: a schedule callback must be a method name');
|
|
157
|
+
}
|
|
158
|
+
const now = options.now ?? Date.now();
|
|
159
|
+
let partial: Pick<Row, 'type' | 'time' | 'delay_s' | 'cron'>;
|
|
160
|
+
if (when instanceof Date) {
|
|
161
|
+
partial = { type: 'scheduled', time: when.getTime(), delay_s: null, cron: null };
|
|
162
|
+
} else if (typeof when === 'number') {
|
|
163
|
+
if (!Number.isFinite(when) || when < 0) {
|
|
164
|
+
throw new Error(`loom: a schedule delay must be a non-negative number of seconds, got ${when}`);
|
|
165
|
+
}
|
|
166
|
+
partial = { type: 'delayed', time: now + when * 1000, delay_s: when, cron: null };
|
|
167
|
+
} else if (typeof when === 'string') {
|
|
168
|
+
const next = parseCronExpression(when).getNextDate(new Date(now)).getTime();
|
|
169
|
+
partial = { type: 'cron', time: next, delay_s: null, cron: when };
|
|
170
|
+
} else {
|
|
171
|
+
throw new Error(`loom: invalid schedule time ${JSON.stringify(when)} for callback '${callback}'`);
|
|
172
|
+
}
|
|
173
|
+
return this.insert(callback, payload, { ...partial, interval_s: null }, options.retry, now);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Persist a fixed-interval schedule. First fire is one interval from now. */
|
|
177
|
+
every<T>(intervalSeconds: number, callback: string, payload?: T, options: ScheduleOptions & { now?: number } = {}): Schedule<T> {
|
|
178
|
+
if (typeof callback !== 'string' || callback === '') {
|
|
179
|
+
throw new Error('loom: a schedule callback must be a method name');
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
182
|
+
throw new Error(`loom: a schedule interval must be a positive number of seconds, got ${intervalSeconds}`);
|
|
183
|
+
}
|
|
184
|
+
const now = options.now ?? Date.now();
|
|
185
|
+
return this.insert(
|
|
186
|
+
callback,
|
|
187
|
+
payload,
|
|
188
|
+
{ type: 'interval', time: now + intervalSeconds * 1000, delay_s: null, cron: null, interval_s: intervalSeconds },
|
|
189
|
+
options.retry,
|
|
190
|
+
now,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private insert<T>(
|
|
195
|
+
callback: string,
|
|
196
|
+
payload: T | undefined,
|
|
197
|
+
partial: Pick<Row, 'type' | 'time' | 'delay_s' | 'cron' | 'interval_s'>,
|
|
198
|
+
retry: ScheduleRetryPolicy | undefined,
|
|
199
|
+
now: number,
|
|
200
|
+
): Schedule<T> {
|
|
201
|
+
this.ensureSchema();
|
|
202
|
+
const id = crypto.randomUUID();
|
|
203
|
+
const payloadJson = payload === undefined ? null : JSON.stringify(payload);
|
|
204
|
+
this.ctx.storage.sql.exec(
|
|
205
|
+
`INSERT INTO loom_schedules (id, callback, payload, type, time, delay_s, cron, interval_s, attempt, retry, created_at)
|
|
206
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
|
207
|
+
id, callback, payloadJson, partial.type, partial.time, partial.delay_s, partial.cron, partial.interval_s,
|
|
208
|
+
retry === undefined ? null : JSON.stringify(retry), now,
|
|
209
|
+
);
|
|
210
|
+
return toSchedule<T>({ id, callback, payload: payloadJson, attempt: 0, retry: retry === undefined ? null : JSON.stringify(retry), ...partial });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
byId<T = unknown>(id: string): Schedule<T> | undefined {
|
|
214
|
+
this.ensureSchema();
|
|
215
|
+
const rows = [...this.ctx.storage.sql.exec(`SELECT * FROM loom_schedules WHERE id = ?`, id)];
|
|
216
|
+
return rows.length > 0 ? toSchedule<T>(RowSchema.parse(rows[0])) : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
list<T = unknown>(criteria: ScheduleCriteria = {}): Array<Schedule<T>> {
|
|
220
|
+
this.ensureSchema();
|
|
221
|
+
const where: string[] = [];
|
|
222
|
+
const bindings: Array<string | number> = [];
|
|
223
|
+
if (criteria.id !== undefined) { where.push('id = ?'); bindings.push(criteria.id); }
|
|
224
|
+
if (criteria.type !== undefined) { where.push('type = ?'); bindings.push(criteria.type); }
|
|
225
|
+
if (criteria.timeRange?.start !== undefined) { where.push('time >= ?'); bindings.push(criteria.timeRange.start.getTime()); }
|
|
226
|
+
if (criteria.timeRange?.end !== undefined) { where.push('time <= ?'); bindings.push(criteria.timeRange.end.getTime()); }
|
|
227
|
+
const rows = [...this.ctx.storage.sql.exec(
|
|
228
|
+
`SELECT * FROM loom_schedules${where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY time, id`,
|
|
229
|
+
...bindings,
|
|
230
|
+
)];
|
|
231
|
+
return rows.map((row) => toSchedule<T>(RowSchema.parse(row)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** True when the id existed and is now gone. */
|
|
235
|
+
cancel(id: string): boolean {
|
|
236
|
+
this.ensureSchema();
|
|
237
|
+
const existing = [...this.ctx.storage.sql.exec(`SELECT 1 AS present FROM loom_schedules WHERE id = ?`, id)];
|
|
238
|
+
if (existing.length === 0) return false;
|
|
239
|
+
this.ctx.storage.sql.exec(`DELETE FROM loom_schedules WHERE id = ?`, id);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The earliest pending deadline, or null when nothing is scheduled. */
|
|
244
|
+
nextDue(): number | null {
|
|
245
|
+
this.ensureSchema();
|
|
246
|
+
const rows = [...this.ctx.storage.sql.exec(`SELECT MIN(time) AS next FROM loom_schedules`)] as Array<{ next: number | null }>;
|
|
247
|
+
return rows[0]?.next ?? null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fire every due schedule against the target, in deadline order.
|
|
252
|
+
*
|
|
253
|
+
* Each callback is `target[callback](payload, invocation)`, awaited in
|
|
254
|
+
* place. The due set is snapshotted up front, and every row is re-read
|
|
255
|
+
* before it fires — a callback that cancels or reschedules a SIBLING due
|
|
256
|
+
* row must win over the snapshot, or `cancel()`'s true would be a lie.
|
|
257
|
+
* Success advances the row: a cron recomputes from its expression, an
|
|
258
|
+
* interval re-arms `intervalSeconds` from this dispatch, a one-shot row
|
|
259
|
+
* is deleted. Failure spends one attempt of the row's retry policy: with
|
|
260
|
+
* budget left, the row's deadline moves to the backed-off time and the
|
|
261
|
+
* row survives; with the budget spent — or on a failure no retry can fix
|
|
262
|
+
* (the callback is not a method on the target; the stored payload does
|
|
263
|
+
* not parse) — `onError` hears about it and the row advances as if it
|
|
264
|
+
* had run.
|
|
265
|
+
*/
|
|
266
|
+
async dispatchDue(
|
|
267
|
+
target: object,
|
|
268
|
+
now: number,
|
|
269
|
+
alarmInfo?: TimerAlarmInfo,
|
|
270
|
+
onError?: (schedule: Schedule, error: unknown) => void,
|
|
271
|
+
): Promise<ScheduleDispatchResult> {
|
|
272
|
+
this.ensureSchema();
|
|
273
|
+
const sql = this.ctx.storage.sql;
|
|
274
|
+
const due = [...sql.exec(
|
|
275
|
+
`SELECT id FROM loom_schedules WHERE time <= ? ORDER BY time, id`, now,
|
|
276
|
+
)] as Array<{ id: string }>;
|
|
277
|
+
let ran = 0;
|
|
278
|
+
for (const { id } of due) {
|
|
279
|
+
// Re-read: an earlier callback in this dispatch may have cancelled
|
|
280
|
+
// this row (gone) or rescheduled it (deadline moved past `now`).
|
|
281
|
+
const current = [...sql.exec(`SELECT * FROM loom_schedules WHERE id = ?`, id)];
|
|
282
|
+
if (current.length === 0) continue;
|
|
283
|
+
const row = RowSchema.parse(current[0]);
|
|
284
|
+
if (row.time > now) continue;
|
|
285
|
+
ran++;
|
|
286
|
+
const schedule = toSchedule(row);
|
|
287
|
+
const attempt = row.attempt + 1;
|
|
288
|
+
const method = (target as Record<string, unknown>)[row.callback];
|
|
289
|
+
const callable = typeof method === 'function';
|
|
290
|
+
let retryable = false;
|
|
291
|
+
try {
|
|
292
|
+
if (!callable) throw new Error(`loom: schedule callback 'this.${row.callback}' is not a method on this actor`);
|
|
293
|
+
const payload = parsePayload(row);
|
|
294
|
+
retryable = true;
|
|
295
|
+
const invocation: ScheduleInvocation = { schedule, attempt, alarmInfo };
|
|
296
|
+
await (method as (payload: unknown, invocation: ScheduleInvocation) => unknown).call(target, payload, invocation);
|
|
297
|
+
this.advance(row, now);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
const policy = { ...SCHEDULE_RETRY_DEFAULTS, ...(schedule.retry ?? {}) };
|
|
300
|
+
if (retryable && attempt < policy.maxAttempts) {
|
|
301
|
+
const delay = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
|
|
302
|
+
sql.exec(`UPDATE loom_schedules SET attempt = ?, time = ? WHERE id = ?`, attempt, now + delay, row.id);
|
|
303
|
+
} else {
|
|
304
|
+
onError?.(schedule, error);
|
|
305
|
+
this.advance(row, now);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { ran, rearmAt: this.nextDue() };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Move a row past a completed (or abandoned) fire. */
|
|
313
|
+
private advance(row: Row, now: number): void {
|
|
314
|
+
const sql = this.ctx.storage.sql;
|
|
315
|
+
if (row.type === 'cron' && row.cron !== null) {
|
|
316
|
+
const next = parseCronExpression(row.cron).getNextDate(new Date(now)).getTime();
|
|
317
|
+
sql.exec(`UPDATE loom_schedules SET time = ?, attempt = 0 WHERE id = ?`, next, row.id);
|
|
318
|
+
} else if (row.type === 'interval' && row.interval_s !== null) {
|
|
319
|
+
sql.exec(`UPDATE loom_schedules SET time = ?, attempt = 0 WHERE id = ?`, now + row.interval_s * 1000, row.id);
|
|
320
|
+
} else {
|
|
321
|
+
sql.exec(`DELETE FROM loom_schedules WHERE id = ?`, row.id);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The stored payload. Throws for a row whose payload no longer parses —
|
|
328
|
+
* that row is poison (only a corrupt write can produce it), and dispatch
|
|
329
|
+
* refuses it as non-retryable instead of burning the retry budget on
|
|
330
|
+
* identical failures.
|
|
331
|
+
*/
|
|
332
|
+
function parsePayload(row: Row): unknown {
|
|
333
|
+
return row.payload === null ? undefined : JSON.parse(row.payload);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function toSchedule<T>(row: Row): Schedule<T> {
|
|
337
|
+
let payload: T;
|
|
338
|
+
try {
|
|
339
|
+
payload = parsePayload(row) as T;
|
|
340
|
+
} catch {
|
|
341
|
+
// Poison-parse row: reads stay usable; dispatch names the failure.
|
|
342
|
+
payload = undefined as T;
|
|
343
|
+
}
|
|
344
|
+
const retry = row.retry === null ? undefined : (JSON.parse(row.retry) as ScheduleRetryPolicy);
|
|
345
|
+
const base = { id: row.id, callback: row.callback, payload, ...(retry !== undefined ? { retry } : {}) };
|
|
346
|
+
switch (row.type) {
|
|
347
|
+
case 'scheduled':
|
|
348
|
+
return { ...base, type: 'scheduled', time: row.time };
|
|
349
|
+
case 'delayed':
|
|
350
|
+
return { ...base, type: 'delayed', time: row.time, delayInSeconds: row.delay_s ?? 0 };
|
|
351
|
+
case 'cron':
|
|
352
|
+
return { ...base, type: 'cron', time: row.time, cron: row.cron ?? '' };
|
|
353
|
+
case 'interval':
|
|
354
|
+
return { ...base, type: 'interval', time: row.time, intervalSeconds: row.interval_s ?? 0 };
|
|
355
|
+
}
|
|
356
|
+
}
|