@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 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
+ }
@@ -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';
@@ -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
+ }