AvelonDocs

@avelon

s4

@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.ts and User.ts
  • app/Http/Controllers/PostController.ts
  • app/Http/Requests/StorePostRequest.ts and DestroyPostRequest.ts
  • app/Http/Views/PostView.ts
  • app/Policies/PostPolicy.ts
  • app/Wards/PostWard.ts and UserWard.ts
  • app/Events/PostPublished.ts
  • app/Listeners/NotifySubscribers.ts
  • routes/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 / exportSignatureDescription
Controller.authorize(ability: string, resource?: unknown) => Promise<void>Delegates to Gate.authorize.
Controller.user() => GateActorReturns the bound request actor.
Controller.userOrFail() => { readonly id: string }Returns the actor or throws Unauthenticated.
Route.get(path, controller, action?) => RouteBuilderRegisters a GET route.
Route.post(path, controller, action?) => RouteBuilderRegisters a POST route.
Route.put(path, controller, action?) => RouteBuilderRegisters a PUT route.
Route.patch(path, controller, action?) => RouteBuilderRegisters a PATCH route.
Route.delete(path, controller, action?) => RouteBuilderRegisters 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) => RouteDefinitionFinds a named route or throws NotFound.
Route.reset() => voidClears registrations.
RouteBuilder.name(name: string) => thisSets the stable route name.
RouteBuilder.middleware(...names: string[]) => thisAppends middleware aliases.
RouteBuilder.bind(param, model) => thisMarks a path parameter for model binding.
route(name, params?) => stringFills {param} placeholders on a named route.
httpRequest(overrides?: Partial<HttpRequest>) => HttpRequestBuilds a kernel request.
PostPublishedInbox.record(postId: string) => voidRecords a queued listener delivery.
PostPublishedInbox.all() => readonly string[]Returns delivered post ids.
PostPublishedInbox.reset() => voidClears recorded deliveries.
bootSlice(options: SliceBootOptions) => KernelWires drivers and registers the Post resource.
createPostKernel() => KernelCreates the Post kernel with actor and binding hooks.
dispatchNamed(name: string, request: HttpRequest) => Promise<KernelResult>Dispatches a named route and flushes after listeners.
SliceBootOptionsinterface SliceBootOptionsDatabase, optional queue, and optional mail drivers.
Postclass PostScrivener model for the posts table.
Userclass UserScrivener 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.
UpdatePostRequesttypeof StorePostRequestAlias of the store request contract.
DestroyPostRequest.validate(request: HttpRequest, actor?: GateActor) => Promise<DestroyPostInput>Empty body; requires an actor.
PostInputinterface PostInputValidated title and body.
DestroyPostInputinterface DestroyPostInputEmpty validated destroy payload.
PostView.make(post: PostRecord) => PostViewModelSerializes one post for a view.
PostView.collection(posts: readonly PostRecord[]) => readonly PostViewModel[]Serializes many posts.
PostViewModelinterface PostViewModelAdapter-safe post props.
PostPolicyPolicyAbilitiesviewAny/view/create/update/delete handlers.
PostWardWardAbilitiesread/insert/update/delete row predicates.
UserWardWardAbilitiesPublic reads; authors update themselves.
PostPublishedclass PostPublishedEvent carrying postId, actorId, and title.
NotifySubscribersListenerDefinition<PostPublished, void, 'queued'>Queued listener that records and mails.
registerApp() => voidRegisters policy, ward, listener, and routes.
defineWebRoutes() => voidRegisters the Post resource routes.
postMigrationsreadonly { 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_URL or postgresql://postgres:avelon@127.0.0.1:5432/avelon_test
  • Supabase PostgREST: SUPABASE_REST_URL (default http://127.0.0.1:3001) plus SUPABASE_DB_URL (default postgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase) and SUPABASE_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.