Skip to content
Closed
9 changes: 9 additions & 0 deletions .changeset/sdk-with-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@noormdev/sdk": minor
---

## Added

* `feat(sdk):` `ctx.withSchema<SDB>(name)` — derive a `Context` scoped to one schema, sharing the parent's connection, pool, and lifecycle
* `feat(sdk):` `proc`/`func`/`tvf` calls through a derived context are automatically qualified with the schema name, unless the caller already passed a dotted name
* `feat(sdk):` `transaction()` and `impersonate()` compose with a derived context — both stay scoped to the derived schema
80 changes: 80 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,86 @@ Use Kysely as the SQL translator. Write database operations once, Kysely handles
For setup wizards where the target database may not exist yet, use `testConnection(config, { testServerOnly: true })`. This connects to the dialect's system database (postgres→`postgres`, mssql→`master`, mysql→no database) to verify credentials without requiring the target database.


## Documentation surfaces

| Path | Covers | Voice |
|------|--------|-------|
| `README.md` | project overview, install, quick start | atomic-writing |
| `docs/index.md` | docs landing, why noorm, quick start | atomic-writing |
| `docs/why-noorm.md` | rationale, prior tools, history | atomic-writing |
| `docs/tui.md` | TUI screens, navigation, keyboard shortcuts | terse-technical |
| `docs/headless.md` | CLI reference, global flags, command discovery | terse-technical |
| `docs/getting-started/installation.md` | requirements, CLI install, SDK install | atomic-writing |
| `docs/getting-started/concepts.md` | SQL files as source of truth, execution order, changes | atomic-writing |
| `docs/getting-started/first-build.md` | init, first build walkthrough | atomic-writing |
| `docs/getting-started/building-your-sdk.md` | monorepo setup, database package, SDK wiring | atomic-writing |
| `docs/guide/automation/ci.md` | test CI, prod CI shapes | atomic-writing |
| `docs/guide/automation/mcp.md` | MCP server, AI agent integration, tools | atomic-writing |
| `docs/guide/automation/non-interactive.md` | --yes semantics, CI bootstrap | atomic-writing |
| `docs/guide/changes/overview.md` | changes vs migrations, directory structure | atomic-writing |
| `docs/guide/changes/forward-revert.md` | apply and revert lifecycle | atomic-writing |
| `docs/guide/changes/history.md` | execution history, TUI history views | atomic-writing |
| `docs/guide/database/create.md` | db create, configs-first workflow | atomic-writing |
| `docs/guide/database/explore.md` | schema explorer screens | atomic-writing |
| `docs/guide/database/teardown.md` | truncate, teardown operations | atomic-writing |
| `docs/guide/database/terminal.md` | SQL terminal usage | atomic-writing |
| `docs/guide/database/transfer.md` | cross-database transfer | atomic-writing |
| `docs/guide/deployment.md` | deploy split, runtime connection, one context per process | atomic-writing |
| `docs/guide/environments/configs.md` | multiple configs, creating configs | atomic-writing |
| `docs/guide/environments/stages.md` | stages | atomic-writing |
| `docs/guide/environments/secrets.md` | secrets, config-scoped vs global | atomic-writing |
| `docs/guide/environments/vault.md` | vault, secret resolution, encryption | atomic-writing |
| `docs/guide/relational-design.md` | inherited keys, basetype-subtype modeling | atomic-writing |
| `docs/guide/sql-files/organization.md` | directory structure, naming, execution order | atomic-writing |
| `docs/guide/sql-files/execution.md` | run build, run file, execution | atomic-writing |
| `docs/guide/sql-files/templates.md` | Eta template syntax, rendering context | atomic-writing |
| `docs/guide/troubleshooting.md` | common failure modes, flag gotchas | atomic-writing |
| `docs/cli/flags.md` | global vs per-subcommand flags, --config overload | terse-technical |
| `docs/cli/help.md` | help discovery | terse-technical |
| `docs/cli/identity.md` | identity management commands | terse-technical |
| `docs/cli/init.md` | noorm init | terse-technical |
| `docs/cli/run.md` | noorm run subcommands, exit codes | terse-technical |
| `docs/cli/secret.md` | noorm secret | terse-technical |
| `docs/cli/settings-edit.md` | noorm settings edit | terse-technical |
| `docs/cli/settings-secret.md` | noorm settings secret | terse-technical |
| `docs/cli/sql.md` | noorm sql | terse-technical |
| `docs/cli/sql-repl.md` | noorm sql repl | terse-technical |
| `docs/dev/index.md` | developer docs index | terse-technical |
| `docs/dev/sdk.md` | SDK developer guide, createContext, withSchema, routines, events | atomic-writing |
| `docs/dev/change.md` | change parsing, execution internals | atomic-writing |
| `docs/dev/runner.md` | runner, checksum change detection | atomic-writing |
| `docs/dev/template.md` | Eta templating internals | atomic-writing |
| `docs/dev/config.md` | config sources, structure | atomic-writing |
| `docs/dev/config-sharing.md` | config export and import | atomic-writing |
| `docs/dev/settings.md` | settings.yml | atomic-writing |
| `docs/dev/state.md` | encrypted state | atomic-writing |
| `docs/dev/identity.md` | audit and cryptographic identity | atomic-writing |
| `docs/dev/secrets.md` | secret tiers | atomic-writing |
| `docs/dev/vault.md` | vault architecture, encryption | atomic-writing |
| `docs/dev/logger.md` | structured logger | atomic-writing |
| `docs/dev/explore.md` | schema exploration internals | atomic-writing |
| `docs/dev/teardown.md` | truncate and teardown internals | atomic-writing |
| `docs/dev/transfer.md` | data transfer, DT format | atomic-writing |
| `docs/dev/sql-terminal.md` | SQL terminal internals | atomic-writing |
| `docs/dev/lock.md` | operation locking | atomic-writing |
| `docs/dev/ci.md` | CI/CD integration, exit codes | atomic-writing |
| `docs/dev/headless.md` | CLI architecture, headless flags | terse-technical |
| `docs/dev/project-discovery.md` | project root discovery | atomic-writing |
| `docs/dev/datamodel.md` | data model ERD, entities | terse-technical |
| `docs/dev/version.md` | version layers, migration | atomic-writing |
| `docs/dev/ink-cheatsheet.md` | Ink API cheatsheet | terse-technical |
| `docs/dev/ink-testing-library-cheatsheet.md` | ink-testing-library cheatsheet | terse-technical |
| `docs/modeling/index.md` | ignatius overview, IDEF1X modeling | atomic-writing |
| `docs/modeling/installation.md` | ignatius install | atomic-writing |
| `docs/modeling/entities.md` | entity format, key inheritance | atomic-writing |
| `docs/modeling/data-flows.md` | SSADM data flow diagrams | atomic-writing |
| `docs/modeling/best-practices.md` | modeling best practices | atomic-writing |
| `docs/modeling/branding.md` | model branding | atomic-writing |
| `docs/modeling/modeling-skill.md` | /noorm-modeling skill | atomic-writing |
| `docs/modeling/reverse-engineering.md` | reverse-engineering via MCP | atomic-writing |
| `docs/reference/sdk.md` | SDK API reference, withSchema, impersonation, routines | terse-technical |
| `packages/sdk/README.md` | npm SDK readme, install, usage, schema scoping | terse-technical |

<atomic-signals>

## Project signals (auto-loaded)
Expand Down
117 changes: 117 additions & 0 deletions docs/design/sdk-with-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# SDK schema scoping — `Context.withSchema`


## Problem


SDK users working against schema-organized databases have no first-class way to scope a `Context` to one schema. Today every call site pays the qualification cost by hand:

- Query builder: `ctx.kysely.withSchema('accounting')` repeated per query, typed against the whole-database shape rather than the schema's slice.
- Routines: `ctx.proc('accounting.rebuild_ledger', …)` — manual string qualification on every `proc`/`func`/`tvf` call.
- Explore: per-call `schema?` args on `ctx.noorm.db.describe*`.

Missing one call site silently targets the default schema. The fix should be per-call-site sugar, not connection state — the connection layer stays schema-agnostic.


## Goals / Non-goals


- Goals:
- `ctx.withSchema<SDB, SProcs, SFuncs, STvfs>(name)` returns a derived `Context` typed to the schema's table/routine shapes.
- Same pool, same connection, same lifecycle — syntax sugar over Kysely's `withSchema` helper; no new connection state.
- Query builder, transactions, and `proc`/`func`/`tvf` are all schema-scoped through the derived context.
- Caller-supplied qualification still wins: a routine name already containing `.` passes through untouched.
- Non-goals:
- No config/connection-level schema field. The connection does not care about schemas.
- No raw-SQL rewriting. Unqualified names inside `` sql`…` `` fragments resolve to the connection default — inherent to Kysely's plugin model, documented, no workaround attempted.
- No schema-defaulting of `ctx.noorm.db.describe*` args (possible follow-up, not this feature).
- No per-dialect behavior. The qualifier means whatever the dialect says it means (see Recommendation).


## Approaches


| # | Approach | Pros | Cons |
|---|----------|------|------|
| A | Status quo, documented (`ctx.kysely.withSchema` per query) | Zero code | No typed schema slice; `proc`/`func`/`tvf` stay manual; per-query repetition; easy to miss a call site |
| B | Config-level default schema (`connection.schema` + pg `search_path` pool wiring) | Covers raw SQL on postgres | Connection layer absorbs a schema concern; mssql has no session-level default schema; per-dialect wiring; global rather than per-call-site |
| C | `Context.withSchema` derived context | Typed slice; same pool; composable per call site; no config or connection change; small surface | Raw SQL not covered (inherent to Kysely plugins); shared lifecycle state must be threaded (`#heldConnections`) |


## Recommendation


**C.** B was rejected on principle — the connection shouldn't care about schemas — and A leaves routines and typing unsolved. C is nearly fall-in because two pieces already exist:

- `quoteIdent` (`src/sdk/sql.ts:35`) already splits qualified names on the first `.` and quotes each segment per dialect (`dbo.sp_Get_Users` → `[dbo].[sp_Get_Users]`). The routine builders need zero changes; the derived context prefixes `${schema}.${name}` before delegating.
- Kysely's `Kysely.withSchema(schema)` returns a copy sharing the executor/pool, with a `WithSchemaPlugin` added at the front (`node_modules/kysely/dist/esm/kysely.js:394-398`, `withPluginAtFront`). Front position means the newest plugin qualifies identifiers first, so the last `withSchema` call wins and accidental stacking is benign. `Transaction` inherits the executor's plugins and carries its own `withSchema` (`kysely.js:507-512`), so transactions started from the wrapped instance are schema-scoped for free.

What it looks like at the call site — illustrative sketch, not the implemented signature:

```
ctx = createContext<DbShape, Procs>({ config: 'dev' })
ctx.connect()

acct = ctx.withSchema<AcctTables, AcctProcs>('accounting') // same pool, no new connection

acct.kysely.selectFrom('invoices').select(['id', 'total']).execute()
-> select "id", "total" from "accounting"."invoices" // typed against AcctTables

acct.proc('rebuild_ledger', { year: 2026 })
-> CALL "accounting"."rebuild_ledger"("year" => $1)

acct.proc('billing.close_period')
-> CALL "billing"."close_period"() // dot present — caller's qualification wins

acct.transaction(fn) // every query inside fn stays accounting-scoped

ctx.kysely.selectFrom('users').execute()
-> select * from "users" // parent untouched — no prefix

ctx.disconnect() // one lifecycle for both instances
```

The derived context is the same `Context` class with fresh generics, sharing the parent's state. Decision rule:

```
withSchema(name):
validate name as a sane identifier (same posture as impersonate's
validateUsername, src/sdk/impersonate/dialect-strategy.ts) —
quoting already prevents injection; validation fails earlier and clearer
derived = Context sharing #state (same connection) and #heldConnections (same Set)
derived schema = name // replaces any parent schema — re-derive, never stack
return derived

kysely getter:
db = bare instance from #state.connection
return schema set ? db.withSchema(schema) : db

proc / func / tvf:
qualified = (schema set and name has no '.') ? schema + '.' + name : name
delegate to the existing builders unchanged
```

Instance relationships — one pool, N typed views:

```mermaid
flowchart LR
root["Context&lt;DbShape&gt; (no schema)"] -- "withSchema('acct')" --> acct["Context&lt;AcctShape&gt;"]
root --> state["shared ContextState — one connection pool"]
acct --> state
root -- "kysely getter" --> bare["bare Kysely"]
acct -- "kysely getter" --> wrap["bare Kysely .withSchema('acct')"]
```

Load-bearing details:

- **`#heldConnections` must be shared.** It is per-instance today (`src/sdk/context.ts:68`); a derived context owning its own Set would let `disconnect()` strand an impersonation scope opened through the sibling instance. Both instances point at one Set.
- **The wrap always derives from the bare instance.** Core modules keep their own bare handle off the connection, so `noormDb(db).withSchema('noorm')` (`src/core/shared/tables.ts:132`) never sees the user's schema plugin. Kysely's last-wins semantics would tolerate stacking anyway; re-deriving keeps the contract obvious.
- **Impersonation composes.** `impersonate` pins a connection via `this.kysely.connection()` — called on a derived context, the pinned instance carries the schema plugin, so the impersonated scope is schema-scoped too. Coherent; worth an integration test; no extra code.
- **`noorm` namespace passes through unchanged.** Its operations are project-level (changes, run, lock, vault); a derived context exposes the same operations against the same state.
- **Dialect semantics are pass-through.** The qualifier is a schema on postgres/mssql, a database on mysql, an ATTACHed database name on sqlite. No dialect gating — Kysely's meaning is the meaning.


## Open questions


- None — shape decisions were settled in the originating session (2026-08-10): derived-context API over config-level schema; raw-SQL caveat accepted; explore schema-defaulting deferred.
29 changes: 29 additions & 0 deletions docs/dev/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ The Context API is split into two levels:
- `connect()`, `disconnect()` — lifecycle
- `transaction()`, `proc()`, `func()`, `tvf()` — SQL execution
- `impersonate()` — run queries as another database principal (callback or explicit scope)
- `withSchema()` — derive a context scoped to one schema (same connection, fresh types)
- `noorm` — namespace for management operations

**ctx.noorm** — noorm management operations, organized by namespace:
Expand Down Expand Up @@ -231,6 +232,34 @@ const result = await ctx.transaction(async (trx) => {
```


### Schema Scoping

#### `withSchema<SDB, SProcs, SFuncs, STvfs>(name)`

Derive a `Context` scoped to one schema. The derived context shares the parent's connection, pool, and lifecycle — `withSchema` is a typed wrapper over Kysely's own `withSchema`, not a new connection. Fresh generics describe the schema's tables and routines, so queries through the derived context are typed against that slice.

```typescript
interface AcctDB {
invoices: { id: number; total: string }
}

const acct = ctx.withSchema<AcctDB>('accounting')

await acct.kysely.selectFrom('invoices').selectAll().execute()
// -> select * from "accounting"."invoices"

await acct.proc('rebuild_ledger', { year: 2026 })
// -> CALL "accounting"."rebuild_ledger"("year" => $1)

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.

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.


### Stored Procedures, Functions & TVFs

Stored procedures, database functions, and table-valued functions get their own type-safe methods. Define your signatures as interfaces using `[Args, ReturnType]` tuples and pass them as extra generics:
Expand Down
Loading
Loading