ZuloOne
English
Sign in
Menu

📦 Model import: bundles and the diff

Updated

How a model travels from one ZuloOne installation to another, what is shown before it is applied, and by what rules objects are compared.

← Developer wiki

The bundle#

A model is exported into a self-contained JSON: a manifest (name/version/publisher), references to its dependencies, the model's OWN objects and the rows it contributes to objects owned by other models (extending fields, bound scripts).

Besides dictionaries/documents/registers, a bundle carries:

JSON field What it is
menu the model's own menu chunk, the one merged into every interface template
resources the scripts' text resources; scriptName is the owner. A script's Resources tab shows that script's keys and the shared ones (the ones with no owner). Developer → Resources is the full catalogue
jobs jobs together with their Task script
scripts standalone scripts with no owner
extensionScripts scripts on objects owned by other models; your own jobs do not go here

Every reference goes by NAME, so identifiers need not match between installations. An imported model always arrives disabled — enabling it is a separate, validated action.

Endpoint Method
GET /api/metadata/models/{id}/export one model
GET …/export?includeDependencies=true a set: transitive dependencies, dependencies first
POST /api/metadata/models/import?dryRun=true a preview, writes nothing
POST /api/metadata/models/import applying it, one transaction per bundle
POST /api/metadata/models/import-set applying a set, one transaction for the whole set

The key files: src/ZuloOne.Core/Services/ModelBundleService.cs; the report's contract is src/ZuloOne.Core/Services/Import/ImportDiffReport.cs.

Applying does not look at the working model (X-ZuloOne-Model). The preview is built without writing and is therefore always "green"; writing creates rows of the model being imported, while the SPA header sends the model currently selected by the operator. Without the bypass, the first MetaResource / job / print form failed with принадлежит другой модели. That string is quoted in Russian on purpose: it is hardcoded in WorkingModelGuard.cs and never localized, so an English rendering here would not be findable by anyone grepping for what they actually saw. The same bypass is used by POST /api/dev/workspace/import-all and apply-file, plus DataPackages / POST /api/data-transfer/import uploads (Tenant setup): dictionary seeding is restore, not designer editing. Without it, an upload with another working model in the header failed with the same error on the package model's MetaNumberSequence. The guard against editing other models still applies to designer edits; the import itself does not overwrite objects owned by other models (Skip / Owns).

import-all writes scripts before commands and jobs. ScriptMetaId is a Restrict FK: one command (or job) whose envelope has no .cs beside it makes SaveChanges fail for the whole phase (23503 / FK_MetaDictionaryCommands_MetaScripts). The pair X.script.json + X.cs is mandatory; if the script is not in the tree, the command/job row is skipped with a warning and the remaining object groups continue to import.

The business-layer CI checks an upgrade separately from importing into an empty database: upgrade_gate.sh installs the tree from the previous release tag, then the current tree into the same PostgreSQL database. When existing model versions changed, the second import must report updated rows. A failure in this step blocks image publication. One successful import from scratch does not prove this: it does not distinguish an upgrade from wrongly skipping existing metaIds.

Additionally, check_installed_models.py reads MetaModels and MetaPackages from the database: every delivered model must be enabled and compiled, and its model version and package version must match the current tree. Core is checked for compilation; its version is set by the platform image. This catches partial success, where the update counter is positive but one model stayed on an old version or did not compile.

If the versions did not change, a repeat run may report Model tree is current without import counters — that is an acceptable absence of updates. A ready /health without the install result is not proof of success. On emergency exit the script prints the line and the command where it stopped.

Subtype transitions have an additional (FromSubtypeMetaId, ToSubtypeMetaId) key. The transition editor recreates their IDs, so the same edge on a tenant and in an export may have different metaIds. Bulk import matches such a pair and keeps the tenant ID; reassigning the edge to another model is forbidden. Without this, an upgrade from v2026.9.0 rolled back the binding phase on the unique transition index, while package versions stayed unchanged even after the models compiled successfully.

What an import does to an existing object#

An import is an update, not only a creation. There is one rule:

You may write over ONLY what the model already owns (the row's ModelId equals the model being imported).

Matching goes by name, so the Common bundle and a dictionary the customer created by hand can collide on Currency without being one and the same thing. An object with the same name owned by another model is a conflict: it lands in Skip and is left untouched. Silently hiding that is not an option: "an update that did not land" is exactly what the operator has to see.

Objects the model has in the database but that are no longer in the bundle land in Orphan — we report, we do not delete: the very first rename understood as a deletion would carry off the customer's data, and inside a committed transaction there is no rollback.

Exception — outgoing model dependencies. The dependencies array in model.json (and the same list on a bundle) is the whole outgoing set for that model, like Menu/menu.json is the whole menu. An edge the file no longer names is removed from MetaModelDependencies. Upsert-only would leave leftover rows pointing at a retired model, and control-plane uninstall then 409s. Designer delete of a model still 409s on incoming edges; the panel drops them after the snapshot and relies on compile-of-what remains (and snapshot rollback) as the guard. Compile failure of the model being removed is ignored — a retired model such as GLIntegration hangs with CS1061 PostAsync against current Accounting, and treating that as an uninstall failure would restore it.

The regression tests are Model_json_dependencies_are_authoritative_on_apply_file / Import_all_drops_a_dependency_edge_no_longer_in_model_json in WorkspaceModelDependencyTests, and Bundle_import_drops_a_dependency_no_longer_declared in ModelBundleTests.

Identity keys#

Matching is NEVER positional — the order inside a bundle is arbitrary almost everywhere. Every kind of object has its own key:

Object Key
A dictionary field, a header field, a dimension, a resource FieldName
A document status StatusValue
A document subtype SubtypeValue
Everything else (a dictionary, a register, a document, a script, a menu item, an EDT, an enumeration, a text resource, a job) Name

The report: ImportDiffReport#

A contract common to every import path (a model bundle, Data Transfer, the workspace sync), so that one viewer (frontend/src/kit/ImportDiffView.tsx) can draw any of them.

ImportDiffReport { applied, scopes[], errors[], warnings[], missingDependencies[], totals }
  ImportDiffScope { key, title, kind, counts, entries[], issues[] }
    ImportDiffEntry { key, parentKey, kind, name, path, change, reason, properties[], code? }
  • scope — the unit of origin: a bundle within a set, a file within the sync, an entity within Data Transfer. Without it, a report over a set is an anonymous pile.
  • counts is always there; entries may be empty — then the viewer draws a summary line with nothing to expand. The producer hands over the detail it actually has, and the contract does not degenerate into a lowest common denominator.
  • parentKey gives a tree (a field → its dictionary). A parent from another scope (the owner of an extending field) is not found — the entry becomes a root.
  • code — only on objects that have a code body, and only in the PREVIEW: the answer to an apply is a confirmation, not a review. There is a budget (~4 MB); beyond it the bodies are dropped, truncated = true, and the entry counts stay correct.

change#

Value Meaning
Add not in the database — the import will create it
Update it is there, it is ours, and at least one transferable property differs
Unchanged it is there, it is ours, identical — we do not write at all
Skip it is there but belongs to another model — a refusal, not an overwrite
Orphan it is in the database under this model, and absent from the bundle
Error it could not be imported, the reason is in reason

Unchanged is essential. An importer that reports an "update" for every existing object without a single comparison gives a preview that is formally truthful and practically useless: re-importing an unchanged bundle would highlight everything in sight. The operator's question is "what will change", and only a comparison can answer it.

ChangeSet: comparing and writing are one statement#

A property is named once:

var cs = new ChangeSet();
cs.Set("Caption", existing.Caption, f.Caption, v => existing.Caption = v);
cs.SetBody(existing.Code, sc.Code, v => existing.Code = v);   // the code body → ImportCodeDiff
await ApplyChangesAsync(db, result, apply, cs, "Field", path,
    () => existing.ModifiedDateTime = DateTime.UtcNow, ct, parentKey);

A property named here is necessarily both compared and written — the two cannot drift apart. Writing without comparing is a preview that starts to lie; comparing without writing is an update that silently loses a column. Nothing is written before Apply(), so a dry run builds the same list of changes without touching the database. The write action is handed to only the properties that changed: an unchanged row is not reassigned, not saved, and does not get a new ModifiedDateTime.

What MUST NOT be in the comparison#

A bundle is a lossy projection. Reflection over an entity's properties (as in WorkspaceDesign.CopyDesign, where it is appropriate, because the file is a complete serialization) would produce false differences here. Only what the bundle really carries gets into ChangeSet:

The trap How it is resolved
On an enumeration value, Caption ↔ the database's Description swap places on export we compare against Description; the database's own Caption takes no part
On a table part, Description ↔ DeveloperComments the same renaming is accounted for
OtherKey, AutoLoad, CascadeDelete constants on both sides — absent
IsOperational (a resource), AllowChange (a status) hard-coded at creation — absent
MetaDocumentStatus.Name there is no slot in the bundle; it is written from StatusName and compared against it
The Caption of a dictionary/document/register shell there is no slot — otherwise an eternal "bundle: null"
MetaId, ModelId, LayerId, the timestamps always regenerated

The regression test for this is Phantom_properties_never_appear_as_changes in src/ZuloOne.Tests/ModelBundleTests.cs.

How far a dry run can be trusted#

The preview and the real import must describe the same work. If the dry run skips children with if (!apply) continue;, a document appears by name only and a register's dimensions and resources stay invisible.

The future row is registered in the name map in BOTH modes; only the write to the database goes under apply. The temporary MetaId matches no saved row, so the children's existence checks honestly find nothing and the children report as Add.

The same name maps prevent false errors: a menu item aiming at a dictionary from this bundle, and the owner of an extension from the same place, resolve through the maps rather than a direct query to the database.

Dry_run_and_apply_report_the_same_objects is the gate: a dry run and an apply against the same database state must yield matching sets.

The order of the import steps#

The shells come before whatever references them:

  1. dependencies → 2. enumerations → 3. dictionary shells → 4. EDTs →
  2. document shells → 6. the dictionaries' contents → 7. registers (dimensions may reference a document type from step 5) → 8. the documents' contents → 9. extensions of objects owned by other models → 10. standalone scripts →
  3. text resources → 12. jobs (they arrive disabled) →
  4. the menu (multi-pass: an item waits for its parent).

Step 5 sits exactly here for a reason, not as a cosmetic choice: a register dimension referencing a document type from this same bundle would get null silently when the map was built later — on a real import, not only in the preview.

Long installs and compiles run in the background#

The control plane reaches a tenant through its public hostname, and so through Cloudflare. Cloudflare drops a request after about 100 seconds with a 524. Installing the product tree and compiling every model takes about a minute on an idle development machine, and longer on a busy app host — past Cloudflare's limit. A synchronous call then looks like «install failed» while the tenant is still running, or has already finished.

Both endpoints accept ?async=true:

Call Answer
POST /api/metadata/models/install-tree?async=true 202 { operationId, statusUrl } right after the archive is unpacked
POST /api/metadata/models/compile?async=true 202 { operationId, statusUrl } at once
POST /api/metadata/tests/{id}/run?async=true, POST /api/metadata/tests/run-all?async=true 202 { operationId, statusUrl } at once; the run is stored in tests/runs when it ends, however long it takes
GET /api/metadata/models/operations/{id} { status: running | done | failed, statusCode, result }. result is the body the synchronous call would have answered. 404 means an unknown id, or a tenant that restarted since.
POST /api/metadata/models/operations/{id}/cancel 202 when a running operation was asked to stop, 404 when it is unknown or finished

The work runs in its own DI scope in BackgroundOperations, a singleton held in memory. A restart loses the operation and its work together, and the poller reads 404. Finished entries are kept for an hour. Without the flag, both calls stay synchronous, as before.

That background run is a different request, not a different process. Roslyn and a workspace export-all still share the tenant's CPUs and the ThreadPool Kestrel uses. While an install, a compile-all, or a test run is in flight the workspace live-sync stands down (RuntimeHeavyWork): Meta* saves would otherwise trigger an export of every file next to the compiler, which is what makes the web UI stop answering on a small tenant. One export runs when the work ends. Script emit is sequential (concurrentBuild: false) so HTTP keeps ThreadPool workers.

The panel (TenantApiClient in the control plane) sends ?async=true and polls every 5 seconds until the operation finishes or the job's timeout runs out. An older Core ignores the flag and answers synchronously, which the panel still accepts.

The in-app test runner works the same way: it starts every run with ?async=true and polls every 2 seconds, and Stop calls cancel. A synchronous run longer than the proxy's limit is cut off with the request; the cancelled request cancels the run, and nothing is stored.

Building a model without a ZuloOne#

A bundle is how a model is delivered. It is not how a model has to be built. A customer owns their own models and extends ours. Without a Build SDK the only way to compile theirs is to stand up a ZuloOne, import the whole tree and let the runtime compile it — so every customer CI would need our source to build their code, and a type error would cost the ten minutes a stand takes instead of the seconds a compile takes.

Where it is in the product#

Ribbon → Configuration → Models, open a model, press Build SDK. The panel leads with the compile scope rather than with the download button, because the scope is the thing people get wrong — a model sees itself, its declared dependencies and the shared catalog, and nothing else.

Build SDK panel for the Inventory model Build SDK for Inventory: the compile scope as badges, the dependency floors a package would declare, and three packs — whole closure, this model only, script framework.

Three downloads, because they answer three different questions. Reference pack is everything a build of this model needs in one folder. This model only is the shape a NuGet package carries. Script framework is the platform half, which is the same for every model on the stand.

The same thing over HTTP, for a CI that has no browser:

GET /api/sdk/model/{name} answers with a zip of reference assemblies for that model's dependency scope:

ref/ZuloOne.Runtime.Generated.Inventory.dll   entity types, generated from metadata
ref/ZuloOne.Services.Contracts.Inventory.dll  the I<Name> interfaces, signatures only
ref/ZuloOne.Runtime.dll  ZuloOne.Shared.dll …  the script framework
ZuloOne.GlobalUsings.cs                        the usings the runtime injects

Four routes, and the last three exist so the packaging below can be built out of the same images rather than a second pipeline:

Route Answers
GET /api/sdk/model/{name} the whole closure: the model, its dependencies, the framework
GET /api/sdk/model/{name}?scope=own that model's images only — the shape a package carries
GET /api/sdk/framework the platform assemblies and the usings, once for the stand
GET /api/sdk/model/{name}/manifest what a download would contain, plus the model's dependencies and each edge's minVersion

Nothing new is being built here. The platform already compiles one assembly per model in dependency order, and a script already compiles against the ASSEMBLIES of its declared dependencies rather than anybody's source — RuntimeCompiler.AddGeneratedEntityReferences, bounded by the modelScope that ModelGraphService.GetScriptAssemblyScopeAsync computes. The images are kept per model and already shipped to agents. This endpoint writes the same images where dotnet build can read them.

Two things leave, and neither is business logic. Entity types come from metadata — dictionaries, documents, registers, table parts — and contain no handler, command or posting rule. Service contracts are signatures. Our script source is not in the pack and does not need to be: a chain of command resolves at runtime by type, not at compile time.

The boundary the runtime enforces is the boundary the pack ships. A model sees itself, its declared dependencies and the shared catalog — asked for Inventory, the pack carries Accounting, Common, Core, Organization and Tax, and a type from Learning fails CS0246 in the customer's build exactly as it would be refused at runtime. Measured both ways.

<ItemGroup>
  <Compile Include="ZuloOne.GlobalUsings.cs" />
  <Reference Include="ref\*.dll" Private="false" />
</ItemGroup>

Omit the usings file and the build fails naming types the script never spelled — TypedDictionaryEventHandler, EventResult, ExtensionOf. The runtime spells them for a script; a csproj has to be told.

What this does not replace. A compiler checks types. It cannot check that a register analytic is spelled the way the register spells it, that a global constant exists, or that a subtype value is real — those travel as strings (RegisterMovementSpec("StockBalance"), GlobalConstants.…, document.Subtype = "Issued"). A customer keeps two gates: dotnet build on every commit, and a stand run before delivery. This makes the fast one fast; it does not make the slow one optional.

The same thing as NuGet packages#

A zip is enough for one person on one machine. A customer's CI needs a feed: something that restores by version, resolves the dependency graph for them, and updates when we release. The packer that produces it is .github/scripts/pack_model_nugets.py in the workspace repository, and it reads the endpoints above off a stand that has compiled.

It writes two kinds of package, and the split is not tidiness:

Package Version Carries
ZuloOne.ScriptFramework the platform's ZuloOne.Runtime, .Shared, .Lib, .Core, and ZuloOne.GlobalUsings.cs
ZuloOne.Model.<Name> the model's modelVersion that model's generated types and I<Name> contracts — nothing wider

Fold the framework into every model package instead, and sixteen models ship sixteen copies of ZuloOne.Runtime; NuGet picks one, and the other fifteen models' types then resolve against a different build without saying so.

A model package's NuGet dependencies are the model's own dependencies, each floored at the minVersion that edge declares — read from the stand, not from a model.json on someone's disk, because the stand is the authority on what is installed. So a customer writes one line:

<ItemGroup>
  <PackageReference Include="ZuloOne.Model.Inventory" Version="1.36.0" />
</ItemGroup>

and restore brings Accounting, Common, Organization, Tax and the framework with it. The usings arrive too, through a build/ZuloOne.ScriptFramework.props that adds ZuloOne.GlobalUsings.cs to the consumer's Compile — a package cannot hand a loose .cs file to a build any other way. They travel with the framework rather than with each model on purpose: the text is identical for every model, and a second copy is a duplicate-definition error.

A handler with [ExtensionOf("Item")] over TypedDictionaryEventHandler<Item> calling next(...) builds green from that one PackageReference alone, and a Learner from Learning — not an Inventory dependency — still fails CS0246.

Where they come from. The workspace CI packs them right after the business suite, while the stand is still up and still compiled, and publishes them to GitHub Packages on a release tag only. The reason is semver, not caution: a model package's version is its modelVersion, which changes when the model changes and not when CI runs, so every push would re-publish the same version. Builds that are not releases upload the packages as a model-sdk-<version> artifact instead.

Packing is also a gate in its own right. Every modelVersion must parse as semver and every dependency floor must parse, or nothing packs — a failure that is cheap in our CI and expensive in a customer's restore.

Two consequences worth knowing before they surprise someone:

  • A version floor that was never published gives NU1603. Inventory declares Common >= 1.15.0; if the feed has only 1.18.0, NuGet resolves up and warns. That is honest — it says the floor is older than anything we ever shipped as a package — and it clears as releases accumulate.
  • A model that declares no objects gets no package. It has no images of its own, the download answers 409, and the packer skips it by name. A menu-only model is the usual case.

Known sharp edges#

  • Menu items with the same name. The parent is looked up by name among all the items that belong to no template, and Dictionaries / Registers / Documents / Settings are the conventional names of the subgroups every model has. On a collision an arbitrary one is taken, and a repeat import can reattach the item under another model's group. In the diff this is visible as a change of the Parent property.
  • Two scripts with one name on one owner. The export will dump both, the import will match both against one row — one of them is silently lost. This is a data defect, but the report shows it (otherwise both would look like "updated").
  • A set is not atomic in its composition: the transaction is one for the whole set, but the import stops at the first broken bundle.

Where next#

Was this page helpful?