yumemi
Have a sweet Gleam. 設計を書けば、コードが生まれる。
A framework for Gleam on Cloudflare Workers. You describe an application in five words — Entity, Property, Type, Service, Authorization — and yumemi derives the Gleam implementation and every entrance to it: HTTP, MCP, CLI.
Open source, coming soon. https://gleam.canon-ical.com/
Status
Extracted from the framework/ directory of a production application on 2026-09-16, history included. Module namespace is still framework/*; the package name is yumemi. Published on Hex as yumemi (0.2.0 = the 2026-09-16 extraction; the generator and gen-3/gen-4 changes ship from 0.3.0).
Layout
| Module | What |
|---|---|
framework/spec framework/er | Entity / Property / Type ── the model and its derivation |
framework/entry framework/verb framework/party framework/require | Entrances and Authorization |
framework/step framework/effect framework/query framework/io | Service logic: read / guard / apply / call / done |
framework/connector framework/page framework/blob framework/vector | Connectors, SSR pages, R2, Vectorize |
framework/secret framework/sealed framework/time | Secrets, sealed values, time |
gleam build
framework/front ── Style (0.11.9)
framework/front/css’s Style is the typed vocabulary a Page / Area / component carries; framework/front/sketch_css maps it to CSS (sketch classes, so a Style used by an island also lands in the island’s shadow <style>). Color values are strings passed straight through, so var(--ma-color-bg) and color-mix(...) work — the app owns its palette, yumemi bakes in no hex. Every word also works inside State(Hover / Focus / Disabled / Current, ..) and Responsive(SP / PC / Tablet, ..).
| Style | CSS |
|---|---|
Color(value) | color |
Background(value) | background-color |
Border(edge: AllEdges / BottomEdge, width, style: Solid / Dashed / Dotted, color) | border / border-bottom (e.g. the 2px tab underline) |
Outline(width, offset, color) | outline-style: solid / outline-width / outline-offset / outline-color — the focus ring, inside State(Focus, ..) |
Space(property:, value:) | margin / padding / gap / width / height / border-radius, and (0.11.7) min-width / min-height / max-width |
Text(family:, size:, weight:, line_height:) | font-family (System / SansSerif / Serif / Monospace, or Named("var(--ma-font-ui)") / Named("\"Noto Sans JP\", sans-serif)")), font-size, font-weight (Normal 400 / Medium 500 / SemiBold 600 / Bold 700), line-height |
Crop(fit: Cover / Contain / Fill / ScaleDown / FitNone, ratio: Ratio(w, h)) | object-fit and aspect-ratio |
Sizing(box: BorderBox / ContentBox) | (0.11.8) box-sizing — BorderBox counts padding and border inside width / min-height, so an input or an <a> button with min-height: 48px and padding stays 48px |
Marker(marker: NoMarker) | (0.11.8) list-style: none — drops the ul / li bullet (clear the indent with Space(Padding, Px(0.0))) |
Decoration(line: NoDecoration / Underline) | (0.11.8) text-decoration: none / underline — e.g. a row link without the underline, underlined again inside State(Hover / Focus, ..) |
State(Current, styles) | (0.11.9) [aria-current]:not([aria-current="false"]) — the item that is where the user is (aria-current="page", also step / location / true). Mark the nav item with aria-current (a Layout block learns the page from CurrentRoute, below) and give it e.g. the 2px underline with Border(BottomEdge, ..) |
Flow(..) State(..) Responsive(..) Animation(..) | layout, interaction states, breakpoints, animations (unchanged) |
Lengths (css.Length) are Px(n) / Rem(n) and, from 0.11.9:
| Length | CSS |
|---|---|
Var(name) | var(--name) — e.g. Var("ma-space-2"). The name passes only [a-z0-9-]; any other name (;, }, ), a space, upper case, empty) is written as unset by sketch_css, and the generator stops on it in an Area’s gap |
Env(SafeTop / SafeRight / SafeBottom / SafeLeft) | env(safe-area-inset-top, 0px) … (0px where the device has no safe area) |
Dvh(n) | n dvh — e.g. Space(MinHeight, Dvh(100.0)) for a short page that still fills the screen |
They work everywhere a Length does: Space, Text, Border, Outline, Flow gaps, and an Area’s flow gap in the generated grid CSS (literal or a style constant).
framework/front ── Layout vars and Overlay (0.11.9)
Var(name, CurrentRoute)— the route spelling of the Page being drawn, as the generated route table writes it (/rosters/:id,/). It is a constant the generator knows per Page; the request’s argument values and query never enter it. It can sit in a Layout (wherePath/Query/Sessioncannot) or in a Page, and arrives as aStringblock arg of the same name — a header block compares it with its links’ routes to setaria-current="page".pin: AnchoredOverlay(side: Below / Above, align: AlignStart / AlignEnd)— an Overlay Area that opens next to theel.openerbutton that opened it, below or above it, lining up its start or end edge with the button’s. It is an Overlay in every other respect (el.opener/el.closer, popover, not in the grid). The generated CSS uses CSS anchor positioning inside@supports (anchor-name: ..)(position-area,position-try-fallbacks: flip-block) and leaves the backdrop clear. A browser without anchor positioning opens it likeOverlay: centred by the UA, with the darkened backdrop. With several openers for the same Area the anchor is the last one in document order.Overlayand[popover]::backdropare unchanged.
framework/front ── Area flow (0.11.8)
An Area’s flow in a Layout or a Page’s Frame now reaches the generated grid CSS (the <style> that htmlWithGridCss puts in SSR pages and _yumemi/style.css, which carry the same text): the Area’s rule gets the flow’s declarations after grid-area, so the blocks placed in the Area are laid out by it. Before 0.11.8 the Area stayed display: block and the flow was ignored.
flow | declarations in the Area’s rule |
|---|---|
Stack(gap:) | display: flex; flex-direction: column; align-items: stretch; gap — blocks keep the Area’s full width |
Row(gap:, wrap:) | display: flex; flex-direction: row; flex-wrap: wrap / nowrap; gap |
Grid(cols:, gap:) | display: grid; grid-template-columns: repeat(cols, minmax(0, 1fr)); gap |
GridTracks(cols:, gap:) | unchanged (already written since 0.11.x: display: grid, the tracks, gap) |
Scroller | display: flex; flex-direction: row; overflow-x: auto |
gapis a literal (css.Px(..)/css.Rem(..)) or a constant of the app’sstylemodule (style.s2). A gap the generator cannot read leaves the Area’s rule without flow lines (as before).- Every variant writes its direction, so a
pc/tabletFrame that gives the Area another flow overrides thespone inside its media rule. An Area shown only at a breakpoint (hidden elsewhere) keepsdisplay: noneoutside it; inside the media rule its flow’sdisplayshows it, and nodisplay: blockis added. - An Area pinned
Overlaygets no flow lines (its display stays with the overlay).pinandstyleoutput is unchanged. framework/front.area_flow_css(flow)returns the declarations for onecss.Flow.
framework/server ── what the app must provide (0.11.1)
The back-end runtime (framework/server/*.mjs) is JavaScript that the generated src/gen/*.mjs imports. It knows no application names: route names, cookie names, key bindings and the party a queue consumer reads its borrowed root as come from the app’s src/server.gleam (attached_roles, browser, hooks, roots’ QueueParty). The generated face gate (<face>/src/gen/gate.mjs, declared in <face>/src/gate.gleam with framework/gate) reads the session through the ReadSession attached route. Gleam packages cannot declare npm dependencies, so the app supplies the following itself.
Generated live modules (0.11.2) send Args by their declared type: Bool as a JSON boolean (the live field’s "true" / "false"), Int / Float as numbers. On a GET route the Args that are not path holes go on the query string (an empty Option is left out), and the runtime reads bool (true / false) and float spellings from the query of GET / HEAD requests. POST / PUT / DELETE bodies are read as JSON as before.
A live field for a List(X) Arg (X a scalar, value type, id or enum) holds a JSON array of strings (["a","b"], an empty field is []); each item is sent by X’s rule. A field for a record, tuple, Dict or a List of those holds the JSON body itself (for example {"background":"#112233"}), which is sent as is and read by the back-end decoder. A field that does not parse is sent as a string, and the back end answers invalid_argument. Sum types with several constructors are sent as strings.
A Service whose logic runs step.commit and continues after it (and is not a queue consumer that only uses the commit as a boundary) ends in Accepted over HTTP: the runtime answers 202 with a one-field body ({"<root>": id}, a respond hook may rename the field). The generated live for such a Service (0.11.3) holds Reply instead of the Service’s Out: Accepted(id) for the 202 body and Replied(out) for a 200 that ends before the commit. Both arrive as Done(Ok(_)) and go on to after_send (for example ReloadPage). Lives of other Services are unchanged.
0.11.4 adds, without changing the existing public types:
- Reads time out and retry once.
driver.mjscuts a read-only statement (oneSELECT/WITH/VALUES/TABLEwith no write keyword —INTOand row locks count as writes — and no call to a function outside the built-in allow list, read after one pass that strips strings, quoted identifiers and comments; seeframework/server/read_retry.mjs) afterDATABASE_READ_TIMEOUT_MS(default 10000) and retries it once when the failure is outside the database (timeout, fetch failure, HTTP 5xx; not an SQLSTATE). Writes and transactions are never retried and get no timeout. Every failure outside the database, read or write, is logged as one JSON line ({"yumemi":"driver.failed",kind,key,attempt,ms,code,status,name,body},kindisread/write/transaction) with the response body. - Attached routes with a framework role that reads a body send it: the live for a
SwitchSubjectroute hasArgs(kind, id)and sends{kind, id}. Other attached lives are unchanged (no Args,nullbody). - The
BlobCopylive copies a URL. Besides the file upload (Send),copy_from(state, url)sends{from: url}to the same route and reads{key}intoDone(Ok(key)). - Split records decode. A record Property stored with
Splitis rebuilt from its columns insrc/gen/codec.mjs. - Islands render sketch classes. The generated client registers each island through
framework/front/island_style.mjs’sstyled(app): the island gets its own sketch stylesheet while its view runs, and the CSS goes into a<style>inside the island (only when the island uses classes and does not render its own stylesheet). - Client navigation between Pages. The generated client calls
framework/front/navigate.mjs’sstart({routes, boot, pageview}). A plain left click on a same-origin link, from a Page to a Page that are both in the face’s route table (minus the gate’sframe_srcPages), fetches the next page with one request (the gate, the adult declaration and the session go through the server as before), swaps<head>and<body>, re-runs the island registrations and pushes history; back / forward do the same. A redirect, a non-200, acontent-security-policyheader, a page with scripts other than the client, an already-registered given island, a modified click,targetordownloadfall back to a page load. - Pageviews count once per navigation. The navigation fetch carries
x-yumemi-navigate: 1; for it the gate puts<meta name="yumemi-pageview">in<head>instead of the pageview script (same conditions: adult session, 200 HTML, apageviewPage) and addsvary: x-yumemi-navigate. The client posts the pageview (kindspa) only after it swapped that page in; a fallback to a page load gets the script as before. - Reload after a write stays in the page. An island’s
yumemi-donecallsnavigate.reload(): on a routed Page it refetches the current URL the same way (no history entry, scroll kept, pageviewkindreload); otherwise it reloads the page.
0.11.5 adds, without changing or removing the existing public types:
- Gate: send back with a return path.
Fail.RedirectBack(location, param)answers 302 to the face pathlocationand puts the request’s path + search, URL-encoded, in the queryparam(joined with&whenlocationalready has a query). When the request’s path + search is not a face path (theSafeParamcheck: it starts with//, or has a backslash or a control character) the param is left out, so the gate never sends anyone to another origin.locationmust start with/, not//, and carry no backslash, control character or#;paramis[A-Za-z0-9_.-]+— the generator stops otherwise (exit 4). A client navigation fetch gets the same 302; the client falls back to a page load as for any redirect. - Gate: a fixed redirect that keeps the query.
To.FixedKeep(location)answers 302 tolocationplus the request’s query.Fixed(location)is unchanged (drops the query). The hash never reaches the server; on a page load the browser carries the original hash across a redirect without one, and a client navigation falls back to that page load.locationfollows the same face-path rule asRedirectBackand has no?either. - A gate that uses neither word gets the same
gate.mjsas 0.11.4, character for character. - A missing root statement stops generation. A Service with a root Entity reads its root with
db/queries/<service>/root.sql. Without it the runtime answers 503, so the generator now stops with exit 3 naming the Service. Services that build their root another way are not stopped: a Service answered by aservice_<name>hook (a Durable Object port), a queue consumer that borrows the root of a Service that has a root statement and calls it through the queue,Rootless, and roots with no Entity (Carriedonly). - Faces narrower than the entries are enforced at runtime. A Service that declares two or more faces gets
entries: [..]insrc/gen/registry.mjswhen an entry outside its faces could still route it (same credential kind; aReadOnlyentry only for a Read Service), and check 7 answers 403 on any other entry. A Service with one face keepsentry: '<name>'as before (the same face repeated counts as one); a Service whose faces hold every entry that could route it (all four session faces in our in-house service) gets neither.
0.11.6 changes when the outbox is swept, without changing the public types:
- A request that wrote to the outbox sweeps it, whatever its status. The fetch handler wraps the request’s database handle and notes every statement or transaction that inserts into
framework.outbox(INSERT INTO framework.outbox, also inside aWITH, in generated and hand-written SQL alike; comments and string literals do not count, quoted identifiers are not recognised) and resolves, that is, commits. After such a request — 200, 4xx, 5xx or a thrown error —waitUntilruns the sweep, as a 202 always did (202 still sweeps). A request that only reads, or whose outbox insert failed or rolled back, does not sweep. The cron sweep is unchanged. - Each row is sent once by overlapping sweeps. The sweep claims a row with
framework/outbox_claim(a conditionalUPDATE … SET sent_at=now() … RETURNING id) before it sends it, and skips a row it could not claim. A second sweep that reaches the same row waits for the first one’s row lock, re-reads the condition and gets no row.framework/outbox_sentis no longer called. A row whose send fails after the claim waits out the resend window (1 hour) like a lost message and is sent by the first sweep after it (a request that sweeps, a 202 or the cron); 0.11.5 resent it on the next sweep. Consumers stay idempotent. - The generator adds a default
framework/outbox_claimstatement tosrc/gen/sql.mjswhen the app has nodb/queries/framework/outbox_claim.sql; the app’s own file wins. Its resend window (1 hour) must match the app’sframework/outbox_sweep.
0.11.7 adds, without changing the existing variants’ meaning or output:
- Style grows the look vocabulary (see the Style section above):
Background,Border(all edges or bottom only),Outline(the focus ring, alwaysoutline-style: solidwithoutline-offset),min-width/min-height/max-widthinSpace,Named(..)font families (a CSS variable or a family name),SemiBold(600), andCrop(object-fit+aspect-ratio). Color values stay strings (var(--ma-*),color-mix(..)pass through). Generation from an unchanged app is byte-identical.
0.11.8 adds, without changing the existing variants’ meaning or output:
- Style:
Sizing(box-sizing),Marker(NoMarker)(list-style: none),Decoration(text-decoration: none/underline), all usable insideState(..)/Responsive(..). - Area flow reaches the grid CSS (see the Area flow section above). Regenerating an app changes only the Area rules of the grid CSS.
0.11.9 adds, without changing the existing variants’ meaning or output:
- Layout knows the page:
front.FromgainsCurrentRoute(see Layout vars above). The shell carries it as a constant per page; the shell’s branch for it is written only for a face that uses it. - Style:
State(Current, ..)(aria-current), and the lengthsVar/Env/Dvh, read by the generator in Area gaps andstyleconstants too. - Overlay:
pin: AnchoredOverlay(..)opens next to its opener (CSS anchor positioning; centred as before without it). - Regenerating an app that uses none of these is byte-identical.
Imports outside the package
| Import | Imported by | Provided by |
|---|---|---|
@neondatabase/serverless | framework/server/driver.mjs (Neon HTTP transport, database(env, observe)) | the app’s package.json |
cloudflare:workers | framework/server/worker.mjs (DurableObject / WorkerEntrypoint) | the Workers runtime (wrangler / workerd); not an npm package, so modules that import worker.mjs (the generated shell.mjs) do not load under plain node |
SQL — the runtime runs these keys through the app’s SQL bundle (src/gen/sql.mjs, built from db/queries/**). The app writes them as db/queries/framework/<name>.sql against its own framework schema (DDL is the app’s). Holes are positional, in the order below.
| Key | Holes | Used for |
|---|---|---|
framework/session_resolve_staff | session id, at, first try | resolve a session cookie (a row with retry asks for a second pass) |
framework/api_key_resolve | key digest, at | resolve an API key entrance |
framework/session_issue | party, session id, credential version, expires at | issue a session (auth binding) |
framework/credential_floor | party, floor | raise the credential version floor |
framework/session_revoke_party | party, version | revoke a party’s sessions below a version |
framework/session_revoke | session id | revoke one session (auth binding) |
framework/session_onboard | session id, party, subject id | bind a created subject to the session |
framework/session_subject_staff | session id, party, kind, subject id | the SwitchSubject attached route |
framework/browser | browser id, at | the DeclareBrowser attached route |
framework/audit | seed, stage, service, party, outcome, at | one row per entrance stage |
framework/outbox_parent | id, dedupe key, payload (json), event id, at, folded | outbox parent row of a write |
framework/outbox_child | id, kind, payload (json), parent id, at | outbox child row (one queue message) |
framework/outbox_done | event id | mark a consumed event done |
framework/outbox_get | id | load a queued message |
framework/outbox_sent | id | mark a swept message sent (not called since 0.11.6) |
framework/outbox_sweep | kinds | list unsent messages to resend |
framework/outbox_claim | id | claim one swept row before sending it (0.11.6; generated by default, see above) |
Worker env — DATABASE_URL, COOKIE_DOMAIN, OUTBOX (queue binding), <ENTRY>_HOST per entrance, the key_binding of browser (a Secret Store binding), the KEK bindings of Sealed properties, and optionally ISOLATE_MARKER=1 (test header).