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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
DATABASE_URL=postgresql://osds:osds_dev_only@localhost:5432/osds
# App and worker connect as the least-privilege role, so RLS is enforced.
DATABASE_URL=postgresql://osds_app:osds_dev_only@localhost:5432/osds
# Migrations and codegen connect as the table owner (DDL, and it may bypass RLS).
DATABASE_URL_ADMIN=postgresql://osds:osds_dev_only@localhost:5432/osds

S3_ENDPOINT=http://localhost:9000
S3_BUCKET=osds
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ build/
coverage/
*.tsbuildinfo

# generated by kysely-codegen
packages/db/src/schema.ts

.env
.env.*
!.env.example
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ services:
POSTGRES_PASSWORD: osds_dev_only
POSTGRES_DB: osds
ports: ["5432:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
volumes:
- "pgdata:/var/lib/postgresql/data"
- "./infra/postgres/init:/docker-entrypoint-initdb.d:ro"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U osds"]
interval: 5s
Expand Down
12 changes: 12 additions & 0 deletions infra/postgres/init/10-osds-app-role.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Local dev only. Runs once, on first cluster init
-- (docker-entrypoint-initdb.d). Gives the least-privilege application role a
-- login for the dev container; its table privileges are granted by migration
-- 0013. Production provisions this role under its own auth model.
--
-- If your pgdata volume already exists, this file will not have run - create
-- the login by hand once:
-- docker compose exec postgres psql -U osds -c \
-- "create role osds_app login password 'osds_dev_only' nosuperuser nobypassrls;"
-- or reset the volume with: pnpm infra:reset

create role osds_app login password 'osds_dev_only' nosuperuser nobypassrls;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --build --force",
"migrate:dev": "pnpm --filter @osds/db migrate",
"db:codegen": "pnpm --filter @osds/db codegen",
"infra:up": "docker compose up -d",
"infra:down": "docker compose down",
"infra:reset": "docker compose down -v && docker compose up -d"
Expand Down
63 changes: 63 additions & 0 deletions packages/db/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# @osds/db

Postgres schema and access layer for OSDS: [Kysely](https://kysely.dev) query
builder, `pg` driver, hand-written SQL migrations. **The database is the source
of truth for the schema.**

## Migrations

`src/migrations/NNNN_name.ts` — each file runs raw SQL through Kysely's `sql`
tag and is tracked in `kysely_migration`. **Forward-only**: a merged migration
is never edited or reverted; every file's header carries a manual rollback note
for emergencies.

pnpm --filter @osds/db migrate # apply all pending
pnpm migrate:dev # same, from the repo root

Migrations connect as the **table owner** via `DATABASE_URL_ADMIN` (falling
back to `DATABASE_URL`). Requires a Postgres with the `postgis` and `pg_trgm`
extensions available on disk. The bundled `postgis/postgis` image
(`docker-compose.yml`) has both; migration `0001` runs `CREATE EXTENSION`.

The runner applies the whole batch in one transaction — a failure rolls back
cleanly.

## Generated types

pnpm --filter @osds/db codegen # writes src/schema.ts from a migrated DB

`src/schema.ts` is generated and git-ignored. Once it exists, pass its `DB`
type to the entry point:

import { createKysely } from "@osds/db";
import type { DB } from "@osds/db/schema";
const db = createKysely<DB>();

## Row-level security

Every table except `tenants` has **forced** RLS scoping rows to
`current_setting('app.tenant_id')`. RLS is only enforced against a role that is
neither the table owner nor holds `BYPASSRLS`.

- **`osds_app`** is that role. Migration `0013` creates it (`NOLOGIN`,
`NOSUPERUSER`, `NOBYPASSRLS`) and grants it `SELECT/INSERT/UPDATE/DELETE` on
the tenant tables (plus read-only `spatial_ref_sys`) and nothing more. The
app and worker connect as it via
`DATABASE_URL`, and must `SET app.tenant_id` (or `SET LOCAL` per transaction)
before any query — with the var unset, every policy returns zero rows, and a
cross-tenant write is refused by the policy's `WITH CHECK`.
- Granting `osds_app` a **login and password** is a deployment step (it touches
authentication — see `docs/agent-operations.md`). For local dev,
`infra/postgres/init/10-osds-app-role.sql` does it on first cluster init; if
your `pgdata` volume predates this, create the login by hand once or run
`pnpm infra:reset`.
- Migrations and codegen connect as the **owner** via `DATABASE_URL_ADMIN`.
- The outbox consumer is the one cross-tenant component: it runs as a role with
`BYPASSRLS`, or iterates tenants and sets `app.tenant_id` per batch.

## Scope

Tables so far: `tenants`, `tiers`, `categories`, `listing_categories`, `users`,
`listings`, `claims`, `entitlements`, `slot_pools`, `slots`, `outbox`. Reviews,
leads, moderation, compliance, agent, import and postal tables are not modelled
yet.
25 changes: 25 additions & 0 deletions packages/db/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@osds/db",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
"scripts": {
"build": "tsc -b",
"dev": "tsc -b --watch",
"migrate": "tsx src/migrate.ts",
"codegen": "kysely-codegen --dialect postgres --out-file src/schema.ts"
},
"dependencies": {
"kysely": "^0.27.4",
"pg": "^8.13.1"
},
"devDependencies": {
"@types/node": "^26.4.0",
"@types/pg": "^8.11.10",
"kysely-codegen": "^0.17.0",
"tsx": "^4.19.2"
}
}
25 changes: 25 additions & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @osds/db - Postgres access for OSDS.
*
* The schema is defined by the SQL migrations in ./migrations and owned by the
* database. Generated row types land in ./schema.ts after
* `pnpm --filter @osds/db codegen` run against a migrated database; pass that
* `DB` type to `createKysely<DB>()`.
*/
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";

export { sql } from "kysely";
export type { Kysely } from "kysely";

/** Build a Kysely instance backed by a `pg` pool. Throws if `DATABASE_URL` is unset. */
export function createKysely<DB = unknown>(
connectionString: string | undefined = process.env.DATABASE_URL,
): Kysely<DB> {
if (!connectionString) {
throw new Error("DATABASE_URL is not set");
}
return new Kysely<DB>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString }) }),
});
}
69 changes: 69 additions & 0 deletions packages/db/src/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Migration runner. Forward-only: applies every pending migration in
* ./migrations, in filename order, connecting as the table owner
* (DATABASE_URL_ADMIN, falling back to DATABASE_URL for single-URL setups).
*
* pnpm --filter @osds/db migrate # or, from the repo root: pnpm migrate:dev
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import {
Kysely,
Migrator,
PostgresDialect,
type Migration,
type MigrationProvider,
} from "kysely";
import { Pool } from "pg";

const migrationFolder = path.join(import.meta.dirname, "migrations");

/** Loads ./migrations/NNNN_*.{ts,js} via file:// URLs so it also works on Windows. */
const provider: MigrationProvider = {
async getMigrations(): Promise<Record<string, Migration>> {
const entries = await fs.readdir(migrationFolder);
const files = entries
.filter((f) => /^\d{4}_.+\.(ts|js)$/.test(f) && !f.endsWith(".d.ts"))
.sort();

const migrations: Record<string, Migration> = {};
for (const file of files) {
const href = pathToFileURL(path.join(migrationFolder, file)).href;
migrations[file.replace(/\.(ts|js)$/, "")] = (await import(href)) as Migration;
}
return migrations;
},
};

async function main(): Promise<void> {
const connectionString = process.env.DATABASE_URL_ADMIN ?? process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL_ADMIN (or DATABASE_URL) is not set");
}

// Untyped on purpose: the runner operates on the raw schema, before codegen.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = new Kysely<any>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString }) }),
});

try {
const { error, results } = await new Migrator({ db, provider }).migrateToLatest();

for (const r of results ?? []) {
const outcome =
r.status === "Success" ? "applied" : r.status === "Error" ? "FAILED " : "skipped";
console.log(`${outcome} ${r.migrationName}`);
}

if (error) {
console.error(error);
process.exitCode = 1;
}
} finally {
await db.destroy();
}
}

await main();
40 changes: 40 additions & 0 deletions packages/db/src/migrations/0001_extensions_and_functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* 0001_extensions_and_functions - database prerequisites.
*
* Enables PostGIS (geography columns + GiST) and pg_trgm (fuzzy name search),
* and creates two helpers used by later migrations and by RLS:
* - osds_current_tenant_id() : reads `app.tenant_id`, '' / unset -> NULL.
* - osds_set_updated_at() : BEFORE UPDATE trigger, touches updated_at.
*
* Rollback:
* drop function if exists osds_set_updated_at();
* drop function if exists osds_current_tenant_id();
* drop extension if exists pg_trgm;
* drop extension if exists postgis;
* (only once every later migration has been rolled back). Forward-only: no down().
*/
import { sql } from "kysely";
import type { MigrationDb } from "./types";

export async function up(db: MigrationDb): Promise<void> {
await sql`create extension if not exists postgis`.execute(db);
await sql`create extension if not exists pg_trgm`.execute(db);

await sql`
create function osds_current_tenant_id() returns text
language sql
stable
as $$ select nullif(current_setting('app.tenant_id', true), '') $$
`.execute(db);

await sql`
create function osds_set_updated_at() returns trigger
language plpgsql
as $$
begin
new.updated_at := now();
return new;
end
$$
`.execute(db);
}
31 changes: 31 additions & 0 deletions packages/db/src/migrations/0002_tenants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* 0002_tenants - the tenant (directory) table, root of every FK chain.
*
* The one table with no `tenant_id` and no RLS: it *is* the tenant. Single- vs
* multi-directory is the `mode` column - a UI toggle, never a data-model change
* (spec §13, invariant 3).
*
* Rollback:
* drop table if exists tenants cascade;
* (cascades to every tenant-scoped table - roll those back first).
* Forward-only: no down().
*/
import { sql } from "kysely";
import type { MigrationDb } from "./types";
import { touchUpdatedAt } from "./helpers";

export async function up(db: MigrationDb): Promise<void> {
await sql`
create table tenants (
id text primary key check (starts_with(id, 'tnt_')),
slug text not null unique,
domain text unique,
name text not null,
mode text not null default 'single' check (mode in ('single', 'multi')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
`.execute(db);

await touchUpdatedAt(db, "tenants");
}
34 changes: 34 additions & 0 deletions packages/db/src/migrations/0003_tiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 0003_tiers - per-tenant ordered tier list (spec §4.2).
*
* Core hardcodes no tier names. `rank` 0 is the fallback tier; a tenant may
* define none, which changes downgrade behaviour (§6.4). PK (tenant_id, key)
* is the target of every `(tenant_id, tier)` FK elsewhere.
*
* Rollback:
* drop table if exists tiers;
* (blocked while listings / entitlements / slot_pools reference it - roll
* those back first). Forward-only: no down().
*/
import { sql } from "kysely";
import type { MigrationDb } from "./types";
import { enableTenantRls, touchUpdatedAt } from "./helpers";

export async function up(db: MigrationDb): Promise<void> {
await sql`
create table tiers (
tenant_id text not null references tenants (id) on delete cascade,
key text not null,
rank integer not null check (rank >= 0),
purchasable boolean not null default false,
uses_slot boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, key),
unique (tenant_id, rank)
)
`.execute(db);

await enableTenantRls(db, "tiers");
await touchUpdatedAt(db, "tiers");
}
35 changes: 35 additions & 0 deletions packages/db/src/migrations/0004_categories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* 0004_categories - listing taxonomy, tenant-scoped, optionally hierarchical.
*
* `cat_`-prefixed ULID PK. The spec names category *slugs* but no PK scheme; a
* stable surrogate keeps the join and the slot-pool scope FK simple.
* `unique (tenant_id, id)` backs those composite FKs.
*
* Rollback:
* drop table if exists categories cascade;
* (roll back 0007 and 0010 first). Forward-only: no down().
*/
import { sql } from "kysely";
import type { MigrationDb } from "./types";
import { enableTenantRls, touchUpdatedAt } from "./helpers";

export async function up(db: MigrationDb): Promise<void> {
await sql`
create table categories (
id text primary key check (starts_with(id, 'cat_')),
tenant_id text not null references tenants (id) on delete cascade,
slug text not null,
name text not null,
parent_id text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, id),
unique (tenant_id, slug),
foreign key (tenant_id, parent_id)
references categories (tenant_id, id) on delete set null
)
`.execute(db);

await enableTenantRls(db, "categories");
await touchUpdatedAt(db, "categories");
}
Loading
Loading