https://github.com/creatiwity/kysely-pg-procedures
https://github.com/creatiwity/kysely-pg-procedures
Last synced: about 1 month ago
JSON representation
- Host: GitHub
- URL: https://github.com/creatiwity/kysely-pg-procedures
- Owner: Creatiwity
- Created: 2026-05-31T23:35:32.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-04T10:16:39.000Z (about 1 month ago)
- Last Synced: 2026-06-04T12:10:03.000Z (about 1 month ago)
- Language: TypeScript
- Size: 12.1 MB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# kysely-pg-procedures
Type-safe PostgreSQL stored procedures and triggers as versioned TypeScript.
---
## What it solves
Writing PostgreSQL stored procedures and triggers in raw SQL is error-prone: no type checking, no refactoring support, and migrations are hard to version or audit. `kysely-pg-procedures` lets you define procedures and triggers in TypeScript with full type inference from your DB schema. A CLI generates versioned Kysely-compatible migration files with tamper-detection markers so you can track every change, detect conflicts from concurrent branches, and safely regenerate the manifest after a merge.
---
## Install
```bash
npm install kysely-pg-procedures kysely
```
---
## Quick start
```typescript
// src/procedures/users.ts
import { defineRowTrigger, sql } from 'kysely-pg-procedures'
// Declare your DB schema once — table names become type-checked keys
type DB = {
users: {
id: number
email: string
updated_at: Date | null
}
}
// One-step: creates the PL/pgSQL function and the CREATE TRIGGER statement together.
// 'users' is a keyof DB — its row type is inferred automatically.
// NEW and OLD availability is determined by events (INSERT/UPDATE → NEW, UPDATE/DELETE → OLD).
const setUpdatedAt = defineRowTrigger()(
'users',
{
name: 'users_set_updated_at',
procedureName: 'fn_users_set_updated_at',
timing: 'BEFORE',
events: ['UPDATE'] as const,
},
[], // temp tables
{}, // vars
({ db, NEW }) => {
db.set(NEW.updated_at, sql`NOW()`)
db.return(NEW)
},
)
export default [setUpdatedAt]
```
Then generate the migration:
```bash
npm run proc:generate
```
---
## Core concepts
### Typed row references (NEW / OLD)
`NEW` and `OLD` are provided by the callback with columns typed from your DB schema — no `any`. Column access returns `ColumnRef`, which is usable in `db.set`, `db.return`, `db.if`, etc.
```typescript
({ db, NEW }) => {
// NEW.email is typed as ColumnRef (derived from DB['users']['email'])
db.set(NEW.updated_at, sql`NOW()`)
db.return(NEW) // compiles to RETURN NEW;
}
```
With `events: ['INSERT'] as const`, only `NEW` is available in the callback. With `['UPDATE'] as const`, both `NEW` and `OLD` are available. With `['DELETE'] as const`, only `OLD` is available.
### defineRowTrigger\() — one-step
Creates both the PL/pgSQL function and the `CREATE TRIGGER` statement in one call.
```typescript
defineRowTrigger()(
table, // keyof DB — deduces row type, type-checked table name
opts, // { name, procedureName, timing, events, when?, columns? }
tempTables, // TempTableDef[] from defineTempTable(...)
vars, // { varName: ColumnType } — compiled to DECLARE block
body, // ({ sql, db, NEW, OLD }) => void — imperative, auto-pushed
)
```
### defineRowProcedure\() + defineTrigger — two-step
Use when the same function is reused across multiple triggers, or when you want to separate the function definition from the trigger DDL.
```typescript
const proc = defineRowProcedure()(
'users',
{ name: 'fn_users_set_updated_at' },
[], {}, ({ db, NEW, OLD }) => { ... },
)
const trigger = defineTrigger(
{ name: 'users_set_updated_at', table: 'users', timing: 'BEFORE', events: ['UPDATE'], forEach: 'ROW' },
proc,
)
export default [trigger]
```
### Row-Level Security (RLS)
Define RLS policies alongside your triggers — same DSL, same migration workflow, same KPP tamper detection.
```typescript
import { defineSessionVars, enableRls, definePolicy } from 'kysely-pg-procedures'
// 1. Declare session variables set by your middleware
const session = defineSessionVars({
orgId: 'uuid', // SET LOCAL app.orgId = ''
userId: 'uuid', // SET LOCAL app.userId = ''
})
// 2. Enable RLS on the table
const rlsItems = enableRls()('items', { force: true })
// 3. Define a policy (table name is type-checked against DB schema)
const tenantPolicy = definePolicy()(
'items',
{
name: 'items_tenant_isolation',
as: 'PERMISSIVE',
command: 'ALL',
roles: ['app_user'],
},
session,
{
// col.xxx → "xxx" (typed to columns of DB['items'])
// session.orgId → current_setting('app.orgId', true)::uuid
using: ({ col, session: s }) => sql.raw(`${col.org_id.text} = ${s.orgId.text}`),
withCheck: ({ col, session: s }) => sql.raw(`${col.org_id.text} = ${s.orgId.text}`),
},
)
export default [rlsItems, tenantPolicy, myTrigger]
```
**Compilation output:**
```sql
ALTER TABLE "items" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "items" FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "items_tenant_isolation" ON "items";
CREATE POLICY "items_tenant_isolation" ON "items" AS PERMISSIVE FOR ALL TO app_user
USING ("org_id" = current_setting('app.orgId', true)::uuid)
WITH CHECK ("org_id" = current_setting('app.orgId', true)::uuid);
```
No `CREATE OR REPLACE POLICY` (requires PG 17) — uses `DROP IF EXISTS` + `CREATE` for broad compatibility.
**Session variables** are set by your HTTP middleware before each request:
```typescript
await sql`SET LOCAL "app.orgId" = ${ctx.org.id}`.execute(db)
await sql`SET LOCAL "app.userId" = ${ctx.user.id}`.execute(db)
```
**`proc:generate`** includes RLS entries in the migration file with KPP markers (`kind="rls-enable"` / `kind="rls-policy"`). **`proc:status`** tracks and detects tampering for RLS blocks the same way it does for procedures.
---
### Typed temp tables with proc:codegen
By default, `db.selectFrom('MyTempTable')` inside a procedure body isn't type-checked against the temp table's columns. Run `proc:codegen` to generate a per-procedure DB extension file that makes temp tables visible to Kysely's type system.
**1. Add to `kysely-procedures.config.ts`:**
```typescript
codegen: {
// Same format as kysely-codegen's import
dbImport: "import type { Database as DB } from './generated/database.js'",
output: 'src/db-proc.generated.ts',
}
```
**2. Run the generator:**
```bash
npm run proc:codegen
```
**3. Generated file (`db-proc.generated.ts`):**
```typescript
// AUTO-GENERATED — DO NOT EDIT
import type { Database as DB } from './generated/database.js'
export interface DBProcMyAuditProc extends DB {
readonly __proc: 'my_audit_proc' // discriminant for TypeScript narrowing
MyTempTable: {
readonly _proc_instance_id: string
readonly userId: string
readonly amount: number | null
}
}
export type DBProc = DBProcMyAuditProc | ...
export type ProcDBMap = {
'my_audit_proc': DBProcMyAuditProc
// one entry per procedure
}
```
**4. Use `withProcDB()`** — the procedure name selects the right extended DB type automatically; no type parameter needed at each call site:
```typescript
import { withProcDB } from 'kysely-pg-procedures'
import type { ProcDBMap } from './db-proc.generated'
const { defineRowProcedure, defineRowTrigger } = withProcDB()
// `procedureName` discriminates → body typed with DBProcMyAuditProc
const proc = defineRowProcedure(
'items',
{ name: 'my_audit_proc', ... },
[myTempTable], {},
({ db }) => {
db.selectFrom('MyTempTable').select(['userId']) // ✓ type-checked
db.modified.insertFrom(...) // ✓ alias still works
},
)
```
Re-run `proc:codegen` whenever you add or modify a temp table definition.
---
### STATEMENT triggers and temp tables
For `FOR EACH STATEMENT` triggers, use `defineProcedure` and define temp tables with `defineTempTable`. Give temp tables an alias for ergonomic access via `db[alias]`.
```typescript
import { defineProcedure, defineTrigger, defineTempTable, sql } from 'kysely-pg-procedures'
const changedRows = defineTempTable(
'changed_rows',
{ user_id: 'integer', email: 'text' },
{ as: 'cr' }, // accessible as db.cr in the callback
)
const auditProc = defineProcedure(
{ name: 'fn_audit_users' },
[changedRows],
{},
({ db, sql }) => {
db.cr.insertFrom(['user_id', 'email'], sql`SELECT id, email FROM inserted`)
db.if(db.cr.notExists(), () => { db.return(sql`NULL`) })
db.execute(sql`UPDATE audit_log SET ... FROM changed_rows AS cr WHERE ${db.cr.filter('cr')}`)
db.return(sql`NULL`)
},
)
const auditTrigger = defineTrigger(
{
name: 'trg_audit_users',
table: 'users',
timing: 'AFTER',
events: ['INSERT', 'UPDATE'],
forEach: 'STATEMENT',
referencing: { old: 'removed', new: 'inserted' },
},
auditProc,
)
```
### sql vs ksql
Two SQL tags are exported:
- **`sql`** — our tag, for PL/pgSQL statement fragments: `db.set(col, sql\`expr\`)`, `db.execute(sql\`...\`)`, template interpolation of `SqlFragment`
- **`ksql`** — Kysely's own `sql` tag, for expressions inside Kysely query builders (`.where(ksql\`...\`)`, `.select([ksql\`col\`.as('alias')])`, etc.)
```typescript
// Inside a procedure body:
db.cr.insertFrom(
['user_id'],
db.selectFrom('inserted').where(ksql`score > 0`).select([ksql`id`.as('user_id')]),
)
db.execute(sql`UPDATE ... WHERE ${db.cr.filter('cr')}`)
```
`filter()` on a temp table helper returns a Kysely `RawBuilder` — it works both in Kysely's `.where()` and in our `sql` template tag.
### db.invoke(proc, args?)
Call a helper function (RETURNS VOID) from within a procedure body:
```typescript
db.invoke(helperProc) // PERFORM helper_fn();
db.invoke(helperProc, [db.var.qty]) // PERFORM helper_fn(qty);
```
---
## Observability
Log levels are baked in at SQL generation time — zero overhead in production.
```typescript
compileAll(defs, { log: 'info', logTarget: 'table' })
```
| `log` | What is emitted |
|-------|----------------|
| `none` (default) | Nothing |
| `info` | One entry per procedure execution |
| `step` | One entry per mutating statement (batched, single INSERT on RETURN) |
| `debug` | Continuous snapshots of temp tables and vars |
`logTarget`:
- `'table'` — INSERT to unlogged `_proc_log` / `_proc_log_steps` tables
- `'notify'` — `pg_notify('proc_log', ...)` for live streaming to a Node listener
Run `logSetupSql()` once to create the log tables. Use `createProcLogListener(pool)` in your Node process to subscribe to `pg_notify` events.
### db.snapshot(label)
Capture the state of all temp tables and declared vars at a point in the procedure. Only emitted when compiled with `{ debug: true }` (or `log: 'debug'`). Zero SQL in production.
```typescript
db.snapshot('after_insert')
```
Run `snapshotSetupSql()` once to create the snapshot tables.
---
## Dev workflow
```bash
# Start local PostgreSQL (port 5433)
npm run db:up
# Apply procedure changes immediately to local DB on save
npm run db:procedures:watch "src/procedures/**/*.ts" postgresql://localhost/mydb
# Run the playground script (compile + optional apply to local PG)
npm run playground -- --dry
npm run playground -- --log step --target notify
```
---
## Migration workflow
### 1. Create kysely-procedures.config.ts
```typescript
import type { ProcConfig } from 'kysely-pg-procedures'
const config: Partial = {
procedures: ['src/procedures/**/*.ts'], // glob patterns for definition files
manifest: 'kysely-procedures.json', // derived index (safe to regenerate)
migrations: 'migrations/', // output folder for migration files
}
export default config
```
The config file is auto-discovered as `kysely-procedures.config.ts` in the project root, or pass `--config ` to any CLI command.
Add a `codegen` section if you use `proc:codegen` (see [Typed temp tables with proc:codegen](#typed-temp-tables-with-proccodegen)).
### 2. Procedure files
Each file exports a default array of trigger or procedure definitions:
```typescript
// src/procedures/users.ts
import { defineRowTrigger } from 'kysely-pg-procedures'
// ...
export default [setUpdatedAt, auditTrigger]
```
### 3. proc:generate
```bash
npm run proc:generate
# Options:
npm run proc:generate -- --only fn_users_set_updated_at,trg_audit_users
npm run proc:generate -- --file migrations/my-feature.ts
```
Generates a Kysely-compatible migration file, for example `migrations/20260601T120000-procedures.ts`, containing:
- `up()` — `CREATE OR REPLACE FUNCTION` / `CREATE OR REPLACE TRIGGER` statements
- `down()` — drops (for new entries) or restores the previous SQL (for modified entries)
### 4. proc:status
```bash
npm run proc:status
```
Compares source code against migration files and prints a status table:
| Icon | Status | Meaning |
|------|--------|---------|
| ✅ | `unchanged` | Source hash matches manifest; migration file not tampered |
| ⚠️ | `modified` | Source changed since last migration — run `proc:generate` |
| 🆕 | `not-migrated` | No migration exists yet — run `proc:generate` |
| ❌ | `orphan` | In migration files but not in source |
| 🔒 | `tampered` | Migration file SQL was edited after generation (KPP hash mismatch) |
| 💥 | `conflict` | Same procedure migrated in multiple files (branch merge) |
Exits with code 1 if any entry is not `unchanged`.
### 5. proc:manifest-rebuild
```bash
npm run proc:manifest-rebuild
```
Rebuilds `kysely-procedures.json` from KPP markers in migration files. Run this after resolving a merge conflict. The manifest is always derived from migration files — it is never the source of truth.
### 6. KPP markers
Every generated block is wrapped in comment markers that embed the hash:
```sql
-- [KPP:BEGIN name="fn_users_set_updated_at" kind="function" hash="sha256:abc123..."]
CREATE OR REPLACE FUNCTION fn_users_set_updated_at() ...
-- [KPP:END name="fn_users_set_updated_at"]
```
Down blocks:
```sql
-- [KPP:DOWN:BEGIN name="fn_users_set_updated_at"]
DROP FUNCTION IF EXISTS fn_users_set_updated_at();
-- [KPP:DOWN:END name="fn_users_set_updated_at"]
```
`proc:status` recomputes the hash from the SQL inside each marker and compares it against the stored hash. A mismatch means the block was manually edited after generation.
### 7. Concurrent branches: detection and resolution
If two branches each generate a migration for the same procedure, `proc:status` reports `💥 conflict` after the merge. To resolve:
1. Delete the duplicate migration file (keep the one with the intended SQL).
2. Run `npm run proc:manifest-rebuild` to regenerate the manifest.
3. Run `npm run proc:status` to confirm no remaining conflicts.
---
## API reference
### Definition
| Export | Description |
|--------|-------------|
| `defineRowTrigger()` | One-step: ROW trigger + backing function; table name typed against DB schema |
| `defineRowProcedure()` | Row procedure only (attach with `defineTrigger`) |
| `withProcDB()` | Pre-bind all factories to a codegen'd `ProcDBMap`; proc name acts as discriminant |
| `defineTrigger(opts, proc)` | Attach a trigger to an existing procedure definition |
| `defineProcedure(opts, tempTables, vars, body)` | Low-level: any procedure type (STATEMENT triggers, RETURNS VOID helpers, etc.) |
| `defineTempTable(name, columns, { as? })` | Define a temp table; accessible as `db[alias]` in the body |
### Compilation
| Export | Description |
|--------|-------------|
| `compileAll(defs, opts?)` | Compile definitions to SQL. `opts`: `{ debug?, log?, logTarget? }` |
| `compileProcedure(def, opts?)` | Compile a single procedure definition |
| `compileTrigger(def)` | Compile a single trigger definition |
### SQL tags
| Export | Use in |
|--------|--------|
| `sql` | PL/pgSQL statement fragments (`db.set`, `db.execute`, template interpolation) |
| `ksql` | Kysely query builders (`.where`, `.select`, `.set`, etc.) |
### Observability setup
| Export | Description |
|--------|-------------|
| `logSetupSql()` | SQL to create `_proc_log` / `_proc_log_steps` tables (run once) |
| `snapshotSetupSql()` | SQL to create `_proc_snapshot*` tables (run once, dev only) |
| `createProcLogListener(pool, opts?)` | Subscribe to `pg_notify` proc log events in Node.js |
### Types
| Export | Description |
|--------|-------------|
| `TypedRowRef` | Typed reference to a trigger row (NEW / OLD) |
| `DbContext` | Type of the `db` object in procedure callbacks |
| `TempTableAliasMap` | Maps temp table aliases to their typed helpers |
| `ProcConfig` | Config file shape for `kysely-procedures.config.ts` |
| `CompileOpts` | Options for `compileAll` / `compileProcedure` |
---
## License
MIT © Creatiwity