@avelon/s4
@avelon/s4 is the Post resource vertical slice. It wires routes, a controller, a form request, Scrivener models, a ward, a policy, opaque views, and one event with a queued listener against the frozen @avelon/core contracts. Reach for this package when you need to prove that Wave A drivers and Wave B runtime/ORM compose, not when you need a Next adapter or reeve generators.
Installation
bun add @avelon/s4
The slice expects @avelon/core and @avelon/orm. You pass database, queue, and mail drivers in at boot; those imports belong in config, not under app/.
Basic Usage
import { FakeDatabase, FakeMail, FakeQueue } from '@avelon/conformance'
import { Events } from '@avelon/core'
import { PostPublishedInbox, User, bootSlice, dispatchNamed, httpRequest } from '@avelon/s4'
const database = new FakeDatabase()
bootSlice({
database,
queue: new FakeQueue(),
mail: new FakeMail(),
})
await User.create({ id: 'u1', email: 'ada@example.test', name: 'Ada' })
await dispatchNamed(
'posts.store',
httpRequest({
method: 'POST',
cookies: { user: 'u1' },
body: { title: 'Hello isle', body: 'A published post.' },
}),
)
await Events.flushQueued()
PostPublishedInbox.all()
Event.dispatch does not exist. The frozen Event class is a primitive; you dispatch through Events. Mail is a type alias, so the listener sends with mailers.mailer().
Resource Shape
The slice lives at conventional application paths inside this package:
app/Models/Post.tsandUser.tsapp/Http/Controllers/PostController.tsapp/Http/Requests/StorePostRequest.tsandDestroyPostRequest.tsapp/Http/Views/PostView.tsapp/Policies/PostPolicy.tsapp/Wards/PostWard.tsandUserWard.tsapp/Events/PostPublished.tsapp/Listeners/NotifySubscribers.tsroutes/web.ts
Route.resource is available; this slice registers index and show publicly and the write actions behind auth middleware so an anonymous reader can list published posts.
Wards And Drivers
PostWard scopes reads to published rows, plus an author's own drafts. UserWard allows public reads and authenticated writes so sign-in lookups work. @avelon/postgres injects that predicate as QueryIR.ward and does not compile RLS (rowSecurity: false). @avelon/supabase injects the same predicate and can compile it to RLS through syncWards(). Writes are authorized by PostPolicy; insert/update/delete ward injection is not applied by Scrivener, and the Supabase compiler rejects a non-constant insert ward.
Queued Listeners
NotifySubscribers declares delivery: 'queued' on the notifications queue. bootSlice calls Events.bindQueue when you pass a queue driver. dispatchNamed flushes after listeners only; you drain queued work with Events.flushQueued().
Method Reference
| Method / export | Signature | Description |
|---|---|---|
Controller.authorize | (ability: string, resource?: unknown) => Promise<void> | Delegates to Gate.authorize. |
Controller.user | () => GateActor | Returns the bound request actor. |
Controller.userOrFail | () => { readonly id: string } | Returns the actor or throws Unauthenticated. |
Route.get | (path, controller, action?) => RouteBuilder | Registers a GET route. |
Route.post | (path, controller, action?) => RouteBuilder | Registers a POST route. |
Route.put | (path, controller, action?) => RouteBuilder | Registers a PUT route. |
Route.patch | (path, controller, action?) => RouteBuilder | Registers a PATCH route. |
Route.delete | (path, controller, action?) => RouteBuilder | Registers a DELETE route. |
Route.resource | (name, controller) => { bind } | Expands the seven resource routes. |
Route.middleware | (...names: string[]) => { group } | Applies middleware inside a group. |
Route.prefix | (prefix: string) => { group } | Prefixes routes inside a group. |
Route.all | () => readonly RouteDefinition[] | Returns registered routes. |
Route.find | (name: string) => RouteDefinition | Finds a named route or throws NotFound. |
Route.reset | () => void | Clears registrations. |
RouteBuilder.name | (name: string) => this | Sets the stable route name. |
RouteBuilder.middleware | (...names: string[]) => this | Appends middleware aliases. |
RouteBuilder.bind | (param, model) => this | Marks a path parameter for model binding. |
route | (name, params?) => string | Fills {param} placeholders on a named route. |
httpRequest | (overrides?: Partial<HttpRequest>) => HttpRequest | Builds a kernel request. |
PostPublishedInbox.record | (postId: string) => void | Records a queued listener delivery. |
PostPublishedInbox.all | () => readonly string[] | Returns delivered post ids. |
PostPublishedInbox.reset | () => void | Clears recorded deliveries. |
bootSlice | (options: SliceBootOptions) => Kernel | Wires drivers and registers the Post resource. |
createPostKernel | () => Kernel | Creates the Post kernel with actor and binding hooks. |
dispatchNamed | (name: string, request: HttpRequest) => Promise<KernelResult> | Dispatches a named route and flushes after listeners. |
SliceBootOptions | interface SliceBootOptions | Database, optional queue, and optional mail drivers. |
Post | class Post | Scrivener model for the posts table. |
User | class User | Scrivener model for the users table. |
Post.published | () => QueryBuilder<Post> | Starts a query constrained to non-null published_at. |
PostController.index | () => Promise<ViewResult> | Lists published posts. |
PostController.create | () => Promise<ViewResult> | Renders the create form. |
PostController.store | (request: HttpRequest) => Promise<RedirectResult> | Validates, creates, and dispatches PostPublished. |
PostController.show | (request: HttpRequest, post: Post) => Promise<ViewResult> | Renders one bound post. |
PostController.edit | (request: HttpRequest, post: Post) => Promise<ViewResult> | Renders the edit form. |
PostController.update | (request: HttpRequest, post: Post) => Promise<RedirectResult> | Validates and updates a bound post. |
PostController.destroy | (request: HttpRequest, post: Post) => Promise<RedirectResult> | Validates and soft-deletes a bound post. |
StorePostRequest.validate | (request: HttpRequest, actor?: GateActor) => Promise<PostInput> | Parses title/body and requires an actor. |
UpdatePostRequest | typeof StorePostRequest | Alias of the store request contract. |
DestroyPostRequest.validate | (request: HttpRequest, actor?: GateActor) => Promise<DestroyPostInput> | Empty body; requires an actor. |
PostInput | interface PostInput | Validated title and body. |
DestroyPostInput | interface DestroyPostInput | Empty validated destroy payload. |
PostView.make | (post: PostRecord) => PostViewModel | Serializes one post for a view. |
PostView.collection | (posts: readonly PostRecord[]) => readonly PostViewModel[] | Serializes many posts. |
PostViewModel | interface PostViewModel | Adapter-safe post props. |
PostPolicy | PolicyAbilities | viewAny/view/create/update/delete handlers. |
PostWard | WardAbilities | read/insert/update/delete row predicates. |
UserWard | WardAbilities | Public reads; authors update themselves. |
PostPublished | class PostPublished | Event carrying postId, actorId, and title. |
NotifySubscribers | ListenerDefinition<PostPublished, void, 'queued'> | Queued listener that records and mails. |
registerApp | () => void | Registers policy, ward, listener, and routes. |
defineWebRoutes | () => void | Registers the Post resource routes. |
postMigrations | readonly { id, up, down }[] | Driver-owned SQL creating users and posts. |
PostIndexView | 'posts.index' | Opaque index view reference. |
PostShowView | 'posts.show' | Opaque show view reference. |
PostCreateView | 'posts.create' | Opaque create view reference. |
PostEditView | 'posts.edit' | Opaque edit view reference. |
Testing
Point bootSlice at FakeDatabase, FakeQueue, and FakeMail for in-process composition. Live tests construct @avelon/postgres and @avelon/supabase drivers, apply postMigrations, and run the same kernel dispatch. Those constructors stay in the test file, which is the config seam.
import { FakeDatabase, FakeMail, FakeQueue } from '@avelon/conformance'
import { bootSlice } from '@avelon/s4'
bootSlice({
database: new FakeDatabase(),
queue: new FakeQueue(),
mail: new FakeMail(),
})
bun test
bun run typecheck
Live databases
S4 tests skip live @avelon/postgres / @avelon/supabase when those services are unreachable. Do not add them to install or start. Defaults:
- Postgres:
POSTGRES_URLorpostgresql://postgres:avelon@127.0.0.1:5432/avelon_test - Supabase PostgREST:
SUPABASE_REST_URL(defaulthttp://127.0.0.1:3001) plusSUPABASE_DB_URL(defaultpostgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase) andSUPABASE_SERVICE_ROLE_KEY
The supabase live test grants anon/authenticated/service_role on users and posts after CREATE TABLE so PostgREST can see the slice. Those roles do not exist on independent Postgres, so the postgres migration omits GRANT.