@manablox/jobs 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +18 -0
  2. package/package.json +3 -3
  3. package/src/index.ts +17 -105
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # `@manablox/jobs`
2
+
3
+ Background work on BullMQ: webhook delivery with retries, image derivatives, scheduled publishing, key pruning. Without Redis the jobs run inline, which is correct for a single-node install.
4
+
5
+ ## Exports
6
+
7
+ - `createJobRunner`, `JobRunner`, `JobPayloads`
8
+ - `attachWebhooks` — turns content hooks into `webhook:deliver` jobs
9
+
10
+ ## Depends on
11
+
12
+ @manablox/db, bullmq
13
+
14
+ ## Check
15
+
16
+ ```sh
17
+ pnpm --filter @manablox/jobs typecheck
18
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manablox/jobs",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,8 +11,8 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.1.0",
15
- "@manablox/db": "0.1.0",
14
+ "@manablox/core": "0.2.0",
15
+ "@manablox/db": "0.2.0",
16
16
  "bullmq": "^6.3.4",
17
17
  "ioredis": "^6.0.0",
18
18
  "drizzle-orm": "^0.45.2"
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
- import { createHmac } from 'node:crypto';
2
1
  import type { Manablox } from '@manablox/core';
3
2
  import { type Database, type Repositories, schema } from '@manablox/db';
4
3
  import { type JobsOptions, Queue, Worker } from 'bullmq';
5
- import { and, eq, sql } from 'drizzle-orm';
4
+ import { sql } from 'drizzle-orm';
6
5
  import { Redis } from 'ioredis';
7
6
 
8
7
  export interface JobPayloads {
9
8
  'webhook:deliver': { webhookId: string; event: string; payload: Record<string, unknown> };
10
9
  'media:derive': { assetId: string; preset: string; format: string };
11
10
  'content:scheduledPublish': { contentId: string };
11
+ 'workflow:run': { runId: string };
12
12
  'maintenance:pruneApiKeys': Record<string, never>;
13
13
  }
14
14
 
@@ -21,11 +21,17 @@ export interface JobRunner {
21
21
  options?: JobsOptions,
22
22
  ): Promise<void>;
23
23
  close(): Promise<void>;
24
+ /** True when there is no queue and `enqueue` runs the job before returning. */
25
+ inline: boolean;
24
26
  }
25
27
 
26
28
  export interface JobHandlers {
27
29
  derive?: (assetId: string, preset: string, format: string) => Promise<void>;
28
30
  publish?: (contentId: string) => Promise<void>;
31
+ /** Executes a queued workflow run; set by the host once the engine exists. */
32
+ workflowRun?: (runId: string) => Promise<void>;
33
+ /** Sends one webhook delivery; set by the host once the webhook service exists. */
34
+ webhookDeliver?: (data: JobPayloads['webhook:deliver']) => Promise<void>;
29
35
  }
30
36
 
31
37
  /** Background work: webhook delivery with retries, image derivatives, scheduled publish. */
@@ -41,6 +47,7 @@ export function createJobRunner(
41
47
  // call sites identical either way.
42
48
  if (!redisUrl) {
43
49
  return {
50
+ inline: true,
44
51
  async enqueue(name, payload) {
45
52
  await runJob(name, payload as never, db, repos, handlers, manablox);
46
53
  },
@@ -76,6 +83,7 @@ export function createJobRunner(
76
83
  });
77
84
 
78
85
  return {
86
+ inline: false,
79
87
  async enqueue(name, payload, options) {
80
88
  await queue.add(name, payload, {
81
89
  attempts: 5,
@@ -103,7 +111,7 @@ async function runJob(
103
111
  switch (name) {
104
112
  case 'webhook:deliver': {
105
113
  const data = payload as JobPayloads['webhook:deliver'];
106
- await deliverWebhook(db, data, manablox);
114
+ await handlers.webhookDeliver?.(data);
107
115
  break;
108
116
  }
109
117
  case 'media:derive': {
@@ -116,6 +124,11 @@ async function runJob(
116
124
  await handlers.publish?.(data.contentId);
117
125
  break;
118
126
  }
127
+ case 'workflow:run': {
128
+ const data = payload as JobPayloads['workflow:run'];
129
+ await handlers.workflowRun?.(data.runId);
130
+ break;
131
+ }
119
132
  case 'maintenance:pruneApiKeys': {
120
133
  await db
121
134
  .delete(schema.apikeys)
@@ -126,106 +139,5 @@ async function runJob(
126
139
  }
127
140
  }
128
141
  void repos;
129
- }
130
-
131
- async function deliverWebhook(
132
- db: Database,
133
- data: JobPayloads['webhook:deliver'],
134
- manablox: Manablox,
135
- ): Promise<void> {
136
- const rows = await db
137
- .select()
138
- .from(schema.webhooks)
139
- .where(and(eq(schema.webhooks.id, data.webhookId), eq(schema.webhooks.enabled, true)))
140
- .limit(1);
141
-
142
- const webhook = rows[0];
143
- if (!webhook) return;
144
-
145
- const body = JSON.stringify({
146
- event: data.event,
147
- payload: data.payload,
148
- at: new Date().toISOString(),
149
- });
150
-
151
- const headers: Record<string, string> = {
152
- 'content-type': 'application/json',
153
- 'x-manablox-event': data.event,
154
- };
155
-
156
- // Signed so a receiver can verify the payload actually came from this instance.
157
- if (webhook.secret) {
158
- headers['x-manablox-signature'] = createHmac('sha256', webhook.secret)
159
- .update(body)
160
- .digest('hex');
161
- }
162
-
163
- let status: number | null = null;
164
- let error: string | null = null;
165
-
166
- try {
167
- const response = await fetch(webhook.url, {
168
- method: 'POST',
169
- headers,
170
- body,
171
- signal: AbortSignal.timeout(10_000),
172
- });
173
- status = response.status;
174
- if (!response.ok) error = `HTTP ${response.status}`;
175
- } catch (cause) {
176
- error = cause instanceof Error ? cause.message : String(cause);
177
- }
178
-
179
- await db.insert(schema.webhookDeliveries).values({
180
- webhookId: webhook.id,
181
- event: data.event,
182
- payload: data.payload,
183
- status,
184
- error,
185
- });
186
-
187
- // Throwing hands the retry decision back to BullMQ's backoff policy.
188
- if (error) {
189
- manablox.logger.warn({ webhook: webhook.id, status, error }, 'webhook delivery failed');
190
- throw new Error(error);
191
- }
192
- }
193
-
194
- /** Fans content lifecycle events out to the space's webhooks. */
195
- export function attachWebhooks(manablox: Manablox, db: Database, jobs: JobRunner): void {
196
- const dispatch = async (event: string, spaceId: string, payload: Record<string, unknown>) => {
197
- const hooks = await db
198
- .select({ id: schema.webhooks.id, events: schema.webhooks.events })
199
- .from(schema.webhooks)
200
- .where(and(eq(schema.webhooks.spaceId, spaceId), eq(schema.webhooks.enabled, true)));
201
-
202
- for (const hook of hooks) {
203
- if (hook.events.length > 0 && !hook.events.includes(event)) continue;
204
- await jobs.enqueue('webhook:deliver', { webhookId: hook.id, event, payload });
205
- }
206
- };
207
-
208
- manablox.hooks.on(
209
- 'content:afterPublish',
210
- async (row) => {
211
- await dispatch('content.published', row.spaceId, { id: row.id, permalink: row.permalink });
212
- },
213
- { source: '@manablox/jobs' },
214
- );
215
-
216
- manablox.hooks.on(
217
- 'content:afterUpdate',
218
- async (row) => {
219
- await dispatch('content.updated', row.spaceId, { id: row.id });
220
- },
221
- { source: '@manablox/jobs' },
222
- );
223
-
224
- manablox.hooks.on(
225
- 'content:afterDelete',
226
- async (payload, context) => {
227
- if (context.spaceId) await dispatch('content.deleted', context.spaceId, { id: payload.id });
228
- },
229
- { source: '@manablox/jobs' },
230
- );
142
+ void manablox;
231
143
  }