Skip to content

feat(db): packages/db — schema, migrations, RLS (spec v0.4) - #14

Merged
OriginDevIT merged 1 commit into
mainfrom
feat/db-schema
Aug 30, 2026
Merged

feat(db): packages/db — schema, migrations, RLS (spec v0.4)#14
OriginDevIT merged 1 commit into
mainfrom
feat/db-schema

Conversation

@OriginDevIT

Copy link
Copy Markdown
Owner

New @osds/db package: Kysely + pg, hand-written forward-only SQL migrations run by a small Kysely Migrator script, kysely-codegen (dev-only) for row types. Adds kysely + pg as runtime deps (approved). Authored against spec v0.4.

Scope

Tables 00010013, per the agreed list — nothing else yet:

tenants · tiers · categories · listing_categories · users · listings · claims · entitlements · slot_pools · slots · outbox

Rules applied

Rule How
tenant_id + RLS on every table except tenants ENABLE + FORCE row level security, policy tenant_id = osds_current_tenant_id() with WITH CHECK. Unset app.tenant_id ⇒ zero rows.
ULID text PKs, spec prefixes tnt_ cat_ usr_ listing_ claim_ ent_ pool_ slot_, enforced with starts_with() CHECKs. Cross-table FKs are composite on (tenant_id, id) — a row can't reference another tenant's data.
postgis + pg_trgm in migration 1 plus helpers osds_current_tenant_id() and osds_set_updated_at().
listings generated tsvector (GIN) + geography (GiST) search_tsv = weighted to_tsvector('simple', name/description); geog = ST_SetSRID(ST_MakePoint(lon,lat),4326)::geography; both STORED. pg_trgm GIN on name too.
timestamptz everywhere; money as integer minor units + ISO 4217 all timestamps timestamptz. No table in this scope stores money (billing is adapter-reported).
Forward-only, rollback note per header no down(); each file's header carries a manual rollback note.

Slots — the approved hold design

slots has one row per unit of pool capacity; the row is the lock. A hold is one statement: UPDATE … FROM (SELECT id … WHERE status='available' OR (status='held' AND held_until < now()) ORDER BY (status='available') DESC, slot_no FOR UPDATE SKIP LOCKED LIMIT 1) at READ COMMITTED. N racers each lock a distinct row or get 0 rows ⇒ immediate "slot taken". Over-sell is impossible by construction. Shape CHECKs bind the nullable columns to status; slots_one_live_per_listing (partial unique) is the multi-tab backstop; slot_no is the stable featured-order ordinal. entitlements.slot_id FK is DEFERRABLE INITIALLY DEFERRED.

App role (migration 0013)

RLS is only enforced against a role that is not the table owner and lacks BYPASSRLS. osds_app is created NOLOGIN NOSUPERUSER NOBYPASSRLS with DML on the tenant tables + read-only spatial_ref_sys and nothing else. App/worker connect as it (DATABASE_URL); migrations run as the owner (DATABASE_URL_ADMIN). Granting it a login is a deployment step (touches authentication) — for local dev, infra/postgres/init/10-osds-app-role.sql does it on first cluster init. .env.example updated accordingly.

Verified

Every migration applied against postgis/postgis:16-3.4, then checked:

  • extensions present; forced RLS on all 12 tables, off on tenants
  • generated geog / search_tsv compute; GiST on geog, GIN on search_tsv + name trgm
  • entitlements_slot_id_fkey is deferrable + deferred; partial unique / job indexes present
  • SELECT/INSERT/UPDATE/DELETE shape CHECKs and consent NOT NULL reject bad rows
  • as a non-owner role member of osds_app: tenant isolation holds, unset var ⇒ 0 rows, cross-tenant INSERT refused by WITH CHECK, CREATE TABLE denied, and the FOR UPDATE SKIP LOCKED hold query works (grabs slot 1, then 2, then 0 rows)

pnpm typecheck · pnpm lint · pnpm test (no test files) all pass.

Notes for review

  • Migrations use .ts files executing raw SQL via Kysely's sql tag (one statement per call — the pg extended protocol forbids multi-statement strings). Tracked in kysely_migration.
  • categories gets a cat_ ULID surrogate PK; the spec names category slugs but no PK scheme.
  • claims.status / verification detail modelled behaviourally (spec §9 has no field-level schema).
  • docker-compose.yml: postgres now mounts infra/postgres/init into docker-entrypoint-initdb.d. Existing pgdata volumes won't re-run it — README documents the one-liner / pnpm infra:reset.

🤖 Generated with Claude Code

New @osds/db package: Kysely + pg, hand-written forward-only SQL
migrations run by a small Kysely Migrator script, kysely-codegen
(dev-only) for row types. Adds kysely + pg as runtime deps (approved).

Schema (migrations 0001-0013), scoped to:
  tenants, tiers, categories, listing_categories, users, listings,
  claims, entitlements, slot_pools, slots, outbox

- 0001 enables postgis + pg_trgm; helper functions
  osds_current_tenant_id() (RLS) and osds_set_updated_at() (trigger).
- Every table except tenants carries tenant_id and has forced RLS
  scoping rows to current_setting('app.tenant_id'); unset => no rows,
  cross-tenant writes refused by WITH CHECK.
- ULID text PKs with spec prefixes (tnt_/cat_/usr_/listing_/claim_/
  ent_/pool_/slot_), enforced by starts_with() CHECKs. Cross-table FKs
  are composite on (tenant_id, id) so a row can never reference another
  tenant's data.
- listings: generated geog geography(Point,4326) (GiST) and search_tsv
  tsvector (GIN), plus pg_trgm GIN on name.
- entitlements: one-live-per-listing partial unique; partial indexes on
  trial/period/grace timestamps for the worker's scheduled jobs.
- slots: one row per unit of pool capacity (row = the lock). Approved
  hold design - FOR UPDATE SKIP LOCKED over available / expired-held
  rows at READ COMMITTED; over-sell impossible by construction. Shape
  CHECKs bind nullable columns to status; slots_one_live_per_listing
  partial unique is the multi-tab backstop; slot_no is the stable
  featured-order ordinal. entitlements.slot_id FK is DEFERRABLE.
- outbox: transactional outbox; AFTER INSERT trigger pg_notify's
  'osds_outbox'; partial index for the undispatched poll; payload
  nulling column per §11.2.
- 0013 creates osds_app: NOLOGIN, NOSUPERUSER, NOBYPASSRLS, DML on the
  tenant tables plus read-only spatial_ref_sys and nothing else. App
  and worker connect as it (DATABASE_URL); migrations run as the owner
  (DATABASE_URL_ADMIN). Granting it a login is a deployment step - for
  local dev, docker-compose's initdb script does it.
  Timestamps are timestamptz; no table in this scope stores money.

Every migration applied against postgis/postgis:16-3.4 and verified:
extensions, forced RLS on all 12 tables, generated columns, GIN/GiST
indexes, deferred FK, tenant isolation + WITH CHECK + DDL denial as a
non-owner role, and the SKIP LOCKED hold query.

Also: root tsconfig references packages/db; migrate:dev / db:codegen
scripts; .env.example gains DATABASE_URL_ADMIN and points DATABASE_URL
at osds_app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Puf9jhj137U3iswf4GWZkG
Signed-off-by: Matthew Wren <info@origindev.com>
@OriginDevIT
OriginDevIT merged commit 19bceb0 into main Aug 30, 2026
1 check passed
@OriginDevIT
OriginDevIT deleted the feat/db-schema branch August 30, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant