diff --git a/docs/docs/api/appkit/Function.database.md b/docs/docs/api/appkit/Function.database.md index 8e0a3ace6..9aa74dcf8 100644 --- a/docs/docs/api/appkit/Function.database.md +++ b/docs/docs/api/appkit/Function.database.md @@ -14,7 +14,7 @@ Create a typed database plugin registration for a finalized schema. | Type Parameter | | ------ | -| `TSchema` *extends* [`Schema`](Interface.Schema.md) | +| `TSchema` *extends* [`Schema`](Interface.Schema.md)\<`string`\> | ## Parameters diff --git a/docs/docs/api/appkit/Function.defineSchema.md b/docs/docs/api/appkit/Function.defineSchema.md index 05b828ad4..f69cb343f 100644 --- a/docs/docs/api/appkit/Function.defineSchema.md +++ b/docs/docs/api/appkit/Function.defineSchema.md @@ -1,16 +1,25 @@ # Function: defineSchema() ```ts -function defineSchema(builder: (context: SchemaBuilderContext) => Record, options?: DefineSchemaOptions): Schema; +function defineSchema(builder: (context: SchemaBuilderContext) => TTables, options?: DefineSchemaOptions): Schema>; ``` +Compile one declared schema. The returned type keeps the table names the +builder returned, so `crudRoutes` and `hooks` can name only real tables. + +## Type Parameters + +| Type Parameter | +| ------ | +| `TTables` *extends* `Record`\<`string`, `AppKitTable`\> | + ## Parameters | Parameter | Type | | ------ | ------ | -| `builder` | (`context`: `SchemaBuilderContext`) => `Record`\<`string`, `AppKitTable`\> | +| `builder` | (`context`: `SchemaBuilderContext`) => `TTables` | | `options?` | `DefineSchemaOptions` | ## Returns -[`Schema`](Interface.Schema.md) +[`Schema`](Interface.Schema.md)\<`Extract`\\> diff --git a/docs/docs/api/appkit/Interface.Schema.md b/docs/docs/api/appkit/Interface.Schema.md index f94b39339..377ffc07f 100644 --- a/docs/docs/api/appkit/Interface.Schema.md +++ b/docs/docs/api/appkit/Interface.Schema.md @@ -1,4 +1,14 @@ -# Interface: Schema +# 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. + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TTableName` *extends* `string` | `string` | ## Properties @@ -21,5 +31,5 @@ readonly $schemaName: string; ### $tables ```ts -readonly $tables: Readonly>; +readonly $tables: Readonly>; ``` diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md index 88bc807c5..d95217a4e 100644 --- a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -2,6 +2,8 @@ ```ts type IDatabaseConfig = { + crudRoutes?: CrudRoutesConfig; + hooks?: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; schema: TSchema; }; ``` @@ -16,6 +18,22 @@ Configuration for one schema-bound DatabasePlugin instance. ## Properties +### crudRoutes? + +```ts +readonly optional crudRoutes: CrudRoutesConfig; +``` + +*** + +### hooks? + +```ts +readonly optional hooks: { readonly [TTable in SchemaTableName]?: { serialize?: ReadSerializer } }; +``` + +*** + ### schema ```ts diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 6269a2dbe..36d752e56 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -75,7 +75,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [RunAgentInput](Interface.RunAgentInput.md) | - | | [RunAgentResult](Interface.RunAgentResult.md) | - | -| [Schema](Interface.Schema.md) | - | +| [Schema](Interface.Schema.md) | 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. | | [SearchRequest](Interface.SearchRequest.md) | - | | [SearchResponse](Interface.SearchResponse.md) | - | | [SearchResult](Interface.SearchResult.md) | - | @@ -155,7 +155,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | | [createWorkspaceClient](Function.createWorkspaceClient.md) | Construct an AppKit workspace client. | | [database](Function.database.md) | Create a typed database plugin registration for a finalized schema. | -| [defineSchema](Function.defineSchema.md) | - | +| [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `crudRoutes` and `hooks` can name only real tables. | | [defineTool](Function.defineTool.md) | Defines a single tool entry for a plugin's internal registry. | | [enumColumn](Function.enumColumn.md) | - | | [executeFromRegistry](Function.executeFromRegistry.md) | Validates tool-call arguments against the entry's schema and invokes its handler. On validation failure, returns an LLM-friendly error string (matching the behavior of `tool()`) rather than throwing, so the model can self-correct on its next turn. | diff --git a/packages/appkit/src/database/contract/tests/wire.test.ts b/packages/appkit/src/database/contract/tests/wire.test.ts index 727f2cf25..8ce3d0f23 100644 --- a/packages/appkit/src/database/contract/tests/wire.test.ts +++ b/packages/appkit/src/database/contract/tests/wire.test.ts @@ -5,6 +5,8 @@ import { type FilterOperator, IN_CAP, isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, MAX_INCLUDES, MAX_LIMIT, } from "../index"; @@ -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", () => { diff --git a/packages/appkit/src/database/contract/wire.ts b/packages/appkit/src/database/contract/wire.ts index 0d8583023..d70b651d1 100644 --- a/packages/appkit/src/database/contract/wire.ts +++ b/packages/appkit/src/database/contract/wire.ts @@ -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; diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts index 10b162256..d4be7e6fa 100644 --- a/packages/appkit/src/database/errors.ts +++ b/packages/appkit/src/database/errors.ts @@ -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" @@ -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 }, }; @@ -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. @@ -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, diff --git a/packages/appkit/src/database/runtime/data-path.ts b/packages/appkit/src/database/runtime/data-path.ts index 75df4a846..54434fabe 100644 --- a/packages/appkit/src/database/runtime/data-path.ts +++ b/packages/appkit/src/database/runtime/data-path.ts @@ -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. */ diff --git a/packages/appkit/src/database/runtime/engine/translate.ts b/packages/appkit/src/database/runtime/engine/translate.ts index fe7a3bd77..cd1cb5c17 100644 --- a/packages/appkit/src/database/runtime/engine/translate.ts +++ b/packages/appkit/src/database/runtime/engine/translate.ts @@ -22,6 +22,8 @@ import { type FilterOperator, IN_CAP, isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, MAX_INCLUDES, } from "../../contract"; import { invalidDatabaseRequest } from "../../errors"; @@ -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 { + return translateIncludeTree(table, schema, include, 1, { nodes: 0 }); +} + +function translateIncludeTree( + table: AppKitTable, + schema: Schema, + include: IncludeSpec, + depth: number, + budget: { nodes: number }, +): Record { + 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( @@ -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] = { @@ -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; diff --git a/packages/appkit/src/database/runtime/tests/translate.test.ts b/packages/appkit/src/database/runtime/tests/translate.test.ts index b1b9e65b6..4345cfb14 100644 --- a/packages/appkit/src/database/runtime/tests/translate.test.ts +++ b/packages/appkit/src/database/runtime/tests/translate.test.ts @@ -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 } }; + 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, diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index be24ea6ff..bcf970baa 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -339,10 +339,14 @@ export function assertFinalizedSchema(value: unknown): asserts value is Schema { } } -export function defineSchema( - builder: (context: SchemaBuilderContext) => Record, +/** + * 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>( + builder: (context: SchemaBuilderContext) => TTables, options?: DefineSchemaOptions, -): Schema { +): Schema> { const schemaName = options?.schemaName ?? "public"; if (!schemaName) throw new SchemaBuildError("Schema name cannot be empty"); diff --git a/packages/appkit/src/database/schema-builder/types.ts b/packages/appkit/src/database/schema-builder/types.ts index 38b993623..d49a14b02 100644 --- a/packages/appkit/src/database/schema-builder/types.ts +++ b/packages/appkit/src/database/schema-builder/types.ts @@ -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 { readonly $schemaName: string; - readonly $tables: Readonly>; + readonly $tables: Readonly>; readonly $engine: Readonly>; } diff --git a/packages/appkit/src/plugins/database/crud/codecs.ts b/packages/appkit/src/plugins/database/crud/codecs.ts new file mode 100644 index 000000000..a474859fc --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/codecs.ts @@ -0,0 +1,118 @@ +import { DatabasePluginError } from "../../../database/errors"; +import type { ScalarValue } from "../../../database/runtime"; +import type { ColumnMeta } from "../../../database/schema-builder"; +import { columnValueSchema } from "../../../database/schema-builder/validators"; + +/** JSON-representable value; every generated response is built from these. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +/** Both wire directions for one column, compiled once per exposed table. */ +export interface CompiledColumn { + readonly meta: ColumnMeta; + /** Untrusted wire value to canonical runtime value; `undefined` when invalid. */ + decode(raw: unknown): ScalarValue | undefined; + /** Trusted runtime value to its deterministic JSON form. */ + encode(value: unknown): JsonValue; +} + +/** Decimal integer with no sign padding, leading zeros, or exponent. */ +const DECIMAL_INT = /^-?(0|[1-9]\d*)$/; + +/** + * Map a wire value onto the runtime representation the schema validators and + * Drizzle columns expect. Only path segments and JSON scalars reach this, so + * the accepted shapes stay exact rather than coercing whatever parses. + */ +function toRuntimeValue( + kind: ColumnMeta["kind"], + raw: unknown, +): ScalarValue | undefined { + switch (kind) { + case "number": + if (typeof raw === "number") return raw; + return typeof raw === "string" && DECIMAL_INT.test(raw) + ? Number(raw) + : undefined; + case "bigint": + if (typeof raw === "string" && DECIMAL_INT.test(raw)) return BigInt(raw); + return typeof raw === "number" && Number.isSafeInteger(raw) + ? BigInt(raw) + : undefined; + case "boolean": + return typeof raw === "boolean" ? raw : undefined; + case "string": + case "uuid": + case "enum": + case "date": + return typeof raw === "string" ? raw : undefined; + default: + return undefined; + } +} + +function encodeValue(meta: ColumnMeta, value: unknown): JsonValue { + if (value === null || value === undefined) return null; + switch (meta.kind) { + case "bigint": + // JSON cannot carry a bigint, and a large id must not lose precision. + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" && Number.isSafeInteger(value)) { + return value.toString(); + } + break; + case "number": + if (typeof value === "number" && Number.isFinite(value)) return value; + break; + case "boolean": + if (typeof value === "boolean") return value; + break; + case "string": + case "uuid": + case "enum": + case "date": + if (typeof value === "string") return value; + break; + case "json": + if (isJsonValue(value)) return value; + break; + case "unknown": + break; + } + // The driver produced a value this column contract cannot describe. + throw new DatabasePluginError("INTERNAL", "read"); +} + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return true; + } + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return true; + if (typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Compile the wire codecs for one column from its finalized metadata. */ +export function compileColumn(meta: ColumnMeta): CompiledColumn { + const schema = columnValueSchema(meta); + return { + meta, + decode: (raw) => { + const candidate = toRuntimeValue(meta.kind, raw); + if (candidate === undefined) return undefined; + return schema.safeParse(candidate).success ? candidate : undefined; + }, + encode: (value) => encodeValue(meta, value), + }; +} diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts new file mode 100644 index 000000000..e0bf5a060 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -0,0 +1,243 @@ +import { + DatabasePluginError, + invalidDatabaseInput, +} from "../../../database/errors"; +import type { IdValue, Row } from "../../../database/runtime"; +import type { AppKitTable } from "../../../database/schema-builder"; +import { filterOperatorsForKind } from "../../../database/schema-builder/types"; +import { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from "../defaults"; +import { type CompiledColumn, compileColumn, type JsonValue } from "./codecs"; + +/** One relation edge wired to the contract of its target table. */ +export interface CrudRelation { + readonly cardinality: "toOne" | "toMany"; + readonly target: CrudTable; +} + +/** Private HTTP contract compiled once for one explicitly exposed table. */ +export interface CrudTable { + readonly name: string; + readonly primaryKey?: CompiledColumn; + readonly columns: ReadonlyMap; + /** Public columns a request may project. */ + readonly selectable: ReadonlySet; + /** Public columns a request may filter or order by. */ + readonly queryable: ReadonlySet; + readonly relations: ReadonlyMap; + decodeId(raw: string): IdValue; + projectPublicRow(row: Row): JsonValue; + sanitizeSerializedRow(row: unknown): JsonValue; +} + +type MutableCrudTable = Omit & { + readonly relations: Map; +}; + +interface SanitizeState { + nodes: number; + readonly ancestors: Set; +} + +/** A bare object literal; a `Date`, class instance, or `Map` is not JSON. */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** A serializer that breaks its contract is trusted code failing, not input. */ +function serializerFault(): never { + throw new DatabasePluginError("INTERNAL", "read"); +} + +/** Charge one value against the output budget before descending into it. */ +function countNode(depth: number, state: SanitizeState): void { + state.nodes += 1; + if (state.nodes > MAX_SERIALIZED_NODES || depth > MAX_SERIALIZED_DEPTH) { + serializerFault(); + } +} + +/** Walk a container while its ancestors are tracked, so a cycle cannot pass. */ +function enterObject( + value: object, + state: SanitizeState, + visit: () => T, +): T { + if (state.ancestors.has(value)) serializerFault(); + state.ancestors.add(value); + try { + return visit(); + } finally { + state.ancestors.delete(value); + } +} + +/** Accept a serializer's own added value only where it is already JSON. */ +function sanitizeJson( + value: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + countNode(depth, state); + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) serializerFault(); + return value; + } + if (Array.isArray(value)) { + return enterObject(value, state, () => + value.map((item) => sanitizeJson(item, depth + 1, state)), + ); + } + if (!isPlainObject(value)) serializerFault(); + return enterObject(value, state, () => { + // A null prototype keeps `__proto__` an ordinary key instead of a setter. + const out: Record = Object.create(null); + for (const [key, child] of Object.entries(value)) { + if (child === undefined) continue; + out[key] = sanitizeJson(child, depth + 1, state); + } + return out; + }); +} + +/** Keep an included row under its own table's policy, one row or many. */ +function sanitizeRelation( + target: CrudTable, + value: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + if (value === null) return null; + if (!Array.isArray(value)) return sanitizeRow(target, value, depth, state); + countNode(depth, state); + return enterObject(value, state, () => + value.map((row) => sanitizeRow(target, row, depth + 1, state)), + ); +} + +/** Re-apply the private-column policy wherever the output stays contracted. */ +function sanitizeRow( + table: CrudTable, + row: unknown, + depth: number, + state: SanitizeState, +): JsonValue { + countNode(depth, state); + if (!isPlainObject(row)) serializerFault(); + return enterObject(row, state, () => { + const out: Record = {}; + for (const [key, child] of Object.entries(row)) { + if (child === undefined) continue; + if (table.columns.get(key)?.meta.isPrivate) continue; + const relation = table.relations.get(key); + out[key] = relation + ? sanitizeRelation(relation.target, child, depth + 1, state) + : sanitizeJson(child, depth + 1, state); + } + return out; + }); +} + +/** Project an included row through its own table; absent to-one reads null. */ +function projectRelation(target: CrudTable, value: unknown): JsonValue { + if (value === null || value === undefined) return null; + return Array.isArray(value) + ? value.map((row) => target.projectPublicRow(row as Row)) + : target.projectPublicRow(value as Row); +} + +/** Build the public JSON for one driver row, dropping anything uncontracted. */ +function projectRow(table: CrudTable, row: Row): JsonValue { + const out: Record = {}; + for (const [key, value] of Object.entries(row)) { + const column = table.columns.get(key); + if (column) { + // Whatever the driver returned, only public contracted columns ship. + if (!column.meta.isPrivate) out[key] = column.encode(value); + continue; + } + const relation = table.relations.get(key); + if (relation) out[key] = projectRelation(relation.target, value); + } + return out; +} + +/** Compile one table's allowlists and codecs from its finalized metadata. */ +function compileTable(table: AppKitTable): MutableCrudTable { + const columns = new Map(); + const selectable = new Set(); + const queryable = new Set(); + let primaryKey: CompiledColumn | undefined; + + for (const meta of Object.values(table.$columns)) { + const column = compileColumn(meta); + columns.set(meta.columnName, column); + if (meta.primaryKey) primaryKey = column; + if (meta.isPrivate) continue; + selectable.add(meta.columnName); + if (filterOperatorsForKind(meta.kind).length > 0) { + queryable.add(meta.columnName); + } + } + + const compiled: MutableCrudTable = { + name: table.$name, + primaryKey, + columns, + selectable, + queryable, + relations: new Map(), + decodeId: (raw) => { + if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read"); + const value = primaryKey.decode(raw); + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "bigint" + ) { + throw invalidDatabaseInput(["id"], "Not a valid identifier"); + } + return value; + }, + projectPublicRow: (row) => projectRow(compiled, row), + sanitizeSerializedRow: (row) => + sanitizeRow(compiled, row, 0, { nodes: 0, ancestors: new Set() }), + }; + return compiled; +} + +/** + * Compile the HTTP contract for every exposed table and wire the relations + * they share. A relation whose target is not exposed stays unreachable, so + * enabling one table never widens another table's public surface. + */ +export function compileCrudTables( + tables: Record, +): Map { + const compiled = new Map(); + for (const table of Object.values(tables)) { + compiled.set(table.$name, compileTable(table)); + } + for (const table of Object.values(tables)) { + const entry = compiled.get(table.$name); + for (const relation of table.$relations) { + const target = compiled.get(relation.targetTable); + if (!entry || !target) continue; + entry.relations.set(relation.name, { + cardinality: relation.cardinality, + target, + }); + } + } + return compiled as Map; +} diff --git a/packages/appkit/src/plugins/database/crud/exposure.ts b/packages/appkit/src/plugins/database/crud/exposure.ts new file mode 100644 index 000000000..c77834a83 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/exposure.ts @@ -0,0 +1,49 @@ +import { databaseSetupFailed } from "../../../database/errors"; + +/** A table name also becomes a URL path segment, so keep it unambiguous. */ +const ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; + +/** Refuse names that cannot address exactly one table over HTTP. */ +function assertRoutable(names: readonly string[]): void { + const lowercased = new Set(); + for (const name of names) { + // Express matches paths case-insensitively, so near-duplicates would alias. + if (!ROUTABLE_TABLE.test(name) || lowercased.has(name.toLowerCase())) { + throw databaseSetupFailed(); + } + lowercased.add(name.toLowerCase()); + } +} + +/** + * Resolve the tables whose generated reads are explicitly turned on. The + * exposure value arrives untyped because a finalized schema widens its table + * names to `string`, so every name is re-checked against the declared schema. + */ +export function resolveExposedTables( + exposure: unknown, + declared: readonly string[], +): string[] { + if (exposure === undefined || exposure === false) return []; + if (exposure === true) { + assertRoutable(declared); + return [...declared]; + } + if (typeof exposure !== "object" || exposure === null) { + throw databaseSetupFailed(); + } + const requested = (exposure as { tables?: unknown }).tables; + if (!Array.isArray(requested)) throw databaseSetupFailed(); + + const names: string[] = []; + for (const name of requested) { + // Unknown and duplicate names are configuration bugs, not empty routes. + if (typeof name !== "string" || !declared.includes(name)) { + throw databaseSetupFailed(); + } + if (names.includes(name)) throw databaseSetupFailed(); + names.push(name); + } + assertRoutable(names); + return names; +} diff --git a/packages/appkit/src/plugins/database/crud/query.ts b/packages/appkit/src/plugins/database/crud/query.ts new file mode 100644 index 000000000..80394d0ab --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/query.ts @@ -0,0 +1,472 @@ +import { + DEFAULT_LIMIT, + IN_CAP, + isFilterOperator, + MAX_INCLUDE_DEPTH, + MAX_INCLUDE_NODES, + MAX_INCLUDES, + MAX_LIMIT, +} from "../../../database/contract"; +import { invalidDatabaseInput } from "../../../database/errors"; +import type { + IncludeOptions, + IncludeSpec, + OrderDirection, + OrderSpec, + ScalarValue, + WhereClause, + WhereValue, +} from "../../../database/runtime"; +import { filterOperatorsForKind } from "../../../database/schema-builder/types"; +import { + MAX_GROUP_ITEMS, + MAX_MATERIALIZED_NODES, + MAX_OFFSET, + MAX_ORDER_FIELDS, + MAX_QUERY_BYTES, + MAX_WHERE_CONDITIONS, + MAX_WHERE_DEPTH, +} from "../defaults"; +import type { CompiledColumn } from "./codecs"; +import type { CrudTable } from "./contract"; + +/** One decoded, budget-checked page request. */ +interface DecodedListQuery { + readonly where?: WhereClause; + readonly order?: OrderSpec; + readonly select?: string[]; + readonly include?: IncludeSpec; + readonly limit: number; + readonly offset: number; +} + +/** One decoded, budget-checked single-row request. */ +interface DecodedDetailQuery { + readonly select?: string[]; + readonly include?: IncludeSpec; +} + +const LIST_PARAMS = new Set([ + "where", + "order", + "select", + "include", + "limit", + "offset", +]); +const DETAIL_PARAMS = new Set(["select", "include"]); +const CANONICAL_INT = /^-?(0|[1-9]\d*)$/; + +/** + * Reject one query parameter. `parameter` is always a fixed name and `reason` + * a fixed sentence, so a rejection can never echo caller-supplied text back. + */ +function reject(parameter: string, reason: string): never { + throw invalidDatabaseInput([parameter], reason); +} + +/** A decoded JSON object; arrays and null are separate wire shapes here. */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Read known, single-occurrence parameters from the raw query string. */ +function parseParams( + rawQuery: string, + allowed: ReadonlySet, +): Map { + if (Buffer.byteLength(rawQuery, "utf8") > MAX_QUERY_BYTES) { + reject("query", "Query string exceeds the maximum size"); + } + const params = new Map(); + for (const [key, value] of new URLSearchParams(rawQuery)) { + if (!allowed.has(key) || params.has(key)) { + reject("query", "Unknown or repeated query parameter"); + } + params.set(key, value); + } + return params; +} + +/** Parse one structured parameter without leaking the parser's diagnostics. */ +function parseJson(raw: string, parameter: string): unknown { + try { + return JSON.parse(raw); + } catch { + reject(parameter, "Expected JSON"); + } +} + +/** Accept one bounded integer, rejecting the forms `Number` would coerce. */ +function decodeIntParam( + raw: string | undefined, + parameter: string, + fallback: number, + max: number, +): number { + if (raw === undefined) return fallback; + if (!CANONICAL_INT.test(raw)) { + reject(parameter, "Expected a canonical decimal integer"); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 0 || value > max) { + reject(parameter, `Must be between 0 and ${max}`); + } + return value; +} + +/** Narrow a projection to the table's public columns. */ +function decodeSelect( + table: CrudTable, + value: unknown, + parameter: string, +): string[] { + if (!Array.isArray(value) || value.length === 0) { + reject(parameter, "Expected a non-empty array of public column names"); + } + for (const name of value) { + if (typeof name !== "string" || !table.selectable.has(name)) { + reject(parameter, "Names an unknown or private column"); + } + } + return value as string[]; +} + +/** Order only by columns the table can sort on, in a declared direction. */ +function decodeOrder( + table: CrudTable, + value: unknown, + parameter: string, +): OrderSpec { + if (!isPlainObject(value)) { + reject(parameter, "Expected an object of column directions"); + } + const entries = Object.entries(value); + if (entries.length === 0 || entries.length > MAX_ORDER_FIELDS) { + reject(parameter, `Expected 1 to ${MAX_ORDER_FIELDS} columns`); + } + const order: Record = {}; + for (const [column, direction] of entries) { + if (!table.queryable.has(column)) { + reject(parameter, "Names an unknown or unorderable column"); + } + if (direction !== "asc" && direction !== "desc") { + reject(parameter, "Expected a direction of asc or desc"); + } + order[column] = direction; + } + return order; +} + +/** Move one filter operand onto its column's canonical runtime value. */ +function decodeOperand( + column: CompiledColumn, + raw: unknown, + parameter: string, +): ScalarValue { + const value = column.decode(raw); + if (value === undefined) + reject(parameter, "Operand does not match its column type"); + return value; +} + +/** + * Decode the predicate on one column against the operator matrix its kind + * allows, counting conditions so a filter cannot grow without bound. + */ +function decodeCondition( + column: CompiledColumn, + value: unknown, + parameter: string, + state: { conditions: number }, +): WhereValue { + const countCondition = () => { + state.conditions += 1; + if (state.conditions > MAX_WHERE_CONDITIONS) { + reject(parameter, `Expected at most ${MAX_WHERE_CONDITIONS} conditions`); + } + }; + + if (value === null) { + // SQL three-valued matching stays explicit; a bare null is ambiguous. + reject(parameter, "Match a null column with is: null"); + } + if (!isPlainObject(value)) { + if (Array.isArray(value)) { + reject(parameter, "Expected a scalar or an operator object"); + } + countCondition(); + return decodeOperand(column, value, parameter); + } + + const nullable = !column.meta.notNull; + const supported = filterOperatorsForKind(column.meta.kind); + const operators: Record = {}; + const entries = Object.entries(value); + if (entries.length === 0) reject(parameter, "Filter cannot be empty"); + + for (const [operator, operand] of entries) { + countCondition(); + if (operator === "is") { + if (operand !== null || !nullable) { + reject( + parameter, + "The is operator accepts only null on a nullable column", + ); + } + operators.is = null; + continue; + } + if (!isFilterOperator(operator) || !supported.includes(operator)) { + reject(parameter, "Operator is not supported for this column"); + } + if (operator !== "in") { + operators[operator] = decodeOperand(column, operand, parameter); + continue; + } + if (!Array.isArray(operand) || operand.length > IN_CAP) { + reject(parameter, `Expected an array of at most ${IN_CAP} values`); + } + operators.in = operand.map((item) => { + if (item === null) + reject(parameter, "The in operator does not accept null"); + return decodeOperand(column, item, parameter); + }); + } + return operators as WhereValue; +} + +/** Decode one filter level, recursing through bounded `and`/`or` groups. */ +function decodeWhere( + table: CrudTable, + node: unknown, + depth: number, + parameter: string, + state: { conditions: number }, +): WhereClause { + if (depth > MAX_WHERE_DEPTH) { + reject(parameter, `Expected at most ${MAX_WHERE_DEPTH} nesting levels`); + } + if (!isPlainObject(node)) reject(parameter, "Expected an object"); + + const clause: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key === "and" || key === "or") { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > MAX_GROUP_ITEMS + ) { + reject(parameter, `Expected 1 to ${MAX_GROUP_ITEMS} group members`); + } + clause[key] = value.map((member) => + decodeWhere(table, member, depth + 1, parameter, state), + ); + continue; + } + // Relations are absent from `queryable`, so relation predicates fail here. + const column = table.queryable.has(key) + ? table.columns.get(key) + : undefined; + if (!column) reject(parameter, "Names an unknown or unfilterable column"); + clause[key] = decodeCondition(column, value, parameter, state); + } + return clause; +} + +/** + * Decode one relation's options against the target table, not the parent, and + * give every to-many edge a limit so an unqualified include stays bounded. + */ +function decodeIncludeOptions( + target: CrudTable, + value: unknown, + depth: number, + toMany: boolean, + parameter: string, +): boolean | IncludeOptions { + if (value === true) return toMany ? { limit: DEFAULT_LIMIT } : true; + if (!isPlainObject(value)) { + reject(parameter, "Expected true or an options object"); + } + + const options: { + select?: string[]; + where?: WhereClause; + order?: OrderSpec; + limit?: number; + include?: IncludeSpec; + } = {}; + for (const [key, inner] of Object.entries(value)) { + switch (key) { + case "select": + options.select = decodeSelect(target, inner, parameter); + break; + case "where": + options.where = decodeWhere(target, inner, 1, parameter, { + conditions: 0, + }); + break; + case "order": + options.order = decodeOrder(target, inner, parameter); + break; + case "limit": + if (!toMany) reject(parameter, "Only to-many relations accept a limit"); + if ( + typeof inner !== "number" || + !Number.isSafeInteger(inner) || + inner < 0 || + inner > MAX_LIMIT + ) { + reject(parameter, `Expected a limit between 0 and ${MAX_LIMIT}`); + } + options.limit = inner; + break; + case "include": + options.include = decodeInclude(target, inner, depth + 1, parameter); + break; + default: + reject(parameter, "Unsupported relation option"); + } + } + if (toMany && options.limit === undefined) options.limit = DEFAULT_LIMIT; + return options; +} + +/** Decode one include level; a relation to an unexposed table has no edge. */ +function decodeInclude( + table: CrudTable, + value: unknown, + depth: number, + parameter: string, +): IncludeSpec { + if (depth > MAX_INCLUDE_DEPTH) { + reject(parameter, `Expected at most ${MAX_INCLUDE_DEPTH} relation edges`); + } + if (!isPlainObject(value)) { + reject(parameter, "Expected an object of relation names"); + } + const entries = Object.entries(value); + if (entries.length > MAX_INCLUDES) { + reject(parameter, `Expected at most ${MAX_INCLUDES} relations per level`); + } + + const include: Record = {}; + for (const [name, options] of entries) { + const relation = table.relations.get(name); + if (!relation) reject(parameter, "Names an unknown or unexposed relation"); + include[name] = decodeIncludeOptions( + relation.target, + options, + depth, + relation.cardinality === "toMany", + parameter, + ); + } + return include; +} + +/** + * Rows one include tree materializes per parent row, counting relation nodes + * on the way. Every term is a non-negative integer bounded by `MAX_LIMIT` and + * both running totals are checked after each addition, so neither can overflow. + */ +function measureInclude( + table: CrudTable, + include: IncludeSpec | undefined, + budget: { nodes: number }, +): number { + let rows = 1; + if (!include) return rows; + for (const [name, value] of Object.entries(include)) { + const relation = table.relations.get(name); + if (!relation) continue; + budget.nodes += 1; + if (budget.nodes > MAX_INCLUDE_NODES) { + reject("include", `Expected at most ${MAX_INCLUDE_NODES} relations`); + } + const options = value === true ? undefined : (value as IncludeOptions); + const child = measureInclude(relation.target, options?.include, budget); + rows += + relation.cardinality === "toMany" + ? (options?.limit ?? DEFAULT_LIMIT) * child + : child; + if (rows > MAX_MATERIALIZED_NODES) { + reject("include", "Relation fan-out exceeds the read budget"); + } + } + return rows; +} + +/** Reject an include tree whose cost is only visible once it is assembled. */ +function assertReadBudget( + table: CrudTable, + include: IncludeSpec | undefined, + rootRows: number, +): void { + const rows = measureInclude(table, include, { nodes: 0 }); + if (rootRows * rows > MAX_MATERIALIZED_NODES) { + reject("include", "Relation fan-out exceeds the read budget"); + } +} + +/** Decode the projection parameters both reads share. */ +function decodeProjection( + table: CrudTable, + params: ReadonlyMap, +): DecodedDetailQuery { + const rawSelect = params.get("select"); + const rawInclude = params.get("include"); + return { + select: + rawSelect === undefined + ? undefined + : decodeSelect(table, parseJson(rawSelect, "select"), "select"), + include: + rawInclude === undefined + ? undefined + : decodeInclude(table, parseJson(rawInclude, "include"), 1, "include"), + }; +} + +/** Decode `GET /:table` and reject it before any database work is scheduled. */ +export function decodeListQuery( + table: CrudTable, + rawQuery: string, +): DecodedListQuery { + const params = parseParams(rawQuery, LIST_PARAMS); + const rawWhere = params.get("where"); + const rawOrder = params.get("order"); + + const where = + rawWhere === undefined + ? undefined + : decodeWhere(table, parseJson(rawWhere, "where"), 1, "where", { + conditions: 0, + }); + const order = + rawOrder === undefined + ? undefined + : decodeOrder(table, parseJson(rawOrder, "order"), "order"); + const { select, include } = decodeProjection(table, params); + const limit = decodeIntParam( + params.get("limit"), + "limit", + DEFAULT_LIMIT, + MAX_LIMIT, + ); + const offset = decodeIntParam(params.get("offset"), "offset", 0, MAX_OFFSET); + + assertReadBudget(table, include, limit); + return { where, order, select, include, limit, offset }; +} + +/** Decode `GET /:table/:id`, which supports projection and includes only. */ +export function decodeDetailQuery( + table: CrudTable, + rawQuery: string, +): DecodedDetailQuery { + const decoded = decodeProjection(table, parseParams(rawQuery, DETAIL_PARAMS)); + assertReadBudget(table, decoded.include, 1); + return decoded; +} diff --git a/packages/appkit/src/plugins/database/crud/routes.ts b/packages/appkit/src/plugins/database/crud/routes.ts new file mode 100644 index 000000000..41829a974 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/routes.ts @@ -0,0 +1,181 @@ +import type { Request, Response } from "express"; +import { + classifyDatabaseError, + type DatabaseErrorDetail, + DatabasePluginError, + invalidDatabaseInput, +} from "../../../database/errors"; +import type { + IdValue, + IncludeSpec, + OrderSpec, + Row, + WhereClause, +} from "../../../database/runtime"; +import { MAX_RESPONSE_BYTES } from "../defaults"; +import type { ReadSerializer } from "../types"; +import type { JsonValue } from "./codecs"; +import type { CrudTable } from "./contract"; +import { decodeDetailQuery, decodeListQuery } from "./query"; + +/** The `EntityClient` subset a generated read drives. */ +export interface CrudReadEntity { + where(where: WhereClause): CrudReadEntity; + order(order: OrderSpec): CrudReadEntity; + select(columns: string[]): CrudReadEntity; + include(include: IncludeSpec): CrudReadEntity; + limit(limit: number): CrudReadEntity; + offset(offset: number): CrudReadEntity; + toArray(): Promise; + find(id: IdValue): Promise; +} + +/** Everything one table's generated reads need from the plugin instance. */ +export interface ReadRouteDeps { + readonly table: CrudTable; + /** Resolved per request so a draining plugin cannot serve a stale client. */ + entity(): CrudReadEntity; + readonly serialize?: ReadSerializer; + runRouteSpan( + operation: "list" | "detail", + route: string, + run: () => Promise, + ): Promise; +} + +type ReadHandler = (req: Request, res: Response) => Promise; + +/** Low-cardinality span outcome for one failed generated read. */ +export function readRouteOutcome( + error: unknown, +): "not_found" | "rejected" | "failed" { + const { statusCode } = classifyDatabaseError(error, "read"); + if (statusCode === 404) return "not_found"; + return statusCode < 500 ? "rejected" : "failed"; +} + +/** Express normalizes `req.query`; the decoders need the untouched string. */ +function rawQuery(req: Request): string { + const url = req.originalUrl ?? req.url; + const start = url.indexOf("?"); + return start === -1 ? "" : url.slice(start + 1); +} + +/** + * Append the primary key so equal sort keys cannot reshuffle between pages. + * A keyless table has no unique tie-breaker to append, so it must order itself + * and its pages stay stable only while no concurrent write reorders them. + */ +function stableOrder( + primaryKey: string | undefined, + order: OrderSpec | undefined, +): OrderSpec { + if (primaryKey) + return { ...order, [primaryKey]: order?.[primaryKey] ?? "asc" }; + if (!order) { + throw invalidDatabaseInput( + ["order"], + "A table without a primary key requires an explicit order", + ); + } + return order; +} + +function serializeRow( + deps: ReadRouteDeps, + operation: "list" | "detail", + row: Row, +): JsonValue { + const projected = deps.table.projectPublicRow(row); + if (!deps.serialize) return projected; + const shaped = deps.serialize(projected as Record, { + entity: deps.table.name, + operation, + }); + return deps.table.sanitizeSerializedRow(shaped); +} + +/** + * Row data is never cacheable by a shared proxy or a browser: the same URL can + * answer differently once the underlying table or the caller's rights change. + */ +function writeJson(res: Response, status: number, payload: string): void { + res.status(status); + res.type("application/json"); + res.setHeader("Cache-Control", "no-store"); + res.send(payload); +} + +/** Measure the encoded body before sending so no partial response escapes. */ +function sendJson(res: Response, body: JsonValue): void { + const payload = JSON.stringify(body); + if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) { + throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read"); + } + writeJson(res, 200, payload); +} + +/** Answer with the failure's safe category and the field it concerns. */ +function writeError(res: Response, error: unknown): void { + if (res.headersSent) return; + const safe = classifyDatabaseError(error, "read"); + const body: { error: string; details?: readonly DatabaseErrorDetail[] } = { + error: safe.clientMessage, + }; + if (safe.details && safe.details.length > 0) body.details = safe.details; + writeJson(res, safe.statusCode, JSON.stringify(body)); +} + +/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */ +export function createListHandler(deps: ReadRouteDeps): ReadHandler { + const primaryKey = deps.table.primaryKey?.meta.columnName; + const route = `/${deps.table.name}`; + + return async (req, res) => { + try { + await deps.runRouteSpan("list", route, async () => { + const decoded = decodeListQuery(deps.table, rawQuery(req)); + let query = deps + .entity() + .order(stableOrder(primaryKey, decoded.order)) + .limit(decoded.limit) + .offset(decoded.offset); + if (decoded.where) query = query.where(decoded.where); + if (decoded.select) query = query.select(decoded.select); + if (decoded.include) query = query.include(decoded.include); + + const rows = await query.toArray(); + sendJson(res, { + items: rows.map((row) => serializeRow(deps, "list", row)), + limit: decoded.limit, + offset: decoded.offset, + }); + }); + } catch (error) { + writeError(res, error); + } + }; +} + +/** `GET /:table/:id` — one public row, or 404 when nothing matches. */ +export function createDetailHandler(deps: ReadRouteDeps): ReadHandler { + const route = `/${deps.table.name}/:id`; + + return async (req, res) => { + try { + await deps.runRouteSpan("detail", route, async () => { + const id = deps.table.decodeId(req.params.id); + const decoded = decodeDetailQuery(deps.table, rawQuery(req)); + let query = deps.entity(); + if (decoded.select) query = query.select(decoded.select); + if (decoded.include) query = query.include(decoded.include); + + const row = await query.find(id); + if (row === null) throw new DatabasePluginError("NOT_FOUND", "read"); + sendJson(res, serializeRow(deps, "detail", row)); + }); + } catch (error) { + writeError(res, error); + } + }; +} diff --git a/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts b/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts new file mode 100644 index 000000000..09423c808 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/codecs.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + bigint, + boolean, + defineSchema, + enumColumn, + id, + integer, + jsonb, + text, + timestamp, + uuid, + varchar, +} from "../../../../database/schema-builder"; +import { compileColumn } from "../codecs"; + +const schema = defineSchema((builder) => { + const things = builder.table("things", { + id: id(), + name: text(), + label: varchar(4), + count: integer(), + total: bigint(), + active: boolean(), + external: uuid(), + status: enumColumn("codec_status", ["active", "disabled"]), + createdAt: timestamp(), + payload: jsonb(), + }); + return { things }; +}); + +const columns = schema.$tables.things.$columns; +const column = (name: keyof typeof columns) => compileColumn(columns[name]); +const UUID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("compileColumn decode", () => { + it("accepts the canonical wire form of every supported kind", () => { + expect(column("count").decode(7)).toBe(7); + expect(column("count").decode("7")).toBe(7); + expect(column("total").decode("9007199254740993")).toBe(9007199254740993n); + expect(column("total").decode(12)).toBe(12n); + expect(column("active").decode(true)).toBe(true); + expect(column("name").decode("Ada")).toBe("Ada"); + expect(column("external").decode(UUID)).toBe(UUID); + expect(column("status").decode("active")).toBe("active"); + expect(column("createdAt").decode("2020-01-01T00:00:00Z")).toBe( + "2020-01-01T00:00:00Z", + ); + }); + + it("rejects coercible input instead of guessing the intended value", () => { + expect(column("active").decode("true")).toBeUndefined(); + expect(column("count").decode("7.0")).toBeUndefined(); + expect(column("count").decode("007")).toBeUndefined(); + expect(column("count").decode(7.5)).toBeUndefined(); + expect(column("count").decode(2_147_483_648)).toBeUndefined(); + expect(column("total").decode("1e3")).toBeUndefined(); + expect(column("total").decode(1.5)).toBeUndefined(); + expect(column("name").decode(7)).toBeUndefined(); + expect(column("label").decode("toolong")).toBeUndefined(); + expect(column("external").decode("not-a-uuid")).toBeUndefined(); + expect(column("status").decode("unknown")).toBeUndefined(); + expect(column("createdAt").decode("yesterday")).toBeUndefined(); + expect(column("createdAt").decode(0)).toBeUndefined(); + }); + + it("keeps unfilterable kinds undecodable", () => { + expect(column("payload").decode({ any: "value" })).toBeUndefined(); + expect(column("name").decode(null)).toBeUndefined(); + expect(column("name").decode(undefined)).toBeUndefined(); + }); +}); + +describe("compileColumn encode", () => { + it("emits one deterministic JSON form per kind", () => { + expect(column("total").encode(9007199254740993n)).toBe("9007199254740993"); + expect(column("total").encode(12)).toBe("12"); + expect(column("count").encode(7)).toBe(7); + expect(column("active").encode(false)).toBe(false); + expect(column("createdAt").encode("2020-01-01T00:00:00Z")).toBe( + "2020-01-01T00:00:00Z", + ); + expect(column("payload").encode({ nested: [1, "two"] })).toEqual({ + nested: [1, "two"], + }); + expect(column("name").encode(null)).toBeNull(); + expect(column("name").encode(undefined)).toBeNull(); + }); + + it("fails closed when a driver value contradicts its column", () => { + expect(() => column("name").encode(7)).toThrow(DatabasePluginError); + expect(() => column("createdAt").encode(new Date())).toThrow( + DatabasePluginError, + ); + expect(() => column("count").encode(Number.NaN)).toThrow( + DatabasePluginError, + ); + expect(() => column("payload").encode(new Map())).toThrow( + DatabasePluginError, + ); + expect(() => column("name").encode(7)).toThrow( + expect.objectContaining({ category: "INTERNAL" }), + ); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts new file mode 100644 index 000000000..ad362c80f --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + bigid, + defineSchema, + fk, + id, + jsonb, + text, +} from "../../../../database/schema-builder"; +import { MAX_SERIALIZED_DEPTH } from "../../defaults"; +import { compileCrudTables } from "../contract"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + profile: jsonb(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + draft: text().private(), + }); + const ledger = builder.table("ledger", { id: bigid(), memo: text() }); + return { users, notes, ledger }; +}); + +const tables = compileCrudTables(schema.$tables); +const users = tables.get("users") as NonNullable>; +const notes = tables.get("notes") as NonNullable>; +const ledger = tables.get("ledger") as NonNullable< + ReturnType +>; + +describe("compileCrudTables", () => { + it("allowlists public columns and excludes unfilterable kinds", () => { + expect([...users.selectable]).toEqual(["id", "name", "profile"]); + expect([...users.queryable]).toEqual(["id", "name"]); + expect(users.columns.has("token")).toBe(true); + expect(users.primaryKey?.meta.columnName).toBe("id"); + }); + + it("wires relations only between exposed tables", () => { + expect(users.relations.get("notes")).toMatchObject({ + cardinality: "toMany", + target: notes, + }); + expect(notes.relations.get("users")).toMatchObject({ + cardinality: "toOne", + target: users, + }); + const isolated = compileCrudTables({ notes: schema.$tables.notes }); + expect(isolated.get("notes")?.relations.size).toBe(0); + }); + + it("decodes identifiers against the declared key type", () => { + expect(users.decodeId("42")).toBe(42); + expect(ledger.decodeId("9007199254740993")).toBe(9007199254740993n); + expect(() => users.decodeId("abc")).toThrow(DatabasePluginError); + expect(() => users.decodeId("abc")).toThrow( + expect.objectContaining({ + category: "INVALID_REQUEST", + details: [{ path: ["id"], message: expect.any(String) }], + }), + ); + }); +}); + +describe("projectPublicRow", () => { + it("drops private columns at every level and encodes relations", () => { + const projected = users.projectPublicRow({ + id: 1, + name: "Ada", + token: "secret", + profile: { theme: "dark" }, + notes: [{ id: 2, body: "hello", draft: "hidden" }], + unknown: "ignored", + }); + expect(projected).toEqual({ + id: 1, + name: "Ada", + profile: { theme: "dark" }, + notes: [{ id: 2, body: "hello" }], + }); + }); + + it("represents an absent to-one relation as null", () => { + expect(notes.projectPublicRow({ id: 1, users: null })).toEqual({ + id: 1, + users: null, + }); + }); +}); + +describe("sanitizeSerializedRow", () => { + it("re-applies the private-column policy to serializer output", () => { + const sanitized = users.sanitizeSerializedRow({ + id: 1, + token: "leaked", + computed: { label: "Ada", tags: ["a", "b"] }, + skipped: undefined, + notes: [{ id: 2, draft: "leaked", body: "hello" }], + }); + expect(sanitized).toEqual({ + id: 1, + computed: { label: "Ada", tags: ["a", "b"] }, + notes: [{ id: 2, body: "hello" }], + }); + }); + + it("treats a broken serializer as an internal fault", () => { + const cyclic: Record = { id: 1 }; + cyclic.self = cyclic; + let deep: Record = {}; + for (let level = 0; level <= MAX_SERIALIZED_DEPTH; level += 1) { + deep = { deep }; + } + for (const broken of [ + cyclic, + deep, + "not-an-object", + { id: Number.POSITIVE_INFINITY }, + { at: new Date() }, + ]) { + expect(() => users.sanitizeSerializedRow(broken)).toThrow( + expect.objectContaining({ category: "INTERNAL" }), + ); + } + }); + + it("carries a __proto__ key as data rather than dropping it", () => { + const sanitized = users.sanitizeSerializedRow({ + id: 1, + computed: JSON.parse('{"__proto__":{"owned":true},"keep":1}'), + }) as { computed: unknown }; + + expect(JSON.stringify(sanitized.computed)).toBe( + '{"__proto__":{"owned":true},"keep":1}', + ); + expect(({} as Record).owned).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/query.test.ts b/packages/appkit/src/plugins/database/crud/tests/query.test.ts new file mode 100644 index 000000000..f0ddad520 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/query.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_LIMIT, + IN_CAP, + MAX_LIMIT, +} from "../../../../database/contract"; +import { DatabasePluginError } from "../../../../database/errors"; +import { + boolean, + type ColumnRef, + defineSchema, + fk, + id, + integer, + jsonb, + type SchemaBuilderContext, + text, + timestamp, +} from "../../../../database/schema-builder"; +import { MAX_OFFSET, MAX_QUERY_BYTES } from "../../defaults"; +import { type CrudTable, compileCrudTables } from "../contract"; +import { decodeDetailQuery, decodeListQuery } from "../query"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + rank: integer(), + active: boolean().notNull(), + createdAt: timestamp(), + profile: jsonb(), + token: text().private(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + }); + return { users, notes }; +}); + +const tables = compileCrudTables(schema.$tables); +const users = tables.get("users") as CrudTable; + +function query(params: Record): string { + const encoded = Object.entries(params).map( + ([key, value]): [string, string] => [ + key, + typeof value === "string" ? value : JSON.stringify(value), + ], + ); + return new URLSearchParams(encoded).toString(); +} + +function expectRejected(raw: string): void { + expect(() => decodeListQuery(users, raw)).toThrow(DatabasePluginError); + expect(() => decodeListQuery(users, raw)).toThrow( + expect.objectContaining({ category: "INVALID_REQUEST", statusCode: 400 }), + ); +} + +describe("query parameters", () => { + it("defaults pagination and accepts canonical integers", () => { + expect(decodeListQuery(users, "")).toEqual({ + where: undefined, + order: undefined, + select: undefined, + include: undefined, + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect(decodeListQuery(users, "limit=10&offset=20")).toMatchObject({ + limit: 10, + offset: 20, + }); + }); + + it("rejects unknown, repeated, oversized, and non-canonical parameters", () => { + expectRejected("unknown=1"); + // A generated list is one page query, so there is no total to request. + expectRejected("includeTotal=true"); + expectRejected("limit=1&limit=2"); + expectRejected(`select=${"x".repeat(MAX_QUERY_BYTES)}`); + expectRejected("limit=01"); + expectRejected("limit=-1"); + expectRejected("limit=1.0"); + expectRejected(`limit=${MAX_LIMIT + 1}`); + expectRejected(`offset=${MAX_OFFSET + 1}`); + expectRejected("where=notjson"); + }); + + it("keeps detail requests to projection and includes", () => { + expect(decodeDetailQuery(users, query({ select: ["id"] }))).toEqual({ + select: ["id"], + include: undefined, + }); + expect(() => decodeDetailQuery(users, query({ where: { id: 1 } }))).toThrow( + DatabasePluginError, + ); + expect(() => decodeDetailQuery(users, "limit=1")).toThrow( + DatabasePluginError, + ); + }); +}); + +describe("select and order", () => { + it("accepts public columns only", () => { + expect( + decodeListQuery(users, query({ select: ["id", "name"] })).select, + ).toEqual(["id", "name"]); + expect( + decodeListQuery(users, query({ order: { name: "desc" } })).order, + ).toEqual({ name: "desc" }); + }); + + it("rejects private, unknown, empty, and unorderable selections", () => { + expectRejected(query({ select: ["token"] })); + expectRejected(query({ select: ["missing"] })); + expectRejected(query({ select: [] })); + expectRejected(query({ select: "id" })); + expectRejected(query({ order: { token: "asc" } })); + expectRejected(query({ order: { profile: "asc" } })); + expectRejected(query({ order: { name: "sideways" } })); + expectRejected(query({ order: {} })); + }); +}); + +describe("where", () => { + it("decodes operands through their column codec", () => { + expect( + decodeListQuery(users, query({ where: { rank: "3" } })).where, + ).toEqual({ rank: 3 }); + expect( + decodeListQuery(users, query({ where: { name: { ilike: "a%" } } })).where, + ).toEqual({ name: { ilike: "a%" } }); + expect( + decodeListQuery(users, query({ where: { name: { is: null } } })).where, + ).toEqual({ name: { is: null } }); + expect( + decodeListQuery( + users, + query({ + where: { or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }, + }), + ).where, + ).toEqual({ or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }); + }); + + it("applies one operator matrix per column kind", () => { + expectRejected(query({ where: { name: { gt: "a" } } })); + expectRejected(query({ where: { rank: { like: "1" } } })); + expectRejected(query({ where: { profile: { eq: {} } } })); + expectRejected(query({ where: { rank: "abc" } })); + expectRejected(query({ where: { createdAt: { gt: "yesterday" } } })); + }); + + it("keeps null matching explicit and bounds in lists", () => { + // An empty set is a legal filter that the engine renders as no match. + expect( + decodeListQuery(users, query({ where: { id: { in: [] } } })).where, + ).toEqual({ id: { in: [] } }); + expectRejected(query({ where: { name: null } })); + expectRejected(query({ where: { name: { eq: null } } })); + expectRejected(query({ where: { active: { is: null } } })); + expectRejected(query({ where: { name: { in: ["a", null] } } })); + expectRejected( + query({ + where: { id: { in: Array.from({ length: IN_CAP + 1 }, (_, i) => i) } }, + }), + ); + }); + + it("rejects unknown columns, relations, and empty filters", () => { + expectRejected(query({ where: { missing: 1 } })); + expectRejected(query({ where: { token: "secret" } })); + expectRejected(query({ where: { notes: { some: { body: "a" } } } })); + expectRejected(query({ where: { name: {} } })); + expectRejected(query({ where: { and: [] } })); + expectRejected(query({ where: [] })); + }); + + it("bounds nesting, group size, and total conditions", () => { + let deep: Record = { rank: 1 }; + for (let level = 0; level < 5; level += 1) deep = { and: [deep] }; + expectRejected(query({ where: deep })); + expectRejected( + query({ where: { or: Array.from({ length: 21 }, () => ({ rank: 1 })) } }), + ); + expectRejected( + query({ + where: { + and: Array.from({ length: 20 }, () => ({ + rank: { gt: 1, lt: 5, gte: 1 }, + })), + }, + }), + ); + }); +}); + +describe("include", () => { + it("bounds to-many relations and carries a second edge", () => { + expect( + decodeListQuery(users, query({ include: { notes: true } })).include, + ).toEqual({ notes: { limit: DEFAULT_LIMIT } }); + expect( + decodeListQuery( + users, + query({ + include: { + notes: { select: ["body"], limit: 5, include: { users: true } }, + }, + }), + ).include, + ).toEqual({ + notes: { select: ["body"], limit: 5, include: { users: true } }, + }); + }); + + it("rejects a third edge, unknown relations, and unsupported options", () => { + expectRejected( + query({ include: { notes: { include: { users: { include: {} } } } } }), + ); + expectRejected(query({ include: { missing: true } })); + expectRejected(query({ include: { notes: false } })); + expectRejected(query({ include: { notes: { offset: 1 } } })); + expectRejected(query({ include: { notes: { limit: MAX_LIMIT + 1 } } })); + expectRejected(query({ include: { notes: { select: ["missing"] } } })); + }); + + it("limits only to-many relations and scopes options to the target", () => { + const notes = tables.get("notes") as CrudTable; + expect(() => + decodeListQuery(notes, query({ include: { users: { limit: 1 } } })), + ).toThrow(DatabasePluginError); + expect( + decodeListQuery( + notes, + query({ include: { users: { where: { rank: 1 } } } }), + ).include, + ).toEqual({ users: { where: { rank: 1 } } }); + expect(() => + decodeListQuery( + notes, + query({ include: { users: { where: { body: "a" } } } }), + ), + ).toThrow(DatabasePluginError); + }); +}); + +describe("read budget", () => { + it("rejects fan-out before the entity terminal runs", () => { + expectRejected( + query({ limit: "500", include: { notes: { limit: MAX_LIMIT } } }), + ); + expectRejected( + query({ + limit: "50", + include: { notes: { limit: 100, include: { users: true } } }, + }), + ); + expect( + decodeListQuery( + users, + query({ + limit: "49", + include: { notes: { limit: 100, include: { users: true } } }, + }), + ).limit, + ).toBe(49); + }); + + it("bounds the total number of relation nodes in one tree", () => { + const wide = defineSchema((builder: SchemaBuilderContext) => { + const shared = builder.table("shared", { id: id() }); + const leaves: Record = {}; + for (let index = 0; index < 9; index += 1) { + leaves[`t${index}`] = builder.table(`t${index}`, { + id: id(), + sharedId: fk(() => shared.id), + }); + } + const hub = builder.table("hub", { + id: id(), + ...Object.fromEntries( + Object.entries(leaves).map(([name, leaf]) => [ + `${name}Id`, + fk(() => leaf.id), + ]), + ), + }); + return { shared, ...leaves, hub }; + }); + const hub = compileCrudTables(wide.$tables).get("hub") as CrudTable; + const branch = { include: { shared: true, hub: { limit: 0 } } }; + const tree = (count: number) => + query({ + limit: "1", + include: Object.fromEntries( + Array.from({ length: count }, (_, index) => [`t${index}`, branch]), + ), + }); + + // 8 branches materialize 24 relation nodes; a ninth crosses the 25 cap. + expect( + Object.keys(decodeListQuery(hub, tree(8)).include ?? {}), + ).toHaveLength(8); + expect(() => decodeListQuery(hub, tree(9))).toThrow(DatabasePluginError); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts new file mode 100644 index 000000000..5c3af5883 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts @@ -0,0 +1,338 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_LIMIT } from "../../../../database/contract"; +import { DatabasePluginError } from "../../../../database/errors"; +import type { Row } from "../../../../database/runtime"; +import { + defineSchema, + fk, + id, + text, +} from "../../../../database/schema-builder"; +import { MAX_RESPONSE_BYTES } from "../../defaults"; +import type { EntityClient } from "../../entity-client"; +import type { ReadSerializer } from "../../types"; +import { type CrudTable, compileCrudTables } from "../contract"; +import { + type CrudReadEntity, + createDetailHandler, + createListHandler, + type ReadRouteDeps, +} from "../routes"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + body: text(), + }); + const events = builder.table("events", { message: text() }); + return { users, notes, events }; +}); + +const tables = compileCrudTables(schema.$tables); + +// The routes reach their entity through an untyped export lookup, so the read +// surface they drive has to stay a subset of the real client. +const _entityClientSatisfiesReads: CrudReadEntity = {} as EntityClient; + +interface FakeEntity extends CrudReadEntity { + readonly calls: Record; +} + +function fakeEntity(rows: Row[], found: Row | null = null): FakeEntity { + const calls: Record = {}; + const record = (name: string, value: unknown) => { + calls[name] ??= []; + calls[name].push(value); + }; + const chain = (name: string) => (value: unknown) => { + record(name, value); + return entity; + }; + const entity: FakeEntity = { + calls, + where: chain("where"), + order: chain("order"), + select: chain("select"), + include: chain("include"), + limit: chain("limit"), + offset: chain("offset"), + toArray: async () => { + record("toArray", true); + return rows; + }, + find: async (value) => { + record("find", value); + return found; + }, + }; + return entity; +} + +function fakeResponse() { + const sent: { + status?: number; + body?: string; + type?: string; + headers: Record; + } = { headers: {} }; + const res = { + headersSent: false, + status: vi.fn((code: number) => { + sent.status = code; + return res; + }), + type: vi.fn((value: string) => { + sent.type = value; + return res; + }), + setHeader: vi.fn((name: string, value: string) => { + sent.headers[name] = value; + return res; + }), + send: vi.fn((body: string) => { + sent.body = body; + return res; + }), + }; + return { + res: res as unknown as Response, + sent, + json: () => JSON.parse(sent.body ?? "null"), + }; +} + +function request(url: string, params: Record = {}): Request { + return { originalUrl: url, url, params } as unknown as Request; +} + +function deps( + table: string, + entity: CrudReadEntity, + serialize?: ReadSerializer, +): ReadRouteDeps { + return { + table: tables.get(table) as CrudTable, + entity: () => entity, + serialize, + runRouteSpan: (_operation, _route, run) => run(), + }; +} + +let response = fakeResponse(); +beforeEach(() => { + response = fakeResponse(); +}); + +describe("list route", () => { + it("returns the bounded envelope from one query", async () => { + const entity = fakeEntity([ + { id: 1, name: "Ada", token: "secret" }, + { id: 2, name: "Grace", token: "secret" }, + ]); + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + + expect(response.sent.status).toBe(200); + expect(response.json()).toEqual({ + items: [ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ], + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect(entity.calls.toArray).toHaveLength(1); + expect(entity.calls.where).toBeUndefined(); + expect(entity.calls.order).toEqual([{ id: "asc" }]); + }); + + it("keeps row data out of shared and browser caches", async () => { + await createListHandler(deps("users", fakeEntity([])))( + request("/users"), + response.res, + ); + expect(response.sent.headers["Cache-Control"]).toBe("no-store"); + + const rejected = fakeResponse(); + await createListHandler(deps("users", fakeEntity([])))( + request("/users?limit=abc"), + rejected.res, + ); + expect(rejected.sent.headers["Cache-Control"]).toBe("no-store"); + }); + + it("appends the primary key so equal sort keys stay stable", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request(`/users?order=${encodeURIComponent('{"name":"desc"}')}`), + response.res, + ); + expect(entity.calls.order).toEqual([{ name: "desc", id: "asc" }]); + }); + + it("keeps a caller-supplied key direction", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request(`/users?order=${encodeURIComponent('{"id":"desc"}')}`), + response.res, + ); + expect(entity.calls.order).toEqual([{ id: "desc" }]); + }); + + it("requires explicit ordering when a table has no key", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("events", entity))( + request("/events"), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(entity.calls.toArray).toBeUndefined(); + + const ordered = fakeEntity([]); + await createListHandler(deps("events", ordered))( + request(`/events?order=${encodeURIComponent('{"message":"asc"}')}`), + fakeResponse().res, + ); + expect(ordered.calls.order).toEqual([{ message: "asc" }]); + }); + + it("forwards only the parameters the caller supplied", async () => { + const entity = fakeEntity([]); + const url = `/notes?where=${encodeURIComponent('{"body":"a"}')}&select=${encodeURIComponent('["body"]')}&include=${encodeURIComponent('{"users":true}')}&limit=5&offset=3`; + await createListHandler(deps("notes", entity))(request(url), response.res); + expect(entity.calls).toMatchObject({ + where: [{ body: "a" }], + select: [["body"]], + include: [{ users: true }], + limit: [5], + offset: [3], + }); + }); + + it("rejects an invalid query before touching the entity", async () => { + const entity = fakeEntity([]); + await createListHandler(deps("users", entity))( + request("/users?limit=abc"), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(response.json()).toEqual({ + error: "Invalid database request", + details: [{ path: ["limit"], message: expect.any(String) }], + }); + expect(entity.calls.toArray).toBeUndefined(); + }); +}); + +describe("detail route", () => { + it("returns one public row for a decoded key", async () => { + const entity = fakeEntity([], { id: 7, name: "Ada", token: "secret" }); + await createDetailHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + response.res, + ); + expect(entity.calls.find).toEqual([7]); + expect(response.json()).toEqual({ id: 7, name: "Ada" }); + }); + + it("answers a missing row with 404 and no internal category", async () => { + const entity = fakeEntity([], null); + await createDetailHandler(deps("users", entity))( + request("/users/7", { id: "7" }), + response.res, + ); + expect(response.sent.status).toBe(404); + expect(response.json()).toEqual({ error: "Database record not found" }); + }); + + it("rejects an unrepresentable key without a lookup", async () => { + const entity = fakeEntity([], null); + await createDetailHandler(deps("users", entity))( + request("/users/abc", { id: "abc" }), + response.res, + ); + expect(response.sent.status).toBe(400); + expect(entity.calls.find).toBeUndefined(); + }); +}); + +describe("serialization and response limits", () => { + it("keeps the serializer contract synchronous", () => { + // @ts-expect-error a serializer may not defer work into the response path + const deferred: ReadSerializer = async (row) => row; + expect(deferred).toBeTypeOf("function"); + }); + + it("applies a synchronous serializer and re-checks private columns", async () => { + const serialize = vi.fn((row) => ({ + ...row, + label: `#${row.id}`, + token: "reintroduced", + })); + const entity = fakeEntity([{ id: 1, name: "Ada", token: "secret" }]); + await createListHandler(deps("users", entity, serialize))( + request("/users"), + response.res, + ); + expect(serialize).toHaveBeenCalledWith( + { id: 1, name: "Ada" }, + { entity: "users", operation: "list" }, + ); + expect(response.json().items).toEqual([ + { id: 1, name: "Ada", label: "#1" }, + ]); + }); + + it("maps a broken serializer to an opaque server error", async () => { + const entity = fakeEntity([{ id: 1, name: "Ada" }]); + const throwing = vi.fn(() => { + throw new Error("boom: select * from users"); + }); + await createListHandler(deps("users", entity, throwing))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(500); + expect(response.json()).toEqual({ error: "Database operation failed" }); + }); + + it("rejects a response that exceeds the byte budget", async () => { + const wide = "x".repeat(1024 * 1024); + const entity = fakeEntity( + Array.from({ length: 6 }, (_, index) => ({ id: index, name: wide })), + ); + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(413); + expect(response.json()).toEqual({ + error: "Database response is too large", + }); + expect(MAX_RESPONSE_BYTES).toBeLessThan(6 * wide.length); + }); + + it("maps an entity failure to its safe category", async () => { + const entity = fakeEntity([]); + entity.toArray = async () => { + throw new DatabasePluginError("FORBIDDEN", "read"); + }; + await createListHandler(deps("users", entity))( + request("/users"), + response.res, + ); + expect(response.sent.status).toBe(403); + expect(response.json()).toEqual({ + error: "Database operation forbidden", + }); + }); +}); diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index f5109b7f2..53a9f929c 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -1,12 +1,25 @@ +import type express from "express"; import type { BasePluginConfig, PluginConstructor } from "shared"; -import { DatabasePluginError } from "../../database/errors"; +import { + DatabasePluginError, + databaseSetupFailed, +} from "../../database/errors"; import type { Schema } from "../../database/schema-builder"; import { Plugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; +import { compileCrudTables } from "./crud/contract"; +import { resolveExposedTables } from "./crud/exposure"; +import { + type CrudReadEntity, + createDetailHandler, + createListHandler, + type ReadRouteDeps, + readRouteOutcome, +} from "./crud/routes"; import type { DatabaseExports } from "./entity-types"; import { createDatabaseState, type DatabaseState } from "./lifecycle"; import manifest from "./manifest.json"; -import type { IDatabaseConfig } from "./types"; +import type { IDatabaseConfig, ReadSerializer } from "./types"; /** Schema-driven database plugin */ export class DatabasePlugin extends Plugin< @@ -19,18 +32,26 @@ export class DatabasePlugin extends Plugin< private setupPromise: Promise | null = null; private draining = false; private shutdownPromise: Promise | null = null; + private exposedTables: string[] = []; constructor(config: IDatabaseConfig) { super({ schema: config.schema }); - this.config = { schema: config.schema }; + this.config = { + schema: config.schema, + crudRoutes: config.crudRoutes, + hooks: config.hooks, + }; } /** Build and verify one candidate state before publishing its exports. */ async setup(): Promise { - if (this.draining || this.state) - throw new DatabasePluginError("SETUP_FAILED", "setup"); + if (this.draining || this.state) throw databaseSetupFailed(); if (!this.setupPromise) { const attempt = (async () => { + this.exposedTables = resolveExposedTables( + this.config.crudRoutes, + Object.keys(this.config.schema.$tables), + ); const candidate = await createDatabaseState( this.config.schema, (operation, options) => this.execute(operation, options), @@ -39,7 +60,7 @@ export class DatabasePlugin extends Plugin< // Setup may finish while shutdown is waiting; never publish that state. candidate.deactivate(); await candidate.pool.end().catch(() => undefined); - throw new DatabasePluginError("SETUP_FAILED", "setup"); + throw databaseSetupFailed(); } this.state = candidate; })(); @@ -48,6 +69,49 @@ export class DatabasePlugin extends Plugin< return this.setupPromise; } + /** Register generated reads for explicitly exposed tables only. */ + injectRoutes(router: express.Router): void { + if (this.exposedTables.length === 0) return; + const tables = compileCrudTables( + Object.fromEntries( + this.exposedTables.map((name) => [ + name, + this.config.schema.$tables[name], + ]), + ), + ); + const serializers = this.config.hooks as + | Record + | undefined; + // Every exposed name is a declared table, so its export is an entity client. + const entities = () => + this.exports() as unknown as Record; + + for (const table of tables.values()) { + const deps: ReadRouteDeps = { + table, + entity: () => entities()[table.name], + serialize: serializers?.[table.name]?.serialize, + runRouteSpan: (operation, route, run) => + this.runReadSpan(table.name, operation, route, run), + }; + this.route(router, { + name: `${table.name}.list`, + method: "get", + path: `/${table.name}`, + handler: createListHandler(deps), + }); + if (table.primaryKey) { + this.route(router, { + name: `${table.name}.detail`, + method: "get", + path: `/${table.name}/:id`, + handler: createDetailHandler(deps), + }); + } + } + } + /** Return the typed database API only while the plugin is active. */ exports() { if (!this.state || this.draining) @@ -78,6 +142,36 @@ export class DatabasePlugin extends Plugin< })(); return this.shutdownPromise; } + + /** Trace one generated read with allowlisted, low-cardinality attributes. */ + private runReadSpan( + table: string, + operation: "list" | "detail", + route: string, + run: () => Promise, + ): Promise { + return this.telemetry.startActiveSpan( + "database.crud.route", + { + attributes: { + table_name: table, + operation, + "http.route": `/api/${this.name}${route}`, + }, + }, + async (span) => { + try { + await run(); + span.setAttribute("outcome", "success"); + } catch (error) { + span.setAttribute("outcome", readRouteOutcome(error)); + throw error; + } finally { + span.end(); + } + }, + ); + } } /** Create a typed database plugin registration for a finalized schema. */ diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts index 68fdcef63..3dbf4806f 100644 --- a/packages/appkit/src/plugins/database/defaults.ts +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -10,3 +10,30 @@ export const databaseWriteDefaults: PluginExecuteConfig = { cache: { enabled: false }, retry: { enabled: false }, }; + +// Generated-read limits. The wire caps a typed caller shares with HTTP live in +// `database/contract`; these bound only what an untrusted request may ask for. + +// Request decoding: rejected before any database work is scheduled. +/** Max encoded size of a generated read query string. */ +export const MAX_QUERY_BYTES = 8 * 1024; +/** Max nesting depth of `and`/`or` groups in one generated filter. */ +export const MAX_WHERE_DEPTH = 5; +/** Max column conditions across one generated filter. */ +export const MAX_WHERE_CONDITIONS = 50; +/** Max members in one `and`/`or` group. */ +export const MAX_GROUP_ITEMS = 20; +/** Max columns one generated `order` may name. */ +export const MAX_ORDER_FIELDS = 10; +/** Max accepted generated `offset`. */ +export const MAX_OFFSET = 10_000; +/** Max rows one generated read may materialize across its include tree. */ +export const MAX_MATERIALIZED_NODES = 10_000; + +// Response shaping: bounds trusted output on its way to the wire. +/** Max nesting depth of read-serializer output. */ +export const MAX_SERIALIZED_DEPTH = 32; +/** Max node count of read-serializer output. */ +export const MAX_SERIALIZED_NODES = 10_000; +/** Max UTF-8 byte length of one generated read response body. */ +export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; diff --git a/packages/appkit/src/plugins/database/entity-types.ts b/packages/appkit/src/plugins/database/entity-types.ts index 927236870..6c8ad0642 100644 --- a/packages/appkit/src/plugins/database/entity-types.ts +++ b/packages/appkit/src/plugins/database/entity-types.ts @@ -57,10 +57,14 @@ type RelationTargetFor< ? Target : never; +/** How many further relation edges may nest inside one include level. */ +type RemainingEdges = 0 | 1; + export type IncludeOptionsFor< Registry extends RegistryShape, K extends EntityNameFor, Many extends boolean, + Remaining extends RemainingEdges = 0, > = { readonly select?: readonly (keyof RowOfFor & string)[]; readonly where?: FiltersOfFor; @@ -69,11 +73,15 @@ export type IncludeOptionsFor< >; } & (Many extends true ? { readonly limit?: number } - : { readonly limit?: never }); + : { readonly limit?: never }) & + (Remaining extends 1 + ? { readonly include?: IncludeArgFor } + : { readonly include?: never }); export type IncludeArgFor< Registry extends RegistryShape, K extends EntityNameFor, + Remaining extends RemainingEdges = 1, > = { readonly [Relation in keyof IncludesOfFor]?: | boolean @@ -84,12 +92,13 @@ export type IncludeArgFor< many: infer Many extends boolean; } ? Many - : false + : false, + Remaining >; }; // Explicit relation projections use trusted rows; implicit projections stay public. -type IncludedRowFor< +type SelectedRowFor< Registry extends RegistryShape, K extends EntityNameFor, Config, @@ -100,6 +109,14 @@ type IncludedRowFor< ? Pick, Columns[number]> : PublicRowOfFor; +type IncludedRowFor< + Registry extends RegistryShape, + K extends EntityNameFor, + Config, +> = Config extends { readonly include: infer Nested } + ? SelectedRowFor & IncludedResultFor + : SelectedRowFor; + type IncludedResultFor< Registry extends RegistryShape, K extends EntityNameFor, diff --git a/packages/appkit/src/plugins/database/tests/crud-span.test.ts b/packages/appkit/src/plugins/database/tests/crud-span.test.ts new file mode 100644 index 000000000..7536282b9 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/crud-span.test.ts @@ -0,0 +1,175 @@ +import type { Span, SpanOptions } from "@opentelemetry/api"; +import type { Request, RequestHandler, Response } from "express"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { defineSchema, id, text } from "../../../database/schema-builder"; +import type { ITelemetry } from "../../../telemetry"; + +const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); +vi.mock("../lifecycle", () => ({ + createDatabaseState: mocks.createDatabaseState, +})); + +import { DatabasePlugin } from "../database"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text(), + token: text().private(), + }); + return { users }; +}); + +interface RecordedSpan { + readonly name: string; + readonly attributes: Record; +} + +function fakeTelemetry(spans: RecordedSpan[]): ITelemetry { + return { + startActiveSpan: ( + name: string, + options: SpanOptions, + run: (span: Span) => Promise, + ) => { + const attributes: Record = { ...options.attributes }; + spans.push({ name, attributes }); + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + end: vi.fn(), + }; + return run(span as unknown as Span); + }, + } as unknown as ITelemetry; +} + +const rows = [{ id: 1, name: "Ada", token: "secret" }]; + +function entity(overrides: Record = {}) { + const chain: Record = { + where: () => chain, + order: () => chain, + select: () => chain, + include: () => chain, + limit: () => chain, + offset: () => chain, + toArray: async () => rows, + find: async () => rows[0], + ...overrides, + }; + return chain; +} + +async function mount(exports: Record) { + const spans: RecordedSpan[] = []; + mocks.createDatabaseState.mockResolvedValue({ + pool: { end: async () => undefined }, + exports, + deactivate: vi.fn(), + }); + const plugin = new DatabasePlugin({ schema, crudRoutes: true }); + (plugin as unknown as { telemetry: ITelemetry }).telemetry = + fakeTelemetry(spans); + await plugin.setup(); + + const handlers = new Map(); + plugin.injectRoutes({ + get: (path: string, handler: RequestHandler) => { + handlers.set(path, handler); + }, + } as unknown as Parameters[0]); + return { plugin, spans, handlers }; +} + +function fakeResponse(): Response { + const res = { + headersSent: false, + status: () => res, + type: () => res, + setHeader: () => res, + send: () => res, + }; + return res as unknown as Response; +} + +async function call( + handler: RequestHandler | undefined, + url: string, + params: Record = {}, +): Promise { + const request = { originalUrl: url, url, params } as unknown as Request; + await (handler as (req: Request, res: Response) => Promise)( + request, + fakeResponse(), + ); +} + +let mounted: Awaited>; +beforeEach(async () => { + mocks.createDatabaseState.mockReset(); + mounted = await mount({ users: entity() }); +}); + +describe("generated read spans", () => { + test("record only allowlisted, low-cardinality attributes", async () => { + await call( + mounted.handlers.get("/users"), + `/users?where=${encodeURIComponent('{"name":"Ada"}')}&limit=5`, + ); + await call(mounted.handlers.get("/users/:id"), "/users/1", { id: "1" }); + + expect(mounted.spans).toEqual([ + { + name: "database.crud.route", + attributes: { + table_name: "users", + operation: "list", + "http.route": "/api/database/users", + outcome: "success", + }, + }, + { + name: "database.crud.route", + attributes: { + table_name: "users", + operation: "detail", + "http.route": "/api/database/users/:id", + outcome: "success", + }, + }, + ]); + }); + + test("classify failures without carrying their cause", async () => { + const failing = await mount({ + users: entity({ + toArray: async () => { + throw new Error("select * from users where token = 'secret'"); + }, + find: async () => null, + }), + }); + await call(failing.handlers.get("/users"), "/users"); + await call(failing.handlers.get("/users"), "/users?limit=abc"); + await call(failing.handlers.get("/users/:id"), "/users/1", { id: "1" }); + + expect(failing.spans.map((span) => span.attributes.outcome)).toEqual([ + "failed", + "rejected", + "not_found", + ]); + const serialized = JSON.stringify(failing.spans); + expect(serialized).not.toContain("select"); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("INTERNAL"); + expect(serialized).not.toContain("Ada"); + }); + + test("stop serving rows once the plugin drains", async () => { + await mounted.plugin.shutdown(); + await call(mounted.handlers.get("/users"), "/users"); + expect(mounted.spans.at(-1)?.attributes.outcome).toBe("failed"); + }); +}); diff --git a/packages/appkit/src/plugins/database/tests/entity-types.test.ts b/packages/appkit/src/plugins/database/tests/entity-types.test.ts index 6b90dff3d..e341a6357 100644 --- a/packages/appkit/src/plugins/database/tests/entity-types.test.ts +++ b/packages/appkit/src/plugins/database/tests/entity-types.test.ts @@ -73,7 +73,7 @@ interface TestRegistry { insert: { text: string; internal: string }; update: { text?: string; internal?: string }; filters: { text?: TextFilter }; - includes: Record; + includes: { author: { to: "users"; many: false } }; hasPrimaryKey: true; }; events: { @@ -167,6 +167,12 @@ describe("typed database entity contract", () => { TestRegistry["notes"]["publicRow"], { author: { select: readonly ["token"] } } >; + type NestedAuthor = EntityResultFor< + TestRegistry, + "notes", + TestRegistry["notes"]["publicRow"], + { comments: { include: { author: true } } } + >; expectTypeOf>().toEqualTypeOf< TestRegistry["users"]["publicRow"] @@ -180,6 +186,9 @@ describe("typed database entity contract", () => { expectTypeOf>().toEqualTypeOf<{ token: string; }>(); + expectTypeOf< + NonNullable + >().toEqualTypeOf(); }); it("omits false relations and replaces successive include configurations", () => { @@ -206,6 +215,7 @@ describe("typed database entity contract", () => { author: { where: { name: "Ada" }, order: { name: "asc" } }, }); notes.include({ comments: { limit: 5 } }); + notes.include({ comments: { include: { author: true } } }); // @ts-expect-error direct null is not a filter shorthand notes.where({ body: null }); @@ -215,14 +225,16 @@ describe("typed database entity contract", () => { notes.where({ rank: { like: "1" } }); // @ts-expect-error JSON and unknown fields are not filterable notes.where({ payload: { eq: {} } }); + // @ts-expect-error a filter names columns on this entity, never relations + notes.where({ comments: { some: { text: "a" } } }); // @ts-expect-error unknown relations fail closed notes.include({ unknown: true }); // @ts-expect-error unknown relation options fail closed notes.include({ author: { offset: 1 } }); // @ts-expect-error to-one includes cannot be limited notes.include({ author: { limit: 1 } }); - // @ts-expect-error nested includes are not part of one-edge options - notes.include({ comments: { include: { author: true } } }); + // @ts-expect-error includes stop after the second relation edge + notes.include({ comments: { include: { author: { include: {} } } } }); } }); diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index 1e0704d16..53c3a10f8 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -1,5 +1,6 @@ +import type express from "express"; import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; -import { defineSchema } from "../../../database/schema-builder"; +import { defineSchema, fk, id, text } from "../../../database/schema-builder"; const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn() })); vi.mock("../lifecycle", () => ({ @@ -19,6 +20,46 @@ function deferred() { } const schema = defineSchema(() => ({})); +const routedSchema = defineSchema((builder) => { + const users = builder.table("users", { id: id(), name: text() }); + const notes = builder.table("notes", { + id: id(), + authorId: fk(() => users.id), + }); + const events = builder.table("events", { message: text() }); + return { users, notes, events }; +}); + +function fakeRouter() { + const routes: string[] = []; + const record = + (method: string) => + (path: string): void => { + routes.push(`${method} ${path}`); + }; + return { + routes, + router: { + get: record("get"), + post: record("post"), + patch: record("patch"), + delete: record("delete"), + put: record("put"), + } as unknown as express.Router, + }; +} + +async function registerRoutes( + config: ConstructorParameters>[0], +) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin(config); + await plugin.setup(); + const { router, routes } = fakeRouter(); + plugin.injectRoutes(router); + return { plugin, routes }; +} + function candidate(marker = "one") { let active = true; const end = vi.fn<() => Promise>(async () => undefined); @@ -139,6 +180,95 @@ describe("DatabasePlugin", () => { await expect(plugin.shutdown()).rejects.toBe(error); }); + test("registers no generated routes unless they are turned on", async () => { + for (const crudRoutes of [undefined, false] as const) { + const { routes } = await registerRoutes({ + schema: routedSchema, + crudRoutes, + }); + expect(routes).toEqual([]); + } + }); + + test("registers reads for every table or an explicit subset", async () => { + const assertNames = () => { + database({ schema: routedSchema, crudRoutes: { tables: ["notes"] } }); + database({ + schema: routedSchema, + hooks: { notes: { serialize: (row) => row } }, + }); + database({ + schema: routedSchema, + // @ts-expect-error only a declared table can be exposed + crudRoutes: { tables: ["missing"] }, + }); + database({ + schema: routedSchema, + // @ts-expect-error only a declared table can shape its own responses + hooks: { missing: { serialize: (row) => row } }, + }); + }; + void assertNames; + + const all = await registerRoutes({ + schema: routedSchema, + crudRoutes: true, + }); + expect(all.routes).toEqual([ + "get /users", + "get /users/:id", + "get /notes", + "get /notes/:id", + // A table without a primary key cannot address a single row. + "get /events", + ]); + expect(all.plugin.getEndpoints()).toMatchObject({ + "users.list": "/api/database/users", + "users.detail": "/api/database/users/:id", + }); + + const subset = await registerRoutes({ + schema: routedSchema, + crudRoutes: { tables: ["notes"] }, + }); + expect(subset.routes).toEqual(["get /notes", "get /notes/:id"]); + }); + + test("fails setup on an exposure list it cannot honor", async () => { + for (const tables of [["missing"], ["users", "users"], "users"]) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ + schema: routedSchema, + crudRoutes: { tables } as unknown as { tables: ["users"] }, + }); + await expect(plugin.setup()).rejects.toMatchObject({ + category: "SETUP_FAILED", + }); + } + }); + + test("refuses to route names it cannot serve unambiguously", async () => { + const unsafe = [ + defineSchema((builder) => ({ + users: builder.table("users", { id: id() }), + Users: builder.table("Users", { id: id() }), + })), + defineSchema((builder) => ({ + _hidden: builder.table("_hidden", { id: id() }), + })), + ]; + for (const unsafeSchema of unsafe) { + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ + schema: unsafeSchema, + crudRoutes: true, + }); + await expect(plugin.setup()).rejects.toMatchObject({ + category: "SETUP_FAILED", + }); + } + }); + test("isolates plugin instances and drains their exports independently", async () => { const one = candidate("one"); const two = candidate("two"); diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts index 1cffbbb1d..a4d0a09bf 100644 --- a/packages/appkit/src/plugins/database/types.ts +++ b/packages/appkit/src/plugins/database/types.ts @@ -1,6 +1,50 @@ import type { Schema } from "../../database/schema-builder"; +/** Table names declared by one finalized schema. */ +export type SchemaTableName = Extract< + keyof TSchema["$tables"], + string +>; + +/** + * Generated read exposure: off by default, every table, or an explicit list. + * + * Reads run as the app's service principal and apply no per-user filter, so an + * enabled table is readable by anyone the app admits. Enabling a table also + * makes it includable from its neighbours; a relation whose target stays off + * cannot be included, which keeps one table's data behind one decision. + * + * Text filters accept caller-supplied `like`/`ilike` patterns, and this beta + * adds no statement cancellation below the connector, so an expensive pattern + * runs to completion while holding its pooled connection. + */ +export type CrudRoutesConfig = + | boolean + | { readonly tables: readonly SchemaTableName[] }; + +/** Which entity and generated operation produced the row being shaped. */ +export interface ReadSerializerContext { + readonly entity: string; + readonly operation: "list" | "detail"; +} + +/** + * Shape one already private-safe row before it reaches the wire. A `Promise` + * is not assignable to the return type, so an async callback fails to compile: + * serializers run inside the response path and must not add latency there. + */ +export type ReadSerializer = ( + row: Record, + context: ReadSerializerContext, +) => Record; + /** Configuration for one schema-bound DatabasePlugin instance. */ export type IDatabaseConfig = { readonly schema: TSchema; + readonly crudRoutes?: CrudRoutesConfig; + readonly hooks?: { + readonly [TTable in SchemaTableName]?: { + readonly serialize?: ReadSerializer; + }; + }; };