Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
83 changes: 83 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,89 @@ 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.


<<<<<<< HEAD
## 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 |

=======
>>>>>>> origin/master
<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