From 7caee2fb07b2d84eb961119362fffefa99ab8951 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 2 Aug 2026 18:32:36 -0400 Subject: [PATCH 01/12] docs: rework the basetype-subtype example The subtype tables repeated their own type as a column, pinned with a DEFAULT and a CHECK, which the table name already said. They now carry only the basetype's key and their own foreign key. The basetype's owner_type draws from an enumerable table rather than a CHECK list, so admitting a new type is an INSERT. Exclusivity between subtypes is no longer enforced in Postgres as a result, and the page says so rather than claiming otherwise; T-SQL can still enforce it with cross-table constraints. The quoted delete error matches the constraint Postgres reports now. Also drops the closing tool section, which ended an argument about data modelling on a feature list, and cuts the stock phrasing and contrastive reveals throughout. --- docs/guide/relational-design.md | 75 ++++----- .../social/data/content/comment_comments.md | 5 - docs/models/social/data/content/comments.md | 2 +- .../social/data/content/group_comments.md | 7 +- .../social/data/content/group_photos.md | 7 +- .../models/social/data/content/group_posts.md | 7 +- docs/models/social/data/content/photos.md | 2 +- .../social/data/content/post_comments.md | 7 +- docs/models/social/data/content/posts.md | 2 +- .../social/data/content/profile_photos.md | 7 +- .../social/data/content/user_comments.md | 7 +- .../models/social/data/content/user_photos.md | 7 +- .../social/data/content/user_post_photos.md | 7 +- docs/models/social/data/content/user_posts.md | 7 +- .../social/data/tagging/comment_tags.md | 7 +- docs/models/social/data/tagging/photo_tags.md | 7 +- docs/models/social/data/tagging/post_tags.md | 7 +- docs/models/social/data/tagging/tags.md | 2 +- docs/public/models/social.html | 154 +++++++++--------- 19 files changed, 127 insertions(+), 199 deletions(-) diff --git a/docs/guide/relational-design.md b/docs/guide/relational-design.md index ed5cc3f1..dd71a016 100644 --- a/docs/guide/relational-design.md +++ b/docs/guide/relational-design.md @@ -121,48 +121,50 @@ basetype-subtypes: post → user_post, group_post tag → post_tag, photo_tag, comment_tag, ... ``` -That is the honest version of the trade. Polymorphism looks cheaper because it never makes you write this list down: one `comments` table absorbs every case, and the count of things you are actually modelling stays hidden in a string column. Here the count is on the page. Photo alone fans out four ways. +Polymorphism looks cheaper because it never makes you write this list down: one `comments` table absorbs every case, and the count of things you are actually modelling stays hidden in a string column. Here the count is on the page. Photo alone fans out four ways. -More tables, and every one of them is a real constraint instead of a convention. The mechanism is the same for each, so here is one cluster in full: +More tables, and every one of them is a constraint the database enforces. The mechanism is the same for each, so here is one cluster in full: ```sql +-- The owner types are rows, not a value list baked into DDL. +-- Admitting ORGANIZATION later is an INSERT. +CREATE TABLE post_owner_types ( + owner_type text PRIMARY KEY +); + +INSERT INTO post_owner_types (owner_type) VALUES ('USER'), ('GROUP'); + CREATE TABLE posts ( post_id serial PRIMARY KEY, - owner_type text NOT NULL CHECK (owner_type IN ('USER', 'GROUP')), + owner_type text NOT NULL REFERENCES post_owner_types (owner_type), body text NOT NULL, - posted_at timestamptz NOT NULL DEFAULT now(), - - -- The discriminator has to be reachable by a - -- foreign key, so it joins a key of its own. - UNIQUE (post_id, owner_type) + posted_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE user_posts ( - post_id int PRIMARY KEY, - owner_type text NOT NULL DEFAULT 'USER' CHECK (owner_type = 'USER'), - user_id int NOT NULL REFERENCES users (user_id), - - FOREIGN KEY (post_id, owner_type) REFERENCES posts (post_id, owner_type) + post_id int PRIMARY KEY REFERENCES posts (post_id), + user_id int NOT NULL REFERENCES users (user_id) ); CREATE TABLE group_posts ( - post_id int PRIMARY KEY, - owner_type text NOT NULL DEFAULT 'GROUP' CHECK (owner_type = 'GROUP'), - group_id int NOT NULL REFERENCES groups (group_id), - - FOREIGN KEY (post_id, owner_type) REFERENCES posts (post_id, owner_type) + post_id int PRIMARY KEY REFERENCES posts (post_id), + group_id int NOT NULL REFERENCES groups (group_id) ); ``` -Read the last two tables together and the exclusivity is structural. `user_posts.owner_type` is pinned to `USER`, `group_posts.owner_type` to `GROUP`, and both carry it into the composite foreign key back to `posts`. A post the basetype marked `USER` therefore *cannot* accept a `group_posts` row. Not "should not". Cannot. +Two things to notice. + +The set of owner types is a table. A `CHECK (owner_type IN (…))` list would have made admitting `ORGANIZATION` a constraint rewrite on the basetype; here it is a row. -`user_id` and `group_id` are real foreign keys to real tables, which is precisely what the polymorphic version gives up. Counting a group's posts is a join, not a join plus a string comparison. +Each subtype's key *is* the basetype's key, so a post gets at most one row in any subtype table and every one of those rows is anchored to a real `posts` row. Keeping a post out of two different subtype tables at once is the one rule this shape leaves to you if you're using postgres. Enforcement is fully possible in TSQL via cross-table constraints. -Repeat that for photo, comment and tag and you get the list above. It is more tables than the polymorphic version, and that is the whole trade: the tables are where the rules live, so they are not also living in application code you have to keep correct. +`user_id` and `group_id` are real foreign keys to real tables, which the polymorphic version gives up. Counting a group's posts is a join, not a join plus a string comparison. -Each relationship gets its own table with proper constraints against its parent. A `user_post` has a foreign key to `user` and `post`. A `group_photo` has a foreign key to `group` and `photo`. No nulls, no type columns, no ambiguity. +Repeat that for photo, comment and tag and you get the list above. It is more tables than the polymorphic version. The tables are where the rules live, so they are not also living in application code you have to keep correct. -You work with existence and non-existence—not "maybe exists" or calculate. You depend on physical existence, not hopeful logic. Statistics are straightforward. Queries are clean. The database enforces integrity at every level. Illegal states become impossible. The trade-off is more tables, but the benefit is less app logic. +Each relationship gets its own table with proper constraints against its parent. A `user_post` has a foreign key to `user` and `post`. A `group_photo` has a foreign key to `group` and `photo`. No nulls, no type string you have to read to know what you are holding, no ambiguity. + +A row exists or it does not, so nothing has to be computed to find out. Statistics are straightforward. Queries are clean. The database enforces integrity at every level. Illegal states become impossible. The trade is more tables for less application logic. ### What that model looks like @@ -171,13 +173,15 @@ You work with existence and non-existence—not "maybe exists" or calculate. You -All twenty entities, exactly as listed above. Four diamonds, one per cluster, each with an X marking it **exclusive**: a post is one or the other, never both. ignatius reads that from the structure rather than a label. +All twenty entities, exactly as listed above. Four diamonds, one per cluster, each with an X marking it **exclusive**: a post is one or the other, never both. That X is the model stating the one rule the tables leave to you. + +`post_owner_types` and its siblings are not among the twenty. A table whose only job is to enumerate the legal values of a column is a domain rather than an entity: it describes what a value may be, not a thing the business has. It belongs in the data dictionary, not on the graph. Solid lines run to a basetype, dashed ones to an owner. Dashed means non-identifying, so a photo's subject is a fact *about* it rather than part of what identifies it. -The point of seeing it whole is the edge count. Every line is a foreign key the database enforces. +Count the edges. Every line is a foreign key the database enforces. -Scroll back to the polymorphic diagram and the difference is not a matter of taste. Twenty entities bound by constraints, against seven that float. Fewer tables did not remove the relationships. It removed the database's knowledge of them, and moved every one into code you have to write, test, and keep correct. +Scroll back to the polymorphic diagram. Twenty entities bound by constraints, against seven that float. Fewer tables did not remove the relationships. It removed the database's knowledge of them, and moved every one into code you have to write, test, and keep correct. @@ -213,7 +217,7 @@ LIMIT 10; No type filter anywhere. `group_posts` is already only group posts, `post_comments` is already only comments on posts. The tables did the filtering when the rows were written. -### What the numbers actually say +### What the numbers say PostgreSQL 17, 1,000,000 posts and 5,000,000 comments, both designs indexed equivalently, best of three runs. @@ -252,7 +256,7 @@ GROUP BY gp.group_id; | Time | 638.2 ms | 452.7 ms | | Pages read from disk | **47,765** | **4,638** | -That is the number to look at. Adding one hop took the polymorphic query from 7,852 pages to 47,765, a six-fold jump. The same hop left the relational query flat, 5,022 to 4,638. +Adding one hop took the polymorphic query from 7,852 pages to 47,765, a six-fold jump. The same hop left the relational query flat, 5,022 to 4,638. The reason is mechanical. Every polymorphic join has to re-derive the same fact at read time: sift a large shared table for the fraction of rows that are the right *kind*. Two hops means doing that twice, over five million comments and two million tags, and the intermediate results are large enough that both designs spill to temp files. @@ -277,7 +281,7 @@ Basetype-subtypes: the database refuses. ``` ERROR: update or delete on table "posts" violates foreign key -constraint "group_posts_post_id_owner_type_fkey" +constraint "group_posts_post_id_fkey" ``` You can find the orphans in the polymorphic design. It costs an anti-join across all five million comments, 154 ms here: @@ -288,19 +292,8 @@ WHERE c.commentable_type = 'POST' AND NOT EXISTS (SELECT 1 FROM posts p WHERE p.post_id = c.commentable_id); ``` -But you have to know to ask, on every polymorphic column, forever, and the answer only tells you about damage already done. That query is not one your product needs. It is rent. +But you have to know to ask, on every polymorphic column, forever, and the answer only tells you about damage already done. That query does nothing for your product; it is rent. So you spend more disk and get back two things: queries whose cost grows more slowly as they get deeper, and the guarantee that the number on the dashboard is true. The polymorphic version spends less disk and pays for it per query, per hop, and in the reconciliation jobs it obliges you to write. You pay for bad relational design later, in complexity and bugs. Sometimes you pay for it in metrics nobody knew to distrust. - - -## What this requires from a tool - -Both patterns need things ORM-shaped migration tools make hard: - -- **Compound primary keys** that you declare, not ones the tool derives from a single ID column. -- **Many more tables** than a naive design, which means execution order matters and has to be explicit. -- **Constraints, triggers, and procedures** as first-class schema objects, not escape-hatch raw SQL bolted onto a migration. - -noorm gives you all three because it never parses your SQL into an object model. Your files are the schema. See [SQL File Organization](/guide/sql-files/organization) for how execution order works, and [Concepts](/getting-started/concepts) for how files and changes divide the work. diff --git a/docs/models/social/data/content/comment_comments.md b/docs/models/social/data/content/comment_comments.md index e5a6a1c9..d53dc8b7 100644 --- a/docs/models/social/data/content/comment_comments.md +++ b/docs/models/social/data/content/comment_comments.md @@ -7,16 +7,11 @@ columns: comment_id: type: integer desc: "The basetype's key, whole." - target_type: - type: text - default: "'COMMENT'" - desc: "Pinned to COMMENT." parent_comment_id: type: integer desc: "The comment being replied to. A real foreign key, even though it points back at the same basetype." examples: - comment_id: 4 - target_type: COMMENT parent_comment_id: 3 relationships: - target: comments diff --git a/docs/models/social/data/content/comments.md b/docs/models/social/data/content/comments.md index 450e42e1..498802d7 100644 --- a/docs/models/social/data/content/comments.md +++ b/docs/models/social/data/content/comments.md @@ -8,7 +8,7 @@ columns: type: integer target_type: type: text - desc: "Discriminator. Joins a unique key so it can travel into each subtype's foreign key." + desc: "Discriminator, drawn from the comment_target_types table rather than a CHECK list. Joins a unique key so it can travel into each subtype's foreign key." created_at: type: datetime examples: diff --git a/docs/models/social/data/content/group_comments.md b/docs/models/social/data/content/group_comments.md index a6193bcc..0ccc49a0 100644 --- a/docs/models/social/data/content/group_comments.md +++ b/docs/models/social/data/content/group_comments.md @@ -7,16 +7,11 @@ columns: comment_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - target_type: - type: text - default: "'GROUP'" - desc: "Pinned to GROUP, so the composite foreign key can only resolve to a GROUP row." group_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - comment_id: 1 - target_type: GROUP group_id: 1 relationships: - target: comments @@ -31,4 +26,4 @@ relationships: # group_comments -A [[comments]] belonging to a [[groups]] row. Its key is the basetype's key, and `target_type` is pinned so the pair can only attach to a row the basetype already marked `GROUP`. +A [[comments]] belonging to a [[groups]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/group_photos.md b/docs/models/social/data/content/group_photos.md index fba46c2f..6a2ebe0f 100644 --- a/docs/models/social/data/content/group_photos.md +++ b/docs/models/social/data/content/group_photos.md @@ -7,16 +7,11 @@ columns: photo_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - subject_type: - type: text - default: "'GROUP'" - desc: "Pinned to GROUP, so the composite foreign key can only resolve to a GROUP row." group_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - photo_id: 1 - subject_type: GROUP group_id: 1 relationships: - target: photos @@ -31,4 +26,4 @@ relationships: # group_photos -A [[photos]] belonging to a [[groups]] row. Its key is the basetype's key, and `subject_type` is pinned so the pair can only attach to a row the basetype already marked `GROUP`. +A [[photos]] belonging to a [[groups]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/group_posts.md b/docs/models/social/data/content/group_posts.md index 46fd01e8..60ba67eb 100644 --- a/docs/models/social/data/content/group_posts.md +++ b/docs/models/social/data/content/group_posts.md @@ -7,16 +7,11 @@ columns: post_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - owner_type: - type: text - default: "'GROUP'" - desc: "Pinned to GROUP, so the composite foreign key can only resolve to a GROUP row." group_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - post_id: 1 - owner_type: GROUP group_id: 1 relationships: - target: posts @@ -31,4 +26,4 @@ relationships: # group_posts -A [[posts]] belonging to a [[groups]] row. Its key is the basetype's key, and `owner_type` is pinned so the pair can only attach to a row the basetype already marked `GROUP`. +A [[posts]] belonging to a [[groups]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/photos.md b/docs/models/social/data/content/photos.md index 77f62eee..5a895acf 100644 --- a/docs/models/social/data/content/photos.md +++ b/docs/models/social/data/content/photos.md @@ -8,7 +8,7 @@ columns: type: integer subject_type: type: text - desc: "Discriminator. Joins a unique key so it can travel into each subtype's foreign key." + desc: "Discriminator, drawn from the photo_subject_types table rather than a CHECK list. Joins a unique key so it can travel into each subtype's foreign key." created_at: type: datetime examples: diff --git a/docs/models/social/data/content/post_comments.md b/docs/models/social/data/content/post_comments.md index 09f9a810..8e1aa938 100644 --- a/docs/models/social/data/content/post_comments.md +++ b/docs/models/social/data/content/post_comments.md @@ -7,16 +7,11 @@ columns: comment_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - target_type: - type: text - default: "'POST'" - desc: "Pinned to POST, so the composite foreign key can only resolve to a POST row." post_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - comment_id: 1 - target_type: POST post_id: 1 relationships: - target: comments @@ -31,4 +26,4 @@ relationships: # post_comments -A [[comments]] belonging to a [[posts]] row. Its key is the basetype's key, and `target_type` is pinned so the pair can only attach to a row the basetype already marked `POST`. +A [[comments]] belonging to a [[posts]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/posts.md b/docs/models/social/data/content/posts.md index 2941fcfc..fb9ff434 100644 --- a/docs/models/social/data/content/posts.md +++ b/docs/models/social/data/content/posts.md @@ -8,7 +8,7 @@ columns: type: integer owner_type: type: text - desc: "Discriminator. Joins a unique key so it can travel into each subtype's foreign key." + desc: "Discriminator, drawn from the post_owner_types table rather than a CHECK list. Joins a unique key so it can travel into each subtype's foreign key." created_at: type: datetime examples: diff --git a/docs/models/social/data/content/profile_photos.md b/docs/models/social/data/content/profile_photos.md index 7ac99593..841e0dec 100644 --- a/docs/models/social/data/content/profile_photos.md +++ b/docs/models/social/data/content/profile_photos.md @@ -7,16 +7,11 @@ columns: photo_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - subject_type: - type: text - default: "'PROFILE'" - desc: "Pinned to PROFILE, so the composite foreign key can only resolve to a PROFILE row." user_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - photo_id: 1 - subject_type: PROFILE user_id: 1 relationships: - target: photos @@ -31,4 +26,4 @@ relationships: # profile_photos -A [[photos]] belonging to a [[profiles]] row. Its key is the basetype's key, and `subject_type` is pinned so the pair can only attach to a row the basetype already marked `PROFILE`. +A [[photos]] belonging to a [[profiles]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/user_comments.md b/docs/models/social/data/content/user_comments.md index e49d0412..fe1de46c 100644 --- a/docs/models/social/data/content/user_comments.md +++ b/docs/models/social/data/content/user_comments.md @@ -7,16 +7,11 @@ columns: comment_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - target_type: - type: text - default: "'USER'" - desc: "Pinned to USER, so the composite foreign key can only resolve to a USER row." user_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - comment_id: 1 - target_type: USER user_id: 1 relationships: - target: comments @@ -31,4 +26,4 @@ relationships: # user_comments -A [[comments]] belonging to a [[users]] row. Its key is the basetype's key, and `target_type` is pinned so the pair can only attach to a row the basetype already marked `USER`. +A [[comments]] belonging to a [[users]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/user_photos.md b/docs/models/social/data/content/user_photos.md index 9c925b62..b06b45e4 100644 --- a/docs/models/social/data/content/user_photos.md +++ b/docs/models/social/data/content/user_photos.md @@ -7,16 +7,11 @@ columns: photo_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - subject_type: - type: text - default: "'USER'" - desc: "Pinned to USER, so the composite foreign key can only resolve to a USER row." user_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - photo_id: 1 - subject_type: USER user_id: 1 relationships: - target: photos @@ -31,4 +26,4 @@ relationships: # user_photos -A [[photos]] belonging to a [[users]] row. Its key is the basetype's key, and `subject_type` is pinned so the pair can only attach to a row the basetype already marked `USER`. +A [[photos]] belonging to a [[users]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/user_post_photos.md b/docs/models/social/data/content/user_post_photos.md index 9f09bd2c..7e17a86c 100644 --- a/docs/models/social/data/content/user_post_photos.md +++ b/docs/models/social/data/content/user_post_photos.md @@ -7,16 +7,11 @@ columns: photo_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - subject_type: - type: text - default: "'USER_POST'" - desc: "Pinned to USER_POST, so the composite foreign key can only resolve to a USER_POST row." post_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - photo_id: 1 - subject_type: USER_POST post_id: 1 relationships: - target: photos @@ -31,4 +26,4 @@ relationships: # user_post_photos -A [[photos]] belonging to a [[user_posts]] row. Its key is the basetype's key, and `subject_type` is pinned so the pair can only attach to a row the basetype already marked `USER_POST`. +A [[photos]] belonging to a [[user_posts]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/content/user_posts.md b/docs/models/social/data/content/user_posts.md index f7e636c5..8399cad5 100644 --- a/docs/models/social/data/content/user_posts.md +++ b/docs/models/social/data/content/user_posts.md @@ -7,16 +7,11 @@ columns: post_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - owner_type: - type: text - default: "'USER'" - desc: "Pinned to USER, so the composite foreign key can only resolve to a USER row." user_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - post_id: 1 - owner_type: USER user_id: 1 relationships: - target: posts @@ -31,4 +26,4 @@ relationships: # user_posts -A [[posts]] belonging to a [[users]] row. Its key is the basetype's key, and `owner_type` is pinned so the pair can only attach to a row the basetype already marked `USER`. +A [[posts]] belonging to a [[users]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/tagging/comment_tags.md b/docs/models/social/data/tagging/comment_tags.md index d4b87be0..3ad1fe0b 100644 --- a/docs/models/social/data/tagging/comment_tags.md +++ b/docs/models/social/data/tagging/comment_tags.md @@ -7,16 +7,11 @@ columns: tag_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - target_type: - type: text - default: "'COMMENT'" - desc: "Pinned to COMMENT, so the composite foreign key can only resolve to a COMMENT row." comment_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - tag_id: 1 - target_type: COMMENT comment_id: 1 relationships: - target: tags @@ -31,4 +26,4 @@ relationships: # comment_tags -A [[tags]] belonging to a [[comments]] row. Its key is the basetype's key, and `target_type` is pinned so the pair can only attach to a row the basetype already marked `COMMENT`. +A [[tags]] belonging to a [[comments]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/tagging/photo_tags.md b/docs/models/social/data/tagging/photo_tags.md index 9a958970..6a989bea 100644 --- a/docs/models/social/data/tagging/photo_tags.md +++ b/docs/models/social/data/tagging/photo_tags.md @@ -7,16 +7,11 @@ columns: tag_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - target_type: - type: text - default: "'PHOTO'" - desc: "Pinned to PHOTO, so the composite foreign key can only resolve to a PHOTO row." photo_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - tag_id: 1 - target_type: PHOTO photo_id: 1 relationships: - target: tags @@ -31,4 +26,4 @@ relationships: # photo_tags -A [[tags]] belonging to a [[photos]] row. Its key is the basetype's key, and `target_type` is pinned so the pair can only attach to a row the basetype already marked `PHOTO`. +A [[tags]] belonging to a [[photos]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/tagging/post_tags.md b/docs/models/social/data/tagging/post_tags.md index 4deb6d98..877a1103 100644 --- a/docs/models/social/data/tagging/post_tags.md +++ b/docs/models/social/data/tagging/post_tags.md @@ -7,16 +7,11 @@ columns: tag_id: type: integer desc: "The basetype's key, whole. That is what makes this a subtype rather than a child." - target_type: - type: text - default: "'POST'" - desc: "Pinned to POST, so the composite foreign key can only resolve to a POST row." post_id: type: integer desc: "A real foreign key to a real table, which polymorphism cannot give you." examples: - tag_id: 1 - target_type: POST post_id: 1 relationships: - target: tags @@ -31,4 +26,4 @@ relationships: # post_tags -A [[tags]] belonging to a [[posts]] row. Its key is the basetype's key, and `target_type` is pinned so the pair can only attach to a row the basetype already marked `POST`. +A [[tags]] belonging to a [[posts]] row. Its key is the basetype's key. diff --git a/docs/models/social/data/tagging/tags.md b/docs/models/social/data/tagging/tags.md index d87a7bc2..986f4f23 100644 --- a/docs/models/social/data/tagging/tags.md +++ b/docs/models/social/data/tagging/tags.md @@ -8,7 +8,7 @@ columns: type: integer target_type: type: text - desc: "Discriminator. Joins a unique key so it can travel into each subtype's foreign key." + desc: "Discriminator, drawn from the tag_target_types table rather than a CHECK list. Joins a unique key so it can travel into each subtype's foreign key." created_at: type: datetime examples: diff --git a/docs/public/models/social.html b/docs/public/models/social.html index dbc42a58..5c5d6442 100644 --- a/docs/public/models/social.html +++ b/docs/public/models/social.html @@ -5,8 +5,8 @@ Basetype-Subtypes — A Social Graph -
From f1e1b396a1c30405a004bdd95f57f8a12685366c Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 01:43:01 -0400 Subject: [PATCH 02/12] 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 20c4ec600d2faa9ba16e6dc8643b92d1ed1a583a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 09:05:13 -0400 Subject: [PATCH 03/12] 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 ef660c9909af670bcb543d64bdb2589d98918f28 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 14:47:11 -0400 Subject: [PATCH 04/12] 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 2296e93a56961945a732c764aae20a250010e323 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 15:17:28 -0400 Subject: [PATCH 05/12] 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 f1f4f2d3303ed64b4beb60aa21ae923ba8aeef98 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 15:37:22 -0400 Subject: [PATCH 06/12] 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 1fafd16e40e8f266636e015b8bb35c8d7a78d911 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 15:47:55 -0400 Subject: [PATCH 07/12] 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 1abd1a7099d5542682e1c41e7f0da05355ccf3a7 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:13:03 -0400 Subject: [PATCH 08/12] 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 b67bd60a57a48b2de4d27e3382e70a218cba369e Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:13:42 -0400 Subject: [PATCH 09/12] 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 8101b602555b3813603e909e6953e3183b6723c0 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:21:03 -0400 Subject: [PATCH 10/12] 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 9054b32b..3f881611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,6 +206,86 @@ the config list. Use `numberNav` prop on `SelectList` for 1-9 quick selection in lists. +## 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 9f25590e0e44eaa7e84b63578d3d6e62ed7b6374 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 16:29:04 -0400 Subject: [PATCH 11/12] chore(signals): refresh after sdk-with-schema --- docs/wiki/index.md | 14 +++---- docs/wiki/scan.md | 98 ++++++++++++++++++++++++++++------------------ docs/wiki/sdk.md | 12 ++++-- 3 files changed, 75 insertions(+), 49 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 f4112cdf..816ddb20 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -7,11 +7,10 @@ │ └── opentui/ (2) │ ├── references/ (0 files, 8 dirs) │ └── SKILL.md (a62967f, 195L, 7253ch, 7427B) -├── .changeset/ (4) +├── .changeset/ (3) │ ├── README.md (bf33c79, 8L, 510ch, 510B) │ ├── config.json (64bb386, 11L, 307ch, 307B) -│ ├── khaki-jars-repeat.md (e04d554, 11L, 441ch, 447B) -│ └── olive-pugs-shave.md (3298d84, 11L, 461ch, 463B) +│ └── sdk-with-schema.md (027fff0, 9L, 468ch, 472B) ├── .claude/ (3) │ ├── rules/ (4) │ │ ├── documentation.md (69cdfde, 30L, 837ch, 837B) @@ -35,15 +34,15 @@ │ ├── docs.yml (1d7b2ac, 51L, 1456ch, 1456B) │ ├── publish.yml (48bab2f, 97L, 2442ch, 2442B) │ └── release-binary.yml (56d023f, 46L, 1333ch, 1333B) -├── docs/ (18) +├── docs/ (19) │ ├── .vitepress/ (3) │ │ ├── theme/ (5) │ │ │ ├── HeroEyebrow.vue (93327f3, 23L, 710ch, 715B) │ │ │ ├── HeroStats.vue (715ce16, 26L, 531ch, 533B) │ │ │ ├── HeroTerminal.vue (95c6581, 156L, 3887ch, 3888B) -│ │ │ ├── brand.css (c54f6e8, 573L, 16393ch, 20025B) +│ │ │ ├── brand.css (6a2d53a, 615L, 17530ch, 21344B) │ │ │ └── index.ts (5ce55a8, 53L, 1207ch, 1207B) -│ │ ├── config.mts (542736d, 279L, 14288ch, 14301B) +│ │ ├── config.mts (73d90d4, 283L, 14561ch, 14574B) │ │ └── og-source.html (f8e2a14, 135L, 3784ch, 3793B) │ ├── cli/ (10) │ │ ├── flags.md (8d4807c, 100L, 4386ch, 4422B) @@ -56,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) @@ -76,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) @@ -116,7 +116,7 @@ │ │ │ ├── organization.md (5febfd5, 351L, 9226ch, 9692B) │ │ │ └── templates.md (c8f060b, 508L, 16419ch, 16547B) │ │ ├── deployment.md (0e61d4d, 190L, 8503ch, 8530B) -│ │ ├── relational-design.md (f1e4faa, 66L, 3512ch, 3534B) +│ │ ├── relational-design.md (b606099, 299L, 14581ch, 14597B) │ │ └── troubleshooting.md (594e50f, 123L, 4356ch, 4370B) │ ├── modeling/ (8) │ │ ├── best-practices.md (88a154a, 149L, 10034ch, 10038B) @@ -127,7 +127,20 @@ │ │ ├── installation.md (e7d7912, 163L, 7135ch, 7143B) │ │ ├── modeling-skill.md (55d7a6d, 86L, 5201ch, 5201B) │ │ └── reverse-engineering.md (a220459, 189L, 13026ch, 13028B) -│ ├── public/ (4) +│ ├── models/ (3) +│ │ ├── polymorphic/ (3) +│ │ │ ├── data/ (0 files, 2 dirs) +│ │ │ ├── groups/ (2 files, 0 dirs) +│ │ │ └── ignatius.yml (61d527e, 8L, 250ch, 252B) +│ │ ├── social/ (3) +│ │ │ ├── data/ (0 files, 3 dirs) +│ │ │ ├── groups/ (3 files, 0 dirs) +│ │ │ └── ignatius.yml (ac15e3e, 12L, 275ch, 277B) +│ │ └── todo-list/ (3) +│ │ ├── data/ (0 files, 1 dir) +│ │ ├── groups/ (1 file, 0 dirs) +│ │ └── ignatius.yml (f8945e4, 8L, 220ch, 222B) +│ ├── public/ (5) │ │ ├── icons/ (10) │ │ │ ├── bolt.svg (8880fa8, 1L, 586ch, 586B) │ │ │ ├── cubes.svg (477517a, 1L, 1009ch, 1009B) @@ -148,15 +161,20 @@ │ │ │ ├── logo.svg (8d46c28, 6L, 2529ch, 2529B) │ │ │ ├── og.png (fa3d4ae, 206L, 57780ch, 60183B) │ │ │ └── tui.gif (8fd1011, 10802L, 1639046ch, 1686253B) +│ │ ├── models/ (3) +│ │ │ ├── polymorphic.html (a2c06a9, 629L, 4272673ch, 4277149B) +│ │ │ ├── social.html (02baebf, 629L, 4287168ch, 4291644B) +│ │ │ └── todo-list.html (c1ae57a, 629L, 4271835ch, 4276311B) │ │ ├── video/ (2) │ │ │ ├── ignatius-poster.jpg (1703473, 149L, 57987ch, 60286B) │ │ │ └── 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) @@ -221,13 +239,13 @@ │ │ │ └── sql/ (11 files, 0 dirs) │ │ ├── .gitignore (ccb61cd, 40L, 446ch, 446B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (3a6d58a, 94L, 2182ch, 2182B) +│ │ ├── CHANGELOG.md (55fb4ca, 101L, 2269ch, 2269B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) │ │ ├── README.md (c228f5b, 121L, 6833ch, 6869B) │ │ ├── REPORT.md (4f3efcd, 161L, 12957ch, 13004B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) │ │ ├── mssql-problems.md (5083524, 328L, 23834ch, 23934B) -│ │ ├── package.json (4c0d6b2, 23L, 623ch, 623B) +│ │ ├── package.json (484acdb, 23L, 623ch, 623B) │ │ └── tsconfig.json (7be3bae, 27L, 736ch, 736B) │ ├── llm-memory-db-pg/ (17) │ │ ├── .cursor/ (1) @@ -271,13 +289,13 @@ │ │ │ └── mcp-discovery.test.ts (02d4035, 765L, 24559ch, 24603B) │ │ ├── .gitignore (a94396a, 36L, 397ch, 397B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (e6d12cc, 94L, 2179ch, 2179B) +│ │ ├── CHANGELOG.md (3ac7a7b, 101L, 2266ch, 2266B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) │ │ ├── README.md (b97f95b, 205L, 10359ch, 10665B) │ │ ├── REPORT-PHASE-1.md (59b7d41, 103L, 9610ch, 9670B) │ │ ├── REPORT.md (98ee180, 140L, 17043ch, 17113B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) -│ │ ├── package.json (792b453, 23L, 662ch, 662B) +│ │ ├── package.json (129231a, 23L, 662ch, 662B) │ │ ├── postgres-problems.md (1cbb5b5, 200L, 13674ch, 13799B) │ │ └── tsconfig.json (4dc04b1, 30L, 735ch, 735B) │ └── todo-db/ (10) @@ -314,26 +332,26 @@ │ │ ├── views/ (2 files, 0 dirs) │ │ └── preload.ts (0d97cd6, 25L, 658ch, 660B) │ ├── .gitignore (81531bd, 5L, 57ch, 57B) -│ ├── CHANGELOG.md (52a8360, 100L, 2239ch, 2239B) +│ ├── CHANGELOG.md (ce8eb26, 107L, 2326ch, 2326B) │ ├── bunfig.toml (e10e7cb, 5L, 89ch, 89B) -│ ├── package.json (79b152f, 22L, 573ch, 573B) +│ ├── package.json (37c659a, 22L, 573ch, 573B) │ └── tsconfig.json (efeae48, 17L, 484ch, 484B) ├── packages/ (2) │ ├── cli/ (7) │ │ ├── scripts/ (1) │ │ │ └── postinstall.js (b82655f, 411L, 12511ch, 12513B) -│ │ ├── CHANGELOG.md (e9ab13f, 1487L, 105723ch, 106239B) +│ │ ├── CHANGELOG.md (1fd031e, 1499L, 106203ch, 106721B) │ │ ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) │ │ ├── NOTICE (d464ce1, 2L, 43ch, 43B) │ │ ├── README.md (5ec4b13, 65L, 1728ch, 1734B) │ │ ├── noorm.js (e3d76e8, 41L, 1041ch, 1043B) -│ │ └── package.json (66a143a, 32L, 563ch, 563B) +│ │ └── package.json (26c70c5, 32L, 563ch, 563B) │ └── sdk/ (5) -│ ├── CHANGELOG.md (94188ea, 1187L, 77564ch, 77996B) +│ ├── CHANGELOG.md (d978dc8, 1199L, 78024ch, 78462B) │ ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) │ ├── NOTICE (5efbb3e, 2L, 43ch, 43B) -│ ├── README.md (0e96b2c, 81L, 2279ch, 2285B) -│ └── package.json (511222e, 62L, 1135ch, 1135B) +│ ├── README.md (4cd7cfa, 104L, 3525ch, 3541B) +│ └── package.json (cbc8fef, 62L, 1135ch, 1135B) ├── scripts/ (5) │ ├── Dockerfile (5fe0d7a, 51L, 1595ch, 1595B) │ ├── build-binary.mjs (f598b0c, 37L, 1284ch, 1288B) @@ -699,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) @@ -1105,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) @@ -1132,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) @@ -1150,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) @@ -1167,7 +1187,7 @@ ├── .npmrc (60376c8, 1L, 36ch, 36B) ├── .prettierignore (e3b0c44, 0L, 0ch, 0B) ├── .signalsignore (b0287a5, 17L, 662ch, 674B) -├── CLAUDE.md (e403148, 216L, 10135ch, 10355B) +├── CLAUDE.md (e874fc5, 296L, 16276ch, 16496B) ├── CNAME (f3bed50, 1L, 9ch, 9B) ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) ├── NOTICE (d698d9d, 2L, 35ch, 35B) @@ -1188,21 +1208,21 @@ ## Manifests - docs/package.json: name=@noormdev/docs, scripts=[build, dev, preview] -- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.2, scripts=[test, test:watch, typecheck] -- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.2, scripts=[test, test:watch, typecheck] -- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.2, scripts=[test, test:watch, typecheck] +- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.3, scripts=[test, test:watch, typecheck] +- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.3, scripts=[test, test:watch, typecheck] +- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.3, scripts=[test, test:watch, typecheck] - package.json: name=@noormdev/main, version=0.0.1, scripts=[build, build:binary, build:packages, changeset, clean, dev, lint, lint:docs, lint:fix, prepublishOnly, release, start, test, test:coverage, test:watch, typecheck, typecheck:tests, version] -- packages/cli/package.json: name=@noormdev/cli, version=1.0.1, scripts=[postinstall] -- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.1 +- packages/cli/package.json: name=@noormdev/cli, version=1.0.2, scripts=[postinstall] +- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.2 ## Languages -- TypeScript: 240887 LOC (81%), 1031 files (80%) -- Markdown: 48422 LOC (16%), 147 files (11%) +- TypeScript: 242110 LOC (80%), 1033 files (77%) +- Markdown: 50058 LOC (16%), 184 files (13%) +- HTML: 2977 LOC (0%), 30 files (2%) - JavaScript: 1261 LOC (0%), 22 files (1%) -- YAML: 1158 LOC (0%), 16 files (1%) -- HTML: 1090 LOC (0%), 27 files (2%) -- CSS: 1061 LOC (0%), 3 files (0%) +- YAML: 1186 LOC (0%), 19 files (1%) +- CSS: 1103 LOC (0%), 3 files (0%) - Shell: 932 LOC (0%), 7 files (0%) - JSON: 473 LOC (0%), 22 files (1%) - Vue: 205 LOC (0%), 3 files (0%) 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()`). From 030184fe131e76c6e6ce343e0a8206ea6de6ef03 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 10 Aug 2026 18:32:24 -0400 Subject: [PATCH 12/12] chore(signals): refresh after merge of master into next --- docs/wiki/scan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index c2e260a8..45228416 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -1187,7 +1187,7 @@ ├── .npmrc (60376c8, 1L, 36ch, 36B) ├── .prettierignore (e3b0c44, 0L, 0ch, 0B) ├── .signalsignore (b0287a5, 17L, 662ch, 674B) -├── CLAUDE.md (aa57be2, 261L, 13391ch, 13421B) +├── CLAUDE.md (c67f06a, 213L, 11996ch, 12024B) ├── CNAME (f3bed50, 1L, 9ch, 9B) ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) ├── NOTICE (d698d9d, 2L, 35ch, 35B) @@ -1218,7 +1218,7 @@ ## Languages - TypeScript: 242110 LOC (80%), 1033 files (77%) -- Markdown: 50087 LOC (16%), 184 files (13%) +- Markdown: 50039 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%)