Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime

# The role migrations are applied as, which owns the schema. Required by
# `db:migrate`, which never falls back to DATABASE_URL.
MIGRATION_DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime

# Public origin the server is reached at.
BETTER_AUTH_URL=http://localhost:3000

Expand Down
4 changes: 2 additions & 2 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ These tables are owned by [Better Auth](https://better-auth.com) and defined in

## Identifiers

Every record's primary key uses `<prefix>_<random>`, for example `usr_v1stgxr8z5jdhi6b`. The prefix identifies the record type, and the random portion is a lowercase base36 value generated with [Nano ID](https://github.com/ai/nanoid). [ADR 0002](adr/0002-prefixed-identifiers.md) documents the decision. `packages/db/id.ts` is the single place where identifiers are generated.
Every generated row identifier uses `<prefix>_<random>`, for example `usr_v1stgxr8z5jdhi6b`. The prefix identifies the record type, and the random portion is a lowercase base36 value generated with [Nano ID](https://github.com/ai/nanoid). [ADR 0002](adr/0002-prefixed-identifiers.md) documents the decision. `packages/db/id.ts` is the single place where identifiers are generated.

| Table | Prefix | Random length |
| -------------- | ------ | ------------- |
Expand All @@ -34,7 +34,7 @@ Every record's primary key uses `<prefix>_<random>`, for example `usr_v1stgxr8z5
| `member` | `mem_` | 16 |
| `two_factor` | `tfa_` | 16 |

A row identifier is not a credential: `session.token` authenticates a session and `verification.value` proves a verification. `invitation` is wider because Better Auth takes an invitation by id. Identifiers are allocated before insertion and reveal no row count. Each table enforces its own prefix, length, and alphabet with a CHECK constraint, so an identifier belonging to another table — or one carrying uppercase — is rejected rather than stored.
A row identifier is not a credential: `session.token` authenticates a session and `verification.value` proves a verification. `invitation` is wider because Better Auth takes an invitation by id. Identifiers are allocated before insertion and reveal no row count. Each `id` column enforces its table's prefix, length, and alphabet with a CHECK constraint, so an identifier belonging to another table — or one carrying uppercase — is rejected rather than stored. Join tables need no separate identifier: `control_requirement` uses `(control_id, requirement_id)` as its primary key.

## Domain entities

Expand Down
6 changes: 2 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ bun run lint # lint only
bun run test # run tests
```

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.
Expand All @@ -52,10 +50,10 @@ Change a table in `packages/db/schema/`, then generate and apply its migration:

```sh
bun run db:generate --name=add_controls # writes packages/db/migrations/NNNN_add_controls.sql
bun run db:migrate # applies pending migrations to DATABASE_URL
bun run db:migrate # applies pending migrations as MIGRATION_DATABASE_URL
```

Both are also `bun run generate` / `bun run migrate` inside `packages/db`; the Drizzle config loads the root `.env` either way. `db:generate` needs no database; `db:migrate` fails if `DATABASE_URL` is unset.
Both are also `bun run generate` / `bun run migrate` inside `packages/db`; the Drizzle config loads the root `.env` either way. `db:generate` needs no database; `db:migrate` connects as `MIGRATION_DATABASE_URL` and fails if it is unset, never falling back to `DATABASE_URL`.

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.

Expand Down
2 changes: 0 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,3 @@ For vulnerability reporting, see [`.github/SECURITY.md`](../.github/SECURITY.md)
`session.activeOrganizationId` records which tenant a request is acting in. It is **context, not authorization**. Tenant authorization must use the caller’s current `member` row — membership and role — resolved per request (TENANT-01).

Never treat possession of a session carrying an organization id as proof of access to that organization, and never filter by it alone.

This document will be expanded as authentication, tenancy, and file access are implemented.
17 changes: 14 additions & 3 deletions packages/db/client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
// SPDX-FileCopyrightText: 2026 Quality Runtime contributors
// SPDX-License-Identifier: Apache-2.0

import type { ExtractTablesWithRelations } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
import type { Client, Pool } from "pg";
import * as schema from "./schema/index.ts";

/**
* A Drizzle handle bound to a connection.
* A Drizzle handle on this schema.
*
* Query code takes this type rather than a connection of its own, so a test can
* hand it a handle bound to a throwaway database.
* hand it a handle bound to a throwaway database. A transaction satisfies it,
* which is what lets a query run inside `withOrganization`.
*
* The driver is a parameter because the handles here come from two:
* node-postgres in a deployment, PGlite in tests.
*/
export type Database = ReturnType<typeof createDatabase>;
export type Database<Q extends PgQueryResultHKT = NodePgQueryResultHKT> = PgDatabase<
Q,
typeof schema,
ExtractTablesWithRelations<typeof schema>
>;

/**
* Binds Drizzle to a connection the caller owns.
Expand Down
20 changes: 15 additions & 5 deletions packages/db/drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ const packageDir = relative(process.cwd(), fileURLToPath(new URL(".", import.met

// The repository keeps one `.env` at its root; drizzle-kit only reads the
// working directory's. Loading it here makes the commands behave the same from
// the root and from this package. The file is optional when DATABASE_URL
// is supplied by the environment.
// the root and from this package. The file is optional when
// MIGRATION_DATABASE_URL is supplied by the environment.
try {
process.loadEnvFile(fileURLToPath(new URL("../../.env", import.meta.url)));
} catch (error) {
Expand All @@ -31,10 +31,20 @@ export default defineConfig({
// added without one is spelled. Must match `createDatabase`.
casing: "snake_case",
dbCredentials: {
// Only read by commands that connect, so `generate` works without it.
/**
* Only read by commands that connect, so `generate` works without it.
*
* The migrator's, and never the server's `DATABASE_URL` as a fallback:
* migrations are applied by the role that owns the schema and runs with
* `row_security = off`, so a data migration that forgot its tenant fails
* rather than quietly matching nothing (ADR 0014). Keep this credential
* separate from the runtime role's.
*/
get url() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set; it is required to reach the database.");
const url = process.env.MIGRATION_DATABASE_URL;
if (!url) {
throw new Error("MIGRATION_DATABASE_URL is not set; migrations connect as the migrator.");
}
return url;
},
},
Expand Down
Loading
Loading