From ae90645195b79c30fbd2e7f8f369bffa2047d8d9 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 26 Oct 2026 03:19:15 -0400 Subject: [PATCH] feat(sdk): add proc/func/tvf to the transaction handle ctx.transaction(fn) hands fn a Kysely Transaction carrying typed proc/func/tvf on the transaction's connection, so a routine call runs under SET LOCAL settings such as statement_timeout and rolls back with the transaction. Inside a transaction, postgres proc picks CALL or SELECT from a pg_proc lookup: a failed CALL aborts the transaction, and a savepoint retry would roll back sibling statements run on the same handle. Closes #106 --- .changeset/sdk-transaction-routines.md | 5 + docs/design/sdk-transaction-routines.md | 78 ++++ docs/dev/sdk.md | 33 +- docs/reference/sdk.md | 29 +- docs/spec/sdk-transaction-routines.md | 220 +++++++++ docs/wiki/scan.md | 440 +++++++++--------- docs/wiki/sdk.md | 7 +- packages/sdk/README.md | 19 + skills/noorm/references/sdk.md | 14 +- src/sdk/context.ts | 146 +----- src/sdk/impersonate/scope.ts | 43 +- src/sdk/impersonate/types.ts | 30 +- src/sdk/index.ts | 2 + src/sdk/routines.ts | 219 +++++++++ src/sdk/types.ts | 55 +++ .../sdk/transaction-routines.test.ts | 278 +++++++++++ tests/sdk/routines.test.ts | 397 ++++++++++++++++ 17 files changed, 1592 insertions(+), 423 deletions(-) create mode 100644 .changeset/sdk-transaction-routines.md create mode 100644 docs/design/sdk-transaction-routines.md create mode 100644 docs/spec/sdk-transaction-routines.md create mode 100644 src/sdk/routines.ts create mode 100644 tests/integration/sdk/transaction-routines.test.ts create mode 100644 tests/sdk/routines.test.ts diff --git a/.changeset/sdk-transaction-routines.md b/.changeset/sdk-transaction-routines.md new file mode 100644 index 00000000..09519ed4 --- /dev/null +++ b/.changeset/sdk-transaction-routines.md @@ -0,0 +1,5 @@ +--- +'@noormdev/sdk': minor +--- + +The handle `ctx.transaction(fn)` passes to `fn` now carries typed `proc`, `func`, and `tvf` that run on the transaction's connection, so a routine call runs under a `SET LOCAL statement_timeout` set earlier in the same transaction and rolls back with the transaction. `scope.transaction(fn)` on an impersonated scope passes a handle of the same type. The handle's type, `TransactionScope`, and the `RoutineCalls` interface are exported. On Postgres, `ImpersonatedScope.proc` now retries a `CALL` against a function as `SELECT * FROM`, as `ctx.proc` already did; before, it threw. diff --git a/docs/design/sdk-transaction-routines.md b/docs/design/sdk-transaction-routines.md new file mode 100644 index 00000000..4f84f48a --- /dev/null +++ b/docs/design/sdk-transaction-routines.md @@ -0,0 +1,78 @@ +# SDK routines inside a transaction + + +## Problem + + +`ctx.transaction(fn)` hands `fn` a plain Kysely `Transaction`. `proc`, `func`, and `tvf` live only on `Context` and `ImpersonatedScope`, so a caller who needs a routine call inside a transaction drops to raw `sql` and loses the typed, dialect-aware call path. + +The case that surfaced it (noormdev/noorm#106): a per-call Postgres `statement_timeout`. A function-level `SET statement_timeout` clause does not fire, because Postgres starts the timer with the calling statement. The working pattern is: + + BEGIN; + SET LOCAL statement_timeout = '8s'; + SELECT * FROM get_graph_neighborhood_fn(...); + COMMIT; + +That needs the routine call on the transaction's connection. + + +## Approaches + + +| Approach | Wins | Loses | +|----------|------|-------| +| A. `proc`/`func`/`tvf` on the transaction handle | Composes with any transaction-scoped setting on any dialect (`SET LOCAL`, isolation level, advisory locks, writes rolled back together); non-breaking | Caller writes the `SET LOCAL` line | +| B. Per-call option, e.g. `{ statementTimeout: '8s' }` | One-liner for the timeout case | Postgres-only semantics; widens every routine signature with an options bag that collides with positional params; solves one setting, not the class | + +**Chosen: A.** The issue lists both; A covers B's case in two lines and every other transaction-scoped need. B can be layered on A later if the timeout case proves common. + + +## Shape + + +The callback's `trx` stays a Kysely `Transaction` and gains `proc`, `func`, and `tvf`. Existing callbacks typed `(trx: Transaction) => …` still type-check, because a function that accepts the narrower `Transaction` accepts the intersection. + + await ctx.transaction(async (trx) => { + + await sql`SET LOCAL statement_timeout = '8s'`.execute(trx); + + return trx.tvf('get_graph_neighborhood_fn', { p_node_id: id }); + + }); + +The routine methods are attached to the transaction instance Kysely creates for the call. Kysely's `Kysely`/`Transaction` classes keep state in `#props`, so a prototype-delegating wrapper (`Object.create(trx)`) would throw on every builder call; assigning the three methods onto the instance avoids that. + +`ImpersonatedScope.transaction(fn)` gets the same handle, so the two transaction entry points match. + + +## Postgres `proc` fallback inside a transaction + + +`ctx.proc` on Postgres tries `CALL name(...)` and, on SQLSTATE 42809/42883 ("is a function, not a procedure"), retries as `SELECT * FROM name(...)`. Inside a transaction the failed `CALL` aborts the transaction, so the retry fails with 25P02 (`current transaction is aborted`). + +When the executor is a transaction (`db.isTransaction`), `proc` asks the catalog first instead of trying `CALL`: + + SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE p.prokind = 'p' AND p.proname = $name + AND n.nspname = $schema -- qualified name + -- or: AND n.nspname = ANY (current_schemas(false)) -- unqualified name + ) + +`true` runs `CALL`, `false` runs `SELECT * FROM`. A function is never sent to `CALL` unless the same name is also a visible procedure, so calling a function does not abort the transaction. Outside a transaction nothing changes. + +| Approach | Wins | Loses | +|----------|------|-------| +| Savepoint around `CALL`, `ROLLBACK TO` before the fallback | No extra round trip on the `CALL` path | `RELEASE`/`ROLLBACK TO` destroy later savepoints, so concurrent `trx.proc` calls break each other; a queue fixes that but not a sibling statement (`trx.insertInto`) that lands inside the savepoint window and is silently rolled back | +| **Catalog lookup** (chosen) | No failing `CALL` against a function, no savepoint window, safe under `Promise.all` | One extra round trip per `trx.proc` on Postgres | + +Edge: when one name is both a FUNCTION and a PROCEDURE (overloads in one schema, or different schemas on `search_path`), the lookup returns true and `trx.proc` runs `CALL`. If `CALL` resolves to the function (a function earlier on `search_path`, or arguments matching the function overload), it fails with 42809 and aborts the transaction, where `ctx.proc` outside a transaction would fall back. The lookup matches on name, not arguments or `search_path` order; `trx.tvf` reaches the function in every case. + + +## Shared routine helpers + + +`Context`, `ImpersonatedScope`, and the transaction handle each need proc/func/tvf over a different executor. One module, `src/sdk/routines.ts`, holds the three executor-parameterised calls (including the Postgres fallback) and the function that attaches them to a transaction. Schema qualification (`qualifyName`) lives there too; `Context` passes its schema in, the scope passes none, as today. + +Moving the scope's `proc` onto the shared helper gives it the Postgres function fallback it currently lacks. That is a behavior change on `ImpersonatedScope.proc` against a Postgres FUNCTION: an error today, rows after. diff --git a/docs/dev/sdk.md b/docs/dev/sdk.md index 89719e0f..127882b0 100644 --- a/docs/dev/sdk.md +++ b/docs/dev/sdk.md @@ -231,6 +231,35 @@ const result = await ctx.transaction(async (trx) => { }) ``` +The handle also carries `proc`, `func`, and `tvf`, typed off the context's `Procs`, `Funcs`, and `Tvfs` and running on the transaction's connection. Use them when a routine call needs a transaction-scoped setting, or has to roll back with the rest of the callback: + +```typescript +import { sql } from 'kysely' + +const deptUsers = await ctx.transaction(async (trx) => { + await sql`SET LOCAL statement_timeout = '8s'`.execute(trx) + return trx.tvf('get_department_users', { dept_id: 5 }) +}) +``` + +`SET LOCAL` is Postgres syntax and lasts until the transaction ends. Routine names on the `ctx.transaction` handle get the context's schema prefix, as `ctx.proc` does. The `scope.transaction` handle inside `impersonate()` passes names as given, like `scope.proc`. Kysely instances derived from the handle, such as `trx.withSchema(...)`, are plain Kysely and have no routine methods. + +The handle's type is `TransactionScope`, which is `Transaction & RoutineCalls`. A callback annotated `(trx: Transaction) => ...` still compiles. + +On Postgres a failed `CALL` aborts the transaction, so `trx.proc` checks `pg_catalog.pg_proc` before running anything: + +```mermaid +flowchart TD + P["trx.proc(name)"] --> L{"pg_proc has a
procedure named name?"} + L -->|no| S["SELECT * FROM name(...)"] + L -->|yes| C["CALL name(...)"] + C --> F{"CALL resolves
to a function?"} + F -->|no| R["Procedure runs"] + F -->|yes| A["42809, transaction
aborted"] +``` + +`CALL` resolves to a function when one name is both kinds and the arguments match a function overload, or a function sits earlier on `search_path` than the procedure. The lookup matches on name only, so `trx.tvf` is the call that reaches the function in every case. + ### Schema Scoping @@ -255,7 +284,7 @@ await acct.proc('billing.close_period') // -> already qualified: caller's schema wins, no prefix added ``` -Scoping composes through `transaction()` and `impersonate()` — both stay qualified against the derived schema. Calling `withSchema` again replaces the schema rather than stacking (`ctx.withSchema('a').withSchema('b')` resolves against `b`). `connect()`/`disconnect()` on either instance affect both — one connection, N typed views. +Scoping composes through `transaction()` and `impersonate()`: the query builder on both, and the routine methods on the `ctx.transaction` handle, stay qualified against the derived schema. Routine methods on an impersonated scope pass names as given; see the `impersonate` row in the [reference page's schema scoping table](../reference/sdk.md#schema-scoping). Calling `withSchema` again replaces the schema rather than stacking (`ctx.withSchema('a').withSchema('b')` resolves against `b`). `connect()`/`disconnect()` on either instance affect both — one connection, N typed views. Unqualified identifiers inside raw `` sql`…` `` fragments are **not** rewritten — they resolve against the connection default. Qualify raw SQL by hand or use the query builder. The qualifier is dialect pass-through: a schema on postgres/mssql, a database on mysql, an ATTACHed database name on sqlite. @@ -311,6 +340,8 @@ Parameter types control the call signature: `proc()` and `func()` throw on SQLite, which has no stored procedure or function call support. `tvf()` is only available on MSSQL and PostgreSQL. +On PostgreSQL, `ctx.proc` outside a transaction runs `CALL` and, when that fails with 42809 or 42883 because the name is a function, retries as `SELECT * FROM name(...)`. Inside a transaction `trx.proc` decides up front instead; see [Transactions](#transactions). + #### Parameter handling and NULL semantics diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index a6db5498..d5b6d4fb 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -186,7 +186,7 @@ Only transient failures retry: messages containing `econnrefused`, `etimedout`, ### Transactions -`transaction(fn)` hands `fn` a Kysely `Transaction` and resolves with its return value. A throw inside `fn` rolls back, so do not swallow errors in the callback. +`transaction(fn)` hands `fn` a Kysely `Transaction` with `proc`, `func`, and `tvf` attached, and resolves with its return value. A throw inside `fn` rolls back, so do not swallow errors in the callback. ```typescript const cancelled = await ctx.transaction(async (trx) => { @@ -208,6 +208,27 @@ const cancelled = await ctx.transaction(async (trx) => { }); ``` +Routine calls run on the transaction's connection, so they see transaction-scoped settings and roll back with the rest of `fn`. + +```typescript +import { sql } from 'kysely'; + +const open = await ctx.transaction(async (trx) => { + await sql`SET LOCAL statement_timeout = '8s'`.execute(trx); + + return trx.tvf('party_open_orders', { party_id: 2 }); +}); +// SELECT * FROM "party_open_orders"("party_id" => $1), cancelled if it runs past 8 seconds +``` + +| Rule | Behavior | +|---|---| +| Handle type | `TransactionScope` is `Transaction & RoutineCalls`. A callback annotated `(trx: Transaction) => …` still compiles | +| `SET LOCAL` | Postgres syntax. It lasts until the transaction ends | +| Routine names | Qualified with the context's schema, as on `ctx` | +| `trx.proc` on postgres | Looks the name up in `pg_catalog.pg_proc` first: a procedure runs `CALL`, anything else runs `SELECT * FROM name(...)`. No `CALL` is attempted against a name with no visible procedure, so calling a function does not abort the transaction. A name that is both a procedure and a function runs `CALL`, which fails with 42809 and aborts the transaction when it resolves to the function: arguments that match a function overload, or a function earlier on `search_path` than the procedure. `trx.tvf` reaches the function in every case | +| Derived handles | `trx.withSchema(...)`, `trx.withPlugin(...)`, and other Kysely instances derived from `trx` carry no `proc`, `func`, or `tvf` | + ### Impersonation @@ -288,7 +309,7 @@ await ctx.kysely.selectFrom('sales_order').selectAll().execute(); | Replace, not stack | `ctx.withSchema('a').withSchema('b')` resolves against `b` only | | Routine names | `proc`, `func`, and `tvf` prefix a name that has no `.` with the schema. A name with a `.` passes through | | `transaction` | Inherits the schema | -| `impersonate` | `scope.kysely` and `scope.transaction` stay schema-qualified. `scope.proc`, `scope.func`, and `scope.tvf` call the builders with the name as given, so qualify routine names yourself inside a scope | +| `impersonate` | `scope.kysely` and the Kysely builder on `scope.transaction` stay schema-qualified. Routine methods inside a scope (`scope.proc`/`func`/`tvf` and the same methods on the `scope.transaction` handle) call the builders with the name as given, so qualify routine names yourself | | Raw `` sql`...` `` | Not rewritten. Unqualified identifiers resolve against the connection's default schema, so qualify them by hand | | `ctx.noorm` | Not affected. Pass `schema` to `db.describe*` explicitly | @@ -357,7 +378,7 @@ await ctx.proc('close_order', [2, 1]); await ctx.proc('archive_closed_orders'); ``` -On postgres, `proc()` retries as `SELECT * FROM name(...)` when `CALL` fails with SQLSTATE `42809` or `42883`. One method therefore reaches both `CREATE PROCEDURE` and `CREATE FUNCTION` objects. +On postgres, `proc()` retries as `SELECT * FROM name(...)` when `CALL` fails with SQLSTATE `42809` or `42883`. One method therefore reaches both `CREATE PROCEDURE` and `CREATE FUNCTION` objects. Inside a transaction a failed `CALL` would abort it, so `trx.proc` decides up front instead; see [Transactions](#transactions). ### Parameter handling and NULL semantics @@ -1067,7 +1088,7 @@ Every class below is exported from `@noormdev/sdk`: | Templates | `TemplateResult` | | Vault | `VaultSecret`, `VaultStatus`, `VaultCopyOptions`, `VaultCopyResult`, `VaultPropagationResult` | | Transfer and DT | `TransferOptions`, `TransferPlan`, `TransferTablePlan`, `TransferResult`, `TransferTableResult`, `ConflictStrategy`, `ExportOptions`, `ImportOptions` | -| Routines | `tvp`, `TvpValue`, `ImpersonatedScope`, `ExtractArgs`, `ExtractReturn` | +| Routines | `tvp`, `TvpValue`, `ImpersonatedScope`, `RoutineCalls`, `TransactionScope`, `ExtractArgs`, `ExtractReturn` | | Events | `noormObserver`, `NoormEvents`, `NoormEventNames` | | Errors | `RequireTestError`, `ProtectedConfigError`, `NotConnectedError`, `VaultAccessError`, `ImpersonationError`, `LockAcquireError`, `LockExpiredError`, `ChangeValidationError`, `ChangeNotFoundError`, `ChangeAlreadyAppliedError`, `ChangeNotAppliedError`, `ChangeOrphanedError`, `ManifestReferenceError` | diff --git a/docs/spec/sdk-transaction-routines.md b/docs/spec/sdk-transaction-routines.md new file mode 100644 index 00000000..fc82b0fb --- /dev/null +++ b/docs/spec/sdk-transaction-routines.md @@ -0,0 +1,220 @@ +# SDK routines inside a transaction — `trx.proc` / `trx.func` / `trx.tvf` + + +## Goal + + +The handle `ctx.transaction(fn)` passes to `fn` is a Kysely `Transaction` that also carries typed `proc`, `func`, and `tvf`, running on the transaction's connection, so a caller can issue `SET LOCAL statement_timeout = '8s'` and then a typed `trx.tvf(...)` in one transaction (noormdev/noorm#106). + + +## Non-goals + + +- No per-call options bag (`{ statementTimeout }`) on `proc`/`func`/`tvf`. +- No SDK-issued `SET LOCAL` or timeout helper; the caller writes the setting with `sql`. +- No change to `proc`/`func`/`tvf` SQL generation (`src/sdk/sql.ts` builders unchanged). +- No routine methods on `Transaction` instances derived from the handle (`trx.withSchema(...)`, `trx.withPlugin(...)` return plain Kysely instances). +- No nested-transaction support; Kysely's `trx.transaction()` throws as it does today. + + +## Success criteria + + +- [ ] `Context.transaction(fn)` calls `fn` with a `TransactionScope` (= `Transaction & RoutineCalls`); a callback annotated `(trx: Transaction) => …` still type-checks. +- [ ] `trx.proc`, `trx.func`, `trx.tvf` have the same generic signatures as `Context.proc`/`func`/`tvf`, typed off the context's `Procs`/`Funcs`/`Tvfs`. +- [ ] Routine calls through `trx` run on the transaction's connection: a write made by `trx.proc(...)` is rolled back when `fn` throws, verified live on postgres. +- [ ] Postgres: `SET LOCAL statement_timeout = '200ms'` followed by `trx.tvf(...)` of a set-returning function that sleeps 1.5 s rejects with SQLSTATE `57014`; the same call outside the setting returns rows. Verified live. +- [ ] Postgres: inside a transaction, `trx.proc(name)` decides `CALL` vs `SELECT * FROM` with a `pg_catalog.pg_proc` lookup (a `prokind = 'p'` row named `name` in the given schema, or in `current_schemas(false)` when unqualified) before running anything, so no `CALL` is attempted against a name with no visible procedure and no savepoint is issued. Against a FUNCTION it returns the function's rows and leaves the transaction usable for a following statement. Outside a transaction `ctx.proc` behaves as before (try `CALL`, fall back on 42809/42883). +- [ ] Postgres: `Promise.all` over two `trx.proc` calls (one PROCEDURE, one FUNCTION) and a sibling `trx.insertInto(...)` on one handle resolves all three and all three effects commit, verified live. +- [ ] A context from `ctx.withSchema('acct')` qualifies `trx.proc`/`func`/`tvf` names with `acct.` unless the name already contains `.`, same rule as `Context` routines. +- [ ] MSSQL: `trx.tvf(...)` returns rows inside `ctx.transaction`, verified live. +- [ ] `ImpersonatedScope.transaction(fn)` passes the same `TransactionScope` handle; `ImpersonatedScope.proc` uses the shared helper, so it also gets the Postgres FUNCTION fallback. +- [ ] `TransactionScope` and `RoutineCalls` are exported types from `@noormdev/sdk`. +- [ ] `docs/reference/sdk.md`, `docs/dev/sdk.md`, `packages/sdk/README.md`, and `skills/noorm/references/sdk.md` document routines on the transaction handle with the `statement_timeout` example; `.changeset/` carries a `minor` entry for `@noormdev/sdk`. +- [ ] `bun run typecheck`, `bun run lint`, CI test groups 1 and 5 (`docs/wiki/index.md`) pass against `docker-compose.test.yml`. + + +## Approach + +Routine methods attached to the transaction handle — see `docs/design/sdk-transaction-routines.md`. + + +## Change tree + +``` +src/sdk/ +├── routines.ts ............................ A (callProc/callFunc/callTvf over any executor; withRoutines) +├── types.ts ............................... M (RoutineCalls, TransactionScope) +├── context.ts ............................. M (transaction yields TransactionScope; proc/func/tvf delegate) +├── index.ts ............................... M (export RoutineCalls, TransactionScope) +└── impersonate/ + ├── scope.ts ........................... M (delegate to routines.ts; transaction yields TransactionScope) + └── types.ts ........................... M (ImpersonatedScope extends RoutineCalls) +tests/sdk/ +└── routines.test.ts ....................... A (unit: attach, qualification, catalog lookup only inside a transaction) +tests/integration/sdk/ +└── transaction-routines.test.ts ........... A (live: pg statement_timeout, rollback, trx.proc against a FUNCTION, concurrency, withSchema; mssql tvf) +docs/reference/sdk.md ...................... M +docs/dev/sdk.md ............................ M +packages/sdk/README.md ..................... M +skills/noorm/references/sdk.md ............. M +.changeset/sdk-transaction-routines.md ..... A +``` + + +## Outline + +``` +src/sdk/routines.ts + qualifyName — prefixes an unqualified routine name with the context schema + routineCalls — binds proc/func/tvf to an executor provider; used by Context, the scope, and withRoutines + callProc — proc call on a given executor; postgres: catalog lookup picks CALL or SELECT inside a transaction, try-CALL-then-SELECT outside + callFunc — scalar function call on a given executor, first row or null + callTvf — table-valued function call on a given executor, all rows + withRoutines — attaches proc/func/tvf (schema-qualified when a schema is given) to a Kysely Transaction instance, returns it as TransactionScope + +src/sdk/types.ts + RoutineCalls — proc/func/tvf generic signatures keyed off Procs/Funcs/Tvfs + TransactionScope — Transaction & RoutineCalls + +src/sdk/context.ts + transaction — opens the Kysely transaction, passes withRoutines(trx, dialect, schema) to fn + proc / func / tvf — qualify name, delegate to callProc/callFunc/callTvf on this.kysely + #executeProcPostgres — removed; logic moves to callProc + +src/sdk/impersonate/types.ts + ImpersonatedScope — extends RoutineCalls; transaction yields TransactionScope + +src/sdk/impersonate/scope.ts + buildScope — proc/func/tvf delegate to routines.ts; transaction wraps trx with withRoutines + +tests/sdk/routines.test.ts + handle carries proc/func/tvf and remains the Kysely Transaction + schema qualification on the handle + pg_proc lookup issued only when the executor is a transaction; CALL vs SELECT follows its result + +tests/integration/sdk/transaction-routines.test.ts + postgres statement_timeout via SET LOCAL cancels trx.tvf + routine write rolled back with the transaction + trx.proc against a FUNCTION runs SELECT and keeps the transaction usable + withSchema-derived context qualifies trx routines + mssql trx.tvf returns rows + +docs/reference/sdk.md + transaction() — handle carries proc/func/tvf; statement_timeout example +docs/dev/sdk.md + Transactions — routines on the handle, SET LOCAL pattern +packages/sdk/README.md + Transactions — routines on the handle example +skills/noorm/references/sdk.md + Transactions — trx.tvf with SET LOCAL statement_timeout + +.changeset/sdk-transaction-routines.md + None — frontmatter + summary +``` + + +## Flows + +**Routine call under a transaction-scoped setting** + +1. Caller calls `ctx.transaction(fn)`. +2. `Context.transaction` opens `this.kysely.transaction()` and receives Kysely's `trx`. +3. `withRoutines(trx, dialect, schema)` assigns `proc`/`func`/`tvf` onto `trx` and returns it typed as `TransactionScope`. +4. `fn` runs `` sql`SET LOCAL statement_timeout = '8s'`.execute(trx) ``, then `trx.tvf(name, params)`. +5. `trx.tvf` qualifies `name` with the context schema (if any), builds the call via `buildTvfCall`, executes it on `trx`, returns rows. +6. `fn` returns; Kysely commits. A throw from `fn` rolls back every statement, routine calls included. + +**Postgres `trx.proc` against a FUNCTION** + +1. `callProc` sees dialect `postgres` and `db.isTransaction === true`. +2. It queries `pg_catalog.pg_proc` joined to `pg_namespace` for a `prokind = 'p'` row with `proname = name`, in the qualifying schema when `name` is qualified, else in `current_schemas(false)`. +3. A procedure exists: `CALL name(...)`, return rows. Any error propagates (the transaction is the caller's to roll back). +4. No procedure: `SELECT * FROM name(...)`, return rows. A missing name surfaces Postgres's "function does not exist" error, the same final error as outside a transaction. +5. Outside a transaction no lookup runs: `CALL`, and on 42809/42883 `SELECT * FROM`, as before. + + +## Checkpoints + + +| # | Checkpoint | Files/areas | Agent | Est. files | Verifies | +|---|------------|-------------|-------|------------|----------| +| 1 | Shared routine helpers, `TransactionScope` on `Context.transaction` and `ImpersonatedScope.transaction`, exports, unit + integration tests | `src/sdk/*`, `tests/sdk/routines.test.ts`, `tests/integration/sdk/transaction-routines.test.ts` | atomic-implementer (feature) | 8 | new tests green; existing `tests/sdk`, `tests/integration/sdk` green; typecheck, lint | +| 2 | Docs + changeset | `docs/reference/sdk.md`, `docs/dev/sdk.md`, `packages/sdk/README.md`, `skills/noorm/references/sdk.md`, `.changeset/` | atomic-implementer (feature) | 5 | pages show the `SET LOCAL` + `trx.tvf` example; changeset `minor` for `@noormdev/sdk` | + + +## Risks + + +| Risk | Likelihood | Mitigation | +|------|-----------|-----------| +| Assigning methods onto Kysely's `Transaction` instance collides with a Kysely member | low | Kysely 0.28 `Transaction` has no `proc`/`func`/`tvf` (`fn` is Kysely's and untouched); unit test asserts builder methods still work on the handle | +| One name is both a PROCEDURE and a FUNCTION (overloads in one schema, or across schemas on `search_path`) | low | Any visible procedure routes to `CALL`, so a call meant for the function overload fails inside a transaction where `ctx.proc` outside one would fall back; the lookup matches on name, not arguments. Documented edge | +| Concurrent statements on one handle while `trx.proc` runs | low | No savepoint or multi-statement window exists, so sibling statements cannot be rolled back by `trx.proc`; live `Promise.all` test | +| `statement_timeout` test flakes on a slow CI host | low | 200 ms limit vs 1.5 s sleep leaves a wide margin | + + +## Change log + +### 2026-10-26 — initial spec + +**What changed:** Spec created from noormdev/noorm#106. + +**Why:** Typed routine calls were unavailable inside `ctx.transaction`, blocking per-call `SET LOCAL statement_timeout`. + +### 2026-10-26 — unique savepoint names + +**What changed:** The Postgres `trx.proc` savepoint uses a per-call unique name, and a failed `ROLLBACK TO SAVEPOINT` no longer masks the `CALL` error. + +**Why:** Iteration 1 review: concurrent `trx.proc` calls on one handle could roll back each other's savepoint under a fixed name. + +**Superseded:** One fixed savepoint name shared by every call. + +### 2026-10-26 — Correction: serialize savepoint-wrapped proc calls + +**What changed:** Savepoint-wrapped `trx.proc` calls on one handle run one at a time, with a live `Promise.all` criterion. A failed rollback skips the fallback and `RELEASE`. + +**Why:** Correction: iteration 2 review verified on Postgres 17 that `RELEASE SAVEPOINT noorm_proc_1` destroys a later `noorm_proc_2`, so unique names did not make concurrent calls safe; two concurrent successful calls aborted the transaction. + +**Superseded:** Per-call unique savepoint names as the concurrency mitigation. + +### 2026-10-26 — Correction: catalog lookup replaces savepoints + +**What changed:** Inside a transaction, Postgres `trx.proc` picks `CALL` or `SELECT * FROM` from a `pg_proc` lookup. No savepoints, no per-handle queue, no rollback-error `cause` handling. + +**Why:** Correction: a `ROLLBACK TO SAVEPOINT` in the fallback path also undoes any sibling statement the caller ran concurrently on the same handle (`Promise.all([trx.proc(fn), trx.insertInto(...)])`), a silent write loss the queue cannot prevent because it orders only `proc` calls. The lookup was verified live on Postgres 17 against qualified, `search_path`-resolved, quoted-mixed-case, function, and missing names. + +**Superseded:** Savepoint-wrapped `CALL` with a per-handle queue of `trx.proc` calls. + +### 2026-10-26 — Correction: outline and criterion wording + +**What changed:** Outline lists `qualifyName` and `routineCalls` in `src/sdk/routines.ts`; the lookup criterion says no `CALL` is attempted against a name with no visible procedure, instead of "no `CALL` fails"; the change-tree note drops the retired "fallback" wording. + +**Why:** Correction: final audit found the outline missing two exports and the criterion contradicting the Risks row (a name that is both a procedure and a function still fails `CALL`). + + +## Implementation log + +### shipped — 2026-10-26 + +Built across 9 iterations of /autopilot (CP-1: 5, CP-2: 3, post-audit: 1). Commits (chronological): + +- `fe6f2181` — spec and design +- `ec25e919` — CP-1 routines on the transaction handle, shared `src/sdk/routines.ts`, unit + live tests +- `dc37fc75` — CP-2 reference, dev guide, SDK README, skill reference, changeset +- `8e07a4bd` — post-audit: `callTvf` reuse, edge-case wording, routing flowchart + +**Out-of-scope work performed during this build:** + +- `ImpersonatedScope.proc` gained the Postgres FUNCTION fallback by moving onto the shared helper; four pre-existing skill-reference examples used an undeclared `key` and were fixed alongside the new one. + +**Unforeseens — surprises that emerged during implementation:** + +- The Postgres `CALL`→`SELECT` fallback aborts an open transaction. Savepoints broke concurrent calls (`RELEASE` destroys later savepoints) and could silently roll back a sibling statement; replaced with a `pg_proc` lookup. +- Final audit reproduced that a function earlier on `search_path` wins `CALL` over a same-named procedure; documented as an edge rather than matched. + +**Deferred items still open:** + +- none + +**Squashed into one commit on `sdk-transaction-routines` — 2026-10-26.** Per-iteration SHAs above are historical (unreachable from any branch). diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 6d5eb6b8..42ef979c 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -16,16 +16,10 @@ │ │ ├── solid/ (5 files, 0 dirs) │ │ └── testing/ (1 file, 0 dirs) │ └── SKILL.md (a62967f, 195L, 7253ch, 7427B) -├── .changeset/ (9) +├── .changeset/ (3) │ ├── README.md (bf33c79, 8L, 510ch, 510B) -│ ├── binary-no-dotenv-autoload.md (c6f0527, 5L, 383ch, 383B) │ ├── config.json (64bb386, 11L, 307ch, 307B) -│ ├── connection-error-reasons.md (d86f802, 6L, 539ch, 539B) -│ ├── mssql-unprivileged-logins.md (df9693f, 6L, 489ch, 489B) -│ ├── prefetch-run-gate.md (9b2ca2f, 6L, 396ch, 396B) -│ ├── slow-statement-watcher.md (7fa4155, 6L, 639ch, 639B) -│ ├── tui-explore-duplicate-keys.md (80009cf, 6L, 445ch, 445B) -│ └── tui-typing-blocks-shortcuts.md (c3a693c, 5L, 356ch, 356B) +│ └── sdk-transaction-routines.md (89abbc4, 5L, 612ch, 612B) ├── .claude/ (3) │ ├── rules/ (5) │ │ ├── wiki/ (12) @@ -41,7 +35,7 @@ │ │ │ ├── sdk.md (792107d, 22L, 620ch, 622B) │ │ │ ├── tui.md (8aac176, 20L, 631ch, 633B) │ │ │ └── worker-bridge.md (394769b, 15L, 521ch, 523B) -│ │ ├── documentation.md (72f6102, 33L, 1577ch, 1577B) +│ │ ├── documentation.md (d203cfb, 31L, 1523ch, 1523B) │ │ ├── testing.md (87fc604, 95L, 3931ch, 3933B) │ │ ├── tui-development.md (07906c0, 422L, 18517ch, 18527B) │ │ └── typescript.md (b106eec, 194L, 6750ch, 6758B) @@ -96,42 +90,44 @@ │ ├── docs.yml (1d7b2ac, 51L, 1456ch, 1456B) │ ├── publish.yml (48bab2f, 97L, 2442ch, 2442B) │ └── release-binary.yml (56d023f, 46L, 1333ch, 1333B) -├── docs/ (19) -│ ├── .vitepress/ (3) -│ │ ├── theme/ (5) -│ │ │ ├── HeroEyebrow.vue (93327f3, 23L, 710ch, 715B) -│ │ │ ├── HeroStats.vue (715ce16, 26L, 531ch, 533B) -│ │ │ ├── HeroTerminal.vue (95c6581, 156L, 3887ch, 3888B) -│ │ │ ├── brand.css (6a2d53a, 615L, 17530ch, 21344B) -│ │ │ └── index.ts (5ce55a8, 53L, 1207ch, 1207B) -│ │ ├── config.mts (73d90d4, 283L, 14561ch, 14574B) -│ │ └── og-source.html (f8e2a14, 135L, 3784ch, 3793B) +├── docs/ (21) +│ ├── .vitepress/ (4) +│ │ ├── theme/ (4) +│ │ │ ├── ModelViewer.vue (f15409f, 65L, 1866ch, 1866B) +│ │ │ ├── brand.css (7640810, 564L, 15719ch, 19167B) +│ │ │ ├── index.ts (be5c492, 64L, 2020ch, 2020B) +│ │ │ └── landing.css (c0cedfd, 645L, 12678ch, 13222B) +│ │ ├── config.mts (c94e0af, 287L, 14933ch, 14945B) +│ │ ├── mermaid.ts (4bfb176, 96L, 4165ch, 4165B) +│ │ └── og-source.html (ef25d5e, 135L, 3789ch, 3798B) │ ├── cli/ (10) -│ │ ├── flags.md (8d4807c, 100L, 4386ch, 4422B) -│ │ ├── help.md (bbb20fe, 47L, 1421ch, 1429B) -│ │ ├── identity.md (8b748d1, 79L, 3857ch, 3871B) -│ │ ├── init.md (121e4ac, 70L, 2887ch, 2903B) -│ │ ├── run.md (9e0fe68, 134L, 4703ch, 4723B) -│ │ ├── secret.md (695aac6, 77L, 3258ch, 3292B) -│ │ ├── settings-edit.md (b5e2e3f, 21L, 602ch, 604B) -│ │ ├── settings-secret.md (99e8f29, 23L, 664ch, 668B) -│ │ ├── sql-repl.md (20a6f63, 28L, 799ch, 803B) -│ │ └── sql.md (52e5a3c, 77L, 3342ch, 3374B) -│ ├── design/ (6) +│ │ ├── flags.md (e79010c, 69L, 2916ch, 2918B) +│ │ ├── help.md (f5c7a97, 51L, 1593ch, 1593B) +│ │ ├── identity.md (a6d9d66, 180L, 6196ch, 6202B) +│ │ ├── init.md (e8c4ecc, 105L, 4063ch, 4143B) +│ │ ├── run.md (a58f321, 190L, 9000ch, 9024B) +│ │ ├── secret.md (414496d, 114L, 4279ch, 4279B) +│ │ ├── settings-edit.md (3631850, 32L, 1269ch, 1271B) +│ │ ├── settings-secret.md (aab5db2, 42L, 1595ch, 1597B) +│ │ ├── sql-repl.md (6ddfbd9, 41L, 1493ch, 1493B) +│ │ └── sql.md (5027bd7, 170L, 6613ch, 6629B) +│ ├── design/ (8) │ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) │ │ ├── config-access-roles.md (bd73baa, 116L, 6566ch, 6670B) │ │ ├── prefetch-run-gate.md (36f0f60, 43L, 3105ch, 3105B) +│ │ ├── sdk-transaction-routines.md (499a6c4, 78L, 5068ch, 5070B) │ │ ├── sdk-with-schema.md (37b324b, 117L, 7194ch, 7234B) +│ │ ├── site-running-example.md (8c8fcb2, 438L, 23920ch, 23965B) │ │ ├── update-download-progress.md (2106019, 47L, 3002ch, 3002B) │ │ └── v1-49-54-cli-field-defects.md (6f051d8, 242L, 12448ch, 12534B) │ ├── dev/ (25) -│ │ ├── change.md (6cb1ae4, 575L, 20544ch, 20628B) -│ │ ├── ci.md (53c262d, 221L, 8752ch, 8772B) +│ │ ├── change.md (2241eaf, 575L, 20547ch, 20631B) +│ │ ├── ci.md (b0f89cc, 221L, 8758ch, 8778B) │ │ ├── config-sharing.md (36147c5, 280L, 10834ch, 10884B) │ │ ├── config.md (d1992d9, 459L, 14716ch, 14768B) │ │ ├── datamodel.md (0e2c39e, 1105L, 36520ch, 36680B) │ │ ├── explore.md (024a815, 361L, 10798ch, 10902B) -│ │ ├── headless.md (8731e15, 839L, 23000ch, 23212B) +│ │ ├── headless.md (522d538, 839L, 23009ch, 23221B) │ │ ├── identity.md (6124955, 429L, 16703ch, 16747B) │ │ ├── index.md (cf0dae0, 44L, 1558ch, 1558B) │ │ ├── ink-cheatsheet.md (4a06b26, 1942L, 57168ch, 57240B) @@ -139,96 +135,85 @@ │ │ ├── lock.md (2096e54, 344L, 10829ch, 10875B) │ │ ├── logger.md (e297dba, 599L, 21334ch, 22867B) │ │ ├── project-discovery.md (ac60c94, 150L, 5071ch, 5083B) -│ │ ├── runner.md (9908c1a, 601L, 26334ch, 26372B) -│ │ ├── sdk.md (b538f8f, 1159L, 31592ch, 31668B) +│ │ ├── runner.md (b73da07, 601L, 26409ch, 26447B) +│ │ ├── sdk.md (ecc61f7, 1190L, 33901ch, 33975B) │ │ ├── secrets.md (47a8ca1, 321L, 11587ch, 11665B) -│ │ ├── settings.md (eacd272, 854L, 23279ch, 23301B) +│ │ ├── settings.md (55242d3, 857L, 23466ch, 23488B) │ │ ├── sql-terminal.md (1cba9d3, 340L, 10785ch, 12173B) │ │ ├── state.md (93a30bc, 403L, 11675ch, 11719B) │ │ ├── teardown.md (eef86b1, 427L, 16037ch, 16087B) -│ │ ├── template.md (2f1066b, 509L, 15252ch, 15368B) +│ │ ├── template.md (4089dc8, 509L, 15282ch, 15398B) │ │ ├── transfer.md (61f1c43, 760L, 30520ch, 30836B) │ │ ├── vault.md (472af9a, 643L, 24576ch, 26226B) │ │ └── version.md (6410eaf, 670L, 21560ch, 21600B) │ ├── getting-started/ (4) -│ │ ├── building-your-sdk.md (f1c8581, 777L, 18565ch, 19051B) -│ │ ├── concepts.md (7571e89, 358L, 12290ch, 12650B) -│ │ ├── first-build.md (96fab52, 345L, 9286ch, 9388B) -│ │ └── installation.md (0fda387, 174L, 6246ch, 6336B) +│ │ ├── building-your-sdk.md (62a60c1, 322L, 8119ch, 8171B) +│ │ ├── concepts.md (4c4db90, 183L, 7462ch, 7536B) +│ │ ├── first-build.md (161e763, 215L, 6297ch, 6377B) +│ │ └── installation.md (9ca4b93, 111L, 3452ch, 3454B) │ ├── guide/ (8) │ │ ├── automation/ (3) -│ │ │ ├── ci.md (622ed6d, 354L, 13570ch, 13610B) -│ │ │ ├── mcp.md (d351f42, 140L, 6520ch, 6802B) -│ │ │ └── non-interactive.md (0babb24, 138L, 5135ch, 5149B) -│ │ ├── changes/ (3) -│ │ │ ├── forward-revert.md (e89c652, 301L, 11159ch, 11161B) -│ │ │ ├── history.md (ce7de9c, 336L, 11526ch, 11528B) -│ │ │ └── overview.md (4bc55b3, 409L, 16143ch, 16485B) +│ │ │ ├── ci.md (054b950, 179L, 7310ch, 7312B) +│ │ │ ├── mcp.md (46220d1, 95L, 3560ch, 3560B) +│ │ │ └── non-interactive.md (90295d9, 67L, 2531ch, 2531B) +│ │ ├── changes/ (4) +│ │ │ ├── add-a-column.md (2ca35a3, 120L, 4431ch, 4435B) +│ │ │ ├── forward-revert.md (3c75b22, 188L, 7200ch, 7224B) +│ │ │ ├── history.md (44071c4, 145L, 5340ch, 5368B) +│ │ │ └── overview.md (bcf0492, 150L, 6364ch, 6440B) │ │ ├── database/ (5) -│ │ │ ├── create.md (061870f, 169L, 6551ch, 6573B) -│ │ │ ├── explore.md (db56d57, 521L, 20944ch, 25422B) -│ │ │ ├── teardown.md (5919704, 397L, 14078ch, 14084B) -│ │ │ ├── terminal.md (6883cd8, 269L, 11978ch, 13738B) -│ │ │ └── transfer.md (7e28d77, 388L, 12244ch, 12270B) +│ │ │ ├── create.md (71eea55, 108L, 3958ch, 3962B) +│ │ │ ├── explore.md (c6b3c4e, 129L, 5405ch, 5419B) +│ │ │ ├── teardown.md (14c8d2d, 141L, 6652ch, 6662B) +│ │ │ ├── terminal.md (88044da, 127L, 5984ch, 6010B) +│ │ │ └── transfer.md (2d40788, 151L, 5479ch, 5479B) │ │ ├── environments/ (4) -│ │ │ ├── configs.md (abc5ba8, 383L, 15598ch, 15638B) -│ │ │ ├── secrets.md (3cb3103, 202L, 7470ch, 7482B) -│ │ │ ├── stages.md (14f1041, 252L, 8279ch, 8283B) -│ │ │ └── vault.md (b1af624, 258L, 8719ch, 9267B) +│ │ │ ├── configs.md (1ca8ad2, 219L, 8550ch, 8556B) +│ │ │ ├── secrets.md (666e139, 93L, 4362ch, 4362B) +│ │ │ ├── stages.md (f8978dd, 127L, 5235ch, 5235B) +│ │ │ └── vault.md (e33eedd, 124L, 4811ch, 4811B) │ │ ├── sql-files/ (3) -│ │ │ ├── execution.md (059d29c, 265L, 11279ch, 11421B) -│ │ │ ├── organization.md (5febfd5, 351L, 9226ch, 9692B) -│ │ │ └── templates.md (c8f060b, 508L, 16419ch, 16547B) -│ │ ├── deployment.md (0e61d4d, 190L, 8503ch, 8530B) -│ │ ├── relational-design.md (b606099, 299L, 14581ch, 14597B) -│ │ └── troubleshooting.md (64d8466, 146L, 5771ch, 5785B) -│ ├── modeling/ (8) -│ │ ├── best-practices.md (88a154a, 149L, 10034ch, 10038B) -│ │ ├── branding.md (bf235bc, 110L, 4486ch, 4487B) -│ │ ├── data-flows.md (700e476, 181L, 10254ch, 10258B) -│ │ ├── entities.md (b4e9a1b, 357L, 17710ch, 17724B) -│ │ ├── index.md (0f5a2e8, 127L, 9119ch, 9119B) -│ │ ├── installation.md (845c455, 163L, 7147ch, 7155B) -│ │ ├── modeling-skill.md (c7d7e19, 86L, 5231ch, 5231B) -│ │ └── reverse-engineering.md (069ebf9, 189L, 13038ch, 13040B) -│ ├── models/ (3) -│ │ ├── polymorphic/ (3) -│ │ │ ├── data/ (2) -│ │ │ │ ├── content/ (4 files, 0 dirs) -│ │ │ │ └── identity/ (3 files, 0 dirs) -│ │ │ ├── groups/ (2) -│ │ │ │ ├── content.md (5a8b1aa, 6L, 104ch, 104B) -│ │ │ │ └── identity.md (b0104ba, 6L, 108ch, 108B) -│ │ │ └── ignatius.yml (61d527e, 8L, 250ch, 252B) -│ │ ├── social/ (3) -│ │ │ ├── data/ (3) -│ │ │ │ ├── content/ (13 files, 0 dirs) -│ │ │ │ ├── identity/ (3 files, 0 dirs) -│ │ │ │ └── tagging/ (4 files, 0 dirs) -│ │ │ ├── groups/ (3) -│ │ │ │ ├── content.md (9605963, 6L, 115ch, 115B) -│ │ │ │ ├── identity.md (893db84, 6L, 75ch, 75B) -│ │ │ │ └── tagging.md (94a6b12, 6L, 91ch, 91B) -│ │ │ └── ignatius.yml (ac15e3e, 12L, 275ch, 277B) -│ │ └── todo-list/ (3) -│ │ ├── data/ (1) -│ │ │ └── core/ (3 files, 0 dirs) -│ │ ├── groups/ (1) -│ │ │ └── core.md (b6f6e38, 6L, 91ch, 91B) -│ │ └── ignatius.yml (f8945e4, 8L, 220ch, 222B) +│ │ │ ├── execution.md (2547025, 178L, 8321ch, 8339B) +│ │ │ ├── organization.md (1b8d2b0, 119L, 4866ch, 4976B) +│ │ │ └── templates.md (01c8219, 210L, 8960ch, 8990B) +│ │ ├── deployment.md (cb21df8, 95L, 4256ch, 4262B) +│ │ ├── relational-design.md (4ae9406, 206L, 8795ch, 8795B) +│ │ └── troubleshooting.md (edaa6db, 42L, 2580ch, 2594B) +│ ├── modeling/ (10) +│ │ ├── best-practices.md (04578b2, 105L, 5610ch, 5610B) +│ │ ├── branding.md (a2b16ff, 93L, 3726ch, 3731B) +│ │ ├── data-flows.md (43de3e6, 151L, 6440ch, 6482B) +│ │ ├── entities.md (60edb1a, 246L, 10371ch, 10453B) +│ │ ├── index.md (0c97c91, 184L, 7138ch, 7151B) +│ │ ├── installation.md (ba02adc, 120L, 5806ch, 5820B) +│ │ ├── model-to-sql.md (6432c26, 119L, 4393ch, 4403B) +│ │ ├── modeling-skill.md (4132781, 97L, 4875ch, 4875B) +│ │ ├── reverse-engineering.md (317d9b2, 143L, 7117ch, 7117B) +│ │ └── why.md (679e297, 95L, 5673ch, 5673B) +│ ├── models/ (1) +│ │ └── order-to-cash/ (6) +│ │ ├── data/ (3) +│ │ │ ├── billing/ (4 files, 0 dirs) +│ │ │ ├── parties/ (4 files, 0 dirs) +│ │ │ └── sales/ (3 files, 0 dirs) +│ │ ├── externals/ (1) +│ │ │ └── Customer.md (5d7d77e, 5L, 123ch, 123B) +│ │ ├── flows/ (1) +│ │ │ └── order-to-cash/ (3 files, 0 dirs) +│ │ ├── groups/ (3) +│ │ │ ├── billing.md (5340158, 6L, 132ch, 132B) +│ │ │ ├── parties.md (ce4bcf8, 6L, 136ch, 136B) +│ │ │ └── sales.md (805fac7, 6L, 95ch, 95B) +│ │ ├── stores/ (1) +│ │ │ └── gateway-log.md (c00e047, 6L, 146ch, 146B) +│ │ └── ignatius.yml (74fd95a, 7L, 192ch, 192B) +│ ├── parts/ (4) +│ │ ├── ignatius-cmds.md (5dededc, 21L, 343ch, 347B) +│ │ ├── ignatius-dfd.md (5101766, 19L, 1014ch, 1215B) +│ │ ├── ignatius-problems.md (9abe926, 22L, 1124ch, 1124B) +│ │ └── ignatius-viewer.md (e70aa66, 32L, 1006ch, 1006B) │ ├── public/ (5) -│ │ ├── icons/ (10) -│ │ │ ├── bolt.svg (8880fa8, 1L, 586ch, 586B) -│ │ │ ├── cubes.svg (477517a, 1L, 1009ch, 1009B) -│ │ │ ├── database.svg (72faaf5, 1L, 389ch, 389B) -│ │ │ ├── fast-forward.svg (fc62b4a, 1L, 302ch, 302B) -│ │ │ ├── flask.svg (ceb88e5, 1L, 676ch, 676B) -│ │ │ ├── git-branch.svg (98f49a3, 1L, 358ch, 358B) -│ │ │ ├── lock.svg (f4c26ec, 1L, 308ch, 308B) -│ │ │ ├── terminal.svg (5102e98, 1L, 613ch, 613B) -│ │ │ ├── toolbox.svg (7cc582d, 1L, 373ch, 373B) -│ │ │ └── users.svg (7c6b4e2, 1L, 1099ch, 1099B) -│ │ ├── image/ (9) +│ │ ├── image/ (11) │ │ │ ├── tui/ (20) │ │ │ │ ├── change-history.png (4e6ddc4, 320L, 99358ch, 103277B) │ │ │ │ ├── changes-list.png (1a5f3a9, 226L, 81769ch, 85137B) @@ -252,26 +237,29 @@ │ │ │ │ └── vault.png (6a5d703, 84L, 39941ch, 41834B) │ │ │ ├── 0105.gif (fa282b2, 31773L, 7759553ch, 8187851B) │ │ │ ├── build-and-change.gif (e90d150, 1334L, 219029ch, 225025B) +│ │ │ ├── favicon.svg (d6c509e, 9L, 552ch, 552B) │ │ │ ├── install.gif (d932cef, 1163L, 187594ch, 193254B) -│ │ │ ├── logo.png (f273558, 100L, 44738ch, 46953B) -│ │ │ ├── logo.svg (8d46c28, 6L, 2529ch, 2529B) -│ │ │ ├── og.png (fa3d4ae, 206L, 57780ch, 60183B) +│ │ │ ├── logo-dark.svg (7d5e3d0, 9L, 458ch, 458B) +│ │ │ ├── logo.png (6794b8f, 4L, 2162ch, 2178B) +│ │ │ ├── logo.svg (e4aac3b, 9L, 448ch, 448B) +│ │ │ ├── og.png (a2ab3d2, 222L, 56680ch, 59157B) │ │ │ ├── tui-rows.gif (9389b46, 4231L, 724149ch, 744438B) │ │ │ └── tui.gif (24fe56d, 11710L, 1679451ch, 1728445B) -│ │ ├── models/ (3) -│ │ │ ├── polymorphic.html (a2c06a9, 629L, 4272673ch, 4277149B) -│ │ │ ├── social.html (02baebf, 629L, 4287168ch, 4291644B) -│ │ │ └── todo-list.html (c1ae57a, 629L, 4271835ch, 4276311B) +│ │ ├── models/ (2) +│ │ │ ├── order-to-cash-dark.html (3ad667a, 640L, 4515812ch, 4520696B) +│ │ │ └── order-to-cash.html (e672273, 640L, 4515813ch, 4520697B) │ │ ├── video/ (2) │ │ │ ├── ignatius-poster.jpg (1703473, 149L, 57987ch, 60286B) │ │ │ └── ignatius.mp4 (f46adb8, 26426L, 6528910ch, 6776756B) +│ │ ├── ignatius.sh (9bff85d, 10L, 375ch, 375B) │ │ └── install.sh (0cc90a2, 116L, 2925ch, 2925B) │ ├── reference/ (1) -│ │ └── sdk.md (a8c2d94, 1707L, 63416ch, 63647B) -│ ├── spec/ (7) +│ │ └── sdk.md (0547d8c, 1126L, 63799ch, 63801B) +│ ├── spec/ (8) │ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) │ │ ├── config-access-roles.md (40ef290, 162L, 21805ch, 21929B) │ │ ├── prefetch-run-gate.md (f939bfe, 232L, 13949ch, 14025B) +│ │ ├── sdk-transaction-routines.md (881f76d, 220L, 13784ch, 13906B) │ │ ├── sdk-with-schema.md (593829a, 174L, 13384ch, 13504B) │ │ ├── update-download-progress.md (85fec84, 152L, 9599ch, 9677B) │ │ ├── v1-45-rewind-tiebreak.md (0e35550, 61L, 5465ch, 5507B) @@ -299,11 +287,12 @@ │ │ ├── shots.sh (2e05acc, 63L, 1971ch, 1975B) │ │ └── theme.tape (3ad3d47, 40L, 2063ch, 2071B) │ ├── bun.lockb (34225b2, 190L, 125711ch, 126677B) -│ ├── headless.md (a76c0a6, 1870L, 55232ch, 55376B) -│ ├── index.md (376b0eb, 166L, 6438ch, 6452B) +│ ├── download.md (319c450, 109L, 2968ch, 2974B) +│ ├── headless.md (2f8defe, 1485L, 52587ch, 52589B) +│ ├── index.md (1a00538, 195L, 8130ch, 8176B) │ ├── package.json (b4778f0, 24L, 657ch, 657B) -│ ├── tui.md (5ae509e, 539L, 18629ch, 19218B) -│ └── why-noorm.md (dc7e978, 109L, 7078ch, 7078B) +│ ├── tui.md (7f6fcf3, 443L, 15914ch, 15999B) +│ └── why-noorm.md (0a26135, 94L, 4920ch, 4920B) ├── examples/ (3) │ ├── llm-memory-db-mssql/ (16) │ │ ├── .cursor/ (1) @@ -576,13 +565,13 @@ │ │ │ └── views.test.ts (0ce91fc, 746L, 22141ch, 22887B) │ │ ├── .gitignore (ccb61cd, 40L, 446ch, 446B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (a10ab3e, 142L, 2752ch, 2752B) +│ │ ├── CHANGELOG.md (87f4ccd, 176L, 3302ch, 3302B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) │ │ ├── README.md (c228f5b, 121L, 6833ch, 6869B) │ │ ├── REPORT.md (4f3efcd, 161L, 12957ch, 13004B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) │ │ ├── mssql-problems.md (5083524, 328L, 23834ch, 23934B) -│ │ ├── package.json (0e20903, 23L, 623ch, 623B) +│ │ ├── package.json (c3dd8c3, 23L, 624ch, 624B) │ │ └── tsconfig.json (7be3bae, 27L, 736ch, 736B) │ ├── llm-memory-db-pg/ (17) │ │ ├── .cursor/ (1) @@ -788,13 +777,13 @@ │ │ │ └── mcp-discovery.test.ts (02d4035, 765L, 24559ch, 24603B) │ │ ├── .gitignore (a94396a, 36L, 397ch, 397B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (97e9542, 142L, 2749ch, 2749B) +│ │ ├── CHANGELOG.md (d4f3867, 176L, 3299ch, 3299B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) │ │ ├── README.md (b97f95b, 205L, 10359ch, 10665B) │ │ ├── REPORT-PHASE-1.md (59b7d41, 103L, 9610ch, 9670B) │ │ ├── REPORT.md (98ee180, 140L, 17043ch, 17113B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) -│ │ ├── package.json (987b772, 23L, 662ch, 662B) +│ │ ├── package.json (d287d77, 23L, 663ch, 663B) │ │ ├── postgres-problems.md (1cbb5b5, 200L, 13674ch, 13799B) │ │ └── tsconfig.json (4dc04b1, 30L, 735ch, 735B) │ └── todo-db/ (10) @@ -881,26 +870,26 @@ │ │ │ └── todos_with_details.test.ts (76bc932, 215L, 6840ch, 6842B) │ │ └── preload.ts (0d97cd6, 25L, 658ch, 660B) │ ├── .gitignore (81531bd, 5L, 57ch, 57B) -│ ├── CHANGELOG.md (446f579, 148L, 2809ch, 2809B) +│ ├── CHANGELOG.md (6d291da, 182L, 3359ch, 3359B) │ ├── bunfig.toml (e10e7cb, 5L, 89ch, 89B) -│ ├── package.json (198dc00, 22L, 573ch, 573B) +│ ├── package.json (b655461, 22L, 574ch, 574B) │ └── tsconfig.json (efeae48, 17L, 484ch, 484B) ├── packages/ (2) │ ├── cli/ (7) │ │ ├── scripts/ (1) │ │ │ └── postinstall.js (b82655f, 411L, 12511ch, 12513B) -│ │ ├── CHANGELOG.md (85afed4, 1619L, 114971ch, 115509B) +│ │ ├── CHANGELOG.md (241ef92, 1662L, 121347ch, 121885B) │ │ ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) │ │ ├── NOTICE (d464ce1, 2L, 43ch, 43B) │ │ ├── README.md (5ec4b13, 65L, 1728ch, 1734B) │ │ ├── noorm.js (e3d76e8, 41L, 1041ch, 1043B) -│ │ └── package.json (10704f8, 32L, 563ch, 563B) +│ │ └── package.json (92acc2f, 32L, 563ch, 563B) │ └── sdk/ (5) -│ ├── CHANGELOG.md (35f5c52, 1233L, 80220ch, 80662B) +│ ├── CHANGELOG.md (10fda2e, 1274L, 85899ch, 86341B) │ ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) │ ├── NOTICE (5efbb3e, 2L, 43ch, 43B) -│ ├── README.md (4cd7cfa, 104L, 3525ch, 3541B) -│ └── package.json (9630b93, 62L, 1135ch, 1135B) +│ ├── README.md (e5819f6, 123L, 4344ch, 4360B) +│ └── package.json (a5937f7, 62L, 1135ch, 1135B) ├── scripts/ (5) │ ├── Dockerfile (5fe0d7a, 51L, 1595ch, 1595B) │ ├── build-binary.mjs (3003f29, 41L, 1627ch, 1631B) @@ -909,27 +898,28 @@ │ └── ralph-wiggum.sh (7182e0a, 319L, 8973ch, 8973B) ├── skills/ (1) │ └── noorm/ (2) -│ ├── references/ (4) -│ │ ├── cli.md (df77aa4, 1013L, 29910ch, 29964B) -│ │ ├── config.md (1248d65, 300L, 10761ch, 11471B) -│ │ ├── sdk.md (0722f21, 650L, 22066ch, 22165B) -│ │ └── templates.md (20dbd54, 386L, 11061ch, 11161B) -│ └── SKILL.md (90e425a, 82L, 4768ch, 4784B) +│ ├── references/ (5) +│ │ ├── cli.md (e4aa144, 1013L, 29967ch, 30021B) +│ │ ├── config.md (b1052a9, 300L, 10802ch, 11512B) +│ │ ├── schema-files.md (0c66026, 281L, 13963ch, 14067B) +│ │ ├── sdk.md (e836a48, 658L, 22484ch, 22583B) +│ │ └── templates.md (4d912ea, 385L, 11223ch, 11323B) +│ └── SKILL.md (c1d1b16, 118L, 8928ch, 8966B) ├── src/ (7) │ ├── cli/ (21) │ │ ├── change/ (13) │ │ │ ├── _prompt.ts (a32b90c, 140L, 3465ch, 3467B) -│ │ │ ├── add.ts (4356dc7, 110L, 3023ch, 3025B) -│ │ │ ├── edit.ts (a646168, 130L, 3887ch, 3891B) +│ │ │ ├── add.ts (23a6584, 111L, 3106ch, 3108B) +│ │ │ ├── edit.ts (8fc899f, 131L, 3970ch, 3974B) │ │ │ ├── ff.ts (77ac7a8, 117L, 3399ch, 3401B) │ │ │ ├── history-detail.ts (208c91f, 181L, 5310ch, 5318B) │ │ │ ├── history.ts (f88c567, 52L, 1373ch, 1375B) │ │ │ ├── index.ts (f87649d, 58L, 1518ch, 1522B) -│ │ │ ├── list.ts (a3b8dd7, 55L, 1720ch, 1724B) +│ │ │ ├── list.ts (bfea78e, 55L, 1809ch, 1813B) │ │ │ ├── next.ts (b0ad529, 119L, 3369ch, 3371B) │ │ │ ├── revert.ts (96d0444, 134L, 3795ch, 3797B) │ │ │ ├── rewind.ts (a762072, 143L, 4254ch, 4260B) -│ │ │ ├── rm.ts (d398cab, 158L, 5016ch, 5018B) +│ │ │ ├── rm.ts (16226a2, 159L, 5099ch, 5101B) │ │ │ └── run.ts (4a3a859, 135L, 3794ch, 3796B) │ │ ├── ci/ (4) │ │ │ ├── identity/ (3) @@ -970,7 +960,7 @@ │ │ │ └── truncate.ts (cdd1ce4, 95L, 2681ch, 2683B) │ │ ├── dev/ (3) │ │ │ ├── index.ts (a926fdd, 18L, 420ch, 422B) -│ │ │ ├── test-helpers.ts (f5bfa13, 167L, 5518ch, 5526B) +│ │ │ ├── test-helpers.ts (f2459b7, 167L, 5530ch, 5538B) │ │ │ └── test-workers.ts (96ceb8e, 256L, 7897ch, 7909B) │ │ ├── identity/ (5) │ │ │ ├── edit.ts (21f57dc, 116L, 4027ch, 4033B) @@ -991,13 +981,13 @@ │ │ ├── run/ (9) │ │ │ ├── _render-secrets.ts (621f487, 87L, 3297ch, 3307B) │ │ │ ├── build.ts (5f3aa6f, 147L, 5419ch, 5429B) -│ │ │ ├── dir.ts (a53d65f, 129L, 3882ch, 3884B) -│ │ │ ├── exec.ts (8c39c6c, 199L, 5858ch, 5860B) -│ │ │ ├── file.ts (2aeb103, 80L, 2042ch, 2044B) -│ │ │ ├── files.ts (179b30e, 112L, 3198ch, 3200B) +│ │ │ ├── dir.ts (30dc1a4, 129L, 3885ch, 3887B) +│ │ │ ├── exec.ts (6b7b3cf, 199L, 5870ch, 5872B) +│ │ │ ├── file.ts (d273f0e, 80L, 2045ch, 2047B) +│ │ │ ├── files.ts (041f836, 112L, 3204ch, 3206B) │ │ │ ├── index.ts (5f4eb57, 28L, 591ch, 593B) -│ │ │ ├── inspect.ts (2e10974, 279L, 8148ch, 8152B) -│ │ │ └── preview.ts (b4e99e3, 155L, 5161ch, 5167B) +│ │ │ ├── inspect.ts (c43550e, 279L, 8160ch, 8164B) +│ │ │ └── preview.ts (8aaf9c6, 155L, 5179ch, 5185B) │ │ ├── secret/ (5) │ │ │ ├── _policy.ts (5bc4cc6, 70L, 2180ch, 2182B) │ │ │ ├── index.ts (8648e62, 13L, 339ch, 341B) @@ -1006,7 +996,7 @@ │ │ │ └── set.ts (186acfe, 99L, 2925ch, 2927B) │ │ ├── settings/ (5) │ │ │ ├── build.ts (d3fc961, 56L, 1551ch, 1553B) -│ │ │ ├── edit.ts (cf17b1b, 644L, 17555ch, 17561B) +│ │ │ ├── edit.ts (62a11a7, 645L, 17663ch, 17669B) │ │ │ ├── index.ts (8333365, 17L, 400ch, 402B) │ │ │ ├── init.ts (984af2c, 67L, 1833ch, 1835B) │ │ │ └── secret.ts (4b9a441, 385L, 9930ch, 9934B) @@ -1032,18 +1022,18 @@ │ │ ├── info.ts (fa5e2b1, 384L, 11512ch, 11516B) │ │ ├── init.ts (e04cc7c, 177L, 5303ch, 5305B) │ │ ├── ui.ts (6d369c6, 75L, 2214ch, 2220B) -│ │ ├── update.ts (2233042, 218L, 6633ch, 6641B) +│ │ ├── update.ts (6bff9e4, 381L, 10778ch, 10786B) │ │ └── version.ts (ca59073, 273L, 6880ch, 6890B) │ ├── core/ (30) │ │ ├── change/ (9) -│ │ │ ├── executor.ts (0f32b4f, 1358L, 37264ch, 38748B) +│ │ │ ├── executor.ts (fa7d0ba, 1359L, 37394ch, 38876B) │ │ │ ├── history.ts (b55db34, 1283L, 37958ch, 40068B) │ │ │ ├── index.ts (ec2928e, 132L, 3116ch, 4824B) -│ │ │ ├── manager.ts (05fe831, 653L, 17287ch, 19599B) -│ │ │ ├── parser.ts (24d6396, 576L, 13658ch, 15124B) +│ │ │ ├── manager.ts (5c8de9f, 705L, 19090ch, 21402B) +│ │ │ ├── parser.ts (b4d3270, 660L, 16210ch, 17676B) │ │ │ ├── scaffold.ts (155fb34, 649L, 15641ch, 17593B) │ │ │ ├── tracker.ts (5752ecd, 266L, 8102ch, 8592B) -│ │ │ ├── types.ts (05976cc, 748L, 18242ch, 20940B) +│ │ │ ├── types.ts (0f5a28e, 752L, 18450ch, 21148B) │ │ │ └── validation.ts (ca6f086, 90L, 2247ch, 2247B) │ │ ├── config/ (5) │ │ │ ├── index.ts (3d6e44e, 127L, 3484ch, 3484B) @@ -1161,18 +1151,18 @@ │ │ ├── runner/ (9) │ │ │ ├── checksum.ts (d21f46d, 99L, 2650ch, 2650B) │ │ │ ├── index.ts (c681b6a, 58L, 1118ch, 1118B) -│ │ │ ├── mssql-batches.ts (3a929cd, 180L, 5670ch, 5684B) -│ │ │ ├── runner.ts (9edaa8b, 1405L, 38723ch, 40441B) +│ │ │ ├── mssql-batches.ts (b1dc845, 183L, 5817ch, 5831B) +│ │ │ ├── runner.ts (3b436b1, 1405L, 38729ch, 40447B) │ │ │ ├── sqlite-statements.ts (74e41da, 252L, 6407ch, 6419B) │ │ │ ├── statement-probes.ts (7c20808, 335L, 10843ch, 10843B) │ │ │ ├── statement-watcher.ts (1ff301a, 341L, 10073ch, 10073B) │ │ │ ├── tracker.ts (fa6d03e, 705L, 21804ch, 22034B) │ │ │ └── types.ts (2ed8de2, 424L, 11368ch, 13328B) │ │ ├── settings/ (7) -│ │ │ ├── defaults.ts (fc071d6, 143L, 3677ch, 3679B) +│ │ │ ├── defaults.ts (61dd8a1, 166L, 4347ch, 4349B) │ │ │ ├── events.ts (f8588b1, 108L, 2257ch, 2257B) │ │ │ ├── index.ts (3b06d77, 82L, 1677ch, 1677B) -│ │ │ ├── manager.ts (76e5e37, 1107L, 25307ch, 28003B) +│ │ │ ├── manager.ts (fa91ec1, 1117L, 25766ch, 28462B) │ │ │ ├── rules.ts (52445be, 288L, 6906ch, 6906B) │ │ │ ├── schema.ts (fbb1ab8, 354L, 9982ch, 12304B) │ │ │ └── types.ts (6cbf075, 341L, 8248ch, 8254B) @@ -1240,13 +1230,14 @@ │ │ │ ├── planner.ts (61d919b, 675L, 18612ch, 18618B) │ │ │ ├── same-server.ts (a2f0cd0, 117L, 2996ch, 3000B) │ │ │ └── types.ts (bdbbcbf, 188L, 4630ch, 4632B) -│ │ ├── update/ (8) +│ │ ├── update/ (9) │ │ │ ├── checker.ts (4adba06, 435L, 11470ch, 11474B) │ │ │ ├── checksum.ts (4d40218, 159L, 4902ch, 4918B) │ │ │ ├── global-settings.ts (ced5668, 317L, 7229ch, 7229B) │ │ │ ├── index.ts (1985bc9, 69L, 1446ch, 1446B) │ │ │ ├── install-mode.ts (c3edf10, 173L, 4399ch, 4419B) │ │ │ ├── registry.ts (b789b99, 177L, 4593ch, 4593B) +│ │ │ ├── skill.ts (e79bc23, 107L, 3143ch, 3143B) │ │ │ ├── types.ts (de1581f, 129L, 4183ch, 4183B) │ │ │ └── updater.ts (f2cf88c, 543L, 16238ch, 16270B) │ │ ├── vault/ (9) @@ -1282,7 +1273,7 @@ │ │ ├── environment.ts (08ebb56, 141L, 3015ch, 3017B) │ │ ├── index.ts (9306009, 422L, 9020ch, 9020B) │ │ ├── observer.ts (ecc7e41, 283L, 10724ch, 10726B) -│ │ ├── project-init.ts (3a2c70e, 174L, 5041ch, 5043B) +│ │ ├── project-init.ts (2a72745, 233L, 6829ch, 6831B) │ │ ├── project.ts (18af87e, 255L, 6533ch, 6533B) │ │ └── theme.ts (f8e8f4a, 545L, 12937ch, 14702B) │ ├── mcp/ (3) @@ -1296,25 +1287,25 @@ │ │ │ ├── explore.ts (f24be95, 91L, 3239ch, 3239B) │ │ │ ├── index.ts (1d30237, 30L, 734ch, 734B) │ │ │ ├── query.ts (d55e353, 40L, 1456ch, 1458B) -│ │ │ ├── run.ts (2e5fb40, 57L, 1667ch, 1667B) +│ │ │ ├── run.ts (f3e9668, 57L, 1670ch, 1670B) │ │ │ └── session.ts (4f1ed45, 126L, 4060ch, 4064B) │ │ ├── index.ts (32e718c, 21L, 630ch, 630B) │ │ ├── registry.ts (02a1571, 109L, 2511ch, 2511B) │ │ ├── session.ts (fb06f61, 197L, 5226ch, 5240B) │ │ └── types.ts (2d76387, 78L, 2043ch, 2045B) -│ ├── sdk/ (11) +│ ├── sdk/ (12) │ │ ├── impersonate/ (4) │ │ │ ├── dialect-strategy.ts (51babcc, 101L, 2765ch, 3745B) │ │ │ ├── index.ts (6a19d4e, 7L, 233ch, 233B) -│ │ │ ├── scope.ts (17426e0, 130L, 4210ch, 4456B) -│ │ │ └── types.ts (ace8416, 67L, 2106ch, 2594B) +│ │ │ ├── scope.ts (9cb4e7b, 99L, 3145ch, 3391B) +│ │ │ └── types.ts (2fa09d7, 47L, 1395ch, 1883B) │ │ ├── namespaces/ (11) -│ │ │ ├── changes.ts (d94a868, 485L, 13218ch, 14524B) +│ │ │ ├── changes.ts (43297e6, 486L, 13330ch, 14636B) │ │ │ ├── db.ts (2cab8af, 417L, 11800ch, 12904B) │ │ │ ├── dt.ts (fca374d, 117L, 3169ch, 3627B) │ │ │ ├── index.ts (9ce85c2, 10L, 454ch, 454B) │ │ │ ├── lock.ts (020b4b0, 164L, 4468ch, 4926B) -│ │ │ ├── run.ts (5786d59, 285L, 9262ch, 10362B) +│ │ │ ├── run.ts (c2aa086, 286L, 9364ch, 10464B) │ │ │ ├── secrets.ts (d87f8c8, 113L, 2995ch, 3247B) │ │ │ ├── templates.ts (b92720b, 83L, 2854ch, 3102B) │ │ │ ├── transfer.ts (9c16d56, 94L, 2856ch, 3104B) @@ -1322,14 +1313,15 @@ │ │ │ └── vault.ts (1984452, 445L, 11887ch, 13237B) │ │ ├── stubs/ (1) │ │ │ └── ansis.ts (16243d6, 19L, 488ch, 490B) -│ │ ├── context.ts (261fc98, 684L, 22223ch, 24815B) +│ │ ├── context.ts (6929cb9, 572L, 18457ch, 20797B) │ │ ├── guards.ts (f17c08b, 156L, 4423ch, 4921B) -│ │ ├── index.ts (cb2bbba, 300L, 9189ch, 9927B) +│ │ ├── index.ts (59f1e0d, 302L, 9229ch, 9967B) │ │ ├── noorm-ops.ts (9b88a4d, 178L, 3939ch, 4609B) +│ │ ├── routines.ts (e7e7b5c, 219L, 6758ch, 6758B) │ │ ├── sql.ts (397c9c9, 728L, 19985ch, 21507B) │ │ ├── state.ts (2e875de, 54L, 1779ch, 2267B) │ │ ├── tvp.ts (05b7f0a, 168L, 4695ch, 5433B) -│ │ └── types.ts (5fec5ed, 183L, 4980ch, 5958B) +│ │ └── types.ts (174ce53, 238L, 6846ch, 7824B) │ ├── tui/ (16) │ │ ├── components/ (10) │ │ │ ├── dialogs/ (6) @@ -1411,12 +1403,12 @@ │ │ ├── screens/ (15) │ │ │ ├── change/ (12) │ │ │ │ ├── ChangeAddScreen.tsx (c19a374, 333L, 8506ch, 8506B) -│ │ │ │ ├── ChangeEditScreen.tsx (b2e1c65, 196L, 5033ch, 5033B) -│ │ │ │ ├── ChangeFFScreen.tsx (5d7fc89, 366L, 10448ch, 10450B) +│ │ │ │ ├── ChangeEditScreen.tsx (66306c7, 196L, 5057ch, 5057B) +│ │ │ │ ├── ChangeFFScreen.tsx (2c50b20, 366L, 10468ch, 10470B) │ │ │ │ ├── ChangeHistoryDetailScreen.tsx (1cb3565, 344L, 11390ch, 11402B) │ │ │ │ ├── ChangeHistoryScreen.tsx (f147bb1, 339L, 11843ch, 11857B) -│ │ │ │ ├── ChangeListScreen.tsx (0f03069, 402L, 11851ch, 11859B) -│ │ │ │ ├── ChangeNextScreen.tsx (32df0ba, 421L, 11755ch, 11757B) +│ │ │ │ ├── ChangeListScreen.tsx (0ecb870, 402L, 11871ch, 11879B) +│ │ │ │ ├── ChangeNextScreen.tsx (892281f, 421L, 11775ch, 11777B) │ │ │ │ ├── ChangeRemoveScreen.tsx (f94fbbe, 310L, 8800ch, 8800B) │ │ │ │ ├── ChangeRevertScreen.tsx (576e522, 356L, 10197ch, 10201B) │ │ │ │ ├── ChangeRewindScreen.tsx (0f4f2da, 489L, 13838ch, 13842B) @@ -1461,8 +1453,8 @@ │ │ │ │ └── index.ts (138be43, 8L, 345ch, 345B) │ │ │ ├── init/ (4) │ │ │ │ ├── IdentitySetup.tsx (e3a2323, 198L, 6399ch, 6409B) -│ │ │ │ ├── InitScreen.tsx (59c5fd3, 545L, 17329ch, 17345B) -│ │ │ │ ├── ProjectSetup.tsx (759ed42, 157L, 4834ch, 4852B) +│ │ │ │ ├── InitScreen.tsx (74395b0, 550L, 17604ch, 17620B) +│ │ │ │ ├── ProjectSetup.tsx (9a52a87, 172L, 5692ch, 5714B) │ │ │ │ └── index.ts (14e323d, 12L, 309ch, 309B) │ │ │ ├── lock/ (6) │ │ │ │ ├── LockAcquireScreen.tsx (d971c1b, 348L, 10116ch, 10116B) @@ -1472,12 +1464,12 @@ │ │ │ │ ├── LockStatusScreen.tsx (34ab652, 347L, 9918ch, 9918B) │ │ │ │ └── index.ts (144f483, 27L, 835ch, 835B) │ │ │ ├── run/ (7) -│ │ │ │ ├── RunBuildScreen.tsx (6bef16c, 571L, 20614ch, 20616B) -│ │ │ │ ├── RunDirScreen.tsx (fcb4c49, 798L, 27739ch, 27741B) -│ │ │ │ ├── RunExecScreen.tsx (678b006, 432L, 13623ch, 13623B) -│ │ │ │ ├── RunFileScreen.tsx (6682193, 618L, 19931ch, 19931B) -│ │ │ │ ├── RunInspectScreen.tsx (9d12203, 995L, 33278ch, 33327B) -│ │ │ │ ├── RunListScreen.tsx (fba2854, 264L, 8125ch, 8125B) +│ │ │ │ ├── RunBuildScreen.tsx (2b27c46, 572L, 20696ch, 20698B) +│ │ │ │ ├── RunDirScreen.tsx (c569557, 799L, 27821ch, 27823B) +│ │ │ │ ├── RunExecScreen.tsx (665cbe9, 433L, 13705ch, 13705B) +│ │ │ │ ├── RunFileScreen.tsx (7e602ad, 619L, 20024ch, 20024B) +│ │ │ │ ├── RunInspectScreen.tsx (94e3e3a, 996L, 33371ch, 33420B) +│ │ │ │ ├── RunListScreen.tsx (837673e, 265L, 8207ch, 8207B) │ │ │ │ └── index.ts (019e1cf, 30L, 955ch, 955B) │ │ │ ├── secret/ (4) │ │ │ │ ├── SecretListScreen.tsx (5e39116, 259L, 7803ch, 7803B) @@ -1485,11 +1477,11 @@ │ │ │ │ ├── SecretSetScreen.tsx (b707801, 236L, 6856ch, 6856B) │ │ │ │ └── index.ts (aa62b82, 6L, 217ch, 217B) │ │ │ ├── settings/ (17) -│ │ │ │ ├── SettingsBuildScreen.tsx (15d1862, 154L, 4304ch, 4304B) +│ │ │ │ ├── SettingsBuildScreen.tsx (45fa37e, 154L, 4265ch, 4265B) │ │ │ │ ├── SettingsInitScreen.tsx (04f1f67, 282L, 7506ch, 7506B) -│ │ │ │ ├── SettingsListScreen.tsx (94cad7e, 272L, 7310ch, 7312B) +│ │ │ │ ├── SettingsListScreen.tsx (8c5e61b, 273L, 7421ch, 7423B) │ │ │ │ ├── SettingsLoggingScreen.tsx (402114b, 166L, 4961ch, 4961B) -│ │ │ │ ├── SettingsPathsScreen.tsx (689ca17, 111L, 3138ch, 3138B) +│ │ │ │ ├── SettingsPathsScreen.tsx (2753318, 112L, 3303ch, 3303B) │ │ │ │ ├── SettingsRuleEditScreen.tsx (7f121e4, 242L, 7312ch, 7312B) │ │ │ │ ├── SettingsRulesListScreen.tsx (5759a57, 328L, 8621ch, 8623B) │ │ │ │ ├── SettingsSecretEditScreen.tsx (5d7f46d, 166L, 4733ch, 4733B) @@ -1510,11 +1502,11 @@ │ │ │ │ └── index.ts (f0d3889, 7L, 258ch, 258B) │ │ │ ├── MoreScreen.tsx (8f8530e, 80L, 2503ch, 2503B) │ │ │ ├── UpdateScreen.tsx (1cd89c8, 217L, 6105ch, 6107B) -│ │ │ ├── home.tsx (7f5fdf6, 635L, 21702ch, 21712B) +│ │ │ ├── home.tsx (8a9060a, 643L, 22049ch, 22059B) │ │ │ └── not-found.tsx (135b44e, 72L, 1958ch, 1958B) │ │ ├── utils/ (14) │ │ │ ├── change-context.ts (962b3e2, 78L, 2735ch, 2735B) -│ │ │ ├── change-loader.ts (4ad58de, 252L, 6631ch, 6633B) +│ │ │ ├── change-loader.ts (9c5e607, 270L, 7360ch, 7362B) │ │ │ ├── clipboard.ts (93a7f8c, 95L, 1991ch, 1991B) │ │ │ ├── config-validation.ts (425dfc6, 218L, 5936ch, 5944B) │ │ │ ├── connection.ts (6391d55, 92L, 2475ch, 2477B) @@ -1522,7 +1514,7 @@ │ │ │ ├── error.ts (97c198b, 33L, 736ch, 736B) │ │ │ ├── identity.ts (a44606a, 32L, 870ch, 870B) │ │ │ ├── index.ts (99b6143, 31L, 1144ch, 1144B) -│ │ │ ├── paths.ts (ee0cf63, 53L, 1329ch, 1329B) +│ │ │ ├── paths.ts (8388434, 54L, 1469ch, 1469B) │ │ │ ├── progress.ts (3025949, 16L, 474ch, 476B) │ │ │ ├── run-context.ts (18ec692, 116L, 3785ch, 3787B) │ │ │ ├── settings-validation.ts (b155b66, 49L, 1148ch, 1152B) @@ -1542,7 +1534,7 @@ │ ├── compute.ts (c7b828a, 46L, 1233ch, 1233B) │ └── connection.ts (128339f, 241L, 5955ch, 5957B) ├── tests/ (11) -│ ├── cli/ (43) +│ ├── cli/ (44) │ │ ├── change/ (4) │ │ │ ├── history.test.ts (e6ab24d, 140L, 4580ch, 4586B) │ │ │ ├── list.test.ts (07f0afa, 158L, 5380ch, 5386B) @@ -1601,7 +1593,7 @@ │ │ │ ├── force.test.ts (ab54903, 180L, 5287ch, 5291B) │ │ │ └── status.test.ts (adb4506, 130L, 3990ch, 3996B) │ │ ├── run/ (13) -│ │ │ ├── _setup.ts (35ce510, 167L, 4749ch, 4753B) +│ │ │ ├── _setup.ts (413589e, 167L, 4746ch, 4750B) │ │ │ ├── build-unmatched-exclude.test.ts (1563076, 109L, 3256ch, 3262B) │ │ │ ├── build.test.ts (3c114ef, 361L, 10657ch, 10671B) │ │ │ ├── change-ff-dryrun.test.ts (963e4be, 197L, 5697ch, 5701B) @@ -1617,7 +1609,7 @@ │ │ ├── screens/ (7) │ │ │ ├── change/ (2) │ │ │ │ ├── change-add-mouse.test.tsx (4ab6bd4, 252L, 8298ch, 8308B) -│ │ │ │ └── change-dry-run.test.tsx (c36431c, 293L, 10103ch, 10105B) +│ │ │ │ └── change-dry-run.test.tsx (04b2185, 293L, 10115ch, 10117B) │ │ │ ├── config/ (2) │ │ │ │ ├── ConfigEditScreen.test.tsx (38085f2, 380L, 13339ch, 13347B) │ │ │ │ └── ConfigRemoveScreen.test.tsx (72c0c1a, 166L, 5412ch, 5412B) @@ -1634,9 +1626,9 @@ │ │ │ │ └── sql-row-view.test.tsx (075c380, 370L, 12256ch, 12274B) │ │ │ ├── init/ (4) │ │ │ │ ├── identity-setup.test.tsx (4db4baf, 188L, 4585ch, 4585B) -│ │ │ │ ├── init-flow.test.tsx (e1c7731, 389L, 10596ch, 10596B) -│ │ │ │ ├── init-screen.test.tsx (d0a5faa, 246L, 6828ch, 6828B) -│ │ │ │ └── project-setup.test.tsx (f77732f, 260L, 7367ch, 7373B) +│ │ │ │ ├── init-flow.test.tsx (10aee4b, 390L, 10651ch, 10651B) +│ │ │ │ ├── init-screen.test.tsx (86ca614, 247L, 6878ch, 6878B) +│ │ │ │ └── project-setup.test.tsx (2743196, 273L, 7772ch, 7778B) │ │ │ ├── lock/ (1) │ │ │ │ └── lock-force-cancel.test.tsx (9a3f9ba, 226L, 7542ch, 7544B) │ │ │ ├── run/ (1) @@ -1645,10 +1637,11 @@ │ │ │ └── rules-list.test.tsx (2c946de, 61L, 2366ch, 2366B) │ │ ├── sql/ (1) │ │ │ └── history-config.test.ts (9960369, 213L, 7053ch, 7065B) -│ │ ├── utils/ (2) -│ │ │ ├── change-context.test.ts (9740ac3, 234L, 7678ch, 7678B) +│ │ ├── utils/ (3) +│ │ │ ├── change-context.test.ts (686b772, 234L, 7690ch, 7690B) +│ │ │ ├── change-loader.test.ts (c742719, 83L, 2652ch, 2652B) │ │ │ └── progress.test.ts (d066ec5, 21L, 580ch, 580B) -│ │ ├── VaultScreen.test.tsx (06317d5, 280L, 8956ch, 8958B) +│ │ ├── VaultScreen.test.tsx (99cb01f, 290L, 9298ch, 9300B) │ │ ├── agent-channel-escalation.test.ts (c445315, 286L, 8469ch, 8477B) │ │ ├── app-context.test.tsx (9ac4dfd, 639L, 17664ch, 17670B) │ │ ├── app.test.tsx (de90d01, 443L, 13008ch, 13025B) @@ -1677,6 +1670,7 @@ │ │ ├── settings-validation.test.ts (d64e9b7, 34L, 903ch, 903B) │ │ ├── sql-repl.test.ts (661771f, 32L, 864ch, 864B) │ │ ├── types.test.ts (c9a86fb, 136L, 4786ch, 4786B) +│ │ ├── update.test.ts (c8d7ebe, 191L, 5678ch, 5690B) │ │ └── yes-flag.test.ts (7eef63b, 476L, 12490ch, 12492B) │ ├── core/ (30) │ │ ├── change/ (10) @@ -1687,7 +1681,7 @@ │ │ │ ├── executor-retry.test.ts (0056899, 279L, 10531ch, 10541B) │ │ │ ├── executor.test.ts (59f53c9, 388L, 14130ch, 14134B) │ │ │ ├── history.test.ts (9b7b5dd, 202L, 6278ch, 6282B) -│ │ │ ├── manager.test.ts (3ab8020, 704L, 25970ch, 25980B) +│ │ │ ├── manager.test.ts (7019ac6, 879L, 32882ch, 32892B) │ │ │ ├── parser.test.ts (a2896bd, 397L, 12524ch, 12526B) │ │ │ ├── prefetch-gate.test.ts (ac565b0, 324L, 11161ch, 11161B) │ │ │ ├── scaffold.test.ts (011896d, 468L, 15139ch, 15139B) @@ -1818,7 +1812,7 @@ │ │ │ └── leakage.test.ts (e06aa00, 174L, 5317ch, 5321B) │ │ ├── settings/ (5) │ │ │ ├── env-override.test.ts (70e079d, 240L, 7571ch, 7573B) -│ │ │ ├── manager.test.ts (f898974, 1173L, 26570ch, 26570B) +│ │ │ ├── manager.test.ts (91e8412, 1246L, 28467ch, 28467B) │ │ │ ├── rules.test.ts (dfc4b91, 416L, 13289ch, 13289B) │ │ │ ├── schema.test.ts (29ab05e, 592L, 15455ch, 15455B) │ │ │ └── setTeardown.test.ts (c2d7f2b, 84L, 2603ch, 2603B) @@ -1877,11 +1871,12 @@ │ │ │ ├── planner.test.ts (a81c2b6, 341L, 10240ch, 10240B) │ │ │ ├── policy-gate.test.ts (766a89d, 89L, 3733ch, 3739B) │ │ │ └── same-server.test.ts (8a0a6f0, 363L, 10660ch, 10662B) -│ │ ├── update/ (6) +│ │ ├── update/ (7) │ │ │ ├── checker.test.ts (4db209f, 191L, 5313ch, 5313B) │ │ │ ├── checksum.test.ts (ef4d8bd, 264L, 7720ch, 7732B) │ │ │ ├── global-settings.test.ts (1190fee, 231L, 6358ch, 6358B) │ │ │ ├── registry.test.ts (6f2fad2, 214L, 5987ch, 5987B) +│ │ │ ├── skill.test.ts (17fd586, 184L, 5875ch, 5875B) │ │ │ ├── updater.test.ts (a8edc3f, 324L, 10516ch, 10536B) │ │ │ └── version-validation.test.ts (1688271, 120L, 3912ch, 3912B) │ │ ├── vault/ (6) @@ -1903,7 +1898,7 @@ │ │ │ ├── pending-set.test.ts (9dc36b2, 100L, 2238ch, 2240B) │ │ │ └── pool.test.ts (f2754a9, 64L, 1798ch, 1798B) │ │ ├── environment.test.ts (958bc19, 142L, 3767ch, 3769B) -│ │ ├── project-init.test.ts (4437e25, 123L, 4046ch, 4050B) +│ │ ├── project-init.test.ts (cfb605a, 271L, 9590ch, 9594B) │ │ └── project.test.ts (cd6cc41, 243L, 6575ch, 6575B) │ ├── fixtures/ (3) │ │ ├── ci/ (2) @@ -1949,14 +1944,15 @@ │ │ ├── echo.ts (e2e3fe3, 12L, 413ch, 413B) │ │ └── silent.ts (77e9168, 7L, 276ch, 278B) │ ├── integration/ (12) -│ │ ├── change/ (3) +│ │ ├── change/ (4) │ │ │ ├── history-dialects.test.ts (7f9a70e, 244L, 9432ch, 9438B) +│ │ │ ├── mssql-batches.test.ts (072da37, 174L, 5882ch, 5884B) │ │ │ ├── mysql-lifecycle.test.ts (b8c508f, 178L, 5956ch, 5960B) │ │ │ └── postgres-transaction.test.ts (abb5d62, 285L, 9557ch, 9567B) │ │ ├── cli/ (3) │ │ │ ├── db.test.ts (87a6c15, 447L, 12079ch, 13545B) │ │ │ ├── lock.test.ts (8ea85ed, 303L, 8252ch, 9472B) -│ │ │ └── setup.ts (9e503fb, 450L, 11325ch, 12301B) +│ │ │ └── setup.ts (d08ee78, 450L, 11322ch, 12298B) │ │ ├── connection/ (4) │ │ │ ├── connection-errors.test.ts (9100e35, 498L, 17702ch, 17702B) │ │ │ ├── mssql-login.test.ts (dfa25cc, 165L, 5636ch, 5636B) @@ -1980,10 +1976,11 @@ │ │ │ ├── mssql-batches.test.ts (b60b9e8, 268L, 8063ch, 8065B) │ │ │ ├── statement-watcher.test.ts (388cdfd, 327L, 10559ch, 10559B) │ │ │ └── tracker-dialects.test.ts (8952118, 219L, 8394ch, 8404B) -│ │ ├── sdk/ (8) +│ │ ├── sdk/ (9) │ │ │ ├── db-reset.test.ts (ddfaa6e, 119L, 3933ch, 3939B) │ │ │ ├── dt-namespace.test.ts (3dee923, 146L, 6011ch, 6513B) │ │ │ ├── run-vault-secrets.test.ts (14d3073, 296L, 10407ch, 10905B) +│ │ │ ├── transaction-routines.test.ts (322098c, 278L, 8345ch, 8345B) │ │ │ ├── transfer-namespace.test.ts (cfe27cd, 180L, 6121ch, 6867B) │ │ │ ├── tvf.test.ts (730359b, 279L, 7502ch, 8234B) │ │ │ ├── tvp.test.ts (61c50ec, 599L, 17433ch, 19149B) @@ -2009,7 +2006,7 @@ │ │ │ └── postgres.test.ts (449c11f, 371L, 12407ch, 12409B) │ │ └── version/ (1) │ │ └── schema.test.ts (e1c16d7, 727L, 22493ch, 23469B) -│ ├── sdk/ (16) +│ ├── sdk/ (17) │ │ ├── impersonate/ (3) │ │ │ ├── dialect-strategy.test.ts (d630381, 146L, 3515ch, 4491B) │ │ │ ├── impersonate.test.ts (9c11613, 297L, 7629ch, 8605B) @@ -2023,7 +2020,8 @@ │ │ ├── lifecycle.test.ts (371af51, 126L, 3617ch, 4353B) │ │ ├── noorm-ops.test.ts (9a800e1, 345L, 9648ch, 11064B) │ │ ├── render-vault-tier.test.ts (df82adb, 142L, 4738ch, 5230B) -│ │ ├── run-build-filtering.test.ts (39d1539, 408L, 14330ch, 14846B) +│ │ ├── routines.test.ts (c90effc, 397L, 12903ch, 12905B) +│ │ ├── run-build-filtering.test.ts (ebc1c96, 408L, 14378ch, 14894B) │ │ ├── sql.test.ts (959c16c, 1035L, 32675ch, 34387B) │ │ ├── templates-policy.test.ts (7d94491, 68L, 2329ch, 2333B) │ │ ├── transfer-dt-namespace.test.ts (fb55bb4, 120L, 3861ch, 4351B) @@ -2045,11 +2043,11 @@ ├── .npmrc (60376c8, 1L, 36ch, 36B) ├── .prettierignore (e3b0c44, 0L, 0ch, 0B) ├── .signalsignore (b0287a5, 17L, 662ch, 674B) -├── CLAUDE.md (8b0f96e, 211L, 12003ch, 12031B) +├── CLAUDE.md (c796fe3, 216L, 12820ch, 12848B) ├── CNAME (f3bed50, 1L, 9ch, 9B) ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) ├── NOTICE (d698d9d, 2L, 35ch, 35B) -├── README.md (c149ae7, 124L, 4991ch, 5012B) +├── README.md (f1b3e72, 124L, 4997ch, 5018B) ├── TODO.md (afd3ad2, 284L, 17713ch, 17967B) ├── bun.lockb (5c93336, 462L, 267003ch, 269072B) ├── bunfig.toml (dab752d, 5L, 89ch, 89B) @@ -2067,22 +2065,22 @@ ## Manifests - docs/package.json: name=@noormdev/docs, scripts=[build, dev, preview] -- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.9, scripts=[test, test:watch, typecheck] -- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.9, scripts=[test, test:watch, typecheck] -- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.9, scripts=[test, test:watch, typecheck] +- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.13, scripts=[test, test:watch, typecheck] +- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.13, scripts=[test, test:watch, typecheck] +- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.13, scripts=[test, test:watch, typecheck] - package.json: name=@noormdev/main, version=0.0.1, scripts=[build, build:binary, build:packages, changeset, clean, dev, lint, lint:docs, lint:fix, prepublishOnly, release, start, test, test:coverage, test:watch, typecheck, typecheck:tests, version] -- packages/cli/package.json: name=@noormdev/cli, version=1.4.2, scripts=[postinstall] -- packages/sdk/package.json: name=@noormdev/sdk, version=1.4.2 +- packages/cli/package.json: name=@noormdev/cli, version=1.6.1, scripts=[postinstall] +- packages/sdk/package.json: name=@noormdev/sdk, version=1.6.1 ## Languages -- TypeScript: 264974 LOC (81%), 1100 files (77%) -- Markdown: 52178 LOC (16%), 206 files (14%) -- HTML: 2977 LOC (0%), 30 files (2%) +- TypeScript: 267489 LOC (82%), 1109 files (78%) +- Markdown: 48419 LOC (14%), 195 files (13%) +- HTML: 2370 LOC (0%), 29 files (2%) +- CSS: 1697 LOC (0%), 4 files (0%) - JavaScript: 1265 LOC (0%), 22 files (1%) -- YAML: 1186 LOC (0%), 19 files (1%) -- CSS: 1103 LOC (0%), 3 files (0%) -- Shell: 952 LOC (0%), 7 files (0%) +- YAML: 1165 LOC (0%), 17 files (1%) +- Shell: 962 LOC (0%), 8 files (0%) - JSON: 473 LOC (0%), 22 files (1%) -- Vue: 205 LOC (0%), 3 files (0%) +- Vue: 65 LOC (0%), 1 file (0%) - TOML: 11 LOC (0%), 3 files (0%) diff --git a/docs/wiki/sdk.md b/docs/wiki/sdk.md index ccabdcc4..34010b50 100644 --- a/docs/wiki/sdk.md +++ b/docs/wiki/sdk.md @@ -20,7 +20,8 @@ The DT (Data Transfer) module under [`src/core/dt/`](../../src/core/dt) is a sep ## CLI code - [`src/sdk/index.ts`](../../src/sdk/index.ts) — `createContext` factory; resolves identity/state/settings/config, runs `checkRequireTest`, defaults `options.channel` to `'user'`, re-exports the full public type/error surface -- [`src/sdk/context.ts`](../../src/sdk/context.ts) — `Context` class: `kysely`, `noorm` (lazy `NoormOps`), `connect`/`disconnect`, `transaction`, `proc`/`func`/`tvf`, `impersonate` (callback and explicit modes), `withSchema` (derives a schema-scoped `Context`); module-level `validateSchemaName` (`/^[a-zA-Z0-9_-]+$/`) and `qualifyName` (prefixes `proc`/`func`/`tvf` names with `${schema}.`) +- [`src/sdk/context.ts`](../../src/sdk/context.ts) — `Context` class: `kysely`, `noorm` (lazy `NoormOps`), `connect`/`disconnect`, `transaction`, `proc`/`func`/`tvf`, `impersonate` (callback and explicit modes), `withSchema` (derives a schema-scoped `Context`); module-level `validateSchemaName` (`/^[a-zA-Z0-9_-]+$/`). `transaction(fn)` hands `fn` a `TransactionScope` (Kysely `Transaction` plus `proc`/`func`/`tvf` on the transaction's connection), built by `withRoutines` +- [`src/sdk/routines.ts`](../../src/sdk/routines.ts) — routine-call layer shared by `Context` and `TransactionScope`: `qualifyName` (prefixes `proc`/`func`/`tvf` names with `${schema}.`), `callProc`/`callFunc`/`callTvf`, `routineCalls`, `withRoutines`, and the module-private `isFunctionNotProcedureError` - [`src/sdk/state.ts`](../../src/sdk/state.ts) — `ContextState` interface (shared mutable state between `Context` and `NoormOps`) and `requireConnection` guard - [`src/sdk/noorm-ops.ts`](../../src/sdk/noorm-ops.ts) — `NoormOps`; lazy per-namespace getters, wires `db.reset` to `run.build` - [`src/sdk/guards.ts`](../../src/sdk/guards.ts) — `checkRequireTest` (throws `RequireTestError` when `requireTest: true` and `config.isTest` is false); `checkProtectedConfig` (calls `checkConfigPolicy` from `core/policy`, throws `ProtectedConfigError` on denial or on an unconfirmed `confirm` cell — the SDK has no interactive prompt) @@ -72,7 +73,7 @@ The DT (Data Transfer) module under [`src/core/dt/`](../../src/core/dt) is a sep - [`src/core/dt/`](../../src/core/dt) is co-owned: the SDK exposes it via `ctx.noorm.dt`, and it is also used directly by the transfer executor for cross-dialect streaming (`DtStreamer`). - [`src/core/dt/index.ts`](../../src/core/dt/index.ts) dispatches serialize/deserialize work to [`src/workers/compute.ts`](../../src/workers/compute.ts) via `WorkerBridge`/`WorkerPool` (`worker-bridge` domain) — a worker-bridge protocol change affects DT export/import. - Impersonation ([`src/sdk/impersonate/`](../../src/sdk/impersonate)) requires a live connection borrowed from the pool; `Context.disconnect()` drains any un-reverted explicit-mode scopes via `#heldConnections` before destroying the pool. -- `Context.proc()` on PostgreSQL retries a `CALL`-against-FUNCTION failure (SQLSTATE 42809/42883) as `SELECT * FROM (...)` — see `isFunctionNotProcedureError` in [`src/sdk/context.ts`](../../src/sdk/context.ts). +- `Context.proc()` on PostgreSQL retries a `CALL`-against-FUNCTION failure (SQLSTATE 42809/42883) as `SELECT * FROM (...)` — see `isFunctionNotProcedureError` in [`src/sdk/routines.ts`](../../src/sdk/routines.ts). - Published package build: [`tsup.sdk.config.ts`](../../tsup.sdk.config.ts) bundles everything except peer deps (`kysely`, `better-sqlite3`, `pg`, `mysql2`, `tedious`, `tarn`) and aliases `ansis` to the stub; [`scripts/build.mjs`](../../scripts/build.mjs) runs `dts-bundle-generator` against [`src/sdk/index.ts`](../../src/sdk/index.ts) for [`packages/sdk/dist/index.d.ts`](../../packages/sdk/dist/index.d.ts). - [`tests/sdk/bundle-smoke.test.ts`](../../tests/sdk/bundle-smoke.test.ts) and [`tests/sdk/dts-surface.test.ts`](../../tests/sdk/dts-surface.test.ts) import the built [`packages/sdk/dist/`](../../packages/sdk/dist) output directly (skipped when `dist` doesn't exist) — they catch bundling regressions the source-level tests can't see. @@ -93,6 +94,6 @@ The DT (Data Transfer) module under [`src/core/dt/`](../../src/core/dt) is a sep - `validateSchemaName` (module-level in [`src/sdk/context.ts`](../../src/sdk/context.ts)) throws synchronously before any connection is borrowed or shared state touched, same allow-list posture as `impersonate`'s `validateUsername`, but hyphens are allowed (Kysely's `withSchema()`/`quoteIdent` both quote the identifier) and dots are rejected (a dot would be mis-split by `quoteIdent`'s qualification logic). - The `kysely` getter re-derives `db.withSchema(this.#schema)` on every access rather than caching the wrapped instance — caching would let a stale schema wrap survive a reconnect, and would make chained `withSchema()` calls stack instead of replace. - `qualifyName(schema, name)` prefixes `${schema}.` onto the name passed to `proc()`/`func()`/`tvf()`, unless the name already contains a [`.`](../..) (caller-supplied explicit qualification) or no schema is set on the context; raw [``](../..) sql`…` [``](../..) tagged fragments are not rewritten by `withSchema` and resolve against the connection's default schema regardless. -- `transaction()` and `impersonate()` called on a schema-derived context compose with that schema, since both use `this.kysely` internally, which is schema-derived. +- `transaction()` (including the routines on its `TransactionScope`) and `impersonate()` called on a schema-derived context compose with that schema, since both use `this.kysely` internally, which is schema-derived. - `withSchema` has no config/connection-level counterpart (the connection itself stays schema-agnostic), does not schema-default `ctx.noorm.db.describe*`'s `schema?` argument, and applies no per-dialect gating — the qualifier means whatever the dialect makes of it (a schema on postgres/mssql, a database on mysql, an ATTACHed database on sqlite). - Test coverage: [`tests/sdk/`](../../tests/sdk) covers namespace behavior per access role (admin/operator/viewer configs), guard errors, SQL builders, and impersonation, plus [`tests/sdk/with-schema.test.ts`](../../tests/sdk/with-schema.test.ts) (schema-name validation, `kysely`-getter re-derivation/replace-not-stack/no-caching, `proc`/`func`/`tvf` name prefixing, and shared `#heldConnections` across a derived context and its parent); [`tests/integration/sdk/`](../../tests/integration/sdk) covers TVF/TVP against live MSSQL/PostgreSQL and vault/db-reset round-trips, plus [`tests/integration/sdk/with-schema.test.ts`](../../tests/integration/sdk/with-schema.test.ts) (a three-schema fixture per dialect — schemas on postgres/mssql, databases on mysql, ATTACHed databases on sqlite — proving interleaved-write isolation, `transaction()`/`impersonate()` composition on a derived context, and held-connection release via a derived context's `impersonate()`). diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a80d0b44..42f32a5e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -70,6 +70,25 @@ await ctx.proc('refresh_cache'); `createContext` takes a map per routine kind. Plain `void` is shorthand for "no arguments, no meaningful return". +The `ctx.transaction(fn)` handle carries the same typed `proc`, `func`, and `tvf`, running on the transaction's connection, so a routine call sees transaction-scoped settings and rolls back with the transaction: + +```typescript +import { sql } from 'kysely'; + +type Tvfs = { + validate_session: [{ session_key: string }, Session]; +}; + +const ctx = await createContext({ config: 'dev' }); + +const sessions = await ctx.transaction(async (trx) => { + await sql`SET LOCAL statement_timeout = '8s'`.execute(trx); // postgres + return trx.tvf('validate_session', { session_key: 'abc123' }); // Session[] +}); +``` + +The handle is typed `TransactionScope` (`Transaction & RoutineCalls`). Instances derived from it, such as `trx.withSchema(...)`, are plain Kysely and have no routine methods. + ## Schema scoping diff --git a/skills/noorm/references/sdk.md b/skills/noorm/references/sdk.md index ee0d06cb..b8eec605 100644 --- a/skills/noorm/references/sdk.md +++ b/skills/noorm/references/sdk.md @@ -116,8 +116,16 @@ await ctx.transaction(async (trx) => { .execute(); // Commits on success, rolls back on throw }); + +// The handle also has typed proc/func/tvf on the transaction's connection +const sessions = await ctx.transaction(async (trx) => { + await sql`SET LOCAL statement_timeout = '8s'`.execute(trx); // postgres only + return trx.tvf('validate_session', { session_key: 'abc123' }); +}); // Session[] ``` +`trx.withSchema(...)` and other instances derived from `trx` have no `proc`/`func`/`tvf`. + ### Stored Procedures Type-safe when `Procs` generic is provided. Return type is inferred from the `[Args, Return]` tuple. Dialect-specific SQL is generated automatically. @@ -245,10 +253,10 @@ Return multiple rows. Same tuple-based typing as procs. Supported on **MSSQL and ```typescript // Return type inferred from Tvfs tuple -const sessions = await ctx.tvf('validate_session', { session_key: key }); // → Session[] +const sessions = await ctx.tvf('validate_session', { session_key: 'abc123' }); // → Session[] // Explicit return type override -const sessions = await ctx.tvf<'validate_session', ExtendedSession>('validate_session', { session_key: key }); +const sessions = await ctx.tvf<'validate_session', ExtendedSession>('validate_session', { session_key: 'abc123' }); // No params const items = await ctx.tvf('get_active_items'); @@ -502,7 +510,7 @@ await ctx.connect(); // Return types are inferred from the tuple: const users = await ctx.proc('get_active_users', { department_id: 5 }); // User[] const total = await ctx.func('calc_total', { order_id: 1 }, 'total'); // CalcResult -const sessions = await ctx.tvf('validate_session', { session_key: key }); // Session[] +const sessions = await ctx.tvf('validate_session', { session_key: 'abc123' }); // Session[] // Override when you need a different return type: const special = await ctx.proc<'get_active_users', SpecialUser>('get_active_users', { department_id: 5 }); diff --git a/src/sdk/context.ts b/src/sdk/context.ts index f1970552..6478d8e7 100644 --- a/src/sdk/context.ts +++ b/src/sdk/context.ts @@ -5,9 +5,8 @@ * database. Noorm-specific operations (schema, changes, locks, etc.) * live behind the ctx.noorm namespace via NoormOps. */ -import type { Kysely, Transaction } from 'kysely'; +import type { Kysely } from 'kysely'; import { sql } from 'kysely'; -import { attempt } from '@logosdx/utils'; import type { Dialect } from '../core/connection/index.js'; import type { Config } from '../core/config/types.js'; @@ -16,11 +15,11 @@ import type { Identity } from '../core/identity/index.js'; import { createConnection } from '../core/connection/index.js'; import type { ConnectionRetryOptions } from '../core/connection/index.js'; -import { buildProcCall, buildFuncCall, buildTvfCall } from './sql.js'; +import { callProc, callFunc, callTvf, qualifyName, withRoutines } from './routines.js'; import { NoormOps } from './noorm-ops.js'; import type { ContextState } from './state.js'; import { requireConnection } from './state.js'; -import type { CreateContextOptions, ExtractArgs, ExtractReturn } from './types.js'; +import type { CreateContextOptions, ExtractArgs, ExtractReturn, RoutineCalls, TransactionScope } from './types.js'; import { dialectStrategy, validateUsername } from './impersonate/dialect-strategy.js'; import { buildScope } from './impersonate/scope.js'; import { ImpersonationError } from './impersonate/types.js'; @@ -57,21 +56,6 @@ function validateSchemaName(name: string): void { } -/** - * Prefix `name` with `${schema}.` for routine calls, unless `name` already - * contains a `.` (caller supplied an explicit qualification) or no schema - * is set on this context. The qualified name is handed unchanged to - * buildProcCall/buildFuncCall/buildTvfCall, which split on `.` and quote - * each segment per dialect. - */ -function qualifyName(schema: string | null, name: string): string { - - if (!schema || name.includes('.')) return name; - - return `${schema}.${name}`; - -} - // ───────────────────────────────────────────────────────────── // Context Class // ───────────────────────────────────────────────────────────── @@ -100,7 +84,7 @@ function qualifyName(schema: string | null, name: string): string { * await ctx.disconnect() * ``` */ -export class Context { +export class Context implements RoutineCalls { #state: ContextState; #noorm: NoormOps | null = null; @@ -295,7 +279,8 @@ export class Context(fn: (trx: Transaction) => Promise): Promise { + async transaction(fn: (trx: TransactionScope) => Promise): Promise { - return this.kysely.transaction().execute(fn); + return this.kysely.transaction().execute((trx) => fn(withRoutines(trx, this.dialect, this.#schema))); } @@ -353,61 +344,7 @@ export class Context extends void ? [] : [params: ExtractArgs] ): Promise { - const params = args[0] as Record | unknown[] | undefined; - const qualifiedName = qualifyName(this.#schema, name); - - if (this.dialect === 'postgres') { - - return this.#executeProcPostgres(qualifiedName, params); - - } - - const query = buildProcCall(this.dialect, qualifiedName, params); - const result = await query.execute(this.kysely); - - return (result.rows ?? []) as T[]; - - } - - /** - * Execute a stored procedure on PostgreSQL with FUNCTION fallback. - * - * PostgreSQL distinguishes between PROCEDURE and FUNCTION objects: - * `CALL` only works against true procedures and raises SQLSTATE 42809 - * (`(...) is not a procedure`) when the target is a function. - * - * The noorm playbook tells schema authors to use `CREATE PROCEDURE` - * for void-returning ops and `CREATE FUNCTION` for row-returning ops, - * and `ctx.proc()` is expected to handle both. So if `CALL` fails - * with the wrong-object-type error, we retry with - * `SELECT * FROM (...)`, which works for both scalar and - * set-returning PG functions. - */ - async #executeProcPostgres( - name: string, - params: Record | unknown[] | undefined, - ): Promise { - - const callQuery = buildProcCall('postgres', name, params); - const [callResult, callErr] = await attempt(() => callQuery.execute(this.kysely)); - - if (!callErr) { - - return (callResult.rows ?? []) as T[]; - - } - - if (!isFunctionNotProcedureError(callErr)) { - - throw callErr; - - } - - // Object is a FUNCTION, not a PROCEDURE — retry as SELECT * FROM (...). - const tvfQuery = buildTvfCall('postgres', name, params); - const tvfResult = await tvfQuery.execute(this.kysely); - - return (tvfResult.rows ?? []) as T[]; + return callProc(() => this.kysely, this.dialect, qualifyName(this.#schema, name), args); } @@ -437,15 +374,7 @@ export class Context extends void ? [column: string] : [params: ExtractArgs, column: string] ): Promise { - // Extract params and column from rest args - const hasParams = !(args.length === 1 && typeof args[0] === 'string'); - const params = hasParams ? args[0] as Record | unknown[] : undefined; - const column = (hasParams ? args[1] : args[0]) as string; - - const query = buildFuncCall(this.dialect, qualifyName(this.#schema, name), column, params); - const result = await query.execute(this.kysely); - - return (result.rows?.[0] ?? null) as T; + return callFunc(() => this.kysely, this.dialect, qualifyName(this.#schema, name), args); } @@ -480,11 +409,7 @@ export class Context extends void ? [] : [params: ExtractArgs] ): Promise { - const params = args[0] as Record | unknown[] | undefined; - const query = buildTvfCall(this.dialect, qualifyName(this.#schema, name), params); - const result = await query.execute(this.kysely); - - return (result.rows ?? []) as T[]; + return callTvf(() => this.kysely, this.dialect, qualifyName(this.#schema, name), args); } @@ -645,40 +570,3 @@ export class Context(...)`. PostgreSQL surfaces this in two shapes: - * - * - SQLSTATE 42809 (`wrong_object_type`) with message - * `(...) is not a procedure` — when an exact match exists but - * is the wrong kind of object. - * - SQLSTATE 42883 (`undefined_function`) with message - * `procedure (...) does not exist` and hint - * `To call a function, use SELECT.` — when the procedure-search - * fails outright but a function with the same signature exists. - * - * Both indicate "this is a FUNCTION, not a PROCEDURE" — retry as a - * function call. - */ -function isFunctionNotProcedureError(err: unknown): boolean { - - if (!err || typeof err !== 'object') return false; - - // cast-justified: pg driver error has no fixed type - const obj = err as { code?: string; message?: string; hint?: string }; - - if (obj.code !== '42809' && obj.code !== '42883') return false; - - const msg = String(obj.message ?? ''); - const hint = String(obj.hint ?? ''); - - return msg.includes('is not a procedure') - || msg.includes('procedure') - || hint.includes('use SELECT'); - -} diff --git a/src/sdk/impersonate/scope.ts b/src/sdk/impersonate/scope.ts index e8a724e8..8da7c1d0 100644 --- a/src/sdk/impersonate/scope.ts +++ b/src/sdk/impersonate/scope.ts @@ -6,12 +6,12 @@ * callback is provided by the caller (Context.impersonate). */ import { attempt } from '@logosdx/utils'; -import { sql } from 'kysely'; -import type { Kysely, Transaction } from 'kysely'; +import type { Kysely } from 'kysely'; import type { Dialect } from '../../core/connection/types.js'; -import { buildProcCall, buildFuncCall, buildTvfCall } from '../sql.js'; +import { routineCalls, withRoutines } from '../routines.js'; +import type { TransactionScope } from '../types.js'; import type { ImpersonatedScope } from './types.js'; // ───────────────────────────────────────────────────────────── @@ -55,42 +55,11 @@ export function buildScope(name: string, ...args: unknown[]): Promise { + ...routineCalls(() => db, dialect, null), - const params = args[0] as Record | unknown[] | undefined; - const query = buildProcCall(dialect, name, params); - const result = await query.execute(db); + async transaction(fn: (trx: TransactionScope) => Promise): Promise { - return result.rows ?? []; - - }, - - async func(name: string, ...args: unknown[]): Promise { - - const hasParams = !(args.length === 1 && typeof args[0] === 'string'); - const params = hasParams ? args[0] as Record | unknown[] : undefined; - const column = (hasParams ? args[1] : args[0]) as string; - - const query = buildFuncCall(dialect, name, column, params); - const result = await query.execute(db); - - return (result.rows?.[0] ?? null) as T; - - }, - - async tvf(name: string, ...args: unknown[]): Promise { - - const params = args[0] as Record | unknown[] | undefined; - const query = buildTvfCall(dialect, name, params); - const result = await query.execute(db); - - return result.rows ?? []; - - }, - - async transaction(fn: (trx: Transaction) => Promise): Promise { - - return db.transaction().execute(fn); + return db.transaction().execute((trx) => fn(withRoutines(trx, dialect, null))); }, diff --git a/src/sdk/impersonate/types.ts b/src/sdk/impersonate/types.ts index 4fa8cd6a..42c80813 100644 --- a/src/sdk/impersonate/types.ts +++ b/src/sdk/impersonate/types.ts @@ -4,9 +4,9 @@ * Defines the scoped interface returned by ctx.impersonate() and * the error class for unsupported dialects / invalid usernames. */ -import type { Kysely, Transaction } from 'kysely'; +import type { Kysely } from 'kysely'; -import type { ExtractArgs, ExtractReturn } from '../types.js'; +import type { RoutineCalls, TransactionScope } from '../types.js'; // ───────────────────────────────────────────────────────────── // ImpersonatedScope @@ -19,30 +19,10 @@ import type { ExtractArgs, ExtractReturn } from '../types.js'; * the impersonated principal. Mirrors Context's query surface without * lifecycle or noorm ops. */ -export interface ImpersonatedScope { +export interface ImpersonatedScope + extends RoutineCalls { kysely: Kysely; - proc: < - N extends keyof Procs & string = keyof Procs & string, - T = ExtractReturn, - >( - name: N, - ...args: ExtractArgs extends void ? [] : [params: ExtractArgs] - ) => Promise; - func: < - N extends keyof Funcs & string = keyof Funcs & string, - T = ExtractReturn, - >( - name: N, - ...args: ExtractArgs extends void ? [column: string] : [params: ExtractArgs, column: string] - ) => Promise; - tvf: < - N extends keyof Tvfs & string = keyof Tvfs & string, - T = ExtractReturn, - >( - name: N, - ...args: ExtractArgs extends void ? [] : [params: ExtractArgs] - ) => Promise; - transaction: (fn: (trx: Transaction) => Promise) => Promise; + transaction: (fn: (trx: TransactionScope) => Promise) => Promise; revert: () => Promise; } diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 713255d3..51f13aa0 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -178,6 +178,8 @@ export type { ImportOptions, ExtractArgs, ExtractReturn, + RoutineCalls, + TransactionScope, } from './types.js'; // Access policy types — `Config.access` (re-exported below) is typed diff --git a/src/sdk/routines.ts b/src/sdk/routines.ts new file mode 100644 index 00000000..eed84397 --- /dev/null +++ b/src/sdk/routines.ts @@ -0,0 +1,219 @@ +/** + * proc/func/tvf over any executor, shared by Context, the transaction + * handle, and the impersonated scope. + */ +import { attempt } from '@logosdx/utils'; +import { sql } from 'kysely'; + +import type { Kysely, Transaction } from 'kysely'; +import type { Dialect } from '../core/connection/types.js'; + +import { buildProcCall, buildFuncCall, buildTvfCall } from './sql.js'; +import type { RoutineCalls, TransactionScope } from './types.js'; + +type RoutineParams = Record | unknown[] | undefined; + +// Resolved after the SQL is built, so an unsupported dialect (sqlite procs, +// mysql TVFs) reports that rather than a missing connection. +type DbProvider = () => Kysely; + +/** + * The sql.ts builders split on `.` and quote each segment. + * + * @example + * qualifyName('acct', 'rebuild_ledger'); // 'acct.rebuild_ledger' + * qualifyName('acct', 'other.rebuild_ledger'); // unchanged + */ +export function qualifyName(schema: string | null, name: string): string { + + if (!schema || name.includes('.')) return name; + + return `${schema}.${name}`; + +} + +/** + * Call a stored procedure and return its rows. + * + * On postgres, `CALL` against a FUNCTION fails, and the noorm playbook has + * row-returning ops written as functions, so that failure retries as + * `SELECT * FROM name(...)`. Inside a transaction the failed `CALL` would + * abort the transaction, so there `pg_proc` decides between the two up front. + * + * @example + * const rows = await callProc(() => db, 'postgres', 'get_users', [{ id: 1 }]); + */ +export async function callProc( + getDb: DbProvider, + dialect: Dialect, + name: string, + args: unknown[], +): Promise { + + const params = routineParams(args); + const callQuery = buildProcCall(dialect, name, params); + const db = getDb(); + + if (dialect !== 'postgres') { + + const result = await callQuery.execute(db); + + return result.rows ?? []; + + } + + if (db.isTransaction) { + + if (!await isProcedure(db, name)) return callTvf(() => db, 'postgres', name, args); + + const result = await callQuery.execute(db); + + return result.rows ?? []; + + } + + const [callResult, callErr] = await attempt(() => callQuery.execute(db)); + + if (!callErr) return callResult.rows ?? []; + + if (!isFunctionNotProcedureError(callErr)) throw callErr; + + return callTvf(() => db, 'postgres', name, args); + +} + +/** + * Call a scalar database function and return its first row, or null. + * `args` is `[column]` or `[params, column]`, as `Context.func` takes them. + * + * @example + * const row = await callFunc<{ total: number }>(() => db, 'postgres', 'calc_total', [{ order_id: 1 }, 'total']); + */ +export async function callFunc( + getDb: DbProvider, + dialect: Dialect, + name: string, + args: unknown[], +): Promise { + + const hasParams = !(args.length === 1 && typeof args[0] === 'string'); + const params = hasParams ? routineParams(args) : undefined; + const column = String(hasParams ? args[1] : args[0]); + + const result = await buildFuncCall(dialect, name, column, params).execute(getDb()); + + return (result.rows?.[0] ?? null) as T; + +} + +/** + * Call a table-valued function and return its rows. Dialect rules and the + * mysql/sqlite rejection live in buildTvfCall. + * + * @example + * const rows = await callTvf(() => db, 'mssql', 'fn_sessions', [['key']]); + */ +export async function callTvf( + getDb: DbProvider, + dialect: Dialect, + name: string, + args: unknown[], +): Promise { + + const result = await buildTvfCall(dialect, name, routineParams(args)).execute(getDb()); + + return result.rows ?? []; + +} + +/** + * Bind proc/func/tvf to a connection, qualifying names with `schema`. + * + * @example + * const { proc } = routineCalls(() => db, 'postgres', null); + */ +export function routineCalls( + getDb: DbProvider, + dialect: Dialect, + schema: string | null, +): RoutineCalls { + + return { + + proc: (name: string, ...args: unknown[]) => callProc(getDb, dialect, qualifyName(schema, name), args), + + func: (name: string, ...args: unknown[]) => callFunc(getDb, dialect, qualifyName(schema, name), args), + + tvf: (name: string, ...args: unknown[]) => callTvf(getDb, dialect, qualifyName(schema, name), args), + + }; + +} + +/** + * Attach proc/func/tvf to Kysely's transaction instance, so the handle + * stays a real `Transaction` for every builder method and callers + * annotated with Kysely's type keep compiling. + * + * @example + * return db.transaction().execute((trx) => fn(withRoutines(trx, dialect, schema))); + */ +export function withRoutines( + trx: Transaction, + dialect: Dialect, + schema: string | null, +): TransactionScope { + + return Object.assign(trx, routineCalls(() => trx, dialect, schema)); + +} + +function routineParams(args: unknown[]): RoutineParams { + + // cast-justified: the typed overloads on Context/RoutineCalls constrain args[0] + return args[0] as RoutineParams; + +} + +// Same split as quoteIdent in sql.ts, so the lookup checks the object CALL would name. +async function isProcedure(db: Kysely, name: string): Promise { + + const [schema, proname] = name.includes('.') ? name.split('.', 2) : [null, name]; + const inSchema = schema === null + ? sql`n.nspname = ANY (current_schemas(false))` + : sql`n.nspname = ${schema}`; + + const result = await sql<{ is_procedure: boolean }>` + SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE p.prokind = 'p' AND p.proname = ${proname} AND ${inSchema} + ) AS is_procedure + `.execute(db); + + return result.rows[0]?.is_procedure === true; + +} + +/** + * Detect a PG `CALL`-against-FUNCTION error. PostgreSQL surfaces it as + * SQLSTATE 42809 (`(...) is not a procedure`) when an exact match is + * the wrong kind of object, or 42883 (`procedure (...) does not + * exist`, hint `To call a function, use SELECT.`) when only a function + * with that signature exists. + */ +function isFunctionNotProcedureError(err: unknown): boolean { + + if (!err || typeof err !== 'object') return false; + + // cast-justified: pg driver error has no fixed type + const obj = err as { code?: string; message?: string; hint?: string }; + + if (obj.code !== '42809' && obj.code !== '42883') return false; + + const msg = String(obj.message ?? ''); + const hint = String(obj.hint ?? ''); + + return msg.includes('procedure') || hint.includes('use SELECT'); + +} diff --git a/src/sdk/types.ts b/src/sdk/types.ts index 2eaf022c..86458865 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -3,6 +3,8 @@ * * All interfaces and types for the noorm programmatic SDK. */ +import type { Transaction } from 'kysely'; + import type { Channel } from '../core/policy/index.js'; import type { ConflictStrategy } from '../core/transfer/types.js'; @@ -181,3 +183,56 @@ export type ExtractArgs = E extends [infer A, unknown] ? A : E; * ``` */ export type ExtractReturn = E extends [unknown, infer R] ? R : unknown; + +/** + * Typed stored-procedure, function, and table-valued-function calls, + * keyed off a context's `Procs`/`Funcs`/`Tvfs` maps. Shared by `Context`, + * the transaction handle, and the impersonated scope so all three match + * signature for signature. + * + * @example + * ```typescript + * function rebuild(calls: RoutineCalls) { + * return calls.proc('rebuild_ledger', { id: 1 }); + * } + * ``` + */ +export interface RoutineCalls { + proc: < + N extends keyof Procs & string = keyof Procs & string, + T = ExtractReturn, + >( + name: N, + ...args: ExtractArgs extends void ? [] : [params: ExtractArgs] + ) => Promise; + func: < + N extends keyof Funcs & string = keyof Funcs & string, + T = ExtractReturn, + >( + name: N, + ...args: ExtractArgs extends void ? [column: string] : [params: ExtractArgs, column: string] + ) => Promise; + tvf: < + N extends keyof Tvfs & string = keyof Tvfs & string, + T = ExtractReturn, + >( + name: N, + ...args: ExtractArgs extends void ? [] : [params: ExtractArgs] + ) => Promise; +} + +/** + * The handle `ctx.transaction(fn)` passes to `fn`: Kysely's own + * `Transaction` with routine calls bound to the transaction's + * connection, so a `SET LOCAL` issued with `sql` applies to them. + * + * @example + * ```typescript + * await ctx.transaction(async (trx) => { + * await sql`SET LOCAL statement_timeout = '8s'`.execute(trx); + * return trx.tvf('search_ledger', { q: 'x' }); + * }); + * ``` + */ +export type TransactionScope = + Transaction & RoutineCalls; diff --git a/tests/integration/sdk/transaction-routines.test.ts b/tests/integration/sdk/transaction-routines.test.ts new file mode 100644 index 00000000..9114ee73 --- /dev/null +++ b/tests/integration/sdk/transaction-routines.test.ts @@ -0,0 +1,278 @@ +/** + * Postgres routines are called unqualified through a withSchema context, so + * most tests also cover schema qualification. Fixture names carry a per-run + * id: the containers are shared. + */ +import { randomUUID } from 'node:crypto'; + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { sql } from 'kysely'; +import type { Kysely } from 'kysely'; +import { attempt } from '@logosdx/utils'; + +import { Context } from '../../../src/sdk/context.js'; +import { + createTestConnection, + skipIfNoContainer, + makeTestConfig, + TEST_CONNECTIONS, +} from '../../utils/db.js'; + +import type { ConnectionResult } from '../../../src/core/connection/types.js'; + +const runId = randomUUID().slice(0, 8); + +interface Entry { id: number } +interface SlowRow { n: number } +interface AcctDB { entries: Entry } + +interface AcctProcs { + 'add_entry': [{ p_id: number }, void]; + 'list_entries': [void, Entry]; + 'add_entry_fn': [{ p_id: number }, Entry]; + 'AddEntry': [{ p_id: number }, void]; +} + +interface AcctTvfs { + 'slow_rows': [void, SlowRow]; +} + +async function runAll(db: Kysely, statements: string[]): Promise { + + for (const statement of statements) { + + await sql.raw(statement).execute(db); + + } + +} + +describe('integration: postgres transaction routines', () => { + + const schema = `trx_routines_${runId}`; + + let conn: ConnectionResult; + let acct: Context; + + async function countEntries(): Promise { + + const result = await sql<{ count: number }>`SELECT count(*)::int AS count FROM ${sql.table(`${schema}.entries`)}`.execute(conn.db); + + return result.rows[0]?.count ?? -1; + + } + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + conn = await createTestConnection('postgres'); + + await runAll(conn.db, [ + `CREATE SCHEMA "${schema}"`, + `CREATE TABLE "${schema}".entries (id integer PRIMARY KEY)`, + `CREATE PROCEDURE "${schema}".add_entry(p_id integer) LANGUAGE sql AS $$ + INSERT INTO "${schema}".entries (id) VALUES (p_id) + $$`, + `CREATE PROCEDURE "${schema}"."AddEntry"(p_id integer) LANGUAGE sql AS $$ + INSERT INTO "${schema}".entries (id) VALUES (p_id) + $$`, + `CREATE FUNCTION "${schema}".list_entries() RETURNS SETOF "${schema}".entries LANGUAGE sql AS $$ + SELECT * FROM "${schema}".entries ORDER BY id + $$`, + `CREATE FUNCTION "${schema}".add_entry_fn(p_id integer) RETURNS SETOF "${schema}".entries LANGUAGE sql AS $$ + INSERT INTO "${schema}".entries (id) VALUES (p_id) RETURNING * + $$`, + `CREATE FUNCTION "${schema}".slow_rows() RETURNS TABLE (n integer) LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_sleep(1.5); + RETURN QUERY SELECT generate_series(1, 2); + END + $$`, + ]); + + const root = new Context( + makeTestConfig('test-pg-trx-routines', TEST_CONNECTIONS.postgres), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test-project', + ); + + acct = root.withSchema(schema); + + // Stands in for the getter's schema wrap over a guarded test connection. + Object.defineProperty(acct, 'kysely', { value: conn.db.withSchema(schema), configurable: true }); + + }, 30_000); + + afterAll(async () => { + + if (!conn) return; + + await sql.raw(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).execute(conn.db); + await conn.destroy(); + + }); + + beforeEach(async () => { + + await sql.raw(`TRUNCATE "${schema}".entries`).execute(conn.db); + + }); + + it('should cancel trx.tvf with SQLSTATE 57014 under SET LOCAL statement_timeout', async () => { + + const [, err] = await attempt(() => acct.transaction(async (trx) => { + + await sql`SET LOCAL statement_timeout = '200ms'`.execute(trx); + + return trx.tvf('slow_rows'); + + })); + + expect(err).toMatchObject({ code: '57014' }); + + }); + + it('should return the slow tvf rows when no timeout is set', async () => { + + const rows = await acct.transaction((trx) => trx.tvf('slow_rows')); + + expect(rows).toEqual([{ n: 1 }, { n: 2 }]); + + }); + + it('should roll back a trx.proc write when the callback throws', async () => { + + const boom = new Error('boom'); + let countInside = -1; + + const [, err] = await attempt(() => acct.transaction(async (trx) => { + + await trx.proc('add_entry', { p_id: 1 }); + + countInside = (await trx.selectFrom('entries').selectAll().execute()).length; + + throw boom; + + })); + + expect(err).toBe(boom); + expect(countInside).toBe(1); + expect(await countEntries()).toBe(0); + + }); + + it('should commit a trx.proc write when the callback returns', async () => { + + await acct.transaction((trx) => trx.proc('add_entry', { p_id: 1 })); + + expect(await countEntries()).toBe(1); + + }); + + it('should SELECT a FUNCTION via trx.proc and keep the transaction usable', async () => { + + const entries = await acct.transaction(async (trx) => { + + await trx.proc('add_entry', { p_id: 1 }); + + const rows = await trx.proc('list_entries'); + + await trx.proc('add_entry', { p_id: 2 }); + + return rows; + + }); + + expect(entries).toEqual([{ id: 1 }]); + expect(await countEntries()).toBe(2); + + }); + + it('should commit concurrent trx.proc calls and a sibling insert on one handle', async () => { + + const [fnRows] = await acct.transaction((trx) => Promise.all([ + trx.proc('add_entry_fn', { p_id: 2 }), + // Delayed one round trip so the insert lands while the FUNCTION call is in flight. + sql`SELECT 1`.execute(trx).then(() => trx.insertInto('entries').values({ id: 3 }).execute()), + trx.proc('add_entry', { p_id: 1 }), + ])); + + expect(fnRows).toEqual([{ id: 2 }]); + expect(await countEntries()).toBe(3); + + }); + + it('should CALL a mixed-case procedure inside a transaction', async () => { + + await acct.transaction((trx) => trx.proc('AddEntry', { p_id: 1 })); + + expect(await countEntries()).toBe(1); + + }); + + it('should resolve an unqualified trx.proc on the search path', async () => { + + const root = new Context( + makeTestConfig('test-pg-trx-routines-root', TEST_CONNECTIONS.postgres), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test-project', + ); + + Object.defineProperty(root, 'kysely', { value: conn.db, configurable: true }); + + const entries = await root.transaction(async (trx) => { + + await sql`SET LOCAL search_path = ${sql.id(schema)}`.execute(trx); + await trx.proc('add_entry', { p_id: 1 }); + + return trx.proc('list_entries'); + + }); + + expect(entries).toEqual([{ id: 1 }]); + + }); + +}); + +describe('integration: mssql transaction routines', () => { + + const tvfName = `fn_trx_rows_${runId}`; + + let conn: ConnectionResult; + let ctx: Context>; + + beforeAll(async () => { + + await skipIfNoContainer('mssql'); + + conn = await createTestConnection('mssql'); + + await sql.raw(`CREATE FUNCTION dbo.${tvfName}() RETURNS TABLE AS RETURN (SELECT 1 AS n UNION ALL SELECT 2 AS n)`).execute(conn.db); + + ctx = new Context( + makeTestConfig('test-mssql-trx-routines', TEST_CONNECTIONS.mssql), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test-project', + ); + + Object.defineProperty(ctx, 'kysely', { value: conn.db, configurable: true }); + + }, 30_000); + + afterAll(async () => { + + if (!conn) return; + + await sql.raw(`DROP FUNCTION IF EXISTS dbo.${tvfName}`).execute(conn.db); + await conn.destroy(); + + }); + + it('should return trx.tvf rows inside a transaction', async () => { + + const rows = await ctx.transaction((trx) => trx.tvf(`dbo.${tvfName}`)); + + expect(rows).toEqual([{ n: 1 }, { n: 2 }]); + + }); + +}); diff --git a/tests/sdk/routines.test.ts b/tests/sdk/routines.test.ts new file mode 100644 index 00000000..eaf059be --- /dev/null +++ b/tests/sdk/routines.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, vi } from 'bun:test'; +import { attempt } from '@logosdx/utils'; +import { + Kysely, + Transaction, + DummyDriver, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely'; +import type { CompiledQuery } from 'kysely'; + +import { Context } from '../../src/sdk/context.js'; +import { buildScope } from '../../src/sdk/impersonate/scope.js'; +import type { TransactionScope, RoutineCalls } from '../../src/sdk/index.js'; + +import type { Config } from '../../src/core/config/types.js'; +import type { Settings } from '../../src/core/settings/types.js'; +import type { Identity } from '../../src/core/identity/types.js'; + +interface Ledger { id: number; amount: number } +interface LedgerDB { ledger: Ledger } + +interface TestProcs { + 'rebuild_ledger': [{ id: number }, Ledger]; + 'refresh_cache': void; +} + +interface TestFuncs { + 'calc_total': [{ order_id: number }, { total: number }]; +} + +interface TestTvfs { + 'search_ledger': [{ q: string }, Ledger]; +} + +const config: Config = { + name: 'test', + type: 'local', + isTest: true, + access: { user: 'admin', agent: 'admin' }, + connection: { dialect: 'postgres', database: 'testdb' }, +}; + +const mockSettings: Settings = {}; +const mockIdentity: Identity = { name: 'tester', source: 'system' }; + +/** PG's answer to `CALL` against a FUNCTION. */ +const notAProcedure = Object.assign(new Error('rebuild_ledger(id => integer) is not a procedure'), { code: '42809' }); + +/** + * DummyDriver-backed Kysely that records every compiled statement and its + * parameters, answers with `rows` (or `rows(sql)`), and throws `failOn(sql)`'s + * error when it returns one. + */ +function createMockKysely( + rows: Record[] | ((sql: string) => Record[]) = [], + failOn: (sql: string) => Error | null = () => null, +) { + + const executedSql: string[] = []; + const executedParams: (readonly unknown[])[] = []; + + const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (kysely) => new PostgresIntrospector(kysely), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + + vi.spyOn(db.getExecutor(), 'provideConnection').mockImplementation(async (consumer) => { + + return consumer({ + executeQuery: vi.fn().mockImplementation(async (compiled: CompiledQuery) => { + + executedSql.push(compiled.sql); + executedParams.push(compiled.parameters); + + const err = failOn(compiled.sql); + + if (err) throw err; + + return { rows: typeof rows === 'function' ? rows(compiled.sql) : rows }; + + }), + streamQuery: () => { + + throw new Error('not implemented'); + + }, + }); + + }); + + return { db, executedSql, executedParams }; + +} + +const isLookup = (sql: string) => sql.includes('pg_catalog.pg_proc'); + +function lookupAnswers(isProcedure: boolean, rows: Record[] = []) { + + return (sql: string) => isLookup(sql) ? [{ is_procedure: isProcedure }] : rows; + +} + +function createCtx(db: Kysely) { + + const ctx = new Context( + config, + mockSettings, + mockIdentity, + {}, + '/tmp/test-project', + ); + + Object.defineProperty(ctx, 'kysely', { value: db, configurable: true }); + + return ctx; + +} + +describe('sdk: transaction handle routines', () => { + + it('should pass a Kysely Transaction that also carries proc/func/tvf', async () => { + + const { db } = createMockKysely(); + const ctx = createCtx(db); + + const handle = await ctx.transaction(async (trx) => trx); + + expect(handle).toBeInstanceOf(Transaction); + expect(typeof handle.proc).toBe('function'); + expect(typeof handle.func).toBe('function'); + expect(typeof handle.tvf).toBe('function'); + + }); + + it('should keep the query builder working on the handle', async () => { + + const ledger: Ledger = { id: 1, amount: 10 }; + const { db, executedSql } = createMockKysely([{ ...ledger }]); + const ctx = createCtx(db); + + const rows = await ctx.transaction((trx) => trx.selectFrom('ledger').selectAll().execute()); + + expect(rows).toEqual([ledger]); + expect(executedSql).toEqual(['select * from "ledger"']); + + }); + + it('should return all rows from trx.tvf', async () => { + + const ledger: Ledger = { id: 1, amount: 10 }; + const { db, executedSql } = createMockKysely([{ ...ledger }]); + const ctx = createCtx(db); + + const rows = await ctx.transaction((trx) => trx.tvf('search_ledger', { q: 'x' })); + + expect(rows).toEqual([ledger]); + expect(executedSql[0]).toContain('SELECT * FROM "search_ledger"('); + + }); + + it('should return the first row from trx.func', async () => { + + const { db, executedSql } = createMockKysely([{ total: 99 }]); + const ctx = createCtx(db); + + const result = await ctx.transaction((trx) => trx.func('calc_total', { order_id: 1 }, 'total')); + + expect(result).toEqual({ total: 99 }); + expect(executedSql[0]).toContain('"calc_total"('); + + }); + + it('should accept a callback annotated with the plain Kysely Transaction type', async () => { + + const { db } = createMockKysely(); + const ctx = createCtx(db); + const plain = async (trx: Transaction) => trx.isTransaction; + + expect(await ctx.transaction(plain)).toBe(true); + + }); + + it('should type the handle routines off the context generics', () => { + + type Handle = TransactionScope; + + const _types = (trx: Handle) => { + + const _rows: Promise = trx.proc('rebuild_ledger', { id: 1 }); + const _total: Promise<{ total: number }> = trx.func('calc_total', { order_id: 1 }, 'total'); + const _found: Promise = trx.tvf('search_ledger', { q: 'x' }); + const _calls: RoutineCalls = trx; + const _trx: Transaction = trx; + + // @ts-expect-error 'nonexistent' is not a key of TestProcs + trx.proc('nonexistent'); + + // @ts-expect-error refresh_cache is void — no params allowed + trx.proc('refresh_cache', { extra: true }); + + }; + + expect(_types).toBeDefined(); + + }); + +}); + +describe('sdk: transaction handle schema qualification', () => { + + it('should qualify trx routine names with a derived context schema', async () => { + + const { db, executedSql } = createMockKysely(); + const acct = createCtx(db).withSchema('acct'); + + Object.defineProperty(acct, 'kysely', { value: db, configurable: true }); + + await acct.transaction(async (trx) => { + + await trx.tvf('search_ledger', { q: 'x' }); + await trx.func('calc_total', { order_id: 1 }, 'total'); + + }); + + expect(executedSql[0]).toContain('"acct"."search_ledger"('); + expect(executedSql[1]).toContain('"acct"."calc_total"('); + + }); + + it('should leave an already-qualified name alone', async () => { + + const { db, executedSql } = createMockKysely(); + const acct = createCtx(db).withSchema('acct'); + + Object.defineProperty(acct, 'kysely', { value: db, configurable: true }); + + await acct.transaction((trx) => trx.tvf('other.search_ledger', { q: 'x' })); + + expect(executedSql[0]).toContain('"other"."search_ledger"('); + expect(executedSql[0]).not.toContain('"acct"'); + + }); + + it('should not qualify names on a root context', async () => { + + const { db, executedSql } = createMockKysely(); + const ctx = createCtx(db); + + await ctx.transaction((trx) => trx.tvf('search_ledger', { q: 'x' })); + + expect(executedSql[0]).toContain('FROM "search_ledger"('); + + }); + +}); + +describe('sdk: postgres proc catalog lookup', () => { + + it('should not query pg_proc outside a transaction', async () => { + + const { db, executedSql } = createMockKysely(); + const ctx = createCtx(db); + + await ctx.proc('refresh_cache'); + + expect(executedSql).toEqual(['CALL "refresh_cache"()']); + + }); + + it('should fall back from a failed CALL to SELECT outside a transaction', async () => { + + const { db, executedSql } = createMockKysely( + [], + (sql) => sql.startsWith('CALL') ? notAProcedure : null, + ); + const ctx = createCtx(db); + + await ctx.proc('rebuild_ledger', { id: 1 }); + + expect(executedSql).toEqual([ + 'CALL "rebuild_ledger"("id" => $1)', + 'SELECT * FROM "rebuild_ledger"("id" => $1)', + ]); + + }); + + it('should CALL inside a transaction when the lookup finds a procedure', async () => { + + const { db, executedSql } = createMockKysely(lookupAnswers(true)); + const ctx = createCtx(db); + + await ctx.transaction((trx) => trx.proc('rebuild_ledger', { id: 1 })); + + expect(executedSql.map(isLookup)).toEqual([true, false]); + expect(executedSql[1]).toBe('CALL "rebuild_ledger"("id" => $1)'); + + }); + + it('should SELECT inside a transaction when the lookup finds no procedure', async () => { + + const ledger: Ledger = { id: 1, amount: 10 }; + const { db, executedSql } = createMockKysely(lookupAnswers(false, [{ ...ledger }])); + const ctx = createCtx(db); + + const rows = await ctx.transaction((trx) => trx.proc('rebuild_ledger', { id: 1 })); + + expect(rows).toEqual([ledger]); + expect(executedSql.map(isLookup)).toEqual([true, false]); + expect(executedSql[1]).toBe('SELECT * FROM "rebuild_ledger"("id" => $1)'); + + }); + + it('should look up a qualified name by its schema and proname', async () => { + + const { db, executedSql, executedParams } = createMockKysely(lookupAnswers(true)); + const acct = createCtx(db).withSchema('acct'); + + Object.defineProperty(acct, 'kysely', { value: db, configurable: true }); + + await acct.transaction((trx) => trx.proc('rebuild_ledger', { id: 1 })); + + expect(executedParams[0]).toEqual(['rebuild_ledger', 'acct']); + expect(executedSql[0]).not.toContain('current_schemas'); + + }); + + it('should look up an unqualified name on the search path', async () => { + + const { db, executedSql, executedParams } = createMockKysely(lookupAnswers(true)); + const ctx = createCtx(db); + + await ctx.transaction((trx) => trx.proc('rebuild_ledger', { id: 1 })); + + expect(executedParams[0]).toEqual(['rebuild_ledger']); + expect(executedSql[0]).toContain('current_schemas(false)'); + + }); + + it('should propagate a CALL error inside a transaction unchanged', async () => { + + const denied = Object.assign(new Error('permission denied for procedure rebuild_ledger'), { code: '42501' }); + const { db, executedSql } = createMockKysely( + lookupAnswers(true), + (sql) => sql.startsWith('CALL') ? denied : null, + ); + const ctx = createCtx(db); + + const [, err] = await attempt(() => ctx.transaction((trx) => trx.proc('rebuild_ledger', { id: 1 }))); + + expect(err).toBe(denied); + expect(executedSql).toHaveLength(2); + + }); + +}); + +describe('sdk: impersonated scope transaction routines', () => { + + it('should pass the routine-carrying handle to scope.transaction', async () => { + + const ledger: Ledger = { id: 1, amount: 10 }; + const { db, executedSql } = createMockKysely([{ ...ledger }]); + const scope = buildScope(db, async () => {}, 'postgres'); + + const rows = await scope.transaction((trx) => trx.tvf('search_ledger', { q: 'x' })); + + expect(rows).toEqual([ledger]); + expect(executedSql[0]).toContain('SELECT * FROM "search_ledger"('); + + }); + + it('should give scope.proc the postgres FUNCTION fallback', async () => { + + const ledger: Ledger = { id: 1, amount: 10 }; + const { db, executedSql } = createMockKysely( + [{ ...ledger }], + (sql) => sql.startsWith('CALL') ? notAProcedure : null, + ); + const scope = buildScope(db, async () => {}, 'postgres'); + + const rows = await scope.proc('rebuild_ledger', { id: 1 }); + + expect(rows).toEqual([ledger]); + expect(executedSql).toEqual([ + 'CALL "rebuild_ledger"("id" => $1)', + 'SELECT * FROM "rebuild_ledger"("id" => $1)', + ]); + + }); + +});