Skip to content

Upgrade Transparency

When you upgrade a customer’s install to a new product release, Fullfinity migrates its own data and standard modules for you (see Migrations & Upgrades — the schema ledger carries renames, drops, and retypes and replays them atomically). What an upgrade cannot do is reach into the modules you wrote and fix the places they reference something a release moved. If a release renamed SaleOrder.total to grand_total, a custom view of yours that still names total is now stale.

Fullfinity does not try to prevent that break — you are free to rename fields, and so are we. Instead it makes the break transparent, precisely located, and fixable by you, locally:

  • preview --db — a read-only pre-flight you run before upgrading. It lists every reference in your custom modules that no longer resolves against the new base.
  • preview — the human-readable list of every reference-affecting change a release shipped, that each report line links back to.

Point the install at the new product code, then, before running the upgrade:

Terminal window
./fullfinity-server -c config.yaml preview --db <db>

This changes nothing — it only reads. It exits 0 when your custom modules are clear to upgrade, or 1 with a punch-list when something needs fixing:

# Upgrade reference report
2 custom-module reference(s) need attention before upgrade:
## acme_sales
- acme_sales_order_form — field references field 'total' which no longer exists on SaleOrder → renamed to grand_total (#42)
## acme_hr
- acme_payslip_form — field references field 'legacy_code' which no longer exists on Employee → removed (#39)

Fix the flagged views/templates in your modules, re-run preview --db until it is clean, then upgrade normally:

Terminal window
./fullfinity-server -c config.yaml -db <db> -u all

Because you fixed the references first, the upgrade composes your modules cleanly and completes in one atomic transaction.

If you run -u all straight away and a custom module references something the release moved, the upgrade rolls back atomically — nothing is changed — and tells you to run preview --db for the full list. You never end up on a half-applied database or with silently-broken screens in production; you either fix first and upgrade clean, or the upgrade declines and points you at the report.

Every place you declare a reference to a model or field — anywhere it’s stored as data, not just in views — is audited against the new base:

  • View field references — a field / linkButton / statusbar / browsepanel bound to a model field that no longer exists.
  • View visible / readonly / required / filter conditions — a Q(...) expression referencing a renamed or removed field (including across relations).
  • View inheritance targets — an inheriting view whose anchor/field target no longer resolves in the base it patches.
  • Record-rule domains, window/portal action filters — a Q(...) over a model whose field was renamed or removed.
  • Automation, approval, campaign filters — a visual filter-group condition on a field that no longer exists.
  • Config-gated visibility — a menu visible_setting referencing a Configuration setting that was renamed.
  • Field-path settings — an approver field, a contact field, an order-by, a “field to set” pointing at a field that’s gone.
  • Model references — a stored model name (e.g. a record rule’s model) that no longer exists after a model rename/removal.
  • Report-template patches — a template extension whose #id/[data-anchor] target is gone from the base template.

Each finding is cross-linked to the changelog by its change #id, so “field not found” becomes “renamed to grand_total (#42)”.

The same checks run at save time on the records themselves: creating or importing one of these records with a stale reference raises immediately. That’s what makes the upgrade rollback above work — a custom module whose reference broke can’t re-import silently.

preview --db sees what is declared. It cannot see custom Python — a record.total attribute read or a method call in your module’s code is invisible to a static pass. For those, read the changelog and grep your module code for the old names. Report-template bodies are opaque text; Fullfinity scans them best-effort for attribute reads (doc.total) whose name exactly matches something the release renamed or removed, and marks those advisory (verify manually). The report states these limits inline.

Writing report templates so they stay validatable

Section titled “Writing report templates so they stay validatable”

A report template renders against one bound model (named by the report action). So that a field a report reads can be checked against the schema, every name in a report body resolves to exactly one place:

You writeIt resolves toRenamed field caught?
a bare {{ total }}, {{ contact.email }}, {% for l in lines %}{{ l.amount }}a field on the report’s modelyes
{{ config.inventory_use_lots }}a Configuration settingyes
{{ report.tax_lines }}, {% for c in report.components %}your report action’s data_method outputno — computed data, not a field
{{ report_data.x }}a wizard/model-less report’s inputno — computed data
{{ company.name }}, {{ paper_format.margin_top }}, {{ t('…') }}, {{ qr(…) }}render globalscompany/paper_format: yes

The rules that follow from this:

  • A bare name is always a field on the report’s model. Don’t read a calculated value as a bare name — put it behind report. (return it from the action’s data_method).
  • Gate on a setting with config.<setting>, not a data_method boolean. config.* is validated against the Configuration model, so renaming a setting is caught in reports too.
  • A data_method returns only computed extras, read as report.<key>. Everything the record already has is a bare field.

A report with no bound model (a wizard/aggregate report) reads its data from report_data.* and has no bare-field checks — there’s no model schema to resolve against, and a model-field rename can’t break a report that references no model.

Writing email templates so they stay validatable

Section titled “Writing email templates so they stay validatable”

An email template is the same kind of surface: it renders against one bound model (the model named on the template), so its content and subject are both validated against that model’s schema. The globals differ from a report — an email is not a paginated document, so it gets no report chrome (config, paper_format, report, report_data). Instead every email render is handed two universal globals:

You writeIt resolves toRenamed field caught?
a bare {{ name }}, {{ contact.email }}, {% for l in lines %}{{ l.amount }}a field on the template’s modelyes
{{ company.name }}the sending companyyes (validated against Company)
{{ base_url }}the install’s public URL, for links back to the app/portaln/a — a scalar global
{{ t('…') }}, `{{ … | date }}`render callables/filtersn/a
  • Never hand-assemble a Jinja environment to inject extra context — render through the template’s own render path so base_url/company come from the one globals surface. A caller that needs to override a global (e.g. a storefront passing its own domain as base_url) does so with extra context, not a bespoke renderer, so the body stays validatable by construction.
  • A template with no bound model (a pure notification) has no bare-field checks — only its company/base_url chains are validated.

Both surfaces are enforced the same three ways: at save time (creating/updating a report action or email template with a stale body raises, so install and -u reject it and roll back), at preview --db (the install-specific punch-list lists every stale body), and by the render/body smoke tests that render every report and validate every email body against demo data.

Terminal window
./fullfinity-server preview # to stdout
./fullfinity-server preview --out CHANGELOG.md # to a file

It renders every reference-affecting ledger change — field and model renames and removals — grouped by release (derived from the repository’s release tags when present; the changes since the newest tag appear under Unreleased), with a separate section for data-only migrations that carry no reference impact. No database is needed.

The static reference gate (check --only refs)

Section titled “The static reference gate (check --only refs)”

Every stored reference is validated at three points, tightest-loop first:

  1. Save time — creating/updating a record with a dangling reference raises, so install and -u reject it and roll back. This is what makes an upgrade refuse a stale reference.
  2. check --only refs — a no-database build-time gate that composes the model surface from code and validates every shipped reference against it. It runs as part of check (CI + .githooks/pre-commit), so a rename that orphans a reference is caught at commit time — before any install.
  3. preview --db <db> — the install-specific pre-flight that audits a partner’s custom modules against a new base (see above).
Terminal window
./fullfinity-server -c config.yaml check --only refs # exit 1 on any dangling reference
./fullfinity-server -c config.yaml check --only refs --module sales # scope to one module

The gate covers the same surfaces the save hooks do — Q-domains, JSON filter-groups, visible_setting, field-paths, model-names, and report/email Jinja bodies — resolving each reference’s model the way the record does (a model: in YAML is a plain class name). It needs no database and no install: it composes the field surface straight from the module source.

The build-time gates are no-database and deterministic. One verb — check — runs all of them (schema, views, references, Valkey-access, manifests) and reports every failure together, so CI and pre-commit block a merge that breaks the schema, a view’s stability, a stored reference, Valkey isolation, or a module manifest:

Terminal window
./fullfinity-server -c config.yaml check || exit 1 # all gates; --only <area> for one

preview --db exits non-zero when it finds anything, so a partner pipeline can additionally gate an automated upgrade on a clean pre-flight against the target install:

Terminal window
./fullfinity-server -c config.yaml preview --db <db> || exit 1 # block the upgrade until clean