Files
shopdb-flask/docs/adr/ADR-014-schema-lean-per-site.md
cproudlock c386e211df ADR-014 Phase 2: flask plugin prune-schema for lean per-site DBs
A lean site still gets every plugin's tables from the shared core Alembic
baseline. prune-schema drops the tables of plugins not installed on this
site, leaving core + chosen-plugin tables, with no edit to any released
migration (the relocate-into-plugin-baselines alternative would mean
rewriting ~15 released core migrations for a cosmetic gain - see ADR-014).

- shopdb/plugins/cli.py: prune-schema command. Dry-run by default; --yes to
  execute; refuses non-empty tables without --force. Drops by table name (no
  plugin import) so it works on a lean image. MySQL: private AUTOCOMMIT engine
  (db.engine's pooled connections sit idle-in-transaction in a CLI context and
  would deadlock the DROP on a metadata lock). SQLite: db.engine, restoring the
  prior foreign_keys pragma so the StaticPool connection is not left changed.
- tests/test_plugin_prune_schema.py: drop-only-not-installed, full no-op,
  refuse-non-empty, force-drops-non-empty.
- docs/DEPLOY.md: lean provisioning step after upgrade-all.
- ADR-014 ACCEPTED; index updated.

Verified on MySQL: full install then prune = no-op (86 tables); lean install
(machines+printers) then prune drops the other 19 plugin tables; second run
no-op. Full suite 1077 passed.
2026-07-19 11:39:46 -04:00

7.3 KiB

ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables)

  • Status: ACCEPTED
  • Date: 2026-07-19
  • Deciders: cproudlock
  • Relates to: ADR-008 (per-plugin migration ownership), ADR-013 (plugin catalog + lean per-site builds), ADR-001 (asset model)

Context

ADR-013 delivered lean per-site builds for plugin CODE (backend tree + frontend bundle carry only chosen plugins). One residual was left, explicitly deferred: the DATABASE. The core Alembic baseline (68b3947ae14f) creates EVERY table, including ~30 plugin-owned tables (PLUGIN_TABLE_OWNERS). Each plugin's own baseline is a stamp-only no-op (the core chain already made its tables, per ADR-008). So a lean site that omits a plugin still creates that plugin's tables, empty and unused.

The deferral cited a blocker: "the computers-owned installedapps table FKs machines.machineid while computers declares no dependency on machines; reversing the cutover would introduce undeclared hard deps or drop FKs; neither is decided." Investigation refined this:

The cross-boundary foreign keys into the machines plugin table are ALL held by DEAD legacy tables/columns that predate the asset model (ADR-001) and the per-plugin cutover (ADR-008), and are queried nowhere in the codebase:

  • machinerelationships (child/parentmachineid -> machines) - superseded by assetrelationships. No model, no query.
  • printerdata (machineid -> machines) - the pre-cutover printers table, superseded by the printers plugin. No model, no query.
  • installedapps (machineid -> machines) - a standalone machine-app link table; the live relationship is computerinstalledapps (FK to computers only). The standalone table has no model, no query.
  • communications.machineid (-> machines) - a legacy column on the core communications table (which is now assetid-based). Not read anywhere.

No LIVE plugin table hard-FKs another plugin's table. computerinstalledapps FKs only computers.computerid (intra-plugin). So the blocker is dead cruft, not live design.

Decision

Two phases, both leaving existing databases correct.

Phase 1: retire the dead cross-boundary cruft - ALREADY DONE

Investigation found this is already accomplished by existing migrations: 7a01_adr001_position_contract and 7c01_drop_legacy_machine drop machinerelationships, printerdata, installedapps, and communications.machineid (with its FK). The current schema (verified on the dev database) has none of them. So the cross-plugin FK blocker ADR-013 cited no longer exists in the live schema - only in the baseline's transient create-then-later-drop. No new migration is needed for Phase 1.

Precedent: ADR-001 dropped a cross-plugin FK the same way (usbcheckouts.machineid -> machines became a soft sentinel).

Enabling change (executed now): idempotent create_plugin_tables

shopdb/plugins/alembic_template.py:create_plugin_tables now skips any table that already exists (inspects the bind first) instead of raising. This is the mechanism Phase 2 needs: a plugin anchor can create its tables on a fresh lean install AND be a safe no-op on an existing database that already has them from the pre-cutover core baseline. Correct and inert regardless of Phase 2 (no current caller creates against a populated schema). Verified against the plugin-migration suite.

Phase 2 (executed): prune not-installed plugin tables after upgrade

Two mechanisms were weighed to make a lean site's database carry only core + chosen-plugin tables:

  • Relocate (rejected): pull every plugin-table create/alter out of the core chain into the plugin baselines, so the core chain never creates a not-installed plugin's table. Measurement killed this: plugin tables are created and altered across ~15 released core migrations (baseline plus 7c04, 7d05, 7d08, 7d13, 7d15, 7d16, 7d17, ...), not just the baseline. Because the whole core chain runs before any plugin chain, removing a table's create from core while a later core migration still alters it breaks FULL installs too, so relocation means surgically rewriting ~15 released migrations - the highest blast radius in the project - for a purely cosmetic gain (the omitted tables are empty and the lean CODE build already never loads the plugin).

  • Prune-after-upgrade (chosen): leave the entire core chain untouched. Add flask plugin prune-schema, which drops the tables of every plugin in PLUGIN_TABLE_OWNERS that is not installed on this site. Run once at deploy, after flask db upgrade and flask plugin upgrade-all. Same end state (core + chosen tables) with near-zero blast radius: no released migration is edited, and an existing full site is unaffected because it never runs the command.

prune-schema drops by table name (no plugin-code import), so it works on a lean image where the omitted plugin's directory is absent. It is a dry-run by default and refuses to drop a table that holds rows unless --force, so a misfire on a populated site cannot silently delete data. Because the core chain seeds a few plugin reference tables (e.g. 7d05 inserts default access protocols), initial lean provisioning uses --force - at that point the tables hold only migration-seeded defaults, before any site data exists.

The idempotent create_plugin_tables (enabling change above) is what lets a lean site later ADD an omitted plugin: its anchor recreates the pruned tables.

Verified end to end on MySQL: fresh full install (86 tables) then prune is a no-op; fresh lean install (machines + printers) then prune drops the other 19 plugin tables, leaving core + chosen; second prune is a no-op; the non-empty guard refuses without --force. Four SQLite regression tests pin the behavior (tests/test_plugin_prune_schema.py), running in the backend CI job via the real CLI runner: drop-only-not-installed, full-site no-op, refuse-non-empty, and force-drops-non-empty.

Consequences

Positive

  • A lean site's database contains only core + chosen-plugin tables, with no edit to any released migration (near-zero blast radius).
  • The cross-plugin FK blocker ADR-013 cited is gone (dead cruft, dropped by existing migrations), so plugin schemas are already FK-independent.
  • Adding an omitted plugin to a lean site later just works: the idempotent anchor recreates its tables.

Negative / risk

  • prune-schema is destructive by nature; the row-count guard + dry-run default + required --force for non-empty tables contain that. It is a deploy-time provisioning step, not something to run casually on a live populated site.
  • A lean fresh install still transiently creates then drops the omitted plugins' tables (the core chain builds them, prune removes them). Harmless and one-time at provisioning; the trade for not touching the released baseline.

Implementation

  • Phase 1: nothing to do - the dead cross-boundary FK objects were already dropped by existing migrations 7a01_adr001_position_contract and 7c01_drop_legacy_machine; verified absent on a fresh full MySQL upgrade.
  • Enabling change: create_plugin_tables made idempotent (shopdb/plugins/alembic_template.py).
  • Phase 2: flask plugin prune-schema (shopdb/plugins/cli.py), dry-run by default, --yes to execute, --force for non-empty tables. Deploy order: flask db upgrade -> flask plugin upgrade-all -> flask plugin prune-schema --yes --force. Regression tests in tests/test_plugin_prune_schema.py (run in the backend CI job).