flowli 0.2.1
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 +21 -0
- package/README.md +815 -0
- package/dist/bun-redis.d.ts +1 -0
- package/dist/bun-redis.js +3 -0
- package/dist/core/define-jobs.d.ts +8 -0
- package/dist/core/define-jobs.js +47 -0
- package/dist/core/errors.d.ts +21 -0
- package/dist/core/errors.js +35 -0
- package/dist/core/job.d.ts +9 -0
- package/dist/core/job.js +20 -0
- package/dist/core/types.d.ts +175 -0
- package/dist/core/types.js +1 -0
- package/dist/driver/duration.d.ts +2 -0
- package/dist/driver/duration.js +23 -0
- package/dist/driver/encoding.d.ts +2 -0
- package/dist/driver/encoding.js +9 -0
- package/dist/driver/keys.d.ts +15 -0
- package/dist/driver/keys.js +14 -0
- package/dist/driver/records.d.ts +25 -0
- package/dist/driver/records.js +61 -0
- package/dist/driver/scheduling.d.ts +6 -0
- package/dist/driver/scheduling.js +127 -0
- package/dist/drivers/bun-redis.d.ts +24 -0
- package/dist/drivers/bun-redis.js +21 -0
- package/dist/drivers/ioredis.d.ts +16 -0
- package/dist/drivers/ioredis.js +31 -0
- package/dist/drivers/redis.d.ts +27 -0
- package/dist/drivers/redis.js +23 -0
- package/dist/drivers/shared.d.ts +21 -0
- package/dist/drivers/shared.js +172 -0
- package/dist/hono.d.ts +1 -0
- package/dist/hono.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/dist/integrations/hono.d.ts +10 -0
- package/dist/integrations/hono.js +7 -0
- package/dist/integrations/next.d.ts +18 -0
- package/dist/integrations/tanstack-start.d.ts +20 -0
- package/dist/ioredis.d.ts +1 -0
- package/dist/ioredis.js +3 -0
- package/dist/next.d.ts +1 -0
- package/dist/next.js +4 -0
- package/dist/redis.d.ts +1 -0
- package/dist/redis.js +3 -0
- package/dist/runner/create-runner.d.ts +3 -0
- package/dist/runner/create-runner.js +104 -0
- package/dist/runner/types.d.ts +20 -0
- package/dist/runner/types.js +1 -0
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +3 -0
- package/dist/runtime/create-job-surface.d.ts +2 -0
- package/dist/runtime/create-job-surface.js +24 -0
- package/dist/runtime/invoke-handler.d.ts +2 -0
- package/dist/runtime/invoke-handler.js +7 -0
- package/dist/runtime/normalize-jobs.d.ts +5 -0
- package/dist/runtime/normalize-jobs.js +14 -0
- package/dist/runtime/resolve-context.d.ts +2 -0
- package/dist/runtime/resolve-context.js +6 -0
- package/dist/runtime/validate.d.ts +2 -0
- package/dist/runtime/validate.js +15 -0
- package/dist/strategies/delay.d.ts +2 -0
- package/dist/strategies/delay.js +32 -0
- package/dist/strategies/enqueue.d.ts +2 -0
- package/dist/strategies/enqueue.js +30 -0
- package/dist/strategies/run.d.ts +2 -0
- package/dist/strategies/run.js +10 -0
- package/dist/strategies/schedule.d.ts +2 -0
- package/dist/strategies/schedule.js +33 -0
- package/dist/tanstack-start.d.ts +1 -0
- package/dist/tanstack-start.js +4 -0
- package/jsr.json +26 -0
- package/package.json +135 -0
package/README.md
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
# Flowli
|
|
2
|
+
|
|
3
|
+
**Typed jobs for modern TypeScript backends.**
|
|
4
|
+
|
|
5
|
+
Flowli is a jobs runtime with a code-first API, first-class execution strategies, runtime-scoped context injection, and pluggable Redis drivers.
|
|
6
|
+
|
|
7
|
+
Define jobs once. Run them anywhere.
|
|
8
|
+
|
|
9
|
+
## Navigate
|
|
10
|
+
|
|
11
|
+
- [Why Flowli](#why-flowli)
|
|
12
|
+
- [What It Feels Like](#what-it-feels-like)
|
|
13
|
+
- [The Core Idea](#the-core-idea)
|
|
14
|
+
- [Primary Authoring Path](#primary-authoring-path)
|
|
15
|
+
- [`run()` Works Without Infrastructure](#run-works-without-infrastructure)
|
|
16
|
+
- [Rich Example](#rich-example)
|
|
17
|
+
- [Context vs Meta](#context-vs-meta)
|
|
18
|
+
- [Async Execution](#async-execution)
|
|
19
|
+
- [Runner](#runner)
|
|
20
|
+
- [Async Semantics](#async-semantics)
|
|
21
|
+
- [Reusable Predeclared Jobs](#reusable-predeclared-jobs)
|
|
22
|
+
- [Hono](#hono)
|
|
23
|
+
- [Next.js](#nextjs)
|
|
24
|
+
- [TanStack Start](#tanstack-start)
|
|
25
|
+
- [Install](#install)
|
|
26
|
+
- [Production](#production)
|
|
27
|
+
- [What Flowli Optimizes For](#what-flowli-optimizes-for)
|
|
28
|
+
- [Exports](#exports)
|
|
29
|
+
- [Status](#status)
|
|
30
|
+
|
|
31
|
+
## Why Flowli
|
|
32
|
+
|
|
33
|
+
Most job systems make one of these tradeoffs:
|
|
34
|
+
|
|
35
|
+
- great queues, weak TypeScript ergonomics
|
|
36
|
+
- strong typing, but framework lock-in
|
|
37
|
+
- easy background work, but awkward direct execution in app code and tests
|
|
38
|
+
|
|
39
|
+
Flowli is built around a different model:
|
|
40
|
+
|
|
41
|
+
- author jobs like application code, not infrastructure config
|
|
42
|
+
- keep `context` centralized in the runtime
|
|
43
|
+
- use the same job surface in route handlers, scripts, tests, and workers
|
|
44
|
+
- choose execution strategy per call: `run`, `enqueue`, `delay`, `schedule`
|
|
45
|
+
- swap Redis clients without rewriting job definitions
|
|
46
|
+
|
|
47
|
+
It is a typed runtime for background and deferred execution with a code-first, framework-agnostic design.
|
|
48
|
+
|
|
49
|
+
```mermaid
|
|
50
|
+
flowchart LR
|
|
51
|
+
App["App Code<br/>Routes, Services, Scripts, Tests"] --> Runtime["defineJobs()<br/>Flowli Runtime"]
|
|
52
|
+
Jobs["Job Definitions<br/>input, meta, handler"] --> Runtime
|
|
53
|
+
Context["Runtime Context<br/>db, logger, mailer, config"] --> Runtime
|
|
54
|
+
|
|
55
|
+
Runtime --> Run["run()<br/>in-process"]
|
|
56
|
+
Runtime --> Persist["enqueue() / delay() / schedule()"]
|
|
57
|
+
|
|
58
|
+
Persist --> Driver["Redis Driver<br/>ioredis, redis, Bun Redis"]
|
|
59
|
+
Driver --> Redis["Redis-compatible backend"]
|
|
60
|
+
Runner["createRunner()"] --> Driver
|
|
61
|
+
Runner --> Jobs
|
|
62
|
+
|
|
63
|
+
Frameworks["Hono / Next.js / TanStack Start"] --> Runtime
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## What It Feels Like
|
|
67
|
+
|
|
68
|
+
```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
|
+
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
|
+
import { defineJobs } from "flowli";
|
|
147
|
+
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
|
+
|
|
157
|
+
export const flowli = defineJobs({
|
|
158
|
+
driver: ioredisDriver({
|
|
159
|
+
client: redis,
|
|
160
|
+
prefix: "app",
|
|
161
|
+
}),
|
|
162
|
+
context: async () => ({
|
|
163
|
+
db,
|
|
164
|
+
schema,
|
|
165
|
+
logger,
|
|
166
|
+
mailer,
|
|
167
|
+
}),
|
|
168
|
+
jobs,
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Then use it where the work happens:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
await flowli.createAuditLog.run(
|
|
176
|
+
{
|
|
177
|
+
entityType: "invoice",
|
|
178
|
+
entityId: "inv_123",
|
|
179
|
+
action: "invoice.created",
|
|
180
|
+
message: "Invoice created",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
meta: {
|
|
184
|
+
requestId: "req_123",
|
|
185
|
+
actorId: "user_123",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await flowli.sendNotificationEmail.enqueue({
|
|
191
|
+
email: "sam@example.com",
|
|
192
|
+
subject: "Flowli event received",
|
|
193
|
+
message: "A new invoice was created.",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await flowli.sendNotificationEmail.delay("10m", {
|
|
197
|
+
email: "sam@example.com",
|
|
198
|
+
subject: "Delayed follow-up",
|
|
199
|
+
message: "This is your delayed notification.",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await flowli.sendNotificationEmail.schedule({
|
|
203
|
+
cron: "0 8 * * *",
|
|
204
|
+
input: {
|
|
205
|
+
email: "sam@example.com",
|
|
206
|
+
subject: "Daily digest",
|
|
207
|
+
message: "Here is your daily digest.",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## The Core Idea
|
|
213
|
+
|
|
214
|
+
Flowli is built around four primitives:
|
|
215
|
+
|
|
216
|
+
1. `job()`
|
|
217
|
+
Define one unit of work with typed `input`, optional typed `meta`, and a handler.
|
|
218
|
+
2. `defineJobs()`
|
|
219
|
+
Bind jobs to a runtime `context`, optional driver, and shared defaults.
|
|
220
|
+
3. execution strategies
|
|
221
|
+
Choose `run`, `enqueue`, `delay`, or `schedule` per invocation.
|
|
222
|
+
4. optional async runtime
|
|
223
|
+
Attach `createRunner({ flowli })` only when you want persisted async processing.
|
|
224
|
+
|
|
225
|
+
```mermaid
|
|
226
|
+
flowchart TD
|
|
227
|
+
Define["job()"] --> Bind["defineJobs()"]
|
|
228
|
+
Bind --> Choose{"Choose a strategy"}
|
|
229
|
+
Choose --> Run["run()<br/>validate -> resolve context -> invoke handler"]
|
|
230
|
+
Choose --> Enqueue["enqueue()<br/>persist now"]
|
|
231
|
+
Choose --> Delay["delay()<br/>persist with scheduledFor"]
|
|
232
|
+
Choose --> Schedule["schedule()<br/>persist recurring definition"]
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Primary Authoring Path
|
|
236
|
+
|
|
237
|
+
The canonical Flowli path is runtime-first:
|
|
238
|
+
|
|
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
|
+
```ts
|
|
281
|
+
// src/flowli/index.ts
|
|
282
|
+
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
|
+
|
|
290
|
+
export const flowli = defineJobs({
|
|
291
|
+
context: {
|
|
292
|
+
logger,
|
|
293
|
+
db,
|
|
294
|
+
},
|
|
295
|
+
jobs,
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
This keeps the mental model clean:
|
|
300
|
+
|
|
301
|
+
- runtime `context` is defined in one place
|
|
302
|
+
- jobs are authored against that runtime
|
|
303
|
+
- handlers receive typed `ctx`
|
|
304
|
+
- app wiring stays separate from business logic
|
|
305
|
+
|
|
306
|
+
## `run()` Works Without Infrastructure
|
|
307
|
+
|
|
308
|
+
`run()` is intentionally independent of drivers, queues, leases, and runner state.
|
|
309
|
+
|
|
310
|
+
That makes Flowli useful in:
|
|
311
|
+
|
|
312
|
+
- route handlers
|
|
313
|
+
- CLI scripts
|
|
314
|
+
- local development
|
|
315
|
+
- unit tests
|
|
316
|
+
- synchronous side-effect flows
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
await flowli.createAuditLog.run(
|
|
320
|
+
{
|
|
321
|
+
entityId: "record_1",
|
|
322
|
+
action: "record.created",
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
meta: {
|
|
326
|
+
requestId: "req_1",
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Rich Example
|
|
333
|
+
|
|
334
|
+
A realistic service can use both direct execution and background work from the same runtime:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
// src/services/create-record.ts
|
|
338
|
+
import { flowli } from "../flowli";
|
|
339
|
+
|
|
340
|
+
export async function createRecord(input: {
|
|
341
|
+
title: string;
|
|
342
|
+
description?: string;
|
|
343
|
+
}) {
|
|
344
|
+
const [record] = await db
|
|
345
|
+
.insert(schema.records)
|
|
346
|
+
.values({
|
|
347
|
+
title: input.title,
|
|
348
|
+
description: input.description ?? null,
|
|
349
|
+
})
|
|
350
|
+
.returning();
|
|
351
|
+
|
|
352
|
+
await flowli.createAuditLog.run(
|
|
353
|
+
{
|
|
354
|
+
entityType: "record",
|
|
355
|
+
entityId: String(record.id),
|
|
356
|
+
action: "record.created",
|
|
357
|
+
message: `Record "${record.title}" was created`,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
meta: {
|
|
361
|
+
requestId: "req_123",
|
|
362
|
+
actorId: "user_123",
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
await flowli.sendNotificationEmail.enqueue({
|
|
368
|
+
email: "owner@example.com",
|
|
369
|
+
subject: "Record created",
|
|
370
|
+
message: `A new record named "${record.title}" is ready.`,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return record;
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
One job surface. Multiple execution modes. Same typing story.
|
|
378
|
+
|
|
379
|
+
## Context vs Meta
|
|
380
|
+
|
|
381
|
+
This separation is intentional and important.
|
|
382
|
+
|
|
383
|
+
Use `context` for runtime-scoped dependencies:
|
|
384
|
+
|
|
385
|
+
- `db`
|
|
386
|
+
- `schema`
|
|
387
|
+
- `logger`
|
|
388
|
+
- `mailer`
|
|
389
|
+
- `storage`
|
|
390
|
+
- `config`
|
|
391
|
+
|
|
392
|
+
Use `meta` for invocation-scoped values:
|
|
393
|
+
|
|
394
|
+
- `requestId`
|
|
395
|
+
- `actorId`
|
|
396
|
+
- `locale`
|
|
397
|
+
- `tenantId`
|
|
398
|
+
- `traceId`
|
|
399
|
+
|
|
400
|
+
In short:
|
|
401
|
+
|
|
402
|
+
- `context` is infrastructure and shared services
|
|
403
|
+
- `meta` is request or invocation data
|
|
404
|
+
|
|
405
|
+
## Async Execution
|
|
406
|
+
|
|
407
|
+
To persist jobs, add a driver:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// src/flowli/jobs/send-email.ts
|
|
411
|
+
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
|
+
import { defineJobs } from "flowli";
|
|
439
|
+
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
|
+
|
|
448
|
+
export const flowli = defineJobs({
|
|
449
|
+
driver: ioredisDriver({
|
|
450
|
+
client: redis,
|
|
451
|
+
prefix: "app",
|
|
452
|
+
}),
|
|
453
|
+
context: async () => ({
|
|
454
|
+
db,
|
|
455
|
+
logger,
|
|
456
|
+
mailer,
|
|
457
|
+
}),
|
|
458
|
+
jobs,
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Flowli supports:
|
|
463
|
+
|
|
464
|
+
- `flowli/ioredis`
|
|
465
|
+
- `flowli/redis`
|
|
466
|
+
- `flowli/bun-redis`
|
|
467
|
+
|
|
468
|
+
The job definitions stay the same. Only the driver changes.
|
|
469
|
+
|
|
470
|
+
## Runner
|
|
471
|
+
|
|
472
|
+
The runner is explicit and secondary by design.
|
|
473
|
+
|
|
474
|
+
Flowli is not a worker-first framework. The story stays:
|
|
475
|
+
|
|
476
|
+
- define jobs
|
|
477
|
+
- configure Flowli
|
|
478
|
+
- optionally attach a runner
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import { createRunner } from "flowli/runner";
|
|
482
|
+
|
|
483
|
+
const runner = createRunner({
|
|
484
|
+
flowli,
|
|
485
|
+
concurrency: 5,
|
|
486
|
+
pollIntervalMs: 1_000,
|
|
487
|
+
leaseMs: 30_000,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
await runner.runOnce();
|
|
491
|
+
await runner.start();
|
|
492
|
+
await runner.stop();
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`createRunner()` consumes an existing runtime. It does not recreate jobs or rebuild context.
|
|
496
|
+
|
|
497
|
+
## Async Semantics
|
|
498
|
+
|
|
499
|
+
Persisted execution in Flowli is:
|
|
500
|
+
|
|
501
|
+
- at-least-once
|
|
502
|
+
- lease-based
|
|
503
|
+
- retry-capable
|
|
504
|
+
- idempotency-sensitive
|
|
505
|
+
|
|
506
|
+
Handlers that run asynchronously should be safe to run more than once.
|
|
507
|
+
|
|
508
|
+
```mermaid
|
|
509
|
+
sequenceDiagram
|
|
510
|
+
participant App as App Code
|
|
511
|
+
participant Flowli as Flowli Runtime
|
|
512
|
+
participant Driver as Redis Driver
|
|
513
|
+
participant Runner as Runner
|
|
514
|
+
participant Handler as Job Handler
|
|
515
|
+
|
|
516
|
+
App->>Flowli: enqueue() / delay() / schedule()
|
|
517
|
+
Flowli->>Driver: persist job or schedule
|
|
518
|
+
Runner->>Driver: reserve due work with lease
|
|
519
|
+
Driver-->>Runner: acquired job
|
|
520
|
+
Runner->>Flowli: resolve context + validate payload
|
|
521
|
+
Flowli->>Handler: invoke handler
|
|
522
|
+
alt success
|
|
523
|
+
Runner->>Driver: mark completed
|
|
524
|
+
else failure
|
|
525
|
+
Runner->>Driver: mark failed or retry
|
|
526
|
+
end
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
## Reusable Predeclared Jobs
|
|
530
|
+
|
|
531
|
+
If you want shareable job modules outside the runtime declaration, Flowli supports that too.
|
|
532
|
+
|
|
533
|
+
This is the advanced path:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
import * as v from "valibot";
|
|
537
|
+
import { defineJobs, job } from "flowli";
|
|
538
|
+
|
|
539
|
+
type AppContext = {
|
|
540
|
+
logger: {
|
|
541
|
+
info(payload: unknown): void;
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const auditLogSchema = v.object({
|
|
546
|
+
entityId: v.string(),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
export const createAuditLog = job.withContext<AppContext>()(
|
|
550
|
+
"create_audit_log",
|
|
551
|
+
{
|
|
552
|
+
input: auditLogSchema,
|
|
553
|
+
handler: async ({ input, ctx }) => {
|
|
554
|
+
ctx.logger.info(input.entityId);
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
export const flowli = defineJobs.withContext<AppContext>()({
|
|
560
|
+
jobs: { createAuditLog },
|
|
561
|
+
context: {
|
|
562
|
+
logger,
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
When you use this path, Flowli checks at compile time that the runtime `context` satisfies the predeclared job requirements.
|
|
568
|
+
|
|
569
|
+
## Hono
|
|
570
|
+
|
|
571
|
+
Attach an existing runtime to Hono without creating a second abstraction:
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
import { honoJobs } from "flowli/hono";
|
|
575
|
+
|
|
576
|
+
app.use("*", honoJobs(flowli));
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Next.js
|
|
580
|
+
|
|
581
|
+
Use the same configured runtime in route handlers and server actions without rebuilding anything:
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
// app/api/audit/[entityId]/route.ts
|
|
585
|
+
import { nextAction, nextRoute } from "flowli/next";
|
|
586
|
+
import { flowli } from "@/src/flowli";
|
|
587
|
+
|
|
588
|
+
export const POST = nextRoute(
|
|
589
|
+
flowli,
|
|
590
|
+
async ({ request, flowli, params }) => {
|
|
591
|
+
const body = await request.json();
|
|
592
|
+
|
|
593
|
+
await flowli.createAuditLog.run(
|
|
594
|
+
{
|
|
595
|
+
entityType: body.entityType ?? "record",
|
|
596
|
+
entityId: params?.entityId ?? body.entityId,
|
|
597
|
+
action: body.action ?? "record.updated",
|
|
598
|
+
message: "Audit event received from route handler",
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
meta: {
|
|
602
|
+
requestId: request.headers.get("x-request-id") ?? "unknown",
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
return Response.json({ ok: true });
|
|
608
|
+
},
|
|
609
|
+
);
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
// app/actions/send-notification.ts
|
|
614
|
+
export const sendNotificationAction = nextAction(
|
|
615
|
+
flowli,
|
|
616
|
+
async ({ flowli }, formData: FormData) => {
|
|
617
|
+
await flowli.sendNotificationEmail.enqueue({
|
|
618
|
+
email: String(formData.get("email")),
|
|
619
|
+
subject: String(formData.get("subject")),
|
|
620
|
+
message: String(formData.get("message")),
|
|
621
|
+
});
|
|
622
|
+
},
|
|
623
|
+
);
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
`flowli/next` stays lightweight:
|
|
627
|
+
|
|
628
|
+
- no second runtime
|
|
629
|
+
- no hidden global registry
|
|
630
|
+
- no direct dependency on Next internals inside your jobs
|
|
631
|
+
- works with an already configured `flowli` instance
|
|
632
|
+
|
|
633
|
+
## TanStack Start
|
|
634
|
+
|
|
635
|
+
Use the same configured runtime in TanStack Start server routes and server functions:
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
// src/routes/api/audit.$entityId.ts
|
|
639
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
640
|
+
import { tanstackStartRoute } from "flowli/tanstack-start";
|
|
641
|
+
import { flowli } from "@/src/flowli";
|
|
642
|
+
|
|
643
|
+
export const Route = createFileRoute("/api/audit/$entityId")({
|
|
644
|
+
server: {
|
|
645
|
+
handlers: {
|
|
646
|
+
POST: tanstackStartRoute(
|
|
647
|
+
flowli,
|
|
648
|
+
async ({ request, params, flowli }) => {
|
|
649
|
+
const body = await request.json();
|
|
650
|
+
|
|
651
|
+
await flowli.createAuditLog.run({
|
|
652
|
+
entityType: body.entityType ?? "record",
|
|
653
|
+
entityId: params.entityId,
|
|
654
|
+
action: body.action ?? "record.updated",
|
|
655
|
+
message: "Audit event received from TanStack Start",
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
return Response.json({ ok: true });
|
|
659
|
+
},
|
|
660
|
+
),
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
```ts
|
|
667
|
+
// src/lib/notifications.functions.ts
|
|
668
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
669
|
+
import { tanstackStartServerFn } from "flowli/tanstack-start";
|
|
670
|
+
import { flowli } from "@/src/flowli";
|
|
671
|
+
|
|
672
|
+
export const sendNotification = createServerFn({ method: "POST" }).handler(
|
|
673
|
+
tanstackStartServerFn(
|
|
674
|
+
flowli,
|
|
675
|
+
async ({ flowli, data }: { data: { email: string; subject: string } }) => {
|
|
676
|
+
await flowli.sendNotificationEmail.enqueue({
|
|
677
|
+
email: data.email,
|
|
678
|
+
subject: data.subject,
|
|
679
|
+
message: "Triggered from a TanStack Start server function.",
|
|
680
|
+
});
|
|
681
|
+
},
|
|
682
|
+
),
|
|
683
|
+
);
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
`flowli/tanstack-start` stays lightweight:
|
|
687
|
+
|
|
688
|
+
- no second runtime
|
|
689
|
+
- no hidden registry
|
|
690
|
+
- no framework state inside your job definitions
|
|
691
|
+
- works with existing TanStack Start server route and server function patterns
|
|
692
|
+
|
|
693
|
+
## Install
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
npm install flowli
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
```bash
|
|
700
|
+
pnpm add flowli
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
```bash
|
|
704
|
+
bun add flowli
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Optional schema peers:
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
bun add valibot zod
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
Optional framework peers:
|
|
714
|
+
|
|
715
|
+
```bash
|
|
716
|
+
bun add next
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
```bash
|
|
720
|
+
bun add @tanstack/react-start
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Optional Redis client peers:
|
|
724
|
+
|
|
725
|
+
```bash
|
|
726
|
+
bun add ioredis
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
```bash
|
|
730
|
+
bun add redis
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Real Redis integration testing:
|
|
734
|
+
|
|
735
|
+
```bash
|
|
736
|
+
bun run docker:up
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
```bash
|
|
740
|
+
bun run test:redis:docker
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
```bash
|
|
744
|
+
bun run docker:down
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
## Production
|
|
748
|
+
|
|
749
|
+
Flowli is close to production use, but its async model is explicit and you should deploy it like queue infrastructure, not just a helper library.
|
|
750
|
+
|
|
751
|
+
Recommended production baseline:
|
|
752
|
+
|
|
753
|
+
- run a dedicated `createRunner({ flowli })` process
|
|
754
|
+
- use Redis/Valkey/Dragonfly with persistence configured appropriately for your durability needs
|
|
755
|
+
- keep handlers idempotent because persisted execution is at-least-once
|
|
756
|
+
- tune `leaseMs`, `concurrency`, and `maxJobsPerTick` to match handler duration and load
|
|
757
|
+
- monitor failed jobs and handler error rates
|
|
758
|
+
- validate delayed and scheduled workloads against real infrastructure before rollout
|
|
759
|
+
|
|
760
|
+
Operational notes:
|
|
761
|
+
|
|
762
|
+
- `run()` is in-process and does not depend on Redis
|
|
763
|
+
- `enqueue()`, `delay()`, and `schedule()` depend on a configured driver
|
|
764
|
+
- async work is lease-based and can be retried after failures or lease recovery
|
|
765
|
+
- if a runner crashes after reserving work, expired leases are recovered and jobs are re-queued
|
|
766
|
+
- schedule execution is UTC-based in v1
|
|
767
|
+
|
|
768
|
+
Suggested rollout plan:
|
|
769
|
+
|
|
770
|
+
1. Start with `run()` in app code and tests.
|
|
771
|
+
2. Enable `enqueue()` with one runner process.
|
|
772
|
+
3. Verify handler idempotency and retry behavior.
|
|
773
|
+
4. Add delayed and scheduled workloads after observing real job throughput and failure patterns.
|
|
774
|
+
|
|
775
|
+
For local real-backend validation, Flowli ships a Redis setup in [docker-compose.yml](/Users/alialnaghmoush/Documents/GitHub/flowli/docker-compose.yml). The default local test URL is `redis://127.0.0.1:6379/0`, which matches `bun run test:redis:docker`.
|
|
776
|
+
|
|
777
|
+
## What Flowli Optimizes For
|
|
778
|
+
|
|
779
|
+
- small API surface
|
|
780
|
+
- explicit runtime wiring
|
|
781
|
+
- strong autocomplete
|
|
782
|
+
- clean context injection
|
|
783
|
+
- type-safe invocation surfaces
|
|
784
|
+
- tree-shakable subpath exports
|
|
785
|
+
- framework-agnostic core design
|
|
786
|
+
|
|
787
|
+
## Exports
|
|
788
|
+
|
|
789
|
+
- `flowli`
|
|
790
|
+
- `flowli/ioredis`
|
|
791
|
+
- `flowli/redis`
|
|
792
|
+
- `flowli/bun-redis`
|
|
793
|
+
- `flowli/next`
|
|
794
|
+
- `flowli/tanstack-start`
|
|
795
|
+
- `flowli/hono`
|
|
796
|
+
- `flowli/runner`
|
|
797
|
+
|
|
798
|
+
## Status
|
|
799
|
+
|
|
800
|
+
Flowli v1 currently includes:
|
|
801
|
+
|
|
802
|
+
- typed `job()` definitions
|
|
803
|
+
- runtime-first `defineJobs()`
|
|
804
|
+
- `run`, `enqueue`, `delay`, and `schedule`
|
|
805
|
+
- pluggable Redis drivers
|
|
806
|
+
- Next.js helpers
|
|
807
|
+
- TanStack Start helpers
|
|
808
|
+
- explicit runner support
|
|
809
|
+
- Hono middleware
|
|
810
|
+
- lease recovery for expired active jobs
|
|
811
|
+
- npm and JSR publish configuration
|
|
812
|
+
|
|
813
|
+
If you want the shortest description:
|
|
814
|
+
|
|
815
|
+
**Flowli is a typed jobs runtime for TypeScript with first-class execution strategies and pluggable Redis drivers.**
|