rivulet-rb 0.1.6 → 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.
data/docs/AGENTS.md ADDED
@@ -0,0 +1,741 @@
1
+ # AGENTS.md
2
+
3
+ Guide for AI agents working on this Rivulet application.
4
+
5
+ ## Overview
6
+
7
+ Rivulet is a Rack web framework built on Railway Oriented Programming, dry-rb,
8
+ falcon, and Sequel. Every request flows through an explicit pipeline of
9
+ operations and steps — no hidden control flow, no implicit context.
10
+
11
+ A freshly generated app starts greenfield: `app/models`, `db/migrations`,
12
+ `handlers/shared/steps`, `services/shared/steps` are all empty, and
13
+ `config/routes.rb` has no routes. Shared infrastructure (validation,
14
+ projection, auth) is not provided — build it as you need it.
15
+
16
+ ## Commands
17
+
18
+ Run the app:
19
+
20
+ ```bash
21
+ docker compose up
22
+ ```
23
+
24
+ Rebuild after Gemfile changes:
25
+
26
+ ```bash
27
+ docker compose up --build
28
+ ```
29
+
30
+ All `rivulet` commands run inside the container — never with `bundle exec`
31
+ on the host. Start the stack with `docker compose up`, then invoke the CLI
32
+ through the compose service (`app`):
33
+
34
+ ```bash
35
+ docker compose exec app rivulet --help
36
+ docker compose exec app rivulet g handler posts --list --read --create --update --delete
37
+ docker compose exec app rivulet g handler operation posts.show
38
+ docker compose exec app rivulet g handler step posts.load_post
39
+ docker compose exec app rivulet g service users
40
+ docker compose exec app rivulet g migration create_users
41
+ docker compose exec app rivulet db migrate
42
+ docker compose exec app rivulet console
43
+ docker compose exec app rivulet routes
44
+ ```
45
+
46
+ ### Host vs. Container: Where to Work
47
+
48
+ The current directory on the host is bind-mounted into the container as `/app`
49
+ (`volumes: ["./:/app"]` in `docker-compose.yml`). The two are the same files,
50
+ kept in sync by the mount:
51
+
52
+ - **Run `rivulet` commands inside the container** (see the list above). They
53
+ read and write under `/app`, which is your current directory — so files a
54
+ generator creates (e.g. `rivulet g handler posts`) appear on the host
55
+ immediately.
56
+ - **Edit application code in your current directory on the host** — with your
57
+ editor or file tools, not inside the container. Edits are reflected in the
58
+ container instantly via the mount.
59
+ - **Do not edit files inside the container.** The container is an execution
60
+ environment for `rivulet` and the app server, not a place to author code.
61
+
62
+ In short: edit on the host, run commands in the container; the bind mount
63
+ keeps both in sync. Because Zeitwerk eager-loads `app/` at startup, restart
64
+ the app container (`docker compose restart app`) after adding new files so
65
+ they are picked up.
66
+
67
+ ## Project Structure
68
+
69
+ ```
70
+ app/
71
+ handlers/ # Transport layer
72
+ shared/ # Reusable handler steps/utils
73
+ <domain>/
74
+ handler.rb
75
+ container.rb
76
+ operations/
77
+ steps/
78
+ services/ # Domain layer
79
+ shared/
80
+ <domain>/
81
+ operations/
82
+ steps/
83
+ contracts/
84
+ utils/
85
+ models/ # Sequel models
86
+ config/
87
+ application.rb # App configuration (DSN, logger, sendfile)
88
+ routes.rb # Route definitions
89
+ db/
90
+ migrations/ # SQL migration files
91
+ config.ru # Rack entrypoint
92
+ falcon.rb # Falcon server config
93
+ Dockerfile
94
+ docker-compose.yml
95
+ ```
96
+
97
+ ## Architecture
98
+
99
+ Rivulet separates transport concerns from domain concerns through two
100
+ independent execution layers that share the same building blocks.
101
+
102
+ ### Handlers (Transport Layer)
103
+
104
+ Handlers process incoming HTTP requests, prepare execution context,
105
+ delegate work to services, and produce HTTP responses.
106
+
107
+ ### Services (Domain Layer)
108
+
109
+ Services execute business use cases, coordinate domain-specific workflows,
110
+ and remain independent from HTTP and transport concerns. Services can be
111
+ invoked from handlers, background jobs, CLI commands, or any other runtime.
112
+
113
+ ### Operations
114
+
115
+ Operations represent application use cases. Their responsibility is to
116
+ define execution flow by composing reusable steps into an explicit Railway
117
+ pipeline. Operations do not implement business logic.
118
+
119
+ `Rivulet::Operation` extends `Dry::Operation`. The `step` keyword unwraps a
120
+ `Success` (returning the inner value) and short-circuits on `Failure` (halting
121
+ the operation and returning the failure). As a result, the value passed to the
122
+ next step is a bare hash, not a monad — chain `result = step foo.(result)` and
123
+ forward `result`, not a wrapped monad.
124
+
125
+ ```ruby
126
+ class Create < Rivulet::Operation
127
+ include Import[
128
+ validate: 'shared.steps.validate',
129
+ authorize: 'shared.steps.authorize',
130
+ create_post: 'steps.create_post',
131
+ project: 'shared.steps.project'
132
+ ]
133
+
134
+ def call(input = {})
135
+ result = step validate.(input, contract)
136
+ result = step authorize.(result)
137
+ result = step create_post.(result)
138
+ result = step project.(result, projection)
139
+
140
+ result
141
+ end
142
+ end
143
+ ```
144
+
145
+ Operations follow a canonical step order:
146
+
147
+ 1. **validate** — run the contract; filter the input to the declared keys.
148
+ 2. **authorize** — enforce access (a business rule; see
149
+ [Authentication vs. Authorization](#authentication-vs-authorization)).
150
+ 3. **business steps** — the operation's work (load, create, update, …).
151
+ 4. **project** — shape the output; the last step.
152
+
153
+ Validate first so the pipeline runs on clean, declared data; authorize before
154
+ business logic so access is enforced before any side effects; project last so
155
+ the output shape is finalized. Not every operation needs every stage (e.g. a
156
+ `list` may skip `project` if it returns rows directly), but keep this order
157
+ when the stages are present.
158
+
159
+ Operations receive an input hash as their entry point. Each step may read,
160
+ add, or modify values in the input hash as it flows through the pipeline.
161
+
162
+ > The `validate`, `authorize`, and `project` steps above are **shared steps**
163
+ > that live in `services/shared/steps` and accept the contract or projection
164
+ > defined in the operation. Because the shared namespace is registered with
165
+ > the `shared.` prefix, they are imported as `'shared.steps.validate'` /
166
+ > `'shared.steps.authorize'` / `'shared.steps.project'`. Templates for these
167
+ > shared steps are not provided yet — create them before this pattern runs.
168
+ > `ApplicationContract` (in `app/application_contract.rb`) and
169
+ > `Rivulet::Projection` are base classes only, not runnable steps. `authorize`
170
+ > may also be per-domain (`'steps.authorize'`) when the policy is
171
+ > resource-specific.
172
+
173
+ A `validate` step runs the contract against the input hash and returns
174
+ `Success(result.to_h)` on success, or `Failure[:validation, result.errors.to_h]`
175
+ on errors:
176
+
177
+ ```ruby
178
+ module Services
179
+ module Shared
180
+ module Steps
181
+ class Validate < Rivulet::Step
182
+ def call(input, contract)
183
+ result = contract.call(input)
184
+ return Success(result.to_h) if result.success?
185
+
186
+ Failure[:validation, result.errors.to_h]
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ ```
193
+
194
+ A `project` step applies a projection to `input[:resource]` (the persisted
195
+ instance) and stores the result in `input[:data]`:
196
+
197
+ ```ruby
198
+ module Services
199
+ module Shared
200
+ module Steps
201
+ class Project < Rivulet::Step
202
+ def call(input, projection)
203
+ input[:data] = projection.call(input[:resource])
204
+ Success(input)
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ ```
211
+
212
+ Register both in `app/services/shared/namespace.rb`:
213
+
214
+ ```ruby
215
+ namespace('steps') do
216
+ register('validate') { Services::Shared::Steps::Validate.new }
217
+ register('project') { Services::Shared::Steps::Project.new }
218
+ end
219
+ ```
220
+
221
+ ### Steps
222
+
223
+ Steps are the executable units within an operation. Each step performs one
224
+ clearly defined responsibility and returns `Success(input)` or `Failure(...)`.
225
+ A failure short-circuits the pipeline.
226
+
227
+ `Rivulet::Step` includes `Dry::Monads[:result]`, so `Success` and `Failure`
228
+ are available inside steps with no extra include.
229
+
230
+ ```ruby
231
+ class CreatePost < Rivulet::Step
232
+ def call(input)
233
+ input[:resource] = input[:resource].create(input[:attributes])
234
+ Success(input)
235
+ end
236
+ end
237
+ ```
238
+
239
+ Steps should be small, focused, and easy to test in isolation.
240
+
241
+ ### Shared Components
242
+
243
+ Each layer maintains a `shared/` namespace for cross-cutting, reusable steps:
244
+
245
+ - `handlers/shared/` — transport-level concerns (authentication, header parsing, context).
246
+ - `services/shared/` — domain-agnostic business operations (authorization, validation, pagination).
247
+
248
+ ### Authentication vs. Authorization
249
+
250
+ Split these concerns across layers by what they depend on:
251
+
252
+ - **Authentication** belongs in the **handler** layer (`handlers/shared/`).
253
+ Establishing who the caller is depends on transport data — JWTs, session
254
+ cookies, headers — so it runs as a handler step before the service is
255
+ invoked and resolves a `current_user` into the request context.
256
+ - **Authorization** belongs in the **service** layer (`services/shared/` or a
257
+ domain service). Access rights are part of the business requirements: who
258
+ may read, create, update, or delete a resource is a domain rule, not a
259
+ transport one. Authorization steps run inside the service pipeline, where
260
+ they enforce policies against the resource and the acting user without
261
+ depending on HTTP.
262
+
263
+ In short: the handler authenticates (who), the service authorizes (may they).
264
+
265
+ #### Deciding gray-area checks
266
+
267
+ Use this test: **would this rule need to hold if the service were called from
268
+ a non-HTTP entry point — a background job, a Kafka consumer, or a CLI
269
+ command?** If yes, it is authorization and belongs in the service. If it only
270
+ makes sense for an HTTP request (it depends on a token, cookie, or header),
271
+ it is authentication and belongs in the handler.
272
+
273
+ A common gray area is the **banned-user check**. "Is this user banned?" is a
274
+ business rule — a banned user must not perform actions whether they arrive
275
+ via HTTP, a Kafka consumer, or a CLI command — so it is authorization. It
276
+ belongs in the service (e.g. a shared `VerifyNotBanned` step), not in the
277
+ handler's `Authenticate` step; placing it in the handler would let non-HTTP
278
+ callers bypass it. The trap: loading the user from the JWT is authentication
279
+ (handler); checking a property of that user which governs whether they may
280
+ act (`banned?`) is authorization (service). The handler resolves
281
+ `current_user`; the service receives it and authorizes.
282
+
283
+ ### Utils
284
+
285
+ Utils encapsulate implementation details (API clients, calculations, token
286
+ generation). Unlike steps, utils do not participate in Railway pipelines and
287
+ return direct results. They are registered in the container (under
288
+ `namespace('utils')`) and injected into steps via `Import`.
289
+
290
+ Every component under `app/handlers/` and `app/services/` must be registered
291
+ in the container — there are no plain modules:
292
+
293
+ - A **step** (`Rivulet::Step`) participates in a pipeline, operates on the
294
+ input hash, and returns `Success`/`Failure`. Registered under
295
+ `namespace('steps')`.
296
+ - A **util** returns direct results, does not participate in a pipeline, and
297
+ is registered under `namespace('utils')`, injected into steps via `Import`.
298
+
299
+ Do not define a plain module and `include` it across steps. Plain objects
300
+ bypass the container: they are invisible to dependency injection, not
301
+ overridable per-domain, and scatter `include SomeModule` across files.
302
+ Reusable helpers go under `handlers/shared/namespace.rb` or
303
+ `services/shared/namespace.rb` — the `namespace('utils')` block is empty and
304
+ waiting for them.
305
+
306
+ ### Handler Input
307
+
308
+ The framework builds the handler input hash before the operation runs. It
309
+ contains:
310
+
311
+ ```ruby
312
+ {
313
+ params: { ... }, # path params + JSON body params
314
+ context: {
315
+ headers: { ... }, # normalized HTTP_* headers (HTTP_X_CUSTOM -> X-Custom)
316
+ cookies: { ... }, # parsed cookies
317
+ session: ... # rack.session (nil unless session middleware)
318
+ }
319
+ }
320
+ ```
321
+
322
+ - `params` — merged path params (e.g. `:id` from `/posts/:id`) and parsed JSON
323
+ body params. Path params take precedence over body params with the same key.
324
+ - `context.headers` — request headers with names normalized
325
+ (`HTTP_X_CUSTOM` → `X-Custom`, `CONTENT_TYPE` → `Content-Type`).
326
+ - `context.cookies` — cookies parsed from the request.
327
+ - `context.session` — the Rack session, or `nil` when no session middleware
328
+ is configured.
329
+
330
+ ### Handler Output
331
+
332
+ Handler operations return a `Rivulet::Response` wrapped in a monad. The final
333
+ step in a handler pipeline produces the response on the success path:
334
+
335
+ ```ruby
336
+ class BuildResponse < Rivulet::Step
337
+ def call(input)
338
+ response = Rivulet::Response.new(
339
+ status: 200,
340
+ body: { data: input[:post] }
341
+ )
342
+ Success(response)
343
+ end
344
+ end
345
+ ```
346
+
347
+ On the failure path, a step short-circuits the pipeline and returns a
348
+ `Rivulet::Response` wrapped in a `Failure` monad. The framework unwraps
349
+ the response regardless of which path produced it:
350
+
351
+ ```ruby
352
+ Failure(
353
+ Rivulet::Response.new(
354
+ status: 422,
355
+ body: { error: 'validation_failed' }
356
+ )
357
+ )
358
+ ```
359
+
360
+ #### Rivulet::Response
361
+
362
+ The standardized transport response object. Attributes (all with defaults):
363
+
364
+ | Attribute | Default | Description |
365
+ |---|---|---|
366
+ | `status` | `200` | HTTP status code |
367
+ | `body` | `[]` | Response payload (shape depends on `format`) |
368
+ | `headers` | `{}` | HTTP headers (merged over framework-generated headers) |
369
+ | `format` | `:json` | How the body is compiled into an HTTP response |
370
+
371
+ Supported formats:
372
+
373
+ - `:json` (default) — body is any JSON-serializable value; serialized with Oj;
374
+ sets `Content-Type: application/json` and `Content-Length`.
375
+ - `:text` — body is converted to a string; sets
376
+ `Content-Type: text/plain; charset=utf-8` and `Content-Length`.
377
+ - `:file` — body is a String path or a Hash with `:path` (required) and
378
+ optional `:filename`, `:disposition` (default `inline`), `:mime_type`;
379
+ streams the file and sets `Content-Type`, `Content-Length`, and
380
+ `Content-Disposition`.
381
+
382
+ ```ruby
383
+ Rivulet::Response.new(
384
+ status: 200,
385
+ format: :file,
386
+ body: { path: '/app/files/report.pdf', disposition: 'attachment' }
387
+ )
388
+ ```
389
+
390
+ - `:stream` — body must be IO-like (responds to `gets`, `each`, `read`,
391
+ `rewind`); wrapped in a streaming body with no automatic headers.
392
+ - `:as_is` — body is passed through unchanged with no automatic headers.
393
+
394
+ Statuses `204` and `304` require `body: nil` (a body with these statuses
395
+ is a validation error).
396
+
397
+ ### Calling Services from Handlers
398
+
399
+ Handler operations do not call services directly from the operation body.
400
+ They delegate through a **step** that acts as the transport↔domain boundary:
401
+ it invokes the service, then translates the service result into a
402
+ `Rivulet::Response` on both the success and failure paths. The operation
403
+ returns the monad this step produces (see [Handler Output](#handler-output)),
404
+ which the framework's dispatch step unwraps.
405
+
406
+ Services are registered in the top-level `Services` container
407
+ (`app/services.rb`) under their domain key. A handler step reaches one with
408
+ `Services['users']`; the generated `Service` object forwards action names
409
+ via `method_missing` to the domain's `operations.<action>` key — the same
410
+ dispatch pattern handlers use:
411
+
412
+ ```ruby
413
+ Services['users'].create_user(resource: User, attributes: input[:params])
414
+ # dispatches to Services::Users::Container['operations.create_user']
415
+ ```
416
+
417
+ Services return `Success(input)` (where `input[:data]` holds the projected
418
+ output) or `Failure[:error_type, payload]` — never a `Rivulet::Response`.
419
+ The calling step builds the response on both paths:
420
+
421
+ ```ruby
422
+ module Handlers
423
+ module Users
424
+ module Steps
425
+ class CreateUser < Rivulet::Step
426
+ def call(input)
427
+ result = Services['users'].create_user(
428
+ resource: User,
429
+ attributes: input[:params]
430
+ )
431
+
432
+ case result
433
+ in Success(service_input)
434
+ response = Rivulet::Response.new(
435
+ status: 201,
436
+ body: { data: service_input[:data] }
437
+ )
438
+ Success(response)
439
+ in Failure[:validation, errors]
440
+ response = Rivulet::Response.new(
441
+ status: 422,
442
+ body: { errors: errors }
443
+ )
444
+ Failure(response)
445
+ in Failure[type, payload]
446
+ response = Rivulet::Response.new(
447
+ status: 500,
448
+ body: { error: type, detail: payload }
449
+ )
450
+ Failure(response)
451
+ end
452
+ end
453
+ end
454
+ end
455
+ end
456
+ end
457
+ ```
458
+
459
+ The handler operation chains this step (optionally after transport-level
460
+ steps such as auth or context enrichment) and returns the result:
461
+
462
+ ```ruby
463
+ class Create < Rivulet::Operation
464
+ include Import[create_user: 'steps.create_user']
465
+
466
+ def call(input = {})
467
+ result = step create_user.(input)
468
+ result
469
+ end
470
+ end
471
+ ```
472
+
473
+ Such a step usually lives per-operation in `app/handlers/<domain>/steps/`,
474
+ since it knows the specific service action and model class. When the
475
+ delegation logic is uniform across domains, extract it to
476
+ `app/handlers/shared/steps/` and inject the service key and model class
477
+ rather than hardcoding them.
478
+
479
+ ### Services
480
+
481
+ Services encapsulate business use cases and remain independent from transport
482
+ concerns. A service domain is organized into operations, steps, contracts,
483
+ projections, and utils. Generate a service with:
484
+
485
+ ```bash
486
+ rivulet g service users --create --read --update --delete --list
487
+ ```
488
+
489
+ Running `rivulet g service operation users.create_user` creates both the
490
+ operation file and its corresponding contract, and ensures the `projections/`
491
+ directory exists. Generate a projection separately with
492
+ `rivulet g service projection users.common`.
493
+
494
+ #### Service Input
495
+
496
+ Service operations receive an input hash of the form:
497
+
498
+ ```ruby
499
+ {
500
+ resource: Post, # a model class (DI slot)
501
+ attributes: { name: 'John Doe', email: 'john.doe@example.com' }
502
+ }
503
+ ```
504
+
505
+ The `resource` slot is a dependency-injection point: it enters as a model
506
+ class (e.g. `Post`, or any ancestor/subclass) and is substituted with a
507
+ persisted instance as the pipeline progresses — for example, a `create` step
508
+ replaces `input[:resource]` with the saved record. The contract is
509
+ responsible for validating the `resource` type (allowed classes/ancestors)
510
+ alongside `attributes`. This shape is distinct from the handler input
511
+ (`{ params:, context: }`) — services never receive transport structures.
512
+
513
+ #### Contracts
514
+
515
+ Contracts define and validate service input using `dry-validation`. They
516
+ describe exactly what a service needs and enforce correctness before business
517
+ logic runs. Contracts subclass `ApplicationContract` (in
518
+ `app/application_contract.rb`) and are registered in the service container
519
+ under the `contracts` namespace. `ApplicationContract` is a base class only —
520
+ the shared `validate` step that runs a contract must be created in
521
+ `services/shared/steps`.
522
+
523
+ A contract must declare and validate **every input the operation needs to
524
+ run** — not just the flat business attributes. This includes `:resource`
525
+ (the model class, type-checked), `:attributes` (the business fields), and
526
+ `:current_user` (for authorization). For example, a `list_users` operation
527
+ that queries the User model and checks the acting user declares both:
528
+
529
+ ```ruby
530
+ class List < ApplicationContract
531
+ params do
532
+ required(:resource).value(type?: Class) # model class to query
533
+ required(:current_user).value(type?: User) # acting user, for authz
534
+ optional(:page).value(:integer)
535
+ optional(:per_page).value(:integer)
536
+ end
537
+ end
538
+ ```
539
+
540
+ A `create_user` operation declares `:resource`, `:current_user`, and the
541
+ `:attributes` hash together:
542
+
543
+ ```ruby
544
+ class CreateUser < ApplicationContract
545
+ params do
546
+ required(:resource).value(type?: Class)
547
+ required(:current_user).value(type?: User)
548
+ required(:attributes).hash do
549
+ required(:name).filled(:string)
550
+ required(:email).filled(:string)
551
+ end
552
+ end
553
+ end
554
+ ```
555
+
556
+ > The shared `Validate` step returns `Success(result.to_h)`, and
557
+ > `result.to_h` carries **only the keys declared in the contract** —
558
+ > undeclared keys are dropped. So a complete contract is what makes
559
+ > `:resource`, `:current_user`, and `:attributes` available to the rest of
560
+ > the pipeline. This is why the `Validate` template stays as-is (no
561
+ > `input.merge(result.to_h)` needed): if the contract declares everything,
562
+ > `result.to_h` already carries it all. An empty or partial contract isn't a
563
+ > placeholder — it's a bug that silently drops the operation's inputs.
564
+
565
+ Use a custom predicate to tighten `:resource` to specific classes or
566
+ ancestors (e.g. `value <= User`) when only certain models are allowed.
567
+
568
+ #### Projections
569
+
570
+ Projections define a service's output structure using `dry-transformer`,
571
+ keeping persistence models out of the response. They transform results into
572
+ an explicit shape describing which fields are exposed to callers. Projections
573
+ subclass `Rivulet::Projection` and are registered under the `projections`
574
+ namespace. `Rivulet::Projection` is a base class only — the shared `project`
575
+ step that applies a projection must be created in `services/shared/steps`.
576
+
577
+ A projection declares its transformations in a `define!` block. `accept_keys`
578
+ keeps only the listed keys (a slice), exposing exactly the fields callers
579
+ should see and dropping internal ones:
580
+
581
+ ```ruby
582
+ module Services
583
+ module Users
584
+ module Projections
585
+ class Common < Rivulet::Projection
586
+ define! do
587
+ accept_keys [:id, :name, :email]
588
+ end
589
+ end
590
+ end
591
+ end
592
+ end
593
+ ```
594
+
595
+ `Common.new.call(id: 1, name: 'Jane', email: 'j@e.org', password_digest: '...')`
596
+ returns `{ id: 1, name: 'Jane', email: 'j@e.org' }`. The shared `project` step
597
+ applies it: `projection.call(input[:resource])` (see the `project` step
598
+ above). Transformations compose left-to-right within `define!`, so you can
599
+ chain e.g. `rename_keys` or `map_value` after `accept_keys`.
600
+
601
+ Every projection must have a `define!` block with at least one transformation.
602
+ The generator produces empty projection classes — fill them before use: an
603
+ empty projection (no transformations) raises `NoMethodError` at runtime when
604
+ the `project` step calls it, and even if it didn't, it would leak internal
605
+ fields. Always declare the exposed fields explicitly, e.g. with `accept_keys`.
606
+
607
+ #### Background Jobs
608
+
609
+ Services are transport-agnostic and can be invoked from background jobs,
610
+ message consumers, CLI commands, or any other runtime — not just handlers.
611
+ Place background job definitions alongside the service domain they belong to.
612
+ A job calls a service operation the same way a handler does, passing an input
613
+ hash and consuming the result.
614
+
615
+ ### Routing
616
+
617
+ Routes are defined in `config/routes.rb` via `Rivulet.routes.draw`:
618
+
619
+ ```ruby
620
+ Rivulet.routes.draw do
621
+ get '/posts', to: 'posts#index'
622
+ post '/posts', to: 'posts#create'
623
+ get '/posts/:id', to: 'posts#show'
624
+ put '/posts/:id/publish', to: 'posts#publish'
625
+ end
626
+ ```
627
+
628
+ HTTP methods: `get`, `post`, `put`, `patch`, `delete`.
629
+
630
+ The `to:` option accepts two forms:
631
+
632
+ - String — `'posts#show'` maps to `Handlers['posts'].show`
633
+ - Hash — `{ to: :posts, action: :index }` (alternative explicit syntax)
634
+
635
+ Path parameters (e.g. `:id`) become part of the input hash available to
636
+ steps. Multiple params are supported in a single route:
637
+
638
+ ```ruby
639
+ get '/organizations/:org_id/users/:id', to: 'users#show'
640
+ # input[:params] => { org_id: '1', id: '42' }
641
+ ```
642
+
643
+ Scopes prefix a group of routes:
644
+
645
+ ```ruby
646
+ scope(:organizations) do
647
+ get '/users', to: 'users#index'
648
+ post '/users', to: 'users#create'
649
+ get '/users/:id', to: 'users#show'
650
+ end
651
+ # produces /organizations/users, /organizations/users/:id, etc.
652
+ ```
653
+
654
+ List all registered routes with `rivulet routes`.
655
+
656
+ #### Handler Dispatch
657
+
658
+ The generated `Handler` forwards actions via `method_missing` to
659
+ `Container['operations.<action>']` — e.g. `Handlers::Posts::Handler.new.show`
660
+ resolves `operations.show` in the posts container and calls it. The framework
661
+ invokes the route callable with `params:` and `context:`, which becomes the
662
+ operation's `input` hash (see [Handler Input](#handler-input)).
663
+
664
+ ## Conventions
665
+
666
+ - Operations chain steps with `step foo.(result)` and return `result`.
667
+ The operation defines flow; steps perform the work.
668
+ - Steps return `Success(input)` / `Failure(...)` and enrich a shared
669
+ input hash. A failure short-circuits the pipeline.
670
+ - Handler output is `Success(Rivulet::Response)` / `Failure(Rivulet::Response)`,
671
+ produced by a step — not constructed bare in the operation.
672
+ - Services return `Success(input)` / `Failure(...)` where the input
673
+ contains the current execution state. Services never produce
674
+ `Rivulet::Response` — that is a transport-layer object. On failure, use
675
+ `Failure[:error_type, payload]` — a `Failure` monad wrapping a two-element
676
+ array: a symbol error type and a payload. For example, validation failures
677
+ use `Failure[:validation, result.errors.to_h]`. The handler layer maps
678
+ this to an HTTP response.
679
+ - App code under `app/` is autoloaded by Zeitwerk (eager-loaded at startup).
680
+ No manual requires — place files per the directory convention.
681
+ - Dependencies are injected via `include Import[name: 'steps.name']`;
682
+ keys resolve against the domain's `Container`.
683
+ - `Import` is auto-built per class by an `inherited` hook that walks the class
684
+ name up to `Operations` or `Steps` and appends `Container`. So
685
+ `Services::Users::Steps::CreateUser` resolves keys against
686
+ `Services::Users::Container`. Shared steps are reachable because each domain
687
+ container does `import Services::Shared::Namespace` /
688
+ `import Handlers::Shared::Namespace`.
689
+ - Container `namespace('steps') do ... end` blocks are maintained manually —
690
+ generators regex-insert registrations after the `namespace` line. Preserve
691
+ these blocks when hand-editing `container.rb`.
692
+ - Plain `Sequel::Model` subclasses in `app/models` work with zero setup. The
693
+ framework sets `Sequel::Model.db` and `require_valid_table = false` at
694
+ startup, so no `DB = Sequel.connect` boilerplate and no base class are needed.
695
+ - Handlers are registered in `app/handlers.rb`:
696
+ `register('posts') { Handlers::Posts::Handler.new }`.
697
+ - Config: `config/application.rb`. Routes: `config/routes.rb`.
698
+
699
+ ## Common Tasks
700
+
701
+ ### Add an endpoint
702
+
703
+ 1. Generate a handler: `docker compose exec app rivulet g handler posts --list`
704
+ 2. Generate operations/steps:
705
+ `docker compose exec app rivulet g handler operation posts.show`,
706
+ `docker compose exec app rivulet g handler step posts.load_post`
707
+ 3. Add a route in `config/routes.rb`: `get '/posts/:id', to: 'posts#show'`
708
+ 4. Implement the steps, then the operation that chains them.
709
+
710
+ ### Add a migration
711
+
712
+ 1. `docker compose exec app rivulet g migration create_users`
713
+ 2. Edit `db/migrations/<timestamp>_create_users.sql`
714
+ 3. `docker compose exec app rivulet db migrate`
715
+
716
+ Migrations are plain SQL, forward-only. The filename must be
717
+ `YYYYMMDDHHMMSS_name.sql`. The runner executes the **entire file as one
718
+ statement** (`db.run(File.read(path))`) — no Ruby DSL, no `down`/rollback.
719
+ Applied versions are tracked in the `schema_migrations` table. Each migration
720
+ must be self-contained SQL; there is no `drop`/`alter` safety net.
721
+
722
+ ## Gotchas
723
+
724
+ - All `rivulet` CLI commands run inside the container via
725
+ `docker compose exec app rivulet` (with the stack up via `docker compose up`).
726
+ Do not run `bundle exec rivulet` on the host.
727
+ - Zeitwerk eager-loads `app/` at startup — misplaced files won't be found.
728
+ - Steps must return a monad (`Success`/`Failure`); returning a bare value
729
+ breaks the pipeline.
730
+ - No session middleware is configured by default, so `context.session` is
731
+ `nil` unless you add Rack session middleware yourself.
732
+ - No auth gem ships in the Gemfile — build it from scratch. Authentication
733
+ (password hashing, token issuance, `authenticate`) lives in
734
+ `handlers/shared/`; authorization (`authorize_*`, access policies) lives
735
+ in `services/shared/`, since access rights are a business requirement.
736
+ - No plain modules in `handlers/` or `services/` — every helper is either a
737
+ `Rivulet::Step` (monad, pipeline) or a registered util (direct result,
738
+ injected via `Import`). Don't `include` ad-hoc modules into steps; register
739
+ them in the container instead.
740
+ - Keep nesting in routes shallow and meaningful — express context, not the
741
+ full domain hierarchy.