|
71 | 71 | - Setup: `pnpm install` → `cp apps/caramel-app/.env.example apps/caramel-app/.env` (secrets table in README) → `pnpm dev` (F-016 one-root-compose: `docker compose up --build` builds web, boots pg :58005, runs `prisma migrate deploy` in-container). |
72 | 72 | - Run: `pnpm dev` (docker compose up --build → app :58000; one root compose = local/CI/prod graph, hot reload traded away 2026-07-09) · host escape hatches (need `docker compose up postgres -d` first): `pnpm dev:next` (app :58000 with hot reload), `pnpm dev:extension` (web-ext). See docs/LOCAL-DEV.md. |
73 | 73 | - Test: `pnpm test` (turbo → vitest: app `tests/unit/**/*.test.{ts,tsx}` + extension `tests/*.mjs`) · single file: `pnpm --filter caramel-app exec vitest run tests/unit/<file>` · e2e: `pnpm --filter caramel-app test:e2e` (needs DB+migrations) · evals (live LLM, costs money): `pnpm --filter caramel-app eval` (needs `OPENROUTER_API_KEY`). |
74 | | -- Gates (all also run in husky pre-commit + CI): `pnpm lint` · `pnpm lint:oxlint` · `pnpm prettier-check` · `pnpm --filter caramel-app knip` · `pnpm -r run type-check`. Ops: `pnpm --filter caramel-app smoke`, `... check:coupons-schema` (see RUNBOOK.md). |
| 74 | +- Gates (all also run in husky pre-commit + CI): `pnpm lint` · `pnpm lint:oxlint` · `pnpm prettier-check` · `pnpm --filter caramel-app knip` · `pnpm -r run type-check`. Ops: `pnpm --filter caramel-app smoke`, `... test:integration` (needs local pg), `... bridge:sync` (migration-period external→app catalog feed; needs `COUPONS_DATABASE_URL`) — see RUNBOOK.md. |
75 | 75 |
|
76 | 76 | ## Architecture (10 lines) |
77 | 77 |
|
78 | | -- pnpm@9 monorepo, two packages. `apps/caramel-app` = Next.js 16 App Router: Prisma → auth/user Postgres; the coupon catalog lives in a SECOND, externally-owned Postgres (`caramel_coupons`, written only by an out-of-repo Python service, +3 sanctioned mutations from this app — see DESIGN.md §2 "Write-ownership") read via porsager `postgres` — connection/fragments/zod schemas in `src/lib/couponsDb.ts`, all 13 query fns + the structural drift-gate registry in `src/lib/couponsRepo.ts` (routes never write inline SQL) — every read zod-parsed by `parseCouponRows` (schema drift throws loudly). |
79 | | -- All env access via `src/lib/env.ts` / `env.client.ts` (zod, boot fail-fast via instrumentation.ts). Every API route declares itself through `src/lib/api/withRoute.ts` (CORS/rate-limit/origin/bearer/zod-body/OPTIONS) and errors through `handleRouteError` → Sentry. Coupon domain vocabulary/predicates live ONLY in `src/lib/coupons.ts` (+ SQL fragment factories in couponsDb.ts). |
| 78 | +- pnpm@9 monorepo, two packages. `apps/caramel-app` = Next.js 16 App Router: Prisma → ONE Postgres (`DATABASE_URL`) holding BOTH auth/user AND the app-OWNED coupon catalog (`coupons`/`store_configs`/`sources`, created by the `catalog_tables` migration + a synthetic seed; app owns it since the Coupons Ownership Inversion — see DESIGN.md §2 "Write-ownership"). The external Python pipeline is now a SUPPLIER: it pushes catalog deltas to `POST /api/ingest/catalog` (`applyCatalogRows` — only-if-newer delta upsert, one transaction, >20% tombstone gate; migration-period feed = the read-only `bridge:sync` job). Reads run via `prisma.$queryRaw` — 10 read fns + 2 sanctioned writes (`expireCoupons`/`requestSource`) in `src/lib/couponsRepo.ts` (routes never write inline SQL), zod row schemas + `parseCouponRows` in `src/lib/couponsDb.ts` (schema drift throws loudly at parse time). |
| 79 | +- All env access via `src/lib/env.ts` / `env.client.ts` (zod, boot fail-fast via instrumentation.ts). Every API route declares itself through `src/lib/api/withRoute.ts` (CORS/rate-limit/origin/bearer/zod-body/OPTIONS) and errors through `handleRouteError` → Sentry. Coupon domain vocabulary/predicates live ONLY in `src/lib/coupons.ts` (+ the `Prisma.sql` fragment factories in couponsRepo.ts). |
80 | 80 | - Extension OAuth session mint: `src/lib/auth/extensionOAuthSession.ts` (one module, deliberately not better-auth — see DESIGN.md). |
81 | 81 | - `apps/caramel-extension` = plain-JS MV3, NO bundler: content scripts share one global scope, load order = manifest order (`coupon-constants.generated.js` → `caramel-base` → `dom-utils` → `store-detect` → `coupon-apply` → `coupon-fetch` → `coupon-runner`); `background.js` is a separate service-worker realm. LLM surface: `cartClassifier.ts` → `/api/classify-cart` (OpenRouter), eval-gated. |
82 | | -- Deploys: Dokploy/Nixpacks → grabcaramel.com. Local coupons DB does NOT exist — coupon routes 500 `{error}` and health reports `coupons_db: error`; that degraded mode is EXPECTED locally. |
| 82 | +- Deploys: Dokploy/Nixpacks → grabcaramel.com. `pnpm dev` migrates AND seeds the catalog locally, so coupon routes return 200 and `/api/health/db` reports app-catalog freshness (`catalog: ok` + `{count,freshestUpdatedAt,ageMinutes,stale}`); the old externally-owned-DB "degraded mode" is RETIRED. App-owned telemetry (worked/used/failed) lives in `coupon_signals` (`src/lib/couponSignals.ts`), split out of the catalog so a usage bump never touches `coupons.updated_at`. |
83 | 83 |
|
84 | 84 | ## Conventions in force → their enforcing check |
85 | 85 |
|
86 | | -- Exact-pinned deps, one root lockfile → `tests/unit/deps-pinned.test.ts` + CI `audit` job (`--audit-level=high`). No `any` → eslint `@typescript-eslint/no-explicit-any` (error). Raw coupon-status literals banned outside `coupons.ts`/generated file → `tests/unit/no-raw-coupon-status.test.ts`. Generated extension constants byte-synced → `tests/unit/coupon-constants.generated.test.ts` (regenerate via `pnpm --filter caramel-app generate:coupon-constants`, NEVER hand-edit; codegen must emit prettier's fixed point — format via prettier API). Root-file allowlist + manifest integrity → `tests/unit/repo-integrity.test.ts`. Extension payload budgets → size-limit (92 KB summed content-scripts group; ~0.9 KB headroom). Eval gate ≥0.85 primary-match, PR path-filtered + nightly → `ai-evals.yml`; model/prompt changes need `pnpm eval` green ×2 + a dated `evals/SCOREBOARD.md` row. knip green with zero unjustified ignores. Coupons SQL lives only in `src/lib/couponsRepo.ts` (never inline in a route/page) → the structural drift gate (`pnpm --filter caramel-app check:coupons-schema`, `tests/drift/coupons-schema.drift.ts`) runs every registered query for real against a live coupons DB, replacing the old hand-maintained `EXPECTED_COLUMNS` mirror (deleted — it missed JOIN/WHERE columns absent from every zod output). No coupons/sources/verification\_\* schema in `prisma/schema.prisma`, ever (secrecy forward-rule, DESIGN.md §2(k)) → no automated check yet, `TODO:` a CI grep gate. Unenforced (memory only): new routes must use `withRoute`; env reads only via the env modules; new `.eval.ts` stays out of the unit glob. |
| 86 | +- Exact-pinned deps, one root lockfile → `tests/unit/deps-pinned.test.ts` + CI `audit` job (`--audit-level=high`). No `any` → eslint `@typescript-eslint/no-explicit-any` (error). Raw coupon-status literals banned outside `coupons.ts`/generated file → `tests/unit/no-raw-coupon-status.test.ts`. Generated extension constants byte-synced → `tests/unit/coupon-constants.generated.test.ts` (regenerate via `pnpm --filter caramel-app generate:coupon-constants`, NEVER hand-edit; codegen must emit prettier's fixed point — format via prettier API). Root-file allowlist + manifest integrity → `tests/unit/repo-integrity.test.ts`. Extension payload budgets → size-limit (98 KB summed content-scripts group). Eval gate ≥0.85 primary-match, PR path-filtered + nightly → `ai-evals.yml`; model/prompt changes need `pnpm eval` green ×2 + a dated `evals/SCOREBOARD.md` row. knip green with zero unjustified ignores. Coupons SQL lives only in `src/lib/couponsRepo.ts` (never inline in a route/page) → exercised by the integration suite (`tests/integration/coupons-read.itest.ts`, `coupons-write.itest.ts`, `ingest-catalog.itest.ts`, `bridge-sync.itest.ts`; `pnpm --filter caramel-app test:integration`) running the real queries against a live app Postgres — this REPLACED the deleted structural drift gate (`check:coupons-schema`, `tests/drift/coupons-schema.drift.ts`, `vitest.drift.config.ts`, `EXPECTED_COLUMNS`, `couponsQueryProbes`, all removed in W4); every read is still zod-parsed by `parseCouponRows` (schema drift throws loudly). Secrecy forward-rule INVERTED (W3): the app now LEGITIMATELY owns `coupons`/`sources`/`store_configs` in `prisma/schema.prisma`; what stays out is the scraper's PIPELINE-INTERNAL machinery (`verification_stores`, `store_verification_configs`, `verification_*`, `agent_*`/`scraper_*`) → enforced by `tests/unit/prisma-schema-secrecy.test.ts` (map-target based: bans those @@map/@map targets, allowlists `verification_message`/`verification_tokens`). Unenforced (memory only): new routes must use `withRoute`; env reads only via the env modules; new `.eval.ts` stays out of the unit glob. |
87 | 87 |
|
88 | 88 | ## Gotchas (each cost a real debugging round) |
89 | 89 |
|
90 | | -- `vi.mock('@/lib/couponsDb', {...importActual})` does NOT intercept internal calls of re-exported functions (closure binds the real module) — read the header comment in `tests/unit/coupons-visibility.test.ts` before touching such mocks. |
| 90 | +- Coupon-read tests now mock `@/lib/prisma` (not couponsDb) and assert on the composed `.sql` string: a `Prisma.sql` fragment nested into a `$queryRaw` template INLINES into the parent's flattened `?`-placeholder text, so proving a route uses a shared predicate is a direct string match — read the header in `tests/unit/coupons-visibility.test.ts` / `couponsRepo.test.ts` before touching such mocks. |
91 | 91 | - `.gitattributes` pins `eol=lf` (NF-04) so fresh clones are safe, but a clone predating it with `core.autocrlf=true` breaks the byte-exact generated-file test — `git config core.autocrlf false && git checkout-index -a -f` (`git reset --hard` won't rewrite round-trip-clean files; docs/LOCAL-DEV.md troubleshooting). |
92 | 92 | - `openai/gpt-5-mini` is a REASONING model: completion budget must include hidden reasoning tokens (`maxTokens: 600`, see F-017 in `evals/SCOREBOARD.md`) — never trim it back to "just enough JSON". |
93 | 93 | - Extension cross-file globals: a function used only from a sibling content-script file needs `// oxlint-disable-next-line no-unused-vars` as the LAST comment line above it (prettier reorders otherwise); `_isDevInstall` must stay in `caramel-base.js` (called at module-eval time — cross-file hoisting doesn't exist). |
|
97 | 97 | ## Hard boundaries (never without explicit human direction) |
98 | 98 |
|
99 | 99 | - Never push to or merge into `dev`/`main`; audit PRs are merged by humans only. Never mutate GitHub repo settings. |
100 | | -- The coupons DB is read-only by discipline — no new write paths (`increment`/`expire`/`sources POST` are the sanctioned exceptions); its schema is owned by the external Python service. |
| 100 | +- The app OWNS the coupon catalog (`DATABASE_URL`): the ONE sanctioned supplier write path is `POST /api/ingest/catalog` (`applyCatalogRows`, tombstone-gated); the app's own sanctioned writes are `expireCoupons` + `requestSource` (usage moved to `coupon_signals.recordUsage`, NOT the catalog — `incrementCouponUsage` retired). The EXTERNAL `caramel_coupons` DB stays strictly read-only — SELECT-only, via the migration-period `bridge:sync` only. Don't add other write paths to either. |
101 | 101 | - Don't convert the extension to a bundler/ESM, don't re-flag the deliberate designs listed in DESIGN.md §standoffs. F-016 one-root-compose landed the root `docker-compose.yml` (`pnpm dev`) for local/CI; the prod cutover onto it stays gated and human-run (never touch prod without explicit in-session confirmation). |
0 commit comments