diff --git a/AGENTS.md b/AGENTS.md index aef81b2..e67fd04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,9 @@ - `src/drizzle-compat.ts` — Drizzle SQL chunk inspection helpers used by strict validation. - `tests/unit/` — fake-builder Vitest suites grouped by behavior: select guardrails, mutation guardrails, relational wrappers, API surface, custom rules/errors, and Drizzle compatibility helpers. - `tests/integration/` — real-driver integration suites for Postgres/PGlite and SQLite. -- `README.md` — OSS-facing package documentation. +- `README.md` — OSS-facing getting-started and project overview. +- `docs/guide.md` — deeper usage guide, security model, and RLS/dialect notes. +- `docs/api.md` — public API reference. - `CHANGELOG.md` — user-visible change log. ## Working rules diff --git a/CHANGELOG.md b/CHANGELOG.md index ca38d60..50f0551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable user-visible changes to this project are documented in this file. ### Changed +- Restructure README as a shorter getting-started overview and move deeper usage/API material into `docs/`. + ### Fixed ## [0.10.0] - 2026-06-30 diff --git a/README.md b/README.md index 9b22981..366d22a 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,26 @@ # drizzle-scoped-db +Typed scoped Drizzle ORM handles for apps that must never forget tenant, org, user, region, visibility, or soft-delete predicates. + +[![CI](https://github.com/modem-dev/drizzle-scoped-db/actions/workflows/ci.yml/badge.svg)](https://github.com/modem-dev/drizzle-scoped-db/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/@modemdev/drizzle-scoped-db.svg)](https://www.npmjs.com/package/@modemdev/drizzle-scoped-db) [![types](https://img.shields.io/npm/types/@modemdev/drizzle-scoped-db.svg)](https://www.npmjs.com/package/@modemdev/drizzle-scoped-db) -[![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](#development) +[![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](#testing) [![license](https://img.shields.io/npm/l/@modemdev/drizzle-scoped-db.svg)](./LICENSE) -**One forgotten `WHERE` clause and a query returns rows it should never see. `drizzle-scoped-db` guards against that.** +`drizzle-scoped-db` wraps a Drizzle database handle with a smaller scoped facade. Code that receives `workspaceDb`, `tenantDb`, or `orgDb` gets scope predicates injected automatically, strict runtime checks by default, and loud errors when a scoped query forgets its boundary.

With a plain Drizzle handle a forgotten org filter silently returns every org's rows; with a drizzle-scoped-db handle the same query throws MissingScopedWhereError, caught before it ships.

-It wraps a Drizzle ORM handle in a typed, scoped one (`orgDb`, `tenantDb`, `workspaceDb`). The guardrail fits any predicate a query must never forget: tenant, org, user, region, soft-delete. Scope predicates are injected into your queries automatically, and in strict mode a scoped query that forgets its predicate throws at the call site, before it reaches the database. - -```ts -// Throws MissingScopedWhereError instead of returning every workspace's projects -await workspaceDb.select().from(projects); - -// Allowed: scoped predicate is present (and re-injected as defense in depth) -await workspaceDb.select().from(projects).where(eq(projects.workspaceId, workspaceId)); -``` - -### TL;DR - -- 🛡️ **Strict by default.** A missing `where` or scope predicate throws instead of leaking rows. -- 🤖 **Catches the mistakes humans, codegen, and AI agents make.** The forgotten scope filter surfaces in review and at runtime, not in an incident. -- 🧩 **Dialect-generic.** Built on Drizzle core types (Postgres, SQLite, MySQL, SingleStore), no DB lock-in. Layers with RLS rather than replacing it. - -## Where this fits - -drizzle-scoped-db is an application-layer guardrail in the query builder. It isn't database-enforced isolation, and it's built to sit alongside RLS, not compete with it. - -| | Enforcement layer | Isolation model | DB lock-in | Catches app-code mistakes | -| --------------------- | ------------------- | ---------------------------------- | ---------------------- | ------------------------- | -| **drizzle-scoped-db** | App (query builder) | Shared tables + injected predicate | None (dialect-generic) | ✅ typed + loud failures | -| Drizzle native RLS | Database | Shared tables + row policies | Postgres-only | ❌ enforced below the app | -| drizzle-multitenant | App (middleware) | Schema-per-tenant | Postgres-only | n/a (different model) | -| pgvpd | Proxy / wire | RLS via protocol proxy | Postgres-only | ❌ | -| Nile | DB vendor | Virtual tenant DBs | Nile-specific | ❌ | - -RLS gives you a boundary the application can't bypass, but it lives in the database: per-row policy evaluation, connection-pooling friction, silent failures that are hard to debug, and Postgres only. PlanetScale [covers the tradeoffs](https://planetscale.com/blog/rls-sounds-great-until-it-isnt) in detail. drizzle-scoped-db keeps the scope boundary in application code instead, where it's visible in TypeScript, type-checked, and loud: a forgotten predicate throws instead of returning the wrong rows. If you want a database-level backstop too, layer RLS underneath. See [How this relates to RLS](#how-this-relates-to-rls). - ## Why use it -- Pass typed scoped DB handles instead of the raw DB. -- Declare scoping rules once per table. -- Strict mode by default: missing `where` or missing scope predicate throws. -- Inject scope predicates into supported selects, joins, mutations, and relational root queries. -- Validate scoped inserts before they reach the database. -- Catch missing predicates in human-written, generated, or agent-authored code. - -## Use cases - -`drizzle-scoped-db` is a guardrail for any predicate a query must never forget. The scope is whatever you express as a Drizzle `where`: - -- **Tenant or org isolation.** Keep `tenant_id = currentTenant` on every query so one customer never sees another's rows. -- **Per-user data.** Force `user_id = currentUser` on private rows. -- **Region or data residency.** Keep `region = 'eu'` on every query. -- **Soft deletes.** Always exclude deleted rows with `isNull(table.deletedAt)` via [`defineScopedTable`](#custom-scope-rules). -- **Visibility.** A read handle that injects `published = true`, so public endpoints never surface drafts. -- **Row-level ACLs.** A composite predicate such as `owner_id = me OR shared_with @> me`. - -These share the same boundaries: the guardrail covers tables with rules, on the wrapped handle, in application code, not as a database-enforced boundary. See [Security model](#security-model). - -## How this relates to RLS - -RLS is enforced by the database. `drizzle-scoped-db` is enforced by the application path: scoped code receives a scoped Drizzle handle instead of the raw DB. It focuses on typed query builders, explicit scoped capabilities, and loud failures when predicates are missing. - -```ts -const workspaceDb = createScopedDb(db, { - scopeName: "workspace", - scopeValue: workspaceId, - rules, -}); - -const project = await workspaceDb - .select({ - id: projects.id, - name: projects.name, - }) - .from(projects) - .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId))); - -// Also injected automatically: eq(projects.workspaceId, workspaceId) -``` - -Conceptually, strict mode makes scoped reads look like this: - -```sql -WHERE projects.id = projectId - AND projects.workspace_id = workspaceId -- caller wrote this; strict mode checks it - AND projects.workspace_id = workspaceId -- wrapper injects this again -``` - -The predicate appears twice on purpose. You write it so the boundary is visible in code review and type-checked by TypeScript. Strict mode verifies you didn't forget it, then the wrapper injects its own copy as a backstop. The duplicate is redundant in the SQL and costs nothing; what it buys is a thrown error instead of a silent cross-scope read when someone forgets the predicate. - -Application code that should be scoped should receive the scoped DB handle, not the raw Drizzle instance. - -The two approaches are not mutually exclusive, and neither is strictly "above" the other: - -- **App-layer scoping (this package)** keeps isolation where your code lives: typed, reviewable, dialect-generic, and loud on mistakes. It can't constrain code that deliberately bypasses the scoped handle (see [Security model](#security-model)). -- **Database RLS** is a boundary the app can't bypass, but it's Postgres-only and carries the operational costs PlanetScale documents in [_RLS sounds great until it isn't_](https://planetscale.com/blog/rls-sounds-great-until-it-isnt): per-row policy evaluation, pooling friction, and silent failures. - -Use app-layer scoping as your primary, visible guardrail; add RLS underneath when you also want a database-level boundary that holds even if app code goes around the wrapper. On MySQL, SingleStore, or other engines without RLS, app-layer scoping is the practical path. +- **Strict by default.** Missing `where` clauses or missing scope predicates throw instead of silently returning cross-scope rows. +- **Typed scoped capabilities.** Pass scoped DB handles through app code instead of the raw Drizzle handle. +- **Defense in depth.** Callers write the scope predicate for review; the wrapper validates it and injects its own predicate too. +- **Covers common Drizzle paths.** Selects, joins, inserts, updates, deletes, transactions, root relational queries, and safe PostgreSQL/SQLite upserts. +- **Dialect-generic.** Uses Drizzle core `Table`, `Column`, and `SQL` types; tested with Postgres/PGlite and SQLite/sql.js. ## Install @@ -114,7 +32,7 @@ npm install @modemdev/drizzle-scoped-db drizzle-orm pnpm add @modemdev/drizzle-scoped-db drizzle-orm ``` -Drizzle is a peer dependency. +Drizzle is a peer dependency. This package supports `drizzle-orm >=0.45.2` and the Drizzle 1.0 release candidate line. ## Quick start @@ -138,25 +56,19 @@ const project = await workspaceDb .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId))); ``` -The wrapper still injects the workspace predicate again as defense in depth. - -Joined tables with declared rules receive their own scope predicates too. For joins, the joined table predicate is added to the join condition so `leftJoin` keeps its outer-join behavior: +Strict mode verifies the caller included `eq(projects.workspaceId, workspaceId)`, then the wrapper injects its own copy as a backstop. ```ts -const rows = await workspaceDb - .select() - .from(projects) - .leftJoin(tasks, eq(tasks.projectId, projects.id)) - .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId))); +// Throws MissingScopedWhereError +await workspaceDb.select().from(projects); -// Also injected automatically: -// - eq(projects.workspaceId, workspaceId) in the WHERE clause -// - eq(tasks.workspaceId, workspaceId) in the JOIN condition +// Throws MissingScopedPredicateError +await workspaceDb.select().from(projects).where(eq(projects.id, projectId)); ``` -## Insert validation +## Common patterns -When `insertKey` is provided, inserted rows must match the current scope value. +### Scoped writes ```ts await workspaceDb.insert(projects).values({ @@ -165,21 +77,6 @@ await workspaceDb.insert(projects).values({ name: "Roadmap", }); -// Throws InvalidScopedInsertError -await workspaceDb.insert(projects).values({ - id: projectId, - workspaceId: "another-workspace", - name: "Wrong workspace", -}); -``` - -Batch inserts are validated row by row. - -## Update and delete - -Scoped predicates are injected into mutations too. - -```ts await workspaceDb .update(tasks) .set({ status: "done" }) @@ -190,321 +87,114 @@ await workspaceDb .where(and(eq(tasks.id, taskId), eq(tasks.workspaceId, workspaceId))); ``` -## Relational query API - -Declare `queryName` to scope `db.query..findFirst` and `findMany`. - -```ts -const workspaceDb = createScopedDb(db, { - scopeName: "workspace", - scopeValue: workspaceId, - rules: [ - scopeByColumn(projects, projects.workspaceId, { - queryName: "projects", - insertKey: "workspaceId", - }), - ], -}); - -const project = await workspaceDb.query.projects.findFirst({ - where: (project, { and, eq }) => - and(eq(project.id, projectId), eq(project.workspaceId, workspaceId)), - with: { - tasks: true, - }, -}); -``` - -Tables without a matching rule pass through unchanged. - -Relational `with` entries are root-only today: `findFirst` / `findMany` are scoped, but nested relation rows rely on scope-safe relationships, explicit relation filters, or database constraints. - -## Data model shape - -This package works best when scope ownership is represented in your schema: - -- scope columns on scoped tables -- scoped rules for protected tables -- indexes for scoped access paths, e.g. `(scope_id, id)` and `(scope_id, foreign_id)` -- globally unique IDs or constraints that reject invalid cross-scope references - -Write rules explicitly for small schemas, or generate them once from schema metadata in an app-specific facade. - -Explicit rules: - -```ts -const rules = [ - scopeByColumn(projects, projects.workspaceId, { insertKey: "workspaceId" }), - scopeByColumn(tasks, tasks.workspaceId, { insertKey: "workspaceId" }), - scopeByColumn(comments, comments.workspaceId, { insertKey: "workspaceId" }), -]; -``` - -Generated rules: - -```ts -const tenantScopedRules = Object.values(schema) - .filter((table) => isDrizzleTable(table) && "tenantId" in table) - .map((table) => - scopeByColumn(table, table.tenantId, { - insertKey: "tenantId", - columnName: "tenant_id", - }), - ); -``` - -With either shape, the wrapper can scope root tables and joined tables with rules. Your schema still owns data consistency, such as preventing a task in one scope from referencing another scope's project. - -## Strict mode - -Strict mode is enabled by default and intended for most app code. Scoped selects, updates, deletes, and relational queries must include a `where` clause with the declared scope predicate. - -Callers write the scope predicate, the wrapper verifies it, then injects it again. If generated code, agent-authored code, or a rushed refactor forgets the predicate, the query throws. - -```ts -const workspaceDb = createScopedDb(db, { - scopeName: "workspace", - scopeValue: workspaceId, - rules: [scopeByColumn(projects, projects.workspaceId)], -}); - -// Throws MissingScopedWhereError -await workspaceDb.select().from(projects); - -// Throws MissingScopedPredicateError -await workspaceDb.select().from(projects).where(eq(projects.id, projectId)); - -// Allowed; the wrapper still injects its own scope predicate as defense in depth. -await workspaceDb - .select() - .from(projects) - .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId))); -``` - -The predicate must sit on the scoped table itself: filtering a joined table's same-named column (e.g. `eq(tasks.workspaceId, workspaceId)` while selecting `projects`) does not satisfy the check. Aliases of scoped tables are rejected unless the alias has its own explicit scoped rule, so an alias cannot silently bypass rule lookup. - -Custom `defineScopedTable` rules need `hasScopeInWhere` for strict validation. Opt out with `strict: false` if you want pure predicate injection: - -```ts -const workspaceDb = createScopedDb(db, { - scopeName: "workspace", - scopeValue: workspaceId, - strict: false, - rules: [scopeByColumn(projects, projects.workspaceId)], -}); - -await workspaceDb.select().from(projects).where(eq(projects.id, projectId)); -// Executes with: and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId)) -``` - -## Custom scope rules - -Use `defineScopedTable` for composite scopes or predicates that are not a single equality column. - -```ts -import { createScopedDb, defineScopedTable } from "@modemdev/drizzle-scoped-db"; -import { and, eq } from "drizzle-orm"; - -const scopedDb = createScopedDb(db, { - scopeName: "workspace-region", - scopeValue: { workspaceId, regionId }, - rules: [ - defineScopedTable(records, { - where: (scope) => - and(eq(records.workspaceId, scope.workspaceId), eq(records.regionId, scope.regionId)), - validateInsert: (row, scope) => - row.workspaceId === scope.workspaceId && row.regionId === scope.regionId, - }), - ], -}); -``` - -## Escape hatches - -`ScopedDb` intentionally does not mirror the full Drizzle API. It covers the common guarded path — scoped selects, joins, CRUD mutations, relational reads, transactions, and scoped PostgreSQL/SQLite upserts — without pretending every advanced Drizzle shape is scope-safe. +When `insertKey` is configured, inserts are validated before they reach the database. Updates can also reject scope-column reassignment. ### Scoped upserts -PostgreSQL/SQLite conflict updates can stay on the scoped facade with any conflict target when the update payload cannot move the row across scopes: +PostgreSQL/SQLite conflict updates stay on the scoped facade when the insert and update payloads are scope-safe: ```ts -workspaceDb - .insert(records) - .values({ workspaceId, regionId, key, value }) // scope-validated here - .onConflictDoUpdate({ target: records.key, set: { value } }); +await workspaceDb + .insert(projects) + .values({ id: projectId, workspaceId, name: "Roadmap" }) + .onConflictDoUpdate({ + target: projects.id, + set: { name: "Roadmap" }, + }); ``` -The wrapper forwards your `target`, `set`, and `targetWhere`, and auto-injects the rule's scope predicate into `setWhere`. If a conflict points at a row from another scope, the `DO UPDATE ... WHERE scope = value` guard is false, so the conflict safely no-ops instead of updating or inserting. - -For `scopeByColumn`, this works when `insertKey` is configured; that validates `.values(...)` and also validates `set` payloads unless you override the update field with `updateKey`. Custom `defineScopedTable` rules can opt in with `validateInsert` and `validateUpdate`; the guard is derived from the rule's existing `where(scopeValue)` predicate. - -When you need deliberate cross-scope writes, use an explicit escape hatch so you (and your agent) can see the audit boundary. +The wrapper forwards your conflict config and injects the scope predicate into `setWhere`, so a conflict against a row in another scope no-ops instead of updating that row. -### Local escape: `.$unsafeUnscoped()` +### Escape hatches -Use after scoped insert validation for conflict handlers the scoped facade intentionally will not guard, such as targetless MySQL `onDuplicateKeyUpdate(...)`, custom rules without upsert validators, or deliberate cross-scope writes like reassigning a row's owner during a connect flow: +Scoped handles intentionally do not expose every raw Drizzle method. Use the loud escapes when you are deliberately leaving the guardrail: ```ts +// Continue from a scope-validated insert into a raw dialect-specific chain. workspaceDb - .insert(records) - .values({ workspaceId, regionId, key, value }) // scope-validated here - .$unsafeUnscoped() - .onConflictDoUpdate({ target: records.key, set: { workspaceId: newWorkspaceId } }); -``` - -The inserted values were checked, but the conflict target, `set`, and follow-up `where` clauses are yours to keep scope-safe. Prefer the scoped facade for normal upserts; it injects the `setWhere` guard automatically. - -### Root escape: `_unsafeUnscopedDb` + .insert(projects) + .values({ id: projectId, workspaceId, name: "Roadmap" }) + .$unsafeUnscoped(); -Use when there is no scoped chain to start from: - -```ts +// Use the raw DB for migrations, admin jobs, raw SQL, CTEs, or cross-scope maintenance. workspaceDb._unsafeUnscopedDb; ``` -Common cases: migrations, admin jobs, test setup, cross-scope maintenance, raw SQL, CTEs/subqueries, `$dynamic`, or query shapes the scoped facade does not model. - -## Security model - -`drizzle-scoped-db` protects supported Drizzle query-builder calls that go through the scoped wrapper. It is not a complete database isolation system and cannot protect code that bypasses the scoped capability. - -The wrapper scopes supported selects, joins, mutations, root relational queries, and validated inserts. The schema shape in [Data model shape](#data-model-shape) still matters: your data model needs ownership columns, indexes, and relationship invariants that match how your app scopes data. - -Not protected: - -- raw SQL, `_unsafeUnscopedDb`, or helpers that close over the raw DB -- query builder methods reached after `.$unsafeUnscoped()` or through `_unsafeUnscopedDb` -- tables or joined tables without rules -- nested relational `with` rows unless your relationships, filters, or constraints enforce scope safety -- invalid cross-scope rows that your database constraints allow -- deliberate bypasses of the scoped DB capability +## Performance -RLS, database permissions, and other database-native controls can be layered with scoped handles when you need enforcement outside the typed application query-builder path. +Scoped wrappers add proxy/facade work around Drizzle builders, so releases include benchmark and heap-growth snapshots. -## Dialect support +- Run locally with `pnpm bench:release`. +- Compare against the latest committed baseline with `pnpm bench:release:compare`. +- Snapshots live in [`benchmarks/release/`](benchmarks/release/) and are generated during release prep. +- The release gate fails on material timing or heap regressions unless the snapshot explicitly records an accepted tradeoff. -The package uses Drizzle core `Table`, `Column`, and `SQL` types, so rules are not tied to `pg-core`. +See [`benchmarks/release/README.md`](benchmarks/release/README.md) for thresholds and workflow details. -Expected support: +## Testing -- PostgreSQL -- SQLite -- MySQL -- SingleStore -- any Drizzle driver with the standard `select`, `insert`, `update`, `delete`, and optional `query` APIs +The project currently enforces 100% source coverage: -`selectDistinctOn` is exposed only when the wrapped Drizzle instance provides it, which is primarily a PostgreSQL feature. - -## Wrapped APIs - -Currently wrapped: - -- `select().from(table).where(...)`, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules -- `selectDistinct().from(table).where(...)`, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules -- `selectDistinctOn(...).from(table).where(...)` when supported by the driver, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules -- `insert(table).values(...)`, plus `.returning(...)`, `.$returningId()`, `.onConflictDoNothing(...)`, safe `.onConflictDoUpdate(...)` when supported, and `.$unsafeUnscoped()` for raw continuation -- `update(table).set(...).where(...)` -- `delete(table).where(...)` -- `query..findFirst(...)` -- `query..findMany(...)` -- `transaction(...)`, with a scoped transaction DB passed to the callback - -Tables without rules and unwrapped APIs pass through to the underlying Drizzle instance. - -## API - -### `createScopedDb(db, options)` - -```ts -type CreateScopedDbOptions = { - scopeName: string; - scopeValue: TScope; - rules: ScopedTableRule[]; - strict?: boolean; // defaults to true - unscopedDbPropertyName?: string; // defaults to '_unsafeUnscopedDb' - scopeValueProperty?: string; - toJSON?: (scopeValue: TScope, scopeName: string) => unknown; - extensions?: (scopeValue: TScope, scopeName: string) => Record; - errors?: ScopedDbErrors; -}; +```text +Statements : 100% +Branches : 100% +Functions : 100% +Lines : 100% ``` -### `scopeByColumn(table, column, options)` +Test coverage includes: -```ts -type ScopeByColumnOptions = { - queryName?: string; - tableName?: string; - insertKey?: string; - updateKey?: string; // defaults to insertKey - columnName?: string; - equals?: (rowValue: unknown, scopeValue: TScope) => boolean; -}; -``` +- behavior-focused unit tests under [`tests/unit/`](tests/unit/) +- real-driver integration tests under [`tests/integration/`](tests/integration/) +- Postgres coverage via PGlite +- SQLite coverage via sql.js +- CI matrix checks for locked Drizzle and `drizzle-orm@rc` -### `defineScopedTable(table, rule)` +Run the same checks CI runs: -```ts -type ScopedTableRule< - TScope, - TInsert = Record, - TUpdate = Record, -> = { - table: Table; - queryName?: string; - tableName?: string; - where: (scopeValue: TScope) => SQL | undefined; - validateInsert?: (row: TInsert, scopeValue: TScope) => boolean; - validateUpdate?: (payload: TUpdate, scopeValue: TScope) => boolean; - // Legacy detector retained for compatibility; scoped onConflictDoUpdate no longer consults it. - hasScopeInConflictTarget?: (target: unknown) => boolean; - // Required when createScopedDb({ strict: true }) is enabled. - hasScopeInWhere?: (condition: SQL | undefined) => boolean; -}; +```bash +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test +pnpm coverage +pnpm build ``` -### `assertDrizzleCompatibility(condition, expectedColumnName, expectedTable?)` - -Optional startup assertion for projects that rely on `strict` mode or `containsColumnFilter`. +## Docs -```ts -import { assertDrizzleCompatibility } from "@modemdev/drizzle-scoped-db"; -import { eq } from "drizzle-orm"; - -// Name-only check (backward compatible) -assertDrizzleCompatibility(eq(projects.workspaceId, "compat-check"), "workspace_id"); +- [Guide](docs/guide.md): use cases, strict mode, custom rules, relational queries, security model, RLS comparison, and dialect support. +- [API reference](docs/api.md): exported functions, options, error types, and wrapped APIs. +- [Benchmarks](benchmarks/release/README.md): release benchmark snapshots and regression policy. +- [Security policy](SECURITY.md): how to report vulnerabilities. +- [Changelog](CHANGELOG.md): release history. -// Table-aware check (recommended when using scopeByColumn's default detector) -assertDrizzleCompatibility(eq(projects.workspaceId, "compat-check"), "workspace_id", projects); -``` +## Security -If a Drizzle upgrade changes the internal SQL chunk shape, this fails fast instead of letting strict validation silently return `false`. Pass the table to also verify that column chunks expose table identity for alias-safe disambiguation. +Please report vulnerabilities through the process in [SECURITY.md](SECURITY.md). -## Errors +`drizzle-scoped-db` protects supported Drizzle query-builder calls that go through the scoped wrapper. It is an application-layer guardrail, not a complete database isolation system, and it cannot protect code that bypasses the scoped capability. Layer database permissions, constraints, or RLS underneath when you need a database-enforced boundary too. -- `MissingScopedWhereError` -- `MissingScopedPredicateError` -- `InvalidScopedInsertError` -- `InvalidScopedUpdateError` -- `InvalidScopedConflictTargetError` +Read the full [security model](docs/guide.md#security-model). -You can replace these with custom error factories in `createScopedDb({ errors })`. +## Contributing -## Development +Issues and focused pull requests are welcome. Before opening a PR, run: ```bash +pnpm format:check +pnpm lint +pnpm typecheck pnpm test pnpm coverage +pnpm build ``` -Release prep also records a committed performance and heap-growth snapshot: +For performance-sensitive wrapper or proxy changes, also run the benchmark commands above. -```bash -pnpm bench:release -pnpm bench:release:compare -``` +## Support -The package has 100% statement, branch, function, and line coverage. +Use [GitHub issues](https://github.com/modem-dev/drizzle-scoped-db/issues) for bug reports, feature requests, and documentation gaps. ## Sponsor @@ -520,4 +210,4 @@ Sponsored by [Modem](https://modem.dev?utm_source=github&utm_medium=oss&utm_camp ## License -MIT. +MIT. See [LICENSE](LICENSE). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..585fc05 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,176 @@ +# API reference + +## Imports + +```ts +import { + assertDrizzleCompatibility, + containsColumnFilter, + createScopedDb, + defineScopedTable, + scopeByColumn, +} from "@modemdev/drizzle-scoped-db"; +``` + +## `createScopedDb(db, options)` + +Wrap a Drizzle database handle with scoped query-builder facades. + +```ts +type CreateScopedDbOptions = { + scopeName: string; + scopeValue: TScope; + rules: ScopedTableRule[]; + strict?: boolean; // defaults to true + unscopedDbPropertyName?: string; // defaults to "_unsafeUnscopedDb" + scopeValueProperty?: string; + toJSON?: (scopeValue: TScope, scopeName: string) => unknown; + extensions?: (scopeValue: TScope, scopeName: string) => Record; + errors?: ScopedDbErrors; +}; +``` + +Example: + +```ts +const workspaceDb = createScopedDb(db, { + scopeName: "workspace", + scopeValue: workspaceId, + rules: [scopeByColumn(projects, projects.workspaceId, { insertKey: "workspaceId" })], +}); +``` + +## `scopeByColumn(table, column, options)` + +Create a rule for the common case where one table column stores the scope value. + +```ts +type ScopeByColumnOptions = { + queryName?: string; + tableName?: string; + insertKey?: string; + updateKey?: string; // defaults to insertKey + columnName?: string; + equals?: (rowValue: unknown, scopeValue: TScope) => boolean; +}; +``` + +Options: + +- `queryName`: property name under `db.query` for relational query wrapping. +- `tableName`: human-readable name used in errors. +- `insertKey`: row key checked during `.insert(...).values(...)`. +- `updateKey`: payload key checked during `.update(...).set(...)` and scoped upserts. Defaults to `insertKey`. +- `columnName`: explicit database column name. Useful when a Drizzle column object does not expose a string `.name`. +- `equals`: custom equality function for comparing payload values to `scopeValue`. + +## `defineScopedTable(table, rule)` + +Create a custom rule for composite scopes or predicates that cannot be represented by one equality column. + +```ts +type ScopedTableRule< + TScope, + TInsert = Record, + TUpdate = Record, +> = { + table: Table; + queryName?: string; + tableName?: string; + where: (scopeValue: TScope) => SQL | undefined; + validateInsert?: (row: TInsert, scopeValue: TScope) => boolean; + validateUpdate?: (payload: TUpdate, scopeValue: TScope) => boolean; + hasScopeInConflictTarget?: (target: unknown) => boolean; + hasScopeInWhere?: (condition: SQL | undefined) => boolean; +}; +``` + +Notes: + +- `where` provides the predicate the wrapper injects. +- `validateInsert` gates scoped inserts. +- `validateUpdate` gates scoped updates and scoped conflict updates. +- `hasScopeInWhere` is required for custom rules when `strict: true` should validate caller-provided predicates. +- `hasScopeInConflictTarget` is retained for compatibility; scoped PostgreSQL/SQLite upserts no longer require conflict targets to include the scope column. + +## `containsColumnFilter(condition, expectedColumnName, expectedTable?)` + +Return whether a Drizzle `SQL` predicate contains a comparison for the expected column, optionally scoped to a specific table object. + +This is primarily useful for custom strict validation helpers and compatibility checks. + +```ts +containsColumnFilter(eq(projects.workspaceId, workspaceId), "workspace_id", projects); +``` + +## `assertDrizzleCompatibility(condition, expectedColumnName, expectedTable?)` + +Fail fast if a Drizzle upgrade changes SQL internals enough that strict validation cannot inspect predicates safely. + +```ts +assertDrizzleCompatibility(eq(projects.workspaceId, "compat-check"), "workspace_id", projects); +``` + +Pass the table when using table-aware validation so the assertion verifies column chunks expose table identity. + +## Errors + +Default scoped validation errors: + +- `MissingScopedWhereError` +- `MissingScopedPredicateError` +- `InvalidScopedInsertError` +- `InvalidScopedUpdateError` +- `InvalidScopedConflictTargetError` + +Customize errors through `createScopedDb({ errors })`: + +```ts +const scopedDb = createScopedDb(db, { + scopeName: "workspace", + scopeValue: workspaceId, + rules, + errors: { + missingWhere: (tableName, scopeName, scopeValue) => + new Error(`${tableName} must include ${scopeName}=${scopeValue}`), + }, +}); +``` + +## Wrapped APIs + +Currently wrapped: + +- `select().from(table).where(...)`, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules +- `selectDistinct().from(table).where(...)`, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules +- `selectDistinctOn(...).from(table).where(...)` when supported by the driver, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules +- `insert(table).values(...)`, plus `.returning(...)`, `.$returningId()`, `.onConflictDoNothing(...)`, safe `.onConflictDoUpdate(...)` when supported, and `.$unsafeUnscoped()` for raw continuation +- `update(table).set(...).where(...)` +- `delete(table).where(...)` +- `query..findFirst(...)` +- `query..findMany(...)` +- `transaction(...)`, with a scoped transaction DB passed to the callback + +Tables without rules and unwrapped APIs pass through to the underlying Drizzle instance. + +## Type exports + +The package exports the main helper types used by the public API: + +- `CreateScopedDbOptions` +- `InferSelection` +- `RelationalWhere` +- `RelationalWhereCallback` +- `ScopedDb` +- `ScopedDbErrors` +- `ScopedDeleteBuilder` +- `ScopedInsertBuilder` +- `ScopedInsertResult` +- `ScopedMutationResult` +- `ScopedQueryBuilder` +- `ScopedSelectBuilder` +- `ScopedTable` +- `ScopedTableRule` +- `ScopedUpdateBuilder` +- `ScopedWhereBuilder` +- `ScopeByColumnOptions` diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..f2702e0 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,306 @@ +# Guide + +`drizzle-scoped-db` is an application-layer guardrail for Drizzle ORM. It helps keep a required predicate on every scoped query: tenant, org, workspace, user, region, soft-delete, visibility, or any predicate your app must not forget. + +## Where this fits + +The library wraps a Drizzle database handle and returns a scoped handle. Application code that should be scoped receives that scoped capability instead of the raw DB. + +```ts +const workspaceDb = createScopedDb(db, { + scopeName: "workspace", + scopeValue: workspaceId, + rules, +}); +``` + +The wrapper scopes supported query builders. Tables without rules pass through unchanged. + +## Use cases + +- **Tenant or org isolation.** Keep `tenant_id = currentTenant` or `org_id = currentOrg` on every query. +- **Per-user data.** Force `user_id = currentUser` on private rows. +- **Region or data residency.** Keep `region = 'eu'` on every query. +- **Soft deletes.** Always exclude deleted rows with a custom `isNull(table.deletedAt)` rule. +- **Visibility.** Use a read handle that injects `published = true` so public endpoints never surface drafts. +- **Row-level ACLs.** Express composite predicates such as `owner_id = me OR shared_with @> me` with `defineScopedTable`. + +## Data model shape + +The guardrail works best when scope ownership is represented in your schema: + +- scope columns on scoped tables +- scoped rules for protected tables +- indexes for scoped access paths, for example `(scope_id, id)` and `(scope_id, foreign_id)` +- globally unique IDs or constraints that reject invalid cross-scope references + +Write rules explicitly for small schemas: + +```ts +const rules = [ + scopeByColumn(projects, projects.workspaceId, { insertKey: "workspaceId" }), + scopeByColumn(tasks, tasks.workspaceId, { insertKey: "workspaceId" }), + scopeByColumn(comments, comments.workspaceId, { insertKey: "workspaceId" }), +]; +``` + +Or generate them once from app-specific schema metadata: + +```ts +const tenantScopedRules = Object.values(schema) + .filter((table) => isDrizzleTable(table) && "tenantId" in table) + .map((table) => + scopeByColumn(table, table.tenantId, { + insertKey: "tenantId", + columnName: "tenant_id", + }), + ); +``` + +Your database schema still owns data consistency, such as preventing a task in one scope from referencing another scope's project. + +## Strict mode + +Strict mode is enabled by default. Scoped selects, updates, deletes, and relational root queries must include a `where` clause with the declared scope predicate. + +Callers write the scope predicate, the wrapper verifies it, then injects it again. If human-written code, generated code, or agent-authored code forgets the predicate, the query throws. + +```ts +const workspaceDb = createScopedDb(db, { + scopeName: "workspace", + scopeValue: workspaceId, + rules: [scopeByColumn(projects, projects.workspaceId)], +}); + +// Throws MissingScopedWhereError +await workspaceDb.select().from(projects); + +// Throws MissingScopedPredicateError +await workspaceDb.select().from(projects).where(eq(projects.id, projectId)); + +// Allowed; the wrapper still injects its own scope predicate. +await workspaceDb + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId))); +``` + +Conceptually, strict mode makes scoped reads look like this: + +```sql +WHERE projects.id = projectId + AND projects.workspace_id = workspaceId -- caller wrote this; strict mode checks it + AND projects.workspace_id = workspaceId -- wrapper injects this again +``` + +The duplicate predicate is intentional. It keeps the boundary visible in code review and adds a generated backstop. + +The predicate must sit on the scoped table itself. Filtering a joined table's same-named column does not satisfy the check for the root table. Aliases of scoped tables are rejected unless the alias has its own explicit scoped rule, so aliases cannot silently bypass rule lookup. + +Custom `defineScopedTable` rules need `hasScopeInWhere` for strict validation. Opt out with `strict: false` if you want pure predicate injection: + +```ts +const workspaceDb = createScopedDb(db, { + scopeName: "workspace", + scopeValue: workspaceId, + strict: false, + rules: [scopeByColumn(projects, projects.workspaceId)], +}); + +await workspaceDb.select().from(projects).where(eq(projects.id, projectId)); +// Executes with: and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId)) +``` + +## Selects and joins + +Scope predicates are injected into scoped root tables. Joined tables with declared rules receive their own predicates too. For joins, the joined table predicate is added to the join condition so `leftJoin` keeps outer-join behavior. + +```ts +const rows = await workspaceDb + .select() + .from(projects) + .leftJoin(tasks, eq(tasks.projectId, projects.id)) + .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId))); + +// Also injected automatically: +// - eq(projects.workspaceId, workspaceId) in the WHERE clause +// - eq(tasks.workspaceId, workspaceId) in the JOIN condition +``` + +## Inserts, updates, and deletes + +When `insertKey` is provided, inserted rows must match the current scope value. + +```ts +await workspaceDb.insert(projects).values({ + id: projectId, + workspaceId, + name: "Roadmap", +}); + +// Throws InvalidScopedInsertError +await workspaceDb.insert(projects).values({ + id: projectId, + workspaceId: "another-workspace", + name: "Wrong workspace", +}); +``` + +Batch inserts are validated row by row. Updates use `updateKey` when provided, otherwise `insertKey` is used as the fallback update validator. + +```ts +await workspaceDb + .update(tasks) + .set({ status: "done" }) + .where(and(eq(tasks.id, taskId), eq(tasks.workspaceId, workspaceId))); + +await workspaceDb + .delete(tasks) + .where(and(eq(tasks.id, taskId), eq(tasks.workspaceId, workspaceId))); +``` + +## Scoped upserts + +PostgreSQL/SQLite conflict updates can stay on the scoped facade with any conflict target when the update payload cannot move the row across scopes. + +```ts +await workspaceDb + .insert(records) + .values({ workspaceId, regionId, key, value }) + .onConflictDoUpdate({ target: records.key, set: { value } }); +``` + +The wrapper forwards your `target`, `set`, and `targetWhere`, then injects the rule's scope predicate into `setWhere`. If a conflict points at a row from another scope, the guarded `DO UPDATE ... WHERE scope = value` is false, so the conflict safely no-ops instead of updating or inserting. + +For `scopeByColumn`, configure `insertKey` to validate `.values(...)` and `set` payloads. Use `updateKey` when the update payload field differs from the insert field. Custom `defineScopedTable` rules can opt in with `validateInsert` and `validateUpdate`. + +Use `.$unsafeUnscoped()` for targetless MySQL upserts, custom rules without upsert validators, or deliberate cross-scope writes. + +## Relational query API + +Declare `queryName` to scope `db.query..findFirst` and `findMany`. + +```ts +const workspaceDb = createScopedDb(db, { + scopeName: "workspace", + scopeValue: workspaceId, + rules: [ + scopeByColumn(projects, projects.workspaceId, { + queryName: "projects", + insertKey: "workspaceId", + }), + ], +}); + +const project = await workspaceDb.query.projects.findFirst({ + where: (project, { and, eq }) => + and(eq(project.id, projectId), eq(project.workspaceId, workspaceId)), + with: { + tasks: true, + }, +}); +``` + +Relational `with` entries are root-only today: `findFirst` / `findMany` are scoped, but nested relation rows rely on scope-safe relationships, explicit relation filters, or database constraints. + +## Custom scope rules + +Use `defineScopedTable` for composite scopes or predicates that are not a single equality column. + +```ts +import { createScopedDb, defineScopedTable } from "@modemdev/drizzle-scoped-db"; +import { and, eq } from "drizzle-orm"; + +const scopedDb = createScopedDb(db, { + scopeName: "workspace-region", + scopeValue: { workspaceId, regionId }, + rules: [ + defineScopedTable(records, { + where: (scope) => + and(eq(records.workspaceId, scope.workspaceId), eq(records.regionId, scope.regionId)), + validateInsert: (row, scope) => + row.workspaceId === scope.workspaceId && row.regionId === scope.regionId, + validateUpdate: (payload, scope) => + (!payload.workspaceId || payload.workspaceId === scope.workspaceId) && + (!payload.regionId || payload.regionId === scope.regionId), + }), + ], +}); +``` + +Add `hasScopeInWhere` if strict validation should inspect caller-provided predicates for a custom rule. + +## Escape hatches + +`ScopedDb` intentionally does not mirror the full Drizzle API. It covers the common guarded path without pretending every advanced Drizzle shape is scope-safe. + +### Local escape: `.$unsafeUnscoped()` + +Use after scoped insert validation for conflict handlers the scoped facade intentionally will not guard. + +```ts +workspaceDb + .insert(records) + .values({ workspaceId, regionId, key, value }) + .$unsafeUnscoped() + .onConflictDoUpdate({ target: records.key, set: { workspaceId: newWorkspaceId } }); +``` + +The inserted values were checked, but the conflict target, `set`, and follow-up `where` clauses are yours to keep scope-safe. + +### Root escape: `_unsafeUnscopedDb` + +Use when there is no scoped chain to start from: + +```ts +workspaceDb._unsafeUnscopedDb; +``` + +Common cases: migrations, admin jobs, test setup, cross-scope maintenance, raw SQL, CTEs/subqueries, `$dynamic`, or query shapes the scoped facade does not model. + +## Security model + +`drizzle-scoped-db` protects supported Drizzle query-builder calls that go through the scoped wrapper. It is not a complete database isolation system and cannot protect code that bypasses the scoped capability. + +Protected paths include supported selects, joins, mutations, root relational queries, validated inserts, transactions, and safe PostgreSQL/SQLite upserts. The schema shape still matters: your data model needs ownership columns, indexes, and relationship invariants that match how your app scopes data. + +Not protected: + +- raw SQL, `_unsafeUnscopedDb`, or helpers that close over the raw DB +- query builder methods reached after `.$unsafeUnscoped()` or through `_unsafeUnscopedDb` +- tables or joined tables without rules +- nested relational `with` rows unless relationships, filters, or constraints enforce scope safety +- invalid cross-scope rows that database constraints allow +- deliberate bypasses of the scoped DB capability + +RLS, database permissions, and other database-native controls can be layered with scoped handles when you need enforcement outside the typed application query-builder path. + +## How this relates to RLS + +RLS is enforced by the database. `drizzle-scoped-db` is enforced by the application path: scoped code receives a scoped Drizzle handle instead of the raw DB. It focuses on typed query builders, explicit scoped capabilities, and loud failures when predicates are missing. + +| | Enforcement layer | Isolation model | DB lock-in | Catches app-code mistakes | +| --------------------- | ----------------- | ---------------------------------- | ---------------------- | ------------------------- | +| **drizzle-scoped-db** | App query builder | Shared tables + injected predicate | None (dialect-generic) | Yes, typed + loud errors | +| Drizzle native RLS | Database | Shared tables + row policies | Postgres-only | Enforced below the app | +| drizzle-multitenant | App middleware | Schema-per-tenant | Postgres-only | Different model | +| pgvpd | Proxy / wire | RLS via protocol proxy | Postgres-only | No | +| Nile | DB vendor | Virtual tenant DBs | Nile-specific | No | + +The approaches can be layered. Use app-layer scoping as a visible guardrail in application code; add RLS underneath when you also want a database-level boundary that holds even if app code goes around the wrapper. On MySQL, SingleStore, SQLite, or other engines without RLS, app-layer scoping is the practical path. + +PlanetScale's [_RLS sounds great until it isn't_](https://planetscale.com/blog/rls-sounds-great-until-it-isnt) covers common operational tradeoffs around RLS, including per-row policy evaluation, pooling friction, and silent failures. + +## Dialect support + +The package uses Drizzle core `Table`, `Column`, and `SQL` types, so rules are not tied to `pg-core`. + +Expected support: + +- PostgreSQL +- SQLite +- MySQL +- SingleStore +- any Drizzle driver with the standard `select`, `insert`, `update`, `delete`, and optional `query` APIs + +`selectDistinctOn` is exposed only when the wrapped Drizzle instance provides it, primarily PostgreSQL. diff --git a/package.json b/package.json index 0815288..a92b04b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ }, "files": [ "dist", + "docs", + "benchmarks/release/README.md", "README.md", "LICENSE", "SECURITY.md"