Describe your data model once. Get a complete RBAC-backed application.
Repo at github.com/macnod/data-ui.
If you aim to develop solid, dependable, performant, maintainable, database-backed, ready-to-deploy applications that include full support for Role-Based Access Control (RBAC), and you want a deterministic development process (no countless iterations with an AI only to have to fix the difficult problems yourself in the end), then Data-UI is your friend.
Data-UI is a Common Lisp system that takes a simple nested plist model and compiles it into a full production-ready data application:
No manual migrations. No per-type boilerplate. Change the model, call (set-model *model*), and everything updates deterministically. Data-UI aims to let you write the model, compile it into an application, and deploy the application in a half hour.
You describe your entities, relations, and UI behavior in one place. Then, Data-UI:
*base-model*):id, :created-at, :updated-at)*compiled-model* for fast runtime useGeneric endpoints like /api/list?type=todos and /api/schema/todos/add-form work for any type — including the built-in RBAC tables themselves.
This example is from what’s assigned to the *model* variable in lisp/workbench.lisp.
(defparameter *model*
`(:todos
(:table t
:create :auto :update :auto :delete :auto
:views (:main (:tables (:todos :todo-tags :tags))
:tags (:tables (:tags)))
:fields (:name (:type :text
:ui (:label "To Do" :input-type :line)
:source (:view :main :column :todo-name :agg :first)
:column t :not-null t :unique t)
:tags (:type :list
:ui (:label "Tags" :input-type :checkbox-list)
:source-sel (:list (:view :main :column :tag-name))
:source-all (:list (:table :tags :column :tag-name))
:ids-table :tags
:join-table :todo-tags))
:list-form (:fields t)
:update-form (:fields t)
:add-form (:fields t))
:todo-tags
(:table t :is-joiner t
:fields (:reference (:target :todos)
:reference (:target :tags)))
:tags
(:table t
:create :auto :update :auto :delete :auto
:fields (:name (:type :text
:ui (:label "Tag" :input-type :line)
:column t :not-null t :unique t))
:list-form (:fields t)
:update-form (:fields t)
:add-form (:fields t))))
This single definition aims to give you:
updated_at triggers:main) that pull related data like tags without extra queriesmacnod/rbac):label, :input-type, form layouts) that a React frontend can read directly to generate dynamic forms and listsThe full RBAC system (:users, :roles, :permissions, :resources, and all join tables) is automatically included from *base-model*.
This section presents some tiny pieces of the resulting enriched model, after compilation with (set-model *model*.
:todos(:TODOS
(:CREATE-TABLE-SQL
(:TABLE
"
create table if not exists rt_todos (
id uuid primary key default uuid_generate_v4(),
created_at timestamp not null default now(),
updated_at timestamp not null default now(),
rt_todo_name text not null unique
)
"
:TRIGGER
"
do $$
begin
if not exists (
select 1 from pg_trigger
where tgname = 'set_rt_todos_updated_at'
and tgrelid = 'rt_todos'::regclass::oid
) then
create trigger set_rt_todos_updated_at
before update on rt_todos
for each row
execute function set_updated_at_column();
end if;
end $$;
"
)
⋮
:todos(:VIEWS
(:MAIN
(:TABLES (:TODOS :TODO-TAGS :TAGS)
:SQL
"
select
rt_todos.id rt_todo_id,
rt_todos.created_at rt_todo_created_at,
rt_todos.updated_at rt_todo_updated_at,
rt_todo_name,
rt_tags.id rt_tag_id,
rt_tags.created_at rt_tag_created_at,
rt_tags.updated_at rt_tag_updated_at,
rt_tag_name
from rt_todos
join rt_todo_tags on rt_todos.id = rt_todo_tags.todo_id
join rt_tags on rt_todo_tags.tag_id = rt_tags.id
"
:COLUMNS
(:TODOS (:RT-TODO-ID "rt_todo_id"
:RT-TODO-CREATED-AT "rt_todo_created_at"
:RT-TODO-UPDATED-AT "rt_todo_updated_at"
:RT-TODO-NAME "rt_todo_name")
:TAGS (:RT-TAG-ID "rt_tag_id"
:RT-TAG-CREATED-AT "rt_tag_created_at"
:RT-TAG-UPDATED-AT "rt_tag_updated_at"
:RT-TAG-NAME "rt_tag_name")))
:TAGS
(:TABLES (:TAGS)
:SQL
"
select
rt_tags.id rt_tag_id,
rt_tags.created_at rt_tag_created_at,
rt_tags.updated_at rt_tag_updated_at,
rt_tag_name
from rt_tags
"
:COLUMNS
(:TAGS (:RT-TAG-ID "rt_tag_id"
:RT-TAG-CREATED-AT "rt_tag_created_at"
:RT-TAG-UPDATED-AT "rt_tag_updated_at"
:RT-TAG-NAME "rt_tag_name")))))
:todos :fields :name(:TODOS
(:FIELDS
(:NAME
(:CHECKED NIL
:UNCHECKED NIL
:UI (:LABEL "To Do" :INPUT-TYPE :LINE)
:UNIQUE T
:PRIMARY-KEY NIL
:FS-BACKED NIL
:TARGET NIL
:IDS-TABLE NIL
:JOIN-TABLE NIL
:FORCE-SQL-NAME NIL
:SQL-NAME "rt_todo_name"
:SQL-ALIAS "rt_todo_name"
:TYPE-SQL "text"
:CREATE-SQL "rt_todo_name text not null unique"
:ALIAS-KEY :RT-TODO-NAME
:SOURCE (:VIEW :MAIN :COLUMN :TODO-NAME :AGG :FIRST)
:TYPE :TEXT
:COLUMN T
:NOT-NULL T
:REFERENCE NIL))))
set-model — Compiles the model, enriches it, generates all SQL/views, and stores the result in *compiled-model*.:reference into proper foreign keys, builds joined view SQL, prepares parameterized CRUD statements.be-list, be-insert, be-update, be-delete) pull pre-generated SQL from the compiled model.user-allowed from the rbac library. RBAC tables are treated exactly like your own types, so you can manage users, roles, permissions, and resource access through the same UI/API.set-model. Separate be-validate (full form) and be-validate-field (real-time single field) functions.:reference instead of manual IDs for clean relations:views to explicitly control joins (e.g., :main (:tables (:todos :todo-tags :tags))):ui hints (:label, :input-type :checkbox-list, etc.) for frontend rendering:validation common validation names or lambdas that validate form/field data:join-table / :ids-table for many-to-many relationships:is-joiner t for explicit join tables:auto for create/update/delete → generated SQL (or override with your own function)rt_ prefix to avoid name collisions with RBAC tablesAll endpoints stay generic — no per-type handler generation needed:
GET /api/schema/:type/:form → JSON with fields, UI hints, and validation rules (for dynamic React forms)GET /api/list?type=todos → RBAC-gated results from the compiled viewPOST /api/validate, /api/insert, /api/update → call be-validate first, then use compiled SQLReact (or any frontend) can fetch the schema once and render forms/lists automatically.
be-list-internal and supporting helpers are in placemacnod/rbac (including user-allowed, list functions, and with-rbac)be-* CRUD functionsThe active implementation lives in lisp/workbench.lisp. Ignore all other files for now.
Target MVP date: December 2026.
Data-UI is and will remain fully open source under the MIT license. The core (model compiler, SQL generation, RBAC integration, CRUD layer, etc.) is free for anyone to use, self-host, or modify.
Initially we will focus on building custom applications for clients while dogfooding the tool on our own projects.
After the MVP, to fund continued development and provide additional value to users, we plan to provide:
If you’re building internal tools or client apps and want help, feel free to reach out.
Contributions and feedback are very welcome — this is still early stage!
MIT