@kubun/plugin-workflow 0.13.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.md +57 -0
- package/lib/definition.d.ts +25 -0
- package/lib/definition.js +8 -0
- package/lib/engine.d.ts +21 -0
- package/lib/engine.js +478 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +30 -0
- package/lib/schema.d.ts +41 -0
- package/lib/schema.js +156 -0
- package/lib/types.d.ts +73 -0
- package/lib/types.js +1 -0
- package/package.json +46 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# The Prosperity Public License 3.0.0
|
|
2
|
+
|
|
3
|
+
Contributor: Paul Le Cam
|
|
4
|
+
|
|
5
|
+
Source Code: https://github.com/PaulLeCam/kubun
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days.
|
|
10
|
+
|
|
11
|
+
## Agreement
|
|
12
|
+
|
|
13
|
+
In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow.
|
|
14
|
+
|
|
15
|
+
## Notices
|
|
16
|
+
|
|
17
|
+
Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above.
|
|
18
|
+
|
|
19
|
+
## Commercial Trial
|
|
20
|
+
|
|
21
|
+
Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person.
|
|
22
|
+
|
|
23
|
+
## Contributions Back
|
|
24
|
+
|
|
25
|
+
Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose.
|
|
26
|
+
|
|
27
|
+
## Personal Uses
|
|
28
|
+
|
|
29
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose.
|
|
30
|
+
|
|
31
|
+
## Noncommercial Organizations
|
|
32
|
+
|
|
33
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding.
|
|
34
|
+
|
|
35
|
+
## Defense
|
|
36
|
+
|
|
37
|
+
Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent.
|
|
38
|
+
|
|
39
|
+
## Copyright
|
|
40
|
+
|
|
41
|
+
The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it.
|
|
42
|
+
|
|
43
|
+
## Patent
|
|
44
|
+
|
|
45
|
+
The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license.
|
|
46
|
+
|
|
47
|
+
## Reliability
|
|
48
|
+
|
|
49
|
+
The contributor can't revoke this license.
|
|
50
|
+
|
|
51
|
+
## Excuse
|
|
52
|
+
|
|
53
|
+
You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule.
|
|
54
|
+
|
|
55
|
+
## No Liability
|
|
56
|
+
|
|
57
|
+
***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.***
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FlowAction, HandlersRecord } from '@sozai/flow';
|
|
2
|
+
import type { Validator } from '@sozai/schema';
|
|
3
|
+
export type RetryPolicy = {
|
|
4
|
+
maxAttempts?: number;
|
|
5
|
+
backoff?: 'exponential' | 'fixed';
|
|
6
|
+
baseMs?: number;
|
|
7
|
+
maxMs?: number;
|
|
8
|
+
};
|
|
9
|
+
export type TerminalStatus = 'completed' | 'failed' | 'cancelled';
|
|
10
|
+
export type TerminalInfo = {
|
|
11
|
+
instanceID: string;
|
|
12
|
+
name: string;
|
|
13
|
+
status: TerminalStatus;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
export type WorkflowDefinition<State extends Record<string, unknown> = Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>> = {
|
|
17
|
+
name: string;
|
|
18
|
+
handlers: Handlers;
|
|
19
|
+
initialAction: FlowAction<State, Handlers>;
|
|
20
|
+
stateValidator?: Validator<State>;
|
|
21
|
+
queue?: string;
|
|
22
|
+
retry?: RetryPolicy;
|
|
23
|
+
onTerminal?: (info: TerminalInfo) => void;
|
|
24
|
+
};
|
|
25
|
+
export declare function defineWorkflow<State extends Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>>(def: WorkflowDefinition<State, Handlers>): WorkflowDefinition<State, Handlers>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// A typed identity helper: authors get inference on handlers/state while the
|
|
2
|
+
// object is validated and returned unchanged for registration.
|
|
3
|
+
export function defineWorkflow(def) {
|
|
4
|
+
if (def.name.length === 0) {
|
|
5
|
+
throw new Error('Workflow definition requires a non-empty name');
|
|
6
|
+
}
|
|
7
|
+
return def;
|
|
8
|
+
}
|
package/lib/engine.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { WorkflowStoreAPI } from '@kubun/store-workflow';
|
|
3
|
+
import type { GetRandomID } from '@sozai/runtime';
|
|
4
|
+
import type { RetryPolicy } from './definition.js';
|
|
5
|
+
import type { WorkflowAPI } from './types.js';
|
|
6
|
+
export declare function backoff(attempts: number, policy?: RetryPolicy): number;
|
|
7
|
+
export declare function parseEvery(spec: string): number;
|
|
8
|
+
export type WorkflowEngineDeps = {
|
|
9
|
+
getStore: () => Promise<WorkflowStoreAPI>;
|
|
10
|
+
logger: Logger;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
getRandomID?: GetRandomID;
|
|
13
|
+
tickMs?: number;
|
|
14
|
+
};
|
|
15
|
+
export type WorkflowEngine = {
|
|
16
|
+
api: WorkflowAPI;
|
|
17
|
+
recover(): Promise<Array<string>>;
|
|
18
|
+
tick(): Promise<void>;
|
|
19
|
+
dispose(): void;
|
|
20
|
+
};
|
|
21
|
+
export declare function createWorkflowEngine(deps: WorkflowEngineDeps): WorkflowEngine;
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { EventEmitter } from '@sozai/event';
|
|
2
|
+
import { createFlow } from '@sozai/flow';
|
|
3
|
+
// One runner may hold an instance's lease for this long before another boot may
|
|
4
|
+
// reclaim it as stranded.
|
|
5
|
+
const LEASE_MS = 30_000;
|
|
6
|
+
// Default scheduler cadence; injectable via deps so tests drive `tick()` directly.
|
|
7
|
+
const DEFAULT_TICK_MS = 1_000;
|
|
8
|
+
// Backoff for a failed attempt (1-based). `fixed` returns `baseMs`; `exponential`
|
|
9
|
+
// doubles per attempt, capped at `maxMs`. Pure so it is unit-testable.
|
|
10
|
+
export function backoff(attempts, policy) {
|
|
11
|
+
const base = policy?.baseMs ?? 1_000;
|
|
12
|
+
const raw = (policy?.backoff ?? 'exponential') === 'fixed' ? base : base * 2 ** (attempts - 1);
|
|
13
|
+
return policy?.maxMs != null ? Math.min(raw, policy.maxMs) : raw;
|
|
14
|
+
}
|
|
15
|
+
// Tiny local interval parser (no dependency, no cron). Accepts `<n>ms|s|m|h` and
|
|
16
|
+
// a bare integer (= ms). Throws on anything else so a bad spec fails loudly at
|
|
17
|
+
// `schedule()` time rather than silently never firing.
|
|
18
|
+
const INTERVAL_UNITS = {
|
|
19
|
+
ms: 1,
|
|
20
|
+
s: 1_000,
|
|
21
|
+
m: 60_000,
|
|
22
|
+
h: 3_600_000
|
|
23
|
+
};
|
|
24
|
+
export function parseEvery(spec) {
|
|
25
|
+
const match = /^(\d+)(ms|s|m|h)?$/.exec(spec.trim());
|
|
26
|
+
if (match == null) throw new Error(`Invalid interval "${spec}"`);
|
|
27
|
+
return Number(match[1]) * (match[2] != null ? INTERVAL_UNITS[match[2]] : 1);
|
|
28
|
+
}
|
|
29
|
+
const TERMINAL = new Set([
|
|
30
|
+
'completed',
|
|
31
|
+
'failed',
|
|
32
|
+
'cancelled'
|
|
33
|
+
]);
|
|
34
|
+
function toStatus(row) {
|
|
35
|
+
return {
|
|
36
|
+
instanceID: row.instance_id,
|
|
37
|
+
name: row.name,
|
|
38
|
+
queue: row.queue,
|
|
39
|
+
status: row.status,
|
|
40
|
+
attempts: row.attempts,
|
|
41
|
+
lastError: row.last_error,
|
|
42
|
+
createdAt: row.created_at,
|
|
43
|
+
updatedAt: row.updated_at
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function createWorkflowEngine(deps) {
|
|
47
|
+
const now = deps.now ?? Date.now;
|
|
48
|
+
const logger = deps.logger;
|
|
49
|
+
const events = new EventEmitter();
|
|
50
|
+
const registry = new Map();
|
|
51
|
+
// A per-process identity for lease ownership. A fresh engine over the same DB
|
|
52
|
+
// (a "reboot") gets a new one, which is what makes crash-reclaim observable.
|
|
53
|
+
const bootID = (deps.getRandomID ?? (()=>crypto.randomUUID()))();
|
|
54
|
+
// Instances currently being driven in THIS process, so `cancel` can signal the
|
|
55
|
+
// running flow via its AbortController.
|
|
56
|
+
const running = new Map();
|
|
57
|
+
// In-flight drives, tracked so nudges are not lost and errors are logged.
|
|
58
|
+
const inflight = new Set();
|
|
59
|
+
let storePromise;
|
|
60
|
+
function store() {
|
|
61
|
+
storePromise ??= deps.getStore();
|
|
62
|
+
return storePromise;
|
|
63
|
+
}
|
|
64
|
+
function track(promise) {
|
|
65
|
+
inflight.add(promise);
|
|
66
|
+
void promise.finally(()=>inflight.delete(promise));
|
|
67
|
+
}
|
|
68
|
+
function fireTerminal(def, instanceID, status, error) {
|
|
69
|
+
events.fire(status, {
|
|
70
|
+
instanceID,
|
|
71
|
+
name: def.name
|
|
72
|
+
});
|
|
73
|
+
if (def.onTerminal != null) {
|
|
74
|
+
try {
|
|
75
|
+
def.onTerminal({
|
|
76
|
+
instanceID,
|
|
77
|
+
name: def.name,
|
|
78
|
+
status,
|
|
79
|
+
error
|
|
80
|
+
});
|
|
81
|
+
} catch (cause) {
|
|
82
|
+
logger.warn('workflow onTerminal threw', {
|
|
83
|
+
name: def.name,
|
|
84
|
+
instanceID,
|
|
85
|
+
error: cause
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Runs an already-claimed, already-leased row through the flow loop. The tick's
|
|
91
|
+
// `claimDue` does the claiming, so this path never re-claims.
|
|
92
|
+
async function run(row) {
|
|
93
|
+
const instanceID = row.instance_id;
|
|
94
|
+
// In-process guard: never drive the same instance twice concurrently (the
|
|
95
|
+
// live lease is the cross-process guard).
|
|
96
|
+
if (running.has(instanceID)) return;
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
running.set(instanceID, controller);
|
|
99
|
+
try {
|
|
100
|
+
const s = await store();
|
|
101
|
+
const def = registry.get(row.name);
|
|
102
|
+
if (def == null) {
|
|
103
|
+
logger.warn('no workflow registered for claimed instance', {
|
|
104
|
+
name: row.name,
|
|
105
|
+
instanceID
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const generate = createFlow({
|
|
110
|
+
handlers: def.handlers,
|
|
111
|
+
stateValidator: def.stateValidator
|
|
112
|
+
});
|
|
113
|
+
const flow = generate({
|
|
114
|
+
state: row.state,
|
|
115
|
+
action: row.next_action ?? def.initialAction,
|
|
116
|
+
signal: controller.signal
|
|
117
|
+
});
|
|
118
|
+
events.fire('started', {
|
|
119
|
+
instanceID,
|
|
120
|
+
name: def.name
|
|
121
|
+
});
|
|
122
|
+
// Pull the generator by hand: flow returns terminal values (`end`/`error`/
|
|
123
|
+
// `aborted`) with `done:true`, which `for await…of` would swallow — so the
|
|
124
|
+
// loop must inspect every value, done or not.
|
|
125
|
+
while(true){
|
|
126
|
+
const { value } = await flow.next();
|
|
127
|
+
// Defensive: flow always yields a defined value, but the generator's
|
|
128
|
+
// return type admits `undefined`.
|
|
129
|
+
if (value == null) return;
|
|
130
|
+
if (value.status === 'state' || value.status === 'action') {
|
|
131
|
+
// Renew before persisting: if the lease was reclaimed elsewhere, stop
|
|
132
|
+
// without writing so a stranded runner cannot clobber the reclaimer's
|
|
133
|
+
// state on a late wake-up.
|
|
134
|
+
const held = await s.renew(instanceID, bootID, LEASE_MS, now());
|
|
135
|
+
if (!held) return;
|
|
136
|
+
const nextAction = value.status === 'action' ? value.action : null;
|
|
137
|
+
await s.checkpoint(instanceID, {
|
|
138
|
+
state: flow.getState(),
|
|
139
|
+
nextAction,
|
|
140
|
+
status: 'running'
|
|
141
|
+
}, now());
|
|
142
|
+
events.fire('checkpoint', {
|
|
143
|
+
instanceID,
|
|
144
|
+
name: def.name
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (value.status === 'end') {
|
|
149
|
+
// Fence terminal writes on our bootID: a lease lost during a long
|
|
150
|
+
// pause means a reclaimer owns the row now, so write nothing.
|
|
151
|
+
const wrote = await s.finish(instanceID, {
|
|
152
|
+
status: 'completed',
|
|
153
|
+
state: flow.getState()
|
|
154
|
+
}, now(), bootID);
|
|
155
|
+
if (!wrote) return;
|
|
156
|
+
fireTerminal(def, instanceID, 'completed');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (value.status === 'aborted') {
|
|
160
|
+
const wrote = await s.finish(instanceID, {
|
|
161
|
+
status: 'cancelled',
|
|
162
|
+
state: flow.getState()
|
|
163
|
+
}, now(), bootID);
|
|
164
|
+
if (!wrote) return;
|
|
165
|
+
fireTerminal(def, instanceID, 'cancelled');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// status === 'error'. `getState()` here is the PRE-handler input state.
|
|
169
|
+
const error = String(value.error);
|
|
170
|
+
const attempts = row.attempts + 1;
|
|
171
|
+
const maxAttempts = def.retry?.maxAttempts ?? 1;
|
|
172
|
+
if (attempts < maxAttempts) {
|
|
173
|
+
// Retry rides the queue: re-queue `pending` with a future run gate; the
|
|
174
|
+
// scheduler re-claims it when due. Not terminal, so no terminal event.
|
|
175
|
+
await s.fail(instanceID, {
|
|
176
|
+
state: flow.getState(),
|
|
177
|
+
nextAction: row.next_action,
|
|
178
|
+
error,
|
|
179
|
+
attempts,
|
|
180
|
+
runAfter: now() + backoff(attempts, def.retry),
|
|
181
|
+
status: 'pending'
|
|
182
|
+
}, now(), bootID);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const wrote = await s.fail(instanceID, {
|
|
186
|
+
state: flow.getState(),
|
|
187
|
+
nextAction: row.next_action,
|
|
188
|
+
error,
|
|
189
|
+
attempts,
|
|
190
|
+
runAfter: now(),
|
|
191
|
+
status: 'failed'
|
|
192
|
+
}, now(), bootID);
|
|
193
|
+
if (!wrote) return;
|
|
194
|
+
fireTerminal(def, instanceID, 'failed', error);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
} catch (cause) {
|
|
198
|
+
logger.error('workflow run failed', {
|
|
199
|
+
instanceID,
|
|
200
|
+
error: cause
|
|
201
|
+
});
|
|
202
|
+
} finally{
|
|
203
|
+
running.delete(instanceID);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Per-lane concurrency. Defaults to 1; `defineQueue` raises a lane's cap.
|
|
207
|
+
const concurrency = new Map();
|
|
208
|
+
function laneConcurrency(lane) {
|
|
209
|
+
return concurrency.get(lane) ?? 1;
|
|
210
|
+
}
|
|
211
|
+
const buckets = new Map();
|
|
212
|
+
const queueOpts = new Map();
|
|
213
|
+
// Refill to `at` then report whole tokens available. Unbucketed lanes are
|
|
214
|
+
// unthrottled. Mutates lastRefillMs so tokens accrue monotonically across ticks.
|
|
215
|
+
function availableTokens(lane, at) {
|
|
216
|
+
const b = buckets.get(lane);
|
|
217
|
+
if (b == null) return Number.POSITIVE_INFINITY;
|
|
218
|
+
const elapsed = Math.max(0, at - b.lastRefillMs);
|
|
219
|
+
b.tokens = Math.min(b.capacity, b.tokens + elapsed * b.ratePerMs);
|
|
220
|
+
b.lastRefillMs = at;
|
|
221
|
+
return Math.floor(b.tokens);
|
|
222
|
+
}
|
|
223
|
+
function consumeTokens(lane, n) {
|
|
224
|
+
const b = buckets.get(lane);
|
|
225
|
+
if (b != null) b.tokens -= n;
|
|
226
|
+
}
|
|
227
|
+
function defineQueue(name, opts) {
|
|
228
|
+
const existing = queueOpts.get(name);
|
|
229
|
+
if (existing != null) {
|
|
230
|
+
if (existing.concurrency !== opts.concurrency || existing.ratePerMinute !== opts.ratePerMinute) {
|
|
231
|
+
logger.warn('workflow queue "{name}" defined with different options, ignoring', {
|
|
232
|
+
name
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
queueOpts.set(name, opts);
|
|
238
|
+
concurrency.set(name, opts.concurrency);
|
|
239
|
+
if (opts.ratePerMinute != null) {
|
|
240
|
+
// Burst capacity = ratePerMinute; start full so a fresh lane can dispatch
|
|
241
|
+
// immediately up to the per-minute allowance.
|
|
242
|
+
buckets.set(name, {
|
|
243
|
+
tokens: opts.ratePerMinute,
|
|
244
|
+
capacity: opts.ratePerMinute,
|
|
245
|
+
ratePerMs: opts.ratePerMinute / 60_000,
|
|
246
|
+
lastRefillMs: now()
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Promote every due schedule into a queued instance. Enqueue is keyed by
|
|
251
|
+
// (schedule, fire time) so a re-entrant or crash-resumed tick coalesces on the
|
|
252
|
+
// idempotency key instead of double-firing; the advance then arms the next
|
|
253
|
+
// interval so the row leaves the due set.
|
|
254
|
+
async function promoteSchedules(s, at) {
|
|
255
|
+
const due = await s.listDueSchedules(at);
|
|
256
|
+
for (const sched of due){
|
|
257
|
+
try {
|
|
258
|
+
if (sched.every == null) {
|
|
259
|
+
// Only interval schedules exist today; a cron-only row can't be armed.
|
|
260
|
+
logger.warn('workflow schedule "{id}" has no interval, skipping', {
|
|
261
|
+
id: sched.id
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const everyMs = parseEvery(sched.every);
|
|
266
|
+
await api.enqueue(sched.workflow_name, sched.params, {
|
|
267
|
+
idempotencyKey: `schedule:${sched.id}:${sched.next_run}`
|
|
268
|
+
});
|
|
269
|
+
await s.advanceSchedule(sched.id, at + everyMs, at);
|
|
270
|
+
} catch (cause) {
|
|
271
|
+
logger.error('workflow schedule promotion failed', {
|
|
272
|
+
id: sched.id,
|
|
273
|
+
error: cause
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function tick() {
|
|
279
|
+
try {
|
|
280
|
+
const s = await store();
|
|
281
|
+
const at = now();
|
|
282
|
+
// Promote due schedules first so their instances are claimable this pass.
|
|
283
|
+
await promoteSchedules(s, at);
|
|
284
|
+
const lanes = await s.listRunnableQueues(at);
|
|
285
|
+
for (const lane of lanes){
|
|
286
|
+
const cap = laneConcurrency(lane) - await s.countRunning(lane, at);
|
|
287
|
+
if (cap <= 0) continue;
|
|
288
|
+
// Clamp to available tokens; consume only for rows actually claimed
|
|
289
|
+
// (claimDue may return fewer than the grant).
|
|
290
|
+
const grant = Math.min(cap, availableTokens(lane, at));
|
|
291
|
+
if (grant <= 0) continue;
|
|
292
|
+
const rows = await s.claimDue(lane, grant, bootID, LEASE_MS, at);
|
|
293
|
+
consumeTokens(lane, rows.length);
|
|
294
|
+
for (const row of rows)track(run(row));
|
|
295
|
+
}
|
|
296
|
+
} catch (cause) {
|
|
297
|
+
logger.error('workflow tick failed', {
|
|
298
|
+
error: cause
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Coalesced early wake: an enqueue makes the scheduler run soon rather than
|
|
303
|
+
// waiting for the next interval. It only triggers a tick — it never drives a
|
|
304
|
+
// specific instance, so `claimDue`'s priority/run_after ordering is preserved.
|
|
305
|
+
let tickScheduled = false;
|
|
306
|
+
function scheduleTick() {
|
|
307
|
+
if (tickScheduled) return;
|
|
308
|
+
tickScheduled = true;
|
|
309
|
+
queueMicrotask(()=>{
|
|
310
|
+
tickScheduled = false;
|
|
311
|
+
track(tick());
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const tickMs = deps.tickMs ?? DEFAULT_TICK_MS;
|
|
315
|
+
let interval = setInterval(()=>{
|
|
316
|
+
void tick();
|
|
317
|
+
}, tickMs);
|
|
318
|
+
// Allow the process to exit even while the scheduler interval is pending.
|
|
319
|
+
const intervalHandle = interval;
|
|
320
|
+
if (typeof intervalHandle?.unref === 'function') intervalHandle.unref();
|
|
321
|
+
const api = {
|
|
322
|
+
register (def) {
|
|
323
|
+
const erased = def;
|
|
324
|
+
const existing = registry.get(def.name);
|
|
325
|
+
if (existing != null) {
|
|
326
|
+
if (existing !== erased) {
|
|
327
|
+
logger.warn('workflow "{name}" registered with different definition, ignoring', {
|
|
328
|
+
name: def.name
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
registry.set(def.name, erased);
|
|
334
|
+
},
|
|
335
|
+
defineQueue,
|
|
336
|
+
async schedule (name, params, when) {
|
|
337
|
+
const s = await store();
|
|
338
|
+
const everyMs = parseEvery(when.every);
|
|
339
|
+
const id = crypto.randomUUID();
|
|
340
|
+
const at = now();
|
|
341
|
+
// First fire one interval out: a restart storm must not refire everything
|
|
342
|
+
// that was ever scheduled.
|
|
343
|
+
await s.insertSchedule({
|
|
344
|
+
id,
|
|
345
|
+
workflowName: name,
|
|
346
|
+
params,
|
|
347
|
+
cron: null,
|
|
348
|
+
every: when.every,
|
|
349
|
+
nextRun: at + everyMs,
|
|
350
|
+
enabled: true
|
|
351
|
+
}, at);
|
|
352
|
+
const handle = {
|
|
353
|
+
id,
|
|
354
|
+
async disable () {
|
|
355
|
+
await (await store()).setScheduleEnabled(id, false, now());
|
|
356
|
+
},
|
|
357
|
+
async enable () {
|
|
358
|
+
// Re-arm one interval out from re-enable so a long-disabled schedule
|
|
359
|
+
// does not immediately fire every missed interval at once.
|
|
360
|
+
const s2 = await store();
|
|
361
|
+
const reAt = now();
|
|
362
|
+
await s2.advanceSchedule(id, reAt + everyMs, reAt);
|
|
363
|
+
await s2.setScheduleEnabled(id, true, reAt);
|
|
364
|
+
},
|
|
365
|
+
async cancel () {
|
|
366
|
+
// No store delete today: permanent disable is the cancel semantics.
|
|
367
|
+
await (await store()).setScheduleEnabled(id, false, now());
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
return handle;
|
|
371
|
+
},
|
|
372
|
+
async enqueue (name, params, opts) {
|
|
373
|
+
const def = registry.get(name);
|
|
374
|
+
if (def == null) {
|
|
375
|
+
throw new Error(`Workflow "${name}" is not registered`);
|
|
376
|
+
}
|
|
377
|
+
const s = await store();
|
|
378
|
+
const instanceID = crypto.randomUUID();
|
|
379
|
+
const createdAt = now();
|
|
380
|
+
const initialAction = params !== undefined ? {
|
|
381
|
+
name: def.initialAction.name,
|
|
382
|
+
params
|
|
383
|
+
} : def.initialAction;
|
|
384
|
+
const input = {
|
|
385
|
+
instanceID,
|
|
386
|
+
name,
|
|
387
|
+
queue: opts?.queue ?? def.queue ?? name,
|
|
388
|
+
idempotencyKey: opts?.idempotencyKey ?? null,
|
|
389
|
+
singletonKey: opts?.singletonKey ?? null,
|
|
390
|
+
priority: opts?.priority ?? 0,
|
|
391
|
+
state: {},
|
|
392
|
+
nextAction: initialAction,
|
|
393
|
+
runAfter: createdAt,
|
|
394
|
+
createdAt
|
|
395
|
+
};
|
|
396
|
+
let result;
|
|
397
|
+
try {
|
|
398
|
+
result = await s.insertInstance(input);
|
|
399
|
+
} catch (cause) {
|
|
400
|
+
// A concurrent insert may lose the singleton race and surface the
|
|
401
|
+
// partial-unique constraint as a thrown error. Treat it as "singleton
|
|
402
|
+
// already active": return the existing active instance rather than throw.
|
|
403
|
+
if (opts?.singletonKey != null) {
|
|
404
|
+
const active = (await s.list({
|
|
405
|
+
name
|
|
406
|
+
})).find((r)=>r.singleton_key === opts.singletonKey && (r.status === 'pending' || r.status === 'running'));
|
|
407
|
+
if (active != null) return {
|
|
408
|
+
instanceID: active.instance_id
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
throw cause;
|
|
412
|
+
}
|
|
413
|
+
// Idempotent or singleton hit: return the existing instance, drive nothing.
|
|
414
|
+
if (!result.created) {
|
|
415
|
+
return {
|
|
416
|
+
instanceID: result.row.instance_id
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// Wake the scheduler early; it claims by priority/run_after order.
|
|
420
|
+
scheduleTick();
|
|
421
|
+
return {
|
|
422
|
+
instanceID
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
async cancel (instanceID) {
|
|
426
|
+
const controller = running.get(instanceID);
|
|
427
|
+
if (controller != null) {
|
|
428
|
+
// The running drive observes `aborted` and finalises `cancelled`.
|
|
429
|
+
controller.abort();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const s = await store();
|
|
433
|
+
const row = await s.get(instanceID);
|
|
434
|
+
if (row == null || TERMINAL.has(row.status)) return;
|
|
435
|
+
await s.finish(instanceID, {
|
|
436
|
+
status: 'cancelled',
|
|
437
|
+
state: row.state
|
|
438
|
+
}, now());
|
|
439
|
+
const def = registry.get(row.name);
|
|
440
|
+
if (def != null) fireTerminal(def, instanceID, 'cancelled');
|
|
441
|
+
else events.fire('cancelled', {
|
|
442
|
+
instanceID,
|
|
443
|
+
name: row.name
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
async get (instanceID) {
|
|
447
|
+
const s = await store();
|
|
448
|
+
const row = await s.get(instanceID);
|
|
449
|
+
return row != null ? toStatus(row) : null;
|
|
450
|
+
},
|
|
451
|
+
async list (filter) {
|
|
452
|
+
const s = await store();
|
|
453
|
+
const rows = await s.list(filter);
|
|
454
|
+
return rows.map(toStatus);
|
|
455
|
+
},
|
|
456
|
+
events
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
api,
|
|
460
|
+
async recover () {
|
|
461
|
+
// Stranded rows are claimable by `claimDue`, so a single tick reclaims and
|
|
462
|
+
// resumes them. The returned ids are the pre-tick stranded set.
|
|
463
|
+
const s = await store();
|
|
464
|
+
const stranded = await s.listRecoverable(now());
|
|
465
|
+
await tick();
|
|
466
|
+
return stranded.map((r)=>r.instance_id);
|
|
467
|
+
},
|
|
468
|
+
tick,
|
|
469
|
+
dispose () {
|
|
470
|
+
if (interval != null) {
|
|
471
|
+
clearInterval(interval);
|
|
472
|
+
interval = null;
|
|
473
|
+
}
|
|
474
|
+
for (const controller of running.values())controller.abort();
|
|
475
|
+
running.clear();
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
|
|
2
|
+
export type WorkflowPluginOptions = Record<string, never>;
|
|
3
|
+
export declare function createWorkflowPlugin(_options?: WorkflowPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
|
|
4
|
+
export type { RetryPolicy, TerminalInfo, TerminalStatus, WorkflowDefinition, } from './definition.js';
|
|
5
|
+
export { defineWorkflow } from './definition.js';
|
|
6
|
+
export type { WorkflowEngine, WorkflowEngineDeps } from './engine.js';
|
|
7
|
+
export { createWorkflowEngine, parseEvery } from './engine.js';
|
|
8
|
+
export type { WorkflowContext, WorkflowEventResult } from './schema.js';
|
|
9
|
+
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents, } from './schema.js';
|
|
10
|
+
export type { EnqueueOptions, InstanceHandle, InstanceStatus, ListFilter, QueueOptions, ScheduleHandle, ScheduleSpec, WorkflowAPI, WorkflowEvents, } from './types.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getWorkflowStore, workflowStoreDefinition } from '@kubun/store-workflow';
|
|
2
|
+
import { createWorkflowEngine } from './engine.js';
|
|
3
|
+
import { createWorkflowContextFactory, createWorkflowSchemaExtension } from './schema.js';
|
|
4
|
+
export function createWorkflowPlugin(_options = {}) {
|
|
5
|
+
return (params)=>{
|
|
6
|
+
params.db.register(workflowStoreDefinition);
|
|
7
|
+
const logger = params.getLogger('workflow');
|
|
8
|
+
const engine = createWorkflowEngine({
|
|
9
|
+
getStore: ()=>getWorkflowStore(params.db),
|
|
10
|
+
logger,
|
|
11
|
+
getRandomID: ()=>params.runtime.getRandomID()
|
|
12
|
+
});
|
|
13
|
+
// No scheduler in Phase 1: re-drive stranded instances once on boot.
|
|
14
|
+
void engine.recover().catch((error)=>{
|
|
15
|
+
logger.error('workflow recovery failed', {
|
|
16
|
+
error
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
name: 'workflow',
|
|
21
|
+
api: engine.api,
|
|
22
|
+
schemaExtension: ()=>createWorkflowSchemaExtension(),
|
|
23
|
+
createContextFactory: ()=>createWorkflowContextFactory(engine.api),
|
|
24
|
+
dispose: ()=>engine.dispose()
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export { defineWorkflow } from './definition.js';
|
|
29
|
+
export { createWorkflowEngine, parseEvery } from './engine.js';
|
|
30
|
+
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents } from './schema.js';
|
package/lib/schema.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ContextFactory, SchemaExtension } from '@kubun/engine';
|
|
2
|
+
import type { EventsSource } from '@sozai/event';
|
|
3
|
+
import type { InstanceStatus, ListFilter, WorkflowAPI, WorkflowEvents } from './types.js';
|
|
4
|
+
export type WorkflowEventResult = {
|
|
5
|
+
type: keyof WorkflowEvents;
|
|
6
|
+
instanceID: string;
|
|
7
|
+
name: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Subscribe to a workflow's lifecycle events, yielding GraphQL result shapes.
|
|
11
|
+
*
|
|
12
|
+
* Implemented as a manual async iterator rather than an `async function*` so a
|
|
13
|
+
* consumer's `return()` removes all listeners immediately. A `for await` wrapper
|
|
14
|
+
* would queue the `return()` behind a parked `next()` between events, leaking the
|
|
15
|
+
* listeners until the next event — for an idle subscription, potentially never.
|
|
16
|
+
*
|
|
17
|
+
* `fromEmitter` (the connector template) handles a single event name over an
|
|
18
|
+
* `EventEmitter`; `WorkflowEvents` has five keys and the API exposes only the
|
|
19
|
+
* listen-only `EventsSource`, so the fan-in is done by hand over `on`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function subscribeToWorkflowEvents(events: EventsSource<WorkflowEvents>, instanceID?: string | null): AsyncIterableIterator<WorkflowEventResult>;
|
|
22
|
+
/**
|
|
23
|
+
* Per-request context for the workflow query/subscription fields. Reads are
|
|
24
|
+
* process-level (not transaction-bound), so the factory closes over a single
|
|
25
|
+
* long-lived API rather than rebuilding one per request.
|
|
26
|
+
*
|
|
27
|
+
* Lands on the GraphQL resolver context under `ctx.workflow` via the
|
|
28
|
+
* `declare module '@kubun/graphql'` augmentation below.
|
|
29
|
+
*/
|
|
30
|
+
export type WorkflowContext = {
|
|
31
|
+
get(instanceID: string): Promise<InstanceStatus | null>;
|
|
32
|
+
list(filter: ListFilter): Promise<Array<InstanceStatus>>;
|
|
33
|
+
subscribeToWorkflowEvents(instanceID?: string | null): AsyncIterableIterator<WorkflowEventResult>;
|
|
34
|
+
};
|
|
35
|
+
declare module '@kubun/graphql' {
|
|
36
|
+
interface PluginContextMap {
|
|
37
|
+
workflow: WorkflowContext;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export declare function createWorkflowContextFactory(api: WorkflowAPI): ContextFactory;
|
|
41
|
+
export declare function createWorkflowSchemaExtension(): SchemaExtension;
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Every lifecycle key. The source must listen to all of them and tag each event
|
|
2
|
+
// with its `type`, since one subscription surfaces the whole lifecycle.
|
|
3
|
+
const EVENT_TYPES = [
|
|
4
|
+
'started',
|
|
5
|
+
'checkpoint',
|
|
6
|
+
'failed',
|
|
7
|
+
'completed',
|
|
8
|
+
'cancelled'
|
|
9
|
+
];
|
|
10
|
+
/**
|
|
11
|
+
* Subscribe to a workflow's lifecycle events, yielding GraphQL result shapes.
|
|
12
|
+
*
|
|
13
|
+
* Implemented as a manual async iterator rather than an `async function*` so a
|
|
14
|
+
* consumer's `return()` removes all listeners immediately. A `for await` wrapper
|
|
15
|
+
* would queue the `return()` behind a parked `next()` between events, leaking the
|
|
16
|
+
* listeners until the next event — for an idle subscription, potentially never.
|
|
17
|
+
*
|
|
18
|
+
* `fromEmitter` (the connector template) handles a single event name over an
|
|
19
|
+
* `EventEmitter`; `WorkflowEvents` has five keys and the API exposes only the
|
|
20
|
+
* listen-only `EventsSource`, so the fan-in is done by hand over `on`.
|
|
21
|
+
*/ export function subscribeToWorkflowEvents(events, instanceID) {
|
|
22
|
+
const filter = instanceID != null ? (data)=>data.instanceID === instanceID : undefined;
|
|
23
|
+
let isDone = false;
|
|
24
|
+
const queue = [];
|
|
25
|
+
const pending = [];
|
|
26
|
+
const unsubscribes = EVENT_TYPES.map((type)=>events.on(type, (data)=>{
|
|
27
|
+
const result = {
|
|
28
|
+
type,
|
|
29
|
+
instanceID: data.instanceID,
|
|
30
|
+
name: data.name
|
|
31
|
+
};
|
|
32
|
+
const waiter = pending.shift();
|
|
33
|
+
if (waiter == null) {
|
|
34
|
+
queue.push(result);
|
|
35
|
+
} else {
|
|
36
|
+
waiter({
|
|
37
|
+
value: result,
|
|
38
|
+
done: false
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}, {
|
|
42
|
+
filter
|
|
43
|
+
}));
|
|
44
|
+
const stop = ()=>{
|
|
45
|
+
if (isDone) return;
|
|
46
|
+
isDone = true;
|
|
47
|
+
for (const off of unsubscribes)off();
|
|
48
|
+
// Settle every parked reader so a return() before the first event does not
|
|
49
|
+
// leave a next() hanging.
|
|
50
|
+
while(pending.length > 0){
|
|
51
|
+
pending.shift()?.({
|
|
52
|
+
done: true,
|
|
53
|
+
value: undefined
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
[Symbol.asyncIterator] () {
|
|
59
|
+
return this;
|
|
60
|
+
},
|
|
61
|
+
next () {
|
|
62
|
+
if (isDone) {
|
|
63
|
+
return Promise.resolve({
|
|
64
|
+
done: true,
|
|
65
|
+
value: undefined
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const queued = queue.shift();
|
|
69
|
+
if (queued != null) {
|
|
70
|
+
return Promise.resolve({
|
|
71
|
+
value: queued,
|
|
72
|
+
done: false
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return new Promise((resolve)=>{
|
|
76
|
+
pending.push(resolve);
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
return () {
|
|
80
|
+
stop();
|
|
81
|
+
return Promise.resolve({
|
|
82
|
+
done: true,
|
|
83
|
+
value: undefined
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
throw (reason) {
|
|
87
|
+
stop();
|
|
88
|
+
return Promise.reject(reason);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function requireWorkflow(ctx) {
|
|
93
|
+
if (ctx.workflow == null) {
|
|
94
|
+
throw new Error('workflow plugin is not wired into this context');
|
|
95
|
+
}
|
|
96
|
+
return ctx.workflow;
|
|
97
|
+
}
|
|
98
|
+
export function createWorkflowContextFactory(api) {
|
|
99
|
+
return ()=>({
|
|
100
|
+
get: (instanceID)=>api.get(instanceID),
|
|
101
|
+
list: (filter)=>api.list(filter),
|
|
102
|
+
subscribeToWorkflowEvents: (instanceID)=>subscribeToWorkflowEvents(api.events, instanceID)
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// ms-epoch timestamps are exposed as `Float` (JS number), matching how the
|
|
106
|
+
// connector exposes numeric time (`ConnectorSyncEvent.duration`). `Int` would
|
|
107
|
+
// overflow GraphQL's 32-bit range for a millisecond epoch. `state`/`next_action`
|
|
108
|
+
// have no field here — the projection cannot leak them.
|
|
109
|
+
const SDL = `
|
|
110
|
+
type WorkflowInstanceStatus {
|
|
111
|
+
instanceID: ID!
|
|
112
|
+
name: String!
|
|
113
|
+
queue: String!
|
|
114
|
+
status: String!
|
|
115
|
+
attempts: Int!
|
|
116
|
+
lastError: String
|
|
117
|
+
createdAt: Float!
|
|
118
|
+
updatedAt: Float!
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type WorkflowEvent {
|
|
122
|
+
type: String!
|
|
123
|
+
instanceID: ID!
|
|
124
|
+
name: String!
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
extend type Query {
|
|
128
|
+
workflowInstance(instanceID: ID!): WorkflowInstanceStatus
|
|
129
|
+
workflowInstances(name: String, queue: String, status: String): [WorkflowInstanceStatus!]!
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
extend type Subscription {
|
|
133
|
+
workflowEvents(instanceID: ID): WorkflowEvent!
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
export function createWorkflowSchemaExtension() {
|
|
137
|
+
return {
|
|
138
|
+
sdl: SDL,
|
|
139
|
+
resolvers: {
|
|
140
|
+
queryFields: {
|
|
141
|
+
workflowInstance: (_source, args, context)=>requireWorkflow(context).get(args.instanceID),
|
|
142
|
+
workflowInstances: (_source, args, context)=>requireWorkflow(context).list({
|
|
143
|
+
name: args.name,
|
|
144
|
+
queue: args.queue,
|
|
145
|
+
status: args.status
|
|
146
|
+
})
|
|
147
|
+
},
|
|
148
|
+
subscriptionFields: {
|
|
149
|
+
workflowEvents: {
|
|
150
|
+
resolve: (event)=>event,
|
|
151
|
+
subscribe: (_source, args, context)=>requireWorkflow(context).subscribeToWorkflowEvents(args.instanceID)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { WorkflowStatus } from '@kubun/store-workflow';
|
|
2
|
+
import type { EventsSource } from '@sozai/event';
|
|
3
|
+
import type { HandlersRecord } from '@sozai/flow';
|
|
4
|
+
import type { WorkflowDefinition } from './definition.js';
|
|
5
|
+
export type WorkflowEvents = {
|
|
6
|
+
started: {
|
|
7
|
+
instanceID: string;
|
|
8
|
+
name: string;
|
|
9
|
+
};
|
|
10
|
+
checkpoint: {
|
|
11
|
+
instanceID: string;
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
failed: {
|
|
15
|
+
instanceID: string;
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
completed: {
|
|
19
|
+
instanceID: string;
|
|
20
|
+
name: string;
|
|
21
|
+
};
|
|
22
|
+
cancelled: {
|
|
23
|
+
instanceID: string;
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export type InstanceStatus = {
|
|
28
|
+
instanceID: string;
|
|
29
|
+
name: string;
|
|
30
|
+
queue: string;
|
|
31
|
+
status: WorkflowStatus;
|
|
32
|
+
attempts: number;
|
|
33
|
+
lastError: string | null;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
updatedAt: number;
|
|
36
|
+
};
|
|
37
|
+
export type InstanceHandle = {
|
|
38
|
+
instanceID: string;
|
|
39
|
+
};
|
|
40
|
+
export type EnqueueOptions = {
|
|
41
|
+
queue?: string;
|
|
42
|
+
priority?: number;
|
|
43
|
+
idempotencyKey?: string;
|
|
44
|
+
singletonKey?: string;
|
|
45
|
+
};
|
|
46
|
+
export type ListFilter = {
|
|
47
|
+
name?: string;
|
|
48
|
+
queue?: string;
|
|
49
|
+
status?: WorkflowStatus;
|
|
50
|
+
};
|
|
51
|
+
export type QueueOptions = {
|
|
52
|
+
concurrency: number;
|
|
53
|
+
ratePerMinute?: number;
|
|
54
|
+
};
|
|
55
|
+
export type ScheduleSpec = {
|
|
56
|
+
every: string;
|
|
57
|
+
};
|
|
58
|
+
export type ScheduleHandle = {
|
|
59
|
+
id: string;
|
|
60
|
+
disable(): Promise<void>;
|
|
61
|
+
enable(): Promise<void>;
|
|
62
|
+
cancel(): Promise<void>;
|
|
63
|
+
};
|
|
64
|
+
export type WorkflowAPI = {
|
|
65
|
+
register<State extends Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>>(def: WorkflowDefinition<State, Handlers>): void;
|
|
66
|
+
defineQueue(name: string, opts: QueueOptions): void;
|
|
67
|
+
enqueue(name: string, params?: unknown, opts?: EnqueueOptions): Promise<InstanceHandle>;
|
|
68
|
+
schedule(name: string, params: unknown, when: ScheduleSpec): Promise<ScheduleHandle>;
|
|
69
|
+
cancel(instanceID: string): Promise<void>;
|
|
70
|
+
get(instanceID: string): Promise<InstanceStatus | null>;
|
|
71
|
+
list(filter?: ListFilter): Promise<Array<InstanceStatus>>;
|
|
72
|
+
events: EventsSource<WorkflowEvents>;
|
|
73
|
+
};
|
package/lib/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kubun/plugin-workflow",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"keywords": [],
|
|
5
|
+
"license": "see LICENSE.md",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"types": "lib/index.d.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"lib/*",
|
|
15
|
+
"LICENSE.md"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@sozai/event": "^0.1.3",
|
|
19
|
+
"@sozai/flow": "^0.3.0",
|
|
20
|
+
"@sozai/runtime": "^0.1.0",
|
|
21
|
+
"@sozai/schema": "^0.1.1",
|
|
22
|
+
"@kubun/engine": "^0.13.0",
|
|
23
|
+
"@kubun/logger": "^0.13.0",
|
|
24
|
+
"@kubun/store-workflow": "^0.13.0",
|
|
25
|
+
"@kubun/graphql": "^0.13.0",
|
|
26
|
+
"@kubun/db": "^0.13.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@testcontainers/postgresql": "^12.1.0",
|
|
30
|
+
"@kubun/db-postgres": "^0.13.0",
|
|
31
|
+
"@kubun/db-better-sqlite": "^0.13.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
38
|
+
"build:clean": "del lib",
|
|
39
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
40
|
+
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
41
|
+
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
42
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
43
|
+
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
44
|
+
"test:unit": "vitest run"
|
|
45
|
+
}
|
|
46
|
+
}
|