From 57b74c078d53aeb10905d981d45886a884426281 Mon Sep 17 00:00:00 2001 From: "qualityruntime[bot]" <330432719+qualityruntime[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:10:53 +0000 Subject: [PATCH] feat: add the server app and wire Better Auth to the schema Add `apps/server`: a Hono application that mounts Better Auth at `/api/auth/*`, plus the Bun entry point that owns the connection pool. `authOptions` is now the single source of truth for the static Better Auth configuration, and the compatibility test derives its expectations from that same object rather than a second plugin list. This proves what `packages/db` could only assert structurally: Better Auth's own writes satisfy the prefixed-identifier CHECK constraints, and its adapter accepts the schema. The structural comparison moved here with the configuration, so `packages/db` no longer depends on Better Auth at all. `createAuth` rejects a secret shorter than 32 characters, which Better Auth only warns about, and every deployment adapter inherits that guard. `better-auth` and its Drizzle adapter are pinned exactly, because compatibility with a hand-maintained schema is version-specific and upgrading it is a deliberate change. --- .env.example | 8 + .vscode/extensions.json | 3 + .vscode/settings.json | 18 +++ AGENTS.md | 2 +- README.md | 1 + apps/server/app.ts | 18 +++ apps/server/auth.test.ts | 146 +++++++++++++++++++ apps/server/auth.ts | 63 ++++++++ apps/server/bun.ts | 34 +++++ apps/server/package.json | 19 +++ bun.lock | 21 ++- docs/adr/0001-drizzle-orm-and-better-auth.md | 18 +-- docs/adr/0002-prefixed-identifiers.md | 4 +- docs/development.md | 27 +++- package.json | 1 + packages/db/package.json | 1 - packages/db/schema/auth.test.ts | 60 -------- packages/db/schema/auth.ts | 6 +- packages/db/schema/index.test.ts | 35 +++++ packages/db/schema/index.ts | 4 +- vite.config.ts | 4 +- 21 files changed, 403 insertions(+), 90 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 apps/server/app.ts create mode 100644 apps/server/auth.test.ts create mode 100644 apps/server/auth.ts create mode 100644 apps/server/bun.ts create mode 100644 apps/server/package.json delete mode 100644 packages/db/schema/auth.test.ts create mode 100644 packages/db/schema/index.test.ts diff --git a/.env.example b/.env.example index 7471a68..5e80585 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime + +# Public origin the server is reached at. +BETTER_AUTH_URL=http://localhost:3000 + +# Used for encryption, signing, and hashing. At least 32 high-entropy +# characters; generate one with `openssl rand -base64 32`. Left empty on +# purpose, so a copied example cannot become a known production secret. +BETTER_AUTH_SECRET= diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4ea53a7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["VoidZero.vite-plus-extension-pack", "TypeScriptTeam.native-preview"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..eab97b0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "editor.defaultFormatter": "oxc.oxc-vscode", + "[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, + "oxc.disableNestedConfig": true, + "oxc.fmt.disableNestedConfig": true, + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + }, + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": "./node_modules/typescript" +} diff --git a/AGENTS.md b/AGENTS.md index 6e5247c..745c2d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Read `ARCHITECTURE.md` before making structural changes. Deployment adapters may depend on core code; core code must not depend on deployment adapters or provider-specific concerns such as Cloudflare. -Apps go in `apps/`, shared code in `packages/` — extract a package only when the boundary is real. +Apps go in `apps/`, shared code in `packages/` — extract a package only when the boundary is real, and keep layouts shallow until more structure earns its keep. ## Changes diff --git a/README.md b/README.md index 3c3fbcf..cd4ac8c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Quality Runtime is being built primarily with: - TypeScript - Bun - PostgreSQL +- Drizzle ORM - React - Vite+ - Hono diff --git a/apps/server/app.ts b/apps/server/app.ts new file mode 100644 index 0000000..83537e3 --- /dev/null +++ b/apps/server/app.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +import { Hono } from "hono"; +import type { Auth } from "./auth.ts"; + +/** + * The HTTP surface. + * + * Better Auth owns every route under `/api/auth` and validates methods itself, + * so every method is forwarded. Domain routes mount alongside it as they + * arrive; `/api/auth` is Better Auth's own API, not this product's public one. + */ +export function createApp(auth: Auth) { + return new Hono().all("/api/auth/*", (c) => auth.handler(c.req.raw)); +} + +export type App = ReturnType; diff --git a/apps/server/auth.test.ts b/apps/server/auth.test.ts new file mode 100644 index 0000000..ac63e1d --- /dev/null +++ b/apps/server/auth.test.ts @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Checks this configuration against the schema in `packages/db`. + * + * Structurally: every model `authOptions` causes Better Auth to write must be + * exported under the name its adapter looks up, with a column for every field. + * Behaviourally: representative flows — credential sign-up and creating an + * organization — run against migrated PostgreSQL, so Better Auth's own writes + * are shown to satisfy the identifier CHECK constraints. Verification, + * invitation, and two-factor writes are not exercised here. + * + * Both of those derive from `authOptions`, so a plugin change moves the + * expectation with it rather than leaving a second list to update. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema } from "@qualityruntime/db"; +import { getAuthTables } from "better-auth/db"; +import { eq, getTableColumns, is, Table } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { type Auth, authOptions, createAuth } from "./auth.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +let db: ReturnType; +let auth: Auth; +let app: ReturnType; + +beforeAll(async () => { + db = drizzle(new PGlite(), { schema }); + await migrate(db, { migrationsFolder }); + auth = createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }); + app = createApp(auth); +}, 60_000); + +/** Drops the response attributes so the value is a valid `Cookie` request header. */ +const toCookieHeader = (response: Response) => + response.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + +const signUp = (email: string) => + app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Ada", email, password: "correct horse battery" }), + }); + +const json = async (response: Response): Promise => (await response.json()) as T; + +describe.each(Object.entries(getAuthTables(authOptions)))("%s", (key, table) => { + const name = table.modelName || key; + // Better Auth's Drizzle adapter finds a table by the key it is exported + // under, not by its physical name. + const exported = (schema as Record)[name]; + + it("is exported under the name the adapter looks up", () => { + expect(is(exported, Table)).toBe(true); + }); + + it("has a column for every field Better Auth writes", () => { + const columns = Object.keys(getTableColumns(exported as Table)); + const written = Object.entries(table.fields).map( + ([field, attribute]) => attribute.fieldName || field, + ); + expect(columns).toEqual(expect.arrayContaining(written)); + }); + + it("does not require a column Better Auth never writes", () => { + const columns = getTableColumns(exported as Table); + const written = new Set([ + "id", + ...Object.entries(table.fields).map(([field, attribute]) => attribute.fieldName || field), + ]); + const unsatisfiable = Object.entries(columns) + .filter(([column]) => !written.has(column)) + .filter(([, column]) => column.notNull && !column.hasDefault) + .map(([column]) => column); + expect(unsatisfiable).toEqual([]); + }); +}); + +describe("configuration", () => { + it("refuses a secret shorter than Better Auth's documented minimum", () => { + // Better Auth only warns, so this guard is the thing that stops a short + // secret reaching production. + expect(() => createAuth(db, { baseURL: "http://localhost", secret: "too-short" })).toThrow( + /at least 32 characters/, + ); + }); +}); + +describe("Better Auth writes against the migrated schema", () => { + it("creates a user, its credential account, and a session", async () => { + const response = await signUp("ada@example.test"); + expect(response.status).toBe(200); + const { user } = await json<{ user: { id: string } }>(response); + + expect(user.id).toMatch(/^usr_[0-9a-z]{16}$/); + const [account] = await db + .select() + .from(schema.account) + .where(eq(schema.account.userId, user.id)); + expect(account?.id).toMatch(/^acc_[0-9a-z]{16}$/); + expect(account?.providerId).toBe("credential"); + + const [session] = await db + .select() + .from(schema.session) + .where(eq(schema.session.userId, user.id)); + expect(session?.id).toMatch(/^ses_[0-9a-z]{16}$/); + }); + + it("creates an organization with its owner membership", async () => { + const signedUp = await signUp("owner@example.test"); + expect(signedUp.status).toBe(200); + const userId = (await json<{ user: { id: string } }>(signedUp)).user.id; + + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie: toCookieHeader(signedUp) }, + body: JSON.stringify({ name: "Acme", slug: "acme" }), + }); + expect(created.status).toBe(200); + const organization = await json<{ id: string }>(created); + + expect(organization.id).toMatch(/^org_[0-9a-z]{16}$/); + const [member] = await db + .select() + .from(schema.member) + .where(eq(schema.member.organizationId, organization.id)); + expect(member?.id).toMatch(/^mem_[0-9a-z]{16}$/); + expect(member?.userId).toBe(userId); + expect(member?.role).toBe("owner"); + }); +}); diff --git a/apps/server/auth.ts b/apps/server/auth.ts new file mode 100644 index 0000000..2e61dd8 --- /dev/null +++ b/apps/server/auth.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +import { drizzleAdapter } from "@better-auth/drizzle-adapter"; +import { generateId, schema } from "@qualityruntime/db"; +// `minimal` leaves out the bundled Kysely path, which the Drizzle adapter +// replaces; it keeps the Workers bundle smaller. +import { betterAuth } from "better-auth/minimal"; +import type { BetterAuthOptions } from "better-auth/types"; +import { admin, organization, twoFactor } from "better-auth/plugins"; + +/** Whatever the Drizzle adapter accepts: any Drizzle PostgreSQL driver, so + * broader than the node-postgres `Database` query code uses. */ +type AuthDatabase = Parameters[0]; + +/** + * The static Better Auth configuration, shared by the runtime and the + * compatibility test. + * + * Some of it decides which tables and columns exist — the plugin list above + * all — so `auth.test.ts` derives Better Auth's expected schema from this same + * object rather than a second copy. Teams and dynamic access control stay off; + * both add tables (ADR 0001). + */ +export const authOptions = { + // Authenticator apps show this as the TOTP issuer. + appName: "Quality Runtime", + emailAndPassword: { enabled: true }, + plugins: [organization(), admin(), twoFactor()], + // Identifiers are prefixed and CHECK-enforced, so Better Auth must generate + // them through `@qualityruntime/db` or every insert is rejected (ADR 0002). + advanced: { database: { generateId } }, +} satisfies BetterAuthOptions; + +export interface AuthEnvironment { + /** Public origin the instance is reached at, used for callbacks and cookies. */ + baseURL: string; + /** At least 32 high-entropy characters; used for encryption, signing, and hashing. */ + secret: string; +} + +/** + * Builds the Better Auth instance for a database handle. + * + * Takes the handle rather than opening one so a test can bind it to a + * throwaway database, matching `createDatabase` (ARCH-01). + */ +export function createAuth(db: AuthDatabase, { baseURL, secret }: AuthEnvironment) { + // Better Auth logs a warning below this length and carries on; a short secret + // should not be able to reach production quietly. + if (secret.length < 32) { + throw new Error("BETTER_AUTH_SECRET must be at least 32 characters."); + } + + return betterAuth({ + ...authOptions, + baseURL, + secret, + database: drizzleAdapter(db, { provider: "pg", schema }), + }); +} + +export type Auth = ReturnType; diff --git a/apps/server/bun.ts b/apps/server/bun.ts new file mode 100644 index 0000000..2ce255c --- /dev/null +++ b/apps/server/bun.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Bun entry point: the deployment adapter for a long-lived server process. + * + * Deployment-specific by design — it reads the environment and owns a + * connection pool for the life of the process. A Workers entry would sit + * beside this file and build a per-request client behind Hyperdrive instead + * (ARCH-01). + */ + +import { createDatabase } from "@qualityruntime/db"; +import { Pool } from "pg"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +/** Fails at start-up rather than on the first request that needs the value. */ +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not set.`); + return value; +} + +// This process owns the pool; `packages/db` only binds Drizzle to it. +const pool = new Pool({ connectionString: requireEnv("DATABASE_URL") }); +const db = createDatabase(pool); + +export default createApp( + createAuth(db, { + baseURL: requireEnv("BETTER_AUTH_URL"), + secret: requireEnv("BETTER_AUTH_SECRET"), + }), +); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..eb04444 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "@qualityruntime/server", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "dependencies": { + "@better-auth/drizzle-adapter": "1.7.5", + "@qualityruntime/db": "workspace:*", + "better-auth": "1.7.5", + "drizzle-orm": "^0.45.2", + "hono": "^4.10.7", + "pg": "^8.23.0" + }, + "devDependencies": { + "@electric-sql/pglite": "^0.5.8", + "@types/pg": "^8.23.1" + } +} diff --git a/bun.lock b/bun.lock index 9d44974..cd4a02f 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,22 @@ "vite-plus": "^0.3.2", }, }, + "apps/server": { + "name": "@qualityruntime/server", + "version": "0.0.0", + "dependencies": { + "@better-auth/drizzle-adapter": "1.7.5", + "@qualityruntime/db": "workspace:*", + "better-auth": "1.7.5", + "drizzle-orm": "^0.45.2", + "hono": "^4.10.7", + "pg": "^8.23.0", + }, + "devDependencies": { + "@electric-sql/pglite": "^0.5.8", + "@types/pg": "^8.23.1", + }, + }, "packages/db": { "name": "@qualityruntime/db", "version": "0.0.0", @@ -21,7 +37,6 @@ "devDependencies": { "@electric-sql/pglite": "^0.5.8", "@types/pg": "^8.23.1", - "better-auth": "^1.7.5", "drizzle-kit": "^0.31.10", }, }, @@ -219,6 +234,8 @@ "@qualityruntime/db": ["@qualityruntime/db@workspace:packages/db"], + "@qualityruntime/server": ["@qualityruntime/server@workspace:apps/server"], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], @@ -441,6 +458,8 @@ "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], + "hono": ["hono@4.13.8", "", {}, "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], diff --git a/docs/adr/0001-drizzle-orm-and-better-auth.md b/docs/adr/0001-drizzle-orm-and-better-auth.md index e7cde84..3158f8a 100644 --- a/docs/adr/0001-drizzle-orm-and-better-auth.md +++ b/docs/adr/0001-drizzle-orm-and-better-auth.md @@ -18,7 +18,7 @@ Use **Drizzle ORM** for schema definition, queries, and migrations, and **Better Drizzle's schema is plain TypeScript that compiles to SQL we can read in review, and `drizzle-kit generate` produces checked-in, forward-only SQL migrations (MIGRATION-01). It stays close to SQL, so the PostgreSQL capabilities the architecture leans on remain reachable. -Better Auth ships the tenancy model the product needs in its `organization` plugin, so organizations, memberships, and invitations are one source of truth rather than a parallel model beside the auth library's own. The schema supports these plugins; application authentication is not implemented yet: +Better Auth ships the tenancy model the product needs in its `organization` plugin, so organizations, memberships, and invitations are one source of truth rather than a parallel model beside the auth library's own. The server enables these plugins: | Plugin | Why | | -------------- | --------------------------------------------------------------------- | @@ -28,12 +28,9 @@ Better Auth ships the tenancy model the product needs in its `organization` plug Teams and dynamic access control are deliberately off. Both add tables, and neither has a requirement behind it yet. -The Drizzle schema is written by hand rather than generated into the repository on every change. Two things keep it honest, and they cover different ground: +The Drizzle schema is written by hand rather than generated into the repository on every change. Better Auth 1.7 runs the authoritative **structural** check when its adapter initializes, against the Drizzle schema object rather than the live database: every table and column it writes must exist, and no column it never fills may be required. `apps/server/auth.test.ts` initializes that adapter in CI, and additionally asserts every model is exported under the name the adapter looks up. -- Better Auth 1.7 runs the authoritative **structural** check when its adapter initializes, against the Drizzle schema object rather than the live database: every table and column it writes must exist, and no column it never fills may be required. It does not compare types, uniqueness, indexes, or references, and it does not prove equivalence with the generated DDL. -- `packages/db/schema/auth.test.ts` asserts the same structural properties from `getAuthTables`, plus an identifier format per model — before any application exists to initialize an adapter. - -Nothing automatically verifies column types, uniqueness, indexes, or references against Better Auth's definitions. When upgrading, compare a reference schema generated from the matching Better Auth configuration with the maintained schema. `migrations.test.ts` exercises selected database-level deviations and invariants against a real engine; it does not verify every deviation. +Neither compares types, uniqueness, indexes, or references, and neither proves equivalence with the generated DDL. Nothing automatically verifies column types, uniqueness, indexes, or references against Better Auth's definitions. When upgrading, compare a reference schema generated from the matching Better Auth configuration with the maintained schema. `migrations.test.ts` exercises selected database-level deviations and invariants against a real engine; it does not verify every deviation. Migrations are generated with `drizzle-kit` and applied with it during development. Deployment applies the same checked-in migration history by a target-appropriate mechanism. @@ -49,11 +46,6 @@ No Drizzle `relations()` are declared. They serve Better Auth's opt-in join mode `createDatabase` takes a `pg` `Client` or `Pool` rather than a connection string. Connection lifecycle differs enough between a long-lived server and a Worker behind Hyperdrive that choosing one here would put a deployment concern in the core (ARCH-01); the deployment owns it and injects the result. -Domain tables join the same package and the same migration history. `packages/db` depends on `better-auth` only as a development dependency, for the schema test; the server configuration that calls `betterAuth()` will live with the application that serves it, and must both use the same schema-affecting plugin configuration the test declares — plugin options such as `teams` add tables — and pass `generateId` from [ADR 0002](0002-prefixed-identifiers.md): +Domain tables join the same package and the same migration history. `packages/db` has no dependency on Better Auth, but it does hold the persistence schema Better Auth expects, in its naming and with its deviations documented. The configuration itself is `authOptions` in `apps/server/auth.ts`, the single source of truth for the server's static Better Auth configuration, which the compatibility test derives its expectations from. Options such as `teams` change the schema too, so it is the whole object that matters, not the plugin names alone. -```ts -betterAuth({ - plugins: [organization(), admin(), twoFactor()], - advanced: { database: { generateId } }, -}); -``` +`better-auth` and its Drizzle adapter are pinned to exact versions. Compatibility with a hand-maintained schema is version-specific, so upgrading is a deliberate change — bump both together, run the tests, diff against freshly generated reference output, and migrate — not something a routine dependency refresh performs. diff --git a/docs/adr/0002-prefixed-identifiers.md b/docs/adr/0002-prefixed-identifiers.md index 14457b3..dfccbcb 100644 --- a/docs/adr/0002-prefixed-identifiers.md +++ b/docs/adr/0002-prefixed-identifiers.md @@ -35,7 +35,7 @@ Sixteen base36 characters is about 83 bits (36^16 ≈ 8 × 10^24) — past any c Sizing by table cardinality — wider for `session` and `verification`, narrower for `organization` — was considered and rejected. It is the wrong axis: `session.token` authenticates a session and `verification.value` proves a verification, so widening those `id` columns hardens nothing, and narrowing `organization` buys four characters in exchange for a third rule and a CHECK constraint that is painful to widen later. -`packages/db/id.ts` owns the prefix and length of every table and is the single place where identifiers are generated. Two things consume it: +`packages/db/id.ts` owns the prefix and length of every generated row-identifier type and is the single place where identifiers are generated. Two things consume it: - Each Drizzle `id` column defaults to `createId(type)`, so ordinary inserts need not think about it. - `generateId` is shaped for Better Auth's `advanced.database.generateId`, which is how the library is told to generate these instead of its own. @@ -50,7 +50,7 @@ CONSTRAINT "user_id_format" CHECK ("id" ~ '^usr_[0-9a-z]{16}$') The format is enforced by the database, not by convention. A writer that has not been wired to `id.ts` — most importantly a Better Auth configuration that forgets `generateId` — fails on its first insert instead of quietly storing identifiers in a second format that later has to be migrated. `migrations.test.ts` applies the migrations to PostgreSQL and asserts exactly that rejection. -Adding a table means choosing its prefix and its length. `auth.test.ts` fails when Better Auth writes a table that `idFormats` does not cover, and `generateId` throws rather than inventing a format. +Adding a table with an `id` column means choosing its prefix and its length. `packages/db/schema/index.test.ts` fails when such a table has no format, and `generateId` throws rather than inventing one. A join table keyed by its foreign keys needs no identifier and is exempt. Changing a table's alphabet or length is a migration, because the CHECK constraints encode both. This is the intended cost: it makes the identifier format part of the schema rather than a convention that drifts. It also means a length should be chosen with room to be wrong in the safe direction — widening later is cheap, narrowing invalidates existing rows. diff --git a/docs/development.md b/docs/development.md index dc06c0c..444425e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,7 @@ This document describes the local development workflow for Quality Runtime. - Bun (the version pinned by `packageManager` in `package.json`) -The toolchain is [Vite+](https://viteplus.dev): dev server, build, tests, linting, and formatting behind the `vp` CLI, configured in `vite.config.ts`. +The toolchain is [Vite+](https://viteplus.dev): formatting, linting, type checks, and tests behind the `vp` CLI, configured in `vite.config.ts`. The server itself runs under Bun. ## Setup @@ -25,13 +25,20 @@ bun run lint # lint only bun run test # run tests ``` -Vite+ offers more (`vp dev`, `vp build`); those are documented here once the application scaffold uses them. Commands shown in this document work as written; do not assume undocumented ones exist. +Commands shown in this document work as written; do not assume undocumented ones exist. + +## Editor + +`.vscode/` carries the shared setup; VS Code offers the recommended extensions on first open. + +- **[Vite+ extension pack](https://marketplace.visualstudio.com/items?itemName=VoidZero.vite-plus-extension-pack)** — formats with Oxfmt on save and lints with Oxlint, the same tools `bun run check` runs. The `[language]` overrides in `settings.json` are required: VS Code gives a user-level `[typescript]` formatter priority over a workspace default, so a global Prettier would otherwise take over and fight `vp fmt`. +- **[TypeScript 7](https://marketplace.visualstudio.com/items?itemName=TypeScriptTeam.native-preview)** — required for type information in the editor. TypeScript 7 is the native compiler and ships no `tsserver.js`, so VS Code's built-in language service cannot run the workspace version; without this extension you get no types, no IntelliSense, and no inline errors. ## Database PostgreSQL is the application database. The schema, the Drizzle client, and the migrations live in `packages/db`. -Point `DATABASE_URL` at a database — copy `.env.example` to `.env` in the repository root and adjust it: +Copy `.env.example` to `.env` in the repository root, point `DATABASE_URL` at a database, and fill `BETTER_AUTH_SECRET` with `openssl rand -base64 32` (Better Auth only warns below 32 characters, so `createAuth` refuses to start): ```sh docker run -d --name qualityruntime-postgres \ @@ -52,9 +59,19 @@ Both are also `bun run generate` / `bun run migrate` inside `packages/db`; the D Always pass `--name`: it names the file and its `tag` in `migrations/meta/_journal.json` together. That file is drizzle-kit's, so never hand-edit it. Add the SPDX header to the generated `.sql`. Never edit a migration that may already have been applied; add a new one. See `AGENTS.md` for the rules that govern migration history, and test migrations against realistic existing data when the change is non-trivial. -`packages/db/schema/migrations.test.ts` applies the migrations to PostgreSQL (via PGlite, so nothing needs to be running) and checks the constraints they create. `packages/db/schema/auth.test.ts` checks the Drizzle tables against what Better Auth says it writes. +`packages/db/schema/migrations.test.ts` applies the migrations to PostgreSQL (via PGlite, so nothing needs to be running) and checks the constraints they create. `apps/server/auth.test.ts` checks structural compatibility with Better Auth and exercises selected Better Auth writes against that migrated schema. + +After upgrading `better-auth`, run `bun run test`, then compare `packages/db/schema/auth.ts` against Drizzle reference output from the matching `auth` CLI version, generated from `authOptions` by a module exporting a built `auth` instance. Neither test compares column types, indexes, or foreign keys against the library — [ADR 0001](adr/0001-drizzle-orm-and-better-auth.md) records that gap. + +## Running the server + +```sh +bun run dev # http://localhost:3000, restarting on change +``` + +It runs from the repository root so Bun loads the root `.env`, and it refuses to start when `DATABASE_URL`, `BETTER_AUTH_URL`, or `BETTER_AUTH_SECRET` is missing rather than failing on the first request that needs one. -After upgrading `better-auth`, run `bun run test`, then compare `schema/auth.ts` with Drizzle reference output from the matching `auth` CLI version, generated from the same schema-affecting plugin configuration. Generating it needs a Better Auth config; the exact command belongs here once the application provides one. Those tests are structural only; [ADR 0001](adr/0001-drizzle-orm-and-better-auth.md) records what they do not cover. +`apps/server` mounts [Better Auth](https://better-auth.com) at `/api/auth/*`. `authOptions` in `apps/server/auth.ts` is the schema contract — it decides which tables exist, and `auth.test.ts` derives its expectations from that same object. Better Auth refuses to start when the Drizzle schema object disagrees with it; that check reads the schema in code, not the live database, so applying migrations is still on you. ## Before finishing diff --git a/package.json b/package.json index fb513af..10463af 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "fmt": "vp fmt", "lint": "vp lint", "test": "vp test", + "dev": "bun --watch apps/server/bun.ts", "db:generate": "bun run --cwd packages/db generate", "db:migrate": "bun run --cwd packages/db migrate" }, diff --git a/packages/db/package.json b/packages/db/package.json index 157e9a0..3fa3975 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -20,7 +20,6 @@ "devDependencies": { "@electric-sql/pglite": "^0.5.8", "@types/pg": "^8.23.1", - "better-auth": "^1.7.5", "drizzle-kit": "^0.31.10" } } diff --git a/packages/db/schema/auth.test.ts b/packages/db/schema/auth.test.ts deleted file mode 100644 index ab96707..0000000 --- a/packages/db/schema/auth.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Quality Runtime contributors -// SPDX-License-Identifier: Apache-2.0 - -/** - * Guards the hand-maintained Drizzle tables against the schema Better Auth - * actually writes, so a library upgrade that adds or renames a field fails here - * in CI rather than when the adapter initializes. - */ - -import { getAuthTables } from "better-auth/db"; -import { admin, organization, twoFactor } from "better-auth/plugins"; -import { getTableColumns, is, Table } from "drizzle-orm"; -import { describe, expect, it } from "vite-plus/test"; -import { idFormats } from "../id.ts"; -import * as schema from "./index.ts"; - -// The future server configuration must use this same schema-affecting plugin -// configuration — options such as `teams` add tables — since it defines the -// schema contract tested here. -const expected = getAuthTables({ - plugins: [organization(), admin(), twoFactor()], -}); - -describe.each(Object.entries(expected))("%s", (key, table) => { - const name = table.modelName || key; - // Better Auth's Drizzle adapter finds a table by the key it is exported - // under, not by its physical name. - const exported = (schema as Record)[name]; - - it("is exported under the name the adapter looks up", () => { - expect(is(exported, Table)).toBe(true); - }); - - it("has an identifier format", () => { - // Better Auth asks `id.ts` for an id under the model name, so a table - // it writes without a format here throws on its first insert. - expect(Object.keys(idFormats)).toContain(key); - }); - - it("has a column for every field Better Auth writes", () => { - const columns = getTableColumns(exported as Table); - const written = Object.entries(table.fields).map( - ([field, attribute]) => attribute.fieldName || field, - ); - expect(Object.keys(columns)).toEqual(expect.arrayContaining(written)); - }); - - it("accepts an insert that omits every column Better Auth does not write", () => { - const columns = getTableColumns(exported as Table); - const written = new Set([ - "id", - ...Object.entries(table.fields).map(([field, attribute]) => attribute.fieldName || field), - ]); - const unsatisfiable = Object.entries(columns) - .filter(([column]) => !written.has(column)) - .filter(([, column]) => column.notNull && !column.hasDefault) - .map(([column]) => column); - expect(unsatisfiable).toEqual([]); - }); -}); diff --git a/packages/db/schema/auth.ts b/packages/db/schema/auth.ts index 967ba00..fcae9a4 100644 --- a/packages/db/schema/auth.ts +++ b/packages/db/schema/auth.ts @@ -6,9 +6,9 @@ * * Better Auth addresses tables by the key they are exported under, so the * export names below are its model names and must not be renamed; the physical - * table and column names are ours. `auth.test.ts` catches models and fields - * this file is missing, but not column types or constraints; after upgrading - * Better Auth, diff this against freshly generated reference output. + * table and column names are ours. `apps/server/auth.test.ts` catches models and + * fields this file is missing, but not column types or constraints; after + * upgrading Better Auth, diff this against freshly generated reference output. * * Deliberate deviations from the generated output: * - `timestamptz` throughout, so audit history is unambiguous across zones. diff --git a/packages/db/schema/index.test.ts b/packages/db/schema/index.test.ts new file mode 100644 index 0000000..f6533a7 --- /dev/null +++ b/packages/db/schema/index.test.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Internal consistency of the schema, with no reference to Better Auth. + * + * Compatibility with Better Auth is checked in `apps/server/auth.test.ts`, + * where the configuration that decides those tables lives. + */ + +import { getTableColumns, is, Table } from "drizzle-orm"; +import { describe, expect, it } from "vite-plus/test"; +import { idFormats } from "../id.ts"; +import * as schema from "./index.ts"; + +const tables = Object.entries(schema).filter(([, value]) => is(value, Table)) as [string, Table][]; + +describe("identifier formats", () => { + it("refer only to exported tables", () => { + const missing = Object.keys(idFormats).filter( + (type) => !is((schema as Record)[type], Table), + ); + expect(missing).toEqual([]); + }); + + it("cover every table that has an id column", () => { + // Every table with a canonical `id` needs an identifier format (ADR 0002). + // A join table keyed by its foreign keys needs no surrogate identifier and + // is deliberately allowed. + const unformatted = tables + .filter(([key, table]) => "id" in getTableColumns(table) && !(key in idFormats)) + .map(([key]) => key); + expect(unformatted).toEqual([]); + }); +}); diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 0e0b1b7..8048066 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -4,8 +4,8 @@ /** * The whole database schema, as one object. * - * Drizzle's relational queries and Better Auth's Drizzle adapter both address - * tables by the key they are exported under, so every table belongs here. + * `createDatabase` hands this to Drizzle, and Better Auth's adapter looks + * tables up by the key they are exported under, so every table belongs here. */ export * from "./auth.ts"; diff --git a/vite.config.ts b/vite.config.ts index bf027e5..c47018d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from "vite-plus"; -// Vite+ is the toolchain entry point: dev server, build, tests, lint, format. -// Per-app and per-package configuration arrives with the application scaffold. +// Vite+ runs format, lint, type check, and test across the workspace; the +// server has its own Bun entry point. export default defineConfig({ // `bun run check` is the documented gate, so it has to type-check too; // without this it only formats and lints.