Apps

Schema Builder

# Schema Builder — visual deftype designer + persist backend manager A GL-host panel app (retro/hydra) for designing `deftype` schemas the way `deft/persist` sees them, generating a real schema module, creating and migrating SQLite backends from it, and browsing/querying compliant databases. It is a standard widget-tree app with a pixel surface embedded as a widget: the schema canvas is `deft/ui/components/surface` (custom pixel art in local coordinates, scissored to its slot), while the inspector, status bar, and data browser are ordinary UI components (`column`/`row`/`label`/`button`/`checkbox`/`text-input`/`dropdown`/`grid`/ `multiline-text`). The canvas needs a GL host; the widget chrome works anywhere. ## Launching ```sh retro app deft/schema-builder # empty canvas (Ctrl+O to open) retro workspace schema-builder # with workspace-example.dft copied in ```` ## Persist mode (global checkbox) The right dock starts with a persist checkbox: - on (default) — full persist schema: SQL storage options, foreign-key relations, declarative views, DDL preview, backend create/migrate/check, and Data mode. - off — pure type designer: the SQL-only UI (table mapping, SQL type/default, composite indexes/append, views, DDL/backend actions, Data mode) disappears. Base-type keys stay visible and are saved: `:primary-key`, `:unique`, `:index` and `:references` are part of the type even when nothing enforces them. SQL type/default mappings stay in the file but their editor rows are hidden. Views are hidden on the canvas; FK badges/edges remain. Saving keeps `types.dft` + `design.dft` + `schema-meta.dft` + `model.dft` (the design file still records canvas positions) and removes a stale `persist.dft` from an earlier persist save, with those field keys intact. Opening a types-only file (no sibling design) enters type-designer mode automatically; its base-type keys (including references) load as written. Toggling persist back on restores the hidden builder state in the current session. ## Two modes (`m`) ### Schema mode — the canvas Every type is a node (header + one row per field, inherited fields dimmed and shown parent-first); `defunion` types get their own color. New types and views are placed in the first free slot in a canvas-fitted grid, so they never land on top of existing nodes. Empty types still have a clickable body ("no fields — press A"). Edges: - inheritance (solid blue): child `<<` parent, - reference (dashed amber): a field's persist `:references` target, - view (dashed purple): a view and the base type it draws from. | Key / mouse | Action | |---|---| | `a` | add a type (prompt) | | `A` / `f` / Enter | add a field to the selected type | | `n` | rename the selected type/field/view | | `x` / Del | delete the selection (types confirm) | | `c` / `C` | set / clear the selected type's parent (`<<`) | | `v` | add an empty view (prompt for name) | | `g` | fit the canvas to the schema | | arrows / Shift+arrows | nudge the selected node (10 / 40 px) | | drag a node | move it (snaps to a 10 px grid) | | drag a field row onto a view | add that field as a view column (base/join created automatically) | | drag empty canvas | pan | | wheel | zoom at the cursor | | `p` | preview the persist DDL in the workspace editor (opens one if needed) | | `m` | switch to Data mode | | `d` | toggle the inline data panel under the canvas | | `Ctrl+S` / `Ctrl+O` / `Ctrl+N` | save / open / new | | `Ctrl+R` | regenerate database list + recompile the probe | | click a header/row | select the type/field | ### Views — drag-built queries `v` creates an empty view on the canvas. Drag field rows from any type node (including imported types) onto it: - the first field sets the view's base type and alias, - fields from other types add a LEFT join with alias = type name; ON pairs are discovered from `:references` when possible, - every dragged field becomes a column (duplicate names are prefixed with the source type). The right dock for a new-style view shows the base, the column list, each join's kind + ON text (`alias.field = alias.field; …`), the `where` clause, and preview/delete actions. Add joins with + Add join…; remove a join drops its columns. Legacy shape-based views keep their old `:from`/`:shape` inspector until the first field is dragged in; that drop converts them to a drag-built view and preserves their existing columns. ### Inline data panel Toggle data in the dock (or press `d`, or Data → Toggle data panel) to show a data grid in its own row beneath the canvas. It follows the current selection: a type shows its table, a view shows the live result of the view's SELECT (join/where included). Changing a dragged column, join, or where clause refreshes it after recompiling. When no database is open it selects the schema's `populate.db` by default (if present); other databases can be picked from the panel header. The full Data mode (`m`) remains available for ad-hoc SQL. ### Inspector (right dock) The dock is a standard widget column (clipped to its slot, fixed width): - text rows (name, type/of via dropdowns, default, description, min/max, pattern, enum, validate, sql type/default, table name, indexes, append, variants, view alias/where) edit inline — type in the input; - checkboxes (`required`, `many`, `coerce`, `primary-key`, `unique`, `index`, `if-not-exists`) toggle immediately; - buttons run actions (add field, preview DDL, check live, new view, delete, done); - `Set reference…` opens the reference wizard (target type → target field → on-delete). The target list hides the selected type, target fields are listed primary-key first, and the FK is stored on the selected field (shown as `FK` on its canvas row and in the type's `relations` list, where `select` jumps to the owning field). `Clear reference` appears when a field already has one. Diagnostics for the selection appear inline (compile/structural issues). ### Data mode — widget console - header: DB and Rel dropdowns (recursive `*.db` scan, configurable via `schema-builder/db-glob`); Design query… starts the visual query wizard; Schema returns to the canvas. - results grid: standard `grid` component — wheel / arrows / PgUp/PgDn / Home/End scroll, auto-fit columns, click to select a row. - SQL editor: `multiline-text`; click to focus and type, `Enter` runs, `Ctrl+Up/Down` walks history (the editor keeps its buffer across rebuilds). `m` switches back to the canvas when no input is focused. - Design query… walks: base type → columns → `where` conditions (`field = value; …`) → order (`field :desc`) → limit, assembles the SQL (using the type's persist table name), switches to Data mode, and runs it. The result stays in the editor for further edits. - Views are persist's declarative joins. Drag-built views live on a hidden `View_<name>` shape type; Preview view SQL opens the `CREATE VIEW` statement in the workspace editor (same editor handling as DDL preview) and ensure/migrate materializes it. Renaming a view creates the new SQL view on migrate; use Migrate’s stale-view prompt (or Schema → Drop stale views…) to remove the old database view. The inline data panel shows their rows before the view exists in the database. ## Persist workflows Drafts are compiled by a scratch child runtime (`sb-probe`) through the real compiler and `deft/persist` — diagnostics and DDL are ground truth, not approximations. Recompiles are debounced (`schema-builder/probe-debounce-ms`, default 300ms) after each edit. - Live diagnostics — compile errors show in the status bar; structural problems (unknown parents, unresolved references, duplicate names, unions in persist columns, references to custom-named tables) highlight nodes red/amber and list in the inspector. - Preview DDL (`p`) — the exact `CREATE TABLE`/`CREATE INDEX`/`CREATE` VIEW` statements `persist/ensure` would run, opened as` `schema-builder-preview.sql` in the current workspace's editor panel. If no editor is open, one is split into the workspace automatically; the buffer is refreshed on each preview. - Create backend… — pick a file; runs `persist/ensure` in the probe (idempotent, `PRAGMA foreign_keys` on) and opens it in Data mode. - Migrate… — additive `persist/migrate` with a per-type report (`created`, `added`, `blocked`, missing/changed diagnostics). After a successful migrate it offers to drop database views the schema no longer declares (renamed/removed views), showing the exact list first. - Drop stale views… — the same cleanup on demand for a chosen database: any non-system view whose name is not declared by the current schema is listed and, after confirmation, dropped. Useful after renaming a view: migrate creates the new name, then this removes the old one. - Check live… — `persist/check`: rows a database has that the schema rejects, with invalid rowids. - Every write action re-derives from the current draft — save first if you want the file to match the database. ## Saved layout — `<name>.scm` directories A schema is a directory named `<name>.scm` (for example `schema/orders.scm/`). It can live anywhere under the workspace; the parent directory is not fixed. Inside it are the schema files: ``` orders.scm/types.dft # types only — importable by ANY Deft program orders.scm/design.dft # design document: layout + type versions orders.scm/schema-meta.dft # full schema descriptor blob (types/views/model) orders.scm/persist.dft # persist opts + API (imports types, design.dft) orders.scm/model.dft # generated in-memory model API orders.scm/populate.dft # optional AI test-data seeder ```` In type-designer mode (persist unchecked) `types.dft`, `design.dft`, and `model.dft` are written (positions survive mode switches); a stale `persist.dft` from an earlier persist save is removed. The generated model is independent of persist mode. - `types.dft` contains only the document's imports and the generated `deftype` / `defunion` declarations (parents first, unions last). It has no persist dependency, so simple programs can use it just to define types. Field `:references` (foreign keys) live here only — the most generic module is the single source of truth for relations:
import "schema/orders.scm/types"

set u %User{ :name "Ada" }
[is? "User" $u]        # true; the types are registered by the import
- `design.dft` carries the `%SchemaDesign{}` data block: canvas positions, the model version, and per-type versions. It deliberately omits field references and SQL/storage options; opening it reloads those from the schema's `types.dft` / `persist.dft`. Importable anywhere the types plus layout are wanted (view rectangles are unused outside the builder). - `schema-meta.dft` is the canonical, tag-free schema descriptor used by persist metadata: every type/field/view/library plus the model graph (canvas layout excluded). It is emitted as a single string blob for hashing/version checks and exposes `schema-descriptor` (parsed), `schema-descriptor-text`, `schema-name`, and `schema-version`. `persist.dft` and `model.dft` import it; DB-to-schema tools can use it to regenerate the `.scm` sources. - `persist.dft` imports its siblings and `deft/persist`, and carries the storage definition plus the persist API. `ensure`/`migrate` also record the schema blob in `_persist_meta_`:
import "deft/persist" as persist
import "schema/shop.scm/persist.dft" as schema

set store [sqlite/open "shop.db"]
[await [schema/ensure $store]]              # create missing tables/views
set sales [schema/repo $store :Sale]        # honours :table-name etc
[await [persist/insert $sales %Sale{ :region "west" :amount 4 }]]
Its `%SchemaPersist{}` data block holds the editable refs (`:table-name`, composite indexes, `append`, …) and `%PersistView{}` maps; the getters (`refs`, `opts`, `views`) derive from it. In type-designer mode the file is absent — import `types.dft` (types) or `design.dft` (positions). `model.dft` is always generated; import it to use the in-memory model API. | Export | Purpose | |---|---| | `refs` / `opts` / `views` | persist refs argument / per-type TypeOpts / `%PersistView{}` maps | | `repo store type` | typed accessor honouring storage options | | `ensure store` | create missing tables, indexes, views | | `migrate store` | additive alignment; returns the per-type report | | `check store` | invalid-row scan | `schema-name` and `schema-version` are bumped automatically: the model version changes when any structured type/view/persist change is saved; a type version changes when that type's structure changes. Canvas moves do not bump versions. Persisting the same version with a different blob is rejected, so bump the version (or run the generated `migrate`) before writing a changed schema. Writes are atomic and fmt-canonical when the TUI formatter is available. Path rules. Save As defaults to `<name>.scm` in the configured start directory (`schema-builder/schema-dir`, default `schema/`). Choose `schema/shop.scm` (or any file inside it, or the legacy `schema/shop.dft` / `schema/shop`) and files land in `schema/shop.scm/`. The `.scm` parent can be anywhere and nested as you like. Opening a `.scm` directory or any of its files loads the builder document — the app resolves siblings from the directory. Legacy `<name>/<name>.dft` trees and `X_builder.dft` / `X_persist.dft` files still load; Save As uses the `.scm` layout by default. A types-only file with no sibling design opens as a type designer. The document's display name follows the directory base name on save. Finding schemas. File → Open schema… scans the workspace recursively for `<name>.scm` directories (config `schema-builder/schema-scan-root`, default `.`; engine/source trees such as `packages`/`ui`/`deft` are skipped) and lists them with a Browse file… fallback. Type-library pickers use the same scan, so a new schema becomes importable without any central registration. Loading never evaluates generated code: the data block is read with `deft/parse`, and a types-only file falls back to walking its `deftype`/`defunion` declarations with an auto layout, so hand-written schemas open too. ## Type libraries — shared types A schema can import types from other modules instead of redefining them. This is how a `customer` schema, a system-wide `types.dft`, or a package's type module is reused: - Add a library — `l`, or Schema → Add type library…. Discovered `<name>.scm` schema directories are listed automatically; Browse file… picks any `.dft` outside the scan root. The path is stored relative to the current schema directory. - Imported types appear on the canvas with an `[imported]` badge and a muted header. They are read-only: they can be used as field types, references, union variants, and `<<` parents, but the inspector offers no field/storage edits. If the library is itself a builder schema, Open source schema opens its design; otherwise edit the file directly. - Extend locally creates a local subtype (`LocalCustomer <<` Customer`) that can add fields and override inherited keys. This is the` way to adapt an external/package type without touching its source. `<<` cannot remove or rename inherited fields; define a separate type in that case. The subtype gets its own table — inheritance is structural, not SQL table inheritance. - Emission — `types.dft` starts with `import "../customers.scm/types"` and contains only this schema's declarations. `design.dft` stores `:libraries` plus positions for imported nodes. `persist.dft` keeps local refs only: each schema creates its own tables; imported tables must be created by their owning schema (the builder's probe/DDL likewise covers only local tables). - Collisions — a type name may come from only one place. Importing a duplicate name (or defining a local type with a library type's name) is rejected; remove the library from the empty inspector to undo an import. In the workspace example, `schema/customers.scm/` owns `Customer` while `schema/orders.scm/` imports `../customers.scm/customers.dft` and keeps only its own `Order`/`OrderItems` definitions. Limits: only direct declarations of a library file are drawn (a library's own imports still compile); package-mount imports are not resolved by the picker yet (import the package's source file by relative path); an imported type's custom `:table-name` is still not honored as an FK target (persist uses snake_case there). ## Populate — AI test data Schema → Populate with AI… (persist mode, saved schema) generates `populate.dft` next to the schema files and runs it in a child runtime. The script builds a schema-aware prompt, asks stdext AI for a JSON dataset, normalizes keyword/enum values, and inserts every seeded type through `persist/insert-many` in dependency order. - Imported types are seeded as fixtures too, so views and references work in the throwaway database without populating other schemas first. They are duplicated for this test DB only; each schema still owns its own tables for real use. - The DB is `<name>.scm/populate.db` (config `schema-builder/populate-db`). Rerunning recreates it — it is test data, not production storage. - The generated script imports `./persist.dft`, so it exercises the real persist schema: tables, indexes, foreign keys, and views. After a successful run the panel opens the DB and switches to Data mode, where views are browsable alongside tables. - Rerun from the schema directory with `deft run populate.dft`, or regenerate from the menu after changing the schema. The number of rows per type is `schema-builder/populate-rows` (default 8) at generation time. - The AI reply is saved before any database work: the extracted JSON goes to `populate.json` (same shape as the request: `{ "Type": [ {...} ] }`), and the raw reply/error goes to `populate.reply.txt`. A failed insert therefore leaves the dataset on disk for inspection, editing, and a retry. Once `populate.json` exists, reruns use it instead of calling AI; delete it to regenerate fresh data. Tests use this same fixture path. - Populate calls the model without tools (the MCP tool loop is skipped), so the model only returns data. - AI configuration follows the workspace: an `:ai` prelude map (via `ai/load-cfg`) wins, otherwise the `ai/provider` / `ai/model` keys in `.deft/config.dft` (this workspace: `:deepseek` / `"deepseek-v4-flash"`), otherwise `ai/default-cfg`. The API key comes from the provider's environment variable. The call uses temperature 0, thinking off, and no tools; empty responses are retried twice. The panel surfaces failures in a dialog and leaves the generated script and reply dump for inspection. - AI output is parsed as data only — never evaluated. Limits: imported fixtures use default storage names (custom `:table-name` on a library type is not applied here); self/cyclic references rely on the model ordering rows parent-first; a validation error identifies the type/row and leaves the script for editing. ## Generated model (`model.dft`) Every save also writes `<schema>.scm/model.dft` (in legacy layouts, `model.dft` beside the types file). It is an in-memory model layer generated from `types.dft`; it does not require persist mode.
from "shop.scm/model" import new-shop

set model [new-shop]

set c [$model create %Customer{ :id "c1" :name "Ada" }]
set a [$c create [Address --street "Main"]]
[$c list Address]
[$model list Customer]
[$model find Customer "c1"]
[$model dump]
[await [$model persist "shop.db"]]   # snapshot replace + schema blob
[await [$model load "shop.db"]]      # verify blob, rebuild the model
Relations are derived from both `:references` (association: child rows linked by FK) and fields typed as another record/union (composition: nested in the parent). Imported parent types referenced by local records also get relations, `ModelEntity` impls, and are included in model persistence, so an imported `Customer` can create and persist local `Order`s. A child type can have several parent relations; generic `create` raises when a child type matches more than one, while `list` combines them. `dump` returns a data snapshot; `persist` snapshot-replaces all model tables and writes the versioned schema blob in one transaction. `load` verifies the stored schema/version/hash against `schema-meta.dft`, then rebuilds the model atomically. See `deft/model`'s help for ids, limits, and the full API. The `todo` app (`packages/todo`, `retro app deft/todo`) is a complete app-state example: its own `todo.scm/` schema directory, a headless store, and a TUI panel that restores its filter/selection on launch. The manual page `help/manual/03-Platform/10-app-state.md` walks through it. The generated module only depends on `deft/model`; import it with `from` to bring the generated `new-<schema>` constructor into scope, then use the model handle for all top-level operations and the stored records for child operations. ## Module API (for scripts and tests) | Module | Provides | |---|---| | `deft/schema-builder/model` | `%SchemaDoc/Type/Field/View/Index`, `%SchemaDesign` / `%SchemaPersist` save shapes, library metadata (`external?`, `local-types`, `library-imports`), pure ops, `validate`, `topo-order`, persist conversion, query-builder SQL | | `deft/schema-builder/emit` | deterministic `types-text` / `design-text` / `persist-text` / `model-text` / `schema-text`, `eval-source` for the probe | | `deft/model` | generated-model runtime: `new-<schema>` construction, `ModelOps`/`ModelEntity`, dump, transactional persist | | `deft/schema-builder/parse` | `read-doc-block`, `parse-text`, `load` (resolves `:libraries`), `save`/`save-both`, `schema-paths` (`.scm` directories + optional schema-root), `resolve-design-path`, `find-schema-dirs`, `rel-path` (atomic multi-file writes) | | `deft/schema-builder/probe` | `refresh`, `ensure`, `migrate`, `check`, `find`, `count`, `view-sql` (child-runtime session) | | `deft/schema-builder/layout` | node measurement, hit-testing, pan/zoom/fit, edge routing | | `deft/schema-builder/query` | db discovery, relations/columns, `run`, grid shaping, query-builder previews | | `deft/schema-builder` | the panel: document ops, probe wrappers, widget-tree builders, menu handlers | | `deft/ui/components/surface` | the embedded pixel-surface widget used for the canvas (`draw-cb`/`event-cb`/`scroll-cb`) | ## Configuration | Key | Default | Purpose | |---|---|---| | `schema-builder/schema-dir` | `"schema"` | Default Save As / file-dialog start directory | | `schema-builder/schema-scan-root` | `"."` | Root scanned for `<name>.scm` schemas | | `schema-builder/db-glob` | `"*.db"` | Data-mode discovery glob (recursive) | | `schema-builder/snap` | `true` | Snap dragged nodes to a 10px grid | | `schema-builder/grid` | `true` | Draw the canvas grid | | `schema-builder/probe-debounce-ms` | `300` | Delay before recompiling the draft | | `schema-builder/populate-db` | `"populate.db"` | Test DB created by Populate (relative to the schema dir) | | `schema-builder/populate-rows` | `8` | Rows/type requested from AI when generating populate.dft | ## Tests From the workspace root: ``` deft test packages/schema-builder/test/test_model.dft # model + validation deft test packages/schema-builder/test/test_emit.dft # deterministic emission deft test packages/schema-builder/test/test_parse.dft # round-trips + foreign files deft test packages/schema-builder/test/test_populate.dft # populate.dft generation + ordering deft test packages/schema-builder/test/test_layout.dft # canvas geometry deft test packages/schema-builder/test/test_probe.dft # scratch runtime + persist ops deft test packages/schema-builder/test/test_query.dft # browser + builder helpers deft test packages/schema-builder/test/test_panel.dft # pixel UI state machine deft test packages/schema-builder/test/test_integration.dft # emitted module as a consumer deft test packages/schema-builder/test/test_model_emit.dft # generated model module deft test packages/schema-builder/test/test_model_integration.dft # model API + persist ```` ## Limits (v1) - GL hosts only (retro/hydra), like codegraf. - SQLite backends only (persist's `io-*` seam keeps other backends open). - Import from an existing database (DDL → schema) is not implemented yet. - Additive migrations only — persist never drops or retypes columns; `migrate` reports `blocked`/`missing`/`changed` instead. - No undo/redo, multi-select, or copy/paste on the canvas. - The inspector column clips when the window is too short (no dock scrolling yet). - Views edit `:from`/`:shape`/`:join`/`:as`/`:where`; explicit `%JoinSpec{}` join lists are preserved from loaded files but not editable yet.