From 874d3cf67f506777f9cc4e70f18cc3d5f4a289db Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 23 Jun 2026 16:07:05 -0400 Subject: [PATCH] poc: scope relational with configs --- CHANGELOG.md | 1 + README.md | 46 +++++- src/index.ts | 129 ++++++++++++++--- src/scoped-db.test.ts | 325 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 460 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fd155..f7fa473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable user-visible changes to this project are documented in this file. ### Added - Inject scope predicates for joined tables with declared rules in select query builders. +- Add an experimental POC for recursively scoping mapped relational `with` entries. ### Changed diff --git a/README.md b/README.md index 553b4f6..c3f1765 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,43 @@ const project = await workspaceDb.query.projects.findFirst({ 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 tenant-safe relationships, explicit relation filters, or database constraints. +By default, relational `with` entries are root-only: `findFirst` / `findMany` are scoped, but nested relation rows rely on tenant-safe relationships, explicit relation filters, or database constraints. + +Experimental POC: pass `relationalWithMode: "scope"` and add a relation map to scope nested `with` entries recursively. + +```ts +const taskRule = scopeByColumn(tasks, tasks.workspaceId, { + queryName: "tasks", + insertKey: "workspaceId", +}); + +const projectRule = scopeByColumn(projects, projects.workspaceId, { + queryName: "projects", + insertKey: "workspaceId", + relations: { + tasks: taskRule, + }, +}); + +const workspaceDb = createScopedDb(db, { + scopeName: "workspace", + scopeValue: workspaceId, + relationalWithMode: "scope", + rules: [projectRule, taskRule], +}); + +await workspaceDb.query.projects.findFirst({ + where: (project, { and, eq }) => + and(eq(project.id, projectId), eq(project.workspaceId, workspaceId)), + with: { + tasks: true, + }, +}); + +// Also injected into the nested relation: eq(tasks.workspaceId, workspaceId) +``` + +In `"scope"` mode, every requested relation must be present in the parent rule's `relations` map. Use `relationalWithMode: "forbid"` to reject relational `with` entirely. ## Data model shape @@ -274,12 +310,14 @@ Use it for migrations, admin jobs, test setup, cross-tenant maintenance, or unsu 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. +Relational `with` is root-only by default. The experimental `relationalWithMode: "scope"` POC can recursively scope mapped relations; `relationalWithMode: "forbid"` rejects `with` configs when you prefer fail-closed behavior. + Not protected: - raw SQL, `_unsafeUnscopedDb`, or helpers that close over the raw DB - query builder methods not wrapped by this package - tables or joined tables without rules -- nested relational `with` rows unless your relationships, filters, or constraints enforce tenant safety +- nested relational `with` rows when using default `relationalWithMode: "root-only"`, unless relationships, filters, or constraints enforce tenant safety - invalid cross-tenant rows that your database constraints allow - deliberate bypasses of the scoped DB capability @@ -325,6 +363,7 @@ type CreateScopedDbOptions = { scopeValue: TScope; rules: ScopedTableRule[]; strict?: boolean; // defaults to true + relationalWithMode?: "root-only" | "scope" | "forbid"; // defaults to 'root-only' unscopedDbPropertyName?: string; // defaults to '_unsafeUnscopedDb' scopeValueProperty?: string; toJSON?: (scopeValue: TScope, scopeName: string) => unknown; @@ -339,6 +378,7 @@ type CreateScopedDbOptions = { type ScopeByColumnOptions = { queryName?: string; tableName?: string; + relations?: Record | string>; insertKey?: string; columnName?: string; equals?: (rowValue: unknown, scopeValue: TScope) => boolean; @@ -356,6 +396,7 @@ type ScopedTableRule> = { validateInsert?: (row: TInsert, scopeValue: TScope) => boolean; // Required when createScopedDb({ strict: true }) is enabled. hasScopeInWhere?: (condition: SQL | undefined) => boolean; + relations?: Record | string>; }; ``` @@ -377,6 +418,7 @@ If a Drizzle upgrade changes the internal SQL chunk shape, this fails fast inste - `MissingScopedWhereError` - `MissingScopedPredicateError` - `InvalidScopedInsertError` +- `UnsupportedRelationalWithError` You can replace these with custom error factories in `createScopedDb({ errors })`. diff --git a/src/index.ts b/src/index.ts index 1fe137e..dfa3ec4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,20 @@ export class InvalidScopedInsertError extends Error { } } +/** Error thrown when relational `with` cannot be safely handled under the configured mode. */ +export class UnsupportedRelationalWithError extends Error { + constructor(scopeName: string, tableName: string, relationName?: string) { + const relationMessage = relationName ? ` relation "${relationName}"` : ""; + super( + `Relational with${relationMessage} on table "${tableName}" is not configured for ${scopeName} scoping.`, + ); + this.name = "UnsupportedRelationalWithError"; + } +} + +/** Relational `with` handling strategy. */ +export type RelationalWithMode = "root-only" | "scope" | "forbid"; + /** A table-specific scoping rule. */ export type ScopedTableRule< TScope, @@ -76,6 +90,8 @@ export type ScopedTableRule< * Required when `strict` mode is enabled; rules without a detector fail strict validation. */ hasScopeInWhere?: (condition: SQL | undefined) => boolean; + /** Experimental relation-name to scoped-rule map for recursively scoping Drizzle relational `with`. */ + relations?: Record | string>; }; /** Error customization hooks for scoped wrappers. */ @@ -105,6 +121,8 @@ export type CreateScopedDbOptions = { strict?: boolean; /** Property name for the intentionally unsafe unscoped DB escape hatch. Defaults to `_unsafeUnscopedDb`. */ unscopedDbPropertyName?: string; + /** Experimental relational `with` handling. Defaults to `root-only` for backward compatibility. */ + relationalWithMode?: RelationalWithMode; /** Optional property name that exposes the current scope value. */ scopeValueProperty?: string; /** Optional custom JSON serialization hook. */ @@ -140,6 +158,8 @@ type DrizzleLikeDb = { export type ScopeByColumnOptions = { /** Optional db.query property name for relational query API support. */ queryName?: string; + /** Experimental relation-name to scoped-rule map for recursively scoping Drizzle relational `with`. */ + relations?: Record | string>; /** Human-readable table name used in errors. */ tableName?: string; /** Insert row property that should equal the current scope value. */ @@ -162,6 +182,7 @@ export function scopeByColumn( return { table, queryName: options.queryName, + relations: options.relations, tableName: options.tableName, where: (scopeValue) => eq(column as Parameters[0], scopeValue), validateInsert: options.insertKey @@ -227,9 +248,9 @@ type RuleIndexes = { }; type NormalizedCreateScopedDbOptions = Required< - Pick, "unscopedDbPropertyName"> + Pick, "unscopedDbPropertyName" | "relationalWithMode"> > & - Omit, "unscopedDbPropertyName"> & + Omit, "unscopedDbPropertyName" | "relationalWithMode"> & RuleIndexes; const ruleIndexCache = new WeakMap[], RuleIndexes>(); @@ -243,6 +264,7 @@ function normalizeOptions( return { ...options, unscopedDbPropertyName: options.unscopedDbPropertyName ?? "_unsafeUnscopedDb", + relationalWithMode: options.relationalWithMode ?? "root-only", ...ruleIndexes, }; } @@ -414,34 +436,101 @@ function createScopedTableQuery< } as TTableQuery; } +/** Minimal relational query config shape shared by root and nested Drizzle relation queries. */ +type RelationalQueryConfig = { + where?: RelationalWhere; + with?: Record; + [key: string]: unknown; +}; + /** Wrap a relational query method to validate and inject scoped predicates. */ function wrapRelationalMethod( - originalMethod: (config?: { - where?: RelationalWhere; - [key: string]: unknown; - }) => Promise, + originalMethod: (config?: RelationalQueryConfig) => Promise, rule: ScopedTableRule, options: NormalizedCreateScopedDbOptions, -): (config?: { where?: RelationalWhere; [key: string]: unknown }) => Promise { +): (config?: RelationalQueryConfig) => Promise { return (config) => { - const originalWhere = config?.where; + return originalMethod(scopeRelationalConfig(config, rule, options, { strict: true })); + }; +} - if (originalWhere === undefined && isStrictMode(options)) { - throw createMissingWhereError(getRuleTableName(rule), options); - } +/** Scope a Drizzle relational query config and optionally recurse through configured `with` relations. */ +function scopeRelationalConfig( + config: RelationalQueryConfig | undefined, + rule: ScopedTableRule, + options: NormalizedCreateScopedDbOptions, + validation: { strict: boolean }, +): RelationalQueryConfig { + const originalWhere = config?.where; + + if (validation.strict && originalWhere === undefined && isStrictMode(options)) { + throw createMissingWhereError(getRuleTableName(rule), options); + } - const wrappedWhere: RelationalWhereCallback = (table, operators) => { - const userCondition = - typeof originalWhere === "function" ? originalWhere(table, operators) : originalWhere; + const wrappedWhere: RelationalWhereCallback = (table, operators) => { + const userCondition = + typeof originalWhere === "function" ? originalWhere(table, operators) : originalWhere; + if (validation.strict) { assertWhereAllowed(userCondition, rule, options); - return scopeCondition(userCondition, rule, options); - }; + } + return scopeCondition(userCondition, rule, options); + }; - return originalMethod({ - ...config, - where: wrappedWhere, - }); + const scopedConfig: RelationalQueryConfig = { + ...config, + where: wrappedWhere, }; + + if (config?.with !== undefined) { + scopedConfig.with = scopeRelationalWith(config.with, rule, options); + } + + return scopedConfig; +} + +/** Scope or reject relational `with` entries according to the configured mode. */ +function scopeRelationalWith( + withConfig: Record, + parentRule: ScopedTableRule, + options: NormalizedCreateScopedDbOptions, +): Record { + if (options.relationalWithMode === "root-only") { + return withConfig; + } + + const parentTableName = getRuleTableName(parentRule); + if (options.relationalWithMode === "forbid") { + throw new UnsupportedRelationalWithError(options.scopeName, parentTableName); + } + + const scopedWith: Record = {}; + for (const [relationName, relationConfig] of Object.entries(withConfig)) { + const relationRule = resolveRelationRule(parentRule.relations?.[relationName], options); + if (!relationRule) { + throw new UnsupportedRelationalWithError(options.scopeName, parentTableName, relationName); + } + + scopedWith[relationName] = scopeRelationalConfig( + relationConfig === true ? undefined : relationConfig, + relationRule, + options, + { strict: false }, + ); + } + + return scopedWith; +} + +/** Resolve a relation rule by inline rule or relational query name. */ +function resolveRelationRule( + relationRule: ScopedTableRule | string | undefined, + options: NormalizedCreateScopedDbOptions, +): ScopedTableRule | undefined { + if (typeof relationRule === "string") { + return options.rulesByQueryName.get(relationRule); + } + + return relationRule; } /** Create an insert builder that validates scoped values. */ diff --git a/src/scoped-db.test.ts b/src/scoped-db.test.ts index 65c8b98..63347cb 100644 --- a/src/scoped-db.test.ts +++ b/src/scoped-db.test.ts @@ -1,5 +1,7 @@ -import { and, type Column, eq, or, type SQL } from "drizzle-orm"; +import { and, type Column, eq, or, relations, type SQL } from "drizzle-orm"; import { pgTable, text } from "drizzle-orm/pg-core"; +import { drizzle as drizzleSqliteProxy } from "drizzle-orm/sqlite-proxy"; +import { sqliteTable, text as sqliteText } from "drizzle-orm/sqlite-core"; import { assertDrizzleCompatibility, containsColumnFilter, @@ -9,6 +11,7 @@ import { MissingScopedPredicateError, MissingScopedWhereError, scopeByColumn, + UnsupportedRelationalWithError, } from "./index"; const projectsTbl = pgTable("projects", { @@ -30,6 +33,48 @@ const usersTbl = pgTable("users", { email: text("email").notNull(), }); +const sqliteWorkspacesTbl = sqliteTable("workspaces", { + id: sqliteText("id").primaryKey(), + tenantId: sqliteText("tenant_id").notNull(), +}); + +const sqliteProjectsTbl = sqliteTable("projects", { + id: sqliteText("id").primaryKey(), + tenantId: sqliteText("tenant_id").notNull(), + workspaceId: sqliteText("workspace_id").notNull(), +}); + +const sqliteTasksTbl = sqliteTable("tasks", { + id: sqliteText("id").primaryKey(), + tenantId: sqliteText("tenant_id").notNull(), + projectId: sqliteText("project_id").notNull(), +}); + +const sqliteProjectRelations = relations(sqliteProjectsTbl, ({ many, one }) => ({ + tasks: many(sqliteTasksTbl), + workspace: one(sqliteWorkspacesTbl, { + fields: [sqliteProjectsTbl.workspaceId], + references: [sqliteWorkspacesTbl.id], + }), +})); + +const sqliteTaskRelations = relations(sqliteTasksTbl, ({ one }) => ({ + project: one(sqliteProjectsTbl, { + fields: [sqliteTasksTbl.projectId], + references: [sqliteProjectsTbl.id], + }), +})); + +const sqliteWorkspaceRelations = relations(sqliteWorkspacesTbl, ({ many }) => ({ + projects: many(sqliteProjectsTbl), +})); + +type SqliteProxyCall = { + sql: string; + params: unknown[]; + method: "run" | "all" | "values" | "get"; +}; + type FakeDbState = { selectCondition?: SQL; joinConditions?: SQL[]; @@ -37,12 +82,27 @@ type FakeDbState = { updateCondition?: SQL; deleteCondition?: SQL; relationalCondition?: SQL; + relationalWithConfig?: unknown; + nestedRelationalCondition?: SQL; transactionRawDb?: FakeDb; }; -type RelationalProjectWhere = +type RelationalWhereFor = | SQL - | ((table: typeof projectsTbl, operators: typeof relationalOperators) => SQL | undefined); + | ((table: TTable, operators: typeof relationalOperators) => SQL | undefined); + +type RelationalTaskConfig = { + where?: RelationalWhereFor; + with?: Record; +}; + +type RelationalProjectConfig = { + where?: RelationalWhereFor; + with?: { + tasks?: true | RelationalTaskConfig; + [key: string]: true | RelationalTaskConfig | undefined; + }; +}; type FakeWhereResult = { condition: SQL | undefined; @@ -64,12 +124,8 @@ type FakeSelectBuilder = { type FakeDb = { query: { projects: { - findFirst(config?: { - where?: RelationalProjectWhere; - }): Promise<{ condition: SQL | undefined }>; - findMany(config?: { - where?: RelationalProjectWhere; - }): Promise<{ condition: SQL | undefined }[]>; + findFirst(config?: RelationalProjectConfig): Promise<{ condition: SQL | undefined }>; + findMany(config?: RelationalProjectConfig): Promise<{ condition: SQL | undefined }[]>; }; users: { findMany(config?: { limit?: number }): Promise<{ config: { limit?: number } | undefined }[]>; @@ -97,17 +153,70 @@ type FakeDb = { _state: FakeDbState; }; +/** Creates a real Drizzle SQLite proxy DB to smoke-test relational query SQL generation. */ +function createSqliteRelationalSmokeDb(calls: SqliteProxyCall[] = []) { + const db = drizzleSqliteProxy( + async (sql, params, method) => { + calls.push({ sql, params, method }); + return { rows: [] }; + }, + { + schema: { + projects: sqliteProjectsTbl, + tasks: sqliteTasksTbl, + workspaces: sqliteWorkspacesTbl, + projectRelations: sqliteProjectRelations, + taskRelations: sqliteTaskRelations, + workspaceRelations: sqliteWorkspaceRelations, + }, + }, + ); + + const taskRule = scopeByColumn(sqliteTasksTbl, sqliteTasksTbl.tenantId, { + queryName: "tasks", + insertKey: "tenantId", + columnName: "tenant_id", + }); + const workspaceRule = scopeByColumn(sqliteWorkspacesTbl, sqliteWorkspacesTbl.tenantId, { + queryName: "workspaces", + insertKey: "tenantId", + columnName: "tenant_id", + }); + const projectRule = scopeByColumn(sqliteProjectsTbl, sqliteProjectsTbl.tenantId, { + queryName: "projects", + insertKey: "tenantId", + columnName: "tenant_id", + relations: { + tasks: taskRule, + workspace: workspaceRule, + }, + }); + + return { db, projectRule, taskRule, workspaceRule }; +} + +function getLastSqliteProxyCall(calls: SqliteProxyCall[]): SqliteProxyCall { + const lastCall = calls[calls.length - 1]; + if (!lastCall) { + throw new Error("Expected a sqlite proxy call to be recorded."); + } + + return lastCall; +} + /** Creates a minimal Drizzle-like DB that records the predicates passed into query builders. */ function createFakeDb(state: FakeDbState = {}): FakeDb { const db = { query: { projects: { - async findFirst(config?: { where?: RelationalProjectWhere }) { - state.relationalCondition = resolveRelationalWhere(config?.where); + async findFirst(config?: RelationalProjectConfig) { + state.relationalCondition = resolveRelationalWhere(config?.where, projectsTbl); + recordNestedRelationalConfig(config, state); return { condition: state.relationalCondition }; }, - async findMany(config?: { where?: RelationalProjectWhere }) { - state.relationalCondition = resolveRelationalWhere(config?.where); + async findMany(config?: RelationalProjectConfig) { + state.relationalCondition = resolveRelationalWhere(config?.where, projectsTbl); + recordNestedRelationalConfig(config, state); return [{ condition: state.relationalCondition }]; }, }, @@ -177,13 +286,23 @@ function createFakeDb(state: FakeDbState = {}): FakeDb { const relationalOperators = { and, eq, or }; /** Resolve the where shape that Drizzle relational queries accept. */ -function resolveRelationalWhere( - where: - | SQL - | ((table: typeof projectsTbl, operators: typeof relationalOperators) => SQL | undefined) - | undefined, +function resolveRelationalWhere( + where: RelationalWhereFor | undefined, + table: TTable, ): SQL | undefined { - return typeof where === "function" ? where(projectsTbl, relationalOperators) : where; + return typeof where === "function" ? where(table, relationalOperators) : where; +} + +/** Records the nested task relation predicate that the scoped relational wrapper sends to Drizzle. */ +function recordNestedRelationalConfig( + config: RelationalProjectConfig | undefined, + state: FakeDbState, +): void { + state.relationalWithConfig = config?.with; + const tasksConfig = config?.with?.tasks; + if (tasksConfig && tasksConfig !== true) { + state.nestedRelationalCondition = resolveRelationalWhere(tasksConfig.where, tasksTbl); + } } /** Creates a minimal select builder with join and where methods. */ @@ -362,6 +481,174 @@ describe("createScopedDb", () => { ]); }); + it("keeps relational with root-only by default", async () => { + const rawDb = createFakeDb(); + const taskRule = scopeByColumn(tasksTbl, tasksTbl.taskWorkspaceId, { queryName: "tasks" }); + const projectRule = scopeByColumn(projectsTbl, projectsTbl.workspaceId, { + queryName: "projects", + relations: { tasks: taskRule }, + }); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + strict: false, + rules: [projectRule, taskRule], + }); + + await scopedDb.query.projects.findFirst({ + where: (project, { eq }) => eq(project.id, "project-1"), + with: { tasks: true }, + }); + + expect(rawDb._state.relationalCondition).toBeDefined(); + expect(containsColumnFilter(rawDb._state.relationalCondition, "workspace_id")).toBe(true); + expect(rawDb._state.relationalWithConfig).toEqual({ tasks: true }); + expect(rawDb._state.nestedRelationalCondition).toBeUndefined(); + }); + + it("can recursively scope configured relational with entries in scope mode", async () => { + const rawDb = createFakeDb(); + const taskRule = scopeByColumn(tasksTbl, tasksTbl.taskWorkspaceId, { queryName: "tasks" }); + const projectRule = scopeByColumn(projectsTbl, projectsTbl.workspaceId, { + queryName: "projects", + relations: { tasks: taskRule }, + }); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + relationalWithMode: "scope", + strict: false, + rules: [projectRule, taskRule], + }); + + await scopedDb.query.projects.findFirst({ + where: (project, { eq }) => eq(project.id, "project-1"), + with: { tasks: true }, + }); + + expect(rawDb._state.relationalCondition).toBeDefined(); + expect(containsColumnFilter(rawDb._state.relationalCondition, "workspace_id")).toBe(true); + expect(rawDb._state.nestedRelationalCondition).toBeDefined(); + expect(containsColumnFilter(rawDb._state.nestedRelationalCondition, "task_workspace_id")).toBe( + true, + ); + }); + + it("combines existing nested relational with where clauses with nested scope predicates", async () => { + const rawDb = createFakeDb(); + const taskRule = scopeByColumn(tasksTbl, tasksTbl.taskWorkspaceId, { queryName: "tasks" }); + const projectRule = scopeByColumn(projectsTbl, projectsTbl.workspaceId, { + queryName: "projects", + relations: { tasks: "tasks" }, + }); + const scopedDb = createScopedDb(rawDb, { + scopeName: "workspace", + scopeValue: "workspace-1", + relationalWithMode: "scope", + strict: false, + rules: [projectRule, taskRule], + }); + + await scopedDb.query.projects.findMany({ + where: (project, { eq }) => eq(project.id, "project-1"), + with: { + tasks: { + where: (task, { eq }) => eq(task.title, "Launch"), + }, + }, + }); + + expect(rawDb._state.nestedRelationalCondition).toBeDefined(); + expect(containsColumnFilter(rawDb._state.nestedRelationalCondition, "title")).toBe(true); + expect(containsColumnFilter(rawDb._state.nestedRelationalCondition, "task_workspace_id")).toBe( + true, + ); + }); + + it("throws for unmapped relational with entries in scope mode", () => { + const scopedDb = createScopedDb(createFakeDb(), { + scopeName: "workspace", + scopeValue: "workspace-1", + relationalWithMode: "scope", + strict: false, + rules: [scopeByColumn(projectsTbl, projectsTbl.workspaceId, { queryName: "projects" })], + }); + + expect(() => + scopedDb.query.projects.findFirst({ + where: (project, { eq }) => eq(project.id, "project-1"), + with: { tasks: true }, + }), + ).toThrow(UnsupportedRelationalWithError); + }); + + it("throws for any relational with entries in forbid mode", () => { + const scopedDb = createScopedDb(createFakeDb(), { + scopeName: "workspace", + scopeValue: "workspace-1", + relationalWithMode: "forbid", + strict: false, + rules: [scopeByColumn(projectsTbl, projectsTbl.workspaceId, { queryName: "projects" })], + }); + + expect(() => + scopedDb.query.projects.findFirst({ + where: (project, { eq }) => eq(project.id, "project-1"), + with: { tasks: true }, + }), + ).toThrow(UnsupportedRelationalWithError); + }); + + it("scopes real Drizzle SQLite many relation with configs", async () => { + const calls: SqliteProxyCall[] = []; + const { db, projectRule, taskRule, workspaceRule } = createSqliteRelationalSmokeDb(calls); + const scopedDb = createScopedDb(db, { + scopeName: "tenant", + scopeValue: "tenant-1", + relationalWithMode: "scope", + rules: [projectRule, taskRule, workspaceRule], + }); + + await scopedDb.query.projects.findMany({ + where: (project, { and, eq }) => + and(eq(project.id, "project-1"), eq(project.tenantId, "tenant-1")), + with: { + tasks: true, + }, + }); + + const query = getLastSqliteProxyCall(calls); + expect(query.sql).toContain('from "tasks" "projects_tasks"'); + expect(query.sql).toContain('"projects_tasks"."tenant_id" = ?'); + expect(query.sql).toContain('"projects"."tenant_id" = ?'); + expect(query.params).toEqual(["tenant-1", "project-1", "tenant-1", "tenant-1"]); + }); + + it("scopes real Drizzle SQLite one relation with configs", async () => { + const calls: SqliteProxyCall[] = []; + const { db, projectRule, taskRule, workspaceRule } = createSqliteRelationalSmokeDb(calls); + const scopedDb = createScopedDb(db, { + scopeName: "tenant", + scopeValue: "tenant-1", + relationalWithMode: "scope", + rules: [projectRule, taskRule, workspaceRule], + }); + + await scopedDb.query.projects.findMany({ + where: (project, { and, eq }) => + and(eq(project.id, "project-1"), eq(project.tenantId, "tenant-1")), + with: { + workspace: true, + }, + }); + + const query = getLastSqliteProxyCall(calls); + expect(query.sql).toContain('from "workspaces" "projects_workspace"'); + expect(query.sql).toContain('"projects_workspace"."tenant_id" = ?'); + expect(query.sql).toContain('"projects"."tenant_id" = ?'); + expect(query.params).toEqual(["tenant-1", 1, "project-1", "tenant-1", "tenant-1"]); + }); + it("supports custom composite scope rules", () => { const rawDb = createFakeDb(); const scopedDb = createScopedDb(rawDb, {