flowli 0.2.1 → 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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Typed jobs for modern TypeScript backends.**
4
4
 
5
+ [npm](https://www.npmjs.com/package/flowli) · [JSR](https://jsr.io/@alialnaghmoush/flowli) · [GitHub](https://github.com/alialnaghmoush/flowli)
6
+
5
7
  Flowli is a jobs runtime with a code-first API, first-class execution strategies, runtime-scoped context injection, and pluggable Redis drivers.
6
8
 
7
9
  Define jobs once. Run them anywhere.
@@ -9,6 +11,7 @@ Define jobs once. Run them anywhere.
9
11
  ## Navigate
10
12
 
11
13
  - [Why Flowli](#why-flowli)
14
+ - [Compare](#compare)
12
15
  - [What It Feels Like](#what-it-feels-like)
13
16
  - [The Core Idea](#the-core-idea)
14
17
  - [Primary Authoring Path](#primary-authoring-path)
@@ -46,6 +49,30 @@ Flowli is built around a different model:
46
49
 
47
50
  It is a typed runtime for background and deferred execution with a code-first, framework-agnostic design.
48
51
 
52
+ ## Compare
53
+
54
+ Choose Flowli when you want:
55
+
56
+ - application-first jobs authored in code, not in a dashboard
57
+ - a single runtime that supports both direct execution and persisted async work
58
+ - typed `context` injection without framework lock-in
59
+ - pluggable Redis clients behind a small API surface
60
+
61
+ Flowli vs BullMQ:
62
+
63
+ - Flowli centers the typed job-definition experience; BullMQ centers queue primitives and worker infrastructure
64
+ - Flowli makes `run()` a first-class in-process path; BullMQ is primarily queue-first
65
+
66
+ Flowli vs Trigger.dev:
67
+
68
+ - Flowli stays library-first and infrastructure-light
69
+ - Trigger.dev is stronger when you want a hosted platform, dashboard, and workflow operations out of the box
70
+
71
+ Flowli vs Inngest:
72
+
73
+ - Flowli is better suited when you want application-local jobs and direct runtime wiring
74
+ - Inngest is stronger when you want event-first workflows across services
75
+
49
76
  ```mermaid
50
77
  flowchart LR
51
78
  App["App Code<br/>Routes, Services, Scripts, Tests"] --> Runtime["defineJobs()<br/>Flowli Runtime"]
@@ -66,93 +93,9 @@ flowchart LR
66
93
  ## What It Feels Like
67
94
 
68
95
  ```ts
69
- // src/flowli/jobs/create-audit-log.ts
70
- import * as v from "valibot";
71
- import { job } from "flowli";
72
- import type { AppContext } from "..";
73
-
74
- export const auditLogSchema = v.object({
75
- entityType: v.string(),
76
- entityId: v.string(),
77
- action: v.string(),
78
- message: v.string(),
79
- });
80
-
81
- export const auditLogMeta = v.object({
82
- requestId: v.string(),
83
- actorId: v.optional(v.string()),
84
- });
85
-
86
- export const createAuditLog = job.withContext<AppContext>()(
87
- "create_audit_log",
88
- {
89
- input: auditLogSchema,
90
- meta: auditLogMeta,
91
- handler: async ({ input, ctx, meta }) => {
92
- await ctx.db.insert(ctx.schema.auditLogs).values({
93
- entityType: input.entityType,
94
- entityId: input.entityId,
95
- action: input.action,
96
- message: input.message,
97
- requestId: meta?.requestId,
98
- actorId: meta?.actorId ?? null,
99
- });
100
-
101
- ctx.logger.info({
102
- job: "create_audit_log",
103
- requestId: meta?.requestId,
104
- entityId: input.entityId,
105
- });
106
- },
107
- },
108
- );
109
- ```
110
-
111
- ```ts
112
- // src/flowli/jobs/send-notification-email.ts
113
96
  import * as v from "valibot";
114
- import { job } from "flowli";
115
- import type { AppContext } from "..";
116
-
117
- export const notificationEmailSchema = v.object({
118
- email: v.string(),
119
- subject: v.string(),
120
- message: v.string(),
121
- });
122
-
123
- export const sendNotificationEmail = job.withContext<AppContext>()(
124
- "send_notification_email",
125
- {
126
- input: notificationEmailSchema,
127
- handler: async ({ input, ctx }) => {
128
- await ctx.mailer.send({
129
- to: input.email,
130
- subject: input.subject,
131
- text: input.message,
132
- });
133
- },
134
- },
135
- );
136
- ```
137
-
138
- ```ts
139
- // src/flowli/jobs/index.ts
140
- export * from "./create-audit-log";
141
- export * from "./send-notification-email";
142
- ```
143
-
144
- ```ts
145
- // src/flowli/index.ts
146
97
  import { defineJobs } from "flowli";
147
98
  import { ioredisDriver } from "flowli/ioredis";
148
- import * as jobs from "./jobs";
149
-
150
- export type AppContext = {
151
- db: typeof db;
152
- schema: typeof schema;
153
- logger: typeof logger;
154
- mailer: typeof mailer;
155
- };
156
99
 
157
100
  export const flowli = defineJobs({
158
101
  driver: ioredisDriver({
@@ -165,7 +108,59 @@ export const flowli = defineJobs({
165
108
  logger,
166
109
  mailer,
167
110
  }),
168
- jobs,
111
+ jobs: ({ job }) => {
112
+ const auditLogSchema = v.object({
113
+ entityType: v.string(),
114
+ entityId: v.string(),
115
+ action: v.string(),
116
+ message: v.string(),
117
+ });
118
+
119
+ const auditLogMetaSchema = v.object({
120
+ requestId: v.string(),
121
+ actorId: v.optional(v.string()),
122
+ });
123
+
124
+ const notificationEmailSchema = v.object({
125
+ email: v.string(),
126
+ subject: v.string(),
127
+ message: v.string(),
128
+ });
129
+
130
+ return {
131
+ createAuditLog: job("create_audit_log", {
132
+ input: auditLogSchema,
133
+ meta: auditLogMetaSchema,
134
+ handler: async ({ input, ctx, meta }) => {
135
+ await ctx.db.insert(ctx.schema.auditLogs).values({
136
+ entityType: input.entityType,
137
+ entityId: input.entityId,
138
+ action: input.action,
139
+ message: input.message,
140
+ requestId: meta?.requestId,
141
+ actorId: meta?.actorId ?? null,
142
+ });
143
+
144
+ ctx.logger.info({
145
+ job: "create_audit_log",
146
+ requestId: meta?.requestId,
147
+ entityId: input.entityId,
148
+ });
149
+ },
150
+ }),
151
+
152
+ sendNotificationEmail: job("send_notification_email", {
153
+ input: notificationEmailSchema,
154
+ handler: async ({ input, ctx }) => {
155
+ await ctx.mailer.send({
156
+ to: input.email,
157
+ subject: input.subject,
158
+ text: input.message,
159
+ });
160
+ },
161
+ }),
162
+ };
163
+ },
169
164
  });
170
165
  ```
171
166
 
@@ -236,63 +231,45 @@ flowchart TD
236
231
 
237
232
  The canonical Flowli path is runtime-first:
238
233
 
239
- ```ts
240
- // src/flowli/jobs/create-audit-log.ts
241
- import * as v from "valibot";
242
- import { job } from "flowli";
243
- import type { AppContext } from "..";
244
-
245
- export const auditLogSchema = v.object({
246
- entityId: v.string(),
247
- action: v.string(),
248
- });
249
-
250
- export const auditLogMeta = v.object({
251
- requestId: v.string(),
252
- });
253
-
254
- export const createAuditLog = job.withContext<AppContext>()(
255
- "create_audit_log",
256
- {
257
- input: auditLogSchema,
258
- meta: auditLogMeta,
259
- handler: async ({ input, ctx, meta }) => {
260
- await ctx.db.insert("audit_logs").values({
261
- entityId: input.entityId,
262
- action: input.action,
263
- requestId: meta?.requestId,
264
- });
265
-
266
- ctx.logger.info({
267
- entityId: input.entityId,
268
- action: input.action,
269
- });
270
- },
271
- },
272
- );
273
- ```
274
-
275
- ```ts
276
- // src/flowli/jobs/index.ts
277
- export * from "./create-audit-log";
278
- ```
279
-
280
234
  ```ts
281
235
  // src/flowli/index.ts
236
+ import * as v from "valibot";
282
237
  import { defineJobs } from "flowli";
283
- import * as jobs from "./jobs";
284
-
285
- export type AppContext = {
286
- logger: typeof logger;
287
- db: typeof db;
288
- };
289
238
 
290
239
  export const flowli = defineJobs({
291
240
  context: {
292
241
  logger,
293
242
  db,
294
243
  },
295
- jobs,
244
+ jobs: ({ job }) => {
245
+ const auditLogSchema = v.object({
246
+ entityId: v.string(),
247
+ action: v.string(),
248
+ });
249
+
250
+ const auditLogMetaSchema = v.object({
251
+ requestId: v.string(),
252
+ });
253
+
254
+ return {
255
+ createAuditLog: job("create_audit_log", {
256
+ input: auditLogSchema,
257
+ meta: auditLogMetaSchema,
258
+ handler: async ({ input, ctx, meta }) => {
259
+ await ctx.db.insert("audit_logs").values({
260
+ entityId: input.entityId,
261
+ action: input.action,
262
+ requestId: meta?.requestId,
263
+ });
264
+
265
+ ctx.logger.info({
266
+ entityId: input.entityId,
267
+ action: input.action,
268
+ });
269
+ },
270
+ }),
271
+ };
272
+ },
296
273
  });
297
274
  ```
298
275
 
@@ -407,43 +384,9 @@ In short:
407
384
  To persist jobs, add a driver:
408
385
 
409
386
  ```ts
410
- // src/flowli/jobs/send-email.ts
411
387
  import * as v from "valibot";
412
- import { job } from "flowli";
413
- import type { AppContext } from "..";
414
-
415
- export const emailInputSchema = v.object({
416
- email: v.string(),
417
- subject: v.string(),
418
- });
419
-
420
- export const sendEmail = job.withContext<AppContext>()("send_email", {
421
- input: emailInputSchema,
422
- handler: async ({ input, ctx }) => {
423
- await ctx.mailer.send({
424
- to: input.email,
425
- subject: input.subject,
426
- });
427
- },
428
- });
429
- ```
430
-
431
- ```ts
432
- // src/flowli/jobs/index.ts
433
- export * from "./send-email";
434
- ```
435
-
436
- ```ts
437
- // src/flowli/index.ts
438
388
  import { defineJobs } from "flowli";
439
389
  import { ioredisDriver } from "flowli/ioredis";
440
- import * as jobs from "./jobs";
441
-
442
- export type AppContext = {
443
- db: typeof db;
444
- logger: typeof logger;
445
- mailer: typeof mailer;
446
- };
447
390
 
448
391
  export const flowli = defineJobs({
449
392
  driver: ioredisDriver({
@@ -455,7 +398,24 @@ export const flowli = defineJobs({
455
398
  logger,
456
399
  mailer,
457
400
  }),
458
- jobs,
401
+ jobs: ({ job }) => {
402
+ const emailInputSchema = v.object({
403
+ email: v.string(),
404
+ subject: v.string(),
405
+ });
406
+
407
+ return {
408
+ sendEmail: job("send_email", {
409
+ input: emailInputSchema,
410
+ handler: async ({ input, ctx }) => {
411
+ await ctx.mailer.send({
412
+ to: input.email,
413
+ subject: input.subject,
414
+ });
415
+ },
416
+ }),
417
+ };
418
+ },
459
419
  });
460
420
  ```
461
421
 
@@ -467,6 +427,12 @@ Flowli supports:
467
427
 
468
428
  The job definitions stay the same. Only the driver changes.
469
429
 
430
+ Retry defaults can be attached globally or per job, including:
431
+
432
+ - `fixed` and `exponential` backoff
433
+ - capped retries with `maxDelayMs`
434
+ - jitter to spread retry bursts
435
+
470
436
  ## Runner
471
437
 
472
438
  The runner is explicit and secondary by design.
@@ -31,9 +31,15 @@ export interface JobDefaults {
31
31
  readonly maxAttempts?: number;
32
32
  readonly backoff?: BackoffOptions;
33
33
  }
34
+ export interface BackoffJitterOptions {
35
+ readonly minRatio: number;
36
+ readonly maxRatio: number;
37
+ }
34
38
  export interface BackoffOptions {
35
39
  readonly type: "fixed" | "exponential";
36
40
  readonly delayMs: number;
41
+ readonly maxDelayMs?: number;
42
+ readonly jitter?: boolean | BackoffJitterOptions;
37
43
  }
38
44
  export interface FlowliInvocationOptions<TMeta> {
39
45
  readonly meta?: TMeta;
@@ -99,7 +105,7 @@ export interface FlowliDriver {
99
105
  acquireNextReady(now: number, leaseMs: number): Promise<AcquiredJobRecord | null>;
100
106
  renewLease(jobId: string, token: string, leaseMs: number): Promise<boolean>;
101
107
  markCompleted(acquired: AcquiredJobRecord, finishedAt: number): Promise<void>;
102
- markFailed(acquired: AcquiredJobRecord, finishedAt: number, error: PersistedJobError): Promise<"failed" | "retrying">;
108
+ markFailed(acquired: AcquiredJobRecord, finishedAt: number, error: PersistedJobError): Promise<MarkFailedResult>;
103
109
  materializeDueSchedules(now: number, leaseMs: number): Promise<number>;
104
110
  }
105
111
  export interface DefineJobsOptions<TJobs extends JobsRecord, TContext extends FlowliContextRecord> {
@@ -153,9 +159,16 @@ export interface PersistedJobRecord {
153
159
  readonly updatedAt: number;
154
160
  readonly scheduledFor: number;
155
161
  readonly attemptsMade: number;
162
+ readonly failureCount: number;
156
163
  readonly maxAttempts: number;
157
164
  readonly backoff?: BackoffOptions;
158
165
  readonly lastError?: PersistedJobError;
166
+ readonly lastFailedAt?: number;
167
+ readonly nextRetryAt?: number;
168
+ }
169
+ export interface MarkFailedResult {
170
+ readonly state: "failed" | "retrying";
171
+ readonly retryAt?: number;
159
172
  }
160
173
  export interface AcquiredJobRecord {
161
174
  readonly token: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { defineJobs } from "./core/define-jobs.js";
2
2
  export { FlowliDefinitionError, FlowliDriverError, FlowliError, FlowliSchedulingError, FlowliStrategyError, FlowliValidationError, } from "./core/errors.js";
3
3
  export { createContextualJobFactory, job } from "./core/job.js";
4
- export type { BackoffOptions, DefineJobsBuilder, DelayValue, FlowliContextRecord, FlowliContextResolver, FlowliDriver, FlowliInvocationOptions, FlowliJobSurface, FlowliRuntime, JobDefaults, JobDefinition, JobHandlerArgs, JobReceipt, ScheduleInvocation, ScheduleReceipt, StandardSchemaIssue, StandardSchemaV1, } from "./core/types.js";
4
+ export type { BackoffJitterOptions, BackoffOptions, DefineJobsBuilder, DelayValue, FlowliContextRecord, FlowliContextResolver, FlowliDriver, FlowliInvocationOptions, FlowliJobSurface, FlowliRuntime, JobDefaults, JobDefinition, JobHandlerArgs, JobReceipt, ScheduleInvocation, ScheduleReceipt, StandardSchemaIssue, StandardSchemaV1, } from "./core/types.js";
@@ -3,6 +3,7 @@ export interface RunnerHooks {
3
3
  readonly onJobStarted?: (jobId: string, jobName: string) => void | Promise<void>;
4
4
  readonly onJobCompleted?: (jobId: string, jobName: string) => void | Promise<void>;
5
5
  readonly onJobFailed?: (jobId: string, jobName: string, error: PersistedJobError) => void | Promise<void>;
6
+ readonly onJobRetryScheduled?: (jobId: string, jobName: string, retryAt: number, error: PersistedJobError) => void | Promise<void>;
6
7
  }
7
8
  export interface RunnerOptions<TJobs extends JobsRecord, TContext extends FlowliContextRecord> {
8
9
  readonly flowli: FlowliRuntime<TJobs, TContext>;
package/jsr.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://jsr.io/schema/config-file.v1.json",
3
3
  "name": "@alialnaghmoush/flowli",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
7
7
  "./ioredis": "./src/ioredis.ts",
@@ -18,9 +18,19 @@
18
18
  "package.json"
19
19
  ],
20
20
  "exclude": [
21
+ ".cursor/",
22
+ ".git/",
23
+ ".gitignore",
24
+ ".npm-cache/",
25
+ "biome.json",
26
+ "bun.lock",
27
+ "docker-compose.yml",
21
28
  "dist/",
22
29
  "node_modules/",
23
30
  "test/",
31
+ "tsconfig.build.json",
32
+ "tsconfig.json",
33
+ "tsconfig.type-tests.json",
24
34
  "type-tests/"
25
35
  ]
26
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Flowli is a jobs runtime with a code-first API, first-class execution strategies, runtime-scoped context injection, and pluggable Redis drivers.",
5
5
  "keywords": [
6
6
  "jobs",
@@ -20,6 +20,7 @@
20
20
  "type": "git",
21
21
  "url": "git+https://github.com/alialnaghmoush/flowli.git"
22
22
  },
23
+ "license": "MIT",
23
24
  "main": "./dist/index.js",
24
25
  "types": "./dist/index.d.ts",
25
26
  "type": "module",
@@ -68,9 +69,9 @@
68
69
  },
69
70
  "peerDependencies": {
70
71
  "@tanstack/react-start": "^1.0.0-rc || ^1",
71
- "ioredis": "",
72
+ "ioredis": "^5",
72
73
  "next": "^15 || ^16",
73
- "redis": "",
74
+ "redis": "^5",
74
75
  "typescript": "^5",
75
76
  "valibot": "^1.3.0",
76
77
  "zod": "^4.3.6"