# 02 — Architecture ## Local-First Principle **SQLite** is the single source of truth for the user. **MariaDB** is a sync server only — it never blocks local operations. ``` User │ ▼ SQLite (qfs.db) ← all CRUD operations go here │ │ background sync (every 5 min) ▼ MariaDB (RFQ_LOG) ← pull/push only ``` **Rules:** - All CRUD operations go through SQLite only - If MariaDB is unavailable → local work continues without restrictions - Changes are queued in `pending_changes` and pushed on next sync ## MariaDB Boundary MariaDB is not part of the runtime read/write path for user features. Hard rules: - HTTP handlers, web pages, quote calculation, export, vendor BOM resolution, pricelist browsing, project browsing, and configuration CRUD must read/write SQLite only. - MariaDB access from the app runtime is allowed only inside the sync subsystem (`internal/services/sync/*`) for explicit pull/push work. - Dedicated tooling under `cmd/migrate` and `cmd/migrate_ops_projects` may access MariaDB for operator-run schema/data migration tasks. - Setup may test/store connection settings, but after setup the application must treat MariaDB as sync transport only. - Any new repository/service/handler that issues MariaDB queries outside sync is a regression and must be rejected in review. - Local SQLite migrations are code-defined only (`AutoMigrate` + `runLocalMigrations`); there is no server-driven client migration registry. - Read-only local sync caches are disposable. If a local cache table cannot be migrated safely at startup, the client may quarantine/reset that cache and continue booting. Forbidden patterns: - calling `connMgr.GetDB()` from non-sync runtime business code; - constructing MariaDB-backed repositories in handlers for normal user requests; - using MariaDB as online fallback for reads when local SQLite already contains the synced dataset; - adding UI/API features that depend on live MariaDB availability. ## Local Client Boundary The running app is a localhost-only thick client. - Browser/UI requests on the local machine are treated as part of the same trusted user session. - Local routes are not modeled as a hardened multi-user API perimeter. - Authorization to the central server happens through the saved MariaDB connection configured during setup. - Any future deployment that binds beyond `127.0.0.1` must add enforced auth/RBAC before exposure. --- ## Synchronization ### Data Flow Diagram ``` [ SERVER / MariaDB ] ┌───────────────────────────┐ │ qt_projects │ │ qt_configurations │ │ qt_pricelists │ │ qt_pricelist_items │ │ qt_pricelist_sync_status │ └─────────────┬─────────────┘ │ pull (projects/configs/pricelists) │ ┌────────────────────┴────────────────────┐ │ │ [ CLIENT A / SQLite ] [ CLIENT B / SQLite ] local_projects local_projects local_configurations local_configurations local_pricelists local_pricelists local_pricelist_items local_pricelist_items pending_changes pending_changes │ │ └────── push (projects/configs only) ─────┘ │ [ SERVER / MariaDB ] ``` ### Sync Direction by Entity | Entity | Direction | |--------|-----------| | Configurations | Client ↔ Server ↔ Other Clients | | Projects | Client ↔ Server ↔ Other Clients | | Pricelists | Server → Clients only (no push) | | Components | Server → Clients only | | Partnumber books | Server → Clients only | Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync. ### Soft Deletes (Archive Pattern) Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`. - `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate` - `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint ## Sync Readiness Guard Before every push/pull, a preflight check runs: 1. Is the server (MariaDB) reachable? 2. Is the local client schema initialized and writable? **If the check fails:** - Local CRUD continues without restriction - Sync API returns `423 Locked` with `reason_code` and `reason_text` - UI shows a red indicator with the block reason --- ## Pricing ### Principle **Prices come only from `local_pricelist_items`.** Components (`local_components`) are metadata-only — they contain no pricing information. Stock enrichment for pricelist rows is persisted into `local_pricelist_items` during sync; UI/runtime must not resolve it live from MariaDB. ### Lookup Pattern ```go // Look up a price for a line item price, found := s.lookupPriceByPricelistID(pricelistID, lotName) if found && price > 0 { // use price } // Inside lookupPriceByPricelistID: localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID) price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName) ``` ### Multi-Level Pricelists A configuration can reference up to three pricelists simultaneously: | Field | Purpose | |-------|---------| | `pricelist_id` | Primary (estimate) | | `warehouse_pricelist_id` | Warehouse pricing | | `competitor_pricelist_id` | Competitor pricing | Pricelist sources: `estimate` | `warehouse` | `competitor` ### "Auto" Pricelist Selection Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`): - **Explicit mode:** concrete `pricelist_id` is set by user in settings. - **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist. `auto` must stay `auto` after price-level refresh and after manual "refresh prices": - resolved IDs are runtime-only and must not overwrite user's mode; - switching to explicit selection must clear runtime auto resolution for that source. ### Latest Pricelist Resolution Rules For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with: 1. only pricelists that have at least one item (`EXISTS ...pricelist_items`); 2. deterministic sort: `created_at DESC, id DESC`. This prevents selecting empty/incomplete snapshots and removes nondeterministic ties. --- ## Configuration Versioning ### Principle Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`. ``` local_configurations └── current_version_id ──► local_configuration_versions (v3) ← active local_configuration_versions (v2) local_configuration_versions (v1) ``` - `version_no = max + 1` when configuration **spec+price** changes - Old versions are never modified or deleted in normal flow - Rollback does **not** rewind history — it creates a **new** version from the snapshot - Operational updates (`line_no` reorder, server count, project move, rename) are synced via `pending_changes` but do **not** create a new revision snapshot ### Rollback ```bash POST /api/configs/:uuid/rollback { "target_version": 3, "note": "optional comment" } ``` Result: - A new version `vN` is created with `data` from the target version - `change_note = "rollback to v{target_version}"` (+ note if provided) - `current_version_id` is switched to the new version - Configuration moves to `sync_status = pending` ### Sync Status Flow ``` local → pending → synced ``` --- ## Project Specification Ordering (`Line`) - Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB. - Project list ordering is deterministic: `line_no ASC`, then `created_at DESC`, then `id DESC`. - Drag-and-drop reorder in project UI updates `line_no` for active project configurations. - Reorder writes are queued as configuration `update` events in `pending_changes` without creating new configuration versions. - Backward compatibility: if remote MariaDB schema does not yet include `line_no`, sync falls back to create/update without `line_no` instead of failing. --- ## Sync Payload for Versioning Events in `pending_changes` for configurations contain: | Field | Description | |-------|-------------| | `configuration_uuid` | Identifier | | `operation` | `create` / `update` / `rollback` | | `current_version_id` | Active version ID | | `current_version_no` | Version number | | `snapshot` | Current configuration state | | `idempotency_key` | For idempotent push | | `conflict_policy` | `last_write_wins` | --- ## Background Processes | Process | Interval | What it does | |---------|----------|--------------| | Sync worker | 5 min | push pending + pull all | | Backup scheduler | configurable (`backup.time`) | creates ZIP archives |