{"id":50619793,"url":"https://github.com/creatiwity/kysely-pg-procedures","last_synced_at":"2026-06-06T10:01:42.961Z","repository":{"id":362471953,"uuid":"1255522813","full_name":"Creatiwity/kysely-pg-procedures","owner":"Creatiwity","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-04T10:16:39.000Z","size":12652,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-04T12:10:03.000Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Creatiwity.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-31T23:35:32.000Z","updated_at":"2026-06-04T10:16:43.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Creatiwity/kysely-pg-procedures","commit_stats":null,"previous_names":["creatiwity/kysely-pg-procedures"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/Creatiwity/kysely-pg-procedures","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Creatiwity%2Fkysely-pg-procedures","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Creatiwity%2Fkysely-pg-procedures/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Creatiwity%2Fkysely-pg-procedures/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Creatiwity%2Fkysely-pg-procedures/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Creatiwity","download_url":"https://codeload.github.com/Creatiwity/kysely-pg-procedures/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Creatiwity%2Fkysely-pg-procedures/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33977371,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-06T02:00:07.033Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-06T10:01:40.925Z","updated_at":"2026-06-06T10:01:42.954Z","avatar_url":"https://github.com/Creatiwity.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# kysely-pg-procedures\n\nType-safe PostgreSQL stored procedures and triggers as versioned TypeScript.\n\n---\n\n## What it solves\n\nWriting 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.\n\n---\n\n## Install\n\n```bash\nnpm install kysely-pg-procedures kysely\n```\n\n---\n\n## Quick start\n\n```typescript\n// src/procedures/users.ts\nimport { defineRowTrigger, sql } from 'kysely-pg-procedures'\n\n// Declare your DB schema once — table names become type-checked keys\ntype DB = {\n  users: {\n    id: number\n    email: string\n    updated_at: Date | null\n  }\n}\n\n// One-step: creates the PL/pgSQL function and the CREATE TRIGGER statement together.\n// 'users' is a keyof DB — its row type is inferred automatically.\n// NEW and OLD availability is determined by events (INSERT/UPDATE → NEW, UPDATE/DELETE → OLD).\nconst setUpdatedAt = defineRowTrigger\u003cDB\u003e()(\n  'users',\n  {\n    name: 'users_set_updated_at',\n    procedureName: 'fn_users_set_updated_at',\n    timing: 'BEFORE',\n    events: ['UPDATE'] as const,\n  },\n  [], // temp tables\n  {}, // vars\n  ({ db, NEW }) =\u003e {\n    db.set(NEW.updated_at, sql`NOW()`)\n    db.return(NEW)\n  },\n)\n\nexport default [setUpdatedAt]\n```\n\nThen generate the migration:\n\n```bash\nnpm run proc:generate\n```\n\n---\n\n## Core concepts\n\n### Typed row references (NEW / OLD)\n\n`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.\n\n```typescript\n({ db, NEW }) =\u003e {\n  // NEW.email is typed as ColumnRef (derived from DB['users']['email'])\n  db.set(NEW.updated_at, sql`NOW()`)\n  db.return(NEW)  // compiles to RETURN NEW;\n}\n```\n\nWith `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.\n\n### defineRowTrigger\\\u003cDB\\\u003e() — one-step\n\nCreates both the PL/pgSQL function and the `CREATE TRIGGER` statement in one call.\n\n```typescript\ndefineRowTrigger\u003cDB\u003e()(\n  table,        // keyof DB — deduces row type, type-checked table name\n  opts,         // { name, procedureName, timing, events, when?, columns? }\n  tempTables,   // TempTableDef[] from defineTempTable(...)\n  vars,         // { varName: ColumnType } — compiled to DECLARE block\n  body,         // ({ sql, db, NEW, OLD }) =\u003e void — imperative, auto-pushed\n)\n```\n\n### defineRowProcedure\\\u003cDB\\\u003e() + defineTrigger — two-step\n\nUse when the same function is reused across multiple triggers, or when you want to separate the function definition from the trigger DDL.\n\n```typescript\nconst proc = defineRowProcedure\u003cDB\u003e()(\n  'users',\n  { name: 'fn_users_set_updated_at' },\n  [], {}, ({ db, NEW, OLD }) =\u003e { ... },\n)\n\nconst trigger = defineTrigger(\n  { name: 'users_set_updated_at', table: 'users', timing: 'BEFORE', events: ['UPDATE'], forEach: 'ROW' },\n  proc,\n)\n\nexport default [trigger]\n```\n\n### Row-Level Security (RLS)\n\nDefine RLS policies alongside your triggers — same DSL, same migration workflow, same KPP tamper detection.\n\n```typescript\nimport { defineSessionVars, enableRls, definePolicy } from 'kysely-pg-procedures'\n\n// 1. Declare session variables set by your middleware\nconst session = defineSessionVars({\n  orgId:  'uuid',   // SET LOCAL app.orgId = '\u003cuuid\u003e'\n  userId: 'uuid',   // SET LOCAL app.userId = '\u003cuuid\u003e'\n})\n\n// 2. Enable RLS on the table\nconst rlsItems = enableRls\u003cDB\u003e()('items', { force: true })\n\n// 3. Define a policy (table name is type-checked against DB schema)\nconst tenantPolicy = definePolicy\u003cDB\u003e()(\n  'items',\n  {\n    name:    'items_tenant_isolation',\n    as:      'PERMISSIVE',\n    command: 'ALL',\n    roles:   ['app_user'],\n  },\n  session,\n  {\n    // col.xxx → \"xxx\" (typed to columns of DB['items'])\n    // session.orgId → current_setting('app.orgId', true)::uuid\n    using:     ({ col, session: s }) =\u003e sql.raw(`${col.org_id.text} = ${s.orgId.text}`),\n    withCheck: ({ col, session: s }) =\u003e sql.raw(`${col.org_id.text} = ${s.orgId.text}`),\n  },\n)\n\nexport default [rlsItems, tenantPolicy, myTrigger]\n```\n\n**Compilation output:**\n```sql\nALTER TABLE \"items\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"items\" FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS \"items_tenant_isolation\" ON \"items\";\nCREATE POLICY \"items_tenant_isolation\" ON \"items\" AS PERMISSIVE FOR ALL TO app_user\n    USING (\"org_id\" = current_setting('app.orgId', true)::uuid)\n    WITH CHECK (\"org_id\" = current_setting('app.orgId', true)::uuid);\n```\n\nNo `CREATE OR REPLACE POLICY` (requires PG 17) — uses `DROP IF EXISTS` + `CREATE` for broad compatibility.\n\n**Session variables** are set by your HTTP middleware before each request:\n```typescript\nawait sql`SET LOCAL \"app.orgId\" = ${ctx.org.id}`.execute(db)\nawait sql`SET LOCAL \"app.userId\" = ${ctx.user.id}`.execute(db)\n```\n\n**`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.\n\n---\n\n### Typed temp tables with proc:codegen\n\nBy 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.\n\n**1. Add to `kysely-procedures.config.ts`:**\n```typescript\ncodegen: {\n  // Same format as kysely-codegen's import\n  dbImport: \"import type { Database as DB } from './generated/database.js'\",\n  output: 'src/db-proc.generated.ts',\n}\n```\n\n**2. Run the generator:**\n```bash\nnpm run proc:codegen\n```\n\n**3. Generated file (`db-proc.generated.ts`):**\n```typescript\n// AUTO-GENERATED — DO NOT EDIT\n\nimport type { Database as DB } from './generated/database.js'\n\nexport interface DBProcMyAuditProc extends DB {\n  readonly __proc: 'my_audit_proc'   // discriminant for TypeScript narrowing\n  MyTempTable: {\n    readonly _proc_instance_id: string\n    readonly userId: string\n    readonly amount: number | null\n  }\n}\n\nexport type DBProc = DBProcMyAuditProc | ...\n\nexport type ProcDBMap = {\n  'my_audit_proc': DBProcMyAuditProc\n  // one entry per procedure\n}\n```\n\n**4. Use `withProcDB\u003cProcDBMap\u003e()`** — the procedure name selects the right extended DB type automatically; no type parameter needed at each call site:\n\n```typescript\nimport { withProcDB } from 'kysely-pg-procedures'\nimport type { ProcDBMap } from './db-proc.generated'\n\nconst { defineRowProcedure, defineRowTrigger } = withProcDB\u003cProcDBMap\u003e()\n\n// `procedureName` discriminates → body typed with DBProcMyAuditProc\nconst proc = defineRowProcedure(\n  'items',\n  { name: 'my_audit_proc', ... },\n  [myTempTable], {},\n  ({ db }) =\u003e {\n    db.selectFrom('MyTempTable').select(['userId'])  // ✓ type-checked\n    db.modified.insertFrom(...)                       // ✓ alias still works\n  },\n)\n```\n\nRe-run `proc:codegen` whenever you add or modify a temp table definition.\n\n---\n\n### STATEMENT triggers and temp tables\n\nFor `FOR EACH STATEMENT` triggers, use `defineProcedure` and define temp tables with `defineTempTable`. Give temp tables an alias for ergonomic access via `db[alias]`.\n\n```typescript\nimport { defineProcedure, defineTrigger, defineTempTable, sql } from 'kysely-pg-procedures'\n\nconst changedRows = defineTempTable(\n  'changed_rows',\n  { user_id: 'integer', email: 'text' },\n  { as: 'cr' },  // accessible as db.cr in the callback\n)\n\nconst auditProc = defineProcedure(\n  { name: 'fn_audit_users' },\n  [changedRows],\n  {},\n  ({ db, sql }) =\u003e {\n    db.cr.insertFrom(['user_id', 'email'], sql`SELECT id, email FROM inserted`)\n    db.if(db.cr.notExists(), () =\u003e { db.return(sql`NULL`) })\n    db.execute(sql`UPDATE audit_log SET ... FROM changed_rows AS cr WHERE ${db.cr.filter('cr')}`)\n    db.return(sql`NULL`)\n  },\n)\n\nconst auditTrigger = defineTrigger(\n  {\n    name: 'trg_audit_users',\n    table: 'users',\n    timing: 'AFTER',\n    events: ['INSERT', 'UPDATE'],\n    forEach: 'STATEMENT',\n    referencing: { old: 'removed', new: 'inserted' },\n  },\n  auditProc,\n)\n```\n\n### sql vs ksql\n\nTwo SQL tags are exported:\n\n- **`sql`** — our tag, for PL/pgSQL statement fragments: `db.set(col, sql\\`expr\\`)`, `db.execute(sql\\`...\\`)`, template interpolation of `SqlFragment`\n- **`ksql`** — Kysely's own `sql` tag, for expressions inside Kysely query builders (`.where(ksql\\`...\\`)`, `.select([ksql\\`col\\`.as('alias')])`, etc.)\n\n```typescript\n// Inside a procedure body:\ndb.cr.insertFrom(\n  ['user_id'],\n  db.selectFrom('inserted').where(ksql`score \u003e 0`).select([ksql`id`.as('user_id')]),\n)\n\ndb.execute(sql`UPDATE ... WHERE ${db.cr.filter('cr')}`)\n```\n\n`filter()` on a temp table helper returns a Kysely `RawBuilder` — it works both in Kysely's `.where()` and in our `sql` template tag.\n\n### db.invoke(proc, args?)\n\nCall a helper function (RETURNS VOID) from within a procedure body:\n\n```typescript\ndb.invoke(helperProc)                    // PERFORM helper_fn();\ndb.invoke(helperProc, [db.var.qty])      // PERFORM helper_fn(qty);\n```\n\n---\n\n## Observability\n\nLog levels are baked in at SQL generation time — zero overhead in production.\n\n```typescript\ncompileAll(defs, { log: 'info', logTarget: 'table' })\n```\n\n| `log` | What is emitted |\n|-------|----------------|\n| `none` (default) | Nothing |\n| `info` | One entry per procedure execution |\n| `step` | One entry per mutating statement (batched, single INSERT on RETURN) |\n| `debug` | Continuous snapshots of temp tables and vars |\n\n`logTarget`:\n- `'table'` — INSERT to unlogged `_proc_log` / `_proc_log_steps` tables\n- `'notify'` — `pg_notify('proc_log', ...)` for live streaming to a Node listener\n\nRun `logSetupSql()` once to create the log tables. Use `createProcLogListener(pool)` in your Node process to subscribe to `pg_notify` events.\n\n### db.snapshot(label)\n\nCapture 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.\n\n```typescript\ndb.snapshot('after_insert')\n```\n\nRun `snapshotSetupSql()` once to create the snapshot tables.\n\n---\n\n## Dev workflow\n\n```bash\n# Start local PostgreSQL (port 5433)\nnpm run db:up\n\n# Apply procedure changes immediately to local DB on save\nnpm run db:procedures:watch \"src/procedures/**/*.ts\" postgresql://localhost/mydb\n\n# Run the playground script (compile + optional apply to local PG)\nnpm run playground -- --dry\nnpm run playground -- --log step --target notify\n```\n\n---\n\n## Migration workflow\n\n### 1. Create kysely-procedures.config.ts\n\n```typescript\nimport type { ProcConfig } from 'kysely-pg-procedures'\n\nconst config: Partial\u003cProcConfig\u003e = {\n  procedures: ['src/procedures/**/*.ts'],  // glob patterns for definition files\n  manifest: 'kysely-procedures.json',      // derived index (safe to regenerate)\n  migrations: 'migrations/',               // output folder for migration files\n}\n\nexport default config\n```\n\nThe config file is auto-discovered as `kysely-procedures.config.ts` in the project root, or pass `--config \u003cpath\u003e` to any CLI command.\n\nAdd a `codegen` section if you use `proc:codegen` (see [Typed temp tables with proc:codegen](#typed-temp-tables-with-proccodegen)).\n\n### 2. Procedure files\n\nEach file exports a default array of trigger or procedure definitions:\n\n```typescript\n// src/procedures/users.ts\nimport { defineRowTrigger } from 'kysely-pg-procedures'\n// ...\nexport default [setUpdatedAt, auditTrigger]\n```\n\n### 3. proc:generate\n\n```bash\nnpm run proc:generate\n# Options:\nnpm run proc:generate -- --only fn_users_set_updated_at,trg_audit_users\nnpm run proc:generate -- --file migrations/my-feature.ts\n```\n\nGenerates a Kysely-compatible migration file, for example `migrations/20260601T120000-procedures.ts`, containing:\n\n- `up()` — `CREATE OR REPLACE FUNCTION` / `CREATE OR REPLACE TRIGGER` statements\n- `down()` — drops (for new entries) or restores the previous SQL (for modified entries)\n\n### 4. proc:status\n\n```bash\nnpm run proc:status\n```\n\nCompares source code against migration files and prints a status table:\n\n| Icon | Status | Meaning |\n|------|--------|---------|\n| ✅ | `unchanged` | Source hash matches manifest; migration file not tampered |\n| ⚠️ | `modified` | Source changed since last migration — run `proc:generate` |\n| 🆕 | `not-migrated` | No migration exists yet — run `proc:generate` |\n| ❌ | `orphan` | In migration files but not in source |\n| 🔒 | `tampered` | Migration file SQL was edited after generation (KPP hash mismatch) |\n| 💥 | `conflict` | Same procedure migrated in multiple files (branch merge) |\n\nExits with code 1 if any entry is not `unchanged`.\n\n### 5. proc:manifest-rebuild\n\n```bash\nnpm run proc:manifest-rebuild\n```\n\nRebuilds `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.\n\n### 6. KPP markers\n\nEvery generated block is wrapped in comment markers that embed the hash:\n\n```sql\n-- [KPP:BEGIN name=\"fn_users_set_updated_at\" kind=\"function\" hash=\"sha256:abc123...\"]\nCREATE OR REPLACE FUNCTION fn_users_set_updated_at() ...\n-- [KPP:END name=\"fn_users_set_updated_at\"]\n```\n\nDown blocks:\n\n```sql\n-- [KPP:DOWN:BEGIN name=\"fn_users_set_updated_at\"]\nDROP FUNCTION IF EXISTS fn_users_set_updated_at();\n-- [KPP:DOWN:END name=\"fn_users_set_updated_at\"]\n```\n\n`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.\n\n### 7. Concurrent branches: detection and resolution\n\nIf two branches each generate a migration for the same procedure, `proc:status` reports `💥 conflict` after the merge. To resolve:\n\n1. Delete the duplicate migration file (keep the one with the intended SQL).\n2. Run `npm run proc:manifest-rebuild` to regenerate the manifest.\n3. Run `npm run proc:status` to confirm no remaining conflicts.\n\n---\n\n## API reference\n\n### Definition\n\n| Export | Description |\n|--------|-------------|\n| `defineRowTrigger\u003cDB\u003e()` | One-step: ROW trigger + backing function; table name typed against DB schema |\n| `defineRowProcedure\u003cDB\u003e()` | Row procedure only (attach with `defineTrigger`) |\n| `withProcDB\u003cProcDBMap\u003e()` | Pre-bind all factories to a codegen'd `ProcDBMap`; proc name acts as discriminant |\n| `defineTrigger(opts, proc)` | Attach a trigger to an existing procedure definition |\n| `defineProcedure(opts, tempTables, vars, body)` | Low-level: any procedure type (STATEMENT triggers, RETURNS VOID helpers, etc.) |\n| `defineTempTable(name, columns, { as? })` | Define a temp table; accessible as `db[alias]` in the body |\n\n### Compilation\n\n| Export | Description |\n|--------|-------------|\n| `compileAll(defs, opts?)` | Compile definitions to SQL. `opts`: `{ debug?, log?, logTarget? }` |\n| `compileProcedure(def, opts?)` | Compile a single procedure definition |\n| `compileTrigger(def)` | Compile a single trigger definition |\n\n### SQL tags\n\n| Export | Use in |\n|--------|--------|\n| `sql` | PL/pgSQL statement fragments (`db.set`, `db.execute`, template interpolation) |\n| `ksql` | Kysely query builders (`.where`, `.select`, `.set`, etc.) |\n\n### Observability setup\n\n| Export | Description |\n|--------|-------------|\n| `logSetupSql()` | SQL to create `_proc_log` / `_proc_log_steps` tables (run once) |\n| `snapshotSetupSql()` | SQL to create `_proc_snapshot*` tables (run once, dev only) |\n| `createProcLogListener(pool, opts?)` | Subscribe to `pg_notify` proc log events in Node.js |\n\n### Types\n\n| Export | Description |\n|--------|-------------|\n| `TypedRowRef\u003cTRow\u003e` | Typed reference to a trigger row (NEW / OLD) |\n| `DbContext\u003cTAliases\u003e` | Type of the `db` object in procedure callbacks |\n| `TempTableAliasMap\u003cTTables\u003e` | Maps temp table aliases to their typed helpers |\n| `ProcConfig` | Config file shape for `kysely-procedures.config.ts` |\n| `CompileOpts` | Options for `compileAll` / `compileProcedure` |\n\n---\n\n## License\n\nMIT © Creatiwity\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcreatiwity%2Fkysely-pg-procedures","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcreatiwity%2Fkysely-pg-procedures","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcreatiwity%2Fkysely-pg-procedures/lists"}