Local-first runtime cleanup and recovery hardening
This commit is contained in:
@@ -14,6 +14,12 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
| `connection_settings` | MariaDB connection settings | key-value store |
|
||||
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
|
||||
|
||||
Read-only cache contract:
|
||||
|
||||
- `local_components`, `local_pricelists`, `local_pricelist_items`, `local_partnumber_books`, and `local_partnumber_book_items` are synchronized caches, not user-authored data.
|
||||
- Startup must prefer application availability over preserving a broken cache schema.
|
||||
- If one of these tables cannot be migrated safely, the client may quarantine or drop it and recreate it empty; the next sync repopulates it.
|
||||
|
||||
#### Pricelists
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
@@ -26,12 +32,10 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
|
||||
| `local_partnumber_book_items` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` |
|
||||
| `local_partnumber_book_items` | Canonical PN catalog rows | `id`, `partnumber`, `lots_json`, `description` |
|
||||
|
||||
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||
|
||||
`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1.
|
||||
|
||||
#### Configurations and Projects
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
@@ -100,17 +104,13 @@ Database: `RFQ_LOG`
|
||||
| `qt_categories` | Component categories | SELECT |
|
||||
| `qt_pricelists` | Pricelists | SELECT |
|
||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment during sync only) | SELECT |
|
||||
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
|
||||
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
||||
| `qt_client_schema_state` | Applied migrations state + client operational status per `username + hostname` | SELECT, INSERT, UPDATE |
|
||||
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
||||
| `qt_partnumber_books` | Partnumber book snapshots (written by PriceForge) | SELECT |
|
||||
| `qt_partnumber_book_items` | PN→LOT mapping rows (written by PriceForge) | SELECT |
|
||||
| `qt_partnumber_books` | Partnumber book headers with snapshot membership in `partnumbers_json` (written by PriceForge) | SELECT |
|
||||
| `qt_partnumber_book_items` | Canonical PN catalog with `lots_json` composition (written by PriceForge) | SELECT |
|
||||
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT only for new `partnumber`; existing rows must not be modified |
|
||||
|
||||
Legacy server tables not used by QuoteForge runtime anymore:
|
||||
@@ -124,11 +124,36 @@ QuoteForge canonical BOM storage is:
|
||||
- `qt_configurations.vendor_spec`
|
||||
- row-level PN -> multiple LOT decomposition in `vendor_spec[].lot_mappings[]`
|
||||
|
||||
Partnumber book server read contract:
|
||||
|
||||
1. Read active or target book from `qt_partnumber_books`.
|
||||
2. Parse `partnumbers_json`.
|
||||
3. Load payloads from `qt_partnumber_book_items WHERE partnumber IN (...)`.
|
||||
|
||||
Pricelist stock enrichment contract:
|
||||
|
||||
1. Sync pulls base pricelist rows from `qt_pricelist_items`.
|
||||
2. Sync reads latest stock quantities from `stock_log`.
|
||||
3. Sync resolves `partnumber -> lot` through the local mirror of `qt_partnumber_book_items` (`local_partnumber_book_items.lots_json`).
|
||||
4. Sync stores enriched `available_qty` and `partnumbers` into `local_pricelist_items`.
|
||||
|
||||
Runtime rule:
|
||||
|
||||
- pricelist UI and quote logic read only `local_pricelist_items`;
|
||||
- runtime code must not query `stock_log`, `qt_pricelist_items`, or `qt_partnumber_book_items` directly outside sync.
|
||||
|
||||
`qt_partnumber_book_items` no longer contains `book_id` or `lot_name`.
|
||||
It stores one row per `partnumber` with:
|
||||
|
||||
- `partnumber`
|
||||
- `lots_json` as `[{"lot_name":"CPU_X","qty":2}, ...]`
|
||||
- `description`
|
||||
|
||||
`qt_client_schema_state` current contract:
|
||||
|
||||
- identity key: `username + hostname`
|
||||
- migration state:
|
||||
`last_applied_migration_id`, `app_version`, `last_checked_at`, `updated_at`
|
||||
- client/runtime state:
|
||||
`app_version`, `last_checked_at`, `updated_at`
|
||||
- operational state:
|
||||
`last_sync_at`, `last_sync_status`
|
||||
- queue health:
|
||||
@@ -154,13 +179,11 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||
|
||||
@@ -181,11 +204,9 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
||||
@@ -196,7 +217,7 @@ FLUSH PRIVILEGES;
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
|
||||
**Note:** If pricelists sync but stock enrichment is empty, verify `SELECT` on `qt_pricelist_items`, `qt_partnumber_books`, `qt_partnumber_book_items`, and `stock_log`.
|
||||
|
||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
||||
|
||||
@@ -204,17 +225,24 @@ SHOW GRANTS FOR 'quote_user'@'%';
|
||||
|
||||
## Migrations
|
||||
|
||||
### SQLite Migrations (local) — три уровня, выполняются при каждом старте
|
||||
### SQLite Migrations (local) — два уровня, выполняются при каждом старте
|
||||
|
||||
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
|
||||
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
|
||||
|
||||
Local SQLite partnumber book cache contract:
|
||||
|
||||
- `local_partnumber_books.partnumbers_json` stores PN membership for a pulled book.
|
||||
- `local_partnumber_book_items` is a deduplicated local catalog by `partnumber`.
|
||||
- `local_partnumber_book_items.lots_json` mirrors the server `lots_json` payload.
|
||||
- SQLite migration `2026_03_07_local_partnumber_book_catalog` rebuilds old `book_id + lot_name` rows into the new local cache shape.
|
||||
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
|
||||
|
||||
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
|
||||
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
|
||||
|
||||
**3. Централизованные (server-side)** — третий уровень, при проверке готовности к синку.
|
||||
SQL-тексты хранятся в `qt_client_local_migrations` (MariaDB, пишет только PriceForge). Клиент читает, применяет к локальной SQLite, записывает в `local_remote_migrations_applied` + `qt_client_schema_state`.
|
||||
QuoteForge does not use centralized server-driven SQLite migrations.
|
||||
All local SQLite schema/data migrations live in the client codebase.
|
||||
|
||||
### MariaDB Migrations (server-side)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user