From 4054a5448b60d99458ade86c21b02b4dd979b209 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 01:43:01 -0400 Subject: [PATCH 01/10] docs: add sdk-with-schema design and spec --- docs/design/sdk-with-schema.md | 92 +++++++++++++++++++++ docs/spec/sdk-with-schema.md | 141 +++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 docs/design/sdk-with-schema.md create mode 100644 docs/spec/sdk-with-schema.md diff --git a/docs/design/sdk-with-schema.md b/docs/design/sdk-with-schema.md new file mode 100644 index 00000000..0a09921e --- /dev/null +++ b/docs/design/sdk-with-schema.md @@ -0,0 +1,92 @@ +# 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(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. + +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<DbShape> (no schema)"] -- "withSchema('acct')" --> acct["Context<AcctShape>"] + 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. diff --git a/docs/spec/sdk-with-schema.md b/docs/spec/sdk-with-schema.md new file mode 100644 index 00000000..5c9a77c0 --- /dev/null +++ b/docs/spec/sdk-with-schema.md @@ -0,0 +1,141 @@ +# SDK schema scoping — `Context.withSchema` + + +## Goal + + +`Context.withSchema(name)` on `@noormdev/sdk` returns a derived `Context` scoped to one schema — same connection/pool as the parent, fresh generics for the schema's shape, with the query builder and `proc`/`func`/`tvf` calls transparently qualified by `name`, composing automatically through `transaction()` and `impersonate()`. + + +## Non-goals + + +- No config/connection-level schema field — the connection stays schema-agnostic; `withSchema` is per-call-site sugar, not connection state. +- No rewriting of unqualified identifiers inside `` sql`…` `` fragments. They resolve against the connection default regardless of `withSchema` — inherent to Kysely's plugin model, documented as a caveat, no workaround attempted. +- No schema-defaulting of `ctx.noorm.db.describe*`'s `schema?` argument. +- No per-dialect gating of the schema qualifier. It means whatever Kysely's `withSchema` means for the active dialect (a schema on postgres/mssql, a database on mysql, an ATTACHed database name on sqlite) — the SDK does not special-case any dialect. + + +## Success criteria + + +- [ ] `const derived = ctx.withSchema('acct')` returns a `Context` instance whose `.kysely` getter compiles queries with `acct`-qualified identifiers, verified by compiling against a dialect query compiler (`DummyDriver` + `PostgresQueryCompiler`, mirroring `tests/sdk/sql.test.ts`'s compiled-SQL assertion pattern) in `tests/sdk/with-schema.test.ts`. +- [ ] Calling `withSchema` again on an already-derived context (`ctx.withSchema('a').withSchema('b')`) replaces rather than stacks — compiled SQL is qualified with `b` only. +- [ ] `derived.proc(name, …)`, `.func(...)`, `.tvf(...)` prefix `name` with `${schema}.` unless `name` already contains a `.`, verified against `buildProcCall`/`buildFuncCall`/`buildTvfCall` (`src/sdk/sql.ts`) output. +- [ ] An invalid schema name throws synchronously from `withSchema` before any connection is borrowed or `#state` is touched. +- [ ] `derived` and its parent share one `#heldConnections` Set: an explicit-mode impersonation scope opened via `derived.impersonate(username)` is released when `parent.disconnect()` runs. +- [ ] `derived.transaction(fn)` and `derived.impersonate(username, fn)` both resolve schema-qualified, verified against live postgres/mysql/mssql/sqlite in `tests/integration/sdk/with-schema.test.ts`. +- [ ] `derived.noorm` exposes the same operations against the same shared `#state` as `parent.noorm` — no schema-specific noorm behavior. +- [ ] `packages/sdk/README.md` and `docs/reference/sdk.md` document `withSchema`, including the raw-`sql` caveat and all four non-goals. +- [ ] `bun run typecheck`, `bun run lint`, and the full test suite (CI's 5-group split, per `docs/wiki/index.md`) pass; `tests/integration/sdk/with-schema.test.ts` passes against the `docker-compose.test.yml` services. + + +## Approach + +Derived-context API (`Context.withSchema`, sharing parent state) — see `docs/design/sdk-with-schema.md`. + + +## Change tree + +``` +src/sdk/ +└── context.ts ............................. M (withSchema; kysely getter re-derivation; proc/func/tvf prefixing) +tests/sdk/ +└── with-schema.test.ts .................... A (unit: compiled-SQL qualification, prefixing, validation, shared #heldConnections) +tests/integration/sdk/ +└── with-schema.test.ts .................... A (live-DB: schema-scoped queries, transaction + impersonation composition, cross-dialect) +packages/sdk/ +└── README.md ............................... M (schema-scoping section + raw-SQL caveat) +docs/reference/ +└── sdk.md ................................... M (withSchema API reference) +.changeset/ +└── sdk-with-schema.md ...................... A (minor bump, @noormdev/sdk — new public API) +``` + + +## Outline + +``` +src/sdk/context.ts + withSchema — validate `name`, derive a sibling Context sharing #state and #heldConnections, schema replaces (never stacks) any parent schema + identifier validation — rejects unsafe schema names before deriving, same posture as impersonate's validateUsername (src/sdk/impersonate/dialect-strategy.ts:33) + kysely (getter) — re-derives against the current schema on every access; never caches the wrapped instance + proc / func / tvf — prefix `${schema}.${name}` unless `name` already contains a `.`, then delegate to the existing builders (buildProcCall/buildFuncCall/buildTvfCall, src/sdk/sql.ts) unchanged + +tests/sdk/with-schema.test.ts + kysely getter qualifies compiled SQL with the derived schema + chained withSchema calls replace rather than stack + proc/func/tvf prefix unqualified names; already-dotted names pass through unchanged + invalid schema name throws synchronously, no state mutated + #heldConnections shared — a scope opened via a derived context is releasable through the parent + +tests/integration/sdk/with-schema.test.ts + schema-scoped queries resolve against the target schema across postgres/mysql/mssql/sqlite + transaction() inherits schema scoping from a derived context + impersonate() composes with a derived context — the impersonated scope's kysely/proc/func/tvf resolve against the schema + parent.disconnect() releases a held connection opened through a derived context + +packages/sdk/README.md + Schema scoping — withSchema usage example, same-pool/same-lifecycle framing, raw-sql caveat + +docs/reference/sdk.md + withSchema(name) — API reference: derivation semantics, replace-not-stack, proc/func/tvf prefixing, transaction/impersonation composition, raw-sql caveat, non-goals + +.changeset/sdk-with-schema.md + None — changeset frontmatter + summary, no nameable pieces +``` + + +## Flows + +``` +Flow: deriving a schema-scoped context +1. caller calls ctx.withSchema('acct') on a parent Context (connected or not) +2. withSchema validates 'acct' as a sane identifier — an invalid name throws synchronously, no state touched +3. withSchema constructs a derived Context sharing the parent's #state (same connection reference) and #heldConnections Set, with schema set to 'acct' — replacing, never stacking, any schema the parent already carried +4. caller receives the derived Context, typed Context + +Flow: query builder access through a schema-scoped context +1. caller reads derived.kysely +2. the getter resolves the bare Kysely instance off the shared #state.connection +3. because a schema is set, the getter re-derives fresh on every access rather than caching a wrapped instance +4. queries run through the derived instance resolve against 'acct'; queries run through the parent (or a sibling derived with a different schema) resolve against the parent's own schema, or none + +Flow: routine call through a schema-scoped context +1. caller calls derived.proc('rebuild_ledger', params) (or .func / .tvf) +2. Context checks whether the name contains '.': it does not, so it prefixes with the schema -> 'acct.rebuild_ledger' +3. the qualified name passes unchanged into the existing buildProcCall/buildFuncCall/buildTvfCall builders, which split on '.' and quote each segment per dialect (quoteIdent, src/sdk/sql.ts:35) +4. if the caller passed an already-qualified name ('other_schema.rebuild_ledger'), the schema prefix step is skipped and the caller's qualification is used unchanged + +Flow: transaction and impersonation composition +1. caller calls derived.transaction(fn) or derived.impersonate(username, fn) +2. transaction() calls this.kysely.transaction() — the transaction stays schema-scoped, resolving against 'acct' the same as the context it was opened from +3. impersonate() calls this.kysely.connection() — the pinned connection stays schema-scoped too, so the returned scope's proc/func/tvf calls resolve against 'acct' +4. both paths read/write the parent's shared #heldConnections Set, so disconnect() on either instance drains scopes opened through the other +``` + + +## Checkpoints + + +| # | Checkpoint | Files/areas | Agent | Est. files | Verifies | +|---|------------|-------------|-------|------------|----------| +| 1 | Implement `Context.withSchema`: identifier validation, `kysely` getter re-derivation, `proc`/`func`/`tvf` prefixing, shared `#state`/`#heldConnections` | `src/sdk/context.ts`, `tests/sdk/with-schema.test.ts` | atomic-implementer (mode: feature) | 2 | `tests/sdk/with-schema.test.ts` green — compiled-SQL qualification, replace-not-stack, prefixing, synchronous validation failure, shared `#heldConnections` | +| 2 | Integration coverage: schema-scoped queries, transaction and impersonation composition, cross-dialect | `tests/integration/sdk/with-schema.test.ts` | atomic-implementer (mode: feature) | 1 | `tests/integration/sdk/with-schema.test.ts` green against `docker-compose.test.yml` (postgres/mysql/mssql/sqlite) | +| 3 | Document `withSchema` — SDK README, VitePress SDK reference, changeset | `packages/sdk/README.md`, `docs/reference/sdk.md`, `.changeset/sdk-with-schema.md` | atomic-implementer (mode: feature) | 3 | Docs describe the API, the raw-SQL caveat, and all four non-goals; changeset references `@noormdev/sdk` with a `minor` bump | + + +## Risks + + +| Risk | Likelihood | Mitigation | +|------|-----------|-----------| +| The `kysely` getter caches the wrapped instance instead of re-deriving fresh each access, letting schema stacking or drift survive a reconnect | low | Unit test asserts the getter re-derives from `#state.connection` on every access rather than caching the wrapped instance | +| Schema-name validation is too permissive (admits characters that don't belong in an unparameterized identifier position) or too restrictive (rejects legitimate schema names) | med | Mirror `validateUsername`'s allow-list posture (`src/sdk/impersonate/dialect-strategy.ts:33`); unit test covers valid/invalid boundary cases | +| Cross-dialect integration coverage for transaction/impersonation composition is uneven because mysql/sqlite already reject some routine kinds (per existing dialect gates in `src/sdk/sql.ts`), risking incomplete or falsely-green assertions | med | Scope routine-composition assertions to dialects that already support the routine kind; assert query-builder + transaction schema-qualification uniformly across all four dialects regardless | +| The raw-`sql` caveat goes unnoticed and users assume `withSchema` rewrites raw fragments | low | Explicit non-goal plus a documented caveat in both `packages/sdk/README.md` and `docs/reference/sdk.md` | + + +## Change log + + From fe0fc2235e30a559c8d285b0491392162e3a73ac Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 09:05:13 -0400 Subject: [PATCH 02/10] docs: require three-schema integration coverage in sdk-with-schema spec --- docs/spec/sdk-with-schema.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/spec/sdk-with-schema.md b/docs/spec/sdk-with-schema.md index 5c9a77c0..7b9516a6 100644 --- a/docs/spec/sdk-with-schema.md +++ b/docs/spec/sdk-with-schema.md @@ -24,7 +24,8 @@ - [ ] `derived.proc(name, …)`, `.func(...)`, `.tvf(...)` prefix `name` with `${schema}.` unless `name` already contains a `.`, verified against `buildProcCall`/`buildFuncCall`/`buildTvfCall` (`src/sdk/sql.ts`) output. - [ ] An invalid schema name throws synchronously from `withSchema` before any connection is borrowed or `#state` is touched. - [ ] `derived` and its parent share one `#heldConnections` Set: an explicit-mode impersonation scope opened via `derived.impersonate(username)` is released when `parent.disconnect()` runs. -- [ ] `derived.transaction(fn)` and `derived.impersonate(username, fn)` both resolve schema-qualified, verified against live postgres/mysql/mssql/sqlite in `tests/integration/sdk/with-schema.test.ts`. +- [ ] The integration suite provisions three schemas per dialect (native qualifier: schemas on postgres/mssql, databases on mysql, ATTACHed databases on sqlite), each with a distinct table shape and a distinct TypeScript type; three derived contexts plus the parent run interleaved reads and writes, and each context resolves only against its own schema — any cross-schema leakage fails the suite. +- [ ] `derived.transaction(fn)` and `derived.impersonate(username, fn)` both resolve schema-qualified against the three-schema fixture, verified live in `tests/integration/sdk/with-schema.test.ts` (routine and impersonation assertions scoped to dialects that support them). - [ ] `derived.noorm` exposes the same operations against the same shared `#state` as `parent.noorm` — no schema-specific noorm behavior. - [ ] `packages/sdk/README.md` and `docs/reference/sdk.md` document `withSchema`, including the raw-`sql` caveat and all four non-goals. - [ ] `bun run typecheck`, `bun run lint`, and the full test suite (CI's 5-group split, per `docs/wiki/index.md`) pass; `tests/integration/sdk/with-schema.test.ts` passes against the `docker-compose.test.yml` services. @@ -70,9 +71,10 @@ tests/sdk/with-schema.test.ts #heldConnections shared — a scope opened via a derived context is releasable through the parent tests/integration/sdk/with-schema.test.ts - schema-scoped queries resolve against the target schema across postgres/mysql/mssql/sqlite - transaction() inherits schema scoping from a derived context - impersonate() composes with a derived context — the impersonated scope's kysely/proc/func/tvf resolve against the schema + three-schema fixture — provisions three schemas with distinct table shapes and distinct TS types per dialect (native qualifier: schemas on pg/mssql, databases on mysql, ATTACHed databases on sqlite); torn down after + schema isolation at scale — three derived contexts plus the parent interleave reads and writes; each context resolves only against its own schema, cross-schema leakage fails + transaction() inherits schema scoping from a derived context against the three-schema fixture + impersonate() composes with a derived context — the impersonated scope's kysely/proc/func/tvf resolve against the derived schema parent.disconnect() releases a held connection opened through a derived context packages/sdk/README.md @@ -121,7 +123,7 @@ Flow: transaction and impersonation composition | # | Checkpoint | Files/areas | Agent | Est. files | Verifies | |---|------------|-------------|-------|------------|----------| | 1 | Implement `Context.withSchema`: identifier validation, `kysely` getter re-derivation, `proc`/`func`/`tvf` prefixing, shared `#state`/`#heldConnections` | `src/sdk/context.ts`, `tests/sdk/with-schema.test.ts` | atomic-implementer (mode: feature) | 2 | `tests/sdk/with-schema.test.ts` green — compiled-SQL qualification, replace-not-stack, prefixing, synchronous validation failure, shared `#heldConnections` | -| 2 | Integration coverage: schema-scoped queries, transaction and impersonation composition, cross-dialect | `tests/integration/sdk/with-schema.test.ts` | atomic-implementer (mode: feature) | 1 | `tests/integration/sdk/with-schema.test.ts` green against `docker-compose.test.yml` (postgres/mysql/mssql/sqlite) | +| 2 | Integration coverage: three-schema fixture with distinct typed shapes, schema isolation under interleaved derived contexts, transaction and impersonation composition, cross-dialect | `tests/integration/sdk/with-schema.test.ts` | atomic-implementer (mode: feature) | 1 | `tests/integration/sdk/with-schema.test.ts` green against `docker-compose.test.yml` (postgres/mysql/mssql/sqlite); isolation assertions across all three schemas on every dialect | | 3 | Document `withSchema` — SDK README, VitePress SDK reference, changeset | `packages/sdk/README.md`, `docs/reference/sdk.md`, `.changeset/sdk-with-schema.md` | atomic-implementer (mode: feature) | 3 | Docs describe the API, the raw-SQL caveat, and all four non-goals; changeset references `@noormdev/sdk` with a `minor` bump | @@ -134,8 +136,15 @@ Flow: transaction and impersonation composition | Schema-name validation is too permissive (admits characters that don't belong in an unparameterized identifier position) or too restrictive (rejects legitimate schema names) | med | Mirror `validateUsername`'s allow-list posture (`src/sdk/impersonate/dialect-strategy.ts:33`); unit test covers valid/invalid boundary cases | | Cross-dialect integration coverage for transaction/impersonation composition is uneven because mysql/sqlite already reject some routine kinds (per existing dialect gates in `src/sdk/sql.ts`), risking incomplete or falsely-green assertions | med | Scope routine-composition assertions to dialects that already support the routine kind; assert query-builder + transaction schema-qualification uniformly across all four dialects regardless | | The raw-`sql` caveat goes unnoticed and users assume `withSchema` rewrites raw fragments | low | Explicit non-goal plus a documented caveat in both `packages/sdk/README.md` and `docs/reference/sdk.md` | +| Three-schema fixture provisioning differs per dialect — mysql needs two extra databases, sqlite needs ATTACHed files — and may collide with the shared integration harness state | med | Provision and tear down inside the suite using the admin-level credentials `docker-compose.test.yml` already grants, following the `tests/global-setup.ts` bootstrap pattern; unique schema names per run avoid collisions | ## Change log - +### 2026-08-10 — three-schema integration coverage + +**What changed:** Integration success criteria, Outline, checkpoint 2, and Risks now require a three-schema fixture — three schemas per dialect via the native qualifier, each with a distinct table shape and TypeScript type — with isolation assertions under interleaved derived contexts (three derived plus the parent). + +**Why:** User requirement — simulate multi-schema scale so cross-schema leakage and type-shape mixups surface in tests instead of production. + +**Superseded:** Integration coverage asserted schema scoping against a single schema per dialect. From 028e9a2175b8ca3f57f0d91cdadaedf31b340f56 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 14:47:11 -0400 Subject: [PATCH 03/10] docs: add call-site usage sketch to sdk-with-schema design --- docs/design/sdk-with-schema.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/design/sdk-with-schema.md b/docs/design/sdk-with-schema.md index 0a09921e..13f15e4d 100644 --- a/docs/design/sdk-with-schema.md +++ b/docs/design/sdk-with-schema.md @@ -46,6 +46,31 @@ Missing one call site silently targets the default schema. The fix should be per - `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({ config: 'dev' }) +ctx.connect() + +acct = ctx.withSchema('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: ``` From 62004a3c3fb4a13a7d54f3b908ab586c5aa0f4ad Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 15:17:28 -0400 Subject: [PATCH 04/10] feat(sdk): add Context.withSchema derived contexts --- src/sdk/context.ts | 116 ++++++++- tests/sdk/with-schema.test.ts | 448 ++++++++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 tests/sdk/with-schema.test.ts diff --git a/src/sdk/context.ts b/src/sdk/context.ts index 071b07d9..f742b154 100644 --- a/src/sdk/context.ts +++ b/src/sdk/context.ts @@ -26,6 +26,49 @@ import { buildScope } from './impersonate/scope.js'; import { ImpersonationError } from './impersonate/types.js'; import type { ImpersonatedScope } from './impersonate/types.js'; +// ───────────────────────────────────────────────────────────── +// Schema Name Validation +// ───────────────────────────────────────────────────────────── + +const VALID_SCHEMA_NAME = /^[a-zA-Z0-9_]+$/; + +/** + * Validate a schema name against a restrictive character set. + * + * Defense-in-depth before the name is interpolated into `${schema}.${name}` + * ahead of dialect quoting (quoteIdent, src/sdk/sql.ts) — same allow-list + * posture as impersonate's validateUsername (dialect-strategy.ts). Dots are + * rejected (unlike usernames) because a schema name containing one would be + * mis-split by quoteIdent's split-on-first-`.` qualification logic. + */ +function validateSchemaName(name: string): void { + + if (!name || !VALID_SCHEMA_NAME.test(name)) { + + throw new Error( + `Invalid schema name: "${name}". ` + + 'Only alphanumeric characters and underscores are allowed.', + ); + + } + +} + +/** + * 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 // ───────────────────────────────────────────────────────────── @@ -67,6 +110,13 @@ export class Context void>(); + /** + * Schema this context is scoped to, or `null` for the root context. + * Set once by withSchema() and never mutated afterward — a chained + * withSchema() call derives a new Context rather than changing this. + */ + #schema: string | null = null; + constructor( config: Config, settings: Settings, @@ -105,7 +155,62 @@ export class Context { - return requireConnection(this.#state).db as Kysely; + const db = requireConnection(this.#state).db as Kysely; + + // Re-derived on every access rather than cached: caching the wrapped + // instance would let a stale schema wrap survive a reconnect, and + // would make chained withSchema() calls stack instead of replace. + return this.#schema === null ? db : db.withSchema(this.#schema); + + } + + // ───────────────────────────────────────────────────────── + // Schema Scoping + // ───────────────────────────────────────────────────────── + + /** + * Derive a schema-scoped Context sharing this context's connection, + * pool, and #heldConnections — same lifecycle, fresh generics for the + * schema's own table/routine shape. + * + * Replaces rather than stacks: calling withSchema() again on an + * already-derived context swaps the schema instead of nesting it, + * because every derived context re-derives its `.kysely` from the + * shared connection's bare instance, never from another derived + * context's already-wrapped one. + * + * `sql`-tagged raw fragments are not rewritten by this — they resolve + * against the connection's default schema regardless of this call. + * + * @throws Error synchronously if `name` fails identifier validation, + * before any connection is borrowed or shared state is touched. + * + * @example + * ```typescript + * const acct = ctx.withSchema('acct'); + * const rows = await acct.kysely.selectFrom('ledger').selectAll().execute(); + * await acct.proc('rebuild_ledger', { id: 1 }); // -> acct.rebuild_ledger + * ``` + */ + withSchema( + name: string, + ): Context { + + validateSchemaName(name); + + const derived = new Context( + this.#state.config, + this.#state.settings, + this.#state.identity, + this.#state.options, + this.#state.projectRoot, + ); + + derived.#state = this.#state; + derived.#heldConnections = this.#heldConnections; + derived.#schema = name; + + return derived; } @@ -246,14 +351,15 @@ export class Context { const params = args[0] as Record | unknown[] | undefined; + const qualifiedName = qualifyName(this.#schema, name); if (this.dialect === 'postgres') { - return this.#executeProcPostgres(name, params); + return this.#executeProcPostgres(qualifiedName, params); } - const query = buildProcCall(this.dialect, name, params); + const query = buildProcCall(this.dialect, qualifiedName, params); const result = await query.execute(this.kysely); return (result.rows ?? []) as T[]; @@ -333,7 +439,7 @@ export class Context | unknown[] : undefined; const column = (hasParams ? args[1] : args[0]) as string; - const query = buildFuncCall(this.dialect, name, column, params); + 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; @@ -372,7 +478,7 @@ export class Context { const params = args[0] as Record | unknown[] | undefined; - const query = buildTvfCall(this.dialect, name, params); + const query = buildTvfCall(this.dialect, qualifyName(this.#schema, name), params); const result = await query.execute(this.kysely); return (result.rows ?? []) as T[]; diff --git a/tests/sdk/with-schema.test.ts b/tests/sdk/with-schema.test.ts new file mode 100644 index 00000000..a2a15768 --- /dev/null +++ b/tests/sdk/with-schema.test.ts @@ -0,0 +1,448 @@ +/** + * Context.withSchema() tests. + * + * Covers schema-name validation, kysely getter re-derivation (compiled-SQL + * qualification, replace-not-stack, no caching), proc/func/tvf name + * prefixing, noorm pass-through, and shared #heldConnections across a + * derived context and its parent. + * + * kysely-getter assertions connect for real against SQLite `:memory:` + * (no external service, in-process, matches tests/core/connection/factory.test.ts's + * precedent) rather than mocking `createConnection` — mock.module never + * restores in this repo (see root CLAUDE.md), and this file is loaded + * within the same `bun test --serial` process as the rest of tests/sdk. + * proc/func/tvf and impersonate assertions use DummyDriver + a mocked + * executor instead, mirroring tests/sdk/context.test.ts and + * tests/sdk/impersonate/impersonate.test.ts. + */ +import { describe, it, expect, vi, afterEach } from 'bun:test'; +import { + Kysely, + DummyDriver, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely'; + +import { Context } from '../../src/sdk/context.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'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +function createMockConfig(dialect: Config['connection']['dialect']): Config { + + return { + name: 'test', + type: 'local', + isTest: true, + access: { user: 'admin', agent: 'admin' }, + connection: dialect === 'sqlite' + ? { dialect, database: ':memory:' } + : { dialect, database: 'testdb' }, + }; + +} + +const mockSettings: Settings = {}; + +const mockIdentity: Identity = { + name: 'tester', + source: 'system', +}; + +interface Ledger { id: number; amount: number } +interface AcctDB { ledger: Ledger } + +interface TestProcs { + 'rebuild_ledger': [{ id: number }, void]; + 'other.rebuild_ledger': [{ id: number }, void]; +} + +interface TestFuncs { + 'calc_total': [{ order_id: number }, { total: number }]; +} + +interface TestTvfs { + 'search_ledger': [{ q: string }, Ledger]; +} + +function createCtx( + dialect: Config['connection']['dialect'] = 'postgres', +) { + + return new Context( + createMockConfig(dialect), + mockSettings, + mockIdentity, + {}, + '/tmp/test-project', + ); + +} + +/** + * DummyDriver-backed Kysely with a mocked executor — captures compiled SQL + * without hitting a real database. Mirrors context.test.ts / impersonate.test.ts. + */ +function createMockKysely(rows: Record[] = []) { + + const executedSql: string[] = []; + + const executeQueryMock = vi.fn().mockImplementation((compiledQuery) => { + + executedSql.push(compiledQuery.sql); + + return { rows }; + + }); + + const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + + const originalExecutor = db.getExecutor(); + + vi.spyOn(originalExecutor, 'provideConnection').mockImplementation(async (consumer) => { + + return consumer({ + executeQuery: executeQueryMock, + streamQuery: () => { + + throw new Error('not implemented'); + + }, + }); + + }); + + return { db, executedSql, executeQueryMock }; + +} + +// ───────────────────────────────────────────────────────────── +// Schema Name Validation +// ───────────────────────────────────────────────────────────── + +describe('sdk: Context.withSchema validation', () => { + + it('throws synchronously for an empty schema name', () => { + + const ctx = createCtx(); + + expect(() => ctx.withSchema('')).toThrow(); + + }); + + it('throws synchronously for schema names with unsafe characters', () => { + + const ctx = createCtx(); + + expect(() => ctx.withSchema('acct; DROP TABLE users')).toThrow(); + expect(() => ctx.withSchema('acct.other')).toThrow(); + expect(() => ctx.withSchema('acct-name')).toThrow(); + expect(() => ctx.withSchema('"acct"')).toThrow(); + expect(() => ctx.withSchema('acct name')).toThrow(); + + }); + + it('accepts alphanumeric and underscore schema names', () => { + + const ctx = createCtx(); + + expect(() => ctx.withSchema('acct_1')).not.toThrow(); + + }); + + it('leaves the context usable after a rejected schema name — no partial state mutation', () => { + + const ctx = createCtx(); + + expect(() => ctx.withSchema('bad name')).toThrow(); + expect(ctx.connected).toBe(false); + + // A later valid call still succeeds, proving the earlier throw left + // no partial derivation or mutated shared state behind. + expect(() => ctx.withSchema('good_name')).not.toThrow(); + + }); + +}); + +// ───────────────────────────────────────────────────────────── +// kysely getter — schema-qualified compiled SQL +// ───────────────────────────────────────────────────────────── + +describe('sdk: Context.withSchema kysely getter', () => { + + const connections: Context[] = []; + + afterEach(async () => { + + for (const ctx of connections.splice(0)) await ctx.disconnect(); + + }); + + async function connectedCtx() { + + const ctx = createCtx('sqlite'); + + await ctx.connect(); + connections.push(ctx); + + return ctx; + + } + + it('compiles queries qualified with the derived schema', async () => { + + const ctx = await connectedCtx(); + const derived = ctx.withSchema('acct'); + + const compiled = derived.kysely.selectFrom('ledger').selectAll().compile(); + + expect(compiled.sql).toBe('select * from "acct"."ledger"'); + + }); + + it('leaves the parent context unqualified', async () => { + + const ctx = await connectedCtx(); + + ctx.withSchema('acct'); // derived, but never queried through + + const compiled = ctx.kysely.selectFrom('ledger').selectAll().compile(); + + expect(compiled.sql).toBe('select * from "ledger"'); + + }); + + it('replaces rather than stacks on a chained withSchema call', async () => { + + const ctx = await connectedCtx(); + const a = ctx.withSchema('a'); + const b = a.withSchema('b'); + + expect(b.kysely.selectFrom('ledger').selectAll().compile().sql).toBe('select * from "b"."ledger"'); + + // 'a' is a distinct instance, untouched by deriving 'b' from it. + expect(a.kysely.selectFrom('ledger').selectAll().compile().sql).toBe('select * from "a"."ledger"'); + + }); + + it('never caches the wrapped instance — each access re-derives fresh', async () => { + + const ctx = await connectedCtx(); + const derived = ctx.withSchema('acct'); + + expect(derived.kysely).not.toBe(derived.kysely); + + }); + +}); + +// ───────────────────────────────────────────────────────────── +// proc / func / tvf prefixing +// ───────────────────────────────────────────────────────────── + +describe('sdk: Context.withSchema proc/func/tvf prefixing', () => { + + it('prefixes an unqualified proc name with the derived schema', async () => { + + const ctx = createCtx('postgres'); + const derived = ctx.withSchema('acct'); + const { db, executeQueryMock } = createMockKysely(); + + Object.defineProperty(derived, 'kysely', { value: db, configurable: true }); + + await derived.proc('rebuild_ledger', { id: 1 }); + + const query = executeQueryMock.mock.calls[0]![0]; + expect(query.sql).toBe('CALL "acct"."rebuild_ledger"("id" => $1)'); + + }); + + it('passes an already-dotted proc name through unchanged', async () => { + + const ctx = createCtx('postgres'); + const derived = ctx.withSchema('acct'); + const { db, executeQueryMock } = createMockKysely(); + + Object.defineProperty(derived, 'kysely', { value: db, configurable: true }); + + await derived.proc('other.rebuild_ledger', { id: 1 }); + + const query = executeQueryMock.mock.calls[0]![0]; + expect(query.sql).toBe('CALL "other"."rebuild_ledger"("id" => $1)'); + + }); + + it('does not prefix the parent context\'s proc calls', async () => { + + const ctx = createCtx('postgres'); + + ctx.withSchema('acct'); // derived, but proc is called on the parent + + const { db, executeQueryMock } = createMockKysely(); + + Object.defineProperty(ctx, 'kysely', { value: db, configurable: true }); + + await ctx.proc('rebuild_ledger', { id: 1 }); + + const query = executeQueryMock.mock.calls[0]![0]; + expect(query.sql).toBe('CALL "rebuild_ledger"("id" => $1)'); + + }); + + it('prefixes an unqualified func name with the derived schema', async () => { + + const ctx = createCtx('postgres'); + const derived = ctx.withSchema('acct'); + const { db, executeQueryMock } = createMockKysely([{ total: 1 }]); + + Object.defineProperty(derived, 'kysely', { value: db, configurable: true }); + + await derived.func('calc_total', { order_id: 1 }, 'total'); + + const query = executeQueryMock.mock.calls[0]![0]; + expect(query.sql).toBe('SELECT "acct"."calc_total"("order_id" => $1) AS "total"'); + + }); + + it('prefixes an unqualified tvf name with the derived schema', async () => { + + const ctx = createCtx('postgres'); + const derived = ctx.withSchema('acct'); + const { db, executeQueryMock } = createMockKysely(); + + Object.defineProperty(derived, 'kysely', { value: db, configurable: true }); + + await derived.tvf('search_ledger', { q: 'x' }); + + const query = executeQueryMock.mock.calls[0]![0]; + expect(query.sql).toBe('SELECT * FROM "acct"."search_ledger"("q" => $1)'); + + }); + +}); + +// ───────────────────────────────────────────────────────────── +// noorm pass-through +// ───────────────────────────────────────────────────────────── + +describe('sdk: Context.withSchema noorm pass-through', () => { + + it('exposes the same config/settings/identity as the parent — shared #state', () => { + + const ctx = createCtx(); + const derived = ctx.withSchema('acct'); + + expect(derived.noorm.config).toBe(ctx.noorm.config); + expect(derived.noorm.settings).toBe(ctx.noorm.settings); + expect(derived.noorm.identity).toBe(ctx.noorm.identity); + + }); + + it('has no schema-specific noorm behavior — dialect stays the parent\'s', () => { + + const ctx = createCtx('mssql'); + const derived = ctx.withSchema('acct'); + + expect(derived.dialect).toBe(ctx.dialect); + + }); + +}); + +// ───────────────────────────────────────────────────────────── +// Shared #heldConnections +// ───────────────────────────────────────────────────────────── + +describe('sdk: Context.withSchema shared #heldConnections', () => { + + it('releases a derived context\'s explicit impersonation scope when the parent disconnects', async () => { + + const ctx = createCtx('sqlite'); + + // Real, in-process connection — needed so disconnect() proceeds past + // its #state.connection guard. dialect/kysely are overridden below + // on the derived context so impersonate() borrows a fake + // postgres-shaped connection instead of the real sqlite one. + await ctx.connect(); + + const derived = ctx.withSchema('acct'); + + const executedSql: string[] = []; + let markReleased!: () => void; + const released = new Promise((resolve) => { + + markReleased = resolve; + + }); + + const executeQueryMock = vi.fn().mockImplementation((compiledQuery) => { + + executedSql.push(compiledQuery.sql); + + return { rows: [] }; + + }); + + const mockDb = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + + vi.spyOn(mockDb.getExecutor(), 'provideConnection').mockImplementation(async (consumer) => { + + // Resolves only once Context's impersonate-explicit callback + // finishes awaiting its held-connection promise — i.e. only + // after something calls release(). Proves #heldConnections + // actually drained, not just that disconnect() ran. + const result = await consumer({ + executeQuery: executeQueryMock, + streamQuery: () => { + + throw new Error('not implemented'); + + }, + }); + + markReleased(); + + return result; + + }); + + Object.defineProperty(derived, 'kysely', { value: mockDb, configurable: true }); + Object.defineProperty(derived, 'dialect', { value: 'postgres', configurable: true }); + + await derived.impersonate('bob'); + + expect(executedSql[0]).toBe("SET ROLE 'bob'"); + + await ctx.disconnect(); + + const outcome = await Promise.race([ + released.then(() => 'released' as const), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 500)), + ]); + + expect(outcome).toBe('released'); + + }); + +}); From 0440febfab144ea0c7e0d66069fa0b890894aad0 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 15:37:22 -0400 Subject: [PATCH 05/10] test(sdk): withSchema three-schema integration coverage --- tests/integration/sdk/with-schema.test.ts | 641 ++++++++++++++++++++++ 1 file changed, 641 insertions(+) create mode 100644 tests/integration/sdk/with-schema.test.ts diff --git a/tests/integration/sdk/with-schema.test.ts b/tests/integration/sdk/with-schema.test.ts new file mode 100644 index 00000000..9c0785ef --- /dev/null +++ b/tests/integration/sdk/with-schema.test.ts @@ -0,0 +1,641 @@ +/** + * Integration tests for Context.withSchema() against live databases. + * + * Provisions three schemas per dialect via the native qualifier (schemas + * on postgres/mssql, databases on mysql, ATTACHed databases on sqlite), + * each with a distinct table shape and TypeScript type, then proves: + * + * - derived-context isolation under interleaved reads/writes across + * three derived contexts plus the parent + * - transaction() inherits schema scoping from a derived context + * - impersonate() composes with a derived context (postgres/mssql only — + * mysql/sqlite have no impersonation strategy, dialect-strategy.ts) + * - parent.disconnect() releases a held connection opened through a + * derived context (postgres/mssql only, same reason) + * + * Schema/database names are suffixed with a per-run random id so + * concurrent runs against the same shared docker-compose.test.yml + * containers (e.g. two worktrees testing at once) never collide. + * + * Requires docker-compose.test.yml containers (postgres 15432, mysql + * 13306, mssql 11433); sqlite runs in-process, no container needed. + */ +import { randomUUID } from 'node:crypto'; + +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Kysely, sql } from 'kysely'; +import { attempt } from '@logosdx/utils'; + +import { Context } from '../../../src/sdk/context.js'; +import { createConnection } from '../../../src/core/connection/factory.js'; +import { + skipIfNoContainer, + makeTestConfig, + TEST_CONNECTIONS, +} from '../../utils/db.js'; + +import type { ConnectionResult } from '../../../src/core/connection/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixture shapes +// ───────────────────────────────────────────────────────────── + +/** Parent's own table lives in the connection's default (unqualified) schema. */ +interface ParentItem { id: number; sku: string } +interface AItem { id: number; label: string } +interface BItem { id: number; quantity: number } +interface CItem { id: number; weight: number; unit: string } + +/** + * Table name is generated per-run (`items_parent_`), so the DB + * shape is keyed by an index signature rather than a literal — there is + * no compile-time-known table name to key a plain interface on. + */ +interface ParentDb { [table: string]: ParentItem } +interface ADb { items: AItem } +interface BDb { items: BItem } +interface CDb { items: CItem } + +interface SchemaNames { a: string; b: string; c: string } + +const runId = randomUUID().slice(0, 8); + +function makeSchemaNames(prefix: string): SchemaNames { + + return { + a: `wschema_${prefix}_a_${runId}`, + b: `wschema_${prefix}_b_${runId}`, + c: `wschema_${prefix}_c_${runId}`, + }; + +} + +async function runAll(db: Kysely, statements: string[]): Promise { + + for (const statement of statements) { + + await sql.raw(statement).execute(db); + + } + +} + +async function runIgnoringErrors(db: Kysely, statements: string[]): Promise { + + for (const statement of statements) { + + await attempt(() => sql.raw(statement).execute(db)); + + } + +} + +/** + * Interleave inserts and updates across the parent and all three derived + * contexts, then read each back through its own context. + * + * Every table is named `items` (or, for the parent, a unique generated + * name) but carries a distinct column shape per schema — a qualifier bug + * that lets a derived context fall through to the wrong schema, or lets + * two contexts collide on one physical table, surfaces as a thrown + * "column does not exist" error or a mismatched/duplicated row here, + * never as a silent pass. + */ +async function assertInterleavedIsolation(fixture: { + parent: { ctx: Context; table: string }; + a: { ctx: Context }; + b: { ctx: Context }; + c: { ctx: Context }; +}): Promise { + + const { parent, a, b, c } = fixture; + + const parentRow: ParentItem = { id: 1, sku: 'PARENT-SKU-1' }; + const aRow: AItem = { id: 1, label: 'widget-a' }; + const bRow: BItem = { id: 1, quantity: 7 }; + const cRow: CItem = { id: 1, weight: 12, unit: 'kg' }; + + // Round 1 — interleaved inserts, scrambled order, run concurrently. + await Promise.all([ + b.ctx.kysely.insertInto('items').values(bRow).execute(), + parent.ctx.kysely.insertInto(parent.table).values(parentRow).execute(), + a.ctx.kysely.insertInto('items').values(aRow).execute(), + c.ctx.kysely.insertInto('items').values(cRow).execute(), + ]); + + const [cRows1, aRows1, parentRows1, bRows1] = await Promise.all([ + c.ctx.kysely.selectFrom('items').selectAll().execute(), + a.ctx.kysely.selectFrom('items').selectAll().execute(), + parent.ctx.kysely.selectFrom(parent.table).selectAll().execute(), + b.ctx.kysely.selectFrom('items').selectAll().execute(), + ]); + + expect(aRows1).toEqual([aRow]); + expect(bRows1).toEqual([bRow]); + expect(cRows1).toEqual([cRow]); + expect(parentRows1).toEqual([parentRow]); + + // Round 2 — interleaved updates in a different scramble, proving + // isolation holds across a second wave, not just the initial write. + const aRowV2 = { ...aRow, label: 'widget-a-v2' }; + const bRowV2 = { ...bRow, quantity: 8 }; + const cRowV2 = { ...cRow, weight: 13 }; + const parentRowV2 = { ...parentRow, sku: 'PARENT-SKU-1-v2' }; + + await Promise.all([ + a.ctx.kysely.updateTable('items').set({ label: aRowV2.label }).where('id', '=', 1).execute(), + c.ctx.kysely.updateTable('items').set({ weight: cRowV2.weight }).where('id', '=', 1).execute(), + parent.ctx.kysely.updateTable(parent.table).set({ sku: parentRowV2.sku }).where('id', '=', 1).execute(), + b.ctx.kysely.updateTable('items').set({ quantity: bRowV2.quantity }).where('id', '=', 1).execute(), + ]); + + const [parentRows2, bRows2, aRows2, cRows2] = await Promise.all([ + parent.ctx.kysely.selectFrom(parent.table).selectAll().execute(), + b.ctx.kysely.selectFrom('items').selectAll().execute(), + a.ctx.kysely.selectFrom('items').selectAll().execute(), + c.ctx.kysely.selectFrom('items').selectAll().execute(), + ]); + + expect(aRows2).toEqual([aRowV2]); + expect(bRows2).toEqual([bRowV2]); + expect(cRows2).toEqual([cRowV2]); + expect(parentRows2).toEqual([parentRowV2]); + +} + +// ───────────────────────────────────────────────────────────── +// PostgreSQL +// ───────────────────────────────────────────────────────────── + +describe('integration: sdk withSchema postgres', () => { + + const names = makeSchemaNames('pg'); + const parentTable = `items_parent_pg_${runId}`; + const TEST_ROLE = `wschema_pg_role_${runId}`; + + let ctx: Context; + let a: Context; + let b: Context; + let c: Context; + + async function dropRoleCompletely(db: Kysely): Promise { + + await sql.raw(` + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${TEST_ROLE}') THEN + EXECUTE 'DROP OWNED BY ${TEST_ROLE}'; + EXECUTE 'DROP ROLE ${TEST_ROLE}'; + END IF; + END $$; + `).execute(db); + + } + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + ctx = new Context( + makeTestConfig('pg_with_schema', TEST_CONNECTIONS.postgres), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await ctx.connect(); + + await runAll(ctx.kysely, [ + `CREATE SCHEMA "${names.a}"`, + `CREATE SCHEMA "${names.b}"`, + `CREATE SCHEMA "${names.c}"`, + `CREATE TABLE "${names.a}"."items" (id integer primary key, label text not null)`, + `CREATE TABLE "${names.b}"."items" (id integer primary key, quantity integer not null)`, + `CREATE TABLE "${names.c}"."items" (id integer primary key, weight integer not null, unit text not null)`, + `CREATE TABLE "${parentTable}" (id integer primary key, sku text not null)`, + `CREATE ROLE ${TEST_ROLE} LOGIN PASSWORD 'test123'`, + `GRANT ${TEST_ROLE} TO noorm_test WITH SET true`, + `GRANT USAGE ON SCHEMA "${names.a}" TO ${TEST_ROLE}`, + `GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA "${names.a}" TO ${TEST_ROLE}`, + ]); + + a = ctx.withSchema(names.a); + b = ctx.withSchema(names.b); + c = ctx.withSchema(names.c); + + }, 30_000); + + afterAll(async () => { + + if (!ctx?.connected) return; + + await dropRoleCompletely(ctx.kysely); + await runIgnoringErrors(ctx.kysely, [ + `DROP TABLE IF EXISTS "${parentTable}"`, + `DROP SCHEMA IF EXISTS "${names.a}" CASCADE`, + `DROP SCHEMA IF EXISTS "${names.b}" CASCADE`, + `DROP SCHEMA IF EXISTS "${names.c}" CASCADE`, + ]); + await ctx.disconnect(); + + }); + + it('provisions three schemas with distinct shapes and isolates interleaved reads/writes across the parent and all three', async () => { + + await assertInterleavedIsolation({ + parent: { ctx, table: parentTable }, + a: { ctx: a }, + b: { ctx: b }, + c: { ctx: c }, + }); + + }); + + it('transaction() inherits schema scoping from a derived context', async () => { + + await a.transaction(async (trx) => { + + await trx.insertInto('items').values({ id: 2, label: 'via-transaction' }).execute(); + + }); + + const ownRows = await a.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(ownRows).toEqual([{ id: 2, label: 'via-transaction' }]); + + // The transaction ran scoped to schema A — not silently against a + // sibling schema. + const siblingRows = await b.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(siblingRows).toEqual([]); + + }); + + it('impersonate() composes with a derived context — resolves against the derived schema', async () => { + + const result = await a.impersonate(TEST_ROLE, async (scope) => { + + const identity = await sql<{ username: string }>`SELECT current_user AS username`.execute(scope.kysely); + + await scope.kysely.insertInto('items').values({ id: 3, label: 'via-impersonation' }).execute(); + const rows = await scope.kysely.selectFrom('items').selectAll().where('id', '=', 3).execute(); + + return { username: identity.rows[0]!.username, rows }; + + }); + + expect(result.username).toBe(TEST_ROLE); + expect(result.rows).toEqual([{ id: 3, label: 'via-impersonation' }]); + + }); + + it('parent.disconnect() releases a held connection opened through a derived context', async () => { + + const leaky = new Context( + makeTestConfig('pg_with_schema_leak', TEST_CONNECTIONS.postgres), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await leaky.connect(); + + const leakyA = leaky.withSchema(names.a); + + // Explicit mode, deliberately never reverted. + await leakyA.impersonate(TEST_ROLE); + + const [, err] = await attempt(() => Promise.race([ + leaky.disconnect(), + new Promise((_, reject) => setTimeout( + () => reject(new Error('disconnect() hung')), + 5000, + ).unref()), + ])); + + expect(err).toBeNull(); + expect(leaky.connected).toBe(false); + + }, 15_000); + +}); + +// ───────────────────────────────────────────────────────────── +// MySQL +// ───────────────────────────────────────────────────────────── + +describe('integration: sdk withSchema mysql', () => { + + const names = makeSchemaNames('mysql'); + const parentTable = `items_parent_mysql_${runId}`; + + let ctx: Context; + let a: Context; + let b: Context; + let c: Context; + let systemConn: ConnectionResult; + + beforeAll(async () => { + + await skipIfNoContainer('mysql'); + + const { database: _unused, ...mysqlNoDb } = TEST_CONNECTIONS.mysql; + + systemConn = await createConnection({ + ...mysqlNoDb, + user: 'root', + database: 'information_schema', + }, 'system'); + + for (const name of [names.a, names.b, names.c]) { + + await sql.raw(`CREATE DATABASE \`${name}\``).execute(systemConn.db); + await sql.raw( + `GRANT ALL PRIVILEGES ON \`${name}\`.* TO '${TEST_CONNECTIONS.mysql.user}'@'%'`, + ).execute(systemConn.db); + + } + await sql.raw('FLUSH PRIVILEGES').execute(systemConn.db); + + ctx = new Context( + makeTestConfig('mysql_with_schema', TEST_CONNECTIONS.mysql), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await ctx.connect(); + + await runAll(ctx.kysely, [ + `CREATE TABLE \`${names.a}\`.items (id INT PRIMARY KEY, label VARCHAR(64) NOT NULL)`, + `CREATE TABLE \`${names.b}\`.items (id INT PRIMARY KEY, quantity INT NOT NULL)`, + `CREATE TABLE \`${names.c}\`.items (id INT PRIMARY KEY, weight INT NOT NULL, unit VARCHAR(16) NOT NULL)`, + `CREATE TABLE \`${parentTable}\` (id INT PRIMARY KEY, sku VARCHAR(64) NOT NULL)`, + ]); + + a = ctx.withSchema(names.a); + b = ctx.withSchema(names.b); + c = ctx.withSchema(names.c); + + }, 30_000); + + afterAll(async () => { + + if (ctx?.connected) { + + await runIgnoringErrors(ctx.kysely, [`DROP TABLE IF EXISTS \`${parentTable}\``]); + await ctx.disconnect(); + + } + + if (systemConn) { + + for (const name of [names.a, names.b, names.c]) { + + await attempt(() => sql.raw(`DROP DATABASE IF EXISTS \`${name}\``).execute(systemConn.db)); + + } + await systemConn.destroy(); + + } + + }); + + it('provisions three databases with distinct shapes and isolates interleaved reads/writes across the parent and all three', async () => { + + await assertInterleavedIsolation({ + parent: { ctx, table: parentTable }, + a: { ctx: a }, + b: { ctx: b }, + c: { ctx: c }, + }); + + }); + + it('transaction() inherits schema scoping from a derived context', async () => { + + await a.transaction(async (trx) => { + + await trx.insertInto('items').values({ id: 2, label: 'via-transaction' }).execute(); + + }); + + const ownRows = await a.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(ownRows).toEqual([{ id: 2, label: 'via-transaction' }]); + + const siblingRows = await b.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(siblingRows).toEqual([]); + + }); + + // No impersonate()/held-connection coverage here — dialectStrategy.mysql + // is null (src/sdk/impersonate/dialect-strategy.ts), so Context.impersonate() + // throws before borrowing a connection. Nothing to compose against. + +}); + +// ───────────────────────────────────────────────────────────── +// MSSQL +// ───────────────────────────────────────────────────────────── + +describe('integration: sdk withSchema mssql', () => { + + const names = makeSchemaNames('mssql'); + const parentTable = `items_parent_mssql_${runId}`; + const TEST_USER = `wschema_mssql_user_${runId}`; + + let ctx: Context; + let a: Context; + let b: Context; + let c: Context; + + beforeAll(async () => { + + await skipIfNoContainer('mssql'); + + ctx = new Context( + makeTestConfig('mssql_with_schema', TEST_CONNECTIONS.mssql), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await ctx.connect(); + + await runAll(ctx.kysely, [ + `CREATE SCHEMA [${names.a}]`, + `CREATE SCHEMA [${names.b}]`, + `CREATE SCHEMA [${names.c}]`, + `CREATE TABLE [${names.a}].items (id INT PRIMARY KEY, label NVARCHAR(64) NOT NULL)`, + `CREATE TABLE [${names.b}].items (id INT PRIMARY KEY, quantity INT NOT NULL)`, + `CREATE TABLE [${names.c}].items (id INT PRIMARY KEY, weight INT NOT NULL, unit NVARCHAR(16) NOT NULL)`, + `CREATE TABLE [${parentTable}] (id INT PRIMARY KEY, sku NVARCHAR(64) NOT NULL)`, + `CREATE USER [${TEST_USER}] WITHOUT LOGIN`, + `GRANT SELECT, INSERT, UPDATE ON SCHEMA::[${names.a}] TO [${TEST_USER}]`, + ]); + + a = ctx.withSchema(names.a); + b = ctx.withSchema(names.b); + c = ctx.withSchema(names.c); + + }, 30_000); + + afterAll(async () => { + + if (!ctx?.connected) return; + + await runIgnoringErrors(ctx.kysely, [ + `DROP USER IF EXISTS [${TEST_USER}]`, + `DROP TABLE IF EXISTS [${parentTable}]`, + `DROP TABLE IF EXISTS [${names.a}].items`, + `DROP TABLE IF EXISTS [${names.b}].items`, + `DROP TABLE IF EXISTS [${names.c}].items`, + `DROP SCHEMA IF EXISTS [${names.a}]`, + `DROP SCHEMA IF EXISTS [${names.b}]`, + `DROP SCHEMA IF EXISTS [${names.c}]`, + ]); + await ctx.disconnect(); + + }); + + it('provisions three schemas with distinct shapes and isolates interleaved reads/writes across the parent and all three', async () => { + + await assertInterleavedIsolation({ + parent: { ctx, table: parentTable }, + a: { ctx: a }, + b: { ctx: b }, + c: { ctx: c }, + }); + + }); + + it('transaction() inherits schema scoping from a derived context', async () => { + + await a.transaction(async (trx) => { + + await trx.insertInto('items').values({ id: 2, label: 'via-transaction' }).execute(); + + }); + + const ownRows = await a.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(ownRows).toEqual([{ id: 2, label: 'via-transaction' }]); + + const siblingRows = await b.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(siblingRows).toEqual([]); + + }); + + it('impersonate() composes with a derived context — resolves against the derived schema', async () => { + + const result = await a.impersonate(TEST_USER, async (scope) => { + + const identity = await sql<{ username: string }>`SELECT USER_NAME() AS username`.execute(scope.kysely); + + await scope.kysely.insertInto('items').values({ id: 3, label: 'via-impersonation' }).execute(); + const rows = await scope.kysely.selectFrom('items').selectAll().where('id', '=', 3).execute(); + + return { username: identity.rows[0]!.username, rows }; + + }); + + expect(result.username).toBe(TEST_USER); + expect(result.rows).toEqual([{ id: 3, label: 'via-impersonation' }]); + + }); + + it('parent.disconnect() releases a held connection opened through a derived context', async () => { + + const leaky = new Context( + makeTestConfig('mssql_with_schema_leak', TEST_CONNECTIONS.mssql), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await leaky.connect(); + + const leakyA = leaky.withSchema(names.a); + + // Explicit mode, deliberately never reverted. + await leakyA.impersonate(TEST_USER); + + const [, err] = await attempt(() => Promise.race([ + leaky.disconnect(), + new Promise((_, reject) => setTimeout( + () => reject(new Error('disconnect() hung')), + 5000, + ).unref()), + ])); + + expect(err).toBeNull(); + expect(leaky.connected).toBe(false); + + }, 15_000); + +}); + +// ───────────────────────────────────────────────────────────── +// SQLite +// ───────────────────────────────────────────────────────────── + +describe('integration: sdk withSchema sqlite', () => { + + const names = makeSchemaNames('sqlite'); + const parentTable = `items_parent_sqlite_${runId}`; + + let ctx: Context; + let a: Context; + let b: Context; + let c: Context; + + beforeAll(async () => { + + ctx = new Context( + makeTestConfig('sqlite_with_schema', TEST_CONNECTIONS.sqlite), + {}, { name: 'tester', source: 'system' }, {}, '/tmp/test', + ); + await ctx.connect(); + + // In-memory ATTACHed databases — no file, no cleanup needed beyond + // closing the connection. sqlite's native "schema" qualifier. + await runAll(ctx.kysely, [ + `ATTACH DATABASE ':memory:' AS "${names.a}"`, + `ATTACH DATABASE ':memory:' AS "${names.b}"`, + `ATTACH DATABASE ':memory:' AS "${names.c}"`, + `CREATE TABLE "${names.a}"."items" (id INTEGER PRIMARY KEY, label TEXT NOT NULL)`, + `CREATE TABLE "${names.b}"."items" (id INTEGER PRIMARY KEY, quantity INTEGER NOT NULL)`, + `CREATE TABLE "${names.c}"."items" (id INTEGER PRIMARY KEY, weight INTEGER NOT NULL, unit TEXT NOT NULL)`, + `CREATE TABLE "${parentTable}" (id INTEGER PRIMARY KEY, sku TEXT NOT NULL)`, + ]); + + a = ctx.withSchema(names.a); + b = ctx.withSchema(names.b); + c = ctx.withSchema(names.c); + + }); + + afterAll(async () => { + + if (!ctx?.connected) return; + + // Attached in-memory databases vanish with the connection — no + // DETACH/DROP needed, unlike the file-backed dialects above. + await ctx.disconnect(); + + }); + + it('provisions three ATTACHed databases with distinct shapes and isolates interleaved reads/writes across the parent and all three', async () => { + + await assertInterleavedIsolation({ + parent: { ctx, table: parentTable }, + a: { ctx: a }, + b: { ctx: b }, + c: { ctx: c }, + }); + + }); + + it('transaction() inherits schema scoping from a derived context', async () => { + + await a.transaction(async (trx) => { + + await trx.insertInto('items').values({ id: 2, label: 'via-transaction' }).execute(); + + }); + + const ownRows = await a.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(ownRows).toEqual([{ id: 2, label: 'via-transaction' }]); + + const siblingRows = await b.kysely.selectFrom('items').selectAll().where('id', '=', 2).execute(); + expect(siblingRows).toEqual([]); + + }); + + // No impersonate()/held-connection coverage here — dialectStrategy.sqlite + // is null (src/sdk/impersonate/dialect-strategy.ts), so Context.impersonate() + // throws before borrowing a connection. Nothing to compose against. + +}); From 27411be2c59e9cd4f81e716e3ad498d5631e6197 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 15:47:55 -0400 Subject: [PATCH 06/10] docs(sdk): document withSchema and add changeset --- .changeset/sdk-with-schema.md | 9 +++++++ docs/reference/sdk.md | 48 +++++++++++++++++++++++++++++++++++ packages/sdk/README.md | 23 +++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 .changeset/sdk-with-schema.md diff --git a/.changeset/sdk-with-schema.md b/.changeset/sdk-with-schema.md new file mode 100644 index 00000000..ec6c58fc --- /dev/null +++ b/.changeset/sdk-with-schema.md @@ -0,0 +1,9 @@ +--- +"@noormdev/sdk": minor +--- + +## Added + +* `feat(sdk):` `ctx.withSchema(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 diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index b354d83c..5f090229 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -169,6 +169,54 @@ Explicit mode holds a pooled connection until you call `revert()`. `ctx.disconne An `ImpersonatedScope` carries no `noorm` namespace and no lifecycle methods. Management operations stay on `ctx.noorm`, running as the context's own principal. +## Schema Scoping + + +### withSchema(name) + +Derive a `Context` scoped to one schema. Same connection, same pool, same lifecycle as the parent — `withSchema` is a typed wrapper over Kysely's own `withSchema`, not a new connection or config-level state. + +```typescript +const acct = ctx.withSchema('accounting'); + +const invoices = await acct.kysely + .selectFrom('invoices') + .selectAll() + .execute(); +// select * from "accounting"."invoices" — typed against AcctDB + +await acct.proc('rebuild_ledger', { year: 2026 }); +// CALL "accounting"."rebuild_ledger"("year" => $1) + +await acct.proc('billing.close_period'); +// CALL "billing"."close_period"() — caller's qualification wins, no prefix added + +await ctx.kysely.selectFrom('users').execute(); +// select * from "users" — the parent is untouched, no prefix + +await ctx.disconnect(); // one lifecycle closes both +``` + +`withSchema(name)` takes up to four generics, one per routine kind, the same shape as `createContext`, and returns a `Context` typed to that schema's own tables and routines. + +**Derivation semantics.** The derived context shares the parent's connection, pool, and lifecycle — `connect()`/`disconnect()` on either side act on both — rather than opening a new connection. `ctx.kysely` re-derives from the bare Kysely instance on every access instead of caching a wrapped copy, so a derived context can never leak a stale schema wrap across a reconnect. + +**Replace, not stack.** Calling `withSchema` again on an already-derived context swaps the schema instead of nesting it: `ctx.withSchema('a').withSchema('b')` resolves against `b` only. + +**`proc`/`func`/`tvf` prefixing.** A routine name with no `.` is qualified with the context's schema before it reaches the query builder — `acct.proc('rebuild_ledger', …)` becomes `accounting.rebuild_ledger`. A name that already contains a `.` passes through unqualified, so `acct.proc('billing.close_period')` still resolves against `billing`. + +**Transaction and impersonation composition.** `derived.transaction(fn)` and `derived.impersonate(username, fn)` both inherit the derived schema — every query inside either stays qualified against it, no extra wiring needed. An impersonation scope opened through a derived context still releases when `parent.disconnect()` runs, because `#heldConnections` is shared between parent and derived instances, not per-instance. + +**Raw SQL caveat.** `withSchema` does not rewrite unqualified identifiers inside `` sql`…` `` fragments — they resolve against the connection's default schema regardless of which context ran them. This is inherent to Kysely's plugin model, since raw `sql` bypasses the query builder plugins entirely; qualify by hand inside raw fragments. + +**Non-goals.** + +- No config/connection-level schema field. The connection stays schema-agnostic — `withSchema` is per-call-site sugar, not connection state. +- No raw-SQL rewriting (see the caveat above). +- No schema-defaulting of `ctx.noorm.db.describe*`'s `schema?` argument — pass it explicitly. +- No per-dialect gating. The qualifier means whatever Kysely's `withSchema` means for the active dialect: a schema on PostgreSQL/MSSQL, a database on MySQL, an ATTACHed database name on SQLite. + + ## Stored Procedures, Functions & TVFs Type-safe helpers for calling stored procedures, database functions, and table-valued functions. Define your signatures as interfaces, then pass them as generics to `createContext`: diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a8e3470e..a80d0b44 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -71,6 +71,29 @@ await ctx.proc('refresh_cache'); `createContext` takes a map per routine kind. Plain `void` is shorthand for "no arguments, no meaningful return". +## Schema scoping + +`ctx.withSchema(name)` derives a `Context` scoped to one schema — same connection, pool, and lifecycle as the parent, just qualified generics and query builder. + +```typescript +const acct = ctx.withSchema('accounting'); + +const invoices = await acct.kysely.selectFrom('invoices').selectAll().execute(); +// select * from "accounting"."invoices" — typed against AcctDB + +await acct.proc('rebuild_ledger', { year: 2026 }); +// CALL "accounting"."rebuild_ledger"("year" => $1) + +await ctx.kysely.selectFrom('users').execute(); // parent untouched, no prefix + +await ctx.disconnect(); // one lifecycle closes both +``` + +Calling `withSchema` again replaces the schema instead of stacking it, and a `proc`/`func`/`tvf` name that already contains a `.` passes through unqualified. Raw `` sql`…` `` fragments are not rewritten — they resolve against the connection's default schema regardless of `withSchema`. + +Non-goals: no config/connection-level schema field (the connection stays schema-agnostic); no schema-defaulting of `ctx.noorm.db.describe*`'s `schema?` arg; 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). + + ## Requires Node >= 22.13. Supports **PostgreSQL**, **MySQL**, **SQLite**, and **SQL Server**. From efb720825d88e559ebb71b7b0f122d38527208c4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:13:03 -0400 Subject: [PATCH 07/10] feat(sdk): admit hyphenated schema names in withSchema --- src/sdk/context.ts | 13 ++++++++----- tests/integration/sdk/with-schema.test.ts | 15 +++++++++++---- tests/sdk/with-schema.test.ts | 20 +++++++++++++++++++- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/sdk/context.ts b/src/sdk/context.ts index f742b154..f1970552 100644 --- a/src/sdk/context.ts +++ b/src/sdk/context.ts @@ -30,16 +30,19 @@ import type { ImpersonatedScope } from './impersonate/types.js'; // Schema Name Validation // ───────────────────────────────────────────────────────────── -const VALID_SCHEMA_NAME = /^[a-zA-Z0-9_]+$/; +const VALID_SCHEMA_NAME = /^[a-zA-Z0-9_-]+$/; /** * Validate a schema name against a restrictive character set. * * Defense-in-depth before the name is interpolated into `${schema}.${name}` * ahead of dialect quoting (quoteIdent, src/sdk/sql.ts) — same allow-list - * posture as impersonate's validateUsername (dialect-strategy.ts). Dots are - * rejected (unlike usernames) because a schema name containing one would be - * mis-split by quoteIdent's split-on-first-`.` qualification logic. + * posture as impersonate's validateUsername (dialect-strategy.ts). Hyphens + * are allowed — hyphenated schema names exist in the wild, and Kysely's + * `withSchema()` and `quoteIdent` both quote the identifier, so a hyphen is + * inert everywhere it's used. Dots are rejected (unlike usernames) because a + * schema name containing one would be mis-split by quoteIdent's + * split-on-first-`.` qualification logic. */ function validateSchemaName(name: string): void { @@ -47,7 +50,7 @@ function validateSchemaName(name: string): void { throw new Error( `Invalid schema name: "${name}". ` + - 'Only alphanumeric characters and underscores are allowed.', + 'Only alphanumeric characters, underscores, and hyphens are allowed.', ); } diff --git a/tests/integration/sdk/with-schema.test.ts b/tests/integration/sdk/with-schema.test.ts index 9c0785ef..9df0fd25 100644 --- a/tests/integration/sdk/with-schema.test.ts +++ b/tests/integration/sdk/with-schema.test.ts @@ -80,7 +80,14 @@ async function runAll(db: Kysely, statements: string[]): Promise { } -async function runIgnoringErrors(db: Kysely, statements: string[]): Promise { +/** + * Run idempotent `DROP ... IF EXISTS` teardown statements, swallowing any + * per-statement error so one already-missing object doesn't abort the rest + * of `afterAll` cleanup. Teardown-only — every call site passes `IF EXISTS` + * DROP statements; this is not a general-purpose "run and ignore errors" + * helper for non-teardown code paths. + */ +async function dropIgnoringErrors(db: Kysely, statements: string[]): Promise { for (const statement of statements) { @@ -227,7 +234,7 @@ describe('integration: sdk withSchema postgres', () => { if (!ctx?.connected) return; await dropRoleCompletely(ctx.kysely); - await runIgnoringErrors(ctx.kysely, [ + await dropIgnoringErrors(ctx.kysely, [ `DROP TABLE IF EXISTS "${parentTable}"`, `DROP SCHEMA IF EXISTS "${names.a}" CASCADE`, `DROP SCHEMA IF EXISTS "${names.b}" CASCADE`, @@ -372,7 +379,7 @@ describe('integration: sdk withSchema mysql', () => { if (ctx?.connected) { - await runIgnoringErrors(ctx.kysely, [`DROP TABLE IF EXISTS \`${parentTable}\``]); + await dropIgnoringErrors(ctx.kysely, [`DROP TABLE IF EXISTS \`${parentTable}\``]); await ctx.disconnect(); } @@ -470,7 +477,7 @@ describe('integration: sdk withSchema mssql', () => { if (!ctx?.connected) return; - await runIgnoringErrors(ctx.kysely, [ + await dropIgnoringErrors(ctx.kysely, [ `DROP USER IF EXISTS [${TEST_USER}]`, `DROP TABLE IF EXISTS [${parentTable}]`, `DROP TABLE IF EXISTS [${names.a}].items`, diff --git a/tests/sdk/with-schema.test.ts b/tests/sdk/with-schema.test.ts index a2a15768..19e955db 100644 --- a/tests/sdk/with-schema.test.ts +++ b/tests/sdk/with-schema.test.ts @@ -149,7 +149,6 @@ describe('sdk: Context.withSchema validation', () => { expect(() => ctx.withSchema('acct; DROP TABLE users')).toThrow(); expect(() => ctx.withSchema('acct.other')).toThrow(); - expect(() => ctx.withSchema('acct-name')).toThrow(); expect(() => ctx.withSchema('"acct"')).toThrow(); expect(() => ctx.withSchema('acct name')).toThrow(); @@ -163,6 +162,14 @@ describe('sdk: Context.withSchema validation', () => { }); + it('accepts hyphenated schema names', () => { + + const ctx = createCtx(); + + expect(() => ctx.withSchema('acct-name')).not.toThrow(); + + }); + it('leaves the context usable after a rejected schema name — no partial state mutation', () => { const ctx = createCtx(); @@ -214,6 +221,17 @@ describe('sdk: Context.withSchema kysely getter', () => { }); + it('compiles queries qualified with a hyphenated schema name', async () => { + + const ctx = await connectedCtx(); + const derived = ctx.withSchema('acct-name'); + + const compiled = derived.kysely.selectFrom('ledger').selectAll().compile(); + + expect(compiled.sql).toBe('select * from "acct-name"."ledger"'); + + }); + it('leaves the parent context unqualified', async () => { const ctx = await connectedCtx(); From a03299c8d649729102563fe5caf1be70ce58c24c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:13:42 -0400 Subject: [PATCH 08/10] docs: add sdk-with-schema implementation log --- docs/spec/sdk-with-schema.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/spec/sdk-with-schema.md b/docs/spec/sdk-with-schema.md index 7b9516a6..8bfb0184 100644 --- a/docs/spec/sdk-with-schema.md +++ b/docs/spec/sdk-with-schema.md @@ -148,3 +148,27 @@ Flow: transaction and impersonation composition **Why:** User requirement — simulate multi-schema scale so cross-schema leakage and type-shape mixups surface in tests instead of production. **Superseded:** Integration coverage asserted schema scoping against a single schema per dialect. + + +## Implementation log + +### shipped — 2026-08-10 + +Built across 4 iterations of /subagent-implementation. Commits (chronological): + +- `40af2d9e` — CP-1 `Context.withSchema` + 16 unit tests (validation, re-derivation, prefixing, shared `#heldConnections`) +- `a90651ff` — CP-2 three-schema integration suite, 12 tests live across all four dialects +- `46588801` — CP-3 SDK README + reference docs + `@noormdev/sdk` minor changeset +- `2eb1e1ff` — polish: hyphens admitted in schema names; teardown helper scoped (`dropIgnoringErrors`) + +**Out-of-scope work performed during this build:** + +- none + +**Unforeseens — surprises that emerged during implementation:** + +- CP-3 first pass documented only 1 of 4 non-goals in the README (reviewer-caught, fixed in-iteration); incidental `bun.lockb` byte churn excluded from CP-1's commit + +**Deferred items still open:** + +- none — both ledgered follow-ups (F-1 hyphen allow-list, F-2 teardown helper naming) were user-dispositioned fix-now and closed in `2eb1e1ff` From 25eedb661d2bb8c1c1c32a6faa41a436f435ae9c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:21:03 -0400 Subject: [PATCH 09/10] docs: add withSchema to dev SDK guide; index documentation surfaces --- CLAUDE.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/dev/sdk.md | 29 ++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 213104f6..c95394db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | + ## Project signals (auto-loaded) diff --git a/docs/dev/sdk.md b/docs/dev/sdk.md index f9fac514..880554f6 100644 --- a/docs/dev/sdk.md +++ b/docs/dev/sdk.md @@ -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: @@ -231,6 +232,34 @@ const result = await ctx.transaction(async (trx) => { ``` +### Schema Scoping + +#### `withSchema(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('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: From 233f2f2a80125517d738ded60d96c2493e10b226 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:29:04 -0400 Subject: [PATCH 10/10] chore(signals): refresh after sdk-with-schema --- docs/wiki/index.md | 14 +++++++------- docs/wiki/scan.md | 35 ++++++++++++++++++++--------------- docs/wiki/sdk.md | 12 +++++++++--- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index e5a0baf2..599aa3a8 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -5,14 +5,14 @@ description: Bun workspace monorepo — noorm, a database schema/change manager --- repo -f4112cdfcca8fa3c44ef2650ffe2943ddc5511f0 +816ddb2050f6e33ddaad8a73c64bf08c228266b1 1 # Project signals ## Framework & runtime -- **Language:** TypeScript (81% LOC, 1031 files), Bun runtime (>=1.2), Node >=22.13 +- **Language:** TypeScript (80% LOC, 1033 files), Bun runtime (>=1.2), Node >=22.13 - **SQL layer:** Kysely 0.28 query builder + executor; dialect-aware across PostgreSQL, MySQL, MSSQL, SQLite - **TUI:** Ink 6.8 + React 19.2 ([`src/tui/`](../../src/tui)); Citty 0.2 for CLI arg parsing ([`src/cli/`](../../src/cli)) - **Event bus:** `@logosdx/observer` (`ObserverEngine`); module-scope singleton in [`src/core/observer.ts`](../../src/core/observer.ts) @@ -44,12 +44,12 @@ CI gate: lint → typecheck → build → 5 test groups → 3 example jobs. Inte | Language | LOC | Files | % | |----------|-----|-------|---| -| TypeScript | 240887 | 1031 | 81% | -| Markdown | 48422 | 147 | 16% | +| TypeScript | 242110 | 1033 | 80% | +| Markdown | 50058 | 184 | 16% | +| HTML | 2977 | 30 | 0% | | JavaScript | 1261 | 22 | 0% | -| YAML | 1158 | 16 | 0% | -| HTML | 1090 | 27 | 0% | -| CSS | 1061 | 3 | 0% | +| YAML | 1186 | 19 | 0% | +| CSS | 1103 | 3 | 0% | | Shell | 932 | 7 | 0% | | JSON | 473 | 22 | 0% | | Vue | 205 | 3 | 0% | diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 13936093..259ae737 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -7,9 +7,10 @@ │ └── opentui/ (2) │ ├── references/ (0 files, 8 dirs) │ └── SKILL.md (a62967f, 195L, 7253ch, 7427B) -├── .changeset/ (2) +├── .changeset/ (3) │ ├── README.md (bf33c79, 8L, 510ch, 510B) -│ └── config.json (64bb386, 11L, 307ch, 307B) +│ ├── config.json (64bb386, 11L, 307ch, 307B) +│ └── sdk-with-schema.md (027fff0, 9L, 468ch, 472B) ├── .claude/ (3) │ ├── rules/ (4) │ │ ├── documentation.md (c0abbb0, 45L, 1419ch, 1421B) @@ -54,9 +55,10 @@ │ │ ├── settings-secret.md (99e8f29, 23L, 664ch, 668B) │ │ ├── sql-repl.md (20a6f63, 28L, 799ch, 803B) │ │ └── sql.md (52e5a3c, 77L, 3342ch, 3374B) -│ ├── design/ (3) +│ ├── design/ (4) │ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) │ │ ├── config-access-roles.md (bd73baa, 116L, 6566ch, 6670B) +│ │ ├── sdk-with-schema.md (37b324b, 117L, 7194ch, 7234B) │ │ └── v1-49-54-cli-field-defects.md (6f051d8, 242L, 12448ch, 12534B) │ ├── dev/ (25) │ │ ├── change.md (89e1ed4, 556L, 19149ch, 19231B) @@ -74,7 +76,7 @@ │ │ ├── logger.md (e297dba, 599L, 21334ch, 22867B) │ │ ├── project-discovery.md (ac60c94, 150L, 5071ch, 5083B) │ │ ├── runner.md (aefbef0, 543L, 21385ch, 21423B) -│ │ ├── sdk.md (0d50694, 1130L, 30012ch, 30076B) +│ │ ├── sdk.md (b538f8f, 1159L, 31592ch, 31668B) │ │ ├── secrets.md (47a8ca1, 321L, 11587ch, 11665B) │ │ ├── settings.md (173bfc7, 800L, 21459ch, 21481B) │ │ ├── sql-terminal.md (1cba9d3, 340L, 10785ch, 12173B) @@ -168,10 +170,11 @@ │ │ │ └── ignatius.mp4 (f46adb8, 26426L, 6528910ch, 6776756B) │ │ └── install.sh (0cc90a2, 116L, 2925ch, 2925B) │ ├── reference/ (1) -│ │ └── sdk.md (d23320e, 1657L, 59913ch, 60118B) -│ ├── spec/ (4) +│ │ └── sdk.md (be03cd8, 1705L, 63186ch, 63417B) +│ ├── spec/ (5) │ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) │ │ ├── config-access-roles.md (40ef290, 162L, 21805ch, 21929B) +│ │ ├── sdk-with-schema.md (593829a, 174L, 13384ch, 13504B) │ │ ├── v1-45-rewind-tiebreak.md (0e35550, 61L, 5465ch, 5507B) │ │ └── v1-49-54-cli-field-defects.md (438757f, 374L, 23347ch, 23495B) │ ├── superpowers/ (1) @@ -347,7 +350,7 @@ │ ├── CHANGELOG.md (d978dc8, 1199L, 78024ch, 78462B) │ ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) │ ├── NOTICE (5efbb3e, 2L, 43ch, 43B) -│ ├── README.md (0e96b2c, 81L, 2279ch, 2285B) +│ ├── README.md (4cd7cfa, 104L, 3525ch, 3541B) │ └── package.json (cbc8fef, 62L, 1135ch, 1135B) ├── scripts/ (5) │ ├── Dockerfile (5fe0d7a, 51L, 1595ch, 1595B) @@ -714,7 +717,7 @@ │ │ │ └── vault.ts (1984452, 445L, 11887ch, 13237B) │ │ ├── stubs/ (1) │ │ │ └── ansis.ts (16243d6, 19L, 488ch, 490B) -│ │ ├── context.ts (b4ecfa2, 575L, 18061ch, 20171B) +│ │ ├── context.ts (261fc98, 684L, 22223ch, 24815B) │ │ ├── guards.ts (f17c08b, 156L, 4423ch, 4921B) │ │ ├── index.ts (cb2bbba, 300L, 9189ch, 9927B) │ │ ├── noorm-ops.ts (9b88a4d, 178L, 3939ch, 4609B) @@ -1120,14 +1123,15 @@ │ │ ├── runner/ (2) │ │ │ ├── mssql-batches.test.ts (b60b9e8, 268L, 8063ch, 8065B) │ │ │ └── tracker-dialects.test.ts (377c0de, 159L, 5889ch, 5899B) -│ │ ├── sdk/ (7) +│ │ ├── sdk/ (8) │ │ │ ├── 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) │ │ │ ├── transfer-namespace.test.ts (cfe27cd, 180L, 6121ch, 6867B) │ │ │ ├── tvf.test.ts (730359b, 279L, 7502ch, 8234B) │ │ │ ├── tvp.test.ts (61c50ec, 599L, 17433ch, 19149B) -│ │ │ └── vault-namespace.test.ts (e7dffc5, 291L, 9810ch, 10312B) +│ │ │ ├── vault-namespace.test.ts (e7dffc5, 291L, 9810ch, 10312B) +│ │ │ └── with-schema.test.ts (95a2dc0, 648L, 23109ch, 24355B) │ │ ├── sql-terminal/ (5) │ │ │ ├── classifier-differential.test.ts (8419403, 226L, 9384ch, 9392B) │ │ │ ├── mssql.test.ts (1c242a3, 776L, 23168ch, 23168B) @@ -1147,7 +1151,7 @@ │ │ │ └── postgres.test.ts (449c11f, 371L, 12407ch, 12409B) │ │ └── version/ (1) │ │ └── schema.test.ts (e1c16d7, 727L, 22493ch, 23469B) -│ ├── sdk/ (15) +│ ├── sdk/ (16) │ │ ├── impersonate/ (3) │ │ │ ├── dialect-strategy.test.ts (d630381, 146L, 3515ch, 4491B) │ │ │ ├── impersonate.test.ts (9c11613, 297L, 7629ch, 8605B) @@ -1165,7 +1169,8 @@ │ │ ├── sql.test.ts (959c16c, 1035L, 32675ch, 34387B) │ │ ├── templates-policy.test.ts (7d94491, 68L, 2329ch, 2333B) │ │ ├── transfer-dt-namespace.test.ts (fb55bb4, 120L, 3861ch, 4351B) -│ │ └── vault-namespace.test.ts (83e76f5, 368L, 11448ch, 11454B) +│ │ ├── vault-namespace.test.ts (83e76f5, 368L, 11448ch, 11454B) +│ │ └── with-schema.test.ts (0c56eab, 466L, 14193ch, 15675B) │ ├── utils/ (4) │ │ ├── db-guard.test.ts (677fa3e, 143L, 3999ch, 4001B) │ │ ├── db-splitter.test.ts (4db513c, 280L, 8506ch, 8506B) @@ -1182,7 +1187,7 @@ ├── .npmrc (60376c8, 1L, 36ch, 36B) ├── .prettierignore (e3b0c44, 0L, 0ch, 0B) ├── .signalsignore (b0287a5, 17L, 662ch, 674B) -├── CLAUDE.md (15e564b, 130L, 5812ch, 5840B) +├── CLAUDE.md (d24f3d2, 210L, 11953ch, 11981B) ├── CNAME (f3bed50, 1L, 9ch, 9B) ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) ├── NOTICE (d698d9d, 2L, 35ch, 35B) @@ -1212,8 +1217,8 @@ ## Languages -- TypeScript: 240887 LOC (80%), 1031 files (78%) -- Markdown: 49556 LOC (16%), 181 files (13%) +- TypeScript: 242110 LOC (80%), 1033 files (77%) +- Markdown: 50036 LOC (16%), 184 files (13%) - HTML: 2977 LOC (0%), 30 files (2%) - JavaScript: 1261 LOC (0%), 22 files (1%) - YAML: 1186 LOC (0%), 19 files (1%) diff --git a/docs/wiki/sdk.md b/docs/wiki/sdk.md index dc7d3f55..ccabdcc4 100644 --- a/docs/wiki/sdk.md +++ b/docs/wiki/sdk.md @@ -7,7 +7,7 @@ description: Programmatic API (createContext) for noorm-managed databases, plus ## What it does -`createContext` (in [`src/sdk/index.ts`](../../src/sdk/index.ts)) returns a `Context` with a raw Kysely instance (`ctx.kysely`), `proc`/`func`/`tvf`/`transaction`/`impersonate` helpers, and a `ctx.noorm` namespace object bundling changes/run/db/dt/lock/vault/secrets/templates/transfer/utils operations. Published as `@noormdev/sdk` version `1.0.1` from [`packages/sdk/`](../../packages/sdk). +`createContext` (in [`src/sdk/index.ts`](../../src/sdk/index.ts)) returns a `Context` with a raw Kysely instance (`ctx.kysely`), `proc`/`func`/`tvf`/`transaction`/`impersonate`/`withSchema` helpers, and a `ctx.noorm` namespace object bundling changes/run/db/dt/lock/vault/secrets/templates/transfer/utils operations. Published as `@noormdev/sdk` version `1.0.1` from [`packages/sdk/`](../../packages/sdk). The DT (Data Transfer) module under [`src/core/dt/`](../../src/core/dt) is a separate universal-type serialization format (`.dt`/`.dtz`/`.dtzx` files) for exporting/importing single tables across PostgreSQL, MySQL, and MSSQL — distinct from the `core-db` domain's live DB-to-DB `transfer` module, though both share row-fetch and worker-pipeline patterns. @@ -20,7 +20,7 @@ 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) +- [`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/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) @@ -89,4 +89,10 @@ The DT (Data Transfer) module under [`src/core/dt/`](../../src/core/dt) is a sep - TVP ([`src/sdk/tvp.ts`](../../src/sdk/tvp.ts)) is MSSQL-only; `buildProcCall`/`buildFuncCall`/`buildTvfCall` throw if a TVP marker is passed on any other dialect. - `ctx.tvf()` (table-valued functions) is only supported on MSSQL and PostgreSQL; MySQL and SQLite throw. - `ctx.impersonate()` supports MSSQL and PostgreSQL only; MySQL and SQLite throw `ImpersonationError` before a connection is borrowed. -- Test coverage: [`tests/sdk/`](../../tests/sdk) covers namespace behavior per access role (admin/operator/viewer configs), guard errors, SQL builders, and impersonation; [`tests/integration/sdk/`](../../tests/integration/sdk) covers TVF/TVP against live MSSQL/PostgreSQL and vault/db-reset round-trips. +- `Context.withSchema(name)` derives a new `Context` sharing the parent's connection, pool, and `#heldConnections`, with fresh generics for the schema's own table/routine shape — the private `#schema` field is set once and never mutated afterward, so calling `withSchema()` again on an already-derived context replaces the schema instead of stacking/nesting it. +- `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. +- `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()`).