Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -325,6 +363,7 @@ type CreateScopedDbOptions<TScope> = {
scopeValue: TScope;
rules: ScopedTableRule<TScope>[];
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;
Expand All @@ -339,6 +378,7 @@ type CreateScopedDbOptions<TScope> = {
type ScopeByColumnOptions<TScope> = {
queryName?: string;
tableName?: string;
relations?: Record<string, ScopedTableRule<TScope> | string>;
insertKey?: string;
columnName?: string;
equals?: (rowValue: unknown, scopeValue: TScope) => boolean;
Expand All @@ -356,6 +396,7 @@ type ScopedTableRule<TScope, TInsert = Record<string, unknown>> = {
validateInsert?: (row: TInsert, scopeValue: TScope) => boolean;
// Required when createScopedDb({ strict: true }) is enabled.
hasScopeInWhere?: (condition: SQL | undefined) => boolean;
relations?: Record<string, ScopedTableRule<TScope> | string>;
};
```

Expand All @@ -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 })`.

Expand Down
129 changes: 109 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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, ScopedTableRule<TScope> | string>;
};

/** Error customization hooks for scoped wrappers. */
Expand Down Expand Up @@ -105,6 +121,8 @@ export type CreateScopedDbOptions<TScope> = {
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. */
Expand Down Expand Up @@ -140,6 +158,8 @@ type DrizzleLikeDb = {
export type ScopeByColumnOptions<TScope> = {
/** 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, ScopedTableRule<TScope> | string>;
/** Human-readable table name used in errors. */
tableName?: string;
/** Insert row property that should equal the current scope value. */
Expand All @@ -162,6 +182,7 @@ export function scopeByColumn<TScope, TTable extends ScopedTable>(
return {
table,
queryName: options.queryName,
relations: options.relations,
tableName: options.tableName,
where: (scopeValue) => eq(column as Parameters<typeof eq>[0], scopeValue),
validateInsert: options.insertKey
Expand Down Expand Up @@ -227,9 +248,9 @@ type RuleIndexes<TScope> = {
};

type NormalizedCreateScopedDbOptions<TScope> = Required<
Pick<CreateScopedDbOptions<TScope>, "unscopedDbPropertyName">
Pick<CreateScopedDbOptions<TScope>, "unscopedDbPropertyName" | "relationalWithMode">
> &
Omit<CreateScopedDbOptions<TScope>, "unscopedDbPropertyName"> &
Omit<CreateScopedDbOptions<TScope>, "unscopedDbPropertyName" | "relationalWithMode"> &
RuleIndexes<TScope>;

const ruleIndexCache = new WeakMap<ScopedTableRule<unknown>[], RuleIndexes<unknown>>();
Expand All @@ -243,6 +264,7 @@ function normalizeOptions<TScope>(
return {
...options,
unscopedDbPropertyName: options.unscopedDbPropertyName ?? "_unsafeUnscopedDb",
relationalWithMode: options.relationalWithMode ?? "root-only",
...ruleIndexes,
};
}
Expand Down Expand Up @@ -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<unknown>;
with?: Record<string, true | RelationalQueryConfig>;
[key: string]: unknown;
};

/** Wrap a relational query method to validate and inject scoped predicates. */
function wrapRelationalMethod<TScope, TResult>(
originalMethod: (config?: {
where?: RelationalWhere<unknown>;
[key: string]: unknown;
}) => Promise<TResult>,
originalMethod: (config?: RelationalQueryConfig) => Promise<TResult>,
rule: ScopedTableRule<TScope>,
options: NormalizedCreateScopedDbOptions<TScope>,
): (config?: { where?: RelationalWhere<unknown>; [key: string]: unknown }) => Promise<TResult> {
): (config?: RelationalQueryConfig) => Promise<TResult> {
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<TScope>(
config: RelationalQueryConfig | undefined,
rule: ScopedTableRule<TScope>,
options: NormalizedCreateScopedDbOptions<TScope>,
validation: { strict: boolean },
): RelationalQueryConfig {
const originalWhere = config?.where;

if (validation.strict && originalWhere === undefined && isStrictMode(options)) {
throw createMissingWhereError(getRuleTableName(rule), options);
}

const wrappedWhere: RelationalWhereCallback<unknown> = (table, operators) => {
const userCondition =
typeof originalWhere === "function" ? originalWhere(table, operators) : originalWhere;
const wrappedWhere: RelationalWhereCallback<unknown> = (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<TScope>(
withConfig: Record<string, true | RelationalQueryConfig>,
parentRule: ScopedTableRule<TScope>,
options: NormalizedCreateScopedDbOptions<TScope>,
): Record<string, true | RelationalQueryConfig> {
if (options.relationalWithMode === "root-only") {
return withConfig;
}

const parentTableName = getRuleTableName(parentRule);
if (options.relationalWithMode === "forbid") {
throw new UnsupportedRelationalWithError(options.scopeName, parentTableName);
}

const scopedWith: Record<string, true | RelationalQueryConfig> = {};
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<TScope>(
relationRule: ScopedTableRule<TScope> | string | undefined,
options: NormalizedCreateScopedDbOptions<TScope>,
): ScopedTableRule<TScope> | undefined {
if (typeof relationRule === "string") {
return options.rulesByQueryName.get(relationRule);
}

return relationRule;
}

/** Create an insert builder that validates scoped values. */
Expand Down
Loading
Loading