feat(db): packages/db — schema, migrations, RLS (spec v0.4) - #14
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
New
@osds/dbpackage: Kysely +pg, hand-written forward-only SQL migrations run by a small KyselyMigratorscript,kysely-codegen(dev-only) for row types. Addskysely+pgas runtime deps (approved). Authored against spec v0.4.Scope
Tables
0001–0013, per the agreed list — nothing else yet:tenants·tiers·categories·listing_categories·users·listings·claims·entitlements·slot_pools·slots·outboxRules applied
tenant_id+ RLS on every table excepttenantsENABLE+FORCErow level security, policytenant_id = osds_current_tenant_id()withWITH CHECK. Unsetapp.tenant_id⇒ zero rows.tnt_ cat_ usr_ listing_ claim_ ent_ pool_ slot_, enforced withstarts_with()CHECKs. Cross-table FKs are composite on(tenant_id, id)— a row can't reference another tenant's data.postgis+pg_trgmin migration 1osds_current_tenant_id()andosds_set_updated_at().listingsgeneratedtsvector(GIN) +geography(GiST)search_tsv= weightedto_tsvector('simple', name/description);geog=ST_SetSRID(ST_MakePoint(lon,lat),4326)::geography; bothSTORED. pg_trgm GIN onnametoo.timestamptzeverywhere; money as integer minor units + ISO 4217timestamptz. No table in this scope stores money (billing is adapter-reported).down(); each file's header carries a manual rollback note.Slots — the approved hold design
slotshas 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 tostatus;slots_one_live_per_listing(partial unique) is the multi-tab backstop;slot_nois the stable featured-order ordinal.entitlements.slot_idFK isDEFERRABLE INITIALLY DEFERRED.App role (migration 0013)
RLS is only enforced against a role that is not the table owner and lacks
BYPASSRLS.osds_appis createdNOLOGIN NOSUPERUSER NOBYPASSRLSwith DML on the tenant tables + read-onlyspatial_ref_sysand 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.sqldoes it on first cluster init..env.exampleupdated accordingly.Verified
Every migration applied against
postgis/postgis:16-3.4, then checked:tenantsgeog/search_tsvcompute; GiST ongeog, GIN onsearch_tsv+nametrgmentitlements_slot_id_fkeyis deferrable + deferred; partial unique / job indexes presentSELECT/INSERT/UPDATE/DELETEshape CHECKs andconsent NOT NULLreject bad rowsosds_app: tenant isolation holds, unset var ⇒ 0 rows, cross-tenantINSERTrefused byWITH CHECK,CREATE TABLEdenied, and theFOR UPDATE SKIP LOCKEDhold query works (grabs slot 1, then 2, then 0 rows)pnpm typecheck·pnpm lint·pnpm test(no test files) all pass.Notes for review
.tsfiles executing raw SQL via Kysely'ssqltag (one statement per call — the pg extended protocol forbids multi-statement strings). Tracked inkysely_migration.categoriesgets acat_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 mountsinfra/postgres/initintodocker-entrypoint-initdb.d. Existingpgdatavolumes won't re-run it — README documents the one-liner /pnpm infra:reset.🤖 Generated with Claude Code