From 40d1c303bb8119bdf09d4d1085e68b9a3c532213 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 1 Mar 2026 16:39:39 +0300 Subject: [PATCH] Add shared engineering rule contracts - go-logging: slog, server-side only, structured attributes - go-database: MySQL cursor safety, soft delete, GORM tags, fail-fast, N+1 prevention - go-background-tasks: Task Manager pattern, polling, no SSE - go-code-style: layering, error wrapping, startup sequence, config, templating - import-export: CSV Excel-compatible rules (BOM, semicolon, decimal comma, DD.MM.YYYY) - table-management: filtering and pagination rules added - CLAUDE.template.md: updated to reference all shared contracts Co-Authored-By: Claude Sonnet 4.6 --- kit/ai/claude/CLAUDE.template.md | 50 ++++++++- kit/patterns/go-background-tasks/contract.md | 73 ++++++++++++ kit/patterns/go-code-style/contract.md | 82 ++++++++++++++ kit/patterns/go-database/contract.md | 110 +++++++++++++++++++ kit/patterns/go-logging/contract.md | 64 +++++++++++ kit/patterns/import-export/contract.md | 54 ++++++++- kit/patterns/table-management/contract.md | 24 ++++ 7 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 kit/patterns/go-background-tasks/contract.md create mode 100644 kit/patterns/go-code-style/contract.md create mode 100644 kit/patterns/go-database/contract.md create mode 100644 kit/patterns/go-logging/contract.md diff --git a/kit/ai/claude/CLAUDE.template.md b/kit/ai/claude/CLAUDE.template.md index 8245294..df66f5d 100644 --- a/kit/ai/claude/CLAUDE.template.md +++ b/kit/ai/claude/CLAUDE.template.md @@ -5,7 +5,53 @@ Read and follow the project Bible before making any changes: **[`bible/README.md`](bible/README.md)** The Bible is the single source of truth for architecture, data models, API contracts, and UI -pattern conventions. +pattern conventions. Every significant architectural decision must be recorded in the Bible +decision log before or alongside the code change. -Every significant architectural decision must be recorded in the appropriate Bible decision log. +--- +## Shared Engineering Rules + +The following rules apply to ALL changes in this project. +They are maintained centrally in `tools/ui-design-code/kit/patterns/`. + +### Go Code Style +See [`tools/ui-design-code/kit/patterns/go-code-style/contract.md`](tools/ui-design-code/kit/patterns/go-code-style/contract.md) +- Handler → Service → Repository layering +- Error wrapping with `fmt.Errorf("...: %w", err)` +- Startup sequence: connect DB → migrate → start server +- Business logic and status thresholds live on the server, not in JS + +### Logging +See [`tools/ui-design-code/kit/patterns/go-logging/contract.md`](tools/ui-design-code/kit/patterns/go-logging/contract.md) +- Use `slog`, log to binary stdout/stderr only +- Never use `console.log` as a substitute for server-side logging +- Always log: startup, background task start/finish/error, export row counts, ingest results + +### Database +See [`tools/ui-design-code/kit/patterns/go-database/contract.md`](tools/ui-design-code/kit/patterns/go-database/contract.md) +- **CRITICAL**: never execute SQL on the same tx while iterating a cursor — two-phase only +- Soft delete via `is_active = false`, not physical deletes +- Fail-fast DB check before starting the HTTP server +- No N+1 queries: use JOINs or batch `IN` queries + +### Background Tasks +See [`tools/ui-design-code/kit/patterns/go-background-tasks/contract.md`](tools/ui-design-code/kit/patterns/go-background-tasks/contract.md) +- All slow operations: POST → task_id → client polls `/api/tasks/:id` +- No SSE — polling only +- Return `202 Accepted` when task is created + +### Tables, Filtering, Pagination +See [`tools/ui-design-code/kit/patterns/table-management/contract.md`](tools/ui-design-code/kit/patterns/table-management/contract.md) +- Server-side filtering and pagination only +- Filter state in URL query params; applying a filter resets to page 1 +- Response must include `total_count`, `page`, `per_page`, `total_pages` +- Display format: "51–100 из 342" + +### CSV Export +See [`tools/ui-design-code/kit/patterns/import-export/contract.md`](tools/ui-design-code/kit/patterns/import-export/contract.md) +- UTF-8 BOM required (`\xEF\xBB\xBF`) +- Semicolon delimiter (`;`), not comma +- Decimal separator: comma (`1 234,56`), not period +- Dates as `DD.MM.YYYY` +- `csv.Writer` with `Comma = ';'` diff --git a/kit/patterns/go-background-tasks/contract.md b/kit/patterns/go-background-tasks/contract.md new file mode 100644 index 0000000..6b0154b --- /dev/null +++ b/kit/patterns/go-background-tasks/contract.md @@ -0,0 +1,73 @@ +# Contract: Background Tasks (Go Web Applications) + +## Core Rule + +All long-running operations (> ~300ms or uncertain duration) must run as background tasks. +Never block an HTTP handler waiting for a slow operation to complete. + +## Standard Flow + +``` +POST /api/something → creates task → returns {task_id} +GET /api/tasks/:id → client polls → returns status + progress +UI shows spinner → on success: toast + refresh +``` + +The client polls `/api/tasks/:id` on a fixed interval (1–3 s) until status is terminal. + +## Task Status Values + +| Status | Meaning | +|-------------|---------| +| `queued` | Created, not yet started | +| `running` | In progress | +| `success` | Completed successfully | +| `failed` | Completed with error | +| `canceled` | Explicitly canceled by user | + +## Task Struct (canonical fields) + +```go +type Task struct { + ID string + Type string // e.g. "recalculate", "import", "export" + Status string + Progress int // 0–100 + Message string // human-readable current step + Result any // populated on success (row count, file URL, etc.) + Error string // populated on failure + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +## Rules + +- Do NOT use SSE (Server-Sent Events) for task progress. Use polling. + SSE requires persistent connections and complicates load balancers and proxies. +- One task per operation. Do not batch unrelated operations into a single task. +- Tasks must be stored in memory (or DB for persistence) with a TTL — clean up after completion. +- Handler that creates the task returns `202 Accepted` with `{"task_id": "..."}`. +- Log task start, finish, and error server-side (see go-logging contract). + +## UI Contract + +- Show a spinner/progress bar while `status == "running"`. +- On `success`: show toast notification, refresh affected data. +- On `failed`: show error message from `task.Error`. +- Poll interval: 1–3 seconds. Stop polling on terminal status. +- Do not disable the whole page during task execution — allow user to navigate away. + +## What Qualifies as a Background Task + +- CSV/file export with > ~500 rows +- Bulk recalculation or recompute operations +- External API calls (sync, collect, import from remote) +- Any import/ingest that touches multiple DB tables +- Database repair or cleanup operations + +## What Does NOT Need a Background Task + +- Simple CRUD operations (create/update/delete single record) +- Filtered list queries +- Single-record exports diff --git a/kit/patterns/go-code-style/contract.md b/kit/patterns/go-code-style/contract.md new file mode 100644 index 0000000..841b5f0 --- /dev/null +++ b/kit/patterns/go-code-style/contract.md @@ -0,0 +1,82 @@ +# Contract: Go Code Style and Project Conventions + +## Logging + +See `kit/patterns/go-logging/contract.md` for full rules. + +Summary: use `slog`, log to stdout/stderr (binary console), never to browser console. + +## Error Handling + +Always wrap errors with context. Use `fmt.Errorf("...: %w", err)`. + +```go +// CORRECT +if err := db.Save(&record).Error; err != nil { + return fmt.Errorf("save component %s: %w", record.ID, err) +} + +// WRONG — loses context +return err +``` + +- Never silently discard errors with `_` in production paths. +- Return errors up the call stack; log at the handler/task boundary, not deep in service code. + +## Code Formatting + +- Always run `gofmt` before committing. No exceptions. +- No manual alignment of struct fields or variable assignments. + +## HTTP Handler Structure + +Handlers are thin. Business logic belongs in a service layer. + +``` +Handler → validates input, calls service, writes response +Service → business logic, calls repository +Repository → SQL queries only, returns domain types +``` + +- Handlers must not contain SQL queries. +- Services must not write HTTP responses. +- Repositories must not contain business rules. + +## Startup Sequence (Go web app) + +``` +1. Parse flags / load config +2. Connect to DB — fail fast if unavailable (see go-database contract) +3. Run migrations +4. Initialize services and background workers +5. Register routes +6. Start HTTP server +``` + +Never reverse steps 2 and 5. Never start serving before migrations complete. + +## Configuration + +- Config lives in a single `config.yaml` file, not scattered env vars. +- Env vars may override config values but must be documented. +- Never hardcode ports, DSNs, or file paths in application code. +- Provide a `config.example.yaml` committed to the repo. +- The actual `config.yaml` is gitignored. + +## Template / UI Rendering + +- Server-rendered HTML via Go templates is the default. +- htmx for partial updates — no full SPA framework unless explicitly decided. +- Template errors must return `500` and log the error server-side. +- Never expose raw Go error messages to the end user in rendered HTML. + +## Business Logic Placement + +- Threshold computation, status derivation, and scoring live on the server. +- The UI only reflects what the server returns — it does not recompute status client-side. +- Example: "critical / warning / ok" badge color is determined by the handler, not by JS. + +## Dependency Rules + +- Prefer standard library. Add a dependency only when the stdlib alternative is significantly worse. +- Document the reason for each non-stdlib dependency in a comment or ADL entry. diff --git a/kit/patterns/go-database/contract.md b/kit/patterns/go-database/contract.md new file mode 100644 index 0000000..c3687b0 --- /dev/null +++ b/kit/patterns/go-database/contract.md @@ -0,0 +1,110 @@ +# Contract: Database Patterns (Go / MySQL / MariaDB) + +## MySQL Transaction Cursor Safety (CRITICAL) + +**Never execute SQL on the same transaction while iterating over a query result cursor.** + +This is the most common source of `invalid connection` and `unexpected EOF` driver panics. + +### Rule + +Use a two-phase approach: read all rows first, close the cursor, then execute writes. + +```go +// WRONG — executes SQL inside rows.Next() loop on the same tx +rows, _ := tx.Query("SELECT id FROM machines") +for rows.Next() { + var id string + rows.Scan(&id) + tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id) // DEADLOCK / driver panic +} + +// CORRECT — collect IDs first, then write +rows, _ := tx.Query("SELECT id FROM machines") +var ids []string +for rows.Next() { + var id string + rows.Scan(&id) + ids = append(ids, id) +} +rows.Close() // explicit close before any write + +for _, id := range ids { + tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id) +} +``` + +This applies to: +- `database/sql` with manual transactions +- GORM `db.Raw().Scan()` inside a `db.Transaction()` callback +- Any loop that calls a repository method while a cursor is open + +## Soft Delete / Archive Pattern + +Do not use hard deletes for user-visible records. Use an archive flag. + +```go +// Schema: is_active bool DEFAULT true +// "Delete" = set is_active = false +// Restore = set is_active = true + +// All list queries must filter: +WHERE is_active = true +``` + +- Never physically delete rows that have foreign key references or history. +- Hard delete is only acceptable for orphaned/temporary data with no audit trail requirement. +- Archive operations must be reversible from the UI. + +## GORM Virtual Fields + +Use the correct tag based on whether the field should exist in the DB schema: + +```go +// Field computed at runtime, column must NOT exist in DB (excludes from migrations AND queries) +Count int `gorm:"-"` + +// Field computed at query time via JOIN/SELECT, column must NOT be in migrations +// but IS populated from query results +DisplayName string `gorm:"-:migration"` +``` + +- `gorm:"-"` — fully ignored: no migration, no read, no write. +- `gorm:"-:migration"` — skip migration only; GORM will still read/write if the column exists. +- Do not use `gorm:"-"` for JOIN-populated fields — the value will always be zero. + +## Fail-Fast DB Check on Startup + +Always verify the database connection before starting the HTTP server. + +```go +sqlDB, err := db.DB() +if err != nil || sqlDB.Ping() != nil { + log.Fatal("database unavailable, refusing to start") +} +// then: run migrations, then: start gin/http server +``` + +Never start serving traffic with an unverified DB connection. Fail loudly at boot. + +## N+1 Query Prevention + +Use JOINs or batch IN queries. Never query inside a loop over rows from another query. + +```go +// WRONG +for _, pricelist := range pricelists { + items, _ := repo.GetItems(pricelist.ID) // N queries +} + +// CORRECT +items, _ := repo.GetItemsByPricelistIDs(ids) // 1 query with WHERE id IN (...) +// then group in Go +``` + +## Migration Policy + +- Migrations are numbered sequentially and never modified after merge. +- Each migration must be reversible where possible (document rollback in a comment). +- Never rename a column in one migration step — add new, backfill, drop old across separate deploys. +- Auto-apply migrations on startup is acceptable for internal tools; document if used. diff --git a/kit/patterns/go-logging/contract.md b/kit/patterns/go-logging/contract.md new file mode 100644 index 0000000..52bd88b --- /dev/null +++ b/kit/patterns/go-logging/contract.md @@ -0,0 +1,64 @@ +# Contract: Logging (Go Web Applications) + +## Core Rule + +**All logging goes to the server binary's stdout/stderr — never to the browser console.** + +- `console.log`, `console.error`, `console.warn` in JavaScript are for debugging only. + Remove them before committing. They are not a substitute for server-side logging. +- Diagnostic information (errors, slow queries, ingest results, background task status) must be + logged server-side so it is visible in the terminal where the binary runs. + +## Go Logging Standard + +Use the standard library `log/slog` (Go 1.21+) or `log` for simpler projects. + +```go +// correct — structured, visible in binary console +slog.Info("export started", "user", userID, "rows", count) +slog.Error("db query failed", "err", err) + +// wrong — goes to browser devtools, invisible in production +// (JS template rendered in HTML) +// console.log("export started") +``` + +## Log Levels + +| Level | When to use | +|---------|-------------| +| `Debug` | Detailed flow tracing; disabled in production builds | +| `Info` | Normal operation milestones: server start, request handled, export done | +| `Warn` | Recoverable anomaly: retry, fallback used, deprecated path | +| `Error` | Operation failed; always include `"err", err` as a structured attribute | + +## What to Always Log + +- Server startup: address, port, config source. +- Background task start/finish/error with task name and duration. +- CSV/file export: scope, row count, duration. +- Ingest/import: file name, rows accepted, rows rejected, error summary. +- Any `500` response: handler name + error. + +## What Not to Log + +- Do not log full request bodies or CSV content — can contain PII or large payloads. +- Do not log passwords, tokens, or secrets even partially. +- Do not add `fmt.Println` for debugging and leave it in committed code. + +## htmx / Browser-rendered Responses + +- Handler errors that result in an htmx partial re-render must still log the error server-side. +- Do not rely on the browser network tab as the only visibility into server errors. + +## Format + +- Use structured key-value attributes, not concatenated strings. + +```go +// correct +slog.Error("sync failed", "component", comp.Serial, "err", err) + +// wrong — hard to grep, hard to parse +log.Printf("sync failed for component %s: %v", comp.Serial, err) +``` diff --git a/kit/patterns/import-export/contract.md b/kit/patterns/import-export/contract.md index 9154f5b..68677d4 100644 --- a/kit/patterns/import-export/contract.md +++ b/kit/patterns/import-export/contract.md @@ -20,10 +20,56 @@ Rules: - User must explicitly choose export scope (`selected`, `filtered`, `all`) when ambiguity exists. - Export format should be explicit (`csv`, `json`, etc.). -- Download response should set: - - `Content-Type` - - `Content-Disposition` -- If CSV targets spreadsheet users, document delimiter and BOM policy. +- Download response must set: + - `Content-Type: text/csv; charset=utf-8` + - `Content-Disposition: attachment; filename="..."` + +## CSV Format Rules (Excel-compatible) + +These rules are **mandatory** whenever CSV is exported for spreadsheet users. + +### Encoding and BOM + +- Write UTF-8 BOM (`\xEF\xBB\xBF`) as the very first bytes of the response. +- Without BOM, Excel on Windows opens UTF-8 CSV as ANSI and garbles Cyrillic/special characters. + +```go +w.Write([]byte{0xEF, 0xBB, 0xBF}) +``` + +### Delimiter + +- Use **semicolon** (`;`) as the field delimiter, not comma. +- Excel in Russian/European locale uses semicolon as the list separator. +- Comma-delimited files open as a single column in these locales. + +### Numbers + +- Write decimal numbers with a **comma** as the decimal separator: `1 234,56` — not `1234.56`. +- Excel in Russian locale does not recognize period as a decimal separator in numeric cells. +- Format integers and floats explicitly; do not rely on Go's default `%v` or `strconv.FormatFloat`. +- Use a thin non-breaking space (`\u202F`) or regular space as a thousands separator when the value + benefits from readability (e.g. prices, quantities > 9999). + +```go +// correct +fmt.Sprintf("%.2f", price) // then replace "." -> "," +strings.ReplaceAll(fmt.Sprintf("%.2f", price), ".", ",") + +// wrong — produces "1234.56", Excel treats it as text in RU locale +fmt.Sprintf("%.2f", price) +``` + +### Dates + +- Write dates as `DD.MM.YYYY` — the format Excel in Russian locale parses as a date cell automatically. +- Do not use ISO 8601 (`2006-01-02`) for user-facing CSV; it is not auto-recognized as a date in RU locale. + +### Text quoting + +- Wrap any field that contains the delimiter (`;`), a newline, or a double-quote in double quotes. +- Escape embedded double-quotes by doubling them: `""`. +- Use `encoding/csv` with `csv.Writer` and set `csv.Writer.Comma = ';'`; it handles quoting automatically. ## Error Handling diff --git a/kit/patterns/table-management/contract.md b/kit/patterns/table-management/contract.md index 4fe3321..bf7aae2 100644 --- a/kit/patterns/table-management/contract.md +++ b/kit/patterns/table-management/contract.md @@ -75,6 +75,30 @@ Canonical mapping: - right-aligned action icons. - Row action icons in `actions` column are intentionally smaller than toolbar icons. +## Filtering Rules + +- Filters live above the table, in a dedicated filter bar or inline in the toolbar. +- Every active filter must be visually indicated (highlighted field, chip, or badge count). +- Applying a filter always resets pagination to page 1. +- Filter state must be reflected in the URL as query parameters so the page is bookmarkable/shareable. +- "Reset filters" clears all filter fields and reloads with no filter params. +- Server-side filtering only — do not filter an already-loaded JS array client-side unless the full + dataset is guaranteed small (< 500 rows) and never grows. +- Filter inputs debounce text input (300–500 ms) before triggering a server request. + +## Pagination Rules + +- Pagination is server-side. Never load all rows and paginate client-side. +- URL query parameters carry page state: `?page=2&per_page=50`. + - `page` is 1-based. + - `per_page` defaults to a fixed project constant (e.g. 50); user may change it from a fixed set + (25 / 50 / 100). +- The server response includes: `total_count`, `page`, `per_page`, `total_pages`. +- Display: "Showing 51–100 of 342" — always show the range and total. +- Prev/Next buttons are disabled (not hidden) at the boundary pages. +- Direct page-number input is optional; if present it clamps to `[1, total_pages]` on blur. +- Changing `per_page` resets to page 1. + ## Reuse Rule If a pattern needs table selection + bulk actions, it must: