xt.db UI Binding FrameworkThis document sketches a hypothetical declarative UI framework built on top of
xt.db.
The goal is to make xt.db easy to attach to:
This is a design README, not an implemented library.
The framework sits between:
xt.db specsxt.db node/model/view stateIt should let application code describe:
without making UI components call low-level xt.db.node.instance-model APIs
directly.
The framework has three layers:
flowchart LR
A[screen spec]
B[binding instance]
C[React-like adapter]
D[Dart-like adapter]
E[xt.db node]
A --> B
B --> E
B --> C
B --> D
The top layer should stay plain data.
Example:
(def OrdersScreen
{:views
{:selected
{:query {:table "Task"
:return-method "default"
:return-id "00000000-0000-0000-0000-0000000000a1"}}
:open-list
{:query {:table "Task"
:select-method "by_status"
:return-method "default"}
:default-input ["open"]}}
:actions
{:refresh-selected {:type :refresh :view :selected}
:set-filter {:type :input :view :open-list}
:archive-task {:type :action :name :archive-task}}})
This spec says:
:selected is a single-record view:open-list is a filtered collection viewThe runtime should turn a screen spec into a live binding object:
{:spec ...
:get-state (fn [] ...)
:subscribe (fn [listener] ...)
:commands
{:refresh! (fn [view-id] ...)
:set-input! (fn [view-id input] ...)
:act! (fn [action-id payload] ...)}
:dispose! (fn [] ...)}
This object is the framework-neutral contract.
:specThe original declarative screen spec.
Useful for:
:get-stateReturns the current public state snapshot:
{:views
{:selected
{:status "ready"
:input []
:value [{"status" "open"}]
:error nil}
:open-list
{:status "stale"
:input ["open"]
:value [{"id" "..." "status" "open" "name" "alpha-task"}]
:error nil}}}
This is what a UI renders from.
:subscribeRegisters a callback for state changes.
Expected shape:
(def unsubscribe
((:subscribe binding)
(fn []
(rerender!))))
The return value should be an unsubscribe function.
:commandsThese are the public write/trigger operations.
:refresh!Refresh one view by id:
((get-in binding [:commands :refresh!]) :open-list)
:set-input!Change the input for a view:
((get-in binding [:commands :set-input!]) :open-list ["closed"])
This should usually refresh the view as part of the operation.
:act!Trigger a named mutation/action:
((get-in binding [:commands :act!])
:archive-task
{:id "00000000-0000-0000-0000-0000000000a1"})
:dispose!Clean up listeners/subscriptions when the UI screen is removed:
((:dispose! binding))
The framework should expose a stable, framework-agnostic state model.
Suggested shape:
{:views
{:view-id
{:status "idle" ;; or pending | ready | stale | error
:input [...]
:value ...
:error nil
:meta {...}}}
:actions
{:action-id
{:pending false
:error nil}}}
This keeps UI adapters simple.
A minimal constructor could look like:
(create-binding
{:node node
:space "room/local"
:model "orders-screen"
:spec OrdersScreen})
Internally it would:
xt.dbxt.db view state into public binding stateThe framework does not need to know about React itself. A hook adapter can be a thin wrapper around the binding.
Conceptual usage:
(defn use-screen [binding]
;; subscribe on mount
;; read binding state
;; unsubscribe on cleanup
{:state ((:get-state binding))
:refresh! (get-in binding [:commands :refresh!])
:set-input! (get-in binding [:commands :set-input!])
:act! (get-in binding [:commands :act!])})
A component then only deals with:
The same binding can be adapted into:
ValueNotifierChangeNotifierStreamConceptual Dart interface:
abstract class ScreenBinding {
Map<String, dynamic> getState();
VoidCallback subscribe(void Function() listener);
Future<void> refresh(String viewId);
Future<void> setInput(String viewId, List<dynamic> input);
Future<void> act(String actionId, dynamic payload);
void dispose();
}
The important point is that Dart should not need any xt.db-specific knowledge
beyond this binding contract.
xt.dbUnder the hood, the binding would map to existing xt.db operations:
| Binding API | xt.db operation |
|---|---|
get-state | view-get, view-val, view-error |
refresh! | view-refresh |
set-input! | view-set-input |
act! | mutation/sync/action wrapper |
dispose! | unsubscribe / teardown listeners |
So the binding is not replacing xt.db. It is packaging xt.db into a
UI-friendly runtime object.
After loading:
{:views
{:selected
{:status "ready"
:input []
:value [{"id" "000...a1"
"status" "open"
"name" "alpha-task"}]
:error nil}
:open-list
{:status "ready"
:input ["open"]
:value [{"id" "000...a1"
"status" "open"
"name" "alpha-task"}]
:error nil}}}
After a filter change:
{:views
{:open-list
{:status "ready"
:input ["closed"]
:value [{"id" "000...a2"
"status" "closed"
"name" "beta-task"}]
:error nil}}}
Without this layer, framework code tends to call low-level APIs directly:
view-refreshview-set-inputview-getview-valThat works, but it couples UI code tightly to xt.db.node.
The binding layer gives:
This hypothetical framework should preserve the current xt.db design:
idle, pending, ready, stale, error)So the framework is best understood as:
a declarative binding layer on top of
xt.db, not a replacement for it.
If the design starts small, this is probably enough:
{:spec ...
:get-state (fn [] ...)
:subscribe (fn [listener] ...)
:commands
{:refresh! (fn [view-id] ...)
:set-input! (fn [view-id input] ...)
:act! (fn [action-id payload] ...)}
:dispose! (fn [] ...)}
That is small enough to implement once, and broad enough to support:
Natural next pieces to define would be:
create-bindingcreate-screen-specbind-viewpending, error, success)Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |