{"id":51413795,"url":"https://github.com/modem-dev/drizzle-scoped-db","last_synced_at":"2026-07-04T17:01:42.040Z","repository":{"id":366909805,"uuid":"1278288698","full_name":"modem-dev/drizzle-scoped-db","owner":"modem-dev","description":"Scope-enforced DB queries for Drizzle ORM","archived":false,"fork":false,"pushed_at":"2026-06-30T21:23:28.000Z","size":673,"stargazers_count":12,"open_issues_count":8,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-30T21:25:25.569Z","etag":null,"topics":["db","drizzle","drizzle-orm","security"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/modem-dev.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-06-23T16:41:43.000Z","updated_at":"2026-06-30T21:21:38.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/modem-dev/drizzle-scoped-db","commit_stats":null,"previous_names":["modem-dev/drizzle-scoped-db"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/modem-dev/drizzle-scoped-db","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/modem-dev%2Fdrizzle-scoped-db","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/modem-dev%2Fdrizzle-scoped-db/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/modem-dev%2Fdrizzle-scoped-db/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/modem-dev%2Fdrizzle-scoped-db/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/modem-dev","download_url":"https://codeload.github.com/modem-dev/drizzle-scoped-db/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/modem-dev%2Fdrizzle-scoped-db/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35129190,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-04T02:00:05.987Z","response_time":113,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["db","drizzle","drizzle-orm","security"],"created_at":"2026-07-04T17:01:41.149Z","updated_at":"2026-07-04T17:01:42.033Z","avatar_url":"https://github.com/modem-dev.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# drizzle-scoped-db\n\n[![npm version](https://img.shields.io/npm/v/@modemdev/drizzle-scoped-db.svg)](https://www.npmjs.com/package/@modemdev/drizzle-scoped-db)\n[![types](https://img.shields.io/npm/types/@modemdev/drizzle-scoped-db.svg)](https://www.npmjs.com/package/@modemdev/drizzle-scoped-db)\n[![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](#development)\n[![license](https://img.shields.io/npm/l/@modemdev/drizzle-scoped-db.svg)](./LICENSE)\n\n**One forgotten `WHERE` clause and a query returns rows it should never see. `drizzle-scoped-db` guards against that.**\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"./assets/before-after.png\" width=\"820\" alt=\"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.\" /\u003e\n\u003c/p\u003e\n\nIt 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.\n\n```ts\n// Throws MissingScopedWhereError instead of returning every workspace's projects\nawait workspaceDb.select().from(projects);\n\n// Allowed: scoped predicate is present (and re-injected as defense in depth)\nawait workspaceDb.select().from(projects).where(eq(projects.workspaceId, workspaceId));\n```\n\n### TL;DR\n\n- 🛡️ **Strict by default.** A missing `where` or scope predicate throws instead of leaking rows.\n- 🤖 **Catches the mistakes humans, codegen, and AI agents make.** The forgotten scope filter surfaces in review and at runtime, not in an incident.\n- 🧩 **Dialect-generic.** Built on Drizzle core types (Postgres, SQLite, MySQL, SingleStore), no DB lock-in. Layers with RLS rather than replacing it.\n\n## Where this fits\n\ndrizzle-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.\n\n|                       | Enforcement layer   | Isolation model                    | DB lock-in             | Catches app-code mistakes |\n| --------------------- | ------------------- | ---------------------------------- | ---------------------- | ------------------------- |\n| **drizzle-scoped-db** | App (query builder) | Shared tables + injected predicate | None (dialect-generic) | ✅ typed + loud failures  |\n| Drizzle native RLS    | Database            | Shared tables + row policies       | Postgres-only          | ❌ enforced below the app |\n| drizzle-multitenant   | App (middleware)    | Schema-per-tenant                  | Postgres-only          | n/a (different model)     |\n| pgvpd                 | Proxy / wire        | RLS via protocol proxy             | Postgres-only          | ❌                        |\n| Nile                  | DB vendor           | Virtual tenant DBs                 | Nile-specific          | ❌                        |\n\nRLS 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).\n\n## Why use it\n\n- Pass typed scoped DB handles instead of the raw DB.\n- Declare scoping rules once per table.\n- Strict mode by default: missing `where` or missing scope predicate throws.\n- Inject scope predicates into supported selects, joins, mutations, and relational root queries.\n- Validate scoped inserts before they reach the database.\n- Catch missing predicates in human-written, generated, or agent-authored code.\n\n## Use cases\n\n`drizzle-scoped-db` is a guardrail for any predicate a query must never forget. The scope is whatever you express as a Drizzle `where`:\n\n- **Tenant or org isolation.** Keep `tenant_id = currentTenant` on every query so one customer never sees another's rows.\n- **Per-user data.** Force `user_id = currentUser` on private rows.\n- **Region or data residency.** Keep `region = 'eu'` on every query.\n- **Soft deletes.** Always exclude deleted rows with `isNull(table.deletedAt)` via [`defineScopedTable`](#custom-scope-rules).\n- **Visibility.** A read handle that injects `published = true`, so public endpoints never surface drafts.\n- **Row-level ACLs.** A composite predicate such as `owner_id = me OR shared_with @\u003e me`.\n\nThese 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).\n\n## How this relates to RLS\n\nRLS 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.\n\n```ts\nconst workspaceDb = createScopedDb(db, {\n  scopeName: \"workspace\",\n  scopeValue: workspaceId,\n  rules,\n});\n\nconst project = await workspaceDb\n  .select({\n    id: projects.id,\n    name: projects.name,\n  })\n  .from(projects)\n  .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId)));\n\n// Also injected automatically: eq(projects.workspaceId, workspaceId)\n```\n\nConceptually, strict mode makes scoped reads look like this:\n\n```sql\nWHERE projects.id = projectId\n  AND projects.workspace_id = workspaceId -- caller wrote this; strict mode checks it\n  AND projects.workspace_id = workspaceId -- wrapper injects this again\n```\n\nThe 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.\n\nApplication code that should be scoped should receive the scoped DB handle, not the raw Drizzle instance.\n\nThe two approaches are not mutually exclusive, and neither is strictly \"above\" the other:\n\n- **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)).\n- **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.\n\nUse 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.\n\n## Install\n\n```bash\nnpm install @modemdev/drizzle-scoped-db drizzle-orm\n```\n\n```bash\npnpm add @modemdev/drizzle-scoped-db drizzle-orm\n```\n\nDrizzle is a peer dependency.\n\n## Quick start\n\n```ts\nimport { createScopedDb, scopeByColumn } from \"@modemdev/drizzle-scoped-db\";\nimport { and, eq } from \"drizzle-orm\";\nimport { projects, tasks } from \"./schema\";\n\nconst workspaceDb = createScopedDb(db, {\n  scopeName: \"workspace\",\n  scopeValue: workspaceId,\n  rules: [\n    scopeByColumn(projects, projects.workspaceId, { insertKey: \"workspaceId\" }),\n    scopeByColumn(tasks, tasks.workspaceId, { insertKey: \"workspaceId\" }),\n  ],\n});\n\nconst project = await workspaceDb\n  .select()\n  .from(projects)\n  .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId)));\n```\n\nThe wrapper still injects the workspace predicate again as defense in depth.\n\nJoined 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:\n\n```ts\nconst rows = await workspaceDb\n  .select()\n  .from(projects)\n  .leftJoin(tasks, eq(tasks.projectId, projects.id))\n  .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId)));\n\n// Also injected automatically:\n// - eq(projects.workspaceId, workspaceId) in the WHERE clause\n// - eq(tasks.workspaceId, workspaceId) in the JOIN condition\n```\n\n## Insert validation\n\nWhen `insertKey` is provided, inserted rows must match the current scope value.\n\n```ts\nawait workspaceDb.insert(projects).values({\n  id: projectId,\n  workspaceId,\n  name: \"Roadmap\",\n});\n\n// Throws InvalidScopedInsertError\nawait workspaceDb.insert(projects).values({\n  id: projectId,\n  workspaceId: \"another-workspace\",\n  name: \"Wrong workspace\",\n});\n```\n\nBatch inserts are validated row by row.\n\n## Update and delete\n\nScoped predicates are injected into mutations too.\n\n```ts\nawait workspaceDb\n  .update(tasks)\n  .set({ status: \"done\" })\n  .where(and(eq(tasks.id, taskId), eq(tasks.workspaceId, workspaceId)));\n\nawait workspaceDb\n  .delete(tasks)\n  .where(and(eq(tasks.id, taskId), eq(tasks.workspaceId, workspaceId)));\n```\n\n## Relational query API\n\nDeclare `queryName` to scope `db.query.\u003cname\u003e.findFirst` and `findMany`.\n\n```ts\nconst workspaceDb = createScopedDb(db, {\n  scopeName: \"workspace\",\n  scopeValue: workspaceId,\n  rules: [\n    scopeByColumn(projects, projects.workspaceId, {\n      queryName: \"projects\",\n      insertKey: \"workspaceId\",\n    }),\n  ],\n});\n\nconst project = await workspaceDb.query.projects.findFirst({\n  where: (project, { and, eq }) =\u003e\n    and(eq(project.id, projectId), eq(project.workspaceId, workspaceId)),\n});\n```\n\nTables without a matching rule pass through unchanged for plain root `findFirst` / `findMany` calls. When any relational scoped rule is configured, relational `with` includes fail closed because nested relation rows cannot yet be scoped safely by the wrapper. Use explicit scoped joins or separate scoped queries for related rows.\n\n## Data model shape\n\nThis package works best when scope ownership is represented in your schema:\n\n- scope columns on scoped tables\n- scoped rules for protected tables\n- indexes for scoped access paths, e.g. `(scope_id, id)` and `(scope_id, foreign_id)`\n- globally unique IDs or constraints that reject invalid cross-scope references\n\nWrite rules explicitly for small schemas, or generate them once from schema metadata in an app-specific facade.\n\nExplicit rules:\n\n```ts\nconst rules = [\n  scopeByColumn(projects, projects.workspaceId, { insertKey: \"workspaceId\" }),\n  scopeByColumn(tasks, tasks.workspaceId, { insertKey: \"workspaceId\" }),\n  scopeByColumn(comments, comments.workspaceId, { insertKey: \"workspaceId\" }),\n];\n```\n\nGenerated rules:\n\n```ts\nconst tenantScopedRules = Object.values(schema)\n  .filter((table) =\u003e isDrizzleTable(table) \u0026\u0026 \"tenantId\" in table)\n  .map((table) =\u003e\n    scopeByColumn(table, table.tenantId, {\n      insertKey: \"tenantId\",\n      columnName: \"tenant_id\",\n    }),\n  );\n```\n\nWith 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.\n\n## Strict mode\n\nStrict 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.\n\nCallers 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.\n\n```ts\nconst workspaceDb = createScopedDb(db, {\n  scopeName: \"workspace\",\n  scopeValue: workspaceId,\n  rules: [scopeByColumn(projects, projects.workspaceId)],\n});\n\n// Throws MissingScopedWhereError\nawait workspaceDb.select().from(projects);\n\n// Throws MissingScopedPredicateError\nawait workspaceDb.select().from(projects).where(eq(projects.id, projectId));\n\n// Allowed; the wrapper still injects its own scope predicate as defense in depth.\nawait workspaceDb\n  .select()\n  .from(projects)\n  .where(and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId)));\n```\n\nThe 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.\n\nCustom `defineScopedTable` rules need `hasScopeInWhere` for strict validation. Opt out with `strict: false` if you want pure predicate injection:\n\n```ts\nconst workspaceDb = createScopedDb(db, {\n  scopeName: \"workspace\",\n  scopeValue: workspaceId,\n  strict: false,\n  rules: [scopeByColumn(projects, projects.workspaceId)],\n});\n\nawait workspaceDb.select().from(projects).where(eq(projects.id, projectId));\n// Executes with: and(eq(projects.id, projectId), eq(projects.workspaceId, workspaceId))\n```\n\n## Custom scope rules\n\nUse `defineScopedTable` for composite scopes or predicates that are not a single equality column.\n\n```ts\nimport { createScopedDb, defineScopedTable } from \"@modemdev/drizzle-scoped-db\";\nimport { and, eq } from \"drizzle-orm\";\n\nconst scopedDb = createScopedDb(db, {\n  scopeName: \"workspace-region\",\n  scopeValue: { workspaceId, regionId },\n  rules: [\n    defineScopedTable(records, {\n      where: (scope) =\u003e\n        and(eq(records.workspaceId, scope.workspaceId), eq(records.regionId, scope.regionId)),\n      validateInsert: (row, scope) =\u003e\n        row.workspaceId === scope.workspaceId \u0026\u0026 row.regionId === scope.regionId,\n    }),\n  ],\n});\n```\n\n## Escape hatches\n\n`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.\n\n### Scoped upserts\n\nPostgreSQL/SQLite conflict updates can stay on the scoped facade with any conflict target when the update payload cannot move the row across scopes:\n\n```ts\nworkspaceDb\n  .insert(records)\n  .values({ workspaceId, regionId, key, value }) // scope-validated here\n  .onConflictDoUpdate({ target: records.key, set: { value } });\n```\n\nThe 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.\n\nFor `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.\n\nWhen you need deliberate cross-scope writes, use an explicit escape hatch so you (and your agent) can see the audit boundary.\n\n### Local escape: `.$unsafeUnscoped()`\n\nUse 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:\n\n```ts\nworkspaceDb\n  .insert(records)\n  .values({ workspaceId, regionId, key, value }) // scope-validated here\n  .$unsafeUnscoped()\n  .onConflictDoUpdate({ target: records.key, set: { workspaceId: newWorkspaceId } });\n```\n\nThe 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.\n\n### Root escape: `_unsafeUnscopedDb`\n\nUse when there is no scoped chain to start from:\n\n```ts\nworkspaceDb._unsafeUnscopedDb;\n```\n\nCommon cases: migrations, admin jobs, test setup, cross-scope maintenance, raw SQL, CTEs/subqueries, `$dynamic`, or query shapes the scoped facade does not model.\n\n## Security model\n\n`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.\n\nThe 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.\n\nNot protected:\n\n- raw SQL, `_unsafeUnscopedDb`, or helpers that close over the raw DB\n- query builder methods reached after `.$unsafeUnscoped()` or through `_unsafeUnscopedDb`\n- tables or joined tables without rules\n- nested relational `with` rows; scoped wrappers reject `with` includes when relational scoped rules are configured, so use explicit scoped joins or separate scoped queries\n- invalid cross-scope rows that your database constraints allow\n- deliberate bypasses of the scoped DB capability\n\nRLS, database permissions, and other database-native controls can be layered with scoped handles when you need enforcement outside the typed application query-builder path.\n\n## Dialect support\n\nThe package uses Drizzle core `Table`, `Column`, and `SQL` types, so rules are not tied to `pg-core`.\n\nExpected support:\n\n- PostgreSQL\n- SQLite\n- MySQL\n- SingleStore\n- any Drizzle driver with the standard `select`, `insert`, `update`, `delete`, and optional `query` APIs\n\n`selectDistinctOn` is exposed only when the wrapped Drizzle instance provides it, which is primarily a PostgreSQL feature.\n\n## Wrapped APIs\n\nCurrently wrapped:\n\n- `select().from(table).where(...)`, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules\n- `selectDistinct().from(table).where(...)`, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules\n- `selectDistinctOn(...).from(table).where(...)` when supported by the driver, including `.leftJoin(...)` / `.innerJoin(...)` tables with rules\n- `insert(table).values(...)`, plus `.returning(...)`, `.$returningId()`, `.onConflictDoNothing(...)`, safe `.onConflictDoUpdate(...)` when supported, and `.$unsafeUnscoped()` for raw continuation\n- `update(table).set(...).where(...)`\n- `delete(table).where(...)`\n- `query.\u003cqueryName\u003e.findFirst(...)`\n- `query.\u003cqueryName\u003e.findMany(...)`\n- `transaction(...)`, with a scoped transaction DB passed to the callback\n\nTables without rules and unwrapped APIs pass through to the underlying Drizzle instance.\n\n## API\n\n### `createScopedDb(db, options)`\n\n```ts\ntype CreateScopedDbOptions\u003cTScope\u003e = {\n  scopeName: string;\n  scopeValue: TScope;\n  rules: ScopedTableRule\u003cTScope\u003e[];\n  strict?: boolean; // defaults to true\n  unscopedDbPropertyName?: string; // defaults to '_unsafeUnscopedDb'\n  scopeValueProperty?: string;\n  toJSON?: (scopeValue: TScope, scopeName: string) =\u003e unknown;\n  extensions?: (scopeValue: TScope, scopeName: string) =\u003e Record\u003cstring, unknown\u003e;\n  errors?: ScopedDbErrors\u003cTScope\u003e;\n};\n```\n\n### `scopeByColumn(table, column, options)`\n\n```ts\ntype ScopeByColumnOptions\u003cTScope\u003e = {\n  queryName?: string;\n  tableName?: string;\n  insertKey?: string;\n  updateKey?: string; // defaults to insertKey\n  columnName?: string;\n  equals?: (rowValue: unknown, scopeValue: TScope) =\u003e boolean;\n};\n```\n\n### `defineScopedTable(table, rule)`\n\n```ts\ntype ScopedTableRule\u003c\n  TScope,\n  TInsert = Record\u003cstring, unknown\u003e,\n  TUpdate = Record\u003cstring, unknown\u003e,\n\u003e = {\n  table: Table;\n  queryName?: string;\n  tableName?: string;\n  where: (scopeValue: TScope) =\u003e SQL | undefined;\n  validateInsert?: (row: TInsert, scopeValue: TScope) =\u003e boolean;\n  validateUpdate?: (payload: TUpdate, scopeValue: TScope) =\u003e boolean;\n  // Legacy detector retained for compatibility; scoped onConflictDoUpdate no longer consults it.\n  hasScopeInConflictTarget?: (target: unknown) =\u003e boolean;\n  // Required when createScopedDb({ strict: true }) is enabled.\n  hasScopeInWhere?: (condition: SQL | undefined) =\u003e boolean;\n};\n```\n\n### `assertDrizzleCompatibility(condition, expectedColumnName, expectedTable?)`\n\nOptional startup assertion for projects that rely on `strict` mode or `containsColumnFilter`.\n\n```ts\nimport { assertDrizzleCompatibility } from \"@modemdev/drizzle-scoped-db\";\nimport { eq } from \"drizzle-orm\";\n\n// Name-only check (backward compatible)\nassertDrizzleCompatibility(eq(projects.workspaceId, \"compat-check\"), \"workspace_id\");\n\n// Table-aware check (recommended when using scopeByColumn's default detector)\nassertDrizzleCompatibility(eq(projects.workspaceId, \"compat-check\"), \"workspace_id\", projects);\n```\n\nIf 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.\n\n## Errors\n\n- `MissingScopedWhereError`\n- `MissingScopedPredicateError`\n- `InvalidScopedInsertError`\n- `InvalidScopedUpdateError`\n- `InvalidScopedConflictTargetError`\n\nYou can replace these with custom error factories in `createScopedDb({ errors })`.\n\n## Development\n\n```bash\npnpm test\npnpm coverage\n```\n\nRelease prep also records a committed performance and heap-growth snapshot:\n\n```bash\npnpm bench:release\npnpm bench:release:compare\n```\n\nThe package has 100% statement, branch, function, and line coverage.\n\n## Sponsor\n\nSponsored by [Modem](https://modem.dev?utm_source=github\u0026utm_medium=oss\u0026utm_campaign=oss_drizzle_scoped_db\u0026utm_content=readme_footer).\n\n\u003ca href=\"https://modem.dev?utm_source=github\u0026utm_medium=oss\u0026utm_campaign=oss_drizzle_scoped_db\u0026utm_content=readme_footer\"\u003e\n  \u003cpicture\u003e\n    \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"https://modem.dev/images/logo/svg/modem-combined-white.svg\"\u003e\n    \u003csource media=\"(prefers-color-scheme: light)\" srcset=\"https://modem.dev/images/logo/svg/modem-combined-black.svg\"\u003e\n    \u003cimg src=\"https://modem.dev/images/logo/svg/modem-combined-black.svg\" alt=\"Modem\" width=\"220\"\u003e\n  \u003c/picture\u003e\n\u003c/a\u003e\n\n## License\n\nMIT.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmodem-dev%2Fdrizzle-scoped-db","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmodem-dev%2Fdrizzle-scoped-db","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmodem-dev%2Fdrizzle-scoped-db/lists"}