Skip to content

Modules (NXM)

Data and upgrades

Where the records of an NXM module live, how package data and sample data get installed, what a new version may change, and what happens to your data during an update, a deactivation or an uninstall.

For
DevelopersAdmins
Time
15 min

An NXM module is code nobody in the tenant wrote, working on data the tenant cannot afford to lose. So nexxus treats the data as the platform’s responsibility. The package describes its business objects, and the platform stores, checks, indexes and migrates the records. A new version may only change what can be changed safely, every update runs in one transaction, and an uninstall asks what should happen to the data before anything goes.

This page follows a record through its life: how it is stored, how a package fills it at install time, how an update reshapes it and what remains when the module leaves.

nexxus keeps the records of every package in one record store. A record’s fields are saved as one JSON document. The values of fields that a package marks as indexed or unique, and every reference, are copied into separate typed lookup tables in the same transaction. Installing a package therefore never changes the database schema: there are no tables per package and no per-tenant DDL.

That design has consequences you will notice as a developer:

  • Filter and sort only on indexed fields. A field is indexed when it says indexed: true, when it is unique, when it is a sequence or when it is a ref. A filter on any other field is refused with Nach '<feld>' lässt sich nicht filtern: das Feld ist nicht indexed. (you cannot filter by this field: it is not indexed).
  • Reporting works on indexed fields only, too. Every entity is a source for tiles, notification rules and the AI Assistant, see Sources from packages. A field that should show up there needs indexed: true.
  • Free-text search (?q= in the API, the search box in the list) matches the entity’s searchFields, case-insensitively. With a reference among the searchFields it also finds the record by the current name of what the reference points at, and only within what the person may see anyway.
  • Unique values are compared trimmed and case-insensitively, so 12/26 and 12/26 collide.
  • Deleting is a soft delete. The record disappears from lists and the API, the row stays.
  • Package records do not show up in the global search in this version.

This is a case document (aktendokument) of the CuriaOS case study as the API and the view SDK return it. The values come from the package’s sample data; ids, dates and the person are illustrative:

{
"id": 12,
"module": "curiaos.kanzlei",
"entity": "aktendokument",
"title": "Vollmacht Sabine Vogl",
"data": {
"akte": 4,
"dokument": 88,
"titel": "Vollmacht Sabine Vogl",
"typ": "vollmacht",
"eingegangen_am": "2026-09-14",
"status": "geprueft"
},
"refs": {
"akte": { "id": 4, "label": "2026/0184" },
"dokument": { "id": 88, "label": "2026-0184_vollmacht.pdf" }
},
"version": 3,
"createdAt": "2026-10-08T07:12:44Z",
"updatedAt": "2026-10-08T09:30:02Z",
"createdBy": { "id": 7, "name": "Jana Brandt" },
"updatedBy": { "id": 7, "name": "Jana Brandt" }
}
  • title is the value of the entity’s titleField, or #<id> when there is none.
  • refs carries a readable label for every reference, so a view does not have to look each one up.
  • data holds the canonical value of each field: dates as YYYY-MM-DD, date-times as UTC ISO-8601, references as numbers.

Every record carries a version, starting at 0. When a client sends the version it read along with a change, nexxus refuses the write if someone changed the record in between. The API answers with HTTP 409, code CONFLICT and the message „Der Datensatz wurde inzwischen geändert (Version 4, gesendet 3). Bitte neu laden.“ (the record was changed in the meantime, please reload). The record page in the app shows “Someone changed this record in the meantime. Reload it and enter your change again.” with the button Reload.

Custom views should always send the version they read (nx.records.update(entity, id, patch, {version})), so a concurrent change comes back as CONFLICT and is shown instead of silently overwritten.

Two field options hand a value over to the platform:

  • readonly fields can be set by actions, migration steps, package data files and module services, never by a person’s form or by a view’s records.update. nexxus rejects a person’s write only when the value actually changes, so a form that sends the whole record back unchanged passes. CuriaOS uses this for the status of a deadline: only the four-eyes action may set it to “Done”.
  • sequence fields get the next number when a record is created and the field is empty. The format is literal text with {yyyy}, {yy} and exactly one {n} or {n:<digits>}, for example {yyyy}/{n:4} for 2026/0184. The counter runs per tenant, module, entity and field, and per year when the format names the year. A value set explicitly (from a data file or a module service) raises the counter, so the next number continues above it. Editing a record never assigns a number. A sequence field is unique and readonly by definition.

A package can bring records with it, in two folders:

FolderInstalledTypical content
data/<entity>.jsonalways, at install and again at every updaterecords that are part of the product: a catalogue of requirements, standard categories
demo/<entity>.jsonat the first install, when the admin ticks Create sample data; on an update, the rows the new version adds, when the admin ticks Create new sample datainvented sample records to try the module out
demo/files/<name>with the sample datafiles (PDFs) that sample records reference

Each file is a JSON array. Every row has a package-local key and an object data with the field values. Rows refer to each other and to core data through placeholders that the installer resolves. Here is a real row of CuriaOS sample data:

{
"key": "d-0184-vollmacht",
"data": {
"akte": { "$key": "akte:2026-0184" },
"dokument": { "$file": "files/2026-0184_vollmacht.pdf" },
"titel": "Vollmacht Sabine Vogl",
"typ": "vollmacht",
"eingegangen_am": { "$date": "-17wd" },
"beteiligter": { "$key": "beteiligung:b-0184-vogl" },
"status": "geprueft"
}
}
PlaceholderResolves to
{"$key": "<entity>:<key>"}the id of the record created from that row of this package
{"$key": "<entity>:<key>", "field": "<field>"}that record’s field value instead of its id
{"$file": "files/<name>.pdf"}a document imported into a folder of the files area named after the module’s German name plus „(Beispieldaten)“
{"$partner": "<name>"}the tenant’s partner with that name (case-insensitive); if it does not exist, nexxus creates it with a number and a default branch, as the partner form does, so it can be invoiced
{"$partner": "<name>", "email": "<address>"}the same; a partner nexxus creates gets that e-mail address (see below)
{"$project": "<designation>", "partner": "<name>"}the tenant’s project with that designation; if it does not exist, nexxus creates it, for that partner when partner is given
{"$contact": "<first> <last>", "partner": "<name>", "email": "<address>"}the contact of that name at that partner; if it does not exist, nexxus creates it with that address (and an optional "position"). partner and email are required
{"$currentUser": true}the person who installs the package
{"$date": "<steps>"}a date relative to the install day

Sample data that is meant to be sent, a letter to the sample insurer for example, needs a contact with an e-mail address: {"$partner": "Muster Rechtsschutz eG", "email": "rsv@muster-rechtsschutz.example"}. Only addresses under the reserved top-level domains .example, .test and .invalid are allowed, so a demo can never mail a real person. nexxus refuses any other address at upload, and anything that is no address, with a message such as „E-Mail ‘rsv@muster-rechtsschutz.de’ ist nicht erlaubt: Kontakte aus Paketdaten bekommen nur Adressen unter .example, .test oder .invalid, damit keine echte Person erreicht wird“ (only addresses under these domains, so no real person is reached). It checks again before the install creates anything. A partner that already exists under that name is used as it is and keeps its own address.

Sample data that hard-codes dates looks wrong a week later: deadlines are overdue, appointments lie in the past. $date counts from the day of the installation (in the Europe/Berlin time zone) instead. The value is a chain of steps applied from left to right. Each step is a sign, a number and a unit:

UnitMeaningExample
ddays"0d" is the install day
wweeks"+1w"
mmonths"-1m"
wdworking days: Monday to Friday, without the nationwide public holidays; 24 and 31 December count as working days"+2wd"

Steps combine: "+2wd-14d" means two working days ahead, then 14 days back. On a datetime field, add the sibling "time": "HH:MM" (default 09:00). $date works only on date and datetime fields; anything else fails the install.

A $key finds only a row that the installer has already created. It creates them in this order:

  1. data/ before demo/. A sample row may point at a row of data/. The other way round does not work, because sample data is optional.
  2. Within each folder, one entity after the other. An entity whose ref fields point at another entity of the package comes after that entity. Where the references leave the choice open, the file names decide in alphabetical order.
  3. Within a file, from the top row to the bottom one. A row that points at another row of the same entity, a parent record for example, has to stand below it.

Two entities whose ref fields point at each other cannot both go first. The installer then takes them in alphabetical order, and a $key from the earlier one to the later one fails. Keep the reference between two entities in one direction; the other side finds its records by searching on that field. Rows that an earlier version or a retained installation created count as created (see the next section).

When a $key has no target yet, the install fails as a whole with a message such as demo/aktendokument.json 'd-0184-vollmacht'.akte: $key 'akte:2026-0184' gibt es nicht (oder er steht weiter hinten). (the key does not exist, or it comes later).

The installer skips every row whose key already exists in the tenant, whether it came from an earlier version or from a retained installation. That is why data/ can be installed again on every update: only rows with new keys create records. The same rule means that changing an existing row in a later version does not change the record. Use a migration step for that.

For sample data, deleted records count too: an update does not recreate a sample record someone deleted, and it does not create a new row that points to that record with $key either.

A $key names a row of the same package only. A $key with another package’s key in front is refused at upload, because packages do not build on each other (see Package structure). Data files may set readonly and sequence fields directly. When something cannot be resolved, the install fails as a whole and the message names file, row and field, for example demo/frist.json 'f-0184-wv-regulierung'.faellig_am: '+2x' ist keine relative Angabe wie "+2wd" oder "+2wd-14d" (not a relative date).

Versions and what a new version may change

Section titled “Versions and what a new version may change”

A package version is a SemVer number like 1.1.0. Once a version has been uploaded, its content is fixed: a fix, even a typo in a view, is a new version. Two more fields of the manifest decide where a version may run:

  • platform is a version range on the nexxus platform, for example ">=0.2.0 <1.0.0".
  • requires names built-in modules with a range, for example "partners": ">=1.0.0". Such a module must exist on the instance in a matching version. A package never requires another package.

Ranges use npm syntax: comparators separated by spaces must all hold, || separates alternatives, ^ and ~ work as in npm.

nexxus checks every update against the installed version before anything happens. These are the reasons it refuses. The texts come from the server and appear in German in every language of the app:

ChangeRefusal text
The target needs another platform„Version 1.1.0 verlangt die Plattform >=1.0.0, hier läuft 0.2.0.“
The major version goes down„Die Hauptversion sinkt von 2 auf 1.“
The target is not newer„Version 1.0.0 ist nicht neuer als die installierte 1.0.0.“
An entity disappears without a new major version„Das Datenobjekt ‘notiz’ fehlt, das geht nur mit einer neuen Hauptversion.“
A field disappears without a new major version„Das Feld ‘frist.ereignis’ fehlt, das geht nur mit einer neuen Hauptversion.“
A field changes its type„Das Feld ‘frist.faellig_am’ ändert seinen Typ von date auf datetime; dafür braucht es ein neues Feld und eine Migration.“
A field changes unique„Das Feld ‘akte.aktennummer’ ändert die Eindeutigkeit (unique).“
A reference points elsewhere„Der Verweis ‘notiz.aktendokument’ zeigt jetzt auf ‘akte’ statt auf ‘aktendokument’.“
A field changes multiple„Das Feld ‘frist.zustaendig’ ändert multiple.“
A new required field has no default„Das neue Pflichtfeld ‘frist.quelle’ hat keinen Standardwert.“
A required built-in module is missing„Das Modul ‘partners’ gibt es auf dieser Plattform nicht.“
A required built-in module has another version„Das Modul ‘partners’ läuft in Version 1.0.0, verlangt ist >=2.0.0.“
The target version was uploaded before packages became self-contained and names another package„Diese Version baut auf dem Paket ‘muster.akten’ auf. Pakete bauen nicht mehr aufeinander auf; was zusammengehört, kommt als ein Paket.“

What a version may change freely: add entities, fields, views, actions, menu entries and roles; add or remove enum values; relabel anything. A type change is a new field plus a migration step that copies the values over. There is no downgrade.

When stored data has to change with a version, the manifest declares the steps under migrations, keyed by the version that introduces them:

"migrations": {
"1.1.0": {
"pre": [ { "op": "renameValue", "entity": "geraet", "field": "kategorie", "from": "telefon", "to": "smartphone" } ],
"post": [ { "op": "setDefault", "entity": "geraet", "field": "standort", "value": "buero" } ]
}
}
opParametersEffectAllowed in
setDefaultentity, field, valuefills the field where it is emptyany version
renameValueentity, field, from, toreplaces an enum value, also inside multiple listsany version
renameFieldentity, from, to (field keys)moves the value to another fieldany version
copyFieldentity, from, to (field keys)copies the value to another fieldany version
deleteFieldentity, fieldremoves the field’s value from every recordmajor versions only (x.0.0)
deleteEntityentitysoft-deletes every record of the entitymajor versions only (x.0.0)

pre or post? Steps under pre run while the old version’s descriptors are still in effect, steps under post after the switch to the new version. Rename values before the switch, fill new fields after it. For an update from version A to version B, nexxus collects the steps of every version V with A < V ≤ B: first all pre steps in version order, then all post steps in version order. A version key in migrations must not be higher than the package’s own version.

Two things to keep in mind:

  • nexxus records every applied step in a run-once ledger per tenant. A step that already ran is skipped.
  • Migration steps write their values as given. They are not checked against the field’s type or enum values, so make sure to and value are valid for the new version.

An admin starts the update on the package page (AdministrationOperationsModules, tab Packages) with Upgrade to 1.1.0. Package, install and update walks through the dialog. Behind that button the platform works in this order:

  1. Plan

    Version rules, required built-in modules, new consent items and a dry run that counts the records each step touches.

  2. Consent

    Every new scope and every new service host must be ticked by the admin.

  3. Pre steps

    Run with the old version in effect.

  4. Switch and post steps

    New version active, post steps, reindex, ledger, roles, AI Assistant skill, new package data, new sample data if ticked.

  5. Commit

    All or nothing. Services hear about it afterwards.

One transaction per tenant. If any step fails, the installation stays on the version it had.
  1. Plan. The update dialog shows the result of the plan before anything changes: under What changes the changelog entries between the two versions, under Data changes one line per migration step with its phase (“before the switch” or “after the switch”) and how many records it would touch today (“1 record”, “9 records”). If the new version brings sample rows the tenant does not have yet, the dialog offers Create new sample data with their number, unticked. Refusals appear under “This upgrade is not possible:” and block the button.
  2. Consent. If the new version wants to use data or services the admin has not agreed to yet, they appear under New consent. Without a tick on every item the package stays on the old version.
  3. The transaction. nexxus runs all pre steps, switches the installation to the new version, runs all post steps, rewrites the lookup tables of every record under the new descriptors, records the steps in the ledger, creates new roles, extends the module’s own roles, rewrites the AI Assistant skill from the new instructions and installs new rows from data/, and with the box ticked the new rows from demo/.
  4. After the commit. The platform announces the update to the module’s services.

If anything in step 3 fails, the whole transaction rolls back. The installation, the records and the roles stay exactly on the old version, and the dialog shows “Not upgraded:” followed by the server’s message, for example Die Migration auf 1.1.0 ist gescheitert, 'acme.leihgeraete' bleibt auf 1.0.0: <reason> (the migration failed, the package stays on 1.0.0).

The update dialog with dry-run counts
The dry run counts the records each migration step will change.

New roles in the manifest are created as new access roles. Roles the module created earlier get the permission keys that are new in this version (new entities, new extra permissions), and only when the manifest role already existed in the old version. Keys an admin removed from such a role stay removed. Roles the module does not own are never touched, so nobody gains rights through an update that an admin did not hand out.

Module services subscribed to record events see every change a migration step makes: one event per record and step, sent in the update transaction. A record that two steps touch is sent twice. After those record events, every service of the installation receives installation.upgraded with the previousVersion, so it can fill in what it would have computed had it been there from the start. See Module services for the event bodies.

An admin has three ways to take a module out of use. They differ in what remains:

ActionMenu, pages, AI Assistant toolsRecordsRolesAI Assistant skillService account
Deactivategone for everyonekept, invisiblekeptswitched offdisabled
Uninstall with Keep the datagonekept, invisiblekeptremoveddisabled
Uninstall with Delete the datagonesoft-deleteddeleted with their assignmentsremoveddeleted with its role
  • Deactivate is a pause. Activate brings everything back as it was.
  • Keep the data removes the module but leaves its records in place. The catalogue lists it as “Removed, data kept”, and the package page offers Install again. A new install reattaches the records and roles. If the admin installs a newer version than the retained one, the update steps between the two versions run as part of the install.
  • Delete the data soft-deletes the records, removes the module’s roles with their permissions and assignments, moves its sample folder and documents in the files area to the deleted state and clears the migration ledger. Partners created by sample data stay, because other records of the tenant may refer to them by now.

Every package can be deactivated or uninstalled on its own: no other package builds on it.

The uninstall dialog with export and the choice for the data
Typing the module key unlocks the uninstall.

The uninstall dialog offers Export data (ZIP). The file <key>-export.zip contains one <entity>.json per entity of the installed version and a module.json with key, version and export time. Each record appears as:

{
"id": 12,
"key": "d-0184-vollmacht",
"data": { "titel": "Vollmacht Sabine Vogl", "typ": "vollmacht", "status": "geprueft" },
"version": 3,
"createdAt": "2026-10-08T07:12:44Z",
"updatedAt": "2026-10-08T09:30:02Z"
}

key is the data file key for records that came from package or sample data, and null for records people created.

Worked example: Loan equipment 1.0.0 to 1.1.0

Section titled “Worked example: Loan equipment 1.0.0 to 1.1.0”

The tutorial package of Build your first module shows a complete update. Version 1.0.0 stores phones with the category value telefon. Version 1.1.0 renames that value to smartphone and adds the field standort (location) with the default buero (office). Its migrations block is exactly the one shown under Migration steps.

The sample data of 1.0.0 holds three devices, none of them a phone. On a tenant that installed 1.0.0 with that sample data and nothing else, the update dialog shows:

  • under Data changes: renameValue on geraet.kategorie, “1.1.0 · before the switch”, 0 records, and setDefault on geraet.standort, “1.1.0 · after the switch”, 3 records;
  • no section New consent, because 1.1.0 asks for no new data access;
  • no refusals: no field disappears, no type changes, and the new field has a default.

Had standort been required without a default, the dialog would refuse with „Das neue Pflichtfeld ‘geraet.standort’ hat keinen Standardwert.“ (the new required field has no default).

These messages come from the server and are German in every language of the app.

MessageWhat it means and what to do
„Version 1.1.0 ist nicht neuer als die installierte 1.1.0.“You picked the installed version. Upload a higher version number.
„Das Feld ‘frist.ereignis’ fehlt, das geht nur mit einer neuen Hauptversion.“A field was dropped in a minor version. Keep the field, or release a new major version with a deleteField step.
„Das neue Pflichtfeld ‘frist.quelle’ hat keinen Standardwert.“Give the new required field a default, or make it optional and fill it with a setDefault post step.
Zustimmung fehlt für: <items>The update request did not include every new consent item. Tick every item under New consent.
Die Migration auf 1.1.0 ist gescheitert, 'acme.leihgeraete' bleibt auf 1.0.0: <reason>A step failed and everything was rolled back. The reason at the end names the problem; fix the step in a new version.
„‘acme.leihgeraete’ ist schon installiert (Version 1.0.0); für eine neue Version gibt es das Upgrade.“The package is installed. Use the upgrade button on the package page instead of installing again.
„‘acme.leihgeraete’ ist deinstalliert; eine neue Installation hängt die Daten wieder an.“The module was uninstalled with its data kept. Use Install again.