@bluelibs/runner 6.3.0 → 6.3.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.
@@ -0,0 +1,524 @@
1
+ # Runner Fluent Builders (r.\*) — End-to-End Guide
2
+
3
+ ← [Back to main README](../README.md) | [Fluent Builders section in FULL_GUIDE](./FULL_GUIDE.md#fluent-builders-r)
4
+
5
+ ---
6
+
7
+ This guide shows how to use the new fluent Builder API exposed via a single `r` namespace. Builders are ergonomic, type-safe, and compile to the same definitions used by Runner today, without runtime overhead or breaking changes.
8
+
9
+ ### Import
10
+
11
+ ```ts
12
+ import { Match, r, run } from "@bluelibs/runner";
13
+ ```
14
+
15
+ You'll primarily use `r`:
16
+
17
+ - `r.resource(id)`
18
+ - `r.task(id)`
19
+ - `r.event(id)`
20
+ - `r.hook(id)`
21
+ - `r.tag(id)`
22
+ - `r.middleware.task(id)`
23
+ - `r.middleware.resource(id)`
24
+ - `r.error(id)`
25
+ - `r.asyncContext(id)`
26
+
27
+ Each builder provides a fluent chain to configure dependencies, schemas, middleware, tags, metadata, and implementation functions. Call `.build()` to produce a definition identical to `defineX`.
28
+
29
+ Quick rules of thumb:
30
+
31
+ - `.build()` materializes the definition; register only built items (task/resource/hook/middleware/tag), not builders.
32
+ - When a method accepts a list (for example, `.register()`, `.tags()`, `.middleware()`), you may pass a single item or an array.
33
+ - For resources, repeated `.register()` calls append by default; pass `{ override: true }` to replace.
34
+ - For resources and tasks, repeated `.middleware()` calls append by default; pass `{ override: true }` to replace.
35
+ - For resources, tasks, hooks, events, and middleware, repeated `.tags()` calls append by default; pass `{ override: true }` to replace.
36
+ - For resources, repeated `.overrides()` calls append by default; pass `{ override: true }` to replace.
37
+ - For resources, tasks, hooks and middleware, repeated `.dependencies()` calls append (shallow merge) by default; pass `{ override: true }` to replace. Mixed function/object deps are supported and merged consistently.
38
+
39
+ ## Strict Chain Constraints (v1)
40
+
41
+ Runner enforces compile-time chain phases for `r.task`, `r.hook`, `r.resource`, and middleware builders.
42
+
43
+ - `task`: after `.run()`, these are locked: `.dependencies()`, `.inputSchema()`/`.schema()`, `.resultSchema()`, `.middleware()`, `.tags()`. `.meta()`, `.throws()`, `.build()` remain valid.
44
+ - `hook`: `.run()` requires `.on(...)` first. After `.run()`, these are locked: `.on()`, `.dependencies()`, `.tags()`. `.build()` requires both `.on()` and `.run()`.
45
+ - `middleware` (`task` and `resource`): after `.run()`, these are locked: `.dependencies()`, `.configSchema()`/`.schema()`, `.tags()`. `.build()` requires `.run()`.
46
+ - `resource`: after `.init()`, these are locked: `.dependencies()`, `.configSchema()`/`.schema()`, `.resultSchema()`, `.middleware()`, `.tags()`, `.context()`. `.init()` stays optional.
47
+
48
+ Examples:
49
+
50
+ ```ts
51
+ r.task("ok")
52
+ .run(async () => "ok")
53
+ .meta({ title: "x" })
54
+ .throws([])
55
+ .build(); // valid
56
+
57
+ r.task("nope")
58
+ .run(async () => "ok")
59
+ .tags([]); // TypeScript error
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Resources
65
+
66
+ Minimal:
67
+
68
+ ```ts
69
+ // Optionally seed the resource config type at the entry point
70
+ type AppConfig = { feature?: boolean };
71
+ const app = r
72
+ .resource<AppConfig>("app")
73
+ .init(async () => "OK")
74
+ .build();
75
+ ```
76
+
77
+ With dependencies, tags, middleware, context and schemas:
78
+
79
+ ```ts
80
+ const svc = resource({
81
+ id: "svc",
82
+ init: async () => ({ add: (a: number, b: number) => a + b }),
83
+ });
84
+
85
+ const tag = r.tag("my.tag").build();
86
+
87
+ const loggingMw = r.middleware
88
+ .resource("mw.logging")
89
+ .run(async ({ next }) => {
90
+ const out = await next();
91
+ return out;
92
+ })
93
+ .build();
94
+
95
+ const app = r
96
+ .resource("app.composed")
97
+ .register([svc, loggingMw, tag]) // single or array is OK
98
+ .dependencies({ svc })
99
+ .tags([tag])
100
+ .middleware([loggingMw])
101
+ .context(() => ({ reqId: Math.random() }))
102
+ .configSchema({ feature: Boolean }) // or configSchema(zodObject)
103
+ .resultSchema({ status: String }) // or resultSchema(zodObject)
104
+ .init(async (config, deps, resourceContext) => {
105
+ const sum = deps.svc.add(2, 3);
106
+ return {
107
+ status: `id=${resourceContext.reqId}; sum=${sum}; feature=${!!config?.feature}`,
108
+ };
109
+ })
110
+ .build();
111
+
112
+ // Append vs override for register()
113
+ const r1 = r
114
+ .resource("app.register.append")
115
+ .register(svc) // append
116
+ .register([loggingMw, tag]) // append
117
+ .build();
118
+
119
+ const r2 = r
120
+ .resource("app.register.override")
121
+ .register([svc, tag])
122
+ .register(loggingMw, { override: true }) // replace previous registrations
123
+ .build();
124
+
125
+ // Dynamic register: compose functions and arrays
126
+ type Cfg = { flag: boolean };
127
+ const r3 = r
128
+ .resource<Cfg>("app.register.dynamic")
129
+ .register((cfg) => (cfg.flag ? [svc] : [])) // function
130
+ .register(loggingMw) // array/single
131
+ .build();
132
+ // r3.register is a function; r3.register({ flag: true }) => [svc, loggingMw]
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Tasks
138
+
139
+ ```ts
140
+ const adder = r
141
+ .task("tasks.add")
142
+ .inputSchema({ a: Number, b: Number })
143
+ .run(async (input) => input!.a + input!.b)
144
+ .build();
145
+ ```
146
+
147
+ With dependencies, tags, middleware, metadata, and result schema:
148
+
149
+ ```ts
150
+ const tmw = r.middleware
151
+ .task("tmw.wrap")
152
+ .run(async ({ next }) => {
153
+ const out = await next();
154
+ return out;
155
+ })
156
+ .build();
157
+
158
+ const calc = r
159
+ .task("tasks.calc")
160
+ .dependencies({ adder })
161
+ .tags([])
162
+ .middleware([tmw])
163
+ .resultSchema(Number)
164
+ .meta({ title: "Calculator" } as any)
165
+ .run(async (n: number, deps) => deps.adder({ a: n, b: 1 }))
166
+ .build();
167
+ ```
168
+
169
+ ---
170
+
171
+ ## Events and Hooks
172
+
173
+ Events:
174
+
175
+ ```ts
176
+ const userCreated = r
177
+ .event("events.userCreated")
178
+ .payloadSchema({ id: String })
179
+ .tags([])
180
+ .meta({ title: "User Created" } as any)
181
+ .build();
182
+ ```
183
+
184
+ Hooks:
185
+
186
+ ```ts
187
+ const listener = r
188
+ .hook("hooks.audit")
189
+ .on(userCreated)
190
+ .order(10)
191
+ .dependencies({})
192
+ .tags([])
193
+ .meta({ title: "Audit" } as any)
194
+ .run(async (ev) => {
195
+ // ev.id, ev.data.id
196
+ })
197
+ .build();
198
+ ```
199
+
200
+ Hooks also support selector-style subscriptions:
201
+
202
+ ```ts
203
+ const subtreeListener = r
204
+ .hook("hooks.subtree")
205
+ .on(r.subtreeOf(featureResource))
206
+ .run(async (event) => {
207
+ // selector-based hooks trade payload autocomplete for broader matching
208
+ console.log(event.id);
209
+ })
210
+ .build();
211
+
212
+ const predicateListener = r
213
+ .hook("hooks.predicate")
214
+ .on((event) => auditTag.exists(event))
215
+ .run(async (event) => {
216
+ console.log(event.id);
217
+ })
218
+ .build();
219
+ ```
220
+
221
+ Selector notes:
222
+
223
+ - selectors resolve once at bootstrap against registered events
224
+ - selector matches are narrowed to events visible to the hook
225
+ - exact event refs still fail fast when visibility is violated
226
+ - arrays may mix exact events, `r.subtreeOf(...)`, and predicates, but `"*"` must stay standalone
227
+
228
+ Register and emit via a resource:
229
+
230
+ ```ts
231
+ const app = resource({ id: "events.app", register: [userCreated, listener] });
232
+ const rr = await run(app);
233
+ await rr.emitEvent(userCreated, { id: "u1" });
234
+ await rr.dispose();
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Async Contexts
240
+
241
+ Use `r.asyncContext(id)` for request-local business state such as tenant ids, auth claims, locale, or request metadata.
242
+
243
+ Builder chain example:
244
+
245
+ ```ts
246
+ const requestContextShape = Match.Object({
247
+ requestId: Match.NonEmptyString,
248
+ tenantId: Match.NonEmptyString,
249
+ });
250
+
251
+ const requestContext = r
252
+ .asyncContext("requestContext")
253
+ .schema(requestContextShape)
254
+ .serialize((value) => JSON.stringify(value))
255
+ .parse((raw) => requestContextShape.parse(JSON.parse(raw)))
256
+ .meta({
257
+ title: "Request Context",
258
+ description: "Per-request business metadata",
259
+ })
260
+ .build();
261
+ ```
262
+
263
+ Runtime usage:
264
+
265
+ ```ts
266
+ const auditTask = r
267
+ .task("tasks.audit")
268
+ .dependencies({ requestContext })
269
+ .middleware([requestContext.require()])
270
+ .run(async (_input, { requestContext }) => {
271
+ return requestContext.use().requestId;
272
+ })
273
+ .build();
274
+
275
+ const app = r
276
+ .resource("app.async-context")
277
+ .register([requestContext, auditTask])
278
+ .build();
279
+
280
+ const runtime = await run(app);
281
+
282
+ await requestContext.provide(
283
+ { requestId: "req_1", tenantId: "acme" },
284
+ () => runtime.runTask(auditTask),
285
+ );
286
+ ```
287
+
288
+ Key rules:
289
+
290
+ - `.schema()` is an alias for `.configSchema()` and validates values when `provide(...)` is called.
291
+ - Call `.schema()` before custom `.serialize()` or `.parse()`; schema rebinding after transport callbacks is rejected.
292
+ - `.meta()` attaches docs/tooling metadata.
293
+ - `.build()` returns the accessor with `use()`, `tryUse()`, `has()`, `provide()`, `require()`, and `optional()`.
294
+ - Register the built context before injecting it as a required dependency.
295
+ - Use `ctx.optional()` when the dependency may not be registered in a given app.
296
+ - Default serialization uses Runner's serializer; customize it only when a transport boundary needs a stable wire format.
297
+
298
+ Optional dependency example:
299
+
300
+ ```ts
301
+ const maybeAudit = r
302
+ .task("tasks.maybeAudit")
303
+ .dependencies({ requestContext: requestContext.optional() })
304
+ .run(async (_input, { requestContext }) => {
305
+ return requestContext?.tryUse()?.requestId;
306
+ })
307
+ .build();
308
+ ```
309
+
310
+ ---
311
+
312
+ ## Middleware Builders
313
+
314
+ Task middleware:
315
+
316
+ ```ts
317
+ const tmw = r.middleware
318
+ .task("tmw.log")
319
+ .dependencies({})
320
+ .configSchema({ level: Match.OneOf("info", "warn", "error") })
321
+ .tags([])
322
+ .meta({ title: "TaskLogger" } as any)
323
+ .run(async ({ next, task }, _deps, config) => {
324
+ return next(task.input);
325
+ })
326
+ .build();
327
+ ```
328
+
329
+ Resource middleware:
330
+
331
+ ```ts
332
+ const rmw = r.middleware
333
+ .resource("rmw.wrap")
334
+ .dependencies({})
335
+ .configSchema({ ttl: Match.Optional(Number) })
336
+ .tags([])
337
+ .meta({ title: "ResourceWrapper" } as any)
338
+ .run(async ({ next }) => next())
339
+ .build();
340
+ ```
341
+
342
+ Attach to resources or tasks via `.middleware([mw])`.
343
+
344
+ For owner-scoped auto-application and governance, use resource subtree policies:
345
+
346
+ ```ts
347
+ const app = r
348
+ .resource("app")
349
+ .subtree({
350
+ tasks: { middleware: [tmw] },
351
+ resources: { middleware: [rmw] },
352
+ hooks: {
353
+ validate: (hook) =>
354
+ hook.meta?.title
355
+ ? []
356
+ : [
357
+ {
358
+ code: "missing-meta-title",
359
+ message: "Hook meta.title required",
360
+ },
361
+ ],
362
+ },
363
+ taskMiddleware: { validate: (mw) => [] },
364
+ resourceMiddleware: { validate: (mw) => [] },
365
+ events: { validate: (event) => [] },
366
+ tags: { validate: (tag) => [] },
367
+ })
368
+ .build();
369
+ ```
370
+
371
+ Conditional subtree middleware entries are also supported:
372
+
373
+ ```ts
374
+ const appWithConditional = r
375
+ .resource("app.conditional")
376
+ .subtree({
377
+ tasks: {
378
+ middleware: [
379
+ {
380
+ use: tmw.with({ mode: "strict" }),
381
+ when: (task) => task.tags.some((tag) => tag.id === "app.tags.strict"),
382
+ },
383
+ ],
384
+ },
385
+ })
386
+ .build();
387
+ ```
388
+
389
+ If subtree middleware and local middleware resolve to the same middleware id on one target, Runner fails fast instead of letting the local middleware override it.
390
+
391
+ Use `taskRunner.intercept(interceptor, { when })` for cross-cutting catch-all task interception.
392
+
393
+ Note on `.init()`:
394
+
395
+ - `.init` uses the classic `(config, deps, resourceContext)` signature; destructure inside the body when needed.
396
+ - If you skip seeding a config type, annotate the first argument in `init` and the builder will adopt that type.
397
+
398
+ Note on `.middleware()` and `.tags()`:
399
+
400
+ - You can pass a single item or an array.
401
+ - `.middleware()` and `.tags()` append by default; pass `{ override: true }` to replace the existing list.
402
+
403
+ Append vs override for `.middleware()` and `.overrides()`:
404
+
405
+ ```ts
406
+ // Resource middleware append and override
407
+ const rmid = r.middleware
408
+ .resource("mw.rid")
409
+ .run(async ({ next }) => next())
410
+ .build();
411
+ const app = r
412
+ .resource("app.mw")
413
+ .middleware([rmid]) // append
414
+ .middleware([rmid], { override: true }) // replace
415
+ .build();
416
+
417
+ // Task middleware append
418
+ const tmid = r.middleware
419
+ .task("mw.tid")
420
+ .run(async ({ next, task }) => next(task.input))
421
+ .build();
422
+ const t = r
423
+ .task("tasks.calc")
424
+ .middleware([tmid]) // append
425
+ .build();
426
+
427
+ // Resource overrides append and override
428
+ const res = r
429
+ .resource("app.overrides")
430
+ .overrides([someResource]) // append
431
+ .overrides([anotherResource], { override: true }) // replace
432
+ .build();
433
+
434
+ // Tags append and override
435
+ const tagA = r.tag("tag.A").build();
436
+ const tagB = r.tag("tag.B").build();
437
+ const tagged = r
438
+ .task("app.tags")
439
+ .tags([tagA]) // append
440
+ .tags([tagB]) // append
441
+ // .tags([tagB], { override: true }) // replace
442
+ .build();
443
+ ```
444
+
445
+ ---
446
+
447
+ ## Running
448
+
449
+ ```ts
450
+ const app = r
451
+ .resource("app")
452
+ .register([])
453
+ .init(async () => "OK")
454
+ .build();
455
+ const rr = await run(app);
456
+ const value = rr.value; // "OK"
457
+ await rr.dispose();
458
+ ```
459
+
460
+ Tasks from runtime:
461
+
462
+ ```ts
463
+ const task = r
464
+ .task("t")
465
+ .run(async (n: number) => n + 1)
466
+ .build();
467
+ const root = resource({ id: "root", register: [task] });
468
+ const rr = await run(root);
469
+ const result = await rr.runTask(task, 1); // 2
470
+ await rr.dispose();
471
+ ```
472
+
473
+ ---
474
+
475
+ ## Type Safety Highlights
476
+
477
+ - Builder generics propagate across the chain: config, value/result, dependencies, context, meta, tags, and middleware are strongly typed.
478
+ - You can pre-seed a resource's config type at the entry point: `r.resource<MyConfig>(id)` — this provides typed `config` for `.dependencies((config) => ...)` and `.register((config) => ...)` callables.
479
+ - Resource `.init` follows `(config, deps, resourceContext)`; task `.run` still supports the object-style helper `({ input, deps })` and will adopt the typed first parameter when you skip `.configSchema()`.
480
+ - Tags and middleware must be registered; otherwise, sanity checks will fail at runtime. Builders keep tag and middleware types intact for compile-time checks.
481
+ - Schemas can be passed as plain objects with `parse` or libraries like `zod`—inference will flow accordingly.
482
+
483
+ Cheat sheet:
484
+
485
+ - Resource `.register()` accepts item | item[] | (config) => item | item[]
486
+ - Default = append; `{ override: true }` replaces prior registrations
487
+ - Tags and middleware accept single or array; repeated calls append by default; pass `{ override: true }` to replace
488
+ - Always call `.build()` and register built definitions
489
+
490
+ For deeper contract tests, see `src/__tests__/typesafety.test.ts` and the builder tests under `src/__tests__/definers/`.
491
+
492
+ ---
493
+
494
+ ## Migration Notes
495
+
496
+ - Existing `defineX` APIs remain. Builders are sugar and compile to the same definitions.
497
+ - Import the single namespace `r` for all builders. You can still import `resource`, `task`, etc., to reference classic definitions or for mixing.
498
+ - No runtime overhead, no breaking changes. Builders are fully tree-shakeable.
499
+ Dependencies append examples:
500
+
501
+ ```ts
502
+ // Resource deps: function + object merge
503
+ const app = r
504
+ .resource("app.deps")
505
+ .dependencies((cfg) => ({ svcA }))
506
+ .dependencies({ svcB }) // merged with previous
507
+ .build();
508
+
509
+ // Task deps: append and override
510
+ const t = r
511
+ .task("tasks.t")
512
+ .dependencies({ a })
513
+ .dependencies({ b }) // append
514
+ .dependencies({ c }, { override: true }) // replace with only c
515
+ .build();
516
+
517
+ // Hook deps
518
+ const hk = r
519
+ .hook("hooks.h")
520
+ .on(ev)
521
+ .dependencies({ a })
522
+ .dependencies({ b })
523
+ .build();
524
+ ```