Executive Summary
Overall health and key metrics for the POS system codebase
- CRITICAL Sync version tracking uses per-row counters instead of a global monotonic sequence — stores can miss updates entirely
-
CRITICAL
shouldRemoteWin()comparescreatedAtinstead ofupdatedAt— recently updated records can be overwritten by older remote changes - CRITICAL Same JWT secret shared across HQ and all stores — a compromised store token works everywhere
- CRITICAL Secrets (JWT, DB passwords, sync tokens) hardcoded in docker-compose.prod.yml in plain text
- CRITICAL N+1 query in sales list endpoint — 350 queries per page load (50 sales)
- CRITICAL Missing database indexes on FK columns (sale_items.sale_id, user_store_roles.user_id, sales.store_id) — full table scans at scale
This analysis covers 4 domains across the POS monorepo: Backend (Fastify, Drizzle, PostgreSQL, Auth, WebSocket, Testing — 19 findings), Frontend (Vue 3, Pinia, PrimeVue, Router, TypeScript, CSS — 33 findings), Architecture & Sync (sync protocol, data flow, offline, scale, deploy, security — 20 findings), and Tauri Desktop (3 apps, hardware integration — code quality observations).
The system is functional with 1,017 passing tests and a working multi-store deployment. The most urgent issues are in the sync protocol (version tracking bug, conflict resolution bug, no idempotency) and security (shared JWT secret, plaintext secrets, open CORS). The frontend has significant type safety gaps (duplicated interfaces, as unknown as casts) and i18n violations (hardcoded English strings).
Tech Stack
Technologies and their versions extracted from package.json and Cargo.toml files
Backend
Frontend
Desktop (Tauri)
All 3 Tauri apps (pos-terminal, hq-manager, store-manager) now import shared logic from @pos/pos-logic (12 Pinia stores, 3 composables, config, modules).
Tooling & Infrastructure
Architecture Diagram
Multi-store POS system with HQ hub and per-store servers
Monorepo Structure
pnpm workspaces + Turborepo — 6 apps, 7 shared packages
378 HQ server tests
185 store server tests
248 web client (Vue) tests
85 pos-logic tests
211 shared package tests
1,102 total
hq.pos.eddieadiaz.com
tienda1.pos.eddieadiaz.com
tienda2.pos.eddieadiaz.com
pos.eddieadiaz.com (portal)
docs.pos.eddieadiaz.com
Money: cents (integers)
Tax: basis points (825 = 8.25%)
Quantities: ×1000
Auth: PIN or password (bcrypt)
i18n: en + es required
Backend Analysis
Fastify, Drizzle ORM, PostgreSQL, Authentication, WebSocket, Testing
Fastify Server
Current: Zod is used for manual .parse(request.body) calls inside route handlers. Fastify's native JSON Schema validation is not used. Query params and path params are cast with as type assertions — completely unvalidated.
Best Practice: Fastify is optimized for JSON Schema validation via schema option. Use fastify-type-provider-zod for both Zod DX and Fastify's compiled validation.
Fix: Adopt fastify-type-provider-zod to register Zod schemas in Fastify's schema option. At minimum, validate query and path params.
Current: Global error handler detects ZodError by checking error.constructor.name === 'ZodError' (brittle, can break with minification). Some routes swallow errors and return 400 for all failures including 500-level DB errors. The /me endpoint returns errors without a proper HTTP status code.
Fix: Use instanceof ZodError. Differentiate client errors (400) from server errors (500) in catch blocks. Ensure all error paths set proper HTTP status codes.
Current: Global rate limiting disabled (global: false). Only /login and /pin-login have rate limits (5 req/min).
Missing: /change-credential, /refresh token endpoint, and sync push endpoint have no rate limiting. No global fallback.
Fix: Add rate limiting to sensitive endpoints. Add global rate limit as safety net (e.g., 1000 req/min).
Current: The authenticate decorator is added to the root instance without fastify-plugin wrapping. Works but could break if plugin load order changes.
Fix: Wrap shared decorators with fastify-plugin for explicit encapsulation breaking.
Current: 22 route modules imported explicitly in app.ts. No grouping by domain.
Fix: Consider @fastify/autoload or group routes by domain (HQ management, POS operations, sync) into parent plugins.
Drizzle ORM & Database
Current: Only unique indexes exist (from constraints). No non-unique indexes defined anywhere.
Critical missing indexes: sale_items.sale_id, sale_payments.sale_id, user_store_roles.user_id, sales.store_id, sales.created_at, sales.status, inventory_adjustments.product_id, products.department_id, products.sync_version, transfers.from_store_id, transfers.to_store_id, register_sessions.store_id, product_specials composite index.
Fix: Create a migration adding all missing indexes. Priority: FK columns used in joins, then filter columns, then sync_version columns.
Current: GET /api/v1/sales has a severe N+1 problem. For each sale, it queries items, then for each item it queries taxes, then queries payments. For a page of 50 sales with 5 items each: 50 (items) + 250 (taxes) + 50 (payments) = 350 queries per page load.
Fix: Collect all saleIds, then batch query: SELECT * FROM sale_items WHERE sale_id IN (...). Same for taxes and payments. Reduces 350 queries to 4.
Current: processEnvelope() does NOT use transactions for multi-step operations. The shouldRemoteWin check-then-update pattern has a Time-of-Check-to-Time-of-Use race condition without a transaction.
Fix: Wrap sync push envelope processing in transactions. The select-then-upsert pattern needs to be atomic.
Current: No relations() defined anywhere. All joins are manual SQL-style innerJoin/leftJoin. No relational query API available.
Fix: Define Drizzle relations for key relationships (sale -> saleItems, product -> department, etc.). Use db.query relational API for reads needing eager loading.
Current: Single postgres() connection with no pool configuration. No explicit pool size, no connection timeout, no idle timeout, no SSL for production.
Fix: Configure pool explicitly: postgres(url, { max: 20, idle_timeout: 30, connect_timeout: 10 }).
Current: Products route constructs WHERE with manual SQL concatenation instead of and(...conditions) like sales route does correctly.
Fix: Standardize on and(...conditions) pattern everywhere.
Authentication & Authorization
Current: Single JWT secret for all token types (access, refresh, store_sync). Access tokens don't include type: 'access'. The /me endpoint doesn't verify the token type. A refresh or sync token could be used as an access token.
Fix: Add type: 'access' to access tokens. Verify type in the authenticate decorator. Use separate secrets or iss/aud claims for different token types.
Current: Refresh token is a signed JWT with 7-day expiry. No server-side storage, no rotation, no family tracking. When a user is deactivated, their refresh tokens remain valid until natural expiry.
Fix: Store refresh tokens in DB. On refresh, invalidate old token and issue new one (rotation). Check user isActive on refresh.
Current: PIN login iterates ALL active users and calls bcrypt.compare() against each until a match. With 100 users, worst case is ~10 second login (100 x ~100ms). Failed PINs always iterate all users; successful PINs stop early — timing attack vector. PIN uniqueness not enforced.
Fix: Enforce PIN uniqueness. Store a PIN hint or short hash for pre-filtering. Add constant-time comparison.
Current: requirePermission() loads all permissions from DB on every request (2-3 queries: userStoreRoles + rolePermissions JOIN permissions). Duplicated logic between auth-guard.ts and auth/routes.ts. No caching.
Fix: Consolidate permission loading. Cache permissions per userId with short TTL (60s) or include permissions in JWT.
WebSocket Server
Current: WebSocket auth passes sync_token as a URL query parameter. Tokens in query strings are logged by reverse proxies and can leak via Referer headers.
Fix: Use the existing message-based auth flow as the primary mechanism. Remove query-string token support.
Current: 40s pong timeout with 30s ping interval gives only 10s grace period. Network jitter could cause false disconnects. Dual heartbeat mechanism (protocol-level ping from server, app-level heartbeat from store) is redundant.
Fix: Increase pong timeout to 60s or reduce ping interval to 15s.
Current: All connected stores held in a single in-memory Map. Broadcast iterates all entries. No multi-process support.
Fix: Acceptable for current scale (<50 stores). For larger scale, introduce Redis pub/sub for cross-process routing.
Testing (Backend)
HQ Server: 373 tests | Store Server: 185 tests
Framework: Vitest 3.x with global test setup, PostgreSQL test databases. Integration tests against real DB (no mocks). Global setup drops/recreates database, runs migrations, seeds data.
Current: Tests within the same file share state (e.g., createdProductId set in POST test, used in PUT test). No per-test cleanup. Tests depend on execution order.
Fix: Use beforeEach for per-test data setup. Wrap tests in rolled-back transactions or don't depend on execution order.
Current: Tests cover happy paths, 404s, and permission enforcement. Missing: concurrent operations, sync push conflict resolution, JWT type confusion, PIN-login timing with multiple users.
Fix: Add tests for JWT type validation, sync conflict resolution, concurrent sale creation.
Current: sync-processor queue defined but has no worker. Queue names are string literals. console.error used instead of Fastify logger.
Fix: Implement the sync-processor worker or remove dead code. Use app logger.
Frontend Analysis
Vue 3, Pinia, PrimeVue, Router, TypeScript, CSS
Vue 3 & Composition API
Current: PosTerminalView.vue is 1400+ lines, ProductsView.vue is 900 lines, StoresView.vue is 586 lines. These combine data fetching, form handling, dialog management, and rendering in a single file.
Fix: Extract composables for data operations and split dialogs into child components. Target: no view exceeds ~300 lines of script.
Current: The tax breakdown calculation in completeSale() (checkout store) is duplicated nearly verbatim in preValidateSale() (CheckoutView).
Fix: Extract the tax breakdown builder into a shared function in @pos/tax-engine.
formatMoney() UtilityCurrent: formatMoney(cents) is defined locally in ProductsView, CheckoutView, StoresView, StoreInventoryView independently.
Fix: Extract useFormatMoney() composable or utility in @pos/ui-kit.
Update (Mar 2026): The @pos/pos-logic package extraction consolidates shared stores and composables. Utility extraction is the next step.
Pinia Stores
Current: Nearly every store has the same pattern: const payload = result.data as unknown as { data: T[] } | T[]; products.value = Array.isArray(payload) ? payload : ('data' in payload ? payload.data : []). This is repeated across all stores.
Fix: Handle response normalization once in the backend adapter's execute() method so stores never need to unwrap.
Update (Mar 2026): Partially addressed — 12 Pinia stores extracted to @pos/pos-logic and shared across all 4 apps, eliminating per-app store duplication.
Current: useCheckoutStore calls useCartStore(), useAuthStore(), useRegisterStore() at the top level of the store definition. Pinia docs warn this can cause circular dependency issues.
Fix: Move store cross-references inside actions: const cartStore = useCartStore() inside completeSale().
Current: useReportsStore has 11 nearly identical async functions sharing one loading/error ref. Loading Report A clobbers Report B's loading state.
Fix: Split into per-report composables or add per-report loading state.
Current: applySpecialsToProducts() mutates product.specialPrice directly on items inside products.value, circumventing Vue's reactivity tracking for nested properties.
Fix: Create new objects instead of mutating existing refs in place.
Current: Auth store manually implements localStorage persistence. Works but non-standard.
Fix: Consider pinia-plugin-persistedstate which handles edge cases (storage quota, serialization errors).
PrimeVue Components
Current: No form validation library (Vee-Validate, FormKit, or manual validation). Users can submit empty product names, zero-price items, etc. PrimeVue 4's built-in invalid prop is not used.
Fix: Adopt Vee-Validate + Zod or at minimum use PrimeVue's :invalid prop with manual validation.
Current: CheckoutView.vue still imports Dropdown (deprecated in PrimeVue 4). Other views correctly use Select.
Fix: Replace all Dropdown imports with Select.
Current: All feedback is via inline Message components. Ephemeral success/error notifications should use PrimeVue's Toast service for better UX.
Fix: Add PrimeVue ToastService and use useToast() for success/error notifications.
Current: Multiple views use inline style="font-size:0.8125rem;...". Should use CSS classes for consistency and dark-mode safety.
Fix: Replace inline styles with scoped CSS classes.
Current: PrimeVue 4 introduced Tabs/TabList/Tab/TabPanels API. The older TabView/TabPanel API is still used in ProductsView.
Router & Navigation
Current: The beforeEach guard waits for authStore.initializing with a manual $subscribe + Promise pattern. Fragile.
Fix: Replace with authStore.whenReady() that returns a cached promise.
Current: No onError handler on the router. If a lazy-loaded chunk fails to load (network error), the user sees nothing.
Fix: Add router.onError() with a retry/reload mechanism.
Current: createRouter does not set scrollBehavior. Scroll position not reset on navigation.
Fix: Add scrollBehavior: () => ({ top: 0 }) to router config.
TypeScript & CSS
Current: ProductsView.vue defines its own Product, Department, TaxGroup, Supplier, Store interfaces nearly identical to @pos/shared-types. Same in CheckoutView, StoreInventoryView, etc. Every view re-defines types that already exist.
Fix: Import from @pos/shared-types instead of re-defining interfaces in views.
as unknown as CastsCurrent: Multiple stores use result.data as unknown as { data: T[] } | T[] to handle inconsistent API response shapes. Bypasses type safety entirely. Root cause: backend adapter's response unwrapping is incomplete.
Fix: Fix the backend adapter to consistently unwrap responses, eliminating all as unknown as casts.
any Usage in StoresViewCurrent: StoresView.vue line 125 uses .map((s: any) => ({...})). Violates the project's "no any" rule.
Fix: Replace with proper typed parameter.
Current: CheckoutView uses mobile-first (min-width breakpoints), while ProductsView and StoreInventoryView use desktop-first (max-width).
Fix: Standardize on mobile-first (recommended for POS targeting tablets).
Current: StoresView uses #10b981, #ef4444 directly instead of PrimeVue tokens. Will not adapt to dark mode correctly.
Fix: Use var(--p-green-500), var(--p-red-500) PrimeVue CSS variables.
i18n Compliance
Current: Multiple views contain hardcoded English text in violation of the project's "All user-facing text via vue-i18n" rule. Affected: StoresView ("Stores Dashboard", "Add Store", "Connected", etc.), StoreInventoryView ("Inventory", "Search products...", "Low Stock Only", etc.), LoginView ("PIN / Password"), and others.
Fix: Audit all views for hardcoded strings. Add keys to both en.json and es.json.
Current: Translation keys are plain strings. Typos in t('products.tilte') silently fail at runtime, showing the key instead of translated text.
Fix: Generate TypeScript types from en.json keys for type-safe t() calls.
Backend Adapter
Current: commandToEndpoint has 100+ entries mapping command strings to URLs. commandToMethod has 30+ if-chains. commandToSuffix has 30+ entries. No compile-time validation; typos silently fall through.
Fix: Replace with a typed registry object: { get_products: { method: 'GET', path: '/api/v1/products', paginated: true } }.
Current: The web adapter partially unwraps { success, data } responses but doesn't handle paginated responses consistently. Every store must re-unwrap with as unknown as casts.
Fix: Have the adapter fully normalize responses so stores receive clean typed data.
Current: No way to add global error handling (401 -> logout), request logging, or retry logic.
Fix: Add an interceptor chain for global 401 redirect and retry.
Build & Bundling
Current: Workbox config caches /api/ responses for 5 minutes. For a POS system handling financial transactions, stale API cache can lead to incorrect stock levels, prices, or duplicate sales.
Fix: Remove or restrict Workbox API caching to safe read-only endpoints only.
Current: PrimeVue is a large library. Without manualChunks, the entire PrimeVue code may end up in one vendor chunk.
Fix: Add manualChunks to split PrimeVue, Chart.js, and vue-i18n into separate vendor chunks.
Current: In turbo.json, "test": { "dependsOn": ["build"] } means every test run requires a full build, slowing feedback.
Fix: Change to "dependsOn": ["^build"] so tests only require dependency packages to be built.
Testing (Frontend)
Web Client: 248 tests | Packages: 211 tests
Framework: Vitest 3.x + @vue/test-utils + happy-dom
Current: The root package.json has a lint script but no ESLint configuration exists. No @typescript-eslint or eslint-plugin-vue.
Fix: Add ESLint with @typescript-eslint and eslint-plugin-vue.
Sync & Data Flow
WebSocket notifications + HTTP data sync with transactional outbox
Current: Per-row syncVersion integer counter, NOT a global monotonically increasing sequence. Multiple rows can share the same syncVersion value. Store pulls with since_version=N and gets rows where syncVersion > N. If Product A and Product B both have syncVersion=5, a pull with since_version=5 returns nothing — both products are missed.
Additionally: Some entity types (permissions, role_permissions, user_store_roles) use pullAll() which re-downloads the entire table every 30-second sync cycle regardless of changes.
Fix: Create a global PostgreSQL sequence: CREATE SEQUENCE sync_global_seq START 1; Set syncVersion = nextval('sync_global_seq') on every INSERT/UPDATE. Add syncVersion to tables that currently lack it.
createdAt vs updatedAtCurrent: shouldRemoteWin() compares existing.createdAt against remoteTimestamp. This means a record created early but updated recently could be overwritten by an older remote change. This is a bug.
Fix: Change shouldRemoteWin() to compare against updatedAt instead of createdAt. Add a conflict log table recording overwritten values for audit.
Pattern: WebSocket notification → HTTP pull/push
Outbox: Transactional outbox pattern for store → HQ pushes
Entities: 21 entity types (products, departments, taxes, tax groups, tenders, users, roles, specials, customers, sales reps, store config, etc.)
Direction: Bidirectional — HQ pushes master data down, stores push sales/inventory up
Polling: 30-second interval, 21 sequential HTTP requests per store per cycle
Pull Mechanism
Current: Each pull cycle iterates through 21 entity types sequentially, making 21 separate HTTP requests. At 20 stores x 30s intervals = 420 HTTP requests/minute to HQ. The latestVersion calculation loops through all returned rows in JavaScript instead of SQL MAX().
Fix: Consolidate into a single POST /api/v1/sync/pull accepting all entity versions. Add LIMIT and cursor pagination for large result sets. Use SQL MAX().
Push Mechanism
Current: No idempotency key on push envelopes. If a push succeeds but the store's outbox deletion fails (e.g., DB crash after HTTP 200), the same entry is pushed again and HQ re-processes it, potentially double-decrementing inventory.
Fix: Add an idempotency key (outbox entry UUID) to push envelopes. HQ should track processed IDs and skip duplicates.
Current: Each processEnvelope() call is a separate DB operation. A sale and its sale_items/sale_payments could be partially synced. Sale items may arrive before their parent sale, causing FK violations. Pull of 21 entity types sequentially means point-in-time inconsistency.
Fix: Wrap batch processing in a DB transaction. Group related entities (sale + sale_items + sale_payments) as atomic units. Enforce parent-before-child ordering.
Current: addToOutbox() takes db as a parameter but there is no guarantee the caller wraps the business operation and outbox insert in a single transaction. If the sale INSERT succeeds but the outbox INSERT fails, data is inconsistent.
Fix: Verify all call sites wrap business operation + outbox insert in the same transaction.
Data Flow Lifecycles
Flow: HQ creates product (syncVersion=1) → Store pulls via GET /sync/pull?entity_type=products → HQ creates store_product_dynamic row during pull → Store upserts product locally → POS sells product, inserts into sync_outbox → Push to HQ → HQ adjusts quantity on store_product_dynamic.
Risk: If pull fails after products inserted locally but before dynamic row created on HQ, store has products without HQ-side tracking. If same sale is re-pushed (idempotency failure), quantity is double-decremented.
Flow: Store A creates customer locally → Push to HQ → HQ upserts, increments syncVersion → Store B pulls new customer.
Risk: Two stores creating the same customer simultaneously creates duplicates (UUID-based upsert, no natural key dedup). Customer updates at both stores in the same sync window: LWW resolves by timestamp, loser's changes silently dropped.
Flow: Store A creates transfer (pending) → Push to HQ → HQ notifies Store B via WebSocket → Store B pulls, receives, adjusts inventory → Push back to HQ.
Risk: No state machine enforcement — any status can be written by any store. Store B may try to receive before pulling the transfer.
Offline Behavior
The store server operates independently with its own PostgreSQL. Sales, inventory, and register sessions work against the local DB. The outbox queues changes for later push. WebSocket reconnects with exponential backoff. Architecture supports indefinite offline operation (10,000 sales = ~20MB outbox).
Current: After a long offline period, the store pulls all 21 entity types sequentially. Critical data (prices, tax rates) should be prioritized. No delta compression. Both push and pull fire simultaneously, potentially saturating the link.
Fix: Prioritize critical entity types. Add compression. Stagger push/pull.
Current: No size limit on outbox. A busy store offline for 3 days accumulates ~12,000 entries. Push sends 100 at a time every 15 seconds, so clearing backlog takes ~30 minutes. No compression, no dynamic batch sizing, no monitoring.
Fix: Add dynamic batch sizing, gzip compression, outbox size monitoring with alerts.
Failure Recovery
Current: Outbox entries with attempts >= 10 are logged as dead letters but remain in the table indefinitely. No cleanup, no alerting, no management UI.
Fix: Move dead letters to a separate table. Add API endpoint to list/retry/purge. Add automated alerting when count exceeds threshold.
Security Analysis
Authentication, secrets management, data protection, access control
| Severity | Finding | Affected Area | Recommendation |
|---|---|---|---|
| Critical | Same JWT secret shared across HQ and all store servers. A compromised store token works everywhere. | HQ + Store servers, docker-compose.prod.yml |
Use separate JWT secrets per service, or add iss/aud claims and validate them. |
| Critical | Secrets in plaintext in docker-compose.prod.yml: JWT secret (pos-production-jwt-secret-change-me), DB passwords, sync tokens. |
docker-compose.prod.yml |
Use Docker secrets, .env file excluded from Git, or a secrets manager (Vault, Coolify secrets). |
| High | CORS defaults to origin: true (allow ALL origins) when CORS_ORIGINS env var is not set. |
HQ + Store app.ts |
Set explicit CORS origins: CORS_ORIGINS=https://hq.pos.eddieadiaz.com,https://tienda1.pos.eddieadiaz.com |
| High | JWT type confusion: access tokens lack type: 'access'. Refresh and sync tokens could be used as access tokens. |
Auth routes, authenticate decorator | Add type: 'access' to access tokens. Verify type in authenticate decorator. |
| High | Refresh tokens cannot be revoked. No server-side storage, no rotation, no family tracking. | Auth routes | Store refresh tokens in DB. Implement token rotation on refresh. |
| High | PIN login O(N) bcrypt comparisons. 100 users = ~10s login. Timing attack: failed PINs iterate all users, successful stop early. | Auth routes (pin-login) |
Enforce PIN uniqueness. Add PIN hint/short hash for pre-filtering. Constant-time comparison. |
| High | No automated PostgreSQL backups. Named volumes with no backup strategy. | docker-compose.prod.yml |
Implement pg_dump cron + offsite copy. Add backup verification. |
| High | No monitoring, metrics, or alerting. Logging is console.log/console.error only. |
All servers | Add Prometheus metrics, health check endpoints, structured JSON logging (Pino), log aggregation. |
| Medium | Only login routes have rate limiting. All other endpoints unprotected. | HQ + Store servers | Enable global rate limiting (100 req/min per IP). Stricter limits on mutation endpoints. |
| Medium | Store nginx confs missing security headers. HQ has X-Frame-Options, X-Content-Type-Options, Referrer-Policy but stores do not. |
deploy/store1/nginx.conf, deploy/store2/nginx.conf |
Copy security headers from HQ nginx to all store nginx confs. |
| Medium | WebSocket sync_token passed as URL query parameter. Logged by reverse proxies, visible in server logs. |
Sync WebSocket connection | Use message-based auth as primary. Remove query-string token support. |
| Medium | All Docker services share a single internal network. Store 1 DB accessible from Store 2 server. Redis has no password. | docker-compose.prod.yml |
Create per-store networks. Add Redis password. Network segmentation. |
| Medium | No Content-Security-Policy header. No X-XSS-Protection on store configs. |
Nginx configs | Add CSP headers in nginx. SQL injection prevented by Drizzle; XSS by Vue; but defense-in-depth missing. |
| Low | No account lockout. Rate limiting is per-IP, not per-user. Distributed attacker can try from multiple IPs. | Auth routes | Add per-user lockout after N failed attempts. |
| Low | Input validation adequate (Drizzle parameterized queries + Vue escaping) but no explicit string sanitization. | All API routes | Defense-in-depth: add CSP headers, consider input length limits. |
Scale Analysis
Performance considerations, bottlenecks, growth capacity
Baseline: 20 Stores, 10K Products, 30s Sync Interval
Current: 20 stores x 21 entity types = 420 HTTP requests per 30 seconds = 14 req/sec to HQ. Most return 0 entities but each still runs a DB query. 420 PostgreSQL queries/30s for pull alone.
With batch pull (recommended): 20 requests per 30 seconds = 0.67 req/sec. Each runs ~21 queries but in a single round-trip.
Initial sync (10K products): ~10K rows x ~500 bytes = 5MB. Should be paginated.
Incremental (steady state): ~10-50 changed entities per cycle, <50KB. Negligible.
Push (100 sale_items): ~100 x 300 bytes = 30KB per batch. Negligible.
Current: postgres(config.databaseUrl) with default pool (10 connections). No explicit pool size, no monitoring. HQ handles 20 store sync connections + HQ manager clients + API calls. Could exceed default pool.
Fix: Configure explicit pool size: postgres(url, { max: 20 }). Add PgBouncer in transaction mode for HQ. Monitor pool utilization.
Scaling Beyond 20 Stores
Current architecture: 2100 pull HTTP requests per 30 seconds = 70 req/sec. This stresses a single Node.js process. Would need:
- Batch pull endpoint (reduces to 100 req/30s = 3.3 req/sec)
- Read replicas for pull queries
- Load balancer if single HQ server is insufficient
- WebSocket moved to dedicated process using Redis pub/sub
Docker Deployment
Current: PostgreSQL and Redis have health checks. Application containers (hq-server, store-server) have NO health checks. Docker cannot detect if Node.js is unhealthy (event loop blocked, DB connection lost).
Fix: Add health check: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
Current: No connection retry on startup. No circuit breaker for DB operations. Pull requests from stores return 500 on HQ DB failure, triggering immediate retry with no backoff.
Fix: Add DB connection retry on startup (exponential backoff). Add circuit breaker for sync endpoints. Store should back off when HQ returns 5xx.
Current: deploy.sh runs docker compose up -d --force-recreate with 5-10 seconds downtime per deploy.
Fix: Acceptable for off-hours deploys. For 24/7, use Traefik health-check-based routing or blue-green.
Tauri Desktop App
Tauri 2 with Rust backend for POS Terminal, HQ Manager, Store Manager. All Tauri apps now import shared logic from @pos/pos-logic.
App Status Overview
17 Tauri commands implemented. Full sales, register, sync, receipt printing (TCP). USB printer stubbed.
13 commands: inventory, transfers, reports, sync. No hardware integration. No auth commands.
Pure skeleton. No commands, no DB connection, no business logic. Only Tauri shell.
POS Terminal Details
Auth: PIN/password login with bcrypt verification against local DB
Products: get_products, get_product_by_barcode with SQL queries
Sales: complete_sale (transactional, inventory deduction, sync outbox), get_sale, get_recent_sales, void_sale
Register: open_register, close_register, get_current_session, get_session_summary
Hardware: Full ESC/POS command builder, receipt rendering, paper cut, barcode printing (TCP only). Cash drawer via ESC/POS pulse.
Sync: Push outbox, pull 9 entity types, WebSocket with reconnect, fallback timer (60s)
Window: Fullscreen, 1280x800 (kiosk mode)
Code Quality Observations
Current: All Rust code uses String for error types via .map_err(|e| format!(...)) rather than proper thiserror enums, despite thiserror being in dependencies.
Fix: Define thiserror enums for each domain (DbError, AuthError, SyncError, HardwareError).
Current: Database access uses a single Arc<Mutex<Option<Client>>>. All DB operations are serialized through one connection.
Fix: Use deadpool-postgres or bb8-postgres for a connection pool.
Current: Uses manual BEGIN/COMMIT/ROLLBACK strings. The unwrap_or(false) in bcrypt verification masks potential errors.
Fix: Use a structured transaction guard. Handle bcrypt errors explicitly.
None of the three Tauri apps have been compiled in the current environment. They exist as committed source code but no Cargo.lock or target/ directories are present.
Architecture Recommendations
Must operate offline (network outages). Hardware integration (printer, drawer, scanner) requires native OS access. Kiosk mode is standard. Local DB queries are sub-millisecond. The existing implementation is 80% complete. Estimated effort to production: 4-6 weeks.
Store managers work from back-office with reliable network. No hardware integration needed. The web-client already covers all functionality. Add Tauri later only if offline inventory management or native report export is required.
HQ operations always require network. No hardware needed. Web-client covers all HQ functionality. Updates via web deployment are simpler than desktop updates. The existing Tauri app is a skeleton with zero implementation. Recommend: abandon.
Hardware Integration Status
| Hardware | Status | Technology | Notes |
|---|---|---|---|
| Receipt Printer (TCP) | Done | Custom ESC/POS implementation | Full command set, receipt rendering, configurable |
| Receipt Printer (USB) | Stubbed | rusb crate needed |
Returns "not yet implemented". Add via rusb 0.9+ |
| Cash Drawer | Done | ESC/POS pulse via printer | Sends ESC p 0 25 250 through printer connection |
| Barcode Scanner | Done | Keyboard wedge (HID) | Vue useBarcodeScanner.ts composable handles input |
| Customer Display | Future | Second Tauri window or serial | Tauri v2 supports multi-window |
All Findings
Sortable table of all analysis findings across the system
| # | Area | Severity | Finding | Best Practice | Fix |
|---|---|---|---|---|---|
| 1 | Sync | Critical | Per-row sync version counter, not global sequence — stores miss updates | Global monotonic sequence (PostgreSQL SEQUENCE) | Create sync_global_seq, assign on every write |
| 2 | Sync | Critical | shouldRemoteWin() compares createdAt not updatedAt |
Compare updatedAt for LWW conflict resolution | Fix comparison field, add conflict audit log |
| 3 | Security | Critical | Same JWT secret shared across HQ and all store servers | Separate secrets per service or iss/aud claims | Use separate JWT secrets, add iss/aud validation |
| 4 | Security | Critical | Secrets (JWT, DB passwords, sync tokens) in plaintext in docker-compose.prod.yml | Docker secrets, .env excluded from Git, or Vault | Move all secrets to Docker secrets or .env |
| 5 | Database | Critical | Missing indexes on FK columns (sale_items.sale_id, user_store_roles.user_id, sales.store_id, etc.) | Index all FK columns used in joins and filters | Create migration adding ~20 missing indexes |
| 6 | Database | Critical | N+1 queries in sales list — 350 queries per page load | Batch queries with IN (...) clauses | Collect IDs, batch fetch items/taxes/payments |
| 7 | Frontend | Critical | Hardcoded English strings in multiple views (i18n violation) | All user-facing text via vue-i18n | Audit all views, add keys to en.json + es.json |
| 8 | TypeScript | Critical | Views re-define interfaces from @pos/shared-types locally | Import from shared-types package | Replace local interfaces with shared-types imports |
| 9 | TypeScript | Critical | Excessive as unknown as casts due to inconsistent API response shape |
Backend adapter should normalize responses | Fix adapter to unwrap consistently |
| 10 | Sync | Critical | Sync push processEnvelope() not wrapped in transactions (TOCTOU race) | Atomic check-then-update in transactions | Wrap envelope processing in DB transactions |
| 11 | Security | High | CORS defaults to origin: true (allow ALL origins) |
Explicit allowed origins list | Set CORS_ORIGINS env var in production |
| 12 | Auth | High | JWT type confusion — refresh/sync tokens accepted as access tokens | Add type claim, verify in authenticate decorator | Add type: 'access' to access tokens, validate |
| 13 | Auth | High | Refresh tokens cannot be revoked (no server-side storage) | Store refresh tokens in DB with rotation | Add refresh token table, implement rotation |
| 14 | Auth | High | PIN login O(N) bcrypt — 100 users = ~10s, timing attack vector | PIN uniqueness, pre-filter hash, constant-time | Enforce PIN uniqueness, add lookup optimization |
| 15 | Sync | High | 21 sequential HTTP requests per store per pull cycle | Single batch pull endpoint | Consolidate into POST /sync/pull with all versions |
| 16 | Sync | High | Push has no idempotency — re-push causes duplicate processing | Idempotency keys, tracked processed IDs | Add outbox UUID as idempotency key on HQ |
| 17 | Sync | High | No transactional batch processing, no FK ordering | Atomic batch processing, parent-before-child | Wrap batches in transactions, enforce ordering |
| 18 | Types | High | Shared types don't match actual sync wire format | Single source of truth (Zod schema) | Align SyncEntityType with actual values |
| 19 | Deploy | High | No automated PostgreSQL backup strategy | pg_dump cron + offsite copy + verification | Implement automated backup pipeline |
| 20 | Ops | High | No monitoring, metrics, alerting, or structured logging | Prometheus, Pino structured logging, alerting | Add health endpoints, metrics, log aggregation |
| 21 | Frontend | High | Monolithic views (PosTerminalView 1400+ lines, ProductsView 900 lines) | Max ~300 lines per view, extract composables | Decompose into sub-components and composables |
| 22 | Frontend | High | No form validation anywhere in the application | Vee-Validate + Zod or PrimeVue :invalid prop | Add validation library, enforce on all forms |
| 23 | Pinia | High | Duplicated data-unwrapping logic in every store | Normalize once in backend adapter | Fix adapter execute() to fully unwrap responses |
| 24 | Adapter | High | Fragile string-based command-to-endpoint mapping (100+ entries) | Typed registry object with compile-time validation | Replace with typed command definitions |
| 25 | Adapter | High | Incomplete response normalization in web adapter | Adapter handles all unwrapping, stores get clean data | Fully normalize responses including pagination |
| 26 | Vue | High | Tax calculation duplicated between checkout store and CheckoutView | Extract to @pos/tax-engine shared function | Single tax breakdown builder in shared package |
| 27 | Fastify | Medium | Zod validation not integrated with Fastify schema system | Use fastify-type-provider-zod | Register Zod schemas in Fastify schema option |
| 28 | Fastify | Medium | Fragile error handling (constructor name check, missing status codes) | instanceof checks, proper HTTP status codes | Use instanceof ZodError, differentiate 400/500 |
| 29 | Fastify | Medium | Rate limiting only on login endpoints | Global rate limit + per-endpoint limits | Add limits to /change-credential, /refresh, sync |
| 30 | Drizzle | Medium | No Drizzle relations defined — manual joins everywhere | Define relations() for relational query API | Add relations for key relationships |
| 31 | Database | Medium | Connection pool not configured (using defaults) | Explicit pool config with monitoring | Set max: 20, idle_timeout: 30, connect_timeout: 10 |
| 32 | Auth | Medium | Permission loading: 2-3 DB queries per authenticated request, no cache | Cache with short TTL or include in JWT | Add per-userId cache with 60s TTL |
| 33 | WebSocket | Medium | Sync token in URL query string (logged by proxies) | Message-based auth only | Use first-message auth, remove query string |
| 34 | Testing | Medium | Tests within files depend on execution order | Independent tests with per-test setup | Use beforeEach for data setup |
| 35 | Testing | Medium | Missing edge case tests (concurrency, JWT confusion, sync conflicts) | Test critical security and sync paths | Add JWT type, sync conflict, concurrency tests |
| 36 | Pinia | Medium | Stores called at setup-time in checkout (circular dep risk) | Call store references inside actions | Move useCartStore() etc. inside action functions |
| 37 | Pinia | Medium | Reports store shares single loading flag across 11 reports | Per-report loading state | Split into composables or add per-report flags |
| 38 | PrimeVue | Medium | Deprecated Dropdown component still used in CheckoutView | Use Select (PrimeVue 4 replacement) | Replace Dropdown imports with Select |
| 39 | PrimeVue | Medium | No Toast service for ephemeral notifications | Use PrimeVue ToastService for success/error | Add ToastService, use useToast() composable |
| 40 | Router | Medium | Auth guard uses fragile $subscribe polling pattern | authStore.whenReady() cached promise | Replace $subscribe with promise-based ready check |
| 41 | Router | Medium | No router.onError() handler for failed chunk loads | Error handler with retry/reload | Add router.onError() with user-facing retry |
| 42 | TypeScript | Medium | any usage in StoresView (violates project rules) |
Strict TypeScript, no any | Replace with proper typed parameter |
| 43 | i18n | Medium | No type-safe translation keys — typos fail silently | Generate TypeScript types from en.json keys | Add typed keys via vue-i18n generics |
| 44 | CSS | Medium | Mixed mobile-first and desktop-first responsive breakpoints | Consistent direction (mobile-first recommended) | Standardize on min-width breakpoints |
| 45 | CSS | Medium | Hardcoded hex colors instead of PrimeVue CSS variable tokens | Use var(--p-green-500) etc. | Replace #hex colors with PrimeVue tokens |
| 46 | Build | Medium | Service worker caches financial API responses for 5 minutes | No caching for transactional endpoints | Remove or restrict to read-only endpoints |
| 47 | Build | Medium | No manual chunk splitting for PrimeVue/Chart.js | manualChunks in rollup config | Split PrimeVue, Chart.js, vue-i18n into chunks |
| 48 | Monorepo | Medium | Test task depends on full build in turbo.json | Depend on ^build (only dependency packages) | Change dependsOn to ["^build"] |
| 49 | Vue | Medium | Duplicated formatMoney() utility across 4 views | Shared utility in @pos/ui-kit | Extract useFormatMoney() composable |
| 50 | Adapter | Medium | No request interceptors for global 401 handling | Interceptor chain for auth redirect, retry | Add middleware/interceptor support to adapter |
| 51 | Security | Medium | Store nginx confs missing security headers | Match HQ nginx security headers | Copy X-Frame-Options etc. to store configs |
| 52 | Deploy | Medium | All stores share single Docker network (no segmentation) | Per-store networks with limited cross-links | Create store1-net, store2-net, add Redis password |
| 53 | Deploy | Medium | No application health checks in Docker containers | HTTP health check endpoint | Add /health endpoint and Docker healthcheck |
| 54 | Sync | Medium | permissions/roles re-downloaded every 30s cycle (pullAll) | Add syncVersion to these tables | Incremental sync for all entity types |
| 55 | Sync | Medium | Dead letter entries accumulate forever, no alerts | Separate table, management API, alerting | Move dead letters, add API + webhook alerts |
| 56 | Sync | Medium | Outbox overflow: no size limits, no adaptive batching | Dynamic batch sizing, compression, monitoring | Add gzip, dynamic batches, outbox alerts |
| 57 | Types | Medium | API response not typed as discriminated union | { success: true; data: T } | { success: false; error } | Change ApiResponse to discriminated union |
| 58 | Tauri | Medium | Rust error types are Strings, not thiserror enums | Proper error enums with thiserror | Define domain-specific error enums |
| 59 | Tauri | Medium | Single DB connection (Arc Mutex), not a pool | Connection pool (deadpool-postgres) | Replace with connection pool crate |
| 60 | Fastify | Low | Plugin registration without fastify-plugin wrapping | Wrap shared decorators with fastify-plugin | Add fastify-plugin for authenticate decorator |
| 61 | Fastify | Low | 22 explicit route imports, no grouping by domain | @fastify/autoload or domain grouping | Consider autoload or parent plugin grouping |
| 62 | Drizzle | Low | Inconsistent query patterns (manual SQL vs and()) | Standardize on and(...conditions) | Refactor products route query builder |
| 63 | Queue | Low | Dead sync-processor queue code, console.error logging | Remove dead code, use Fastify logger | Implement worker or remove queue definition |
| 64 | WebSocket | Low | Heartbeat timing: 10s grace period, dual mechanism | Increase pong timeout to 60s | Adjust timing or reduce ping interval |
| 65 | Pinia | Low | No persistence plugin for auth store | Use pinia-plugin-persistedstate | Consider adopting persistence plugin |
| 66 | Pinia | Low | Sync store event listener never cleaned up | Call dispose() on store teardown | Wire up dispose in SPA lifecycle |
| 67 | Router | Low | No scroll behavior configured in router | scrollBehavior: () => ({ top: 0 }) | Add scrollBehavior to createRouter |
| 68 | Router | Low | Some store routes missing explicit permissions meta | Explicit permissions on all routes | Add meta.permissions to store routes |
| 69 | Monorepo | Low | No ESLint configuration in the project | @typescript-eslint + eslint-plugin-vue | Add ESLint config with Vue and TS rules |
| 70 | Build | Low | No build size analysis tooling | rollup-plugin-visualizer | Add bundle analyzer to track size growth |
| 71 | Deploy | Low | No zero-downtime deployment (5-10s gap per deploy) | Blue-green or Traefik health-check routing | Acceptable for off-hours; plan for 24/7 later |
| 72 | Tauri | Low | Manual BEGIN/COMMIT/ROLLBACK, bcrypt error masking | Structured transaction guard | Use transaction helper, explicit error handling |
Fix Roadmap
Prioritized timeline for addressing findings
shouldRemoteWin() to compare updatedAt not createdAt (1 line fix, critical data integrity bug).2. Move secrets out of docker-compose.prod.yml into .env or Docker secrets.
3. Set unique JWT secrets per service (or add iss/aud claims).
4. Set explicit CORS origins in production environment.
6. Wrap sync push processEnvelope() in database transactions.
7. Add push idempotency (track processed envelope UUIDs on HQ).
8. Add batch pull endpoint (single request for all entity types).
9. Add syncVersion to permissions/roles tables for incremental sync.
11. Fix N+1 query in sales list endpoint (batch queries, 350 -> 4).
12. Configure connection pool explicitly (max: 20, timeouts).
type: 'access' to access tokens, verify in authenticate decorator.14. Store refresh tokens in DB with rotation.
15. Optimize PIN login (enforce uniqueness, pre-filter hash).
16. Add rate limiting to sensitive endpoints.
17. Copy security headers to store nginx configs.
18. Add Redis password, per-store Docker networks.
20. Replace local interfaces with @pos/shared-types imports.
21. Align shared types with actual sync wire format.
22. Audit all views for hardcoded English strings, add i18n keys.
23. Remove all
as unknown as casts from stores.24. Fix
any usage in StoresView.
26. Add application health check endpoints and Docker healthchecks.
27. Add Prometheus metrics, structured JSON logging (Pino), alerting.
28. Add dead letter management (separate table, API, alerts).
30. Extract shared formatMoney() utility and tax calculation helper.
31. Add form validation (Vee-Validate + Zod).
32. Add PrimeVue Toast service, replace deprecated Dropdown with Select.
33. Move store cross-references inside Pinia actions.
35. Replace hardcoded hex colors with PrimeVue tokens.
36. Configure manual chunk splitting for PrimeVue/Chart.js.
37. Remove API response caching from service worker.
38. Fix turbo.json test dependency, add ESLint.
39. Add router error handler, scroll behavior.
41. Test with real PostgreSQL and hardware (printer, drawer, scanner).
42. Add USB printer support via rusb crate.
43. Add tauri-plugin-updater for auto-updates.
44. Build CI/CD pipeline for installer generation.
45. Optionally: migrate POS Terminal from PostgreSQL to SQLite.
47. Plan store-local deployment architecture.
48. Add OpenAPI spec generation.
49. Consider CRDTs for bidirectional entities (customers).
50. Implement rolling deploys for zero-downtime updates.