diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2c6bb908..6ef045e6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -209,6 +209,98 @@ jobs: api.log vite.log + # Guards the asset-delivery wins from docs/perf/2026-08-02-baseline.md: + # response compression (~70% of transfer) and chunk grouping (55 -> 13 + # requests on cold load). Both are invisible to every other job — the app + # renders identically either way, just slower, so only a browser measuring + # the built bundle catches a regression. + # + # Runs against the PRODUCTION build deliberately: chunk groups only apply to + # `vite build`, and non-dev environments are what reference the built + # manifest. Asserts on structure (request count, compression ratio), never on + # milliseconds, so shared-runner noise cannot make it flaky. + # + # The other three guards (compression headers, canonical menu URLs, dialect + # branch selection) are plain pytest and already run in `python-tests`. + perf-guards: + name: Perf guards (Playwright) + runs-on: ubuntu-latest + env: + # SQLite keeps the job self-contained — these guards measure asset + # delivery, which does not depend on row volumes. + SM_DATABASE_URL: sqlite+aiosqlite:///./app.db + PERF_BASE_URL: http://localhost:8000 + PERF_BUILD: ci-prod + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.0.0 + with: + enable-cache: true + cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + - run: make install + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('uv.lock') }} + - name: Install Playwright chromium + run: | + if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then + uv run --project host playwright install-deps chromium + else + uv run --project host playwright install --with-deps chromium + fi + - run: make gen-pages + - run: make build + - run: uv run --project host alembic -c host/alembic.ini upgrade heads + # The production config lives on THIS step only, never job-wide. Under + # SM_ENVIRONMENT=production the simple_module_test pytest plugin fails to + # import — it builds BackgroundTasksSettings() eagerly and those reject a + # localhost broker — so exporting it job-wide stops pytest from starting + # at all. The server needs it; the test process must not see it. + - name: Start API + env: + # A non-dev environment is what makes the host serve the built + # manifest instead of pointing at the Vite dev server. + SM_ENVIRONMENT: production + SM_SECRET_KEY: ci-perf-secret-key-not-a-real-secret-000000000000 + SM_USERS_RESET_PASSWORD_TOKEN_SECRET: ci-perf-reset-secret-000000000000000000 + SM_USERS_VERIFICATION_TOKEN_SECRET: ci-perf-verify-secret-00000000000000000 + SM_USERS_BOOTSTRAP_EMAIL: admin@example.com + SM_USERS_BOOTSTRAP_PASSWORD: admin + # Keycloak excluded (SM020: one auth provider). BackgroundTasks + # excluded because its DB-hydrated settings reject a localhost broker + # under SM_ENVIRONMENT=production. + SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","FileStorage","FeatureFlags","AuditLog","Branding"]' + run: | + uv run --project host uvicorn host.main:app --port 8000 > api.log 2>&1 & + echo $! > api.pid + - name: Wait for API + run: | + for i in $(seq 1 60); do + curl -sf http://localhost:8000/health > /dev/null && echo "api ready" && exit 0 + sleep 1 + done + echo "api did not come up in time"; cat api.log || true; exit 1 + - name: Verify built assets are being served + run: | + # If this regresses to the Vite dev path the guards would measure the + # wrong bundle and pass vacuously. + curl -sf http://localhost:8000/users/login | grep -q '/static/dist/assets/' \ + || { echo "server is not serving built assets"; exit 1; } + - run: uv run pytest -m "perf and e2e" tests/perf/test_page_load.py tests/perf/test_asset_integrity.py -v -s + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v6 + with: + name: perf-guards-api-log + path: api.log + file-size-check: name: File size (300-line cap) runs-on: ubuntu-latest @@ -252,6 +344,7 @@ jobs: - js-tests - js-build - e2e-smoke + - perf-guards - file-size-check - package-build if: always() diff --git a/CLAUDE.md b/CLAUDE.md index 62b5660d..c10af6fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ modules/// `register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md). **Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request: -`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → SecurityHeaders → Session → → Tenant (opt-in) → Locale → InertiaLayoutData → app`. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. +`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → → Tenant (opt-in) → Locale → InertiaLayoutData → app`. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. **Database**: per-module `Base` via `create_module_base("")`. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout. diff --git a/Makefile b/Makefile index 1c7ca9d2..c3485c55 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker # Install install: @@ -52,6 +52,15 @@ test-e2e: ## Run end-to-end browser smoke tests (requires `mak bench: ## Run pytest-benchmark suite (tests/benchmarks). Override args with BENCH_ARGS=... uv run pytest -m perf --benchmark-enable --benchmark-columns=min,mean,median,max,stddev,ops,rounds $(BENCH_ARGS) tests/benchmarks +# Navigation benchmark — click-to-paint for Inertia client-side navigations. +# Separate from `bench` because it needs a live server and a browser, whereas +# `bench` runs in-process. Point PERF_BASE_URL at the server under test and set +# PERF_BUILD=dev|prod so the report records which build produced the numbers. +PERF_ROUNDS ?= 20 +PERF_BUILD ?= dev +bench-nav: ## Navigation benchmark (needs a running server + `uv run playwright install chromium`) + PERF_ROUNDS=$(PERF_ROUNDS) PERF_BUILD=$(PERF_BUILD) uv run pytest -m "perf and e2e" tests/perf -v -s + # Memory profiling with memray. Point TARGET at any runnable script/module. # Examples: # make memray-run TARGET="-m pytest tests/benchmarks -m perf --benchmark-disable" @@ -77,6 +86,7 @@ LOCUST_ARGS ?= -u 20 -r 5 -t 30s loadtest-seed: ## Seed realistic faker data into $$SM_DATABASE_URL (users + audit) uv run python tests/loadtest/seed.py $(SEED_ARGS) + loadtest: ## Run locust against a server already on $(LOCUST_HOST) uv run locust -f tests/loadtest/locustfile.py --host $(LOCUST_HOST) --headless $(LOCUST_ARGS) diff --git a/docs/perf/2026-08-02-baseline.md b/docs/perf/2026-08-02-baseline.md new file mode 100644 index 00000000..0dfe61bd --- /dev/null +++ b/docs/perf/2026-08-02-baseline.md @@ -0,0 +1,858 @@ +# Navigation performance: baseline and results + +**Date:** 2026-08-02 +**Spec:** [../superpowers/specs/2026-08-02-navigation-perf-design.md](../superpowers/specs/2026-08-02-navigation-perf-design.md) + +## Environment + +| | | +|---|---| +| Machine | darwin, local | +| Database | **SQLite** (`app.db`), 5 000 catalog products / 12 categories | +| Modules | `Auth, FeatureFlags, Settings, FileStorage, Users, AuditLog, Dashboard, Permissions, Branding, Catalog` | +| Builds | dev (`npm run dev`, Vite) and prod (`npm run build`, `SM_ENVIRONMENT=production`) | +| Rounds | 20 navigations per route, median reported | + +> **Parts 1–4 are SQLite measurements. Part 6 re-runs them on Postgres and +> corrects them.** Docker was unavailable for the first pass, so the initial +> baseline used SQLite with small tables. That turned out to matter: on +> Postgres with 10k users / 100k audit rows / 5k products, server time is +> **not** the 4–15 % that SQLite suggested — it reaches 57 % on the audit-log +> route. Read Part 6 before drawing conclusions from Part 1 or Part 3. + +## Part 1 — Client-side navigation (click → painted) + +Measured by hooking Inertia's `inertia:start` / `inertia:finish` document +events and clicking real sidebar links. `page.goto()` is deliberately not used: +it is a full document load, a different and much heavier path than what a user +experiences clicking around an already-loaded app. + +Phases partition the total: + +``` +start ──────────────── finish ──────── painted + └── request_ms ──┘└── render_ms ─┘ + └── ttfb_ms ─┘ +``` + +### dev build + +| Route | total | ttfb (server) | client_request | render | bytes | +|---|---|---|---|---|---| +| dashboard | 33.8 ms | 1.2 | 24.1 | 8.6 | 2 593 | +| catalog_list | 33.9 ms | 4.1 | 26.6 | 3.6 | 9 804 | +| users_admin | 35.6 ms | 4.2 | 30.8 | 0.7 | 2 943 | +| audit_log | 44.0 ms | 3.1 | 36.4 | 4.5 | 14 433 | +| catalog_detail | 31.7 ms | 2.6 | 20.9 | 8.0 | 2 369 | + +### prod build + +| Route | total | ttfb (server) | client_request | render | bytes | +|---|---|---|---|---|---| +| dashboard | 32.5 ms | 1.5 | 16.4 | 14.7 | 2 379 | +| catalog_list | 32.9 ms | 4.7 | 18.1 | 10.2 | 9 635 | +| users_admin | 31.5 ms | 4.7 | 16.7 | 10.5 | 2 774 | +| audit_log | 35.1 ms | 4.1 | 21.0 | 10.1 | 16 367 | +| catalog_detail | 31.2 ms | 2.9 | 14.7 | 13.6 | 2 200 | + +### What this says + +1. **Server time is 4–15 % of a navigation.** 1.5–5.5 ms out of ~32 ms. +2. **Prod ≈ dev.** 32.5 vs 33.8 ms on the dashboard. Vite's dev transform is + *not* the problem — **suspect S4 is falsified**. +3. **Total is insensitive to payload.** `catalog_detail` (2.2 KB) costs 31.2 ms; + `audit_log` (16.4 KB) costs 35.1 ms. A 7× payload difference buys 4 ms. +4. **One XHR per navigation** — verified by resource timing. No hidden N+1. +5. At ~32 ms, client-side navigation is **already fast**. Nothing in the + server request path can meaningfully improve it. + +Single-navigation breakdown (prod, `/catalog`), from the Resource Timing API: + +| Phase | ms | +|---|---| +| `inertia:start` → `inertia:finish` | 24.5 | +| ...of which the XHR itself | 19.6 | +| ...of which server (ttfb) | 7.9 | +| Inertia component resolve + render | ~4.9 | + +**Measurement caveat:** `painted` is sampled in a `requestAnimationFrame` +callback after `finish`, so `render_ms` absorbs up to one frame (~16.7 ms) of +idle wait. Treat `render_ms` as an upper bound, not as CPU work. + +## Part 2 — Cold full page load + +This is where the cost actually lives, and where the original complaint most +likely originates: any first visit or hard refresh pays the whole bundle. + +Measured on localhost (prod build, before any change): + +| Route | FCP | JS transfer | CSS transfer | requests | +|---|---|---|---|---| +| /catalog | 200 ms | 679 KB | 137 KB | 55 | +| /dashboard/ | 196 ms | 685 KB | 137 KB | 63 | + +**~816 KB across ~60 requests.** The CSS is 139 491 bytes on the wire but only +~21 KB gzipped — which was the tell: `curl -D-` showed **no `Content-Encoding` +header at all**, and `GZipMiddleware` appeared nowhere in the hosting layer. +Asset *caching* was correct (`immutable`, from `984443b`); compression had +simply never been added. + +On localhost this costs almost nothing — bandwidth is effectively infinite, so +816 KB and 249 KB paint at the same time. It is invisible until throttled. + +## Part 3 — Ranking the suspects + +| Suspect | Hypothesis | Measured | Verdict | +|---|---|---|---| +| **S1** | Shared props built for `/api/*` that discard them | Server time is 1.5–5.5 ms total; the discarded work is a fraction of that | **Dropped** — below the 5 % floor | +| **S2** | Menus + permissions recomputed per request | `menu_get_for_user` 10.5 µs, `expand_permissions` 7.2 µs → **~18 µs**, ≈0.05 % of a navigation | **Dropped** — 3 orders of magnitude too small | +| **S3** | Static shared props re-sent every navigation | menus 1 161 B + permissions 362 B = **~15 %** of payload — but payload barely affects total (finding 3) | **Dropped** — real but does not convert to time | +| **S4** | Vite dev-mode transform overhead | prod 32.5 ms vs dev 33.8 ms | **Falsified** | +| **NEW** | **Assets served uncompressed** | 816 KB → 249 KB; FCP halves under throttling | **Fixed** | + +All three a-priori suspects were wrong or marginal. The actual win was not on +the list — it was only visible once transfer size was measured. This is the +argument for measuring before optimizing, in one table. + +## Part 4 — The fix, and its result + +Installed `GZipMiddleware` (`minimum_size=500`) in the hosting pipeline, +positioned inside `CorrelationId`/`RequestLogging` but outside everything that +produces a body — including the `/static` mount, where it matters most. + +Execution order is now: + +``` +(ProxyHeaders) → CorrelationId → RequestLogging → GZip → SecurityHeaders + → Session → → Tenant → Locale → InertiaLayoutData → app +``` + +A/B'd by varying only the `Accept-Encoding` request header, so both arms hit +the same running server. Emulated link: **4 Mbps, 40 ms latency** (CDP). + +| Route | metric | uncompressed | compressed | change | +|---|---|---|---|---| +| /catalog | **FCP** | 2 268 ms | **1 168 ms** | **−48.5 %** | +| /catalog | load | 1 487 ms | 582 ms | −60.9 % | +| /catalog | transfer | 858 KB | 259 KB | **−69.8 %** | +| /dashboard/ | **FCP** | 2 252 ms | 1 140 ms | −49.4 % | +| /dashboard/ | load | 1 429 ms | 529 ms | −63.0 % | +| /dashboard/ | transfer | 857 KB | 261 KB | −69.5 % | + +**First Contentful Paint halved.** On localhost the same change shows ~0 ms +improvement — the win exists only where bandwidth is finite, which is +everywhere except a developer's own machine. + +Locked in by `tests/perf/test_page_load.py::test_compression_materially_reduces_cold_load`, +which fails if transfer saving drops below 40 %. + +## Part 5 — What is now the largest remaining cost + +In priority order, for whoever picks this up next: + +1. **~60 requests and ~228 KB of gzipped JS on a cold load.** The `main` chunk + alone is 488 KB raw / 154 KB gzipped. Route-level lazy loading already + works; the shared vendor chunk is the target. Likely the single biggest + remaining win for perceived speed. +2. **`client_request_ms`, 12–21 ms per navigation.** Of a 19.6 ms XHR only + 7.9 ms is server; the rest is queueing, download, and Inertia's resolve. + Worth a closer look, but the ceiling is small. +3. **Postgres verification.** Everything here ran on SQLite. Query-plan work + (the composite `(status, created_at)` index in particular) is unverified + against a real planner. +4. **Locust + memray under load.** Not run — the throwaway Postgres database + was unavailable. The harness and the catalog traffic mix are in place and + ready. + +## Reproducing + +```sh +# Terminal 1 — server (prod build) +npm run build +SM_SECRET_KEY=... SM_USERS_RESET_PASSWORD_TOKEN_SECRET=... \ + SM_USERS_VERIFICATION_TOKEN_SECRET=... SM_ENVIRONMENT=production \ + SM_MODULES_ENABLED='["Auth","FeatureFlags","Settings","FileStorage","Users","AuditLog","Dashboard","Permissions","Branding","Catalog"]' \ + uv run --project host uvicorn host.main:app --port 8000 --host 127.0.0.1 + +# Terminal 2 — benchmarks +uv run playwright install chromium +make bench-nav PERF_BUILD=prod PERF_ROUNDS=20 +make bench BENCH_ARGS=tests/benchmarks/test_shared_props_bench.py +``` + +`BackgroundTasks` is excluded because its settings are DB-hydrated and it +refuses a localhost broker under `SM_ENVIRONMENT=production`; `Keycloak` is +excluded to avoid `SM020` (two auth providers). The same module set is used for +the dev run so the two are comparable. + +--- + +# Part 6 — Postgres re-baseline + +Docker became available, so everything above was re-run against the real +stack. **This part corrects Parts 1 and 3.** + +| | | +|---|---| +| Database | **Postgres 16** (`smpy_loadtest`, PostGIS image), shared `dev-services` | +| Volumes | 10 000 users · 100 000 audit entries · 5 000 products / 12 categories | +| Post-seed | `ANALYZE` run | +| Build | prod (`npm run build`, `SM_ENVIRONMENT=production`) | + +## 6.1 — The SQLite baseline was misleading + +| Route | ttfb SQLite | ttfb **Postgres** | total SQLite | total **Postgres** | +|---|---|---|---|---| +| dashboard | 1.5 ms | 1.7 ms | 32.5 ms | 32.5 ms | +| catalog_list | 4.7 ms | **14.9 ms** | 32.9 ms | **49.8 ms** | +| users_admin | 4.7 ms | **29.7 ms** | 31.5 ms | **47.6 ms** | +| audit_log | 4.1 ms | **49.3 ms** | 35.1 ms | **86.2 ms** | + +On SQLite at small scale, server time was 4–15 % of a navigation and the +conclusion was "the server is not the bottleneck". On Postgres at realistic +volumes it is **57 % of the audit-log navigation**. The correction matters: +list endpoints *are* a real cost once the tables are real. + +Dashboard and catalog_detail are unchanged — neither runs a list query. + +## 6.2 — Second finding: 5 of 8 sidebar links 307-redirected + +Sidebar links pointed at `/catalog` while the route was registered at +`/catalog/`. Starlette 307s to the canonical path and the client follows, so +everything *works* — it just costs a full extra round trip on every navigation +to that page. Seven menu URLs were affected: + +``` +/admin/background-tasks /audit_log /branding /catalog +/feature_flags /file-storage /settings +``` + +No functional test caught this, because nothing is broken — only slower. On +localhost the hop is ~8 ms; on the 40 ms-latency profile it roughly doubles +the navigation, and it is worse on mobile. + +**Fix:** point each menu item at the canonical path. Locked in by +`framework/hosting/tests/test_menu_urls_are_canonical.py`, which fails if any +registered menu URL redirects or 404s — so this cannot recur for future modules. + +### Result (prod, Postgres, 20 rounds, median) + +| Route | with redirect | canonical | change | +|---|---|---|---| +| catalog_list | 49.8 ms | **33.0 ms** | **−34 %** | +| audit_log | 86.2 ms | **50.8 ms** | **−41 %** | +| users_admin | 47.6 ms | 34.1 ms | −28 %¹ | +| dashboard | 32.5 ms | 32.3 ms | — (never redirected) | +| catalog_detail | 31.5 ms | 31.1 ms | — (never redirected) | + +¹ `/users/admin` never redirected; its improvement is warm-cache variance, not +the fix. Reported for completeness rather than claimed as a gain. + +## 6.3 — Where the remaining audit-log server time goes + +`audit_log` is still the slowest route at 50.8 ms, with 30.4 ms of ttfb. The +browse view issues three queries (`EXPLAIN ANALYZE`, 100 028 rows): + +| Query | Time | Note | +|---|---|---| +| `SELECT count(*)` | 11.4 ms | index-only scan of all 100 k rows for one number | +| paginated `SELECT … LIMIT 20 OFFSET n` | 2.0–2.8 ms | fine — `created_at` index handles it; only 2.8 ms even at OFFSET 3980 | +| `SELECT DISTINCT entity_type` | 11.5–13.9 ms | scans all 100 k rows to return **8 values** | + +≈26 ms of the 30 ms ttfb. Pagination is *not* the problem — the two full scans are. + +## 6.4 — Load test (locust) + +20 concurrent users, 60 s, against Postgres at the volumes above, with the +catalog traffic mix added. + +**3662 requests · 0 failures · 62.6 req/s · aggregate p50 11 ms, p95 26 ms, p99 37 ms** + +| Endpoint | p50 | p95 | p99 | +|---|---|---|---| +| /dashboard/ | 2 ms | 5 ms | 6 ms | +| /api/dashboard/stats | 2 ms | 4 ms | 6 ms | +| /api/settings/modules | 3 ms | 6 ms | 19 ms | +| /api/permissions/ | 3 ms | 6 ms | 93 ms | +| /api/catalog/products | 10 ms | 17 ms | 21 ms | +| /api/users/admin | 10 ms | 16 ms | 23 ms | +| /catalog/ | 11 ms | 18 ms | 34 ms | +| /api/audit_log/ | 14 ms | 25 ms | 31 ms | +| /api/catalog/products?q | 15 ms | 29 ms | 39 ms | +| /users/admin | 17 ms | 30 ms | 38 ms | +| /api/users/admin?q | 23 ms | 38 ms | 52 ms | + +No errors, no saturation at this concurrency. Search endpoints (`?q`, an +unanchored `ILIKE '%term%'`) are the slowest — expected, since a leading +wildcard cannot use a btree index. + +## 6.5 — Revised remaining work + +1. **`SELECT DISTINCT entity_type` — 11.5 ms for 8 values, and it grows with + the table.** A recursive-CTE loose index scan measures **2.9 ms vs 13.9 ms** + (4.7× faster) and scales with distinct-value count rather than row count, so + it stays flat as the table grows. **Not applied:** SQLite rejects that CTE + form (`Error: in prepare, near "("`), so it would mean dialect-specific SQL + in a module service. The alternative — caching the values — trades that for + invalidation semantics. This is a genuine design call and is left open + rather than guessed at. +2. **`count(*)` on every browse render — 11.4 ms.** Exact totals are needed for + the pager. Options: cache per filter-set, or use a planner estimate above + some row threshold. +3. **Cold-load bundle: ~60 requests, ~228 KB gzipped JS.** The `main` chunk is + 154 KB gzipped by itself. Unchanged from Part 5 and still the largest + single item for perceived first-load speed. +4. **Unanchored `ILIKE '%term%'` search** (p50 23 ms on users). A trigram index + (`pg_trgm`) would fix it on Postgres, but it is dialect-specific — same + tradeoff as item 1. + +## 6.6 — Summary of what shipped + +| Change | Measured effect | +|---|---| +| `GZipMiddleware` | FCP **−48.5 %** (2268→1168 ms), transfer **−69.8 %** (858→259 KB) at 4 Mbps/40 ms | +| Canonical menu URLs | audit_log nav **−41 %** (86.2→50.8 ms), catalog nav **−34 %** (49.8→33.0 ms) | + +Both are guarded by regression tests. Neither was among the four suspects the +design doc started with. + +--- + +# Part 7 — Bundle chunking + +## 7.1 — The bundle was over-split, not over-sized + +`ANALYZE=1 npm run build` (the repo's existing `rollup-plugin-visualizer` hook) +gives the composition of the 149 KB-gzipped `main` chunk: + +| Package | gz | Note | +|---|---|---| +| react-dom | 84.9 KB | unavoidable | +| lodash-es | 35.4 KB | transitive via `@inertiajs/core` + `laravel-precognition` | +| axios | 33.1 KB | transitive via `@inertiajs/core` | +| lucide-react | 28.7 KB | `NavIcon.tsx` eagerly imports 74 icons; ~10 are used | +| @inertiajs/core | 25.9 KB | | +| i18next | 18.7 KB | | + +(Percentages are per-module and double-count shared modules, so they rank +rather than sum.) + +Nothing here is easily removable — `lodash-es` and `axios` are Inertia's own +dependencies. But the chunk *count* was the real problem: + +| | before | +|---|---| +| JS chunks | 88 | +| chunks under 2 KB | **40**, holding 31.7 KB between them | +| requests on cold load | 55–63 | + +40 HTTP requests for 32 KB of code. uvicorn speaks HTTP/1.1, so the browser +opens ~6 connections; 60 requests is ~10 serial round trips, and at 40 ms +latency that is ~400 ms of pure waiting before first paint. + +## 7.2 — Fix + +Declared `build.rollupOptions.output.advancedChunks` groups in +`host/client_app/vite.config.ts`: `react-vendor` (react / react-dom / +scheduler), `vendor` (remaining `node_modules`), and `ui` (shared +`packages/ui` components, `minShareCount: 2`), with `minSize: 20_000`. + +Two Rolldown specifics worth knowing, both hit during this work: + +* Vite 8 bundles with **Rolldown**, so Rollup's `experimentalMinChunkSize` + does not exist — it type-errors. The equivalent is `advancedChunks`. +* Rolldown **silently ignores `minSize` unless `groups` is also declared** + (it warns: "Manual code splitting options (minSize) specified without + groups"). Setting `minSize` alone changed nothing. + +React is split from the rest of vendor deliberately: it changes only on a +dependency bump, so a normal deploy leaves it cached while app chunks +re-download. + +## 7.3 — Result (prod, 4 Mbps / 40 ms) + +| Metric | before | after | change | +|---|---|---|---| +| **FCP** /catalog/ | 1128 ms | **748 ms** | **−34 %** | +| **FCP** /dashboard/ | 1140 ms | **804 ms** | −29 % | +| requests | 55 | **13** | **−76 %** | +| JS chunks | 88 | 50 | −43 % | +| chunks < 2 KB | 40 | 15 | −63 % | +| transfer | 259 KB | 247 KB | −5 % | + +**Transfer barely moved; FCP dropped a third.** That is the whole finding — +the cost was round trips, not bytes. Chasing bundle *size* would have missed it. + +Client-side navigation is unchanged, as expected (all within noise): + +| Route | before | after | +|---|---|---| +| dashboard | 32.3 ms | 32.7 ms | +| catalog_list | 33.0 ms | 33.4 ms | +| audit_log | 50.8 ms | 52.8 ms | + +## 7.4 — Cumulative effect of all three fixes + +Cold load of `/catalog/` at 4 Mbps / 40 ms, from the original build: + +| | FCP | transfer | requests | +|---|---|---|---| +| Original (no compression, 88 chunks) | **2268 ms** | 858 KB | 55 | +| \+ GZip | 1168 ms | 259 KB | 55 | +| \+ chunk groups | **748 ms** | 247 KB | **13** | + +**First Contentful Paint: 2268 ms → 748 ms, a 67 % reduction.** + +## 7.5 — Still open + +* **`NavIcon.tsx` imports 74 lucide icons (~28.7 KB gz) for ~10 in use.** The + icon name arrives from the server as a string (`MenuItem.icon`), so the map + is the framework's contract with module authors. Trimming it narrows that + API; generating it at build time from the registered menu items would keep + the API and drop the weight, at the cost of build complexity. Not done. +* The `DISTINCT` and `count(*)` items from §6.5 are unchanged. + +--- + +# Part 8 — Postgres skip scan for `distinct_entity_types` + +Taken up after an explicit decision to optimize for Postgres and not hold back +for SQLite parity. + +## 8.1 — Change + +`audit_log`'s browse view calls `distinct_entity_types()` on every render to +fill a filter dropdown. `SELECT DISTINCT` makes the planner walk every index +entry to produce a handful of values — cost proportional to **rows**. + +A recursive *skip scan* (loose index scan) hops value-to-value through +`ix_audit_entry_entity_type` instead: one index seek per **distinct value**. +Its cost tracks the number of distinct values, so it stays flat as the table +grows. + +SQLite rejects that CTE form, so `_distinct_stmt_for_dialect()` returns the +plain query there. The dialect is read from `session.bind.dialect.name`, whose +values (`"postgresql"` / `"sqlite"`) match `DatabaseProvider` exactly. + +## 8.2 — Verified against the live database + +Both paths return identical results (8 entity types over 100 028 rows), and +the service picks the right branch by dialect: + +| Query | Best of 5 | +|---|---| +| plain `SELECT DISTINCT` | 5.26 ms | +| recursive skip scan | **0.48 ms** | +| | **11× faster** | + +(Cold `EXPLAIN ANALYZE` showed 13.9 ms vs 2.9 ms — same ratio; the figures +above are with prepared statements warm, as in steady-state serving.) + +## 8.3 — End-to-end effect + +| Route | before | after | change | +|---|---|---|---| +| audit_log **ttfb** | 35.9 ms | **23.9 ms** | **−34 %** | +| audit_log **total** | 52.8 ms | **37.0 ms** | **−30 %** | + +Cumulative on the audit-log navigation: **86.2 ms → 37.0 ms, −57 %**. + +Tests: `modules/audit_log/tests/test_distinct_entity_types.py` covers +behaviour on the SQLite path and asserts the dialect branch selection, so the +Postgres query cannot silently become the SQLite one. + +## 8.4 — Deliberately not done + +* **`count(*)` on every browse render — 11.4 ms.** Now the largest remaining + piece of audit-log ttfb. Postgres can answer this far faster with a planner + estimate (`reltuples`), but that makes the pager's total *approximate*. + That is a product decision about what users see, not a technical one, so it + is left alone. +* **`NavIcon.tsx`'s 74 eagerly-imported lucide icons (~28.7 KB gz).** Left as + is by decision — the icon-name map is the framework's contract with module + authors and trimming it would narrow that API. +* **`pg_trgm` index for `ILIKE '%term%'` search** (p50 23 ms on users). Would + work on Postgres but needs a Postgres-only migration in a repo whose + migrations also run on SQLite. Not attempted. + +--- + +# Part 9 — Pre-compressed assets and brotli + +## 9.1 — Why + +`GZipMiddleware` re-compresses the same immutable, content-hashed bundle on +every request, and on-the-fly compression has to use a fast (worse) level. +Compressing once at build time fixes both, and brotli is meaningfully smaller +than gzip: + +| | size | vs raw | +|---|---|---| +| raw | 996.5 KB | — | +| gzip -9 | 287.6 KB | −71.1 % | +| brotli -11 | 248.5 KB | −75.1 % | + +**Brotli is 13.6 % smaller than gzip-9** — 39 KB across the bundle. + +## 9.2 — Change + +* `host/client_app/compress-assets.ts` — a Vite plugin (`apply: 'build'`) + emitting `.gz` and `.br` siblings at maximum level, using Node's built-in + `zlib`, so no dependency is added. A variant that fails to beat its original + is not written. +* `simple_module_hosting/static_files.py` — `PrecompressedStaticFiles` serves + those siblings, preferring brotli, falling back to gzip and then the + original. Split out of `_phase_helpers` (which was near the 300-line cap); + it subsumes the old `ImmutableStaticFiles`, whose behaviour is covered by + its own tests. + +Two traps worth recording: + +* **Content-Type.** Serving `app.js.br` directly makes Starlette type it from + the `.br` extension, and a browser refuses to execute a script served as + `application/octet-stream`. The response must carry the *original* file's + type. Pinned by `test_content_type_is_the_original_not_the_variant`. +* **`StaticFiles` raises, it does not return.** A missing file surfaces as + `HTTPException(404)`, so a missing variant cannot be detected from a status + code — the first implementation looked correct and 404'd every asset. + +`Vary: Accept-Encoding` is set on every negotiated response so a shared cache +cannot hand a compressed body to a client that never asked for one. + +## 9.3 — Verified on a real asset (`vendor-*.js`) + +| Accept-Encoding | Content-Encoding | bytes | Content-Type | +|---|---|---|---| +| `br` | br | **122 178** | text/javascript | +| `gzip` | gzip | 141 024 | text/javascript | +| `identity` | — | 474 069 | text/javascript | + +## 9.4 — Result (prod, 4 Mbps / 40 ms) + +| Metric | gzip on-the-fly | pre-compressed + brotli | change | +|---|---|---|---| +| **FCP** /catalog/ | 712 ms | **672 ms** | −6 % | +| transfer | 244 KB | **215 KB** | **−13 %** | +| JS transfer | 216 KB | 188 KB | −13 % | +| CSS transfer | 21 KB | 17 KB | −19 % | +| compression saving vs identity | 72.6 % | **76.0 %** | | + +Per-request compression CPU for static assets is now zero. `GZipMiddleware` +still covers dynamic responses (Inertia JSON), and correctly skips anything +already carrying a `Content-Encoding`. + +## 9.5 — Cumulative + +Cold load of `/catalog/` at 4 Mbps / 40 ms, from the original build: + +| | FCP | transfer | requests | +|---|---|---|---| +| Original | **2268 ms** | 858 KB | 55 | +| \+ GZip middleware | 1168 ms | 259 KB | 55 | +| \+ chunk groups | 748 ms | 247 KB | 13 | +| \+ pre-compressed brotli | **672 ms** | **215 KB** | 13 | + +**First Contentful Paint: 2268 ms → 672 ms, a 70 % reduction.** +**Transfer: 858 KB → 215 KB, a 75 % reduction.** + +--- + +# Part 10 — Perceived smoothness (layout stability) + +Everything above measures *duration*. A page can hit a fast First Contentful +Paint and still feel bad if content reflows after paint — the user loses their +place and has to re-find what they were reading. No timing metric captures it. + +## 10.1 — Result + +**CLS is 0 across every route**, on both cold load and Inertia client-side +navigation: + +| Route | cold load | client navigation | +|---|---|---| +| dashboard | 0 (good) | 0 (good) | +| catalog_list | 0 (good) | 0 (good) | +| users_admin | 0 (good) | 0 (good) | +| audit_log | 0 (good) | 0 (good) | + +Nothing to fix. The layout is genuinely stable: server-rendered props mean +content arrives before paint rather than reflowing in afterwards. + +## 10.2 — Why that zero is trustworthy + +The first version of this measurement also reported all zeros — and was +completely broken. Forcing an unmistakable 500 px reflow on a real page +produced **no observer entries at all**, proving the numbers were meaningless. + +Two distinct causes, worth recording: + +1. **Observers were installed after `page.goto()`.** `longtask` does not + replay through `buffered: true`, so everything during load was missed. + Fixed by arming via `add_init_script`, which runs before any page script. +2. **The `longtask` observer never fires in Playwright's Chromium at all.** It + arms without error, then silently records nothing — a deliberate 250 ms + blocking loop goes unrecorded in both `chromium-headless-shell` and + `channel=chromium`. + +**Long-task measurement was therefore removed rather than shipped.** A metric +that always reads zero is worse than no metric: it manufactures confidence +that nothing is wrong. Measure long tasks in a real browser session instead. + +`test_layout_shift_observer_is_live` now forces a reflow and asserts the +observer catches it. If a future browser update breaks the API again, that +test fails loudly instead of the suite quietly reporting a perfect score +forever. + +This is the same lesson as Part 3, arriving from the opposite direction: there, +plausible hypotheses turned out to be wrong; here, a plausible *measurement* +turned out to be wrong. Both only surfaced by checking the instrument against +something with a known answer. + +--- + +# Part 11 — End-to-end flow audit + +Drove the whole app through Playwright against the production build and the +seeded Postgres database, capturing every request, console message and +uncaught error, then correlated against the backend log. + +## 11.1 — Broken asset paths on every lazy page load + +The audit's first run surfaced this immediately: + +| | before | after | +|---|---|---| +| total requests | 91 | **56** (−38 %) | +| 4xx/5xx | **15** | **0** | +| console errors | **20** | **0** | +| failed requests | 10 | 1 | +| redirects | 11 | 1 | + +**Cause.** Vite records a lazy chunk's preload dependencies as base-relative +paths (`"assets/Browse-x.js"`) and prefixes them with `base` at runtime. The +default `base: "/"` sent those to `/assets/…`, but the host serves the build +under `/static/dist/`. Each one fell through to the SPA fallback, came back as +HTML, and produced a 404 plus: + +``` +Failed to load module script: Expected a JavaScript-or-Wasm module script +but the server responded with a MIME type of "text/html". +``` + +**Why nothing caught it.** The *actual* dynamic import uses a relative `"./"` +specifier and resolved fine — only the preloads were broken. So no test +failed, no page broke, and the app looked healthy. The network tab just +quietly filled with errors on every navigation. + +**Fix.** `base: command === 'build' ? '/static/dist/' : '/'`. Build-only, +because in dev the host points `