@manablox/workflows 0.2.0 → 0.3.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/src/engine.ts DELETED
@@ -1,499 +0,0 @@
1
- import type {
2
- ContentHookContext,
3
- ContentRecord,
4
- Manablox,
5
- WorkflowActor,
6
- WorkflowCursor,
7
- WorkflowEvent,
8
- WorkflowEventTrigger,
9
- WorkflowRunContext,
10
- WorkflowStep,
11
- WorkflowStepLog,
12
- } from '@manablox/core';
13
- import type { ContentRow, Repositories, WorkflowRow, WorkflowRunRow } from '@manablox/db';
14
- import { evaluateCondition } from './conditions.js';
15
- import { cronMatches, floorToMinute, parseCron } from './cron.js';
16
- import type { Mailer } from './mail.js';
17
- import type { Pusher } from './push.js';
18
- import { executeStep, type StepEnvironment } from './steps.js';
19
-
20
- export interface WorkflowEngineOptions {
21
- mailer?: Mailer | null | undefined;
22
- pusher?: Pusher | null | undefined;
23
- fetch?: typeof fetch | undefined;
24
- /** Where the admin lives, for links in mails and notifications. */
25
- adminUrl?: string | undefined;
26
- /**
27
- * Hands a queued run to a worker. The default runs it in this process, off the
28
- * request; a host with a queue passes `(runId) => jobs.enqueue('workflow:run', …)`.
29
- */
30
- dispatch?: ((runId: string) => Promise<void>) | undefined;
31
- /** How often the scheduler looks at the clock. Every 20 s catches each minute once. */
32
- tickMs?: number | undefined;
33
- now?: (() => Date) | undefined;
34
- }
35
-
36
- type ChainOutcome =
37
- | { kind: 'done' }
38
- | { kind: 'wait' }
39
- | { kind: 'stop'; cursor: WorkflowCursor }
40
- | { kind: 'fail'; cursor: WorkflowCursor; error: string };
41
-
42
- /** The hook events a content change raises, and the trigger events each satisfies. */
43
- const EVENT_ALIASES: Record<WorkflowEvent, WorkflowEvent[]> = {
44
- 'content.created': ['content.created', 'content.saved'],
45
- 'content.updated': ['content.updated', 'content.saved'],
46
- 'content.saved': ['content.saved'],
47
- 'content.deleted': ['content.deleted'],
48
- 'content.published': ['content.published'],
49
- 'content.unpublished': ['content.unpublished'],
50
- };
51
-
52
- /**
53
- * Runs workflows: listens for content events, keeps the clock for scheduled ones, and
54
- * executes a run's steps one after another, pausing at a delay and picking it up again.
55
- *
56
- * A run is a row from the moment it is queued, so nothing is lost if the process goes
57
- * away mid-way: the next tick finds `waiting` runs whose time has come, and a worker
58
- * claims a run before touching it so two never execute the same one.
59
- */
60
- export class WorkflowEngine {
61
- private readonly env: StepEnvironment;
62
- private readonly dispatch: (runId: string) => Promise<void>;
63
- private readonly now: () => Date;
64
- private readonly tickMs: number;
65
- private readonly pending = new Set<Promise<void>>();
66
- private timer: ReturnType<typeof setInterval> | null = null;
67
- private ticking = false;
68
-
69
- constructor(
70
- private readonly manablox: Manablox,
71
- private readonly repos: Repositories,
72
- options: WorkflowEngineOptions = {},
73
- ) {
74
- this.env = {
75
- repos,
76
- mailer: options.mailer ?? null,
77
- pusher: options.pusher ?? null,
78
- fetch: options.fetch ?? globalThis.fetch,
79
- adminUrl: options.adminUrl ?? manablox.config.server.adminUrl,
80
- };
81
- this.dispatch = options.dispatch ?? ((runId) => this.background(this.run(runId)));
82
- this.now = options.now ?? (() => new Date());
83
- this.tickMs = options.tickMs ?? 20_000;
84
- }
85
-
86
- get adminUrl(): string {
87
- return this.env.adminUrl;
88
- }
89
-
90
- /** Registers the content hooks. Call once, at boot. */
91
- attach(): void {
92
- const source = '@manablox/workflows';
93
- const on =
94
- (event: WorkflowEvent) => async (row: ContentRecord, context: ContentHookContext) => {
95
- await this.onContentEvent(event, row, context);
96
- };
97
- this.manablox.hooks.on('content:afterCreate', on('content.created'), { source });
98
- this.manablox.hooks.on('content:afterUpdate', on('content.updated'), { source });
99
- this.manablox.hooks.on('content:afterPublish', on('content.published'), { source });
100
- this.manablox.hooks.on(
101
- 'content:afterDelete',
102
- async (payload, context) => {
103
- await this.onContentEvent('content.deleted', payload.record, context);
104
- },
105
- { source },
106
- );
107
- this.manablox.hooks.on(
108
- 'content:afterUnpublish',
109
- async (payload, context) => {
110
- const row = await this.repos.content.findById(payload.id);
111
- if (row) await this.onContentEvent('content.unpublished', row as never, context);
112
- },
113
- { source },
114
- );
115
- }
116
-
117
- /** Starts the clock for scheduled workflows and paused runs. */
118
- start(): void {
119
- if (this.timer) return;
120
- this.timer = setInterval(() => void this.tick(), this.tickMs);
121
- this.timer.unref?.();
122
- this.manablox.onDispose(() => this.stop());
123
- }
124
-
125
- async stop(): Promise<void> {
126
- if (this.timer) clearInterval(this.timer);
127
- this.timer = null;
128
- await this.idle();
129
- }
130
-
131
- /** Resolves once every run started in this process has finished. For tests and shutdown. */
132
- async idle(): Promise<void> {
133
- while (this.pending.size) await Promise.allSettled([...this.pending]);
134
- }
135
-
136
- // --- event triggers --------------------------------------------------------
137
-
138
- private async onContentEvent(
139
- event: WorkflowEvent,
140
- row: ContentRecord,
141
- context: ContentHookContext,
142
- ): Promise<void> {
143
- const candidates = await this.repos.workflows.listEnabled(row.spaceId);
144
- const matching = candidates.filter((workflow) => matchesEvent(workflow.trigger, event, row));
145
- if (matching.length === 0) return;
146
-
147
- const space = await this.spaceInfo(row.spaceId);
148
- const actor = await this.actorInfo(context.actor?.userId ?? null);
149
- const previous = context.previous ? serialise(context.previous) : null;
150
-
151
- for (const workflow of matching) {
152
- const runContext: WorkflowRunContext = {
153
- event,
154
- workflow: { id: workflow.id, name: workflow.name },
155
- space,
156
- content: serialise(row),
157
- previous: event === 'content.updated' ? previous : null,
158
- documents: [],
159
- actor,
160
- url: event === 'content.deleted' ? null : this.documentUrl(row.id),
161
- at: this.now().toISOString(),
162
- };
163
- await this.enqueue(workflow, event, runContext);
164
- }
165
- }
166
-
167
- // --- schedule ----------------------------------------------------------------
168
-
169
- /**
170
- * One look at the clock: start every scheduled workflow whose cron names this minute,
171
- * and resume every paused run whose time has come. Safe to call from several
172
- * processes — each claim is an atomic update.
173
- */
174
- async tick(now: Date = this.now()): Promise<void> {
175
- if (this.ticking) return;
176
- this.ticking = true;
177
- try {
178
- const minute = floorToMinute(now);
179
- const enabled = await this.repos.workflows.listEnabled();
180
- for (const workflow of enabled) {
181
- if (workflow.trigger.kind !== 'schedule') continue;
182
- const spec = parseCron(workflow.trigger.cron);
183
- if (!spec || !cronMatches(spec, minute, workflow.trigger.timezone)) continue;
184
- if (!(await this.repos.workflows.claimSchedule(workflow.id, minute))) continue;
185
- await this.startScheduled(workflow);
186
- }
187
-
188
- for (const run of await this.repos.workflows.dueRuns(now)) {
189
- await this.dispatch(run.id);
190
- }
191
- } catch (error) {
192
- this.manablox.logger.error({ err: error }, 'workflow tick failed');
193
- } finally {
194
- this.ticking = false;
195
- }
196
- }
197
-
198
- private async startScheduled(workflow: WorkflowRow): Promise<void> {
199
- if (workflow.trigger.kind !== 'schedule') return;
200
- const space = await this.spaceInfo(workflow.spaceId);
201
- const selection = workflow.trigger.selection;
202
- const documents = selection
203
- ? (await this.repos.workflows.selectDocuments(workflow.spaceId, selection)).map(serialise)
204
- : [];
205
-
206
- const base = {
207
- event: 'schedule',
208
- workflow: { id: workflow.id, name: workflow.name },
209
- space,
210
- previous: null,
211
- actor: null,
212
- at: this.now().toISOString(),
213
- };
214
-
215
- if (workflow.trigger.perDocument) {
216
- for (const document of documents) {
217
- await this.enqueue(workflow, 'schedule', {
218
- ...base,
219
- content: document,
220
- documents: [],
221
- url: this.documentUrl(String(document.id)),
222
- });
223
- }
224
- return;
225
- }
226
- await this.enqueue(workflow, 'schedule', { ...base, content: null, documents, url: null });
227
- }
228
-
229
- // --- manual ------------------------------------------------------------------
230
-
231
- /** A run started by hand from the editor, executed inline so the caller sees the log. */
232
- async runManually(workflow: WorkflowRow, document: ContentRow | null): Promise<WorkflowRunRow> {
233
- const space = await this.spaceInfo(workflow.spaceId);
234
- const documents =
235
- workflow.trigger.kind === 'schedule' && workflow.trigger.selection && !document
236
- ? (
237
- await this.repos.workflows.selectDocuments(workflow.spaceId, workflow.trigger.selection)
238
- ).map(serialise)
239
- : [];
240
- const run = await this.repos.workflows.createRun({
241
- workflowId: workflow.id,
242
- spaceId: workflow.spaceId,
243
- trigger: 'manual',
244
- context: {
245
- event: 'manual',
246
- workflow: { id: workflow.id, name: workflow.name },
247
- space,
248
- content: document ? serialise(document) : null,
249
- previous: null,
250
- documents,
251
- actor: null,
252
- url: document ? this.documentUrl(document.id) : null,
253
- at: this.now().toISOString(),
254
- },
255
- });
256
- await this.run(run.id);
257
- return (await this.repos.workflows.findRun(run.id)) ?? run;
258
- }
259
-
260
- // --- execution ---------------------------------------------------------------
261
-
262
- private async enqueue(
263
- workflow: WorkflowRow,
264
- trigger: string,
265
- context: WorkflowRunContext,
266
- ): Promise<void> {
267
- const run = await this.repos.workflows.createRun({
268
- workflowId: workflow.id,
269
- spaceId: workflow.spaceId,
270
- trigger,
271
- context,
272
- });
273
- await this.dispatch(run.id);
274
- }
275
-
276
- private background(work: Promise<void>): Promise<void> {
277
- const tracked = work
278
- .catch((error) => {
279
- this.manablox.logger.error({ err: error }, 'workflow run crashed');
280
- })
281
- .finally(() => {
282
- this.pending.delete(tracked);
283
- });
284
- this.pending.add(tracked);
285
- // Resolved at once: the caller queued the run, and does not wait for it to finish.
286
- return Promise.resolve();
287
- }
288
-
289
- /** Executes a queued or paused run from where it stands. The worker entry point. */
290
- async run(runId: string): Promise<void> {
291
- const run = await this.repos.workflows.claimRun(runId);
292
- if (!run) return;
293
- const workflow = await this.repos.workflows.findById(run.workflowId);
294
- if (!workflow) {
295
- await this.repos.workflows.saveRunProgress(run.id, {
296
- status: 'failed',
297
- cursor: run.cursor,
298
- log: run.log,
299
- error: 'The workflow no longer exists',
300
- finished: true,
301
- });
302
- return;
303
- }
304
-
305
- const log: WorkflowStepLog[] = [...run.log];
306
- const outcome = await this.runChain(run, workflow, workflow.steps, [], run.cursor, log);
307
- if (outcome.kind === 'done') await this.finish(run, workflow, 'succeeded', [], log, null);
308
- else if (outcome.kind === 'stop')
309
- await this.finish(run, workflow, 'skipped', outcome.cursor, log, null);
310
- else if (outcome.kind === 'fail')
311
- await this.finish(run, workflow, 'failed', outcome.cursor, log, outcome.error);
312
- // `wait` has already been saved.
313
- }
314
-
315
- /**
316
- * Walks one chain — the top level, or a side of a fork — from `resume` when the run is
317
- * being picked up inside it. A fork's chosen side is walked recursively; the position
318
- * saved at a delay is the full path, so a resume finds its way back down.
319
- */
320
- private async runChain(
321
- run: WorkflowRunRow,
322
- workflow: WorkflowRow,
323
- steps: WorkflowStep[],
324
- prefix: WorkflowCursor,
325
- resume: WorkflowCursor | null,
326
- log: WorkflowStepLog[],
327
- ): Promise<ChainOutcome> {
328
- let index = 0;
329
- let inner: { side: 'then' | 'else'; cursor: WorkflowCursor } | null = null;
330
- if (resume?.length) {
331
- index = resume[0] as number;
332
- const side = resume[1];
333
- if (side === 'then' || side === 'else') inner = { side, cursor: resume.slice(2) };
334
- }
335
-
336
- for (; index < steps.length; index++) {
337
- const step = steps[index];
338
- if (!step) break;
339
- const here: WorkflowCursor = [...prefix, index];
340
- const startedAt = this.now();
341
- const entry = (
342
- status: WorkflowStepLog['status'],
343
- message: string | null,
344
- detail: Record<string, unknown> | null = null,
345
- ): WorkflowStepLog => ({
346
- stepId: step.id,
347
- type: step.type,
348
- name: step.name,
349
- status,
350
- message,
351
- detail,
352
- startedAt: startedAt.toISOString(),
353
- ms: this.now().getTime() - startedAt.getTime(),
354
- });
355
-
356
- // Resuming inside a fork: skip the rules, they were decided before the pause.
357
- if (inner) {
358
- const { side, cursor } = inner;
359
- inner = null;
360
- if (step.type === 'branch') {
361
- const outcome = await this.runChain(
362
- run,
363
- workflow,
364
- step[side],
365
- [...here, side],
366
- cursor,
367
- log,
368
- );
369
- if (outcome.kind !== 'done') return outcome;
370
- }
371
- continue;
372
- }
373
-
374
- if (!step.enabled) {
375
- log.push(entry('skipped', 'Step is switched off'));
376
- continue;
377
- }
378
-
379
- if (step.type === 'branch') {
380
- const holds = evaluateCondition(step, run.context);
381
- const side = holds ? 'then' : 'else';
382
- log.push(
383
- entry(
384
- 'ok',
385
- holds ? 'Rules hold — taking the yes side' : 'Rules do not hold — taking the no side',
386
- {
387
- branch: side,
388
- },
389
- ),
390
- );
391
- const outcome = await this.runChain(run, workflow, step[side], [...here, side], null, log);
392
- if (outcome.kind !== 'done') return outcome;
393
- continue;
394
- }
395
-
396
- try {
397
- const outcome = await executeStep(step, run.context, this.env);
398
- if (outcome.kind === 'wait') {
399
- const resumeAt = new Date(this.now().getTime() + outcome.minutes * 60_000);
400
- log.push(
401
- entry('waiting', `Waiting until ${resumeAt.toISOString()}`, {
402
- resumeAt: resumeAt.toISOString(),
403
- }),
404
- );
405
- await this.repos.workflows.saveRunProgress(run.id, {
406
- status: 'waiting',
407
- cursor: [...prefix, index + 1],
408
- log,
409
- resumeAt,
410
- });
411
- return { kind: 'wait' };
412
- }
413
- if (outcome.kind === 'stop') {
414
- log.push(entry('stopped', outcome.message));
415
- return { kind: 'stop', cursor: here };
416
- }
417
- log.push(entry('ok', outcome.message, outcome.detail));
418
- } catch (error) {
419
- const message = error instanceof Error ? error.message : String(error);
420
- const detail = (error as { detail?: Record<string, unknown> }).detail ?? null;
421
- log.push(entry('failed', message, detail));
422
- this.manablox.logger.warn(
423
- { workflow: workflow.id, run: run.id, step: step.id, err: error },
424
- 'workflow step failed',
425
- );
426
- if (!step.continueOnError) return { kind: 'fail', cursor: here, error: message };
427
- }
428
- }
429
- return { kind: 'done' };
430
- }
431
-
432
- private async finish(
433
- run: WorkflowRunRow,
434
- workflow: WorkflowRow,
435
- status: 'succeeded' | 'failed' | 'skipped',
436
- cursor: WorkflowCursor,
437
- log: WorkflowStepLog[],
438
- error: string | null,
439
- ): Promise<void> {
440
- await this.repos.workflows.saveRunProgress(run.id, {
441
- status,
442
- cursor,
443
- log,
444
- error,
445
- finished: true,
446
- });
447
- await this.repos.workflows.touchRun(workflow.id, this.now());
448
- await this.manablox.hooks.run(
449
- 'workflow:afterRun',
450
- {
451
- runId: run.id,
452
- workflowId: workflow.id,
453
- spaceId: workflow.spaceId,
454
- status,
455
- trigger: run.trigger,
456
- },
457
- { manablox: this.manablox, spaceId: workflow.spaceId },
458
- );
459
- }
460
-
461
- // --- context helpers ---------------------------------------------------------
462
-
463
- private async spaceInfo(spaceId: string): Promise<WorkflowRunContext['space']> {
464
- const space = await this.repos.spaces.findById(spaceId);
465
- return space
466
- ? { id: space.id, name: space.name, machineName: space.machineName, url: space.url }
467
- : { id: spaceId, name: '', machineName: '', url: '' };
468
- }
469
-
470
- private async actorInfo(userId: string | null): Promise<WorkflowActor | null> {
471
- if (!userId) return null;
472
- const user = await this.repos.users.findById(userId);
473
- return user ? { id: user.id, name: user.name, email: user.email } : null;
474
- }
475
-
476
- private documentUrl(contentId: string): string {
477
- return `${this.env.adminUrl.replace(/\/$/, '')}/content/${contentId}`;
478
- }
479
- }
480
-
481
- /** Whether an event trigger wants this event for this document. */
482
- export function matchesEvent(
483
- trigger: WorkflowRow['trigger'],
484
- event: WorkflowEvent,
485
- row: Pick<ContentRecord, 'typeId' | 'locale'>,
486
- ): trigger is WorkflowEventTrigger {
487
- if (trigger.kind !== 'event') return false;
488
- const satisfied = EVENT_ALIASES[event];
489
- if (!trigger.events.some((wanted) => satisfied.includes(wanted))) return false;
490
- if (trigger.typeIds.length && !trigger.typeIds.includes(row.typeId)) return false;
491
- if (trigger.locales.length && !trigger.locales.includes(row.locale)) return false;
492
- return true;
493
- }
494
-
495
- /** A row as a template sees it: plain JSON, without the search machinery. */
496
- export function serialise(row: ContentRecord | ContentRow): Record<string, unknown> {
497
- const { search: _search, searchText: _searchText, ...rest } = row as ContentRecord;
498
- return JSON.parse(JSON.stringify(rest)) as Record<string, unknown>;
499
- }
package/src/index.ts DELETED
@@ -1,9 +0,0 @@
1
- export * from './conditions.js';
2
- export * from './cron.js';
3
- export * from './engine.js';
4
- export * from './mail.js';
5
- export * from './push.js';
6
- export * from './service.js';
7
- export * from './steps.js';
8
- export * from './template.js';
9
- export * from './validate.js';
package/src/mail.ts DELETED
@@ -1,38 +0,0 @@
1
- import type { Logger, MailConfig } from '@manablox/core';
2
- import nodemailer from 'nodemailer';
3
-
4
- export interface MailMessage {
5
- to: string[];
6
- subject: string;
7
- text: string;
8
- html?: string | undefined;
9
- }
10
-
11
- /** What the email step needs from the outside world; the tests hand in a recorder. */
12
- export interface Mailer {
13
- send(message: MailMessage): Promise<{ id: string | null }>;
14
- }
15
-
16
- /**
17
- * A mailer over the configured SMTP URL, or `null` when there is none — the step then
18
- * fails with `workflow.mail.notConfigured`, which the run log shows verbatim.
19
- */
20
- export function createMailer(config: MailConfig, logger: Logger): Mailer | null {
21
- if (!config.smtpUrl) return null;
22
- const transport = nodemailer.createTransport(config.smtpUrl);
23
- const from = config.from ?? 'Manablox <no-reply@localhost>';
24
-
25
- return {
26
- async send(message) {
27
- const info = await transport.sendMail({
28
- from,
29
- to: message.to,
30
- subject: message.subject,
31
- text: message.text,
32
- ...(message.html ? { html: message.html } : {}),
33
- });
34
- logger.debug({ to: message.to, id: info.messageId }, 'workflow mail sent');
35
- return { id: info.messageId ?? null };
36
- },
37
- };
38
- }
package/src/push.ts DELETED
@@ -1,61 +0,0 @@
1
- import type { Logger, PushConfig } from '@manablox/core';
2
- import webpush from 'web-push';
3
-
4
- export interface PushTarget {
5
- endpoint: string;
6
- keys: { p256dh: string; auth: string };
7
- }
8
-
9
- /** What a notification carries; the admin's service worker reads exactly this. */
10
- export interface PushPayload {
11
- title: string;
12
- body: string;
13
- url: string | null;
14
- }
15
-
16
- /** What the push step needs from the outside world; the tests hand in a recorder. */
17
- export interface Pusher {
18
- /** `gone` means the push service no longer knows the subscription and the row should go. */
19
- send(target: PushTarget, payload: PushPayload): Promise<'sent' | 'gone'>;
20
- publicKey: string;
21
- }
22
-
23
- export function isPushConfigured(config: PushConfig): boolean {
24
- return Boolean(config.vapidPublicKey && config.vapidPrivateKey);
25
- }
26
-
27
- /**
28
- * A Web Push sender over the configured VAPID keys, or `null` when there are none. The
29
- * keys are made once per installation with `pnpm --filter @manablox/api push:keys`.
30
- */
31
- export function createPusher(config: PushConfig, logger: Logger): Pusher | null {
32
- if (!config.vapidPublicKey || !config.vapidPrivateKey) return null;
33
- const subject = config.subject ?? 'mailto:admin@localhost';
34
- const publicKey = config.vapidPublicKey;
35
- const privateKey = config.vapidPrivateKey;
36
-
37
- return {
38
- publicKey,
39
- async send(target, payload) {
40
- try {
41
- await webpush.sendNotification(target, JSON.stringify(payload), {
42
- vapidDetails: { subject, publicKey, privateKey },
43
- TTL: 60 * 60 * 24,
44
- });
45
- return 'sent';
46
- } catch (error) {
47
- const status = (error as { statusCode?: number }).statusCode;
48
- if (status === 404 || status === 410) {
49
- logger.debug({ endpoint: target.endpoint }, 'push subscription gone');
50
- return 'gone';
51
- }
52
- throw error;
53
- }
54
- },
55
- };
56
- }
57
-
58
- /** A fresh VAPID key pair, for the CLI that prints them. */
59
- export function generatePushKeys(): { publicKey: string; privateKey: string } {
60
- return webpush.generateVAPIDKeys();
61
- }