diff --git a/CLAUDE.md b/CLAUDE.md index 76e22ef..289731c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/adr/ADR-014-schema-lean-per-site.md b/docs/adr/ADR-014-schema-lean-per-site.md new file mode 100644 index 0000000..8b99372 --- /dev/null +++ b/docs/adr/ADR-014-schema-lean-per-site.md @@ -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()` / `drop_plugin_tables()` (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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7c27c19..07621d7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 67c8702..076f72e 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -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))) diff --git a/tools/export-github.sh b/tools/export-github.sh index f00b9d2..01a8603 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -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__' \