Framework: - Per-plugin Alembic migration chains (ADR-008): every bundled plugin carries its own chain with a stamp-only anchor at the ownership cutover; new plugin schema lands in plugins/<name>/migrations/, never the core chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the shared alembic template (engine URL resolution) and taught the metadata filter to include FK-referenced core tables. - Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin; a disabled plugin's pages redirect to the dashboard via a cached, fail-open check against the new public GET /api/plugins/enabled. - get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute report cards; warranty and toner cards moved off the hardcoded list. Reports: - Hub grouped by category with search; inline reports render at the top, are URL-backed (?report=id, back-button and deep links work), expose their server-side filter params as controls, and export CSV. Warranty and Toner pages gained CSV export. - Deleted the dead legacy Warranty Status report (always-zero buckets from a retired column). Theming and fonts: - Inter (variable) bundled locally via @fontsource, replacing the Google Fonts Roboto import - air-gapped installs now render correctly; tables use tabular numerals. - Optional brand_primary_dark_color, brand_accent_color, brand_sidebar_color settings applied to CSS vars at bootstrap. USB frontend repair (views were reading a dead legacy shape): - List/detail/form and the employee profile USB panels remapped to the real API shape (device_id/device_desc/checkinoutlog); employee panels now use /usb/checkouts endpoints; external-mode /usb/checkouts/active honors the badge filter; dead client methods pruned. Also: warranties list page no longer requires login (matches app convention); collector doc rewritten with a GE-Enforce integration guide and paste-ready PowerShell reporter; ADR index and CHANGELOG updated. Verified: 323 tests pass, naming/style green, frontend builds, plugin migration dry-run green on scratch MySQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
6.2 KiB
Markdown
124 lines
6.2 KiB
Markdown
# ADR-008: Plugin migration ownership (per-plugin chains from the cutover)
|
|
|
|
- **Status:** ACCEPTED
|
|
- **Date:** 2026-07-10
|
|
- **Deciders:** cproudlock
|
|
- **Supersedes:** the "Migration strategy (resolved)" section of ADR-004
|
|
|
|
## Context
|
|
|
|
ADR-004 resolved a Phase 7B footgun by folding every bundled plugin's tables
|
|
into the single core Alembic chain (migration `7c04_fold_plugin_schema`), and
|
|
later core migrations (`7d04`..`7d16`) kept adding plugin schema directly to the
|
|
core chain. At the time this was the safe choice: bundled-plugin baselines and
|
|
the core baseline had both been creating the same tables, so
|
|
`flask plugin upgrade-all` would collide with `flask db upgrade`.
|
|
|
|
That resolution left the per-plugin Alembic engine
|
|
(`shopdb/plugins/migrations.py`, `shopdb/plugins/alembic_template.py`, the
|
|
`alembic_version_<plugin>` version tables, and `flask plugin upgrade-all`) fully
|
|
built but unused for the bundled plugins. The framework is the product (per the
|
|
project's charter); a plugin that cannot own its own schema is not really a
|
|
plugin. Sister sites that adopt or fork a plugin need its schema history to
|
|
travel with the plugin, not be entangled in the host's core chain. Keeping every
|
|
future plugin table change in the core chain also means the core chain grows
|
|
without bound and a plugin can never be cleanly removed.
|
|
|
|
The blocker ADR-004 worried about (double table creation) only exists while a
|
|
plugin's own migration tries to CREATE tables the core chain already created.
|
|
That is avoidable: history is immutable, so the tables already built by the core
|
|
chain stay owned by the core chain; only NEW schema needs a new home.
|
|
|
|
## Decision
|
|
|
|
Ownership splits at a fixed cutover, the current core-chain head.
|
|
|
|
1. **Core chain owns history through its head.** The core Alembic chain
|
|
(baseline `68b3947ae14f` .. head `7d16_directoryemployees`) remains
|
|
authoritative for every table that exists at the cutover, including the
|
|
bundled-plugin tables it created. Those migrations are immutable and are not
|
|
rewritten. `flask db upgrade` continues to reproduce the full schema.
|
|
|
|
2. **Plugin chains own plugin schema going forward.** From the cutover forward,
|
|
any change to a plugin's schema lands as
|
|
`plugins/<name>/migrations/versions/000N_*.py` in that plugin's own chain,
|
|
never in the core chain. The core chain is reserved for core tables.
|
|
|
|
3. **Every table-owning bundled plugin gets a `0001` anchor.** Each such plugin
|
|
carries a migration chain whose first revision is a stamp-only no-op:
|
|
`upgrade()` does nothing because the core chain already created the tables.
|
|
The anchor exists so the plugin chain has a base that
|
|
`flask plugin upgrade-all` can stamp into the per-plugin version table
|
|
`alembic_version_<plugin>`. Blueprint-only plugins that own no tables get no
|
|
chain.
|
|
|
|
4. **Deploy and upgrade sequence.** A deploy runs `flask db upgrade`
|
|
(core chain, creates everything through the head) then
|
|
`flask plugin upgrade-all` (stamps every plugin anchor and applies any later
|
|
per-plugin migrations). The same two commands upgrade an existing install;
|
|
both are idempotent. The registry (`instance/plugins.json`) tracks each
|
|
plugin's applied revisions in `migrations_applied`.
|
|
|
|
5. **Table-ownership registry.** `PLUGIN_TABLE_OWNERS` in
|
|
`shopdb/plugins/alembic_template.py` is the explicit map of which tables each
|
|
plugin owns; it is kept in sync with the plugins' `__tablename__` declarations
|
|
and pinned by `tests/test_plugin_migrations.py`.
|
|
|
|
External (out-of-tree) plugins per ADR-003 already shipped their own chains;
|
|
this ADR brings the bundled plugins onto the same model, so there is one rule
|
|
for all plugins.
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- A plugin's schema history travels with the plugin. Adopting or forking sites
|
|
get the plugin's migrations, not a slice of someone else's core chain.
|
|
- The core chain stops accreting plugin schema; it stays about core tables.
|
|
- A plugin can be evolved (or, with its own downgrade, removed) independently.
|
|
- The long-built per-plugin Alembic engine is finally exercised on every deploy,
|
|
so it cannot silently rot.
|
|
|
|
### Negative / cost
|
|
|
|
- Two migrate commands per deploy instead of one. Documented in `docs/DEPLOY.md`
|
|
and `docs/UPGRADE.md`; both are idempotent so the cost is one extra safe call.
|
|
- A plugin author must now put new tables in the plugin chain and register them
|
|
in `PLUGIN_TABLE_OWNERS`, rather than autogenerating into the core chain. The
|
|
`flask plugin new` guidance and this ADR spell that out.
|
|
- The cutover is a discontinuity: tables created before it are core-owned,
|
|
tables created after it are plugin-owned. The line is the core-chain head at
|
|
this ADR's date, recorded here so it is unambiguous.
|
|
|
|
### Neutral
|
|
|
|
- No schema changes and no data migration: the anchors are no-ops. A fresh
|
|
install and an existing install converge to the same state.
|
|
|
|
## Alternatives considered
|
|
|
|
1. **Keep everything in the core chain (status quo per ADR-004).** Simplest
|
|
operationally but defeats the plugin-as-product goal: plugin schema cannot
|
|
travel, the core chain grows without bound, and plugins can never be cleanly
|
|
removed. Rejected.
|
|
2. **Rewrite history so plugin tables move out of the core chain into plugin
|
|
`0001` CREATE migrations.** Would make each plugin chain self-contained from
|
|
empty, but breaks the immutability rule, forces every existing site to
|
|
re-run a rewritten chain, and re-introduces the exact double-creation footgun
|
|
ADR-004 fixed. Rejected.
|
|
3. **Anchor that CREATEs tables with `IF NOT EXISTS` guards.** Lets a from-empty
|
|
install build plugin tables from the plugin chain, but then two chains both
|
|
claim the same tables and drift can diverge silently. The no-op anchor keeps
|
|
a single authoritative creator (the core chain) for cutover-era tables.
|
|
Rejected.
|
|
|
|
## References
|
|
|
|
- ADR-003 (plugin distribution; external plugins already ship chains)
|
|
- ADR-004 (deployment topology; this ADR supersedes its migration-strategy note)
|
|
- `shopdb/plugins/alembic_template.py` (`PLUGIN_TABLE_OWNERS`, shared env runner)
|
|
- `shopdb/plugins/migrations.py`, `shopdb/plugins/cli.py` (`upgrade-all`)
|
|
- `plugins/<name>/migrations/` (per-plugin chains and `0001` anchors)
|
|
- `tests/test_plugin_migrations.py` (ownership + chain + idempotency guards)
|
|
- `docs/DEPLOY.md`, `docs/UPGRADE.md`, `docs/PLUGINS.md` (deploy sequence)
|