Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/docs/api/appkit/Function.database.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 12 additions & 3 deletions docs/docs/api/appkit/Function.defineSchema.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions docs/docs/api/appkit/Interface.Schema.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/docs/api/appkit/index.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/appkit/src/database/contract/tests/wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type FilterOperator,
IN_CAP,
isFilterOperator,
MAX_INCLUDE_DEPTH,
MAX_INCLUDE_NODES,
MAX_INCLUDES,
MAX_LIMIT,
} from "../index";
Expand All @@ -15,6 +17,8 @@ describe("wire caps", () => {
expect(MAX_LIMIT).toBe(500);
expect(DEFAULT_LIMIT).toBe(50);
expect(MAX_INCLUDES).toBe(10);
expect(MAX_INCLUDE_DEPTH).toBe(2);
expect(MAX_INCLUDE_NODES).toBe(25);
});

it("keeps DEFAULT_LIMIT within MAX_LIMIT", () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/appkit/src/database/contract/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export const MAX_LIMIT = 500;
export const DEFAULT_LIMIT = 50;
/** Max number of relations resolvable in a single `.include()`. */
export const MAX_INCLUDES = 10;
/** Max number of relation edges one include path may traverse. */
export const MAX_INCLUDE_DEPTH = 2;
/** Max number of relation nodes across a complete include tree. */
export const MAX_INCLUDE_NODES = 25;

/** Scalar values accepted by primary-key operations. */
export type IdValue = string | number | bigint;
Expand Down
29 changes: 29 additions & 0 deletions packages/appkit/src/database/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ const logger = createLogger("database");

export type DatabaseErrorCategory =
| "INVALID_REQUEST"
| "NOT_FOUND"
| "CONFLICT"
| "FORBIDDEN"
| "PAYLOAD_TOO_LARGE"
| "INTERNAL"
| "SETUP_FAILED";

/** Which request field a rejection concerns; it never carries caller values. */
export interface DatabaseErrorDetail {
readonly path: readonly string[];
readonly message: string;
}

type DatabaseErrorPhase =
| "setup"
| "shutdown"
Expand All @@ -23,8 +31,13 @@ const definitions: Record<
{ readonly message: string; readonly statusCode: number }
> = {
INVALID_REQUEST: { message: "Invalid database request", statusCode: 400 },
NOT_FOUND: { message: "Database record not found", statusCode: 404 },
CONFLICT: { message: "Database conflict", statusCode: 409 },
FORBIDDEN: { message: "Database operation forbidden", statusCode: 403 },
PAYLOAD_TOO_LARGE: {
message: "Database response is too large",
statusCode: 413,
},
INTERNAL: { message: "Database operation failed", statusCode: 500 },
SETUP_FAILED: { message: "Database setup failed", statusCode: 500 },
};
Expand All @@ -45,6 +58,7 @@ export class DatabasePluginError extends AppKitError {
readonly category: DatabaseErrorCategory,
readonly phase: DatabaseErrorPhase,
runtimeMessage?: string,
readonly details?: readonly DatabaseErrorDetail[],
) {
const definition = definitions[category];
// Plugin boundaries replace runtime diagnostics with the stable message.
Expand All @@ -68,6 +82,21 @@ export function invalidDatabaseRequest(
return new DatabasePluginError("INVALID_REQUEST", "runtime", runtimeMessage);
}

/** Refuse to publish a plugin whose configuration cannot be honored. */
export function databaseSetupFailed(): DatabasePluginError {
return new DatabasePluginError("SETUP_FAILED", "setup");
}

/** Reject untrusted request input, naming the field but never its value. */
export function invalidDatabaseInput(
path: readonly string[],
message: string,
): DatabasePluginError {
return new DatabasePluginError("INVALID_REQUEST", "read", undefined, [
{ path, message },
]);
}

/** Add operation context without retaining an unknown error's details. */
export function classifyDatabaseError(
error: unknown,
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/src/database/runtime/data-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface IncludeOptions {
readonly where?: WhereClause;
readonly order?: OrderSpec;
readonly limit?: number;
readonly include?: IncludeSpec;
}

/** Selection and bounds for one declared relation edge. */
Expand Down
35 changes: 34 additions & 1 deletion packages/appkit/src/database/runtime/engine/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
type FilterOperator,
IN_CAP,
isFilterOperator,
MAX_INCLUDE_DEPTH,
MAX_INCLUDE_NODES,
MAX_INCLUDES,
} from "../../contract";
import { invalidDatabaseRequest } from "../../errors";
Expand Down Expand Up @@ -231,12 +233,27 @@ function tableByName(schema: Schema, name: string): AppKitTable {
return table;
}

/** Translate one relation edge into Drizzle's relational `with` config. */
/** Translate relation edges into Drizzle's relational `with` config. */
export function translateInclude(
table: AppKitTable,
schema: Schema,
include: IncludeSpec,
): Record<string, unknown> {
return translateIncludeTree(table, schema, include, 1, { nodes: 0 });
}

function translateIncludeTree(
table: AppKitTable,
schema: Schema,
include: IncludeSpec,
depth: number,
budget: { nodes: number },
): Record<string, unknown> {
if (depth > MAX_INCLUDE_DEPTH) {
throw invalidDatabaseRequest(
`include exceeds the ${MAX_INCLUDE_DEPTH}-edge depth limit`,
);
}
const entries = Object.entries(include);
if (entries.length > MAX_INCLUDES) {
throw invalidDatabaseRequest(
Expand All @@ -256,6 +273,13 @@ export function translateInclude(
}
if (rawOptions === false) continue;

budget.nodes += 1;
if (budget.nodes > MAX_INCLUDE_NODES) {
throw invalidDatabaseRequest(
`include exceeds the ${MAX_INCLUDE_NODES}-node limit`,
);
}

const target = tableByName(schema, relation.targetTable);
if (rawOptions === true) {
config[relationName] = {
Expand Down Expand Up @@ -286,6 +310,15 @@ export function translateInclude(
} else if (relation.cardinality === "toMany") {
relationConfig.limit = DEFAULT_LIMIT;
}
if (options.include !== undefined) {
relationConfig.with = translateIncludeTree(
target,
schema,
options.include,
depth + 1,
budget,
);
}
config[relationName] = relationConfig;
}
return config;
Expand Down
17 changes: 17 additions & 0 deletions packages/appkit/src/database/runtime/tests/translate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,23 @@ describe("translateInclude", () => {
expect(render(config.posts.where as SQL).params).toEqual(["a%"]);
});

it("resolves a second relation edge with the target's own defaults", () => {
const config = translateInclude(users, schema, {
posts: { include: { users: true } },
}) as { posts: { with: Record<string, unknown> } };
expect(config.posts.with).toEqual({
users: { columns: defaultColumns(users) },
});
});

it("stops after the second relation edge", () => {
expect(() =>
translateInclude(users, schema, {
posts: { include: { users: { include: { posts: true } } } },
}),
).toThrow(DatabasePluginError);
});

it("rejects unknown relations and invalid relation limits", () => {
expect(() => translateInclude(users, schema, { missing: true })).toThrow(
DatabasePluginError,
Expand Down
10 changes: 7 additions & 3 deletions packages/appkit/src/database/schema-builder/define-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,10 +339,14 @@ export function assertFinalizedSchema(value: unknown): asserts value is Schema {
}
}

export function defineSchema(
builder: (context: SchemaBuilderContext) => Record<string, AppKitTable>,
/**
* Compile one declared schema. The returned type keeps the table names the
* builder returned, so `crudRoutes` and `hooks` can name only real tables.
*/
export function defineSchema<TTables extends Record<string, AppKitTable>>(
builder: (context: SchemaBuilderContext) => TTables,
options?: DefineSchemaOptions,
): Schema {
): Schema<Extract<keyof TTables, string>> {
const schemaName = options?.schemaName ?? "public";
if (!schemaName) throw new SchemaBuildError("Schema name cannot be empty");

Expand Down
9 changes: 7 additions & 2 deletions packages/appkit/src/database/schema-builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,14 @@ export interface DefineSchemaOptions {
readonly schemaName?: string;
}

export interface Schema {
/**
* One finalized schema. `TTableName` keeps the declared names in the type, so
* configuration that addresses a table by name is checked against the schema
* it was written for. Code that accepts any schema uses the default.
*/
export interface Schema<TTableName extends string = string> {
readonly $schemaName: string;
readonly $tables: Readonly<Record<string, AppKitTable>>;
readonly $tables: Readonly<Record<TTableName, AppKitTable>>;
readonly $engine: Readonly<Record<string, EngineTable>>;
}

Expand Down
Loading