📦 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.
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
ModelIdequals 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.countsis always there;entriesmay 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.parentKeygives 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:
- dependencies → 2. enumerations → 3. dictionary shells → 4. EDTs →
- 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 →
- text resources → 12. jobs (they arrive disabled) →
- 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 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 declaresCommon >= 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/Settingsare 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 theParentproperty. - 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.