ADR-014: schema-lean per-site (investigation + idempotent create_plugin_tables)

Cross-plugin FK blocker ADR-013 cited is already resolved: the FKs into
machines were held only by dead legacy tables (machinerelationships,
printerdata, installedapps, communications.machineid) that existing
migrations 7a01/7c01 already drop. No live plugin table hard-FKs another
plugin. Schema-lean is unblocked.

Enabling change: create_plugin_tables now skips already-existing tables
(idempotent) so a plugin anchor can create its tables on a fresh lean
install and no-op on a database that has them from the pre-cutover
baseline. The load-bearing baseline lift is staged as ADR-014 Phase 2.
This commit is contained in:
cproudlock
2026-07-19 00:36:27 -04:00
parent 5861caf78f
commit 42ca8d75c3
5 changed files with 131 additions and 6 deletions

View File

@@ -23,6 +23,7 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c
- ADR-011: Machines rename + modeltypes retyping - ACCEPTED
- ADR-012: GE-Enforce manifest ownership in shopdb - ACCEPTED
- ADR-013: Plugin catalog, curated shelf, and lean per-site builds - PROPOSED
- ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables) - PROPOSED
## Coding convention

View File

@@ -0,0 +1,118 @@
# ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables)
- Status: PROPOSED
- 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 (load-bearing, dedicated pass): lift plugin tables into plugin baselines
With no cross-plugin FKs remaining, each plugin's tables can be created
independently. One coherent baseline edit:
- Remove the ~30 plugin-owned `create_table` blocks from the core baseline
(68b3947ae14f), plus the four dead-object blocks (machinerelationships,
printerdata, installedapps, communications.machineid) it creates only for
later migrations to drop. Core baseline then creates only core tables.
- Change each of the 14 plugin 0001 anchors from stamp-only `pass` to
`create_plugin_tables(<name>)` / `drop_plugin_tables(<name>)` (idempotent, per
above).
A fresh lean install then creates core tables plus only the chosen plugins'
tables. A fresh full install creates the identical table set it does today.
Existing-database safety: an existing database is stamped past the baseline and
past each plugin's old stamp-anchor, so neither re-runs; it keeps its tables.
Editing the baseline's content only changes what a FRESH install creates. This
is the highest-blast-radius edit in the project (the released baseline every
site's DB derives from), so it is staged as its own pass gated on the full
verification matrix: fresh-full (== current schema), fresh-lean (strict subset),
existing-DB (no re-run, unchanged), and the migrations-mysql CI (fresh upgrade
from empty + per-plugin install + second-run no-op).
## Consequences
### Positive
- Removes every cross-plugin foreign key; plugin schemas become independent, as
ADR-013 requires.
- Deletes dead legacy tables/columns every database has carried since the
cutover (real cleanup, not just lean).
- After Phase 2, a lean site's database contains only core + chosen-plugin
tables.
### Negative / risk
- Phase 2 edits the released baseline's content. It is safe because existing
databases never re-run a stamped revision, but it demands the full fresh +
existing + CI verification and is therefore staged separately.
- Dropping tables is destructive; the migration downgrade recreates them empty
(structure only) - acceptable because they hold no live data.
## Implementation
- Phase 1: core migration `7d27_retire_legacy_machine_fk_tables` + drop the dead
`communications.machineid` column from the model. Verified: fresh upgrade,
idempotent re-run, and a scratch database that had the tables drops them.
- Phase 2: baseline edit + 14 plugin anchor rewrites + idempotent-create guards,
gated by the migrations-mysql CI, in a dedicated pass.

View File

@@ -26,6 +26,7 @@ Each ADR captures a single architectural decision: the context, the decision its
| [011](ADR-011-machines-rename.md) | Machines rename + modeltypes retyping | ACCEPTED |
| [012](ADR-012-geenforce-manifest-ownership.md) | GE-Enforce manifest ownership in shopdb | ACCEPTED |
| [013](ADR-013-plugin-catalog-and-lean-builds.md) | Plugin catalog, curated shelf, and lean per-site builds | PROPOSED |
| [014](ADR-014-schema-lean-per-site.md) | Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables) | PROPOSED |
## Authoring

View File

@@ -85,21 +85,24 @@ def _get_plugin_metadata(plugin_name: str) -> MetaData:
def create_plugin_tables(plugin_name: str):
"""Emit CreateTable DDL for every table this plugin owns. Idempotent
via Alembic's batch_op.create_table behavior (raises if exists; the
baseline migration is meant to run against an empty schema).
"""Create every table this plugin owns, sourced from the SQLAlchemy models
(not duplicated DDL). IDEMPOTENT: a table that already exists is skipped, so
this is safe on an existing database that has the table from the pre-cutover
core baseline as well as on a fresh install (ADR-014 Phase 2).
Called from each plugin's 0001_baseline.py upgrade() so the table
definitions stay sourced from the SQLAlchemy models rather than being
duplicated in handwritten Alembic ops.
Called from each plugin's 0001 baseline.py upgrade().
"""
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.schema import CreateTable
md = _get_plugin_metadata(plugin_name)
bind = op.get_bind()
existing = set(inspect(bind).get_table_names())
# Sort by FK dependency so parent tables are created first.
for table in md.sorted_tables:
if table.name in existing:
continue
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))

View File

@@ -44,6 +44,8 @@ rsync -a --delete \
--exclude 'venv' \
--exclude 'node_modules' \
--exclude 'frontend/dist*' \
--exclude 'frontend/src/.plugins-staged' \
--exclude 'frontend/src/router/routes.gen.js' \
--exclude 'instance' \
--exclude '.env' \
--exclude '__pycache__' \