From 37370dd079e09ba11cefda2d1426a366bf0ac451 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 6 Sep 2026 22:03:13 +0000 Subject: [PATCH] phase-8: run it on Postgres, and find out what that was hiding Six phases claimed the product runs on SQLite and on Postgres. Nothing had ever run it on Postgres. TEST_DATABASE_URL now points the whole suite at a real server and CI runs both arms. The first run found a bug that would have shipped. better-auth's banned flag is integer 0/1 on SQLite and a real boolean on Postgres, and the code read it as `banned === 1`, so on Postgres an account someone asked us to freeze went on receiving email. Both of those flags are now typed for either dialect and read through isFlagSet. It also found the migration advisory lock being taken on a pool. An advisory lock belongs to the session that took it, so a lock on one pooled connection and an unlock on another leaves it held. Two replicas migrating at once is the ordinary case in k8s and is precisely what it was there to protect. New for the scaled mode: k8s manifests with migrations as an initContainer, one poller in its own worker Deployment rather than one per API replica, and an Ingress that exposes the web app only. The storage drivers finally have tests, S3 included, since scaled mode requires it and it had never been exercised. Two acceptance tests, both checked against a deliberately broken build first: two workers claiming a hundred jobs report 188 claims with SKIP LOCKED removed, and the in-flight request is cut off with the drain wait removed. 340 tests on SQLite, 341 on Postgres, 70 Playwright, rules coverage 100%. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 131 ++++++++++++++++ DECISIONS.md | 75 ++++++++++ README.md | 58 +++++++- apps/api/.env.example | 4 + apps/api/src/db/index.ts | 5 +- apps/api/src/db/postgres.ts | 32 +++- apps/api/src/db/schema.ts | 19 ++- apps/api/src/deploy.test.ts | 111 ++++++++++++++ apps/api/src/http/routes/me.test.ts | 4 +- apps/api/src/index.ts | 2 +- apps/api/src/lib/env.ts | 6 + apps/api/src/modules/jobs/scale.test.ts | 88 +++++++++++ apps/api/src/modules/jobs/sweeps.test.ts | 45 +++++- apps/api/src/modules/notifications/send.ts | 4 +- apps/api/src/modules/storage/storage.test.ts | 138 +++++++++++++++++ apps/api/src/shutdown.test.ts | 148 +++++++++++++++++++ apps/api/src/test/harness.ts | 71 ++++++++- deploy/k8s/README.md | 38 +++++ deploy/k8s/api.yaml | 84 +++++++++++ deploy/k8s/config.yaml | 47 ++++++ deploy/k8s/hpa.yaml | 42 ++++++ deploy/k8s/ingress.yaml | 27 ++++ deploy/k8s/namespace.yaml | 4 + deploy/k8s/secret.example.yaml | 29 ++++ deploy/k8s/web.yaml | 64 ++++++++ deploy/k8s/worker.yaml | 62 ++++++++ package.json | 3 +- pnpm-lock.yaml | 44 ++++-- vitest.config.ts | 7 + 29 files changed, 1349 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 apps/api/src/deploy.test.ts create mode 100644 apps/api/src/modules/jobs/scale.test.ts create mode 100644 apps/api/src/modules/storage/storage.test.ts create mode 100644 apps/api/src/shutdown.test.ts create mode 100644 deploy/k8s/README.md create mode 100644 deploy/k8s/api.yaml create mode 100644 deploy/k8s/config.yaml create mode 100644 deploy/k8s/hpa.yaml create mode 100644 deploy/k8s/ingress.yaml create mode 100644 deploy/k8s/namespace.yaml create mode 100644 deploy/k8s/secret.example.yaml create mode 100644 deploy/k8s/web.yaml create mode 100644 deploy/k8s/worker.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b4a7659 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Everything that does not need a database, once. + static: + name: types, lint and rules coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm lint + # RULES.md section 10: the tax logic is the product, so its coverage is a gate. + - run: pnpm test:coverage + + # The same suite against both dialects. SPEC.md section 13: the product claims to run on + # SQLite and on Postgres, so both are run rather than one being assumed from the other. + test: + name: tests (${{ matrix.dialect }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dialect: [sqlite, postgres] + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: impuestos + POSTGRES_PASSWORD: impuestos + POSTGRES_DB: impuestos + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U impuestos" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + # Scaled mode requires S3, so the driver is exercised against a real object store + # rather than assumed from the local one. + minio: + image: bitnami/minio:latest + env: + MINIO_ROOT_USER: impuestos + MINIO_ROOT_PASSWORD: impuestos-secret + MINIO_DEFAULT_BUCKETS: comprobantes + ports: ['9000:9000'] + options: >- + --health-cmd "curl -f http://localhost:9000/minio/health/live" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + + # TEST_DATABASE_URL unset means SQLite in memory, which is what the harness falls + # back to. Set, every harness builds itself a database of its own inside this server, + # and the two-worker claim test in scale.test.ts stops skipping. + - name: Run the suite + env: + TEST_DATABASE_URL: ${{ matrix.dialect == 'postgres' && 'postgres://impuestos:impuestos@localhost:5432/impuestos' || '' }} + TEST_S3_ENDPOINT: http://localhost:9000 + TEST_S3_BUCKET: comprobantes + TEST_S3_REGION: us-east-1 + TEST_S3_ACCESS_KEY_ID: impuestos + TEST_S3_SECRET_ACCESS_KEY: impuestos-secret + run: pnpm test + + # The golden paths, against a built stack rather than the dev server. SQLite and local + # files: that is the combined mode compose ships, and it is the one the helpers in + # e2e/db.ts can read, since they open the database file to check what a flow wrote. + e2e: + name: playwright + runs-on: ubuntu-latest + env: + DATABASE_URL: sqlite:./apps/api/data/app.db + STORAGE_DRIVER: local + STORAGE_LOCAL_PATH: ./apps/api/data/files + BETTER_AUTH_SECRET: ci-secret-that-is-long-enough-32-chars + BETTER_AUTH_URL: http://localhost:3005 + APP_PUBLIC_URL: http://localhost:3005 + PORT: '4000' + API_INTERNAL_URL: http://localhost:4000 + E2E_BASE_URL: http://localhost:3005 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + + - run: pnpm db:migrate + - run: pnpm db:seed + - run: pnpm build + + - name: Start the stack + run: | + node --import tsx apps/api/src/index.ts & + pnpm --filter @impuestos/web start & + npx --yes wait-on http://localhost:4000/readyz http://localhost:3005/healthz -t 120000 + + - run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/DECISIONS.md b/DECISIONS.md index 290e95f..f3b7379 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -752,3 +752,78 @@ either has its subject or is a 404, and there is no third case. The crossfade fr to content is applied where the skeleton is a separate early return; the three list screens render their skeleton inside the same tree as their heading, where fading the whole tree would also fade a heading that never changed. + +## Phase 8 + +### The dual-dialect claim is now run, not asserted +Six phases said the product runs on SQLite and on Postgres. Nothing had ever run it on +Postgres. `TEST_DATABASE_URL` now points the whole suite at a real server, and CI runs both +arms of the matrix. + +The first run found three things, one of them a bug that would have shipped: + +**A frozen account went on being notified on Postgres.** `banned` is integer 0/1 on SQLite +and a real boolean on Postgres, and the code read it as `recipient.banned === 1`. On +Postgres the driver hands back `true`, the comparison is false, and an account someone +asked us to freeze keeps getting email. The two better-auth flags are now typed for both +dialects and read through `isFlagSet`, and a test asserts the behaviour on whichever +dialect the suite is pointed at. + +**The migration advisory lock was taken on a pool.** `pg_advisory_lock` belongs to the +session that took it. Issued on one pooled connection and released on another, the unlock +is a no-op with a warning and the lock stays held until that connection happens to close. +Two replicas migrating at once, which is the ordinary case in k8s, is exactly what it was +supposed to protect. It now runs on one pinned connection. + +**Writes survived by accident.** `set banned = 1` and `where banned = 0` work on Postgres +only because its boolean input syntax accepts the strings '1' and '0'. That is luck rather +than design, and it is why the read path was the one that broke. + +### Each Postgres harness gets a database, not a schema +A schema per harness was the obvious choice and it does not work. Kysely's migrator asks +the introspector whether its bookkeeping tables exist, and with no schema configured a +table of that name in any schema counts, so twenty parallel schemas each holding a +`kysely_migration` convince each other the work is already done. A database each has no +such ambiguity. It is slower, which is what the longer timeout under `TEST_DATABASE_URL` +is for: building a real database is honest work, not a hang. + +### DATABASE_POOL_MAX +Connections are the resource that runs out first when replicas multiply. It was hardcoded +at ten. It is now a variable, documented next to the arithmetic that matters: pool size +times (api replicas plus worker replicas) has to stay under the server's max_connections. + +### Both new tests were checked against a broken implementation +A green test that cannot fail proves nothing, so both were run against a deliberately +broken version first: + +- Remove `FOR UPDATE SKIP LOCKED` from the Postgres claim and the two-worker test reports + 188 claims for 100 jobs. +- Remove the drain wait from the shutdown path and the in-flight request is cut off. + +The shutdown test spawns a real process and holds a request open by sending its body in two +pieces, so the server is genuinely inside the handler when SIGTERM arrives. Asserting on +the app object would have proved nothing about a socket. + +### The manifests are checked for the thing that actually rots +They cannot be applied to a cluster from here, so `deploy.test.ts` checks the part that +drifts: every variable set in a ConfigMap, Secret or `env:` block has to be one the env +schema knows, the grace period has to outlast the 25 second drain, and the API has to be +the one that does not poll. A variable renamed in `src/lib/env.ts` and forgotten in a +ConfigMap is a pod that starts and then refuses to boot, and no generic schema validator +would catch it. + +### The e2e job stays on SQLite +SPEC.md section 13 puts the golden paths against the compose stack, which is combined mode: +SQLite and local files. It is also the only shape `e2e/db.ts` can read, since those helpers +open the database file to check what a flow actually wrote. S3 is covered where it belongs, +in the unit suite against MinIO, and Postgres by the dialect matrix. + +### The storage drivers had no tests at all +S3 is required in scaled mode and had never been exercised. Both drivers are now held to +the same round trip, with the S3 half gated on `TEST_S3_ENDPOINT`. That half has not run on +this machine: there is no object store here and no Docker to start one. CI runs it. + +### Still not run here +The images, the compose stack and the manifests have never been built or applied on this +machine. No Docker daemon it can reach, no cluster. The README says so where someone about +to deploy will read it, rather than only here. diff --git a/README.md b/README.md index 0e8cb4d..71a623e 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,13 @@ and ready to file yourself. Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the way and the gaps that still need answers. -> **Status: phase 7 of 8.** The whole taxpayer path works: scan a comprobante, confirm it, -> watch the position move, and take the resulting Formulario 120 or 515 from review to +> **Status: phase 8 of 8, feature complete.** The whole taxpayer path works: scan a +> comprobante, confirm it, watch the position move, and take the resulting Formulario 120 or 515 from review to > approved to a PDF you file yourself in Marangatu. Staff have a console: look an account > up, work the ingestion error queue, read and export the audit log. It installs to a home > screen, keeps a capture taken with no signal and sends it later, and can send a push when -> a deadline is close. The scale-out work is the last phase. +> a deadline is close. It runs on SQLite on one box or on Postgres and S3 across as many +> replicas as you like, and the whole suite is run against both. --- @@ -131,6 +132,40 @@ S3. The k8s manifests assume both. Rate limiting is in memory and therefore per replica. At this scale that is deliberate; the limiter is behind an interface for when it is not. +### Running it on Kubernetes + +`deploy/k8s/` holds the manifests, and its README explains what each one is for. The order +is namespace, config, secret, api, worker, web, ingress, hpa. Three things about the shape +are worth knowing before you read them: + +**Migrations run as an initContainer on every API pod.** Two pods starting together is the +normal case, not the exception, and it is safe: the migration takes a Postgres advisory +lock on one pinned connection, so the second waits for the first and then finds nothing to +do. + +**Exactly one poller per job, not one per replica.** The API Deployment sets +`JOBS_INLINE=false` and the polling lives in its own worker Deployment. The worker is safe +at any replica count on Postgres: claiming uses `FOR UPDATE SKIP LOCKED`, which +`apps/api/src/modules/jobs/scale.test.ts` holds to a hundred jobs and two workers with no +job claimed twice. + +**Only the web app is exposed.** The Ingress routes to the web Service and the API has no +route in from outside. The browser talks to one origin and `/api` is forwarded inside the +cluster, which is why there is no CORS configuration anywhere in this repository. + +Sizing: `DATABASE_POOL_MAX` is per pod. Multiply it by (api replicas + worker replicas) and +keep the total under the Postgres `max_connections`, or the tenth pod to start is the one +that cannot connect. + +### What has not been run here + +The container images, the compose stack and the Kubernetes manifests have never been built +or applied on the machine this was written on: there is no Docker daemon it can reach and +no cluster. The manifests parse, their configuration is checked against the env schema by +`apps/api/src/deploy.test.ts`, and the Dockerfiles are ordinary multi stage Node builds, +but none of that is the same as having watched a pod come up. Treat the first deploy as the +first real test of them. + ## Installing it, offline and push `app/manifest.ts` and the icons in `apps/web/public` make the web app installable; the @@ -196,9 +231,20 @@ old definition in place: it is what old declarations still render through. ## Testing -Vitest covers the rules package, the API services against SQLite in memory, and catalog -parity. Playwright covers the golden paths against a running stack. CI runs the suite -against both SQLite and Postgres. +Vitest covers the rules package, the API services, and catalog parity. Playwright covers +the golden paths against a running stack. + +The same suite runs against both dialects. With no `TEST_DATABASE_URL` it uses SQLite in +memory; point that at a Postgres and every harness builds a database of its own inside it, +which is how CI covers the claim that the product runs on either: + +```bash +TEST_DATABASE_URL=postgres://user:pass@localhost:5432/postgres pnpm test +``` + +Two tests only mean something on Postgres and skip themselves without it: the two-worker +hundred-job claim test, and anything that depends on `FOR UPDATE SKIP LOCKED`. The S3 +driver behaves the same way through `TEST_S3_ENDPOINT`. `pnpm test:e2e` does not start anything: bring the stack up first, then point it at the right origin. The suite signs in as the demo accounts and confirms, rejects and scans diff --git a/apps/api/.env.example b/apps/api/.env.example index 4617c96..5ec6aad 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -45,6 +45,10 @@ S3_FORCE_PATH_STYLE=true ANTHROPIC_API_KEY= OCR_MODEL=claude-sonnet-4-6 +# Postgres connections per process. Multiply by the number of replicas and keep the total +# under the server's max_connections. Ignored on SQLite. +DATABASE_POOL_MAX=10 + # Optional. Web push is hidden in the UI when unset. Generate: npx web-push generate-vapid-keys PUSH_VAPID_PUBLIC_KEY= PUSH_VAPID_PRIVATE_KEY= diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 0147d8d..005993d 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -20,9 +20,10 @@ export function dialectOf(databaseUrl: string): Dialect { ); } -export function createDb(databaseUrl: string): DbHandle { +export function createDb(databaseUrl: string, options: { poolMax?: number } = {}): DbHandle { const dialect = dialectOf(databaseUrl); - const handle = dialect === 'sqlite' ? createSqliteDb(databaseUrl) : createPostgresDb(databaseUrl); + const handle = + dialect === 'sqlite' ? createSqliteDb(databaseUrl) : createPostgresDb(databaseUrl, options); return { ...handle, dialect }; } diff --git a/apps/api/src/db/postgres.ts b/apps/api/src/db/postgres.ts index 20892d3..21913b2 100644 --- a/apps/api/src/db/postgres.ts +++ b/apps/api/src/db/postgres.ts @@ -8,11 +8,14 @@ import type { Database } from './schema'; * Timestamps are stored as ISO-8601 text in our own tables, so the driver is told to * hand back `numeric` as a number and nothing else needs a type parser. */ -export function createPostgresDb(databaseUrl: string): { +export function createPostgresDb( + databaseUrl: string, + options: { poolMax?: number } = {}, +): { db: Kysely; close: () => Promise; } { - const pool = new pg.Pool({ connectionString: databaseUrl, max: 10 }); + const pool = new pg.Pool({ connectionString: databaseUrl, max: options.poolMax ?? 10 }); const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); return { db, @@ -45,10 +48,23 @@ pg.types.setTypeParser(pg.types.builtins.INT8, (value) => { * api pod, so two migrators can start at the same moment. */ export async function withMigrationLock(db: Kysely, fn: () => Promise): Promise { - await sql`select pg_advisory_lock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute(db); - try { - return await fn(); - } finally { - await sql`select pg_advisory_unlock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute(db); - } + /** + * On one pinned connection, not on the pool. An advisory lock belongs to the session that + * took it: issue the lock on one pooled connection and the unlock on another and the + * unlock is a no-op with a warning, leaving the lock held until that connection happens + * to close. The next replica to start then waits on a lock nobody holds any more. + * + * `fn` still uses the pool for its own work, which is what makes this mutual exclusion + * rather than a single threaded migration. + */ + return db.connection().execute(async (connection) => { + await sql`select pg_advisory_lock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute(connection); + try { + return await fn(); + } finally { + await sql`select pg_advisory_unlock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute( + connection, + ); + } + }); } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index bff29e5..02ab62f 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -37,20 +37,35 @@ export interface Database { insight_dismissals: InsightDismissalsTable; } +/** + * better-auth owns this table and emits its own DDL per dialect, which is why the two + * flags are typed for both: on SQLite they come back as integer 0/1 and on Postgres as a + * real boolean. Read them through `isFlagSet` rather than comparing to 1. + */ export interface UserTable { id: string; name: string; email: string; - emailVerified: number; + emailVerified: number | boolean; image: string | null; createdAt: string; updatedAt: string; role: string | null; - banned: number | null; + banned: number | boolean | null; banReason: string | null; banExpires: string | null; } +/** + * True for a flag on a better-auth table, whichever dialect wrote it. + * + * `banned === 1` was silently false on Postgres, where the driver hands back `true`, and a + * frozen account went on receiving notifications. + */ +export function isFlagSet(value: number | boolean | null | undefined): boolean { + return value === true || value === 1; +} + export interface SessionTable { id: string; expiresAt: string; diff --git a/apps/api/src/deploy.test.ts b/apps/api/src/deploy.test.ts new file mode 100644 index 0000000..5f0ffb7 --- /dev/null +++ b/apps/api/src/deploy.test.ts @@ -0,0 +1,111 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { parseAllDocuments } from 'yaml'; +import { describe, expect, it } from 'vitest'; +import { ENV_KEYS } from './lib/env'; + +/** + * The manifests in deploy/k8s cannot be applied to a cluster from here, so what is checked + * is the part that actually rots: the configuration contract between them and the env + * schema. A variable renamed in src/lib/env.ts and forgotten in a ConfigMap is a pod that + * starts and then refuses to boot, and it is exactly the sort of thing nobody notices until + * a deploy. + */ +const K8S = fileURLToPath(new URL('../../../deploy/k8s/', import.meta.url)); + +interface Manifest { + kind?: string; + metadata?: { name?: string }; + data?: Record; + stringData?: Record; + spec?: { + template?: { + spec?: { + terminationGracePeriodSeconds?: number; + containers?: { name?: string; env?: { name?: string; value?: string }[] }[]; + initContainers?: { name?: string }[]; + }; + }; + }; +} + +function load(): { file: string; doc: Manifest }[] { + return readdirSync(K8S) + .filter((file) => file.endsWith('.yaml')) + .flatMap((file) => + parseAllDocuments(readFileSync(K8S + file, 'utf8')) + .map((document) => document.toJS() as Manifest) + .filter((doc): doc is Manifest => Boolean(doc)) + .map((doc) => ({ file, doc })), + ); +} + +const manifests = load(); +/** Name alone is ambiguous: a Deployment, its Service and its HPA all share one. */ +const byName = (name: string, kind = 'Deployment') => + manifests.find((m) => m.doc.metadata?.name === name && m.doc.kind === kind)?.doc; + +describe('the kubernetes manifests', () => { + it('parse, and every one names a kind', () => { + expect(manifests.length).toBeGreaterThan(0); + for (const { file, doc } of manifests) { + expect(doc.kind, file).toBeTruthy(); + } + }); + + it('set only variables the API actually reads', () => { + const known = new Set(ENV_KEYS); + const configured = [ + ...Object.keys(byName('impuestos-config', 'ConfigMap')?.data ?? {}), + ...Object.keys(byName('impuestos-secrets', 'Secret')?.stringData ?? {}), + ...(byName('impuestos-api')?.spec?.template?.spec?.containers ?? []) + .flatMap((container) => container.env ?? []) + .map((entry) => entry.name ?? ''), + ...(byName('impuestos-worker')?.spec?.template?.spec?.containers ?? []) + .flatMap((container) => container.env ?? []) + .map((entry) => entry.name ?? ''), + ]; + + for (const key of configured) { + expect(known.has(key), `${key} is set in deploy/k8s but no such variable exists`).toBe(true); + } + }); + + it('carries everything the scaled mode needs', () => { + const config = byName('impuestos-config', 'ConfigMap')?.data ?? {}; + const secrets = byName('impuestos-secrets', 'Secret')?.stringData ?? {}; + + // SPEC.md section 15: scaled mode is Postgres and S3, not a choice. + expect(secrets['DATABASE_URL']).toMatch(/^postgres/); + expect(config['STORAGE_DRIVER']).toBe('s3'); + for (const key of ['S3_BUCKET', 'S3_REGION']) expect(config[key]).toBeTruthy(); + for (const key of ['BETTER_AUTH_SECRET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY']) { + expect(secrets).toHaveProperty(key); + } + }); + + it('runs exactly one poller per job, not one per replica', () => { + const api = byName('impuestos-api')?.spec?.template?.spec?.containers?.[0]?.env ?? []; + const worker = byName('impuestos-worker')?.spec?.template?.spec?.containers?.[0]?.env ?? []; + + // An API replica that polled would multiply pollers by the replica count. + expect(api.find((entry) => entry.name === 'ROLE')?.value).toBe('server'); + expect(api.find((entry) => entry.name === 'JOBS_INLINE')?.value).toBe('false'); + expect(worker.find((entry) => entry.name === 'ROLE')?.value).toBe('worker'); + }); + + it('migrates before it serves', () => { + const init = byName('impuestos-api')?.spec?.template?.spec?.initContainers ?? []; + expect(init.map((container) => container.name)).toContain('migrate'); + }); + + it('gives every pod longer to stop than the API spends draining', () => { + // src/index.ts drains for up to 25 seconds. A shorter grace period would have the + // cluster kill the process in the middle of the requests it is trying to finish. + const drainSeconds = 25; + for (const name of ['impuestos-api', 'impuestos-worker', 'impuestos-web']) { + const grace = byName(name)?.spec?.template?.spec?.terminationGracePeriodSeconds; + expect(grace, name).toBeGreaterThan(drainSeconds); + } + }); +}); diff --git a/apps/api/src/http/routes/me.test.ts b/apps/api/src/http/routes/me.test.ts index 9c8b46f..137b7ea 100644 --- a/apps/api/src/http/routes/me.test.ts +++ b/apps/api/src/http/routes/me.test.ts @@ -1,5 +1,6 @@ import { DataExportDto, DependentDto, ProfileDto } from '@impuestos/contracts'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { isFlagSet } from '../../db/schema'; import { createHarness, type Harness } from '../../test/harness'; let h: Harness; @@ -228,7 +229,8 @@ describe('DELETE /me/account', () => { .select(['id', 'banned', 'banReason']) .where('email', '=', 'carlos@demo.local') .executeTakeFirstOrThrow(); - expect(user.banned).toBe(1); + // Integer 0/1 on SQLite, a real boolean on Postgres: the flag is read, not compared. + expect(isFlagSet(user.banned)).toBe(true); expect(user.banReason).toBe('account_deleted'); const sessions = await h.deps.handle.db diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a10db71..c7b68ac 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,7 +14,7 @@ import { createStorage } from './modules/storage'; const DRAIN_TIMEOUT_MS = 25_000; const env = loadEnv(); -const handle = createDb(env.DATABASE_URL); +const handle = createDb(env.DATABASE_URL, { poolMax: env.DATABASE_POOL_MAX }); const auth = createAuth({ db: handle.db, dialect: handle.dialect, diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 6cdee7b..25e18a0 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -42,6 +42,12 @@ const EnvObject = z.object({ ANTHROPIC_API_KEY: optionalString, OCR_MODEL: z.string().trim().default('claude-sonnet-4-6'), + /** + * Postgres connections per process. Replicas multiply it, so it has to be sized + * against the server's max_connections rather than left to a library default. + */ + DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(100).default(10), + PUSH_VAPID_PUBLIC_KEY: optionalString, PUSH_VAPID_PRIVATE_KEY: optionalString, /** Contact for the push service. Must be an https: or mailto: URL, per RFC 8292. */ diff --git a/apps/api/src/modules/jobs/scale.test.ts b/apps/api/src/modules/jobs/scale.test.ts new file mode 100644 index 0000000..68e0e43 --- /dev/null +++ b/apps/api/src/modules/jobs/scale.test.ts @@ -0,0 +1,88 @@ +import pg from 'pg'; +import { describe, expect, it } from 'vitest'; +import { createDb } from '../../db/index'; +import { migrateToLatest } from '../../db/migrator'; +import { parseEnv } from '../../lib/env'; +import { TEST_ENV } from '../../test/harness'; +import { claimJob } from './claim'; +import { enqueue } from './queue'; + +/** + * SPEC.md section 13: with Postgres and two workers, enqueue 100 jobs and assert each one + * is claimed exactly once. + * + * This is the whole basis of running more than one replica, so it runs against a real + * Postgres or not at all. `FOR UPDATE SKIP LOCKED` cannot be simulated: SQLite's single + * writer makes any test of it pass for the wrong reason. + */ +const POSTGRES_URL = process.env['TEST_DATABASE_URL']; +const JOBS = 100; +const WORKERS = 2; + +describe.skipIf(!POSTGRES_URL)('two workers on one Postgres queue', () => { + it('claims each of a hundred jobs exactly once', async () => { + const database = `scale_${Date.now()}`; + await withAdmin(POSTGRES_URL as string, (db) => db.query(`create database "${database}"`)); + + const url = new URL(POSTGRES_URL as string); + url.pathname = `/${database}`; + const parsed = parseEnv({ ...TEST_ENV, DATABASE_URL: url.toString(), DATABASE_POOL_MAX: '5' }); + const env = parsed.env; + if (!parsed.ok || !env) throw new Error(parsed.message); + + // Two handles, because two worker processes would have two pools. + const workers = Array.from({ length: WORKERS }, () => + createDb(env.DATABASE_URL, { poolMax: 5 }), + ); + + try { + await migrateToLatest(workers[0] as (typeof workers)[number], env); + + for (let index = 0; index < JOBS; index += 1) { + await enqueue(workers[0]!.db, { type: 'classify_document', payload: { index } }); + } + + const now = new Date().toISOString(); + const claimed: string[] = []; + + /** One worker draining until the queue gives it nothing. */ + async function drain(handle: (typeof workers)[number], instanceId: string): Promise { + for (;;) { + const job = await claimJob(handle.db, handle.dialect, { instanceId, now }); + if (!job) return; + claimed.push(job.id); + } + } + + // Both at once, which is the only arrangement that can double claim. + await Promise.all(workers.map((handle, index) => drain(handle, `worker-${index}`))); + + expect(claimed).toHaveLength(JOBS); + expect(new Set(claimed).size).toBe(JOBS); + + // And the queue agrees: everything is running under someone, nothing is pending. + const rows = await workers[0]!.db.selectFrom('jobs').select(['status', 'locked_by']).execute(); + expect(rows).toHaveLength(JOBS); + expect(rows.every((row) => row.status === 'running')).toBe(true); + expect(new Set(rows.map((row) => row.locked_by)).size).toBe(WORKERS); + } finally { + await Promise.all(workers.map((handle) => handle.close())); + await withAdmin(POSTGRES_URL as string, (db) => + db.query(`drop database if exists "${database}" with (force)`), + ); + } + }); +}); + +async function withAdmin( + databaseUrl: string, + run: (client: pg.Client) => Promise, +): Promise { + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + await run(client); + } finally { + await client.end(); + } +} diff --git a/apps/api/src/modules/jobs/sweeps.test.ts b/apps/api/src/modules/jobs/sweeps.test.ts index 66d3361..fe1a540 100644 --- a/apps/api/src/modules/jobs/sweeps.test.ts +++ b/apps/api/src/modules/jobs/sweeps.test.ts @@ -1,7 +1,8 @@ import { computeRucDv, dueDateFor, formatIsoDate } from '@impuestos/rules'; import { afterEach, describe, expect, it } from 'vitest'; +import { isFlagSet } from '../../db/schema'; import { createHarness, type Harness } from '../../test/harness'; -import type { Channels, OutgoingMessage } from '../notifications'; +import { sendNotification, type Channels, type OutgoingMessage } from '../notifications'; import { runAutoConfirmSweep, runDeadlineSweep, runDigestSweep } from './sweep-handlers'; import { scheduleSweeps, sweepDedupeKey } from './sweeps'; @@ -140,6 +141,48 @@ describe('deadline sweep, ten days out', () => { }); }); +/** + * The flag better-auth writes is integer 0/1 on SQLite and a boolean on Postgres. Reading + * it with `=== 1` was silently false on Postgres, so a frozen account went on being + * notified. This runs on whichever dialect the suite is pointed at. + */ +describe('a frozen account', () => { + it('is not a recipient on either dialect', async () => { + const h = (harness = await createHarness({ channels: recordingChannels() })); + const db = h.deps.handle.db; + + const carlos = await db + .selectFrom('user') + .select('id') + .where('email', '=', 'carlos@demo.local') + .executeTakeFirstOrThrow(); + + await db + .updateTable('notification_prefs') + .set({ email_enabled: 1 }) + .where('user_id', '=', carlos.id) + .execute(); + await db.updateTable('user').set({ banned: 1 }).where('id', '=', carlos.id).execute(); + + const stored = await db + .selectFrom('user') + .select('banned') + .where('id', '=', carlos.id) + .executeTakeFirstOrThrow(); + // Whatever the driver hands back, the helper agrees it is set. + expect(isFlagSet(stored.banned)).toBe(true); + + const result = await sendNotification( + { db, channels: h.channels }, + { + userId: carlos.id, + notification: { kind: 'declaration_ready', form: '120', period: '2026-08', declarationId: 'x' }, + }, + ); + expect(result.delivered).toEqual([]); + }); +}); + describe('the queued notification actually goes out', () => { it('reaches the channels the user has enabled, in their language', async () => { const channels = recordingChannels(); diff --git a/apps/api/src/modules/notifications/send.ts b/apps/api/src/modules/notifications/send.ts index 2e59c6a..70ecf99 100644 --- a/apps/api/src/modules/notifications/send.ts +++ b/apps/api/src/modules/notifications/send.ts @@ -1,7 +1,7 @@ import { DEFAULT_LOCALE, isLocale, type Locale } from '@impuestos/i18n'; import type { Kysely } from 'kysely'; import { z } from 'zod'; -import type { Database } from '../../db/schema'; +import { isFlagSet, type Database } from '../../db/schema'; import type { Channels } from './channels'; import { emailSubject, renderNotification, type NotificationKind } from './templates'; @@ -38,7 +38,7 @@ export async function sendNotification( .executeTakeFirst(); // A frozen account is not a recipient. - if (!recipient || recipient.banned === 1) return { delivered: [] }; + if (!recipient || isFlagSet(recipient.banned)) return { delivered: [] }; const locale: Locale = isLocale(recipient.locale) ? recipient.locale : DEFAULT_LOCALE; const message = renderNotification(locale, args.notification); diff --git a/apps/api/src/modules/storage/storage.test.ts b/apps/api/src/modules/storage/storage.test.ts new file mode 100644 index 0000000..5ce452f --- /dev/null +++ b/apps/api/src/modules/storage/storage.test.ts @@ -0,0 +1,138 @@ +import { mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseEnv } from '../../lib/env'; +import { createStorage, storageKey, type StorageDriver } from './index'; + +/** + * The storage driver is the one thing scaled mode swaps out (SPEC.md section 15: S3 is + * required there), so both implementations are held to the same round trip. + * + * The S3 half needs a real endpoint and is skipped without one. CI points it at MinIO; + * locally, set TEST_S3_ENDPOINT and the four S3 variables to run it. + */ +const S3_ENDPOINT = process.env['TEST_S3_ENDPOINT']; + +const BASE = { + NODE_ENV: 'test', + DATABASE_URL: 'sqlite::memory:', + BETTER_AUTH_SECRET: 'storage-test-secret-long-enough-32ch', + BETTER_AUTH_URL: 'http://localhost:3000', + APP_PUBLIC_URL: 'http://localhost:3000', +}; + +function driverFor(overrides: Record): StorageDriver { + const parsed = parseEnv({ ...BASE, ...overrides }); + if (!parsed.ok || !parsed.env) throw new Error(parsed.message); + return createStorage(parsed.env); +} + +async function readAll(stream: ReadableStream): Promise { + const chunks: Uint8Array[] = []; + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return new Uint8Array(chunks.flatMap((chunk) => [...chunk])); +} + +describe('storage keys', () => { + it('never carry a name the uploader chose', () => { + // A key is the owner and an id we generated, so "../../etc/passwd" as a filename has + // nowhere to go: it is not part of the key at all. + expect(storageKey('user-1', 'abc')).toBe('user-1/abc'); + expect(storageKey('user-1', 'abc')).not.toContain('..'); + }); + + it('keep one account out of the prefix belonging to another', () => { + expect(storageKey('user-1', 'abc').startsWith('user-2/')).toBe(false); + }); +}); + +describe('the driver switch', () => { + it('follows STORAGE_DRIVER and nothing else', () => { + expect(driverFor({ STORAGE_LOCAL_PATH: mkdtempSync(join(tmpdir(), 'st-')) }).kind).toBe('local'); + expect( + driverFor({ + STORAGE_DRIVER: 's3', + S3_BUCKET: 'whatever', + S3_REGION: 'us-east-1', + S3_ACCESS_KEY_ID: 'a', + S3_SECRET_ACCESS_KEY: 'b', + }).kind, + ).toBe('s3'); + }); + + it('refuses the s3 driver with no bucket rather than failing on first upload', () => { + expect(() => driverFor({ STORAGE_DRIVER: 's3' })).toThrow(/S3_BUCKET/); + }); +}); + +describe('the local driver', () => { + const path = mkdtempSync(join(tmpdir(), 'impuestos-storage-')); + const storage = driverFor({ STORAGE_LOCAL_PATH: path }); + + it('stores, reads back and deletes', async () => { + const key = storageKey('user-1', 'factura'); + const body = new TextEncoder().encode('una factura'); + + await storage.put(key, body, 'text/plain'); + expect(new TextDecoder().decode(await readAll(await storage.getStream(key)))).toBe('una factura'); + + // Local files are served through the authenticated route, never linked directly, and + // the key is escaped into the path rather than pasted into it. + expect(await storage.url(key)).toBe(`/api/files/${encodeURIComponent(key)}`); + + await storage.delete(key); + await expect(storage.getStream(key)).rejects.toThrow(); + }); + + it('answers the readiness check once its directory exists', async () => { + await expect(storage.check()).resolves.toBeUndefined(); + // Writing created the per-user prefix; nothing else is in there. + expect(readdirSync(path).length).toBeGreaterThanOrEqual(0); + }); +}); + +/** Built inside the tests: a skipped describe still runs its own body. */ +function s3Driver(overrides: Record = {}): StorageDriver { + return driverFor({ + STORAGE_DRIVER: 's3', + S3_ENDPOINT: S3_ENDPOINT ?? '', + S3_REGION: process.env['TEST_S3_REGION'] ?? 'us-east-1', + S3_BUCKET: process.env['TEST_S3_BUCKET'] ?? 'comprobantes', + S3_ACCESS_KEY_ID: process.env['TEST_S3_ACCESS_KEY_ID'] ?? '', + S3_SECRET_ACCESS_KEY: process.env['TEST_S3_SECRET_ACCESS_KEY'] ?? '', + S3_FORCE_PATH_STYLE: 'true', + ...overrides, + }); +} + +describe.skipIf(!S3_ENDPOINT)('the s3 driver', () => { + it('stores, reads back, signs and deletes', async () => { + const storage = s3Driver(); + const key = storageKey('user-1', `factura-${Date.now()}`); + const body = new TextEncoder().encode('una factura'); + + await storage.put(key, body, 'text/plain'); + expect(new TextDecoder().decode(await readAll(await storage.getStream(key)))).toBe('una factura'); + + // A signed URL, which is how a browser gets the file in scaled mode. + const url = await storage.url(key); + expect(url).toContain(key); + expect(url).toMatch(/X-Amz-Signature/); + expect(new TextDecoder().decode(new Uint8Array(await (await fetch(url)).arrayBuffer()))).toBe( + 'una factura', + ); + + await storage.delete(key); + await expect(storage.getStream(key)).rejects.toThrow(); + }); + + it('fails the readiness check when the bucket is not there', async () => { + await expect(s3Driver({ S3_BUCKET: 'no-such-bucket-here' }).check()).rejects.toThrow(); + }); +}); diff --git a/apps/api/src/shutdown.test.ts b/apps/api/src/shutdown.test.ts new file mode 100644 index 0000000..e22159e --- /dev/null +++ b/apps/api/src/shutdown.test.ts @@ -0,0 +1,148 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { request as httpRequest } from 'node:http'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { createAuth } from './auth/options'; +import { createDb } from './db/index'; +import { migrateToLatest } from './db/migrator'; +import { seed } from './db/seed'; +import { parseEnv } from './lib/env'; + +/** + * SPEC.md section 15: on SIGTERM the API stops accepting, finishes what it is already + * handling, and exits 0. A rolling deploy that cuts a request in half loses a scan someone + * just took, so this runs against a real process and a real socket rather than the app + * object. + */ +const ENTRY = fileURLToPath(new URL('./index.ts', import.meta.url)); +const PORT = 4571; +const DIR = mkdtempSync(join(tmpdir(), 'impuestos-shutdown-')); + +let child: ChildProcess | null = null; + +afterAll(() => { + child?.kill('SIGKILL'); +}); + +describe('graceful shutdown', () => { + it('finishes an in-flight request, then exits 0', async () => { + const env = { + NODE_ENV: 'test', + PORT: String(PORT), + DATABASE_URL: `sqlite:${join(DIR, 'app.db')}`, + STORAGE_LOCAL_PATH: join(DIR, 'files'), + BETTER_AUTH_SECRET: 'shutdown-test-secret-long-enough-32ch', + BETTER_AUTH_URL: `http://127.0.0.1:${PORT}`, + APP_PUBLIC_URL: `http://127.0.0.1:${PORT}`, + }; + + await prepareDatabase(env); + child = await start(env); + + const cookie = await signIn(); + + /** + * A request whose body arrives in two pieces. The server has the headers and is inside + * the handler waiting for the rest, which is exactly the state a deploy must not cut. + */ + const body = JSON.stringify({ pushEnabled: false }); + const [head, tail] = [body.slice(0, 4), body.slice(4)]; + + const response = new Promise<{ status: number; text: string }>((resolve, reject) => { + const outgoing = httpRequest( + { + host: '127.0.0.1', + port: PORT, + path: '/api/me/notification-prefs', + method: 'PATCH', + headers: { + cookie, + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + }, + (incoming) => { + let text = ''; + incoming.on('data', (chunk) => (text += chunk)); + incoming.on('end', () => resolve({ status: incoming.statusCode ?? 0, text })); + }, + ); + outgoing.on('error', reject); + outgoing.write(head); + // Held open on purpose. + setTimeout(() => outgoing.end(tail), 1200); + }); + + // Long enough for the server to be inside the handler, well short of the body. + await new Promise((resolve) => setTimeout(resolve, 400)); + const exited = new Promise((resolve) => { + child?.on('exit', (code) => resolve(code ?? -1)); + }); + child?.kill('SIGTERM'); + + // Draining, so a load balancer stops sending here before the door closes. + await new Promise((resolve) => setTimeout(resolve, 100)); + const health = await fetch(`http://127.0.0.1:${PORT}/healthz`).catch(() => null); + if (health) expect(health.status).toBe(503); + + const result = await response; + expect(result.status).toBe(200); + + expect(await exited).toBe(0); + // Spawning a process, migrating a file database and holding a request open is slower + // than a unit test has any business being. + }, 60_000); +}); + +async function prepareDatabase(env: Record): Promise { + const parsed = parseEnv(env); + if (!parsed.ok || !parsed.env) throw new Error(parsed.message); + const handle = createDb(parsed.env.DATABASE_URL); + try { + await migrateToLatest(handle, parsed.env); + const auth = createAuth({ + db: handle.db, + dialect: handle.dialect, + env: parsed.env, + sendOtp: async () => undefined, + }); + await seed(handle, auth); + } finally { + await handle.close(); + } +} + +async function start(env: Record): Promise { + const proc = spawn(process.execPath, ['--import', 'tsx', ENTRY], { + // From apps/api, where tsx is a dependency and where `pnpm dev` runs it from. + cwd: fileURLToPath(new URL('..', import.meta.url)), + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + proc.stderr?.on('data', (chunk) => console.error(`[api] ${String(chunk).trim()}`)); + + const deadline = Date.now() + 30_000; + for (;;) { + if (Date.now() > deadline) throw new Error('the api did not come up'); + const ok = await fetch(`http://127.0.0.1:${PORT}/healthz`) + .then((response) => response.ok) + .catch(() => false); + if (ok) return proc; + await new Promise((resolve) => setTimeout(resolve, 200)); + } +} + +async function signIn(): Promise { + const response = await fetch(`http://127.0.0.1:${PORT}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'maria@demo.local', password: 'demo-maria-1' }), + }); + if (!response.ok) throw new Error(`sign in failed: ${response.status}`); + const cookie = response.headers.get('set-cookie'); + if (!cookie) throw new Error('sign in returned no cookie'); + return cookie.split(';')[0] ?? ''; +} diff --git a/apps/api/src/test/harness.ts b/apps/api/src/test/harness.ts index 449fc4b..10e3b56 100644 --- a/apps/api/src/test/harness.ts +++ b/apps/api/src/test/harness.ts @@ -1,3 +1,5 @@ +import pg from 'pg'; +import { uuidv7 } from 'uuidv7'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -13,9 +15,16 @@ import type { OcrProvider } from '../modules/documents/ocr'; import { createChannels, type Channels } from '../modules/notifications'; import { createStorage, type StorageDriver } from '../modules/storage'; +/** + * The suite runs against SQLite in memory by default and against a real Postgres when + * `TEST_DATABASE_URL` is set, which is how the CI matrix covers both dialects with one set + * of tests (SPEC.md section 13). + */ +const POSTGRES_URL = process.env['TEST_DATABASE_URL']; + export const TEST_ENV: Record = { NODE_ENV: 'test', - DATABASE_URL: 'sqlite::memory:', + DATABASE_URL: POSTGRES_URL ?? 'sqlite::memory:', BETTER_AUTH_SECRET: 'test-secret-that-is-long-enough-32chars', BETTER_AUTH_URL: 'http://localhost:3000', APP_PUBLIC_URL: 'http://localhost:3000', @@ -47,11 +56,35 @@ export interface Harness extends AppHandle { } export async function createHarness(options: HarnessOptions = {}): Promise { - const parsed = parseEnv({ ...TEST_ENV, ...(options.env ?? {}) }); + /** + * On Postgres every harness gets a database of its own. `sqlite::memory:` gives each test + * a private database for free; a shared Postgres does not, and tests that seed the same + * four accounts into one set of tables would tread on each other. + * + * A database rather than a schema: Kysely's migrator asks the introspector whether its + * bookkeeping tables exist and, with no schema configured, a table of that name in any + * schema counts. Twenty parallel schemas each holding a `kysely_migration` therefore + * convince each other that the work is already done. + */ + const scratchDatabase = POSTGRES_URL ? `t_${uuidv7().replaceAll('-', '')}` : null; + if (scratchDatabase) await createDatabase(POSTGRES_URL as string, scratchDatabase); + + const parsed = parseEnv({ + ...TEST_ENV, + ...(scratchDatabase + ? { + DATABASE_URL: withDatabase(POSTGRES_URL as string, scratchDatabase), + // Small on purpose: one pool per harness, many harnesses at once, and one + // server's max_connections between them. + DATABASE_POOL_MAX: '2', + } + : {}), + ...(options.env ?? {}), + }); if (!parsed.ok || !parsed.env) throw new Error(parsed.message); const env = parsed.env; - const handle = createDb(env.DATABASE_URL); + const handle = createDb(env.DATABASE_URL, { poolMax: env.DATABASE_POOL_MAX }); await migrateToLatest(handle, env); const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined }); @@ -92,6 +125,8 @@ export async function createHarness(options: HarnessOptions = {}): Promise { await poller.stop(); await handle.close(); + // After the pool is closed: Postgres refuses to drop a database anything is on. + if (scratchDatabase) await dropDatabase(POSTGRES_URL as string, scratchDatabase); }, signIn: async (email, password) => { const response = await appHandle.app.request('/api/auth/sign-in/email', { @@ -106,3 +141,33 @@ export async function createHarness(options: HarnessOptions = {}): Promise { + await onAdminConnection(databaseUrl, (client) => client.query(`create database "${name}"`)); +} + +async function dropDatabase(databaseUrl: string, name: string): Promise { + await onAdminConnection(databaseUrl, (client) => + client.query(`drop database if exists "${name}" with (force)`), + ); +} + +async function onAdminConnection( + databaseUrl: string, + run: (client: pg.Client) => Promise, +): Promise { + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + await run(client); + } finally { + await client.end(); + } +} diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..aa7ed89 --- /dev/null +++ b/deploy/k8s/README.md @@ -0,0 +1,38 @@ +# Kubernetes manifests + +Scaled mode (SPEC.md section 15): **Postgres and S3 are required.** SQLite is not +supported here and the API refuses the configuration that would need it: a dedicated +worker means `JOBS_INLINE=false`, which env validation rejects on SQLite because a second +poller against one SQLite file is a corruption waiting to happen. Local file storage is +equally unsupported unless every replica mounts the same RWX volume, which S3 exists to +avoid. + +Apply in order: + +```bash +kubectl apply -f namespace.yaml +kubectl apply -f config.yaml # edit first: hostnames, bucket, replicas +kubectl apply -f secret.example.yaml # do not commit real values, see the note inside +kubectl apply -f api.yaml +kubectl apply -f worker.yaml +kubectl apply -f web.yaml +kubectl apply -f ingress.yaml +kubectl apply -f hpa.yaml +``` + +What each piece is for: + +| File | What it does | +|---|---| +| `namespace.yaml` | One namespace, so everything can be removed in one command | +| `config.yaml` | Non-secret settings, the same keys as `apps/api/.env.example` | +| `secret.example.yaml` | The shape of the Secret. Real values come from your secret store | +| `api.yaml` | The HTTP API, N replicas, migrations as an initContainer, plus its Service | +| `worker.yaml` | The job poller, `ROLE=worker`, safe at N replicas on Postgres | +| `web.yaml` | The Next server, plus its Service. Never talks to the database | +| `ingress.yaml` | Public traffic reaches the web Service only. `/api` is proxied inside it | +| `hpa.yaml` | Scales the API on CPU. The worker is not autoscaled; see the note in it | + +Every API and worker pod runs `db:migrate` before it serves. That is safe: the migration +takes a Postgres advisory lock on one pinned connection, so replicas starting together +queue rather than race. diff --git a/deploy/k8s/api.yaml b/deploy/k8s/api.yaml new file mode 100644 index 0000000..2d3297c --- /dev/null +++ b/deploy/k8s/api.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: impuestos-api + namespace: impuestos + labels: { app: impuestos, component: api } +spec: + replicas: 2 + selector: + matchLabels: { app: impuestos, component: api } + template: + metadata: + labels: { app: impuestos, component: api } + spec: + # 30s: the process drains in-flight requests for up to 25s before it exits. + terminationGracePeriodSeconds: 30 + initContainers: + # Every replica runs this. Concurrent runs are safe: the migration holds a Postgres + # advisory lock on one pinned connection, so the second pod waits for the first. + - name: migrate + image: ghcr.io/example/impuestos-api:latest + command: ['node', 'dist/db/migrate.cli.js'] + envFrom: + - configMapRef: { name: impuestos-config } + - secretRef: { name: impuestos-secrets } + resources: + requests: { cpu: 50m, memory: 128Mi } + limits: { memory: 256Mi } + containers: + - name: api + image: ghcr.io/example/impuestos-api:latest + ports: + - name: http + containerPort: 4000 + env: + - name: ROLE + value: 'server' + # The dedicated worker Deployment does the jobs. An API replica that also + # polled would multiply the pollers by the replica count. + - name: JOBS_INLINE + value: 'false' + envFrom: + - configMapRef: { name: impuestos-config } + - secretRef: { name: impuestos-secrets } + # Liveness answers as long as the process is alive; readiness also checks the + # database and the storage driver, and goes false the moment a drain begins. + livenessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: { path: /readyz, port: http } + initialDelaySeconds: 2 + periodSeconds: 5 + failureThreshold: 2 + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 512Mi } + volumeMounts: + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ['ALL'] } + volumes: + # The root filesystem is read only, so the one place anything may be written is an + # empty directory that dies with the pod. Nothing durable belongs here: uploads go + # through the storage driver to S3. + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: impuestos-api + namespace: impuestos +spec: + type: ClusterIP + selector: { app: impuestos, component: api } + ports: + - name: http + port: 4000 + targetPort: http diff --git a/deploy/k8s/config.yaml b/deploy/k8s/config.yaml new file mode 100644 index 0000000..2e403c1 --- /dev/null +++ b/deploy/k8s/config.yaml @@ -0,0 +1,47 @@ +# Non-secret configuration. Every key here exists in apps/api/.env.example with the same +# meaning; nothing is invented for Kubernetes. +apiVersion: v1 +kind: ConfigMap +metadata: + name: impuestos-config + namespace: impuestos +data: + NODE_ENV: 'production' + PORT: '4000' + + # The origin a person types. Cookies are issued for it and links in email point at it. + APP_PUBLIC_URL: 'https://impuestos.example' + BETTER_AUTH_URL: 'https://impuestos.example' + + # Postgres connections per pod. Multiply by (api replicas + worker replicas) and keep the + # total under the server's max_connections. + DATABASE_POOL_MAX: '10' + + JOBS_POLL_INTERVAL_MS: '2000' + JOBS_STALE_MINUTES: '10' + + STORAGE_DRIVER: 's3' + S3_REGION: 'us-east-1' + S3_BUCKET: 'impuestos-comprobantes' + S3_FORCE_PATH_STYLE: 'true' + # Leave empty for AWS; set it for MinIO or another S3 compatible service. + S3_ENDPOINT: '' + + OCR_MODEL: 'claude-sonnet-4-6' + DEFAULT_LOCALE: 'es' + + SMTP_PORT: '587' + SMTP_FROM: 'avisos@impuestos.example' + # RFC 8292 wants an https: or mailto: URL. Without one, push switches itself off. + PUSH_VAPID_SUBJECT: 'mailto:soporte@impuestos.example' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: impuestos-web-config + namespace: impuestos +data: + NODE_ENV: 'production' + # Cluster DNS for the api Service. The browser never sees this address. + API_INTERNAL_URL: 'http://impuestos-api:4000' + NEXT_PUBLIC_DEFAULT_LOCALE: 'es' diff --git a/deploy/k8s/hpa.yaml b/deploy/k8s/hpa.yaml new file mode 100644 index 0000000..f1966ab --- /dev/null +++ b/deploy/k8s/hpa.yaml @@ -0,0 +1,42 @@ +# The API scales on CPU. Stateless by construction: no local writes outside the storage +# driver, no in-memory cache that changes an answer, so a new replica behaves like an old +# one from its first request. +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: impuestos-api + namespace: impuestos +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: impuestos-api + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: impuestos-web + namespace: impuestos +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: impuestos-web + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 0000000..57449f4 --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -0,0 +1,27 @@ +# Public traffic reaches the web Service and nothing else. The API is not exposed: the +# browser talks to one origin, the web server forwards /api inside the cluster, and the +# session cookie stays first party. SPEC.md is explicit that there is no CORS in v1, and +# an Ingress that published the API would be the thing that made CORS necessary. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: impuestos + namespace: impuestos + annotations: + # A scan is a photograph. + nginx.ingress.kubernetes.io/proxy-body-size: '12m' +spec: + ingressClassName: nginx + tls: + - hosts: ['impuestos.example'] + secretName: impuestos-tls + rules: + - host: impuestos.example + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: impuestos-web + port: { name: http } diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 0000000..535102a --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: impuestos diff --git a/deploy/k8s/secret.example.yaml b/deploy/k8s/secret.example.yaml new file mode 100644 index 0000000..0f6066e --- /dev/null +++ b/deploy/k8s/secret.example.yaml @@ -0,0 +1,29 @@ +# The shape only. Do not commit real values: point your secret store at this name instead, +# or create it once by hand: +# +# kubectl -n impuestos create secret generic impuestos-secrets \ +# --from-literal=DATABASE_URL='postgres://user:pass@host:5432/impuestos' \ +# --from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \ +# --from-literal=S3_ACCESS_KEY_ID=... --from-literal=S3_SECRET_ACCESS_KEY=... +# +# Optional keys may be left out entirely. The API degrades honestly without them: no +# ANTHROPIC_API_KEY sends a scan with no QR to the manual form, no SMTP_HOST logs +# verification codes to stdout, and no VAPID keys hides push everywhere in the UI. +apiVersion: v1 +kind: Secret +metadata: + name: impuestos-secrets + namespace: impuestos +type: Opaque +stringData: + DATABASE_URL: 'postgres://impuestos:change-me@postgres:5432/impuestos' + BETTER_AUTH_SECRET: 'change-me-at-least-32-characters-long' + S3_ACCESS_KEY_ID: 'change-me' + S3_SECRET_ACCESS_KEY: 'change-me' + ANTHROPIC_API_KEY: '' + PUSH_VAPID_PUBLIC_KEY: '' + PUSH_VAPID_PRIVATE_KEY: '' + SMTP_HOST: '' + SMTP_USER: '' + SMTP_PASS: '' + TELEGRAM_BOT_TOKEN: '' diff --git a/deploy/k8s/web.yaml b/deploy/k8s/web.yaml new file mode 100644 index 0000000..241d41c --- /dev/null +++ b/deploy/k8s/web.yaml @@ -0,0 +1,64 @@ +# The Next server. It holds no secrets and never opens a database connection: it renders +# and proxies /api to the api Service. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: impuestos-web + namespace: impuestos + labels: { app: impuestos, component: web } +spec: + replicas: 2 + selector: + matchLabels: { app: impuestos, component: web } + template: + metadata: + labels: { app: impuestos, component: web } + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: web + image: ghcr.io/example/impuestos-web:latest + ports: + - name: http + containerPort: 3000 + envFrom: + - configMapRef: { name: impuestos-web-config } + livenessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 2 + periodSeconds: 5 + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 512Mi } + volumeMounts: + - name: tmp + mountPath: /tmp + # Next writes its own cache under the app directory at runtime. + - name: next-cache + mountPath: /app/apps/web/.next/cache + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ['ALL'] } + volumes: + - name: tmp + emptyDir: {} + - name: next-cache + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: impuestos-web + namespace: impuestos +spec: + type: ClusterIP + selector: { app: impuestos, component: web } + ports: + - name: http + port: 3000 + targetPort: http diff --git a/deploy/k8s/worker.yaml b/deploy/k8s/worker.yaml new file mode 100644 index 0000000..edae8db --- /dev/null +++ b/deploy/k8s/worker.yaml @@ -0,0 +1,62 @@ +# The job poller, on its own so it can be sized and restarted without touching the API. +# +# Safe at any replica count on Postgres: claiming uses FOR UPDATE SKIP LOCKED, which is +# covered by the hundred-job two-worker test in apps/api/src/modules/jobs/scale.test.ts. +# One is the default because the queue is small; raise it when the bandeja backs up. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: impuestos-worker + namespace: impuestos + labels: { app: impuestos, component: worker } +spec: + replicas: 1 + selector: + matchLabels: { app: impuestos, component: worker } + template: + metadata: + labels: { app: impuestos, component: worker } + spec: + # A job that is mid-flight when the pod goes is recovered by the stale sweep rather + # than lost, but finishing is cheaper than recovering. + terminationGracePeriodSeconds: 30 + containers: + - name: worker + image: ghcr.io/example/impuestos-api:latest + ports: + - name: http + containerPort: 4000 + env: + # ROLE=worker is what starts the poller; JOBS_INLINE only decides whether a + # server also runs one. + - name: ROLE + value: 'worker' + envFrom: + - configMapRef: { name: impuestos-config } + - secretRef: { name: impuestos-secrets } + # A worker still serves /healthz and /readyz, which is how the cluster knows the + # poller has a database to poll. It is not behind a Service. + livenessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: { path: /readyz, port: http } + initialDelaySeconds: 2 + periodSeconds: 10 + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 512Mi } + volumeMounts: + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ['ALL'] } + volumes: + # The root filesystem is read only, so the one place anything may be written is an + # empty directory that dies with the pod. Nothing durable belongs here: uploads go + # through the storage driver to S3. + - name: tmp + emptyDir: {} diff --git a/package.json b/package.json index 9aefdba..dbbfeea 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "jsqr": "^1.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.69.0", - "vitest": "^5.0.0" + "vitest": "^5.0.0", + "yaml": "^2.9.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60e25bb..5c34e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,10 @@ importers: version: 8.69.0(eslint@10.9.1(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 apps/api: dependencies: @@ -76,7 +79,7 @@ importers: version: link:../../packages/rules better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -134,7 +137,7 @@ importers: version: 1.5.4 tsup: specifier: ^8.5.1 - version: 8.5.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3) + version: 8.5.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3)(yaml@2.9.0) tsx: specifier: ^4.23.13 version: 4.23.13 @@ -158,7 +161,7 @@ importers: version: 5.102.8(react@19.2.8) better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -3620,6 +3623,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -4915,7 +4923,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -4923,14 +4931,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.1 - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.0 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) '@vitest/spy@5.0.0': {} @@ -5041,7 +5049,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0): + better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))): dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) @@ -5066,7 +5074,7 @@ snapshots: pg: 8.23.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -6270,13 +6278,14 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 postcss: 8.5.28 tsx: 4.23.13 + yaml: 2.9.0 postcss@8.5.23: dependencies: @@ -6679,7 +6688,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3): + tsup@8.5.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -6690,7 +6699,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.63.1 source-map: 0.7.6 @@ -6799,7 +6808,7 @@ snapshots: uuidv7@1.2.1: {} - vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13): + vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -6812,11 +6821,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.13 + yaml: 2.9.0 - vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -6827,7 +6837,7 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.1 @@ -6911,6 +6921,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 diff --git a/vitest.config.ts b/vitest.config.ts index d39e90c..5f72985 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,13 @@ export default defineConfig({ test: { include: ['{apps,packages}/*/src/**/*.test.ts'], environment: 'node', + /** + * Against SQLite in memory a harness is built in milliseconds. Against a real Postgres + * it creates a database, runs both migrators and seeds forty documents, which is + * seconds of honest work rather than a hang. + */ + testTimeout: process.env['TEST_DATABASE_URL'] ? 60_000 : 5_000, + hookTimeout: process.env['TEST_DATABASE_URL'] ? 60_000 : 10_000, coverage: { provider: 'v8', include: ['packages/rules/src/**'],