@kestack/kabuki
v0.0.5
Published
An app as one tree of Actors outside React: each Actor a small state machine driven by Messages, one immutable Snapshot for the whole app, Effect running the work, and React only drawing it.
Maintainers
Readme
@kestack/kabuki
An app as one tree of Actors outside React: each Actor a small state machine driven by Messages, one immutable Snapshot for the whole app, Effect running the work, and React only drawing it.
Big picture
Kabuki is the Elm architecture, as Foldkit
builds it on Effect, organized as a tree of Actors and drawn by React. Each
Actor is a state machine and an actor at once: Messages are the only way it
changes, and its State decides which Children it Invokes, which Capabilities
it Provides to them and which Workers run. Leaving a State stops all of it.
Any fact in the Model can own a Worker too: an upload runs for as long as its
row says Uploading.
Children are written the way Workers are: always, while a test holds, or
one per key each names, so a list of items can be a list of Actors.
It is declarative, as React is: a parent says what should be, never what to
do. It controls its Children only by whether they exist and by their Props,
it hears from them only what they Emit, each turned into one of its own
Messages, and what it must control it owns, as React lifts state up.
The whole app is one immutable Snapshot, and handling a Message is a pure
function from one Snapshot to the next. There is one mailbox, and a Message is
handled before send returns. Around that pure core, everything that touches
the outside world is Effect: each Instance's work lives in nested Scopes, and
only the work where a Message landed is stopped or started. An Actor's own
code reaches only the Capabilities it Requires. What its Children need bubbles
up until an ancestor Provides it, and the app does not compile until its Layer
covers the rest.
The Runtime runs a History. It starts the root Instance at the History's Head, hands it every Message through one Mailbox, and writes each one into the History; every Instance runs the rest of its own life, starting and stopping its States, Children, Workers and Commands. The History is a tree of Messages, like git commits, that replays itself to the Snapshot right after any one of them, on any Branch, while the live app keeps running. Stop, move its Head, and start again, and new Messages grow a new Branch. A History is also what an app is saved as and drawn from before it runs: on a server, at build time, and across a hot reload.
Routing is a layer on top, in @kestack/kabuki/routing, that core never
imports: a page is an Actor with a place in the URL. The router is read down
from the pages' definitions, so any build can import it and name a Route by
type. One Router Actor above the pages holds the history and the data of
every page on screen, and hands each page its own as Props, so a page move
is one Message and a page is a Child coming and going. It routes in memory
by default; the browser's history and TanStack Router's are Adapters, each
its own subpath. How it fits together is in
docs/routing.md.
Start, in @kestack/kabuki/start and its Vite plugin, serves a routed app
from a Cloudflare Worker: for a URL it runs the Loaders of the places it
names at once, starts every page from what its Loader gave, and sends the HTML with the
Snapshot beside it, which the browser hydrates and carries on from. It never
runs a Runtime on the server. An app writes three files: its pages, its app
with its Document and what it needs in the browser, and its Loaders with what
they need on the server, as a Layer made once and one made per visitor; the
plugin writes every entry. Each
platform is an adapter of its own subpath: Cloudflare Workers, or through
Nitro any host it has a preset for, Node, Bun, Vercel and AWS Lambda among them. For what stands in
front of the app, such as a service worker, Start serves its Pages, each with
how its first drawing is made, and its App Shell, which the browser draws the
app in at its own URL; it knows nothing of what uses them. How it works is in
docs/start.md.
Kabuki is four layers, each its own subpath and each using only the layers
below it: core, @kestack/kabuki, defines and runs Actors and knows no
React; @kestack/kabuki/react draws them; @kestack/kabuki/routing gives
pages a place in the URL; @kestack/kabuki/start serves a routed app. An
app takes as many as it needs, from the bottom up. A layer's path gives
only the values an app is made of: Actor, Command, History and
Runtime; View; Page, Router and Navigation; Start.
Each folder in a layer is a path of its own that gives its value and every
type that goes with it, so a type is imported from where it lives:
import type { Running } from '@kestack/kabuki/core/runtime'.
Read the language in CONTEXT.md, the decisions in
docs/adr/, and how the History works in
docs/history.md. A live demo, Opening an app, is at /demos/opening-an-app in the docs app: every concept in one story, drawn as the tree.
Install
pnpm add @kestack/kabuki effect reacteffect: Schemas describe Inputs, Models, States and Messages, and every one is checked against its Schema as it is made; Workers, Commands and Capabilities are Effect values and Layers.react:@kestack/kabuki/reactdraws the tree with React components.react-dom, optional: Start draws pages withreact-dom/serverand hydrates them withreact-dom/client.vite, optional:@kestack/kabuki/start/viteis a Vite plugin.
Exports
@kestack/kabuki
| Export | What it does |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Actor.define | Says what an Actor is: its name, Requires, Schemas, what it Emits and the Queries it answers, each with what it is asked with and answers; each Schema may be written as its fields or its cases. It runs nothing. |
| definition.build | Says how a defined Actor behaves, at the top and under states in one block per State: its rules, Children, Workers, Provides and answers to its Queries. The build is what runs and is drawn. |
| Actor.child | Makes one Child in a level's children: its Actor, then always, while a test holds, or one per key each names, with what it is handed and what the parent hears; every function in it is typed by the level it is written in. |
| Command.make | Defines a Command from its name, the Schema of its args and the Effect it runs; an Update calls it with the args. |
| Runtime.start | Runs an Actor, or carries a History on, inside a Scope, with the app's Layer; gives back send, start and stop. Started from an Actor, its History keeps no Messages. |
| running.changes | The live Snapshot as an Effect Stream: now, then whenever the app settles; a slow reader gets only the latest. |
| running.instance | The root Instance, typed and live, for code outside the tree: reads any Instance's data, Children and Workers, asks its Queries, Sends to it under a name and learns what became of the Message, and waits until its data passes a test. |
| History.make | A new, empty History for an Actor, with the root's Input. |
| History.load | A History carrying on from one saved before, read with the Actor's code as it is now. |
| History.restored | The lightest History an app can be saved as: one Restore of a Snapshot at a Time, right after init. |
| history.changes | The History as plain JSON in an Effect Stream: now, then after every change, so saving it is Stream.debounce and Stream.runForEach. |
| history.keep | Sets the History's Window, how many entries it keeps; older ones Compact into a Restore. Works while it runs. |
@kestack/kabuki/core/actor
Actor, as @kestack/kabuki gives it, with the types an Actor is named and written by: ActorType, AnyActor, ActorDefinition, Self, Single, Types and the rest.
| Export | What it does |
| -------------- | ---------------------------------------------- |
| Actor.define | As in @kestack/kabuki: what an Actor is. |
| Actor.child | As in @kestack/kabuki: one Child of a level. |
@kestack/kabuki/core/command
| Export | What it does |
| -------------- | ---------------------------------------------------------------------------------------- |
| Command.make | As in @kestack/kabuki: a Command from its name, the Schema of its args and its Effect. |
@kestack/kabuki/core/history
History, with SavedHistory, Entry and HistoryOptions.
| Export | What it does |
| ------------------ | ------------------------ |
| History.make | As in @kestack/kabuki. |
| History.load | As in @kestack/kabuki. |
| History.restored | As in @kestack/kabuki. |
@kestack/kabuki/core/runtime
Runtime, with Running, LiveInstance, Needs, Provided and WorkerStatus.
| Export | What it does |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Runtime.start | As in @kestack/kabuki. |
| Stopped | What a Query asked from outside the tree, or an until waiting on an Instance, fails with once the Instance, or the Runtime, has stopped. |
@kestack/kabuki/core/snapshot
The whole app as one value, read and stepped from outside the Runtime, as a devtool or a test does. With Snapshot, Instance, Envelope, Handled and Source.
| Export | What it does |
| --------------------- | ------------------------------------------------------------------------------ |
| Snapshot.handle | Applies one Envelope to a Snapshot and gives the next one, purely. |
| Snapshot.instanceAt | Finds the Instance with an ID in a Snapshot. |
| Snapshot.isMany | Tells the list of an each Child from one Instance in an Instance's Children. |
@kestack/kabuki/react
| Export | What it does |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| View.make | Defines how one built Actor is drawn, one function for every State, as a React component that takes the Instance, and a ref and style, from its parent; the View carries its Actor as .actor. |
| View.app | Turns the root's View, with the Layer that gives what the tree Requires, into one React component that runs the whole app, one Runtime for every mount; useRuntime reads its History, shows any Step, and stops and starts it. Drawn before it runs, on a server too, from a saved History if given one. |
| View.lazy | A View of the Actor it is given first, whose code is loaded the first time it is drawn, or on preload(); a server waits for it before it sends, and tells the App's onLazyView. |
| View.preload | Loads the lazy Views made with these ids, so a browser hydrating a page a server drew never waits on their code. |
| useChild | Reads one Child's Model, State and Props from its parent's View, to draw, or only what an optional selector picks; it can never Send. |
| useChildren | Reads the Instances of an each Child the same way, in order; only the component that calls it re-renders, as they change or as what the selector picks does. |
@kestack/kabuki/react/view
View, useChild and useChildren, as @kestack/kabuki/react gives them, and useLive, with ViewProps, ViewOf, Handle, Root, ChildData, LazyView, AppRuntime, Attachment, AppSetup, AppOptions and AppProps.
| Export | What it does |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| View.make, View.lazy, View.preload, View.app | As in @kestack/kabuki/react. |
| useChild, useChildren | As in @kestack/kabuki/react. |
| useLive | Says whether the app is drawn as it is now, not at a past Step, so an effect that touches the page around it can stay still under Time Travel. | |
@kestack/kabuki/routing
| Export | What it does |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page.define | Says what a page is: an Actor definition with a place in the URL, its params, query, data, Render Mode, Metadata, the definitions of the pages under it, and its own Schemas and Queries. |
| page.build | Says how a page behaves, as an Actor's build takes it, with the build of each page under it in pages; a page is drawn with View.make. |
| Page.notFound | What a Loader fails with when its place is not found. |
| Page.redirect | What a Loader fails with to send the app to another of its Routes, or to a whole URL outside it; { permanent: true } makes a server answer 308 rather than 302. |
| Page.NotFound | The Schema of a Loader's not-found failure, to carry it over the wire. |
| Page.Redirect | The Schema of a Loader's Redirect, to carry it over the wire. |
| Page.wholeQuery | A query field holding the whole query as written, every pair of it, a repeated or undeclared one too: for a page whose query is someone else's to say, as a signed request. Written back whole, the page's other fields over it. |
| Router.define | Reads the app's router down from the root page's definition: a constructor per place, the URL both ways, each place's Metadata and Render Mode, every path, the typing of the Loaders, and how the Router behaves in the browser, such as how long a move waits for its data, delay (150 ms), and how long a loading screen stays at least once drawn, minimum (300 ms). |
| Router.view | Puts the Router above the root page's View: it holds the history, every move and every page's data. It gives the Router's View, which View.app starts at the browser's URL, with startAt and href. With scrollRestoration: true on the router it restores scroll as TanStack Router does. |
| Router.Link | Draws a plain <a href> to a Route, preloading it on hover, once in view, or never, going to an element on the page with hash, and leaving every scroll as it is with resetScroll={false}; the Router catches a plain click and moves the app. |
| Router.Retry | A button that loads every page on screen again, for a page whose Loader broke. |
| Router.push | A Command that moves the app to a Route, as a new entry. |
| Router.replace | A Command that puts a Route in place of the current entry, so Back skips it. |
| Router.back | A Command that goes Back. |
| Router.forward | A Command that goes Forward. |
| Router.backTo | A Command that goes Back when the app came from a Route, and replaces the entry with it otherwise: closing a dialog with a Route of its own. |
| Router.reload | A Command that loads every page on screen again, as after a save; the pages and their Models stay. Router.reload({ anew: true }), when what the data belongs to changed, as the signed-in account: the data shown goes, held and then loading as on a move; with hold: true, held until the new data comes, the Command ending once it lands. |
| Router.preload | A Command that warms the Data cache, and the code of the lazy Views, for a Route. |
| Navigation | The Capability the Router Provides to every page, which an Actor under a page that moves the app lists in its Requires. |
| useRouter | Reads the Router from a component drawn inside the app: the current Route and URL, the history, the move it holds while it loads as pending, the title, Back, Forward, reload and preload; useRouter<Route>() types its Routes as the app's. |
@kestack/kabuki/routing/page
Page, as @kestack/kabuki/routing gives it, with PageDefinition, PageBuild, Status, Metadata, RedirectTo and the rest of what a page is typed by.
@kestack/kabuki/routing/router
Router and useRouter, as @kestack/kabuki/routing gives them, useElementScrollRestoration and useKeepScrollAnchor, with RouterDefinition, RouterOptions, Preload, MoveOptions and ReloadOptions.
| Export | What it does |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| useElementScrollRestoration | Reads where an element, or the window, was scrolled on the entry drawn, and the anchor it kept, as scroll restoration kept it: for a virtual list that puts itself back. |
| useKeepScrollAnchor | Keeps where a list that puts itself back is, in its own terms, under the entry drawn; scroll restoration then leaves that list to put itself back. |
@kestack/kabuki/routing/capabilities
Navigation, as @kestack/kabuki/routing gives it, and Loading, with Answer, AnyRoute, MoveOptions and ReloadOptions.
| Export | What it does |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| Loading | The Capability the Router uses, if the Layer has it, to load the data of places. |
| Loading.memory | A Loading that runs the Loaders in place, for tests, stories and the server. |
| Loading.cached | Puts the Data cache in front of a Loading: the last data of each place, shown at once on Back, a link or a preload. |
@kestack/kabuki/routing/adapter
| Export | What it does |
| --------- | ---------------------------------------------------------------------------------------------------------- |
| Adapter | The service where an app's address lives, which each adapter below gives: write one for any other history. |
| KEY | The key the Router keeps in a history entry's state, so Back and Forward land on an entry it knows. |
| keyIn | Reads that key from a history entry's state, if the Router wrote one. |
@kestack/kabuki/routing/browser
| Export | What it does |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| browser | A Layer that puts the app's address in the browser's own history, for an app that owns the whole site or everything under its base. |
@kestack/kabuki/routing/tanstack
| Export | What it does |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| tanstack | A Layer that puts the app's address in TanStack Router's history, for an app on a TanStack Start catch-all route; TanStack gets back every URL outside the base. |
@kestack/kabuki/start
| Export | What it does |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Start.app | Takes the app's router, its root page's View, its Document, what the Actors Require in the browser, checked against them, its name there, and what to attach there: what src/app default-exports. |
| Start.Document | The plainest Document, Start's parts in an English page: what an app that gives none is drawn in. |
| Start.Head | Draws, in the Document's <head>, the charset and viewport (its own with viewport), the Metadata, and the styles and chunks the page needs. |
| Start.Scripts | Draws, at the end of the Document's <body>, the saved History as JSON and the script that hydrates the page. |
| Start.useDrawn | Reads when and by which build the server drew the page, and whether the component asking was in that drawing or drawn later by the browser. |
| Start.useRuntime | Reads the running app from the Document, for devtools drawn around it; null on the server. |
| Start.pages | Where the server answers the Pages as JSON (Pages, from @kestack/kabuki/start/app): the build, where the App Shell is, and every page with its path, whether it has params, and its Render Mode. |
@kestack/kabuki/start/app
Start, as @kestack/kabuki/start gives it, with DocumentProps, Drawn, Pages, PageInfo, StartApp, DocumentType and Attachment.
@kestack/kabuki/start/platform
The port each platform's adapter fills, in types only: no runtime value.
@kestack/kabuki/start/cloudflare
| Export | What it does |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| cloudflare | Cloudflare Workers, for start({ platform: cloudflare() }): the server built as one module in its edge form. |
| serve | Start's handler as a Worker's fetch, handed the Worker's env and the Cloudflare Cache: what the server's entry default-exports. |
@kestack/kabuki/start/nitro
| Export | What it does |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| nitro | Any Nitro preset, for start({ platform: nitro({ preset: 'vercel' }) }) beside Nitro's own plain nitro() plugin, which it hands the preset; Nitro builds the server for the host and serves the assets. |
| serve | Start's handler as Nitro's SSR entry, handed the Worker's env and the Cloudflare Cache on Cloudflare, and the process's env elsewhere: what the server's entry default-exports. |
@kestack/kabuki/start/vite
| Export | What it does |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| start | The Vite plugin: builds for the platform its adapter names, finds the app's two files by name, writes Start's handler, the server's and the browser's entries, names every lazy View by its module, and serves SSR in dev; works under Alchemy's Website.Vite. |
@kestack/kabuki/start/server
| Export | What it does |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| handler | Makes Start's handler, which knows no platform, from what the app's two files export: what the plugin's handler module runs; an app calls it only in a test. |
| Start.server | Takes Loaders that Require nothing alone, or first what they Require on the server, a layer made once and a visitor made per request, then the Loaders with .loaders(…), checked against both: what src/loaders.server default-exports. |
| Start.env | Reads the platform's env, typed as the app says it is, from Start.Env. |
| Start.Env | The service holding the platform's env for this request: a Worker's bindings, or the process's. |
| Start.Request | The service holding the request being answered, for the visitor and the dynamic and client Loaders only. |
| Start.ResponseHeaders | The service a dynamic or client Loader, or the visitor, adds headers to its response with, as a refreshed session's set-cookie. |
@kestack/kabuki/start/client
| Export | What it does |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | Hydrates a page the Worker drew, from the History it saved in it: what the plugin's browser entry runs; an app never calls it. |
| loading | The Loading that asks the server for the data of places at POST /_kabuki/load, each answered as it comes: what client hydrates with, behind the Data cache. |
Usage
A gate whose States decide what exists
This root signs a user in. While Anonymous it Invokes login, which Emits
SignedIn once the form is sent; while Authenticated it Provides Session
and Invokes todos.
export const Login = Actor.define('Login', {
model: { name: Schema.String },
message: { Typed: { name: Schema.String }, Submitted: {} },
emits: { SignedIn: { user: Schema.String } },
});
export const LoginLive = Login.build({
init: () => ({ model: { name: '' } }),
on: {
Typed: ({ name }) => ({ model: { name } }),
Submitted: (_, { model }) => ({
emit: { _tag: 'SignedIn', user: model.name },
}),
},
});
export const Auth = Actor.define('Auth', {
requires: [Server],
state: {
Checking: {},
Anonymous: {},
Authenticated: { user: Schema.String },
},
message: {
CheckedSession: { user: Schema.optional(Schema.String) },
LoggedIn: { user: Schema.String },
LoggedOut: {},
},
});
export const AuthLive = Auth.build({
init: () => ({ state: { _tag: 'Checking' } }),
states: {
Checking: {
workers: {
check: {
run: (self) =>
Effect.gen(function* () {
const user = yield* (yield* Server).checkSession;
yield* self.send({ _tag: 'CheckedSession', user });
}),
},
},
on: {
CheckedSession: ({ user }) => ({
state: user ? { _tag: 'Authenticated', user } : { _tag: 'Anonymous' },
}),
},
},
Anonymous: {
children: {
login: Actor.child(LoginLive, {
emits: { SignedIn: ({ user }) => ({ _tag: 'LoggedIn', user }) },
}),
},
on: {
LoggedIn: ({ user }) => ({ state: { _tag: 'Authenticated', user } }),
},
},
Authenticated: {
children: { todos: Actor.child(TodosLive) },
provides: (self) =>
Layer.succeed(Session, {
user: self.get.pipe(Effect.map(({ state }) => state.user)),
logOut: self.send({ _tag: 'LoggedOut' }),
}),
on: { LoggedOut: () => ({ state: { _tag: 'Anonymous' } }) },
},
},
});Actor.definetakes only the name, the Requires, the Schemas and what the Actor Emits, which fix every type, and imports nothing but other definitions. A struct is written as its fields, a tagged union as its cases by tag, asSchema.TaggedUniontakes them; a whole Schema works too. The Model is always a struct. The definition's ownbuildis checked against them: an Update that returns a State the Schema does not have, or astate.userread where there is none, does not compile. The definition isAuth, the buildAuthLive, side by side in one file.- A build has two Levels. What is written at the top holds for as long as the Instance lives; what is written under
states, in a State's block, holds while that State lasts. Each Level can holdprovides,children,on,workersandqueries. - An Update gives only the fields of the Model that change; the rest stay as they are, and a Model whose fields all stay the same is the same object. An Update that returns a different
_tagis a Transition: the old State's Children, Workers and Capabilities stop, the new State's start. A Child written at the top lasts through it. - A level's
childrennames each Child, made withActor.child:Actor.child(LoginLive, { … }), the Actor, then when it exists, what it is handed and what the parent hears.Actor.childis typed by the level it is written in, its State narrowed in a State's block, so every function in it is checked against the parent's data and the Child's Actor. A Child that needs no Input and no Props isActor.child(TodosLive). - What a Child tells its parent it Emits: its Update returns
emit, checked against itsemitsSchema, and the slot'semitsturns each into one of the parent's Messages, Sent to the parent right after. That Message is in the History as its own entry, Sent by the Child. An event the slot does not turn is not heard, so a Child fits any parent. Use Emits between a parent and its Child, and a Capability for what any Actor further down may need. - A Worker is a named, scoped Effect given
self:sendto its own Instance,getandchangesfor its current data, andnowfor the app's Time. Written in theCheckingblock,checkruns while that State lasts. - Provides is one Layer per Level, built each time its Level starts. A Capability that reads the Model reads it through
self, so it never goes stale. It cancels out what the Children below it need; a need nothing cancels bubbles up to the app's Layer. - Requires caps the Actor's own code: a Worker, Command or Provide that reaches for anything else does not compile. Update and init never see Capabilities.
- A Query is a question code outside the tree asks an Instance.
definedeclares it, with what it is asked with and what it answers,queries: { peers: { args: { room: Schema.String }, result: Schema.Array(Schema.String) } }, andbuildanswers it,queries: { peers: (self, { room }) => Effect }: it readsselfand the Actor's Requires, and never Sends. Its args and answer are checked against their Schemas, and a Query answered but not declared does not compile. Written in a State's block it is askable only in that State, and the live Instance has it on its type only once_taghas narrowed to it. Asked of an Instance that has stopped or left the State, it fails withStopped, from@kestack/kabuki/core/runtime. - Outside code tells an Instance with
send, which says what became of the Message:handled,ignoredwith no rule for it in that State,queuedbehind one being handled, orrefused. It waits on one withuntil,yield* session.until((s) => s._tag === 'Running'), which answers with the Instance narrowed as the test narrows it, from data the History keeps, or fails withStopped. A job that needs outside work and then a change asks a Query, then Sends.
A list of Actors, a Command, and a Worker per key
const save = Command.make('save', {
args: Schema.Struct({ id: Schema.String }),
run: () =>
Effect.sleep('1 second').pipe(Effect.as({ _tag: 'Saved' as const })),
});
const List = Actor.define('List', {
model: {
ids: Schema.Array(Schema.String),
uploading: Schema.Array(Schema.String),
next: Schema.Number,
},
message: {
Added: {},
Removed: { id: Schema.String },
Saved: {},
Uploaded: { id: Schema.String },
},
});
const ListLive = List.build({
init: () => ({ model: { ids: [], uploading: [], next: 0 } }),
children: {
rows: Actor.child(RowLive, {
each: ({ model }) => model.ids,
input: (_, id) => ({ text: `#${id}` }),
props: ({ model }, id) => ({ uploading: model.uploading.includes(id) }),
emits: { Remove: (_, id) => ({ _tag: 'Removed', id }) },
}),
},
on: {
Added: (_, { model }) => {
const id = `${model.next}`;
return {
model: {
ids: [...model.ids, id],
uploading: [...model.uploading, id],
next: model.next + 1,
},
command: save({ id }),
};
},
Removed: ({ id }, { model }) => ({
model: {
ids: model.ids.filter((kept) => kept !== id),
uploading: model.uploading.filter((kept) => kept !== id),
},
}),
Saved: () => ({}),
Uploaded: ({ id }, { model }) => ({
model: { uploading: model.uploading.filter((k) => k !== id) },
}),
},
workers: {
upload: {
each: ({ model }) => model.uploading,
run: (self, id) =>
Effect.sleep('3 seconds').pipe(
Effect.andThen(self.send({ _tag: 'Uploaded', id })),
),
},
},
});- A Child is made as a Worker is written:
Actor.childtakes its Actor, then always,whilea test is true, oreachkey a function names, one Instance per key. Itsinput,propsandemitsare worked out from the parent's data and the Child's key,''unless it is one ofeach, so the List knows which Row EmittedRemove. After every Update, a new key Invokes a Row, a key gone stops it, and a key kept keeps its Instance. From then on the Row owns its data. A Child written withwhilestops when its test turns false and comes back as a fresh Instance. - Props are what the parent says now, worked out again after each of its Updates: the Row declares them with
propsbesideinput, and its Update, its Workers' Conditions and its View read them. It never writes them, and a change runs none of its rules. Props equal to the last ones keep the Instance; new ones are not in the History, since Replay works them out again from the parent. - Instance IDs are deterministic:
List/rows[0]#1is the firstrowskeyed0. A key Invoked again gets#2, so a Message meant for the old Instance is dropped. - A Command is an errand, made once with
Command.make: a name, the Schema of its args and the Effect it runs from them, ending with at most one Message. The Update asks for it with its args,save({ id }), which is plain data a test can compare; the args are checked when it runs. Nothing stops it but its Instance stopping, and there is no cancel. An Update may return a list of Commands; they run concurrently. - A Worker runs for exactly as long as its Condition holds, read from the Model, State and Props whenever they change: where it is written, at the top or in a State, and within that always,
whilea test is true, oreachkey a function names, one Worker per key. Removing an id fromuploadinginterrupts that upload and runs its finalizers; a finished upload SendsUploaded, whose Update takes its key away. Stopping work is changing the Model. - The Worker does the work and the Model keeps the story: every step the user could notice is a Message. A Worker must be able to start from the Model alone, because a resume or a start from an earlier History entry starts every Worker whose condition holds. A Worker that waits until a Time in the Model sleeps for
that - now, so it waits only what is left. A finalizer never Sends. - Time (
at,self.now) is the app's stopwatch: it stands still while the app is stopped, so use it for how long things take inside the app, such as an animation or a countdown that pauses with it. For a moment in the real world, a session that expires or a reminder at nine, send a real date in the Message and have the Worker wait on the real clock, so it holds across a reload or a server starting over.
Drawing it with React
export const AuthView = View.make(AuthLive, ({ _tag, state, children }) => {
switch (_tag) {
case 'Checking':
return <Spinner />;
case 'Anonymous':
return <LoginView instance={children.login} />;
case 'Authenticated':
return (
<>
<p>Signed in as {state.user}</p>
<TodosView instance={children.todos} />
</>
);
}
});
const RowView = View.make(RowLive, ({ model, props, root }) => (
<li {...root} data-uploading={props.uploading}>
{model.text}
</li>
));
const MotionRow = motion.create(RowView);
const DemoApp = View.app(AuthView, { layer: ServerLive });- One function draws every State. It gets
_tag,model,state,props,children(Handles: one for a Child that is always there, one orundefinedfor awhileChild, a list for aneachChild, each with itskey),sendandroot; aswitchon_tagnarrowsstateandchildren. The DOM stays across a Transition. It never sees Capabilities, so it draws a Replay exactly like the live app. - A Child is placed with
<RowView instance={handle} />. A parent View hands it only where to draw; data from the parent is the Row's Props, which the parent Actor hands it, so its Update and Workers see the same. - Spreading
rooton the View's own element hands it the parent'srefandstyle, somotion.create(RowView)animates it, with no wrapper element, and it stays memoized. A View whose Instance has stopped keeps drawing its last data, soAnimatePresencecan play its exit. - A View re-renders only when its own Instance's Model, State, Props or set of Children changes, or its
reforstyledoes. Many Messages handled in a row make one render. DemoApp.useRuntime()returns{ history, branch, head, running, shown, show, start, stop, clear, workers, provided, failure, subscribe }from anywhere on the page. Eachbranchentry is only its Envelope:destination,message,at, andsource, the Instance that Sent it and what in it did;history.outcome(id)works out whether it was handled, ignored or dropped, andhistory.snapshotAt(id)takes the app apart there.show(id)draws the app right after that entry, on any Branch, while the live app runs on;stop()closes every Worker, Command and Capability;start(id)moves the Head to that entry and goes live there, growing a new Branch, andstart()carries on from the Head, at its Time.workers(id)says what an Instance's Workers are doing live: running, finished, or failed;provided(id)names the Capabilities it provides now, at its top and in its State. A start, stop or clear that fails, like the Runtime's first start orstart(id)with no such entry, is logged withconsole.errorand kept asfailure, an EffectCause, until one succeeds.- Work that must survive a stop belongs in a Worker: Commands running at a stop are lost. The outside world is not rewound.
- What changes over time arrives as Messages: a Worker Sends a
Tickor its progress, and the View draws the Model. motion can smooth what it draws from one Message to the next. View.appdoes not compile until itslayercovers every Capability the tree still needs; a tree that Requires nothing needs nolayer.- Give
attach: [(runtime) => stop]to run something beside the app, such as a devtool: each Attachment is called with the running app at the first mount in a browser, never on a server, and itsstopat the last unmount or a hot reload. It holds the same objectuseRuntimereturns, always read as it is now, andruntime.subscribetells it of each change. One that throws is logged withconsole.error, and the app and the other Attachments run on.Start.app({ …, attach })hands them on in the same way.
Drawing it before it runs
const DemoApp = View.app(AuthView, { layer: ServerLive, saved });- The app is drawn before any Runtime exists: with no History, right after init; with a saved one, at its Head, replayed through Suspense. A server or a static build gets the same HTML the client hydrates, and
useRuntimereads the saved History from the start, so a Timeline is drawn there too. Until the Runtime starts, a Send is dropped. savedis whathistory.save()gives: its entries and its Head, each Message, and each Instance of a Restore's Snapshot, written through its Actor's Schemas and read back through them on load. So the encoded side of every Schema must be JSON-safe, and then it is plain JSON: aDateasSchema.DateFromString, anOptionasSchema.OptionFromNullOr. A saved History never changes, and one whose Head is not one of its entries fails to load. The Runtime carries on live at the Head, with Time going on from the Head's.History.restored(AuthLive, snapshot, at).save()makes the lightest one, a History of one Restore right after init, for an app that saves only its Snapshot; init stays the first Step. Where the History is kept is the app's business:history.changesstreams it as it changes, so a server saves it withStream.debounceandStream.runForEachand a finalizer for the last write, and starts again fromHistory.load(actor, saved, { window: 0, onMismatch: 'fail' }).onMismatch: 'fail'throws at load when what was saved no longer fits the code, where the default warns, skips it, and may start from init. Givewindowto keep only that many entries on the Head's Branch: the oldest kept becomes a Restore of the Snapshot right after it, and Time Travel reaches nothing before it.history.keep(n)changes it at any time,history.keep(null)stops Compacting. A Runtime started from an Actor keeps none, only the app as it is now, and an app made withView.appkeeps the last 100. An app made withView.appremembers a Window changed that way inlocalStorageunder its name, and starts from it after a reload, over thewindowit was given.- When the last mount closes, the History is left under the app's name, the root Actor's by default, and the next mount of an app with that name carries on from it: the same component mounted again, or a new one after a hot reload, whose code replays the old Messages. A Message the new code handles differently warns and goes on. Give
namewhen two apps share a root Actor and must not share a History; two apps of one name mounted at once is a console error.
