ZuloOne
English
Sign in
Menu

🏛️ Platform architecture

Updated

← Developer wiki

The nested reference for this map is Kernel (models, metadata, identity, scripts, schema, runtime) and Kinds (document, dictionary, register, command, …). This page is the long-form of the same architecture.

The idea: metadata instead of code#

ZuloOne is a low-code, metadata-driven platform. An application object (a dictionary, a document, a register…) is described by metadata (rows in the Meta* tables plus JSON), and its behaviour by C# scripts (events/commands/services). Out of that description the platform generates:

  • database tables (SchemaSynchronizer),
  • entity classes and contracts,
  • a REST API (the generic controllers /api/data, /api/documents, …),
  • the user interface (forms/lists out of metadata).

Adding a module means adding metadata and scripts, not writing a new layer of code.

Record identity (it matters for transfer)#

  • The PK of every row is MetaId (a Guid), generated by the application → different on every instance. ID (out of the number sequence) is a separate human-readable key.
  • References are stored as the target's bare MetaId (a Guid, with no type tag); the target's type is taken from the metadata.
  • The consequence: internal keys are not portable. The transfer of data and customizations is built on business keys and names rather than on MetaId (see the data transfer framework and the forms system).

Kinds of metadata#

Kind Purpose
Dictionary a dictionary (a list of records) plus its fields
Document a document: a header + table parts + subtypes-as-states + commands. From the UI, once the document is saved, the subtype is changed only by a command (checks → the target subtype → the postings)
Register a register of totals/movements (balances, turnovers, FIFO lots) or of information (Information, the IR_ table) — see the information register
EDT the data type of a field (a scalar, a Reference to a dictionary, an Enum)
TablePart the type of a table part (a document's lines)
LinkTable a link table (M:N)
NumberSequence number sequences (auto-numbering of the ID field; on a dictionary its caption is "Code", on a document "Number". The Number column in the table is a shadow of the same value, there for document.Number in scripts). The counter is committed separately from a test's ambient transaction — otherwise rolling the case back returns NextValue onto IDs the data package has already handed out (IX_TaxCode_ID). If the sequence did land in an occupied range, an insert pulls the pointer up to MAX(ID)+1 once; on PostgreSQL the conflict is caught in a savepoint, otherwise 25P02 aborts the whole transaction
GlobalConstant global constants
Enum enumerations (stored as an integer)
WebService RPC /api/rest/{Name} — an operation, not a collection. See web services
DataEntity a public OData collection /odata/{Name} — a dictionary/document projection. See Data Entity
MetaFormLayout a form layout (JSON) — see the forms system

In-app guides#

The ⓘ drawer reads MetaGuide rows (Key, Title, Body). The source of the default rows is wiki/guides/<key>.md: first line # <Title>, then the Markdown body. GuideSeeder embeds those files into ZuloOne.Core as ZuloOne.Guides.*.md resources and loads them on startup.

Seeding inserts missing keys. It also upgrades an existing row only when the row still has the exact pre-file Russian default text: Core stores SHA-256 fingerprints of Title + "\n" + Body, not the old prose, and compares the row against them. A user-edited guide has a different hash and is never overwritten.

Run node tools/check-guide-keys.mjs when adding or renaming a key. The check fails when a literal guideKey in frontend/src has no file, when a non-grid guide file is unused, or when the guide tree is empty. --coverage lists StandardGrid ids that still have no grid.* guide; those are coverage gaps, not build failures.

Layers and models#

Metadata is organized into models (Model) with layers (Layer): the kernel → Solution → the application modules. A model is the unit of ownership and of transfer: its metadata (shared form layouts and scripts included) is exported and imported as a whole (the workspace export).

The SPA sends the X-ZuloOne-Model header: a record owned by another model is read-only (the designer). A tree import, apply-file, compiling a virtual total, seeding the totals bench (POST /api/dev/totals-testbench) and running an integration test write rows of the owning model (MetaVirtualTotalGroup groups — derivatives of the DSL; TestBench / TestBenchExt; a constant's SetAsync, a deployment rollback). This is not editing in the designer: without the bypass, the lock blocks "Totals bench" and a run from Local. Editing the text of the DSL or a constant in the designer is still possible only in the owning model.

The kernel's key services#

  • The assistant — POST /api/assistant/chat (an NDJSON stream). Chat sessions live in the MetaAssistantConversations table. They are per user and do not travel into the workspace. List/read/write/delete: /api/assistant/conversations. The panel docks on the right, in the same row as the work area (not an overlay), and squeezes it; "Open in a new window" opens /assistant in a separate window — open_record goes to the main window through BroadcastChannel. In the panel, tool calls are compact lines (as in Claude Code): a click expands the result. A query_records table stays open (it is the answer); list_entities / describe_entity are collapsed. export_file (xlsx) builds the workbook through the same read as query_records (permissions and row security) and returns the bytes on a "Download" chip — the model composes no URL and invents no rows. Print forms: list_print_forms / get_print_form / set_print_form_layout (ZuloReport LayoutJson only, the Update permission) / preview_print_form (a URL plus an excerpt, not a PDF). An Html script, an Xlsx template or a ZuloReport engine are still in the designer; see print-forms.md. query_records accepts a name on a reference field (Customer = Иванов, not a guid) — Russian transliteration and a typo (виджет / ивджет = Widget) — and includeLines on a document (the header plus the lines). lookup_docs searches the wiki/business and wiki/work pages (embedded into Core; roadmap.md excluded; lessons excluded because their check blocks carry answer keys) and the ⓘ guides; queries are in English, answers in the user's language. Clarifications go through the ask_user widget (buttons, not free text); open_record opens a card through the ClientAction allowlist. Object captions inside the tools are localized by X-ZuloOne-Lang, and the model's answer comes in lang. In the system prompt, the rules are followed by a catalog of the objects the user is allowed to read (documents with their lines, SalesOrder.Lines). The history the model sees is a server-side transcript (ModelTranscriptJson): query_records tables are written into the tool turn and are sent with the next question as <prior_tool_result>. The client sends only {role, text} and conversationId — tool rows are not accepted from the browser (they can be forged). The MessagesJson blob is for rendering the panel and nothing more.
  • IDataService (src/ZuloOne.Core/Services/DataService.cs) — CRUD over the physical tables: QueryAsync/InsertAsync/UpdateAsync/DeleteAsync. A row is a Dictionary<string, object?> keyed by column names. Image / Binary fields accept a data URL (data:image/…;base64,… from the card widget) or raw base64 and are written as byte[] (VARBINARY / BYTEA). On save it sends the real-time RecordChanged event. DeleteAsync does not delete a row that another record still references (RecordReferenceGuard: a Reference EDT, table parts, link tables, register dimensions, ParentId, a dictionary-list OtherKey) — the API returns 409 with the list of references. There are no FKs in the database; this is the only data integrity check there is.
  • IMetadataService — introspection of the metadata (dictionaries, documents, fields, EDTs, table parts…).
  • IAccountingPeriodService — the posting boundary: two dates per tenant (closed / audit). Not the FiscalPeriod dictionary. In the UI: Administrator → Posting boundary. See the posting boundary.
  • ITotalDriverProvider — for kernel drivers (StockBaseTotalDriver and the rest of those compiled into Core) there is no owner script: the engine IS the handler. A script in a model above is only a CoC wrap with next(), not a partial class of the same name.
  • SchemaSynchronizer — brings the physical database into line with the metadata (creates and alters tables and columns).
  • EDTResolver — a field's type → a column's type (e.g. Reference → uniqueidentifier, Enum → an integer).
  • The script runtime (src/ZuloOne.Runtime) — the execution of events/commands/services; the result is handed to the client, typed ClientActions included.
  • Compiled code never leaves memory. RuntimeCompiler loads every assembly into one AssemblyLoadContext that is never unloaded, so each load stays until the process restarts, outside the managed heap the GC manages. A stand that recompiles without unloading assemblies holds 13,000+ assemblies and ~5 GB of working set against an 87 MB managed heap. Each «Compile model» adds ~490 more, until the container's memory ceiling kills the process (exit 137). The rules that keep it bounded:
    • A check does not load. Editor Check, live check, «Compile model», compile-by-kind, the ZR and test checks all compile with checkOnly: true. Only code that will run is loaded.
    • Unchanged entities are not re-emitted. An entity assembly is re-emitted only when its generated sources changed (SourceSetHash), or when a model before it in topological order was re-emitted in the same pass. So EntitiesGeneration moves only on a real metadata change, and the caches keyed on it (service registry, contracts, reports, jobs, form modules) stay.
    • Event handlers are reused. A handler with the same text, entity class, scope and generations gets the type it already has (RuntimeCompiler.TryGetReusableType).
    • A per-call compile reuses what it loaded. Integration test runs, report runs, document print scripts, analytic-column providers and command wraps compile with reuseLoaded: true. An identical compile returns the loaded assembly: same parsed texts, framework parts included, same references and generations. The caller then finds its types by the returned AssemblyName, not the name it asked for. A side effect: static fields in a reused test or report survive between runs.
    • A new per-call compile uses reuseLoaded: true or CompileCachedAsync, never only a fresh Guid in the assembly name.

Real-time#

The SignalR hub /hubs/zuloone (src/ZuloOne.Web/Hubs/ZuloOneHub.cs) broadcasts the RecordChanged {table, recordId, changeType}} event, which DataService sends on Insert/Update/Delete. The frontend client is frontend/src/lib/realtime.ts (a per-record subscription, auto-reconnect, a fallback to polling).

Data transfer#

The framework for exporting and importing rows between instances: src/ZuloOne.Core/Services/DataTransferService.cs + DataTransferController (/api/data-transfer). On export, references are resolved to the target's business key; on import, they are resolved again to the local MetaId (with an upsert in topological order). Dictionaries, link tables and documents are supported (a header plus table parts; movements are not copied — they are regenerated by reposting). A reference whose target is not included in the export is not written into the package (the published-version pointer is filled by handlers during import). A zip with several JSON files is applied by file name. POST /api/data-transfer/export?zip=true returns an archive.

The front end#

React + Mantine + react-query + react-router. The page registry is frontend/src/pages/pageRegistry.tsx; the ribbon is frontend/src/layout/Ribbon.tsx; the API wrappers are frontend/src/api/api.ts and the types frontend/src/api/models.ts.

Build and deploy#

Docker Compose (v1: docker-compose, with the hyphen). The services: core (:5257, MSSQL), web (the SignalR hub, :5049), frontend (:5173), plus the PG stand (core-pg :5258, frontend-pg :5174, the pg profile). Migrations are applied at startup (dbContext.Database.Migrate()). The dp-keys volume holds the ASP.NET Data Protection key ring: without it the AI agent's keys in MetaSystemSettings stop decrypting after the container is rebuilt (the page shows "key unreadable"). The usual rebuild:

docker-compose up -d --build core frontend

Where next#

Was this page helpful?