RatchetRatchet

Changelog

Notable changes to Ratchet.

All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

Fixed

  • Agent chat: a failed turn no longer dumps the raw provider error (often a whole HTTP/JSON response body) into the chat bubble. The full error — stack and cause included — is now logged server-side ([automation] chat turn failed …); the chat shows a single clean line, with a plain-language explanation for the common cases (bad API key, rate limit / quota, provider outage, unreachable provider, oversized conversation, unknown model) and a generic "see the server logs" fallback otherwise.
  • ratchet serve/dev: with a routes/ site opted in, the SSR handler was unreachable — a separate publicDir router mounted at / matched every request first and the local App never falls through to a second / mount, so every non-static path 404'd. publicDir serving is now folded into createWebRouter.
  • Static file serving (publicDir, /_ratchet/*) threw EISDIR on any path that resolved to a directory (e.g. /) instead of falling through — now stat-checks for a regular file.
  • tailwindcss is a direct dependency again — the console/web CSS build shells out to the standalone Tailwind CLI, which resolves @import "tailwindcss" from the top-level node_modules; Bun's isolated linker doesn't hoist it there as a transitive dep of @tailwindcss/cli.

Added

  • createRatchetApp (@egig/ratchet/server) — one function that assembles the whole server (/api/auth, /api/automation, /api, /_site-assets, the console, and the src/web/ site) in the registration-order-sensitive sequence the router relies on, so that sequence lives in exactly one place instead of being copy-pasted into ratchet serve, the built dist/server.js, and every Cloudflare/Vercel entry file (where it had already drifted — missing /api/automation, /_site-assets, and storage). Takes already-constructed infrastructure (db, optional storage, optional console consoleAssets source) plus the new generated bundle — it does no config loading or dynamic import(), so it runs unchanged inside a Cloudflare Worker's fetch(request, env) handler. Features are always mounted; only infrastructure is configurable — the console mounts when you pass an asset source (Vercel omits it and serves those files off its CDN), the web app when you pass its runtime paths. ratchet generate now also emits .ratchet/app.ts, a barrel exporting bundle ({ models, domains, web? }) so an entry file has one import, not four. App + createApiRouter/createAuthRouter/… stay exported for hand-composition. /_site-assets now mounts just ahead of the console (was just after) so a consolePath: '/' can't shadow public asset URLs. Deploy guide rewritten around it.
  • The web app (src/web/, @egig/ratchet/web) — a code-driven, server-rendered React Router data-mode site, bundled with Bun.build (no Vite). Write routes/**/*.tsx in a folder convention (routes/root.tsx renders the whole <html>; about.tsx/about; blog/$slug.tsx/blog/:slug; blog/_layout.tsx is a layout; a leading-underscore folder is a pathless layout group; $.tsx is a splat; sitemap[.]xml.tsx escapes a literal dot; a module with a loader/action and no default is a resource route serving a raw Response). ratchet generate scans them and emits .ratchet/app-routes.{server,client}.ts; ratchet build/dev bundle the client. Single fetch: loader/action always run on the server with an injected context ({ db, session, settings, registry, storage } + requirePermission(), off the same ratchet_session cookie); client navigations fetch one turbo-stream <path>.data response. Streaming SSR via renderToReadableStream, hydrating the whole document. Config: routesDir (default routes), publicDir (default public, served at / — a matching static file wins over SSR, handled inside createWebRouter itself since the local App doesn't fall through a second / mount). ratchet build's bundled dist/server.js mounts /_ratchet and the web SSR/.data router (with publicDir folded in) too, via the new @egig/ratchet/web/router export, matching ratchet serve — no-op when the site isn't opted into. See the "Web app" guide and ADR 0003.
  • Console UI primitives (src/console/client/ui/, experimental) — the first slice of a design-system pass on the console. Button/Input/Label/Dialog are now built on unstyled Radix Primitives (@radix-ui/react-dialog, @radix-ui/react-label) restyled onto a token layer (styles.css's @theme inline block — bg-background/text-foreground/border-border/bg-accent/…) with class-variance-authority for variants, in the console's own compact, dense visual language (32px controls, tight spacing). Re-exported from @egig/ratchet/console/client so a consumer's *.form.tsx/*.input.tsx can match the console's own look — see that barrel's export comment for the experimental-API caveat. The route-driven Dialog (ModelFormDialog) is rebuilt on the new ui/dialog.tsx — same external behavior (Escape/backdrop-click calls onClose), plus a real focus trap and scroll lock. LoginPage/SetupPage migrated to the new primitives as a worked example. Dark mode: a per-browser light/dark toggle (theme.ts, top-right of the console header) persisted to localStorage, defaulting to the OS preference on first visit; a small inline script in the console shell (router.ts) applies it before first paint so there's no flash of the wrong theme. Only the new primitives, both console shells (Layout.tsx's sidebar, and WorkspacePage's own separate header — see that file's comment for why it's a second one), and the auth pages are dark-mode-aware so far — the rest of the console (model list/form pages, the chat panel, RowTable, the comboboxes) still uses plain Tailwind grays and won't re-theme yet; migrating those, plus a Select/DropdownMenu primitive and a cmdk-based rebuild of ReferenceCombobox/TreeCombobox/ManyToManyMultiSelect, is planned as incremental follow-up work.

Changed

  • Breaking: agent runtime rebuilt on the LangChain stack. The hand-rolled provider adapters (@anthropic-ai/sdk / openai used directly) and the hand-rolled tool-use loop are gone. run-turn.ts now wraps LangChain v1's createAgent (the ReAct agent, built on LangGraph); the model is built by a new src/automation/model-factory.ts from the Provider row (ChatAnthropic for kind: 'anthropic' — adaptive thinking + output_config.effort + advancing ephemeral prompt-cache all still forwarded; ChatOpenAI on the chat-completions API for kind: 'openai', configuration.baseURL from Provider.url, still covering every OpenAI-compatible host). The RBAC tool layer (tool.ts, resolveAgentTools/executeAgentTool) is unchanged — each tool call still runs through the target model's pipeline re-authenticated as the chatting user. Message.content (assistant-ui parts) and the assistant-stream wire protocol are unchanged — no database migration. New required deps: langchain, @langchain/core, @langchain/langgraph, @langchain/anthropic, @langchain/openai; @anthropic-ai/sdk and openai dropped as direct deps. @egig/ratchet/automation no longer exports ChatProvider / ChatRequest / ChatMessage / ToolSpec / resolveProvider / parseToolInput (internal plumbing, zero external consumers); it now exports createChatModel. Setting LANGSMITH_TRACING=true + LANGSMITH_API_KEY gives full per-turn tracing (inert otherwise). The /api/automation routes now require a Node-compatible runtime — see the Deploy guide.
  • Breaking: there is no built-in website domain — Ratchet neither renders pages nor ships a Page/Contact model or WebsiteDomain settings. Rendering is src/web/ (below); the content is scaffolded source. ratchet init now writes models/website/ — a Page model (slug, title, metaDescription, a body text field sanitized on every write with sanitize-html, status, navLocation/navOrder), a Contact model (name/email/message/status, no public write endpoint — the scaffolded routes/contact.tsx action inserts rows through its loader context.db with its own honeypot + validation), and a settings.domain.ts declaring the website Domain Settings (title, description, siteUrl, noindex, favicon, ogImage) — all editable source in the consumer's project. No first-run seeding: create pages in the console.
  • Removed: field.custom('richtext', …) and the console's Quill rich-text editor. Page.body is a plain field.text() (a <textarea> in the console); quill and sanitize-html are no longer @egig/ratchet dependencies.
  • ratchet init now scaffolds a working public site: routes/root.tsx (nav from the scaffolded pages table + website settings), a hand-authored routes/index.tsx landing page, routes/$.tsx (renders a published Page by slug, 404s otherwise), routes/contact.tsx (a working contact form — server action, honeypot, inserts a contacts row), public/theme.css (a production-ready light/dark theme), and the models/website/ set above. Adds react/react-dom/react-router/sanitize-html to the scaffolded dependencies.
  • Breaking: Hono removed — every router (createApiRouter, createAuthRouter, createAutomationRouter, createConsoleRouter, createSiteAssetsRouter) now returns a small local App (ratchet/router's new App/Ctx, router/http-app.ts) built directly on the Fetch API's Request/Response — the same "no framework, just Bun.serve+the platform" style as a hand-rolled Data Mode React Router server, rather than a general-purpose routing framework. App supports the same app.get/post/patch/delete, app.route(prefix, subApp), app.onError, app.fetch, and (for tests) app.request(path, init) this package's own routers and test suite already relied on, so ratchet serve/ratchet dev/ratchet build's generated bundle all keep working unchanged from a consumer's point of view — but any code that imported Hono's own Context/Hono types, or reached into hono/cookie/hono/utils/mime directly, needs to switch to ratchet/router's Ctx/setCookie/deleteCookie equivalents. hono/@hono/node-server are no longer dependencies of @egig/ratchet or of a ratchet init-scaffolded project; ratchet build's generated dist/server.js now boots via a new serveNode (ratchet/router), a minimal Node http-to-Fetch bridge that preserves the existing "runs on plain Node, no Bun required" VPS/container deploy story.
  • Docs site migrated from VitePress to Fumadocs on React Router: docs/ is now a React Router (framework mode, SPA/prerendered, matching the console's own router) app with its own package.json, installed as part of the repo's Bun workspace. Content lives under docs/content/docs/*.mdx; the same guides, at the same /docs/... URL shape (/guide/* -> /docs/*).

Added

  • Agent read tools: an Agent whose role holds a read grant now gets two builtin tools per granted resource — list_<model> (JSON-shaped filters/sort/limit/offset/include params, returns the same { data, meta } envelope as GET /api/:model) and findOne_<model> (one row by id). They run through the exact same listRows/getOneRow path the REST GET routes use, so field-level read grants, ?include= relation filtering, and api.ownerField scoping all apply identically — a chatting agent can't read anything the driving user couldn't. action: '*' now expands to these plus the existing write tools.
  • Multi-column sort: ?sort= now takes a comma-separated, priority-ordered list of keys — ?sort=status,-createdAt sorts by status ascending, then createdAt descending. id/createdAt/updatedAt/createdById are always sortable (previously only indexed: true fields were).
  • Console: sortable column headers plus a "Sort" panel next to "Filter" — click a header to sort by it (cycles asc → desc → off), shift-click to add it as a secondary key, or compose the full ordered list in the panel. Works on model list pages (as a shareable ?sort= URL overlay) and on workspace tabs (persisted to the saved view).
  • Granular, per-field permission: Permission rows can now name a field (resource/action/field, any of which may be '*') to grant a role read/write access to individual fields of a model, not just whole resource:action pairs.
  • ilike filter operator — the case-insensitive form of like for string/text fields (?filter=[["name","ilike","%ada%"]]).
  • Workspace.chatEnabled (defaults true): a persistent per-workspace setting that removes the console's agent chat panel and its show/hide toggle entirely when off, distinct from the per-browser hide toggle.
  • Console: reference fields now render as a searchable combobox instead of a plain <select> — typing filters server-side (ilike on the target model's displayField) when that field is an indexed string, and falls back to client-side filtering of the first 100 rows otherwise.
  • Console: workspace tabs can be renamed inline (double-click the tab label); the sidebar account menu has a "Workspace" link back to the signed-in user's workspace.
  • Custom operations: a model can now declare named operations beyond create/update/remove (e.g. a lock/unlock button that's really an update with a fixed field value) as extra keys in operations, dispatched by a new generic POST /:model/:id/:operation route. presetFields() (ratchet/auth) is the sugar helper for the common "write these fixed fields" case; a custom operation can also declare params (validated request input, same field.*() DSL as model fields) and a console block (label, confirm, placement, a data-driven visibleWhen) controlling how it renders as a button — with a param-taking operation auto-rendering a small modal form — in the generated console. See the "Custom Operations" guide.
  • Console: custom model forms: a <name>.form.tsx under modelsDir (<name> being a model's own name, e.g. customers.form.tsx) replaces that model's generated create/edit form entirely — ratchet generate collects them into a registry the console client bundle imports, rejecting an unmatched or duplicate name. It receives fields, the model's own fields each pre-bound to their built-in editor (fields[name].render({ value, onChange, error? }) renders a reference dropdown/file upload/manyToMany multiselect/etc. without switching on field kind by hand; fields[name].meta is that field's metadata), plus getRow/createRow/updateRow/useModels/useAuth/FieldInput exported from @egig/ratchet/console/client for everything else — so a custom form doesn't have to reinvent the generated one's building blocks. A custom form's own Tailwind classes are scanned into the console bundle's stylesheet the same way the framework's own components are. See the "Console" guide's "Custom forms" section.
  • Console: custom field inputs: a <model>.<field>.input.tsx under modelsDir (e.g. customers.email.input.tsx) replaces just that field's input — everywhere it would normally render (the generated form, and fields[name].render(...) in a custom form, above) — with no change to the model definition needed, unlike the existing model-declared field.custom(name, base) (which this now takes priority over). ratchet generate rejects an unmatched model/field, a malformed filename, or two inputs for the same model+field. See the "Console" guide's "Custom field inputs" section.
  • field.tree(): a self-referencing parent-pointer hierarchy on a model — for a Category tree, a Chart-of-Accounts Account, an org chart's managerId, or any other tree-shaped data. A model may declare at most one; defineModel() resolves its target to the model's own name automatically. ?include=parent embeds the parent row, ?filter=/?sort= work like reference's, and every write is checked for cycles (reparenting a node under itself or one of its own descendants is rejected with TREE_CYCLE) before it commits. The console renders it as a searchable tree picker (parent / child breadcrumb labels) that excludes the record being edited and its descendants from the option list. See the "Models & Fields" guide's "Tree / hierarchy fields" section.
  • Root admin onboarding (POST /api/auth/setup) now also provisions the framework's first built-in Agent, named Ratchet, wired to the new Root role so it can call every tool from turn one. Since Agent.providerId is required, setup additionally collects a providerApiKey (plus optional providerKind/providerUrl) and creates that Provider in the same transaction — a fresh instance ends setup with a chat-ready assistant instead of an empty Agents list. The console's /setup form has matching provider fields.
  • file field storage is now driven by flystorage, with well-known cloud backends configurable declaratively via a new storage key in ratchet.config.ts{ driver: 's3' | 'gcs' | 'azure', ... } (S3's endpoint/forcePathStyle also cover S3-compatible services: R2, MinIO, DigitalOcean Spaces, Backblaze B2). ratchet serve builds the configured backend automatically via the new buildStorageAdapter (ratchet/storage), the same helper any other Node/Bun entry file can call. Omitting storage keeps today's zero-config local-fs default. Each cloud driver's adapter + SDK is a peer dependency behind its own subpath (ratchet/storage/s3, /gcs, /azure) so picking one doesn't require installing the others.
  • Reading a file field's bytes back (GET /api/:model/:id/:field) now streams directly from the storage backend instead of buffering the whole file into memory first.

Changed

  • ratchet generate now also runs drizzle-kit generate, emitting SQL migration files from the fresh schema diff. ratchet migrate is correspondingly narrowed to only drizzle-kit migrate — it no longer regenerates the schema or diffs it, just applies the pending migration files. The full workflow is ratchet generate (review the SQL) → ratchet migrate (apply it).
  • Breaking: the framework's tooling and runtime moved from Node/npm to Bunratchet (including ratchet serve) now runs under Bun, package installs use bun install, and the framework's own build/test scripts use Bun.build/bun test instead of esbuild/tsx/vitest. Consumer apps need Bun 1.3+ installed; ratchet init scaffolds a Bun-based package.json accordingly. ratchet build's generated dist/server.js still targets plain Node, so a VPS/container deploy doesn't need Bun.
  • Breaking: the generic /api/:model router now requires a matching Permission row for every route by default, including reads (a new implicit 'read' action) — previously only create/update/remove were gated, and only when a model author manually composed requireAuth/requirePermission into its pipeline. A model that must stay reachable without a session opts out via the new api: { public: true }.
  • Breaking: field-level access is secure-by-default — a role with a (resource, action) grant but no matching field grant gets zero fields, not every field. Existing Permission rows need a field: '*' added (or per-field rows) to keep working after upgrading; the bootstrap Root role created by POST /api/auth/setup already does this automatically.
  • requireAuth/requirePermission no longer need to be composed by hand into a model's own operations — the router applies both automatically. They're still exported for custom/dedicated routers that bypass the generic router entirely (e.g. an agent tool call, automation/tool.ts).
  • Breaking: Workspace freezes/unfreezes a row via lock/unlock custom operations (built on presetFields(), above) instead of a plain PATCH { locked: … } — a role needs its own lock/unlock grant in addition to the update+locked field grant it already needed. The console's "Lock workspace"/"Unlock workspace" button calls the new operations.
  • Breaking: a bare ?sort= on GET /api/:model is now offset-mode (response meta is { total, limit, offset }, just ordered) instead of switching to cursor-mode. Cursor-mode pagination now requires an explicit ?cursor= (pass it empty for the first page) alongside a single-key ?sort=.
  • Breaking: workspace_views.sortField + sortDirection are replaced by a single sort JSONB column holding an ordered [{ field, direction }] list. Consumer apps must re-run ratchet migrate; the update_workspace_views agent tool now takes sort instead of the two scalar fields.
  • Breaking: POST /api/auth/setup now requires a providerApiKey field (see the built-in Ratchet agent, above) — any script or test fixture calling it directly needs to add one.
  • Breaking: createApiRouter's storage parameter, and every FileStorageAdapter implementation, is replaced by flystorage's own FileStorage (see "file field storage", above) — no compatibility shim. ratchet/storage/node's factory is renamed createLocalStorage (was createNodeFsStorageAdapter) and now returns a FileStorage; a hand-written FileStorageAdapter (e.g. for a binding flystorage has no adapter for, like Cloudflare R2's) needs to become a flystorage StorageAdapter instead — see example/deploy/cloudflare/worker.ts's R2StorageAdapter for the new shape.

Fixed

  • ratchet dev/ratchet build no longer shell out to npx tailwindcss for the console stylesheet — it invoked the Tailwind CLI as if it were a consumer dependency and failed with could not determine executable to run in any app that didn't also install @tailwindcss/cli. The framework now resolves and runs its own bundled @tailwindcss/cli.
  • ratchet dev no longer prints [dev] server exited with code 130 on Ctrl-C — the interrupt reaches the spawned server directly through the terminal, and that (plus signal kills) is now recognized as a deliberate shutdown rather than a crash. Shutdown also no longer hangs when the server has already exited.

v0.1.0 - 2026-08-24

Initial release.

Added

  • Model definitions (defineModel(), field()) that generate a Drizzle schema, Zod validators, and a model registry.
  • Generic REST API — GET/POST/PATCH/DELETE at /api/:model with filtering, sorting, cursor and offset pagination, and ?include= relations.
  • Composable pipelines: create/update/remove as pipe(...) chains around validate and persist.
  • Session-based auth router (register/login/logout/me) with role/permission checks.
  • Generated console SPA, mountable via createConsoleRouter.
  • ratchet CLI (build, generate, migrate, serve).
  • Runtime-agnostic routers (createApiRouter, createAuthRouter, createConsoleRouter) usable outside Node, alongside Node-only ratchet build/serve tooling.
  • VitePress documentation site, deployed to GitHub Pages.

On this page