diff --git a/.changeset/projection-aware-synchronous-mapping.md b/.changeset/projection-aware-synchronous-mapping.md new file mode 100644 index 00000000..647a4c89 --- /dev/null +++ b/.changeset/projection-aware-synchronous-mapping.md @@ -0,0 +1,23 @@ +--- +"@cleverbrush/mapper": minor +"@cleverbrush/knex-schema": minor +"@cleverbrush/orm": minor +"@cleverbrush/schema": minor +"@cleverbrush/di": minor +--- + +Add opt-in immutable, detached PostgreSQL reads with projection-aware runtime +schemas, precise decimal/bigint decoding, nested relation graphs and explicit +STI/CTI branch schemas. Reuse result schemas directly in separately defined +application mappings without duplicating projection schemas. + +Add `getSyncMapper()` with synchronous eligibility inferred through the existing +`configure()` API, completeness checks, nested mapping propagation and runtime +thenable guards. Existing queries and asynchronous mapping behavior are unchanged. + +Add application-agnostic typed metadata extension methods that retain metadata +through immutable chains. Use these in database extensions without modifying +global schema prototypes. + +Preserve typed function-schema compatibility in dependency injection when a +function declares its return schema. diff --git a/docs/assets/projection-read-schemas.png b/docs/assets/projection-read-schemas.png new file mode 100644 index 00000000..399e7888 Binary files /dev/null and b/docs/assets/projection-read-schemas.png differ diff --git a/docs/assets/projection-sync-mapper.png b/docs/assets/projection-sync-mapper.png new file mode 100644 index 00000000..389538af Binary files /dev/null and b/docs/assets/projection-sync-mapper.png differ diff --git a/libs/benchmarks/src/mapper.bench.ts b/libs/benchmarks/src/mapper.bench.ts new file mode 100644 index 00000000..b6a5248e --- /dev/null +++ b/libs/benchmarks/src/mapper.bench.ts @@ -0,0 +1,44 @@ +import { array, number, object, string } from '@cleverbrush/schema'; +import { bench, describe } from 'vitest'; +import { mapper } from '../../mapper/src/index.js'; + +const Source = object({ id: number(), name: string() }); +const Target = object({ id: number(), label: string() }); +const Parent = object({ id: number(), children: array(Source) }); +const ParentDto = object({ id: number(), children: array(Target) }); +const registry = mapper() + .configure(Source, Source, m => m) + .configure(Source, Target, m => + m.for(t => t.label).compute(s => s.name.toUpperCase()) + ) + .configure(Parent, ParentDto, m => m); +const sourceRows = Array.from({ length: 1000 }, (_, id) => ({ + id, + name: `user-${id}` +})); +const graphRows = Array.from({ length: 100 }, (_, id) => ({ + id, + children: sourceRows.slice(id, id + 10) +})); + +for (const [name, source, target, rows] of [ + ['flat copy / 1000 rows', Source, Source, sourceRows], + ['computed / 1000 rows', Source, Target, sourceRows], + ['nested arrays / 100 parents', Parent, ParentDto, graphRows] +] as const) { + // Set-up is intentionally outside the timed function. This is mapper overhead, + // not SQL/network latency or schema-construction performance. + const sync = (registry.getSyncMapper as any)(source, target); + const async = (registry.getMapper as any)(source, target); + const expected = JSON.stringify(rows.map(sync)); + if (JSON.stringify(await Promise.all(rows.map(async))) !== expected) + throw new Error('Benchmark variants must produce identical values'); + describe(`Prepared mapper: ${name}`, () => { + bench('synchronous map', () => { + rows.map(sync); + }); + bench('async Promise.all', async () => { + await Promise.all(rows.map(async)); + }); + }); +} diff --git a/libs/di/src/ServiceCollection.ts b/libs/di/src/ServiceCollection.ts index 0784b381..4c0cf780 100644 --- a/libs/di/src/ServiceCollection.ts +++ b/libs/di/src/ServiceCollection.ts @@ -260,7 +260,16 @@ export class ServiceCollection { */ public addSingletonFromSchema< TTargetSchema extends SchemaBuilder, - TFuncSchema extends FunctionSchemaBuilder + TFuncSchema extends FunctionSchemaBuilder< + any, + any, + any, + any, + any, + any, + any, + any + > >( targetSchema: TTargetSchema, funcSchema: TFuncSchema, @@ -295,7 +304,16 @@ export class ServiceCollection { */ public addScopedFromSchema< TTargetSchema extends SchemaBuilder, - TFuncSchema extends FunctionSchemaBuilder + TFuncSchema extends FunctionSchemaBuilder< + any, + any, + any, + any, + any, + any, + any, + any + > >( targetSchema: TTargetSchema, funcSchema: TFuncSchema, @@ -330,7 +348,16 @@ export class ServiceCollection { */ public addTransientFromSchema< TTargetSchema extends SchemaBuilder, - TFuncSchema extends FunctionSchemaBuilder + TFuncSchema extends FunctionSchemaBuilder< + any, + any, + any, + any, + any, + any, + any, + any + > >( targetSchema: TTargetSchema, funcSchema: TFuncSchema, @@ -416,7 +443,16 @@ export class ServiceCollection { #addFromSchema< TTargetSchema extends SchemaBuilder, - TFuncSchema extends FunctionSchemaBuilder + TFuncSchema extends FunctionSchemaBuilder< + any, + any, + any, + any, + any, + any, + any, + any + > >( targetSchema: TTargetSchema, funcSchema: TFuncSchema, @@ -451,7 +487,7 @@ export class ServiceCollection { * Used internally to type-check `addSingletonFromSchema`-style registrations. */ type FuncSchemaParameters< - T extends FunctionSchemaBuilder + T extends FunctionSchemaBuilder > = T extends FunctionSchemaBuilder< any, @@ -459,7 +495,9 @@ type FuncSchemaParameters< any, any, any, - infer TParams extends SchemaBuilder[] + infer TParams extends SchemaBuilder[], + any, + any > ? { [K in keyof TParams]: InferType } : never; diff --git a/libs/di/src/ServiceProvider.ts b/libs/di/src/ServiceProvider.ts index 26b335e0..63816393 100644 --- a/libs/di/src/ServiceProvider.ts +++ b/libs/di/src/ServiceProvider.ts @@ -193,7 +193,16 @@ export class ServiceProvider implements IServiceProvider { * @see {@link FunctionSchemaBuilder.hasReturnType} */ public invoke< - TFuncSchema extends FunctionSchemaBuilder + TFuncSchema extends FunctionSchemaBuilder< + any, + any, + any, + any, + any, + any, + any, + any + > >( funcSchema: TFuncSchema, implementation: InferType diff --git a/libs/di/src/ServiceScope.ts b/libs/di/src/ServiceScope.ts index 87c8b087..d55622eb 100644 --- a/libs/di/src/ServiceScope.ts +++ b/libs/di/src/ServiceScope.ts @@ -305,7 +305,16 @@ export class ScopedServiceProvider implements IServiceProvider { * @see {@link ServiceProvider.invoke} */ public invoke< - TFuncSchema extends FunctionSchemaBuilder + TFuncSchema extends FunctionSchemaBuilder< + any, + any, + any, + any, + any, + any, + any, + any + > >( funcSchema: TFuncSchema, implementation: InferType diff --git a/libs/knex-schema/COMPOSABLE_QUERIES.md b/libs/knex-schema/COMPOSABLE_QUERIES.md deleted file mode 100644 index e86394bb..00000000 --- a/libs/knex-schema/COMPOSABLE_QUERIES.md +++ /dev/null @@ -1,188 +0,0 @@ -# Composable read queries - -These APIs are additive. Existing single-schema selectors, nested eager loading, -legacy aggregate methods, and single-column cursor calls remain available. - -## Typed flat joins - -```ts -import { alias, eq, query } from '@cleverbrush/knex-schema'; - -const task = alias(TaskSchema, 'task'); -const owner = alias(UserSchema, 'owner'); -const rows = await query(knex, task) - .leftJoin(owner, t => eq(t.task.ownerId, t.owner.id)) - .where(t => t.task.projectId, projectId) - .orderBy(t => t.task.id, 'desc') - .select(t => ({ id: t.task.id, ownerName: t.owner.name })); -// ownerName includes null because owner is left-joined. -``` - -Aliases do not mutate schemas. Each source retains default scopes and soft-delete -filters; filtering the right-hand source does not turn a left join into an inner -join. `createQuery(knex)` also accepts aliased schemas. `eq`, `and`, and `or` -compose column-based join conditions. Duplicate aliases are errors. - -Both `and` and `or` accept multiple predicates and can be nested. Each group -becomes a parenthesized SQL expression, preserving the intended precedence: - -```ts -import { alias, and, eq, or, query } from '@cleverbrush/knex-schema'; - -const rows = await query(knex, alias(TaskSchema, 'task')) - .join(alias(UserSchema, 'owner'), t => or( - eq(t.task.ownerId, t.owner.id), - and( - eq(t.task.approverId, t.owner.id), - or( - eq(t.task.teamId, t.owner.teamId), - eq(t.task.creatorId, t.owner.id) - ) - ) - )) - .select(t => ({ id: t.task.id, ownerName: t.owner.name })); -``` - -This example assumes the illustrated ID properties exist on the schemas. -Empty `and()`/`or()` groups are rejected. Predicates describe SQL; they do not -execute a JavaScript callback for each returned row. - -`isSqlIdentifier(value)` is an exported type guard used by `alias()`. It accepts -one ASCII identifier (`task_owner2`), not qualified names (`public.tasks`), quoted -names, whitespace or non-string values. This deliberately conservative format -does not describe every valid PostgreSQL identifier and does not replace Knex -identifier quoting. Existing table/column metadata APIs are not restricted by it. - -The read-only aliased builder requires an explicit, non-empty projection and -supports `where`, `whereIn`, `whereNull`, `whereNotNull`, `orderBy`, `orderByRaw`, -`groupBy`, `having`, `limit`, `offset`, `first`, `execute`, `transacting`, and -awaiting the query. `apply`/`toKnexQuery` remain raw escape hatches whose effects -on result shape/cardinality are the caller's responsibility. Values remain bound -and identifiers quoted. Flat collection joins can repeat parents; they do not -deduplicate or fetch one related row at a time. Choose ORM eager loading for -nested related objects instead. - -For reusable connection/transaction handling, ordinary and aliased schemas retain -the same inference through `createQuery(knex)`, `withTransaction(trx)`, and -`transaction(callback)`. Only ordinary-schema calls accept a custom Knex base -query. These APIs and their JSDoc are also available through `@cleverbrush/orm`. - -## Aggregates with optional output schemas - -```ts -import { aggregate, number } from '@cleverbrush/knex-schema'; - -const count = await query(knex, TaskSchema).countValue(); // number -const total = await query(knex, TaskSchema) - .sumValue(t => t.estimate); // string | null - -const grouped = await query(knex, TaskSchema) - .groupBy(t => t.ownerId) - .select(t => ({ - ownerId: t.ownerId, - count: aggregate.count(), - total: aggregate.sum(t.estimate) - })); - -// Explicit floating-point conversion when appropriate for your domain. -const average = await query(knex, TaskSchema) - .avgValue(t => t.estimate, { - output: number().isFloat().coerce().nullable() - }); // number | null -``` - -| API | Default result | -| --- | --- | -| `countValue(options?)`, `countValue(column, options?)` | Safe number; counts rows or non-null values | -| `countDistinctValue(column, options?)` | Safe number | -| `sumValue(column, options?)`, `avgValue(column, options?)` | Database numeric text, or null | -| `minValue(column, options?)`, `maxValue(column, options?)` | Column's database representation, or null | - -The corresponding `aggregate.count/countDistinct/sum/avg/min/max` expressions -work in ordinary and aliased object projections. Expression options are the -second argument: `aggregate.count(undefined, { output: schema })` customizes -`COUNT(*)`. Aliased `having` also accepts aggregate expressions; its comparisons -use native SQL values, not decoded/text-formatted values. - -Counts reject malformed results and integers outside JavaScript's safe range. -Sum/average preserve PostgreSQL's result as text without changing global driver -parsers. This does not make floating-point source columns exact. Numeric/decimal/ -bigint extrema retain strings; because existing SQL overrides are not fully -represented in schema types, numeric extrema are conservatively typed as -`number | string | null`. Date extrema return `Date | null`. - -An optional **output schema replaces the default decoder**. Its synchronous -`parse` receives the raw driver value, including null, before default conversion. -Its schema output type determines the result, including nullable/optional -modifiers. PostgreSQL counts normally reach it as strings, so a number schema -needs explicit coercion. Validation failures reject the query. Custom parsers -may return bigint/domain decimal types; JSON serialization remains application -code. With custom parsers, the application owns their conversion/precision policy. - -Scalar helpers clone the source, ignore its limit/offset/order, and retain -filters, semantic joins, scopes, and transactions. They reject grouped, HAVING, -distinct, and already aggregated queries; use aggregate projections for those. -An empty scalar count is zero; other empty/all-null aggregates are null. An -empty grouped query returns no rows. Legacy `.count()`, `.sum()`, etc. keep their -existing behavior and signatures. - -## Eager-loading order - -```ts -const tasks = await query(knex, TaskSchema) - .orderBy(t => t.createdAt, 'desc') - .orderBy(t => t.id, 'desc') - .joinMany({ - foreignSchema: NoteSchema, - localColumn: t => t.id, - foreignColumn: t => t.taskId, - as: 'notes' - }) - .limit(20); -``` - -The final SELECT retains parent order, including bound raw ordering expressions. -Limits/offsets select parents, not expanded child rows. Internal fields are -removed from mapped results. Parent ordering, child ordering, and relation -filtering are separate operations. Add a unique tie-breaker for deterministic -ordering of tied parents. - -## Composite cursors - -```ts -const page = await query(knex, TaskSchema) - .where(t => t.projectId, projectId) - .select(t => ({ id: t.id, title: t.title })) - .paginateAfter({ - cursor: previousPage?.nextCursor, - limit: 50, - orderBy: [ - { column: t => t.createdAt, direction: 'desc' }, - { column: t => t.id, direction: 'desc' } - ] - }); -// { data, nextCursor: string | null, hasMore: boolean } -``` - -The opt-in `orderBy` form controls the full sort, replacing earlier ordering. -Mixed directions, projections, and eager loading are supported. Sort fields must -be non-null scalar columns and include a schema-declared primary/unique key. -Required eager relations filter parents before the cursor page limit is applied. -Offsets, flat joins, distinct/grouped/aggregate queries, malformed cursors, and -cursors from a different table/order are rejected. The source builder is not -mutated. The original `column`/`direction` form is unchanged. - -Cursors are versioned opaque strings preserving database timestamp precision -and numeric values without Date/number round-trips. Clients must not parse them. -Reapply access filters on every request and discard cursors when filters change: -they are positions, not authorization. They do not provide snapshot isolation; -sort-key updates can move records across the cursor. Sequential paging does not -support arbitrary page-number jumps; counts remain separate queries. Indexes -and representative query-plan measurements remain application work. - -## Integration verification - -From the repository root, run `npm run test:queries:integration` with -`QUERY_TEST_DATABASE_URL` pointing to a dedicated PostgreSQL test database. -The suite creates/drops only its randomly named fixture tables and fails if the -URL is missing. CI runs PostgreSQL 16 integration tests alongside unit/type tests. diff --git a/libs/knex-schema/README.md b/libs/knex-schema/README.md index 0b5421de..0811875a 100644 --- a/libs/knex-schema/README.md +++ b/libs/knex-schema/README.md @@ -462,9 +462,387 @@ The schema's `hasColumnName()` metadata is used to map `firstName` → `first_na --- +## Composable read queries + +Use aliases for flat results, aggregate expressions for summaries, and composite +cursors for deterministic sequential paging. Nested eager loading returns graphs. + +### Typed flat joins + +```ts +import { alias, eq, query } from '@cleverbrush/knex-schema'; + +const task = alias(TaskSchema, 'task'); +const owner = alias(UserSchema, 'owner'); +const rows = await query(knex, task) + .leftJoin(owner, t => eq(t.task.ownerId, t.owner.id)) + .where(t => t.task.projectId, projectId) + .orderBy(t => t.task.id, 'desc') + .select(t => ({ id: t.task.id, ownerName: t.owner.name })); +// ownerName includes null because owner is left-joined. +``` + +Aliases do not mutate schemas. Each source retains default scopes and soft-delete +filters; filtering the right-hand source does not turn a left join into an inner +join. `createQuery(knex)` also accepts aliased schemas. `eq`, `and`, and `or` +compose column-based join conditions. Duplicate aliases are errors. + +Both `and` and `or` accept multiple predicates and can be nested. Each group +becomes a parenthesized SQL expression, preserving the intended precedence: + +```ts +import { alias, and, eq, or, query } from '@cleverbrush/knex-schema'; + +const rows = await query(knex, alias(TaskSchema, 'task')) + .join(alias(UserSchema, 'owner'), t => or( + eq(t.task.ownerId, t.owner.id), + and( + eq(t.task.approverId, t.owner.id), + or( + eq(t.task.teamId, t.owner.teamId), + eq(t.task.creatorId, t.owner.id) + ) + ) + )) + .select(t => ({ id: t.task.id, ownerName: t.owner.name })); +``` + +This example assumes the illustrated ID properties exist on the schemas. +Empty `and()`/`or()` groups are rejected. Predicates describe SQL; they do not +execute a JavaScript callback for each returned row. + +`isSqlIdentifier(value)` is an exported type guard used by `alias()`. It accepts +one ASCII identifier (`task_owner2`), not qualified names (`public.tasks`), quoted +names, whitespace or non-string values. This deliberately conservative format +does not describe every valid PostgreSQL identifier and does not replace Knex +identifier quoting. Existing table/column metadata APIs are not restricted by it. + +The read-only aliased builder requires an explicit, non-empty projection and +supports `where`, `whereIn`, `whereNull`, `whereNotNull`, `orderBy`, `orderByRaw`, +`groupBy`, `having`, `limit`, `offset`, `first`, `execute`, `transacting`, and +awaiting the query. `apply`/`toKnexQuery` remain raw escape hatches whose effects +on result shape/cardinality are the caller's responsibility. Values remain bound +and identifiers quoted. Flat collection joins can repeat parents; they do not +deduplicate or fetch one related row at a time. Choose ORM eager loading for +nested related objects instead. + +For reusable connection/transaction handling, ordinary and aliased schemas retain +the same inference through `createQuery(knex)`, `withTransaction(trx)`, and +`transaction(callback)`. Only ordinary-schema calls accept a custom Knex base +query. These APIs and their JSDoc are also available through `@cleverbrush/orm`. + +### Aggregates with optional output schemas + +```ts +import { aggregate, number } from '@cleverbrush/knex-schema'; + +const count = await query(knex, TaskSchema).countValue(); // number +const total = await query(knex, TaskSchema) + .sumValue(t => t.estimate); // string | null + +const grouped = await query(knex, TaskSchema) + .groupBy(t => t.ownerId) + .select(t => ({ + ownerId: t.ownerId, + count: aggregate.count(), + total: aggregate.sum(t.estimate) + })); + +// Explicit floating-point conversion when appropriate for your domain. +const average = await query(knex, TaskSchema) + .avgValue(t => t.estimate, { + output: number().isFloat().coerce().nullable() + }); // number | null +``` + +| API | Default result | +| --- | --- | +| `countValue(options?)`, `countValue(column, options?)` | Safe number; counts rows or non-null values | +| `countDistinctValue(column, options?)` | Safe number | +| `sumValue(column, options?)`, `avgValue(column, options?)` | Database numeric text, or null | +| `minValue(column, options?)`, `maxValue(column, options?)` | Column's database representation, or null | + +The corresponding `aggregate.count/countDistinct/sum/avg/min/max` expressions +work in ordinary and aliased object projections. Expression options are the +second argument: `aggregate.count(undefined, { output: schema })` customizes +`COUNT(*)`. Aliased `having` also accepts aggregate expressions; its comparisons +use native SQL values, not decoded/text-formatted values. + +Counts reject malformed results and integers outside JavaScript's safe range. +Sum/average preserve PostgreSQL's result as text without changing global driver +parsers. This does not make floating-point source columns exact. Numeric/decimal/ +bigint extrema retain strings; because existing SQL overrides are not fully +represented in schema types, numeric extrema are conservatively typed as +`number | string | null`. Date extrema return `Date | null`. + +An optional **output schema replaces the default decoder**. Its synchronous +`parse` receives the raw driver value, including null, before default conversion. +Its schema output type determines the result, including nullable/optional +modifiers. PostgreSQL counts normally reach it as strings, so a number schema +needs explicit coercion. Validation failures reject the query. Custom parsers +may return bigint/domain decimal types; JSON serialization remains application +code. With custom parsers, the application owns their conversion/precision policy. + +Scalar helpers clone the source, ignore its limit/offset/order, and retain +filters, semantic joins, scopes, and transactions. They reject grouped, HAVING, +distinct, and already aggregated queries; use aggregate projections for those. +An empty scalar count is zero; other empty/all-null aggregates are null. An +empty grouped query returns no rows. Legacy `.count()`, `.sum()`, etc. keep their +existing behavior and signatures. + +### Eager-loading order + +```ts +const tasks = await query(knex, TaskSchema) + .orderBy(t => t.createdAt, 'desc') + .orderBy(t => t.id, 'desc') + .joinMany({ + foreignSchema: NoteSchema, + localColumn: t => t.id, + foreignColumn: t => t.taskId, + as: 'notes' + }) + .limit(20); +``` + +The final SELECT retains parent order, including bound raw ordering expressions. +Limits/offsets select parents, not expanded child rows. Internal fields are +removed from mapped results. Parent ordering, child ordering, and relation +filtering are separate operations. Add a unique tie-breaker for deterministic +ordering of tied parents. + +### Composite cursors + +```ts +const page = await query(knex, TaskSchema) + .where(t => t.projectId, projectId) + .select(t => ({ id: t.id, title: t.title })) + .paginateAfter({ + cursor: previousPage?.nextCursor, + limit: 50, + orderBy: [ + { column: t => t.createdAt, direction: 'desc' }, + { column: t => t.id, direction: 'desc' } + ] + }); +// { data, nextCursor: string | null, hasMore: boolean } +``` + +The opt-in `orderBy` form controls the full sort, replacing earlier ordering. +Mixed directions, projections, and eager loading are supported. Sort fields must +be non-null scalar columns and include a schema-declared primary/unique key. +Required eager relations filter parents before the cursor page limit is applied. +Offsets, flat joins, distinct/grouped/aggregate queries, malformed cursors, and +cursors from a different table/order are rejected. The source builder is not +mutated. The original `column`/`direction` form is unchanged. + +Cursors are versioned opaque strings preserving database timestamp precision +and numeric values without Date/number round-trips. Clients must not parse them. +Reapply access filters on every request and discard cursors when filters change: +they are positions, not authorization. They do not provide snapshot isolation; +sort-key updates can move records across the cursor. Sequential paging does not +support arbitrary page-number jumps; counts remain separate queries. Indexes +and representative query-plan measurements remain application work. + +### Integration verification + +From the repository root, run `npm run test:queries:integration` with +`QUERY_TEST_DATABASE_URL` pointing to a dedicated PostgreSQL test database. +The suite creates/drops only its randomly named fixture tables and fails if the +URL is missing. CI runs PostgreSQL 16 integration tests alongside unit/type tests. + +## Projection-aware reads + +`withRowSchema()` is an **opt-in PostgreSQL read API**. Schema-aware queries are immutable, +detached read plans: capture the returned value when adding filters or includes. +They do not track entities, save changes, or run SQL when inspecting metadata. + +### Definitions can live in separate files + +```ts +// user-read.ts +import { createQuery, date, number, object, string } from '@cleverbrush/knex-schema'; +import { knex } from './database.js'; + +const UserTable = object({ + id: number().primaryKey(), name: string(), + lastSeen: date().optional().hasColumnName('last_seen'), + secret: string() +}).hasTableName('users'); + +export const userRead = createQuery(knex)(UserTable).withRowSchema() + .select(u => ({ id: u.id, name: u.name, lastSeen: u.lastSeen })); +export const UserRow = userRead.rowSchema; +``` + +```ts +// user-mapping.ts +import { date, number, object, string } from '@cleverbrush/schema'; +import { mapper } from '@cleverbrush/mapper'; +import { UserRow } from './user-read.js'; + +export const PublicUser = object({ + id: number(), name: string(), lastSeen: date().optional() +}); +export const toPublicUser = mapper().configure(UserRow, PublicUser, m => m + .for(t => t.lastSeen).compute(row => row.lastSeen ?? undefined)) + .getSyncMapper(UserRow, PublicUser); +``` + +```ts +// user-service.ts +import { userRead } from './user-read.js'; +import { toPublicUser } from './user-mapping.js'; + +export async function findPublicUser(id: number) { + const row = await userRead.where(u => u.id, id).first(); + return row === undefined ? undefined : toPublicUser(row); +} +``` + +Configure reusable mappings once. `where`, ordering, limits, offsets and +transaction clones retain the same `rowSchema` object. A different projection or +include produces a different schema. Independently created queries do not share +schema identity; the mapper registry keys mappings by schema identity. + +### What values does the schema describe? + +| Stored value | Decoded read value | +| --- | --- | +| Required integer/float | Finite JavaScript `number` | +| `number().bigint()` or `.decimal(p, s)` | Exact `string` | +| Optional or nullable SQL column | Present property containing its value or `null` | +| Date/timestamp | `Date`, including inside JSON relation graphs | +| Missing optional JSON property | Absent property, unlike SQL null | +| Missing optional object relation | `null` | +| Missing collection relation | `[]` | + +Exact numeric fields are cast to text **before** PostgreSQL JSON aggregation and +driver parsing. Converting a rounded JavaScript number to a string is not used. +Use the SQL-type builder methods to declare storage accurately; unknown SQL types +are rejected. A widened dynamic numeric SQL type has a conservative +`number | string` read type. Driver custom parsers must still honor the declared +representation, or decoding fails. + +Timezone-less SQL dates/timestamps are interpreted as UTC in this opt-in mode; +explicit offsets preserve their instant. Native date fields are projected as text +before decoding, so root and nested values do not depend on a driver's local-time +date parser. This is a deliberate opt-in convention, not a change to legacy reads. + +Input defaults, preprocessors and input-only validators are not replayed on stored +rows. Read schemas are structural output schemas. Optional schemas with input +defaults are rejected: `optional().default(...)` hides the storage requirement +in the inferred input/output type. Keep defaults in a separate input schema, +and use an accurately required/optional storage schema for these reads. +JavaScript `Date` has +millisecond precision: use explicit application representations if an API must +expose microseconds. Composite cursor tokens retain native timestamp precision +in private text columns, independently of the public date values. + +### Projections, aliases and aggregates + +```ts +const read = query(knex, alias(UserTable, 'user')).withRowSchema() + .leftJoin(alias(ProfileTable, 'profile'), t => eq(t.user.id, t.profile.userId)) + .select(t => ({ id: t.user.id, displayName: t.profile.displayName })); +// displayName is nullable even if ProfileTable declares it required. +const rows = await read; +read.rowSchema.validate(rows[0]); +``` + +Aliased reads require an explicit selection before execution or accessing +`rowSchema`. Declared named projections use `.projected('name')` on ordinary +readers. Typed aggregates work in object selections: count returns a safe number, +sum/average preserve exact text, and empty extrema/sums remain nullable. An explicit +Framework output schema replaces aggregate decoding and is parsed once; opaque +parser objects without schema introspection are rejected in this mode. + +### Nested graphs + +Declare relations on entities as usual, and return the child query from customizers: + +```ts +const read = db.projects.withRowSchema() + .select(p => ({ id: p.id, name: p.name })) + .include(r => r.tasks, tasks => tasks + .select(t => ({ title: t.title, createdAt: t.createdAt })) + .orderBy(t => t.id, 'desc').limit(3) + .include(r => r.notes, notes => notes.select(n => ({ body: n.body })))); +``` + +This executes one SQL statement. Child scopes, filters, ordering, projections and +limits apply independently per parent. Collections are not lazy-loaded. Required +belongs-to joins filter missing parents; `{ optional: true }` belongs-to and has-one +reads expose nullable objects. `joinOne(spec, customize)` and +`joinMany(spec, customize)` support explicit local/foreign keys without declaring +an entity relation. Use their typed child customizer for ordering/projections; +raw `foreignQuery` and post-load `mappers` cannot supply trustworthy row metadata. + +Numbered `.paginate({ page, pageSize })` performs a count and a page query. +`.paginateAfter({ limit, cursor, orderBy })` on ordinary readers reuses lossless +composite cursors, including unique-key validation. Cursor fields need not be in +the public projection. Reapply authorization filters for every page; a cursor is +not a permission or a transaction snapshot. Grouped/aggregate cursor reads are +rejected. + +### Polymorphic graphs and explicit dispatch + +STI/CTI entities produce a genuine union `rowSchema` and an object schema for each +discriminator in `variantRowSchemas`. All declared branch bodies are selected by +default. Use `selectVariants(['photo'])` to narrow the returned union, or +`forVariant()` to project a branch or load its relations: + +```ts +const read = db.assets.withRowSchema() + .forVariant('photo', q => q.include(r => r.tags)) + .forVariant('text', q => q.select(a => ({ id: a.id, kind: a.kind, body: a.body }))); + +const photoSource = read.variantRowSchemas.photo; +const textSource = read.variantRowSchemas.text; +const registry = mapper() + .configure(photoSource, PublicPhoto, configurePhoto) + .configure(textSource, PublicText, configureText); +const photo = registry.getSyncMapper(photoSource, PublicPhoto); +const text = registry.getSyncMapper(textSource, PublicText); +const result = (await read).map(row => row.kind === 'photo' ? photo(row) : text(row)); +``` + +Keep the original discriminator in branch projections. Framework intentionally +does not choose a public DTO or automatically dispatch a union mapper. Polymorphic +readers also work inside ordinary includes. A missing required CTI body or unknown +discriminator fails decoding. Explicit `allowOrphan: true` makes body fields +nullable. Union ordering/pagination applies to the combined branches, not to each +branch separately. Use `forVariant()` for branch-specific filtering/includes. + +### API boundaries + +- Enter read mode **before** legacy select/include/join, ordering/pagination, + raw callbacks or variant-specific operations. Continue configuration on the + immutable reader. Default scopes may filter rows but must not preselect, + order or paginate them. +- The opt-in surface is deliberately read-only. It is not a replacement for + entity writes or tracked queries, and has no raw SQL shape escape hatch. +- Reads are PostgreSQL-oriented; this does not promise equivalent JSON/CTI SQL + behavior on other Knex dialects. +- Declared graph metadata must match storage. Unsupported opaque shapes throw + rather than pretending an entity schema describes their output. Explicit + TypeScript assertions such as `hasType()` remain the caller's responsibility. +- Ordinary queries are mutable and retain their driver-value behavior. + Read mode is opt-in; it does not change application nulls or numeric values + globally. +- `getSyncMapper()` accepts only complete mappings with synchronous final steps + and nested mappings. It never probes callbacks. Known async mappings are + rejected; a disguised thenable throws when invoked. Keep `getMapper()` for + asynchronous work. Fetch/enrich data explicitly before pure mapping. + +Run the mapper-only benchmark with +`npm run bench -- --project benchmarks mapper.bench.ts`. It compares prepared +sync/async functions with identical rows and excludes SQL/network time. Do not +interpret its result as an endpoint-latency guarantee. + ## API Reference -See [Composable read queries](./COMPOSABLE_QUERIES.md) for typed flat joins with +See [Composable read queries](#composable-read-queries) for typed flat joins with `alias`, all aggregate families with optional output schemas, preserved eager-load ordering, and multi-column cursor pagination. These APIs preserve existing calls and include runtime, type, and PostgreSQL integration coverage. diff --git a/libs/knex-schema/integration/queries.test.ts b/libs/knex-schema/integration/queries.test.ts index dcc6ea59..0609dd02 100644 --- a/libs/knex-schema/integration/queries.test.ts +++ b/libs/knex-schema/integration/queries.test.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { mapper } from '@cleverbrush/mapper'; import { aggregate, alias, @@ -71,6 +72,151 @@ const taskEntity = defineEntity(Task) t => t.taskId ); const db = createDb(knex, { tasks: taskEntity }); + +it('transaction clones retain row schemas and raw-query safety guards', async () => { + const read = db.tasks.withRowSchema().select(t => ({ title: t.title })); + await knex.transaction(async trx => { + const inTransaction = read.transacting(trx); + expect(inTransaction.rowSchema).toBe(read.rowSchema); + expect(await inTransaction.where(t => t.id, 104).first()).toEqual( + await read.where(t => t.id, 104).first() + ); + expect(() => + query(knex, Task) + .apply(q => { + q.whereRaw('true'); + }) + .transacting(trx) + .withRowSchema() + ).toThrow(/before raw/); + }); +}); + +it('schema-aware reads stay detached in a tracked context', async () => { + const tracked = createDb(knex, { tasks: taskEntity }, { tracking: true }); + const calls: unknown[] = []; + const listener = (sql: unknown) => calls.push(sql); + knex.on('query', listener); + try { + const read = tracked.tasks + .withRowSchema() + .select(t => ({ id: t.id, title: t.title })); + expect(calls).toHaveLength(0); + const row = await read.where(t => t.id, 102).first(); + expect(calls).toHaveLength(1); + expect(() => tracked.entry(row!)).toThrow(/not tracked/i); + expect(await read.where(t => t.id, 102).first()).not.toBe(row); + } finally { + knex.off('query', listener); + } +}); + +it('schema-aware explicit joins preserve nullable objects and custom collections', async () => { + const read = query(knex, Task) + .withRowSchema() + .select(t => ({ id: t.id })) + .joinOne( + { + as: 'person', + localColumn: t => t.ownerId, + foreignColumn: u => u.id, + foreignSchema: User, + required: false + }, + q => q.select(u => ({ name: u.name })) + ) + .joinMany( + { + as: 'comments', + localColumn: t => t.id, + foreignColumn: n => n.taskId, + foreignSchema: Note + }, + q => + q + .select(n => ({ text: n.body })) + .orderBy(n => n.id, 'desc') + .limit(1) + ) + .where(t => t.id, 104); + const row = await read.first(); + expect(row).toEqual({ id: 104, person: null, comments: [{ text: 'two' }] }); + expect(read.rowSchema.validate(row).valid).toBe(true); +}); + +it('schema-aware optional belongs-to joins keep unmatched parents', async () => { + const optional = defineEntity(Task).belongsTo( + t => t.owner, + t => t.ownerId, + u => u.id, + { optional: true } + ); + const read = query(knex, optional.schema) + .withRowSchema() + .select(t => ({ id: t.id })) + .include( + r => r.owner, + q => q.select(u => ({ name: u.name })) + ) + .where(t => t.id, 104); + const row = await read.first(); + expect(row).toEqual({ id: 104, owner: null }); + expect(read.rowSchema.validate(row).valid).toBe(true); +}); + +it('schema-aware flat joins preserve precision and scoped outer join nulls', async () => { + const read = query(knex, alias(Task, 'task')) + .withRowSchema() + .leftJoin(alias(User, 'owner'), t => eq(t.task.ownerId, t.owner.id)) + .select(t => ({ + id: t.task.id, + amount: t.task.amount, + date: t.task.createdAt, + owner: t.owner.name + })) + .orderBy(t => t.task.id); + const rows = await read; + expect(rows.find(r => r.id === 102)?.amount).toBe( + '9007199254740993.000001' + ); + expect(rows.find(r => r.id === 104)?.owner).toBeNull(); + expect(rows.find(r => r.id === 102)?.owner).toBe('Alice'); + expect( + rows.every( + r => r.date instanceof Date && read.rowSchema.validate(r).valid + ) + ).toBe(true); +}); + +it('schema-aware cursor pages keep microsecond ordering private', async () => { + const read = db.tasks + .withRowSchema() + .select(t => ({ title: t.title })) + .include(r => r.notes) + .where(t => t.projectId, 1); + const first = await read.paginateAfter({ + limit: 2, + orderBy: [ + { column: t => t.createdAt, direction: 'desc' }, + { column: t => t.id, direction: 'desc' } + ] + }); + const second = await read.paginateAfter({ + limit: 2, + cursor: first.nextCursor, + orderBy: [ + { column: t => t.createdAt, direction: 'desc' }, + { column: t => t.id, direction: 'desc' } + ] + }); + expect(first.data.map(row => row.title)).toEqual(['B', 'A']); + expect(second.data.map(row => row.title)).toEqual(['A', 'C']); + expect(second.hasMore).toBe(false); + expect(Object.keys(first.data[0]).sort()).toEqual(['notes', 'title']); + expect(first.data.every(row => read.rowSchema.validate(row).valid)).toBe( + true + ); +}); const orderBy = [ { column: (t: any) => t.createdAt, direction: 'desc' as const }, { column: (t: any) => t.id, direction: 'desc' as const } @@ -173,6 +319,76 @@ afterAll(async () => { }); describe('flat joins', () => { + it('reads exact values and selected relation schemas without hidden queries', async () => { + const read = db.tasks + .withRowSchema() + .select(t => ({ + id: t.id, + amount: t.amount, + createdAt: t.createdAt + })) + .include( + t => t.notes, + q => + q.select(n => ({ body: n.body })).orderBy(n => n.id, 'desc') + ) + .where(t => t.id, 104); + let queries = 0; + const onQuery = () => { + queries++; + }; + knex.on('query', onQuery); + try { + expect(read.rowSchema).toBe(read.where(t => t.id, 104).rowSchema); + expect(queries).toBe(0); + const rows = await read; + expect(queries).toBe(1); + expect(rows).toEqual([ + { + id: 104, + amount: '0.200000', + createdAt: expect.any(Date), + notes: [{ body: 'two' }, { body: 'one' }] + } + ]); + expect(read.rowSchema.validate(rows[0]).valid).toBe(true); + const exact = await db.tasks + .withRowSchema() + .where(t => t.id, 102) + .select(t => ({ amount: t.amount })) + .first(); + expect(exact).toEqual({ amount: '9007199254740993.000001' }); + const AmountRow = db.tasks + .withRowSchema() + .select(t => ({ amount: t.amount })); + const PublicAmount = object({ amount: string().optional() }); + const toPublic = mapper() + .configure(AmountRow.rowSchema, PublicAmount, m => + m.for(t => t.amount).compute(s => s.amount ?? undefined) + ) + .getSyncMapper(AmountRow.rowSchema, PublicAmount); + expect( + (await AmountRow.where(t => t.id, 102)).map(toPublic) + ).toEqual([{ amount: '9007199254740993.000001' }]); + } finally { + knex.off('query', onQuery); + } + }); + + it('decodes typed aggregate outputs once in schema-aware reads', async () => { + const read = db.tasks + .withRowSchema() + .where(t => t.projectId, 1) + .select(t => ({ + count: aggregate.count(), + total: aggregate.sum(t.amount) + })); + const [row] = await read; + expect(row.count).toBe(4); + expect(typeof row.total).toBe('string'); + expect(read.rowSchema.validate(row).valid).toBe(true); + }); + it('executes ordinary and aliased queries through a bound transaction callback', async () => { const result = await createQuery(knex).transaction(async tx => ({ ordinary: await tx(Task) diff --git a/libs/knex-schema/integration/read-graphs.test.ts b/libs/knex-schema/integration/read-graphs.test.ts new file mode 100644 index 00000000..7d6b4917 --- /dev/null +++ b/libs/knex-schema/integration/read-graphs.test.ts @@ -0,0 +1,278 @@ +import { randomUUID } from 'node:crypto'; +import { + array, + createDb, + date, + defineEntity, + number, + object, + string +} from '@cleverbrush/orm'; +import Knex from 'knex'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const connection = process.env.QUERY_TEST_DATABASE_URL; +if (!connection) throw new Error('QUERY_TEST_DATABASE_URL is required'); +const knex = Knex({ client: 'pg', connection }); +const prefix = `cb_read_${randomUUID().replaceAll('-', '')}`; +const names = { + albums: `${prefix}_albums`, + assets: `${prefix}_assets`, + photos: `${prefix}_photos`, + labels: `${prefix}_labels` +}; +const Label = object({ + id: number().primaryKey(), + assetId: number().hasColumnName('asset_id'), + label: string(), + addedAt: date().hasColumnName('added_at') +}).hasTableName(names.labels); +const Photo = defineEntity( + object({ + assetId: number().primaryKey().hasColumnName('asset_id'), + size: number().bigint(), + takenAt: date().hasColumnName('taken_at'), + labels: array(Label).optional() + }).hasTableName(names.photos) +).hasMany( + p => p.labels, + p => p.assetId, + l => l.assetId +); +const Asset = defineEntity( + object({ + id: number().primaryKey(), + kind: string().hasColumnName('asset_kind'), + albumId: number().hasColumnName('album_id'), + rank: number().decimal(24, 6), + createdAt: date().hasColumnName('created_at') + }).hasTableName(names.assets) +) + .discriminator(a => a.kind) + .ctiVariant('photo', Photo, p => p.assetId) + .stiVariant('text', object({ body: string() })); +const Album = defineEntity( + object({ + id: number().primaryKey(), + assets: array(Asset.schema).optional() + }).hasTableName(names.albums) +).hasMany( + a => a.assets, + a => a.id, + a => a.albumId +); +const db = createDb(knex, { assets: Asset, albums: Album }); + +beforeAll(async () => { + await knex.schema.createTable(names.albums, t => { + t.integer('id').primary(); + }); + await knex.schema.createTable(names.assets, t => { + t.integer('id').primary(); + t.text('asset_kind'); + t.integer('album_id'); + t.decimal('rank', 24, 6); + t.timestamp('created_at', { useTz: true }); + t.text('body'); + }); + await knex.schema.createTable(names.photos, t => { + t.integer('asset_id').primary(); + t.bigInteger('size'); + t.timestamp('taken_at', { useTz: true }); + }); + await knex.schema.createTable(names.labels, t => { + t.integer('id').primary(); + t.integer('asset_id'); + t.text('label'); + t.timestamp('added_at', { useTz: true }); + }); + await knex(names.albums).insert([{ id: 1 }, { id: 2 }]); + await knex(names.assets).insert([ + { + id: 1, + asset_kind: 'photo', + album_id: 1, + rank: '10.000001', + created_at: '2026-01-01T00:00:00Z' + }, + { + id: 2, + asset_kind: 'text', + album_id: 1, + rank: '2.000001', + created_at: '2026-01-01T00:00:00Z', + body: 'hello' + } + ]); + await knex(names.photos).insert({ + asset_id: 1, + size: '9007199254740993', + taken_at: '2026-01-02T00:00:00Z' + }); + await knex(names.labels).insert([ + { + id: 1, + asset_id: 1, + label: 'first', + added_at: '2026-01-03T00:00:00Z' + }, + { + id: 2, + asset_id: 1, + label: 'second', + added_at: '2026-01-04T00:00:00Z' + } + ]); +}); +afterAll(async () => { + for (const table of [ + names.labels, + names.photos, + names.assets, + names.albums + ]) + await knex.schema.dropTableIfExists(table); + await knex.destroy(); +}); + +describe('schema-aware polymorphic graphs', () => { + it('orders using native values even when branch projections omit sort fields', async () => { + const read = db.assets + .withRowSchema() + .forVariant('photo', q => + q.select(a => ({ id: a.id, kind: a.kind })) + ) + .forVariant('text', q => + q.select(a => ({ id: a.id, kind: a.kind })) + ) + .orderBy(a => a.rank); + expect(await read).toEqual([ + { id: 2, kind: 'text' }, + { id: 1, kind: 'photo' } + ]); + expect(await read.selectVariants(['photo'])).toEqual([ + { id: 1, kind: 'photo' } + ]); + expect(() => + db.assets + .withRowSchema() + .forVariant('text', q => q.select(a => ({ id: a.id }))) + ).toThrow(/retain.*discriminator/); + }); + it('exposes exact branch schemas, dates and numeric ordering in one statement', async () => { + const read = db.assets + .withRowSchema() + .forVariant('photo', q => + q.include( + r => r.labels, + labels => labels.orderBy(l => l.id, 'desc').limit(1) + ) + ) + .orderBy(a => a.rank); + const calls: unknown[] = []; + const listener = (sql: unknown) => calls.push(sql); + knex.on('query', listener); + try { + expect(read.rowSchema.introspect().type).toBe('union'); + expect(read.limit(1).rowSchema).toBe(read.rowSchema); + expect(calls).toHaveLength(0); + const rows = await read; + expect(calls).toHaveLength(1); + expect(rows.map(r => r.id)).toEqual([2, 1]); + const photo = rows[1]; + expect(photo).toMatchObject({ + kind: 'photo', + size: '9007199254740993', + rank: '10.000001', + labels: [{ label: 'second' }] + }); + expect(photo.createdAt).toBeInstanceOf(Date); + if (photo.kind === 'photo') { + expect(photo.takenAt).toBeInstanceOf(Date); + expect(photo.labels[0].addedAt).toBeInstanceOf(Date); + expect(read.variantRowSchemas.photo.validate(photo).valid).toBe( + true + ); + } + expect(rows.every(row => read.rowSchema.validate(row).valid)).toBe( + true + ); + } finally { + knex.off('query', listener); + } + }); + + it('decodes nested polymorphic relations and empty arrays without hidden fetches', async () => { + const read = db.albums + .withRowSchema() + .include( + r => r.assets, + assets => + assets + .forVariant('photo', q => q.include(r => r.labels)) + .orderBy(a => a.id) + ) + .orderBy(a => a.id); + const rows = await read; + expect(rows[0].assets).toHaveLength(2); + expect(rows[1].assets).toEqual([]); + const photo = rows[0].assets[0]; + if (photo.kind !== 'photo') throw new Error('expected photo'); + expect(photo.size).toBe('9007199254740993'); + expect(photo.labels[0].addedAt).toBeInstanceOf(Date); + expect(read.rowSchema.validate(rows[0]).valid).toBe(true); + }); + + it('rejects unknown discriminators and missing required CTI bodies', async () => { + await knex(names.assets).insert([ + { + id: 3, + asset_kind: 'unknown', + album_id: 2, + rank: '1', + created_at: new Date() + }, + { + id: 4, + asset_kind: 'photo', + album_id: 2, + rank: '1', + created_at: new Date() + } + ]); + try { + await expect( + db.assets + .withRowSchema() + .where(a => a.id, 3) + .execute() + ).rejects.toThrow('unknown polymorphic discriminator'); + await expect( + db.assets + .withRowSchema() + .where(a => a.id, 4) + .execute() + ).rejects.toThrow('missing CTI variant body'); + const tolerant = defineEntity(Asset.schema) + .discriminator(a => a.kind) + .ctiVariant('photo', Photo, p => p.assetId, { + allowOrphan: true + }); + const reader = createDb(knex, { assets: tolerant }) + .assets.withRowSchema() + .where(a => a.id, 4); + const orphan = await reader.first(); + expect(orphan).toMatchObject({ + id: 4, + kind: 'photo', + size: null, + takenAt: null + }); + expect(reader.variantRowSchemas.photo.validate(orphan!).valid).toBe( + true + ); + } finally { + await knex(names.assets).whereIn('id', [3, 4]).delete(); + } + }); +}); diff --git a/libs/knex-schema/package.json b/libs/knex-schema/package.json index e65e3c6f..d7bb3a5a 100644 --- a/libs/knex-schema/package.json +++ b/libs/knex-schema/package.json @@ -12,8 +12,7 @@ }, "description": "Type-safe schema-driven query builder for Knex using @cleverbrush/schema — strongly typed CRUD, eager loading, and column mapping for PostgreSQL", "files": [ - "dist", - "COMPOSABLE_QUERIES.md" + "dist" ], "homepage": "https://docs.cleverbrush.com/knex-schema", "keywords": [ diff --git a/libs/knex-schema/src/AliasedReadQuery.ts b/libs/knex-schema/src/AliasedReadQuery.ts new file mode 100644 index 00000000..8b34cb1f --- /dev/null +++ b/libs/knex-schema/src/AliasedReadQuery.ts @@ -0,0 +1,234 @@ +import { type InferType, object } from '@cleverbrush/schema'; +import type { Knex } from 'knex'; +import type { + AliasedQueryBuilder, + AliasTables, + JoinPredicate, + TableAlias +} from './aliased-query.js'; +import { + type AggregateExpression, + type AliasedColumn, + COLUMN +} from './expressions.js'; +import { compileReadProjection, type ReadField } from './read-projection.js'; +import { + compileReadSchema, + decodeObject, + type ReadObject, + ReadSchemaError, + type ReadValue, + type SchemaForValue +} from './read-schema.js'; +import type { ReadColumn, ReadProjection } from './SchemaReadQuery.js'; + +/** Schema-backed aliases whose exact numeric and outer-join values match decoded rows. */ +export type ReadAliasTables = { + [A in keyof T]: { + [K in keyof T[A]]: T[A][K] extends AliasedColumn + ? ReadColumn< + SchemaForValue | (N extends true ? null : never)> + > + : never; + }; +}; +type Selection = Record | AggregateExpression>; +type Selector = (tables: ReadAliasTables) => AliasedColumn; + +/** Immutable flat joined read. Supply select() before accessing rowSchema or executing. */ +export class AliasedReadQuery { + private fields?: Record; + private schema?: Row; + /** @internal Enter through an aliased query's withRowSchema() method. */ + constructor(private planner: AliasedQueryBuilder) {} + + /** Exact structural schema, stable across operations that do not change selection. */ + get rowSchema(): Row { + if (!this.schema) + throw new ReadSchemaError( + 'Aliased reads require an explicit select projection' + ); + return this.schema; + } + private copy(): this { + return Object.assign(Object.create(Object.getPrototypeOf(this)), this, { + planner: this.planner.cloneReadSource() + }); + } + /** Add an immutable inner join with typed alias predicates. */ + join( + table: N extends keyof T ? never : TableAlias, + on: (tables: ReadAliasTables>) => JoinPredicate + ): AliasedReadQuery, Row> { + const copy = this.copy(); + copy.planner = copy.planner.join(table, on as any) as any; + return copy as any; + } + /** Add an immutable left join; projected right-side fields are nullable. */ + leftJoin( + table: N extends keyof T ? never : TableAlias, + on: ( + tables: ReadAliasTables> + ) => JoinPredicate + ): AliasedReadQuery, Row> { + const copy = this.copy(); + copy.planner = copy.planner.leftJoin(table, on as any) as any; + return copy as any; + } + /** Select exact columns and aggregates; opaque raw expressions are deliberately unsupported. */ + select

( + select: (tables: ReadAliasTables) => P + ): AliasedReadQuery> { + if (this.fields) + throw new ReadSchemaError( + 'Only one projection is allowed per read query' + ); + const { knex, columns } = this.planner.readContext(); + const entries = Object.values( + columns as Record>> + ).flatMap(table => Object.values(table)); + const copy = this.copy(); + copy.fields = compileReadProjection( + knex, + select(columns as ReadAliasTables), + column => { + if (!entries.includes(column)) + throw new ReadSchemaError( + 'Projection column does not belong to this query' + ); + const info = column[COLUMN]; + return { + name: `${info.alias}.${info.column}`, + node: compileReadSchema(info.schema) + }; + } + ); + copy.schema = object( + Object.fromEntries( + Object.entries(copy.fields).map(([key, field]) => [ + key, + field.node.schema + ]) + ) + ) as unknown as Row; + return copy as any; + } + /** Add a bound equality predicate. */ + where(column: Selector, value: unknown): this; + /** Add a bound predicate with an explicit supported operator. */ + where(column: Selector, operator: string, value: unknown): this; + /** Return a filtered clone without changing metadata identity. */ + where(column: Selector, ...args: [unknown] | [string, unknown]): this { + const copy = this.copy(); + if (args.length === 1) copy.planner.where(column as any, args[0]); + else copy.planner.where(column as any, args[0], args[1]); + return copy; + } + /** Match SQL null, including absent outer-joined rows. */ + whereNull(column: Selector): this { + const copy = this.copy(); + copy.planner.whereNull(column as any); + return copy; + } + /** Exclude SQL null without silently narrowing result types. */ + whereNotNull(column: Selector): this { + const copy = this.copy(); + copy.planner.whereNotNull(column as any); + return copy; + } + /** Match a bound value list. */ + whereIn(column: Selector, values: readonly unknown[]): this { + const copy = this.copy(); + copy.planner.whereIn(column as any, values); + return copy; + } + /** Order by native database values before decoding. */ + orderBy(column: Selector, direction: 'asc' | 'desc' = 'asc'): this { + const copy = this.copy(); + copy.planner.orderBy(column as any, direction); + return copy; + } + /** Group native columns for an aggregate projection. */ + groupBy(...columns: Selector[]): this { + const copy = this.copy(); + copy.planner.groupBy(...(columns as any)); + return copy; + } + /** Filter grouped rows using native SQL aggregate values. */ + having( + value: ( + tables: ReadAliasTables + ) => AggregateExpression | AliasedColumn, + operator: string, + right: unknown + ): this { + const copy = this.copy(); + copy.planner.having(value as any, operator, right); + return copy; + } + /** Limit the flat row count, including repeated parents produced by joins. */ + limit(count: number): this { + if (!Number.isInteger(count) || count < 0) + throw new ReadSchemaError('Limit must be a non-negative integer'); + const copy = this.copy(); + copy.planner.limit(count); + return copy; + } + /** Offset flat rows using caller-supplied deterministic ordering. */ + offset(count: number): this { + if (!Number.isInteger(count) || count < 0) + throw new ReadSchemaError('Offset must be a non-negative integer'); + const copy = this.copy(); + copy.planner.offset(count); + return copy; + } + /** Use a caller-owned transaction without mutating the original read query. */ + transacting(trx: Knex.Transaction): this { + const copy = this.copy(); + copy.planner = copy.planner.transacting(trx); + return copy; + } + /** @internal Compile selected fields, casting exact numeric values before driver parsing. */ + compile(): Knex.QueryBuilder { + void this.rowSchema; + const { sql, knex } = this.planner.readContext(); + return sql + .clearSelect() + .select( + Object.fromEntries( + Object.entries(this.fields!).map(([key, field]) => [ + key, + field.expression(knex) + ]) + ) + ); + } + /** Render SQL for debugging without executing it. */ + toQuery(): string { + return this.compile().toQuery(); + } + /** Execute one statement and validate/decode its detached results. */ + async execute(): Promise[]> { + const nodes = Object.fromEntries( + Object.entries(this.fields ?? {}).map(([key, field]) => [ + key, + field.node + ]) + ); + return (await this.compile()).map((row: unknown) => + decodeObject(nodes, row, 'row') + ); + } + /** Fetch the first selected row, or undefined. */ + async first(): Promise | undefined> { + return (await this.limit(1).execute())[0]; + } + /** Awaiting executes this reader; repeated awaits execute again. */ + // biome-ignore lint/suspicious/noThenProperty: query readers intentionally support await + then[], E = never>( + resolve?: ((rows: InferType[]) => R | PromiseLike) | null, + reject?: ((error: any) => E | PromiseLike) | null + ): Promise { + return this.execute().then(resolve, reject); + } +} diff --git a/libs/knex-schema/src/PolymorphicReadQuery.ts b/libs/knex-schema/src/PolymorphicReadQuery.ts new file mode 100644 index 00000000..e813f368 --- /dev/null +++ b/libs/knex-schema/src/PolymorphicReadQuery.ts @@ -0,0 +1,564 @@ +import { + type InferType, + type ObjectSchemaBuilder, + object, + type SchemaBuilder, + string, + type UnionSchemaBuilder, + union +} from '@cleverbrush/schema'; +import type { Knex } from 'knex'; +import { buildColumnMap, getPrimaryKeyColumns } from './columns.js'; +import type { SchemaProps } from './entity.js'; +import { COLUMN } from './expressions.js'; +import { getVariants } from './extension.js'; +import { + getEffectiveBaseQuery, + getSchemaQueryBuilderCtor +} from './operations/helpers.js'; +import { privateColumn } from './operations/ordering.js'; +import type { + EntityReadSchema, + ReadRelations, + ReadVariantMetadata +} from './read-entity.js'; +import { + type ObjectReadSchema, + type ReadObject, + type ReadSchema, + ReadSchemaError, + type SchemaForValue +} from './read-schema.js'; +import { + READ_QUERY, + type ReadColumns, + type ReadCorrelation, + type ReadQueryShape, + SchemaReadQuery +} from './SchemaReadQuery.js'; + +type VariantMap = + ReadVariantMetadata extends { variants: infer V } ? V : {}; +type VariantBody> = VariantMap[K] extends { + schema: infer Body; +} + ? Body + : never; +type VariantKey> = VariantMap[K] extends { + foreignKey: infer FK extends string; +} + ? FK + : never; +type Discriminator = + ReadVariantMetadata extends { discriminator: infer D extends string } + ? D + : never; +type BranchProps> = Omit< + SchemaProps, + keyof ReadRelations +> & + Omit< + SchemaProps>, + keyof ReadRelations> | VariantKey + >; +type OrphanProps = { + [K in keyof P & string]: V extends { allowOrphan: true } + ? SchemaForValue | null> + : P[K] extends ReadSchema + ? P[K] + : never; +}; +/** Runtime object schema for one polymorphic discriminator branch. */ +export type VariantReadSchema< + S extends ReadObject, + K extends keyof VariantMap & string +> = ObjectSchemaBuilder< + Omit< + SchemaProps>>, + keyof SchemaProps> | Discriminator + > & + OrphanProps< + Omit< + SchemaProps< + ObjectReadSchema< + VariantBody, + keyof ReadRelations> + > + >, + VariantKey | Discriminator + >, + VariantMap[K] + > & + Record, SchemaBuilder> +>; +/** Per-variant schemas for explicit application-level discriminator dispatch. */ +export type VariantReadSchemas = { + [K in keyof VariantMap & string]: VariantReadSchema; +}; +/** The genuine union schema returned for polymorphic read rows. */ +export type PolymorphicRowSchema> = + UnionSchemaBuilder, true, false, InferType>; +type BranchSource< + S extends ReadObject, + K extends keyof VariantMap & string +> = EntityReadSchema< + ObjectSchemaBuilder>, + ReadRelations & ReadRelations> +>; +type BranchQueries< + S extends ReadObject, + B extends Record +> = { + [K in keyof B & keyof VariantMap & string]: SchemaReadQuery< + BranchSource, + B[K], + ReadRelations & ReadRelations> + >; +}; +type Selector = ( + columns: ReadColumns> +) => { readonly [COLUMN]: { column: string } }; + +/** + * Immutable polymorphic read graph. Branches are combined in one PostgreSQL statement; + * rowSchema is a real discriminated union and variantRowSchemas supplies object schemas + * for separately configured mappers. Framework does not choose application DTO mappings. + */ +export class PolymorphicReadQuery< + S extends ReadObject, + B extends Record = VariantReadSchemas +> { + /** @internal Nominal identity for typed child-query customizers. */ + declare readonly [READ_QUERY]: true; + /** Runtime union matching decoded results, including selected variant bodies. */ + readonly rowSchema: PolymorphicRowSchema; + /** Stable object schemas keyed by discriminator, suitable for mapper.configure(). */ + readonly variantRowSchemas: Readonly; + private branches: Record>; + private fallback: SchemaReadQuery; + private orders: Array<{ key: string; direction: 'asc' | 'desc' }> = []; + private rowLimit?: number; + private rowOffset?: number; + private includeUnknown = true; + + /** @internal Use withRowSchema() instead of constructing polymorphic readers. */ + constructor( + private readonly knex: Knex, + private readonly source: S, + private readonly base: Knex.QueryBuilder + ) { + const config = getVariants(source); + if (!config) + throw new ReadSchemaError('No polymorphic variants are declared'); + this.branches = Object.create(null); + for (const key of Object.keys(config.variants)) + this.branches[key] = this.branch(key, true); + const common = this.commonSource(); + const discriminator = + buildColumnMap(source).propToCol.get(config.discriminatorKey) ?? + config.discriminatorKey; + // Invert only the discriminator guard, not caller/default-scope filters. + this.fallback = new SchemaReadQuery( + knex, + common, + knex + .from(base.clone().as('__read_unknown')) + .where(q => + q + .whereNotIn(discriminator, Object.keys(config.variants)) + .orWhereNull(discriminator) + ) + ); + const schemas = Object.fromEntries( + Object.entries(this.branches).map(([key, q]) => [key, q.rowSchema]) + ); + this.variantRowSchemas = Object.freeze(schemas) as Readonly; + this.rowSchema = this.unionSchema( + schemas + ) as unknown as PolymorphicRowSchema; + } + + private commonSource(): ReadObject { + const info = this.source.introspect(); + return (object(info.properties) as any) + .withExtension('tableName', info.extensions?.tableName) + .withExtension('relations', info.extensions?.relations ?? []); + } + + private unionSchema(schemas: Record): ReadSchema { + const [first, ...rest] = Object.values(schemas); + if (!first) + throw new ReadSchemaError( + 'A polymorphic read requires at least one variant' + ); + return rest.reduce( + (result: any, schema) => result.or(schema), + union(first) + ); + } + + private branch(key: string, body: boolean): SchemaReadQuery { + const config = getVariants(this.source)!; + const variant = config.variants[key]; + const baseInfo = this.source.introspect(); + const baseProperties = baseInfo.properties as Record< + string, + ReadSchema + >; + const variantProperties = variant.schema.introspect() + .properties as Record; + const commonRelations = (baseInfo.extensions?.relations ?? []) as any[]; + const declaredRelations = (variant.schema.introspect().extensions + ?.relations ?? []) as any[]; + const variantRelations = body + ? [ + ...declaredRelations, + ...variant.relations.filter( + r => !declaredRelations.some(d => d.name === r.name) + ) + ] + : []; + const excluded = new Set( + [...commonRelations, ...variantRelations].map(r => r.name) + ); + const baseCols = buildColumnMap(this.source).propToCol; + const variantCols = buildColumnMap(variant.schema).propToCol; + const baseAlias = '__read_poly_base'; + const bodyAlias = '__read_poly_body'; + const basePk = getPrimaryKeyColumns(this.source).columnNames; + const query = this.knex + .from(this.base.clone().as(baseAlias)) + .where( + `${baseAlias}.${baseCols.get(config.discriminatorKey) ?? config.discriminatorKey}`, + key + ); + if (body && variant.storage === 'cti') { + if (basePk.length !== 1) + throw new ReadSchemaError( + 'CTI read graphs require a single-column primary key' + ); + const Constructor = getSchemaQueryBuilderCtor(); + const bodyQuery = getEffectiveBaseQuery( + new Constructor(this.knex, variant.schema) + ).clone(); + query.leftJoin( + bodyQuery.as(bodyAlias), + `${bodyAlias}.${variant.foreignKey}`, + `${baseAlias}.${basePk[0]}` + ); + } + const columns: Record = Object.create(null); + const properties: Record = Object.create(null); + for (const [name, schema] of Object.entries(baseProperties)) { + if (excluded.has(name)) continue; + columns[name] = this.knex.raw('??', [ + `${baseAlias}.${baseCols.get(name) ?? name}` + ]); + properties[name] = (schema as any).withExtension( + 'columnName', + name + ); + } + if (body) + for (const [name, schema] of Object.entries(variantProperties)) { + if ( + excluded.has(name) || + (variant.storage === 'cti' && + variantCols.get(name) === variant.foreignKey) + ) + continue; + columns[name] = this.knex.raw('??', [ + `${variant.storage === 'cti' ? bodyAlias : baseAlias}.${variantCols.get(name) ?? name}` + ]); + properties[name] = ( + variant.allowOrphan ? schema.nullable() : (schema as any) + ).withExtension('columnName', name); + } + properties[config.discriminatorKey] = string(key); + const basePrimaryProperty = buildColumnMap(this.source).colToProp.get( + basePk[0] + ); + const variantForeignProperty = buildColumnMap( + variant.schema + ).colToProp.get(variant.foreignKey ?? ''); + const relations = [...commonRelations, ...variantRelations].map( + relation => ({ + ...relation, + localKey: + relation.localKey === variantForeignProperty + ? basePrimaryProperty + : relation.localKey, + foreignKey: + relation.type === 'belongsTo' + ? (buildColumnMap(variant.schema).colToProp.get( + relation.foreignKey + ) ?? relation.foreignKey) + : relation.foreignKey + }) + ); + let schema = (object(properties) as any) + .withExtension('tableName', '__read_poly_source') + .withExtension('relations', relations); + if (body && variant.storage === 'cti' && !variant.allowOrphan) { + if (Object.hasOwn(properties, '__read_cti_present')) + throw new ReadSchemaError('Reserved CTI read column collision'); + columns.__read_cti_present = this.knex.raw('??', [ + `${bodyAlias}.${variant.foreignKey}` + ]); + schema = schema.withExtension( + 'readOrphanColumn', + '__read_cti_present' + ); + } + return new SchemaReadQuery( + this.knex, + schema, + query.select(columns) + ); + } + + private copy(): this { + return Object.assign(Object.create(Object.getPrototypeOf(this)), this, { + branches: { ...this.branches }, + orders: [...this.orders] + }); + } + + /** @internal Check the identity of the original read source, retained by clones. */ + sameSource(other: unknown): boolean { + return ( + other instanceof PolymorphicReadQuery && + this.base === other.base && + this.source === other.source + ); + } + + private refresh(): void { + const schemas = Object.fromEntries( + Object.entries(this.branches).map(([key, q]) => [key, q.rowSchema]) + ); + Object.assign(this, { + variantRowSchemas: Object.freeze(schemas), + rowSchema: this.unionSchema(schemas) + }); + } + + /** Add a comparison to every branch without changing its declared result schema. */ + where(selector: Selector, value: unknown): this; + /** Add an explicit supported comparison operator to every branch. */ + where(selector: Selector, operator: string, value: unknown): this; + /** Add a bound comparison; existing reader instances remain unchanged. */ + where(selector: Selector, ...args: unknown[]): this { + const copy = this.copy(); + for (const [key, branch] of Object.entries(copy.branches)) + copy.branches[key] = (branch.where as Function)(selector, ...args); + copy.fallback = (copy.fallback.where as Function)(selector, ...args); + return copy; + } + /** Order all variants together, not independently within each branch. */ + orderBy(selector: Selector, direction: 'asc' | 'desc' = 'asc'): this { + if (direction !== 'asc' && direction !== 'desc') + throw new ReadSchemaError('Invalid ordering direction'); + const properties = this.source.introspect().properties; + const key = selector( + Object.fromEntries( + Object.keys(properties).map(k => [ + k, + { [COLUMN]: { column: k } } + ]) + ) as any + )[COLUMN].column; + if (!Object.hasOwn(properties, key)) + throw new ReadSchemaError('Unknown polymorphic ordering column'); + const copy = this.copy(); + copy.orders.push({ key, direction }); + return copy; + } + /** Limit the combined result across all variants. */ + limit(count: number): this { + if (!Number.isInteger(count) || count < 0) + throw new ReadSchemaError('Limit must be a non-negative integer'); + const copy = this.copy(); + copy.rowLimit = count; + return copy; + } + /** Restrict returned discriminator branches and narrow both runtime and inferred schemas. */ + selectVariants( + keys: K + ): PolymorphicReadQuery> { + if ( + !keys.length || + new Set(keys).size !== keys.length || + keys.some(key => !Object.hasOwn(this.branches, key)) + ) + throw new ReadSchemaError( + 'Select a non-empty set of declared variants without duplicates' + ); + const copy = this.copy(); + copy.branches = Object.fromEntries( + keys.map(key => [key, this.branches[key]]) + ); + copy.includeUnknown = false; + copy.refresh(); + return copy as any; + } + /** Skip rows of the combined result, using a stable explicit ordering. */ + offset(count: number): this { + if (!Number.isInteger(count) || count < 0) + throw new ReadSchemaError('Offset must be a non-negative integer'); + const copy = this.copy(); + copy.rowOffset = count; + return copy; + } + + /** + * Customize a variant's typed read query, including nested relations. Return the + * configured query; other branches and their schemas are unchanged. + */ + forVariant< + K extends keyof B & keyof VariantMap & string, + Q extends ReadQueryShape + >( + key: K, + configure: (query: BranchQueries[K]) => Q + ): PolymorphicReadQuery & Record> { + const current = this.branches[key]; + if (!current) throw new ReadSchemaError(`Unknown variant: ${key}`); + const configured = configure(current as any); + if (!current.sameSource(configured)) + throw new ReadSchemaError( + 'Variant customizer must return its configured read query' + ); + const discriminator = getVariants(this.source)!.discriminatorKey; + if ( + configured.rowSchema + .introspect() + .properties[discriminator]?.introspect().equalsTo !== key + ) + throw new ReadSchemaError( + 'Variant projections must retain the original discriminator' + ); + const copy = this.copy(); + copy.branches[key] = configured as unknown as SchemaReadQuery< + any, + any, + any + >; + copy.refresh(); + return copy as any; + } + + /** @internal Compile one UNION ALL statement; JSON preserves distinct branch shapes. */ + compile(correlate?: ReadCorrelation): Knex.QueryBuilder { + const reserved = Object.values(this.branches).flatMap(branch => + Object.keys(branch.rowSchema.introspect().properties) + ); + const order = this.orders.map(item => { + const hidden = privateColumn(reserved, 'read_order'); + reserved.push(hidden); + return { ...item, hidden }; + }); + const queries = [ + ...Object.values(this.branches), + ...(this.includeUnknown ? [this.fallback] : []) + ].map(branch => + this.knex + .from( + branch + .compile((sql, alias, source) => { + correlate?.(sql, alias, source); + const columns = buildColumnMap(source).propToCol; + for (const { key, hidden } of order) + sql.select({ + [hidden]: this.knex.raw( + 'cast(?? as text)', + [`${alias}.${columns.get(key) ?? key}`] + ) + }); + }) + .as('__read_branch') + ) + .select(this.knex.raw('to_jsonb(__read_branch) as __read_poly')) + ); + const query = this.knex + .from( + this.knex + .queryBuilder() + .unionAll(queries, true) + .as('__read_variants') + ) + .select('__read_poly'); + for (const { key, direction, hidden } of order) { + const info = this.source.introspect().properties[key].introspect(); + const type = + info.type === 'number' + ? 'numeric' + : info.type === 'date' + ? 'timestamptz' + : info.type === 'boolean' + ? 'boolean' + : info.type === 'string' + ? 'text' + : undefined; + if (!type) + throw new ReadSchemaError( + 'Polymorphic ordering requires a scalar column' + ); + query.orderByRaw( + `cast(__read_poly ->> ? as ${type}) ${direction}`, + [hidden] + ); + } + if (this.rowLimit !== undefined) query.limit(this.rowLimit); + if (this.rowOffset !== undefined) query.offset(this.rowOffset); + return query; + } + /** @internal Decode using exactly the selected branch's schema and codecs. */ + decode(row: any, path = 'row'): InferType> { + const value = row.__read_poly; + const key = value?.[getVariants(this.source)!.discriminatorKey]; + const branch = this.branches[key]; + if (!Object.hasOwn(this.branches, key) || !branch) + throw new ReadSchemaError( + `${path}: unknown polymorphic discriminator` + ); + return branch.decode(value, path); + } + /** Render debugging SQL without performing any database calls. */ + toQuery(): string { + return this.compile().toQuery(); + } + /** Execute and decode one statement containing every requested branch. */ + async execute(): Promise>[]> { + return (await this.compile()).map((row: any) => this.decode(row)); + } + /** Return the first globally ordered row, or undefined. */ + async first(): Promise> | undefined> { + return (await this.limit(1).execute())[0]; + } + /** Awaiting deliberately executes the query each time. */ + // biome-ignore lint/suspicious/noThenProperty: query readers intentionally support await + then>[], E = never>( + resolve?: + | (( + rows: InferType>[] + ) => T | PromiseLike) + | null, + reject?: ((error: any) => E | PromiseLike) | null + ): Promise { + return this.execute().then(resolve, reject); + } + /** Bind independent branch queries to a caller-owned transaction. */ + transacting(trx: Knex.Transaction): this { + const copy = this.copy(); + Object.assign(copy, { knex: trx }); + copy.branches = Object.fromEntries( + Object.entries(this.branches).map(([key, q]) => [ + key, + q.transacting(trx) + ]) + ); + copy.fallback = this.fallback.transacting(trx); + return copy; + } +} diff --git a/libs/knex-schema/src/SchemaQueryBuilder.ts b/libs/knex-schema/src/SchemaQueryBuilder.ts index cc7b97c4..104a8724 100644 --- a/libs/knex-schema/src/SchemaQueryBuilder.ts +++ b/libs/knex-schema/src/SchemaQueryBuilder.ts @@ -129,6 +129,7 @@ import { whereNullImpl, whereRawImpl } from './operations/where.js'; +import { type SchemaAwareQuery, schemaReadQuery } from './SchemaReadQuery.js'; // --------------------------------------------------------------------------- // Type-level helpers @@ -175,6 +176,14 @@ export class SchemaQueryBuilder< TLocalSchema extends ObjectSchemaBuilder, TResult > { + /** + * Enter immutable, detached schema-aware read mode before selecting/loading fields. + * Row metadata never runs SQL; existing query behavior remains unchanged. + * @throws ReadSchemaError if result-shaping operations were already applied. + */ + withRowSchema(): SchemaAwareQuery { + return schemaReadQuery(this); + } /** * Create a query over the schema's configured table. * @param knex - Database connection or transaction used to execute the query. @@ -1259,6 +1268,7 @@ export class SchemaQueryBuilder< */ apply(fn: (builder: Knex.QueryBuilder) => void): this { const state = getState(this); + state.opaqueReadShape = true; invalidateCache(this); fn(state.baseQuery); return this; @@ -1301,6 +1311,7 @@ export class SchemaQueryBuilder< builderState.includeDeleted = state.includeDeleted; builderState.onlyDeleted = state.onlyDeleted; builderState.skipDefaultScope = state.skipDefaultScope; + builderState.opaqueReadShape = state.opaqueReadShape; builderState.variantConfig = state.variantConfig; builderState.enabledVariants = state.enabledVariants !== null diff --git a/libs/knex-schema/src/SchemaReadQuery.ts b/libs/knex-schema/src/SchemaReadQuery.ts new file mode 100644 index 00000000..c980565b --- /dev/null +++ b/libs/knex-schema/src/SchemaReadQuery.ts @@ -0,0 +1,885 @@ +import { + type ArraySchemaBuilder, + array, + EXTRA_TYPE_BRAND, + type InferType, + type ObjectSchemaBuilder, + object, + SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR +} from '@cleverbrush/schema'; +import type { Knex } from 'knex'; +import { + buildColumnMap, + getPrimaryKeyColumns, + resolvePropertyKey +} from './columns.js'; +import type { RelationInfo, SchemaProps } from './entity.js'; +import { + type AggregateExpression, + type AliasedColumn, + COLUMN, + isAggregate +} from './expressions.js'; +import { getProjections, getVariants } from './extension.js'; +import { + type CompositeCursorOptions, + compositeCursor +} from './operations/composite-cursor.js'; +import { + ALLOWED_OPS, + getEffectiveBaseQuery, + getSchemaQueryBuilderCtor +} from './operations/helpers.js'; +import { getState } from './operations/state.js'; +import { PolymorphicReadQuery } from './PolymorphicReadQuery.js'; +import type { ReadRelations, ReadVariantMetadata } from './read-entity.js'; +import { compileReadProjection, type ReadField } from './read-projection.js'; +import { + type ColumnReadSchema, + compileReadSchema, + decodeObject, + type ObjectReadSchema, + type ReadNode, + type ReadObject, + type ReadSchema, + ReadSchemaError, + readExpression, + type SchemaForValue +} from './read-schema.js'; +import type { SchemaQueryBuilder } from './SchemaQueryBuilder.js'; +import type { + CursorPaginationResult, + JoinManySpec, + JoinOneSpec, + PaginationResult, + RelationSpec +} from './types.js'; + +const READ_COLUMN = Symbol('schema-read-column'); +/** @internal Nominal read-query identity used without recursively comparing fluent APIs. */ +export const READ_QUERY = Symbol('schema-read-query'); +/** Minimal type surface for a customizer's strongly inferred result schema. */ +export interface ReadQueryShape { + /** @internal Nominal marker; constructing a plain schema object is not a query. */ + readonly [READ_QUERY]: true; + /** Decoded result schema inferred from the returned query. */ + readonly rowSchema: R; +} +let readAliasSequence = 0; +/** A typed SQL column; selectors receive descriptions, not row values. */ +export interface ReadColumn + extends AliasedColumn> { + /** @internal Decoding and projection metadata shared by the query compiler. */ + readonly [READ_COLUMN]: ReadNode; +} +/** Columns available for typed projections and filters. */ +export type ReadColumns< + S extends ReadObject, + Relations extends PropertyKey = never +> = { + [K in Exclude, Relations> & string]: ReadColumn< + ColumnReadSchema[K]> + >; +}; +type Selection = Record | AggregateExpression>; +type MergeProps = { + [K in keyof A | keyof B]: K extends keyof B + ? B[K] + : K extends keyof A + ? A[K] + : never; +}; +type NamedProjections = S extends { readonly [EXTRA_TYPE_BRAND]?: infer P } + ? P + : {}; +type NamedKeys< + S, + K extends keyof NamedProjections +> = NamedProjections[K] extends readonly (infer Key extends string)[] + ? Key + : never; +/** Structural schema inferred from a typed projection. */ +export type ReadProjection = ObjectSchemaBuilder<{ + [K in keyof S & string]: S[K] extends ReadColumn + ? R + : S[K] extends AggregateExpression + ? SchemaForValue + : never; +}>; +type AddField< + S extends ReadObject, + K extends string, + F extends ReadSchema +> = ObjectSchemaBuilder, K> & Record>; +type Related = R extends RelationInfo ? S : never; +type RelationField = + R extends RelationInfo<'hasMany' | 'belongsToMany', any> + ? ArraySchemaBuilder + : R extends RelationInfo<'belongsTo', any> + ? R extends { optional: infer O } + ? true extends O + ? SchemaForValue | null> + : S + : S + : SchemaForValue | null>; +type Selector = (columns: C) => ReadColumn; +type AnyReadQuery = + | SchemaReadQuery + | PolymorphicReadQuery; +type Loaded = { + name: string; + query: AnyReadQuery; + relation: RelationSpec; + required: boolean; +}; +/** Result of entering opt-in read mode: an object reader or a declared variant union. */ +export type SchemaAwareQuery = + ReadVariantMetadata extends { + discriminator: string; + variants: Record; + } + ? PolymorphicReadQuery + : SchemaReadQuery; +/** @internal Apply parent correlation before child selection, ordering and pagination. */ +export type ReadCorrelation = ( + query: Knex.QueryBuilder, + alias: string, + source: ReadObject +) => void; + +/** + * Immutable, detached read query whose row schema matches its decoded SQL result. + * Enter through withRowSchema() before projections/includes. Ordinary legacy queries + * remain mutable and keep their existing values. Reading metadata never executes SQL. + */ +export class SchemaReadQuery< + S extends ReadObject, + Row extends ReadObject = ObjectReadSchema>, + Relations extends Record = ReadRelations +> { + /** @internal Nominal identity for typed child-query customizers. */ + declare readonly [READ_QUERY]: true; + private readonly alias = `__schema_read_${readAliasSequence++}`; + private fields: Record; + private loaded: Loaded[] = []; + private base: Knex.QueryBuilder; + private selected = false; + private grouped = false; + private readonly columns: Record>; + /** Runtime structural schema; stable across filters, pagination and transaction clones. */ + readonly rowSchema: Row; + + /** @internal Use withRowSchema() on a query/DbSet instead of constructing directly. */ + constructor( + private readonly knex: Knex, + private readonly source: S, + base: Knex.QueryBuilder + ) { + this.base = knex.queryBuilder().from(base.clone().as(this.alias)); + const relations = (source.introspect().extensions?.relations ?? + []) as RelationSpec[]; + const excluded = new Set(relations.map(r => r.name)); + const { propToCol } = buildColumnMap(source); + this.fields = Object.create(null); + this.columns = Object.create(null); + for (const [key, schema] of Object.entries( + source.introspect().properties ?? {} + )) { + if (excluded.has(key)) continue; + const node = compileReadSchema(schema as ReadSchema); + const column = `${this.alias}.${propToCol.get(key) ?? key}`; + this.columns[key] = { + [COLUMN]: { + alias: this.alias, + column: propToCol.get(key) ?? key, + schema + }, + [READ_COLUMN]: node + }; + this.fields[key] = { + node, + expression: knex => readExpression(knex, node, column) + }; + } + this.rowSchema = this.schema() as Row; + } + + private schema(): ReadObject { + return object( + Object.fromEntries( + Object.entries(this.fields).map(([key, field]) => [ + key, + field.node.schema + ]) + ) + ); + } + + private copy(): this { + const copy = Object.create(Object.getPrototypeOf(this)) as this; + Object.assign(copy, this, { + base: this.base.clone(), + fields: { ...this.fields }, + loaded: [...this.loaded] + }); + return copy; + } + + /** @internal Prevent customizers from substituting an unrelated query source. */ + sameSource(other: unknown): boolean { + return ( + other instanceof SchemaReadQuery && this.columns === other.columns + ); + } + + private column( + selector: Selector> + ): ReadColumn { + const column = selector( + this.columns as ReadColumns + ); + if (!column || !Object.values(this.columns).includes(column)) + throw new ReadSchemaError( + 'Column does not belong to this read query' + ); + return column; + } + + private name(column: ReadColumn): string { + return `${column[COLUMN].alias}.${column[COLUMN].column}`; + } + + /** Select an exact flat row shape, retaining per-field runtime schemas. */ + select

( + selector: (columns: ReadColumns) => P + ): SchemaReadQuery< + S, + ObjectSchemaBuilder< + MergeProps< + SchemaProps>, + Pick< + SchemaProps, + Extract, keyof Relations> + > + > + >, + Relations + > { + if (this.selected) + throw new ReadSchemaError( + 'Only one projection is allowed per read query' + ); + const selection = selector( + this.columns as ReadColumns + ); + if (this.loaded.length && Object.values(selection).some(isAggregate)) + throw new ReadSchemaError( + 'Aggregate projections cannot contain relations' + ); + const copy = this.copy(); + copy.fields = compileReadProjection( + this.knex, + selection, + expression => { + if ( + !Object.values(this.columns).includes( + expression as ReadColumn + ) + ) + throw new ReadSchemaError( + 'Projection column does not belong to this query' + ); + const column = expression as ReadColumn; + return { node: column[READ_COLUMN], name: this.name(column) }; + } + ); + copy.selected = true; + // Included relations are independent of the scalar projection. + for (const relation of copy.loaded) + copy.fields[relation.name] = this.fields[relation.name]; + Object.assign(copy, { rowSchema: copy.schema() }); + return copy as any; + } + + /** Apply a named schema projection with the same exact row-schema guarantees. */ + projected & string>( + name: K + ): SchemaReadQuery< + S, + ObjectSchemaBuilder< + Pick< + SchemaProps>, + Extract, keyof SchemaProps>> + > & + Pick< + SchemaProps, + Extract, keyof Relations> + > + >, + Relations + > { + const definition = getProjections(this.source)[name]; + if (!definition) + throw new ReadSchemaError(`Unknown projection: ${name}`); + return this.select(() => + Object.fromEntries( + definition.keys.map(key => [key, this.columns[key]]) + ) + ) as any; + } + + /** Add a bound comparison, returning an independent query with the same row schema. */ + where( + column: Selector>, + value: unknown + ): this; + /** Add a bound comparison using a supported SQL operator. */ + where( + column: Selector>, + operator: string, + value: unknown + ): this; + /** Add a bound comparison using a supported SQL operator. */ + where( + column: Selector>, + ...args: [unknown] | [string, unknown] + ): this { + const operator = args.length === 1 ? '=' : args[0].toLowerCase(); + if (!ALLOWED_OPS.has(operator)) + throw new ReadSchemaError( + `Unsupported comparison operator: ${operator}` + ); + const copy = this.copy(); + copy.base.where( + this.name(this.column(column)), + operator, + (args.length === 1 ? args[0] : args[1]) as any + ); + return copy; + } + + /** Filter SQL null values without changing the declared read shape. */ + whereNull(column: Selector>): this { + const copy = this.copy(); + copy.base.whereNull(this.name(this.column(column))); + return copy; + } + /** Exclude SQL null values without implicitly narrowing schema nullability. */ + whereNotNull(column: Selector>): this { + const copy = this.copy(); + copy.base.whereNotNull(this.name(this.column(column))); + return copy; + } + /** Filter against a bound list of values. An empty list produces no rows. */ + whereIn( + column: Selector>, + values: readonly unknown[] + ): this { + const copy = this.copy(); + copy.base.whereIn(this.name(this.column(column)), [...values] as any[]); + return copy; + } + /** Order parent rows independently of any child relation's ordering. */ + orderBy( + column: Selector>, + direction: 'asc' | 'desc' = 'asc' + ): this { + const copy = this.copy(); + copy.base.orderBy(this.name(this.column(column)), direction); + return copy; + } + /** Group rows before typed aggregate projection. */ + groupBy(...columns: Selector>[]): this { + const copy = this.copy(); + copy.base.groupBy(columns.map(c => this.name(this.column(c)))); + copy.grouped = true; + return copy; + } + /** Limit parent rows; relation limits apply independently within each parent. */ + limit(count: number): this { + if (!Number.isInteger(count) || count < 0) + throw new ReadSchemaError('Limit must be a non-negative integer'); + const copy = this.copy(); + copy.base.limit(count); + return copy; + } + /** Skip parent rows; use a deterministic order for pagination. */ + offset(count: number): this { + if (!Number.isInteger(count) || count < 0) + throw new ReadSchemaError('Offset must be a non-negative integer'); + const copy = this.copy(); + copy.base.offset(count); + return copy; + } + + /** + * Load a declared relation in the same SQL statement. Return the customized child + * query so its selected fields and nested includes remain strongly typed. + */ + include< + K extends keyof Relations & string, + Child extends ReadQueryShape = SchemaAwareQuery> + >( + selector: (relations: { [P in keyof Relations]: P }) => K, + customize?: (query: SchemaAwareQuery>) => Child + ): SchemaReadQuery< + S, + AddField>, + Relations + > { + if (Object.values(this.fields).some(f => f.aggregate) || this.grouped) + throw new ReadSchemaError( + 'Grouped/aggregate reads cannot load entity relations' + ); + const definitions = (this.source.introspect().extensions?.relations ?? + []) as RelationSpec[]; + const key = selector( + Object.fromEntries(definitions.map(r => [r.name, r.name])) as any + ); + const relation = definitions.find(r => r.name === key); + if (!relation) throw new ReadSchemaError(`Unknown relation: ${key}`); + if (this.loaded.some(r => r.name === key)) + throw new ReadSchemaError(`Duplicate relation: ${key}`); + const foreign = + typeof relation.schema === 'function' + ? relation.schema() + : relation.schema; + return this.load( + relation, + this.child(foreign, customize as any), + relation.type === 'belongsTo' && !relation.optional + ) as any; + } + + private child( + foreign: ReadObject, + customize?: (query: any) => ReadQueryShape + ): AnyReadQuery { + const Constructor = getSchemaQueryBuilderCtor(); + let child: AnyReadQuery = createReadQuery( + this.knex, + foreign, + getEffectiveBaseQuery(new Constructor(this.knex, foreign)).clone() + ); + if (customize) { + const customized = customize(child as any); + if (!child.sameSource(customized)) + throw new ReadSchemaError( + 'Relation customizer must return its configured read query' + ); + child = customized as any; + } + return child; + } + + private load( + relation: RelationSpec, + child: AnyReadQuery, + required: boolean + ): this { + const key = relation.name; + if (this.grouped || Object.values(this.fields).some(f => f.aggregate)) + throw new ReadSchemaError( + 'Grouped/aggregate reads cannot load entity relations' + ); + if ( + this.loaded.some(r => r.name === key) || + Object.hasOwn(this.fields, key) + ) + throw new ReadSchemaError(`Duplicate result field: ${key}`); + const many = + relation.type === 'hasMany' || relation.type === 'belongsToMany'; + const schema = many + ? array(child.rowSchema) + : required + ? child.rowSchema + : child.rowSchema.nullable(); + const node: ReadNode = { + schema, + exact: false, + decode: (value, path) => { + if (value === null && !required && !many) return null; + if (many) { + if (!Array.isArray(value)) + throw new ReadSchemaError( + `${path}: expected a relation array` + ); + return value.map((row, i) => + child.decode(row, `${path}[${i}]`) + ); + } + return child.decode(value, path); + } + }; + const copy = this.copy(); + copy.loaded.push({ name: key, query: child, relation, required }); + copy.fields[key] = { + node, + expression: () => { + throw new ReadSchemaError( + 'Relation expression must be compiled in context' + ); + } + }; + Object.assign(copy, { rowSchema: copy.schema() }); + return copy as any; + } + + /** Join one typed nested object with explicit property keys, including nullable joins. */ + joinOne< + F extends ReadObject, + K extends string, + Required extends boolean = true, + Child extends ReadQueryShape = SchemaAwareQuery + >( + spec: JoinOneSpec, + customize?: (query: SchemaAwareQuery) => Child + ): SchemaReadQuery< + S, + AddField< + Row, + K, + Required extends true + ? Child['rowSchema'] + : SchemaForValue | null> + >, + Relations & Record> + > { + if (spec.foreignQuery || spec.mappers) + throw new ReadSchemaError( + 'Use the typed child customizer instead of foreignQuery/mappers in schema-aware mode' + ); + return this.load( + { + name: spec.as, + type: 'hasOne', + schema: spec.foreignSchema, + localKey: resolvePropertyKey( + spec.localColumn as any, + this.source, + 'joinOne' + ), + remoteKey: resolvePropertyKey( + spec.foreignColumn as any, + spec.foreignSchema, + 'joinOne' + ) + }, + this.child(spec.foreignSchema, customize as any), + spec.required !== false + ) as any; + } + + /** Join a typed collection, applying child projection and pagination independently per parent. */ + joinMany< + F extends ReadObject, + K extends string, + Child extends ReadQueryShape = SchemaAwareQuery + >( + spec: Omit, 'orderBy'>, + customize?: (query: SchemaAwareQuery) => Child + ): SchemaReadQuery< + S, + AddField>, + Relations & Record> + > { + if (spec.foreignQuery || spec.mappers || 'orderBy' in spec) + throw new ReadSchemaError( + 'Use the typed child customizer for ordering instead of raw foreignQuery/mappers' + ); + let child = this.child(spec.foreignSchema, customize as any); + if (spec.limit !== undefined) child = child.limit(spec.limit); + if (spec.offset !== undefined) child = child.offset(spec.offset); + return this.load( + { + name: spec.as, + type: 'hasMany', + schema: spec.foreignSchema, + localKey: resolvePropertyKey( + spec.localColumn as any, + this.source, + 'joinMany' + ), + remoteKey: resolvePropertyKey( + spec.foreignColumn as any, + spec.foreignSchema, + 'joinMany' + ) + }, + child, + false + ) as any; + } + + /** @internal Decode a SQL/JSON row using the same metadata exposed to consumers. */ + decode(row: any, path = 'row'): any { + if ( + this.source.introspect().extensions?.readOrphanColumn && + row.__read_cti_present == null + ) + throw new ReadSchemaError(`${path}: missing CTI variant body`); + return decodeObject( + Object.fromEntries( + Object.entries(this.fields).map(([key, field]) => [ + key, + field.node + ]) + ), + row, + path + ); + } + + /** @internal Compile a bound SQL statement; callers never receive the mutable builder. */ + compile(correlate?: ReadCorrelation): Knex.QueryBuilder { + const query = this.base.clone().clearSelect(); + correlate?.(query, this.alias, this.source); + const expressions: Record = Object.create(null); + for (const [key, field] of Object.entries(this.fields)) { + if (!this.loaded.some(r => r.name === key)) + expressions[key] = field.expression(this.knex); + } + const orphanColumn = + this.source.introspect().extensions?.readOrphanColumn; + if (orphanColumn) + expressions.__read_cti_present = this.knex.raw('??', [ + `${this.alias}.${orphanColumn}` + ]); + for (const loaded of this.loaded) { + const { relation, query: child } = loaded; + const parentTable = this.alias; + const parentPk = getPrimaryKeyColumns(this.source).columnNames; + const foreignKey = relation.foreignKey; + const resolveKey = (schema: ReadObject, key: any) => { + if (typeof key === 'function') { + const descriptor = key( + ObjectSchemaBuilderValue.getPropertiesFor(schema) + ); + key = + descriptor[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR] + .propertyName; + } + return buildColumnMap(schema).propToCol.get(key) ?? key; + }; + const childSql = child.compile((sql, childTable, childSource) => { + const childPk = getPrimaryKeyColumns(childSource).columnNames; + if (relation.localKey && relation.remoteKey) { + sql.where( + `${childTable}.${resolveKey(childSource, relation.remoteKey)}`, + this.knex.ref( + `${parentTable}.${resolveKey(this.source, relation.localKey)}` + ) + ); + return; + } + if (parentPk.length !== 1) + throw new ReadSchemaError( + 'Automatic relation reads require single-column primary keys' + ); + if (childPk.length !== 1) + throw new ReadSchemaError( + 'Automatic relation reads require single-column primary keys' + ); + if (relation.type === 'belongsTo') + sql.where( + `${childTable}.${childPk[0]}`, + this.knex.ref( + `${parentTable}.${resolveKey(this.source, foreignKey)}` + ) + ); + else if (relation.type === 'belongsToMany') { + const through = relation.through!; + sql.join( + through.table, + `${through.table}.${through.foreignKey}`, + `${childTable}.${childPk[0]}` + ).where( + `${through.table}.${through.localKey}`, + this.knex.ref(`${parentTable}.${parentPk[0]}`) + ); + } else + sql.where( + `${childTable}.${resolveKey(childSource, foreignKey)}`, + this.knex.ref(`${parentTable}.${parentPk[0]}`) + ); + }); + const many = + relation.type === 'hasMany' || + relation.type === 'belongsToMany'; + if (!many) childSql.limit(1); + const wrapped = this.knex + .queryBuilder() + .from(childSql.clone().as('__read_relation')); + expressions[loaded.name] = many + ? this.knex.raw( + "(select coalesce(jsonb_agg(to_jsonb(__read_relation)), '[]'::jsonb) from (?) as __read_relation)", + [childSql] + ) + : this.knex.raw( + '(select to_jsonb(__read_relation) from (?) as __read_relation)', + [childSql] + ); + if (loaded.required) + query.whereExists(wrapped.clone().select(this.knex.raw('1'))); + } + return query.select(expressions); + } + + /** Render debugging SQL without execution; bound values may be sensitive. */ + toQuery(): string { + return this.compile().toQuery(); + } + /** Execute one statement and decode its selected row graph. */ + async execute(): Promise[]> { + return (await this.compile()).map((row: unknown) => this.decode(row)); + } + /** Execute a limited copy, returning undefined when no row matches. */ + async first(): Promise | undefined> { + return (await this.limit(1).execute())[0]; + } + /** Awaiting executes the query; repeated awaits deliberately execute again. */ + // biome-ignore lint/suspicious/noThenProperty: query readers intentionally support await + then[], E = never>( + resolve?: ((rows: InferType[]) => T | PromiseLike) | null, + reject?: ((error: any) => E | PromiseLike) | null + ): Promise { + return this.execute().then(resolve, reject); + } + /** Bind an independent query graph to a caller-owned transaction. */ + transacting(trx: Knex.Transaction): this { + const copy = this.copy(); + copy.base.transacting(trx); + Object.assign(copy, { knex: trx }); + copy.loaded = this.loaded.map(r => ({ + ...r, + query: r.query.transacting(trx) + })); + return copy; + } + /** + * Read a lossless composite cursor page using native SQL ordering. Cursor sort + * values remain private text columns, independent of projections and Date decoding. + * Requires non-null scalar order columns containing a declared unique key. + */ + async paginateAfter( + options: CompositeCursorOptions + ): Promise>> { + if (this.grouped || Object.values(this.fields).some(f => f.aggregate)) + throw new ReadSchemaError( + 'Cursor pagination cannot be used for aggregate reads' + ); + const Constructor = getSchemaQueryBuilderCtor(); + const source = this.source.withExtension('tableName', this.alias); + const legacy = new Constructor(this.knex, source, this.compile()); + const state = getState(legacy); + state.skipDefaultScope = true; + state.includeDeleted = true; + // The query is already projected; reserve its aliases against cursor fields. + state.hiddenColumns = new Set(Object.keys(this.fields)); + return compositeCursor( + legacy, + options, + row => this.decode(row), + this.source.introspect().extensions?.tableName as string + ); + } + /** Fetch a numbered page and a count without mutating the source query. */ + async paginate(options: { + page: number; + pageSize: number; + }): Promise>> { + const { page, pageSize } = options; + if ( + !Number.isInteger(page) || + page < 1 || + !Number.isInteger(pageSize) || + pageSize < 1 + ) + throw new ReadSchemaError( + 'Page and pageSize must be positive integers' + ); + const countQuery = this.compile() + .clearOrder() + .clear('limit') + .clear('offset'); + const countRow = await this.knex + .from(countQuery.as('__read_count')) + .count({ count: '*' }) + .first(); + const total = Number(countRow?.count ?? 0); + if (!Number.isSafeInteger(total)) + throw new ReadSchemaError( + 'Pagination count exceeds the safe integer range' + ); + const data = await this.offset((page - 1) * pageSize) + .limit(pageSize) + .execute(); + const totalPages = Math.ceil(total / pageSize); + return { + data, + total, + page, + pageSize, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1 + }; + } +} + +import { ObjectSchemaBuilder as ObjectSchemaBuilderValue } from '@cleverbrush/schema'; + +/** @internal Enter schema-aware mode only before result-shaping legacy operations. */ +export function schemaReadQuery( + builder: SchemaQueryBuilder +): SchemaAwareQuery { + const state = getState(builder); + if ( + state.selectionMode !== null || + state.specs.length || + state.variantRelationIncludes.length + ) + throw new ReadSchemaError( + 'Call withRowSchema() before select/include/join operations' + ); + if ( + state.opaqueReadShape || + state.enabledVariants || + state.variantWhereFilters.length + ) + throw new ReadSchemaError( + 'Call withRowSchema() before raw or variant-specific operations' + ); + const base = getEffectiveBaseQuery(builder).clone(); + const statements = (base as any)._statements as Array<{ grouping: string }>; + if ( + statements.some(s => s.grouping === 'order') || + (base as any)._single.limit !== undefined || + (base as any)._single.offset !== undefined + ) + throw new ReadSchemaError( + 'Call withRowSchema() before ordering or pagination' + ); + if ( + statements.some(s => + ['columns', 'join', 'group', 'having', 'union'].includes(s.grouping) + ) + ) + throw new ReadSchemaError( + 'Existing raw projections, joins and aggregates cannot declare a read schema' + ); + return createReadQuery(state.knex, state.localSchema as S, base); +} + +/** @internal Shared reader factory for roots and nested relations. */ +export function createReadQuery( + knex: Knex, + schema: S, + base: Knex.QueryBuilder +): SchemaAwareQuery { + return ( + getVariants(schema) + ? new PolymorphicReadQuery(knex, schema, base) + : new SchemaReadQuery(knex, schema, base) + ) as SchemaAwareQuery; +} diff --git a/libs/knex-schema/src/aliased-query.ts b/libs/knex-schema/src/aliased-query.ts index 5ac2979f..455b16dd 100644 --- a/libs/knex-schema/src/aliased-query.ts +++ b/libs/knex-schema/src/aliased-query.ts @@ -1,6 +1,8 @@ import type { InferType, ObjectSchemaBuilder } from '@cleverbrush/schema'; import type { Knex } from 'knex'; +import { AliasedReadQuery } from './AliasedReadQuery.js'; import { buildColumnMap } from './columns.js'; +import type { SchemaProps } from './entity.js'; import { type AggregateExpression, type AliasedColumn, @@ -66,7 +68,9 @@ type Columns = { [K in keyof InferType]-?: AliasedColumn< | Exclude[K], undefined> | (undefined extends InferType[K] ? null : never) - | (Nullable extends true ? null : never) + | (Nullable extends true ? null : never), + K extends keyof SchemaProps ? SchemaProps[K] : never, + Nullable >; }; @@ -137,6 +141,35 @@ export class AliasedQueryBuilder { private tables = new Map(); private selected = false; private decoders: Record unknown> = {}; + private nullableTables = new Set(); + private opaqueReadShape = false; + + /** Enter immutable read mode before select/raw changes; an explicit projection is required. */ + withRowSchema(): AliasedReadQuery { + if (this.selected || this.opaqueReadShape) + throw new Error( + 'Call withRowSchema() before select/apply operations' + ); + return new AliasedReadQuery(this.cloneReadSource()); + } + + /** @internal Snapshot the SQL planner without sharing mutable query state. */ + cloneReadSource(): AliasedQueryBuilder { + const copy = Object.assign( + Object.create(Object.getPrototypeOf(this)), + this + ); + copy.sql = this.sql.clone(); + copy.tables = new Map(this.tables); + copy.nullableTables = new Set(this.nullableTables); + copy.decoders = { ...this.decoders }; + return copy; + } + + /** @internal Schema-backed state used by the immutable opt-in adapter. */ + readContext(): { knex: Knex; sql: Knex.QueryBuilder; columns: TTables } { + return { knex: this.knex, sql: this.sql.clone(), columns: this.tree() }; + } /** * Create a read-only query for one aliased schema. @@ -172,7 +205,9 @@ export class AliasedQueryBuilder { [COLUMN]: { alias: name, column, - schema: properties[key] + schema: this.nullableTables.has(name) + ? properties[key].nullable() + : properties[key] } } ]) @@ -248,11 +283,13 @@ export class AliasedQueryBuilder { if (this.tables.has(table.name)) throw new Error(`Duplicate table alias: ${table.name}`); this.tables.set(table.name, table.schema); + if (left) this.nullableTables.add(table.name); try { const predicate = this.predicate(on(this.tree())); this.sql[left ? 'leftJoin' : 'join'](this.source(table), predicate); } catch (error) { this.tables.delete(table.name); + this.nullableTables.delete(table.name); throw error; } } @@ -421,6 +458,7 @@ export class AliasedQueryBuilder { /** Raw escape hatch; callers own any effects on result shape or cardinality. */ apply(callback: (query: Knex.QueryBuilder) => void): this { + this.opaqueReadShape = true; callback(this.sql); return this; } @@ -439,6 +477,8 @@ export class AliasedQueryBuilder { copy.tables = new Map(this.tables); copy.selected = this.selected; copy.decoders = { ...this.decoders }; + copy.nullableTables = new Set(this.nullableTables); + copy.opaqueReadShape = this.opaqueReadShape; return copy; } diff --git a/libs/knex-schema/src/entity.ts b/libs/knex-schema/src/entity.ts index 93ff5896..fe6ea98e 100644 --- a/libs/knex-schema/src/entity.ts +++ b/libs/knex-schema/src/entity.ts @@ -23,6 +23,11 @@ import { getColumnName, type VariantInputForResolver } from './extension.js'; +import type { + EntityReadSchema, + ReadVariants, + WithReadVariant +} from './read-entity.js'; import type { ResolvedVariantRelationSpec } from './types.js'; // --------------------------------------------------------------------------- @@ -167,11 +172,13 @@ export type WithRelation< TKey extends string, TKind extends 'belongsTo' | 'hasOne' | 'hasMany' | 'belongsToMany', TForeign extends ObjectSchemaBuilder, - TVariantUnion = never + TVariantUnion = never, + TReadVariants extends ReadVariants = {} > = Entity< TSchema, TRels & Record>, - TVariantUnion + TVariantUnion, + TReadVariants >; // --------------------------------------------------------------------------- @@ -192,10 +199,11 @@ export type WithRelation< export class Entity< TSchema extends ObjectSchemaBuilder, TRels extends Record = {}, - TVariantUnion = never + TVariantUnion = never, + TReadVariants extends ReadVariants = {} > { /** The underlying schema (relations registered via `withExtension('relations', ...)`). */ - readonly schema: TSchema; + readonly schema: EntityReadSchema; /** @internal Phantom slot to retain `TRels` in inferred types. */ private declare readonly __relations__: TRels; @@ -209,7 +217,11 @@ export class Entity< * definitions; constructing an Entity does not query or create database tables. */ constructor(schema: TSchema) { - this.schema = schema; + this.schema = schema as unknown as EntityReadSchema< + TSchema, + TRels, + TReadVariants + >; } private _addRelation(spec: { @@ -217,6 +229,8 @@ export class Entity< name: string; schema: any; foreignKey?: any; + localKey?: string; + remoteKey?: string; through?: { table: string; localKey: string; foreignKey: string }; optional?: boolean; }): Entity { @@ -257,7 +271,15 @@ export class Entity< _localSel: EntityPropSelector, remoteSel: EntityPropSelector, opts?: { optional?: boolean } - ): WithRelation { + ): WithRelation< + TSchema, + TRels, + TKey, + 'hasOne', + TForeign, + TVariantUnion, + TReadVariants + > { const navName = this._resolvePropName( navSel as EntityPropSelector, this.schema @@ -273,6 +295,8 @@ export class Entity< name: navName, schema: foreignSchema, foreignKey: remoteKey, + localKey: this._resolvePropName(_localSel, this.schema), + remoteKey, optional: opts?.optional }) as any; } @@ -299,7 +323,15 @@ export class Entity< navSel: EntityPropSelector, _localSel: EntityPropSelector, remoteSel: EntityPropSelector - ): WithRelation { + ): WithRelation< + TSchema, + TRels, + TKey, + 'hasMany', + TForeign, + TVariantUnion, + TReadVariants + > { const navName = this._resolvePropName( navSel as EntityPropSelector, this.schema @@ -313,7 +345,9 @@ export class Entity< type: 'hasMany', name: navName, schema: foreignSchema, - foreignKey: remoteKey + foreignKey: remoteKey, + localKey: this._resolvePropName(_localSel, this.schema), + remoteKey }) as any; } @@ -334,19 +368,22 @@ export class Entity< any, any, any - > = UnwrapNavSchema[TKey]> + > = UnwrapNavSchema[TKey]>, + TOptional extends boolean = false >( navSel: EntityPropSelector, localSel: EntityPropSelector, _remoteSel: EntityPropSelector, - opts?: { optional?: boolean } - ): WithRelation< + opts?: { optional?: TOptional } + ): Entity< TSchema, - TRels, - TKey, - 'belongsTo', - TForeign, - TVariantUnion + TRels & + Record< + TKey, + RelationInfo<'belongsTo', TForeign> & { optional: TOptional } + >, + TVariantUnion, + TReadVariants > { const navName = this._resolvePropName( navSel as EntityPropSelector, @@ -360,6 +397,11 @@ export class Entity< name: navName, schema: foreignSchema, foreignKey: localKey, + localKey, + remoteKey: this._resolvePropName( + _remoteSel as EntityPropSelector, + foreignSchema + ), optional: opts?.optional }) as any; } @@ -390,7 +432,8 @@ export class Entity< TKey, 'belongsToMany', TForeign, - TVariantUnion + TVariantUnion, + TReadVariants > { const navName = this._resolvePropName( navSel as EntityPropSelector, @@ -426,7 +469,7 @@ export class Entity< */ discriminator & string>( sel: TKey | EntityPropSelector - ): Entity { + ): Entity { const discKey = typeof sel === 'string' ? (sel as string) @@ -434,7 +477,10 @@ export class Entity< sel as EntityPropSelector, this.schema ); - const next = new Entity(this.schema) as Entity; + const next = new Entity(this.schema) as unknown as Entity< + TSchema, + TRels + >; // Carry the variant-builder state on the new Entity so subsequent // `.ctiVariant()`/`.stiVariant()` calls can extend it. Stored on // the instance (not in the schema) so the original schema is @@ -443,7 +489,7 @@ export class Entity< discKey, variants: {} as Record }; - return next; + return next as any; } /** @@ -466,19 +512,33 @@ export class Entity< any, any, any - > + >, + const TKey extends string, + TForeignKey extends keyof SchemaProps & string, + TVarRelations extends Record, + TAllowOrphan extends boolean = false >( - key: string, - variant: Entity, - fkSel: (t: EntityTree) => any, + key: TKey, + variant: Entity, + fkSel: EntityPropSelector, opts?: { - allowOrphan?: boolean; + allowOrphan?: TAllowOrphan; relations?: Record>; } ): Entity< TSchema, TRels, - TVariantUnion | VariantBranch + TVariantUnion | VariantBranch, + WithReadVariant< + TReadVariants, + TKey, + { + schema: EntityReadSchema; + storage: 'cti'; + foreignKey: TForeignKey; + allowOrphan: TAllowOrphan; + } + > > { const builder = (this as any)._variantBuilder as | VariantBuilderState @@ -510,7 +570,7 @@ export class Entity< }; return this._withVariantBuilder< TVariantUnion | VariantBranch - >(builder.discKey, nextVariants); + >(builder.discKey, nextVariants) as any; } /** @@ -527,10 +587,12 @@ export class Entity< any, any, any - > + >, + const TKey extends string, + TVarRelations extends Record = {} >( - key: string, - body: Entity | TVarSchema, + key: TKey, + body: Entity | TVarSchema, opts?: { enforceCheck?: boolean; relations?: Record>; @@ -538,7 +600,15 @@ export class Entity< ): Entity< TSchema, TRels, - TVariantUnion | VariantBranch + TVariantUnion | VariantBranch, + WithReadVariant< + TReadVariants, + TKey, + { + schema: EntityReadSchema; + storage: 'sti'; + } + > > { const builder = (this as any)._variantBuilder as | VariantBuilderState @@ -570,7 +640,7 @@ export class Entity< }; return this._withVariantBuilder< TVariantUnion | VariantBranch - >(builder.discKey, nextVariants); + >(builder.discKey, nextVariants) as any; } /** @internal Apply the variant extension to the schema and return a new Entity. */ @@ -828,7 +898,7 @@ export type EntityRelations = * Type-level helper: extract the underlying schema type from any `Entity`. * @public */ -export type EntitySchema = E extends Entity ? S : never; +export type EntitySchema = E extends { schema: infer S } ? S : never; /** * Type-level helper: extract the accumulated variant union from any `Entity`. diff --git a/libs/knex-schema/src/expressions.ts b/libs/knex-schema/src/expressions.ts index 04aae2be..1b198372 100644 --- a/libs/knex-schema/src/expressions.ts +++ b/libs/knex-schema/src/expressions.ts @@ -36,7 +36,7 @@ export const COLUMN = Symbol('query-column'); export const EXPRESSION = Symbol('query-expression'); /** A schema-backed SQL column belonging to one explicit table alias. */ -export interface AliasedColumn { +export interface AliasedColumn { readonly [COLUMN]: { alias: string; column: string; @@ -46,6 +46,10 @@ export interface AliasedColumn { * Type-only marker carrying column nullability and value type; not a runtime row value. */ readonly __value?: T; + /** Type-only original schema used by opt-in projection metadata. */ + readonly __readSource?: S; + /** Type-only join nullability, independent of the stored column schema. */ + readonly __leftJoined?: Nullable; } export type SelectableColumn = diff --git a/libs/knex-schema/src/extension.ts b/libs/knex-schema/src/extension.ts index 5a421518..e5875aac 100644 --- a/libs/knex-schema/src/extension.ts +++ b/libs/knex-schema/src/extension.ts @@ -1,5 +1,4 @@ // @cleverbrush/knex-schema — Schema extension: hasColumnName / hasTableName - import type { AnySchemaBuilder, ArraySchemaBuilder, @@ -18,6 +17,7 @@ import type { import { arrayExtensions, defineExtension, + defineMetadataMethod, EXTRA_TYPE_BRAND, METHOD_LITERAL_BRAND, NumberSchemaBuilder as NumberSchemaBuilderClass, @@ -56,6 +56,9 @@ export const PRIMARY_KEY_BRAND: unique symbol = Symbol.for( '@cleverbrush/knex-schema:primaryKey' ); +/** @internal Named primary-key result so exported consumer schemas emit portable declarations. */ +export type PrimaryKeyColumn = S & { readonly [PRIMARY_KEY_BRAND]?: true }; + /** * Phantom-type brand placed on an object schema by `.hasPrimaryKey([cols])` * to record the composite primary-key column tuple at the type level. @@ -334,34 +337,19 @@ export const ddlExtension = defineExtension({ return this.withExtension('unique', name ?? true); }, /** Override the SQL column type (e.g. `'bigint'`, `'smallint'`). */ - columnType( - this: NumberSchemaBuilder, - type: string - ) { - return this.withExtension('columnType', type); - }, + columnType: defineMetadataMethod('columnType').argument(), /** Shorthand for `.columnType('bigint')`. */ - bigint(this: NumberSchemaBuilder) { - return this.withExtension('columnType', 'bigint'); - }, + bigint: defineMetadataMethod('columnType').value('bigint'), /** Shorthand for `.columnType('smallint')`. */ - smallint(this: NumberSchemaBuilder) { - return this.withExtension('columnType', 'smallint'); - }, + smallint: defineMetadataMethod('columnType').value('smallint'), /** Shorthand for `.columnType('decimal(p,s)')` — exact numeric. * @param precision - Total digits. * @param scale - Digits after decimal point. */ - decimal( - this: NumberSchemaBuilder, - precision: number, - scale: number - ) { - return this.withExtension( - 'columnType', - `decimal(${precision},${scale})` - ); - }, + decimal: defineMetadataMethod('columnType').compute( + (precision: number, scale: number) => + `decimal(${precision},${scale})` as const + ), /** Set a raw SQL default expression. * @param expression - Raw SQL expression (e.g. `"nextval('my_seq')"`). */ @@ -1215,9 +1203,7 @@ declare module '@cleverbrush/schema' { /** Mark this column as a primary key. * @param opts - Options. `autoIncrement` defaults to `true`. */ - primaryKey(opts?: { autoIncrement?: boolean }): this & { - readonly [PRIMARY_KEY_BRAND]?: true; - }; + primaryKey(opts?: { autoIncrement?: boolean }): PrimaryKeyColumn; } interface StringSchemaBuilder< @@ -1228,9 +1214,7 @@ declare module '@cleverbrush/schema' { TExtensions > { /** Mark this column as a primary key (non-auto-increment). */ - primaryKey(): this & { - readonly [PRIMARY_KEY_BRAND]?: true; - }; + primaryKey(): PrimaryKeyColumn; } interface ObjectSchemaBuilder< diff --git a/libs/knex-schema/src/index.ts b/libs/knex-schema/src/index.ts index e925f107..90904f0b 100644 --- a/libs/knex-schema/src/index.ts +++ b/libs/knex-schema/src/index.ts @@ -1,5 +1,7 @@ // @cleverbrush/knex-schema — Type-safe schema-driven query builder for Knex +export type { ReadAliasTables } from './AliasedReadQuery.js'; +export { AliasedReadQuery } from './AliasedReadQuery.js'; export type { AliasTables, JoinedProjection, @@ -50,6 +52,7 @@ export type { } from './expressions.js'; export { aggregate } from './expressions.js'; // Schema extension (hasColumnName / hasTableName + DDL/ORM) +export type { PrimaryKeyColumn } from './extension.js'; export { any, array, @@ -90,8 +93,32 @@ export { validateEntitiesAgainstDatabase } from './migration.js'; export type { CompositeCursorOptions } from './operations/composite-cursor.js'; +export type { + PolymorphicRowSchema, + VariantReadSchema, + VariantReadSchemas +} from './PolymorphicReadQuery.js'; +export { PolymorphicReadQuery } from './PolymorphicReadQuery.js'; // Raw query execution export { rawQuery } from './raw.js'; +export type { + EntityReadSchema, + ReadRelations, + ReadVariant, + ReadVariantMetadata, + ReadVariants, + WithReadVariant +} from './read-entity.js'; +export { READ_ENTITY } from './read-entity.js'; +export type { + ColumnReadSchema, + ObjectReadSchema, + ReadObject, + ReadSchema, + ReadValue, + SchemaForValue +} from './read-schema.js'; +export { ReadSchemaError } from './read-schema.js'; export type { BoundQuery } from './SchemaQueryBuilder.js'; // Main entry point export { @@ -99,6 +126,13 @@ export { query, SchemaQueryBuilder } from './SchemaQueryBuilder.js'; +export type { + ReadColumn, + ReadColumns, + ReadProjection, + SchemaAwareQuery +} from './SchemaReadQuery.js'; +export { SchemaReadQuery } from './SchemaReadQuery.js'; // Snapshot-based migration export { entitiesToSnapshot, diff --git a/libs/knex-schema/src/operations/composite-cursor.ts b/libs/knex-schema/src/operations/composite-cursor.ts index 6a36e639..342bda95 100644 --- a/libs/knex-schema/src/operations/composite-cursor.ts +++ b/libs/knex-schema/src/operations/composite-cursor.ts @@ -31,7 +31,9 @@ export interface CompositeCursorOptions< export async function compositeCursor( builder: SchemaQueryBuilder, - options: CompositeCursorOptions + options: CompositeCursorOptions, + decode?: (row: Record) => any, + sourceIdentity?: string ): Promise> { if ( !Number.isSafeInteger(options.limit) || @@ -121,9 +123,11 @@ export async function compositeCursor( ); } const identity = JSON.stringify([ - state.tableName, + sourceIdentity ?? state.tableName, order.map(({ column, direction, type }) => [column, direction, type]) ]); + const nativeColumn = (column: string) => + sourceIdentity ? `${state.tableName}.${column}` : column; let values: string[] | undefined; if (options.cursor != null) { try { @@ -193,10 +197,13 @@ export async function compositeCursor( order.forEach((item, index) => { this.orWhere(function () { for (let before = 0; before < index; before++) { - this.where(order[before].column, cursorValues[before]); + this.where( + nativeColumn(order[before].column), + cursorValues[before] + ); } this.where( - item.column, + nativeColumn(item.column), item.direction === 'desc' ? '<' : '>', cursorValues[index] ); @@ -209,7 +216,7 @@ export async function compositeCursor( if (!statements(sql).some(s => s.grouping === 'columns')) sql.select(`${state.tableName}.*`); for (const item of order) { - sql.orderBy(item.column, item.direction); + sql.orderBy(nativeColumn(item.column), item.direction); const key = privateColumn( [ ...colToProp.keys(), @@ -219,7 +226,9 @@ export async function compositeCursor( ], 'cursor' ); - const value = state.knex.raw('cast(?? as text)', [item.column]); + const value = state.knex.raw('cast(?? as text)', [ + nativeColumn(item.column) + ]); sql.select({ [key]: value }); state.explicitSelects?.push(key); // Object projections must carry hidden cursor values through eager wrapping. @@ -256,7 +265,9 @@ export async function compositeCursor( ).toString('base64url') : null; return { - data: page.map(row => cleanAndMapRow(copy, row)), + data: page.map(row => + decode ? decode(row) : cleanAndMapRow(copy, row) + ), nextCursor, hasMore }; diff --git a/libs/knex-schema/src/operations/state.ts b/libs/knex-schema/src/operations/state.ts index 18d18c57..c71645fa 100644 --- a/libs/knex-schema/src/operations/state.ts +++ b/libs/knex-schema/src/operations/state.ts @@ -10,6 +10,8 @@ import type { } from '../types.js'; export interface QueryBuilderState { + /** Raw callbacks have no statically declared result shape for opt-in reads. */ + opaqueReadShape?: boolean; knex: Knex; baseQuery: Knex.QueryBuilder; localSchema: ObjectSchemaBuilder; diff --git a/libs/knex-schema/src/read-consumer.test.ts b/libs/knex-schema/src/read-consumer.test.ts new file mode 100644 index 00000000..5cfb3d9f --- /dev/null +++ b/libs/knex-schema/src/read-consumer.test.ts @@ -0,0 +1,166 @@ +import { spawnSync } from 'node:child_process'; +import { + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from 'vitest'; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +test('documentation and multi-file metadata consumers compile against published declarations', () => { + const directory = mkdtempSync(join(tmpdir(), 'framework-read-consumer-')); + try { + symlinkSync( + join(repository, 'node_modules'), + join(directory, 'node_modules'), + 'dir' + ); + const write = (name: string, text: string) => + writeFileSync(join(directory, name), text); + write('package.json', JSON.stringify({ type: 'module' })); + const llms = readFileSync( + join(repository, 'websites/docs/public/llms.txt'), + 'utf8' + ); + const queryDocs = readFileSync( + join(repository, 'libs/knex-schema/README.md'), + 'utf8' + ); + const schemaDocs = readFileSync( + join(repository, 'libs/schema/README.md'), + 'utf8' + ); + const namedBlocks = (text: string) => [ + ...text.matchAll( + /```(?:typescript|ts)\n\/\/ ([\w-]+\.ts)\n([\s\S]*?)```/g + ) + ]; + expect(namedBlocks(llms)).toHaveLength(4); + expect(namedBlocks(queryDocs)).toHaveLength(3); + for (const [, name, content] of [ + ...namedBlocks(llms), + ...namedBlocks(queryDocs) + ]) + write(name, content); + const metadataDocs = schemaDocs + .split('### Typed metadata methods\n')[1] + .split('### Stacking Extensions')[0]; + write( + 'metadata-doc.ts', + metadataDocs.match(/```ts\n([\s\S]*?)```/)![1] + ); + write( + 'database.ts', + `import Knex from 'knex'; export const knex = Knex({ client: 'pg' });` + ); + write( + 'storage.ts', + ` +import { number, object, string } from '@cleverbrush/knex-schema'; +import { defineExtension, defineMetadataMethod, withExtensions } from '@cleverbrush/schema'; +export const Account = object({ + id: number().primaryKey().bigint(), + balance: number().decimal(24, 6).optional().hasColumnName('balance_value'), + label: string() +}).hasTableName('accounts') + .projection('summary', 'id', 'label') + .projection('withBalance', a => a.id, a => a.balance); +export const tagged = withExtensions(defineExtension({ + number: { unit: defineMetadataMethod('unit').argument() } +})).number().unit('metres').optional(); +` + ); + write( + 'storage-consumer.ts', + ` +import { type InferExtensionMetadata, type InferType, number } from '@cleverbrush/schema'; +import { query } from '@cleverbrush/knex-schema'; +import { mapper } from '@cleverbrush/mapper'; +import { object, string } from '@cleverbrush/schema'; +import { Account, tagged } from './storage.js'; +import { knex } from './database.js'; +const unit: InferExtensionMetadata['unit'] = 'metres'; +const value: InferType = undefined; +// @ts-expect-error metadata literal must not widen to string +const invalidUnit: InferExtensionMetadata['unit'] = 'seconds'; +// @ts-expect-error importing database libraries cannot install global storage methods +number().bigint(); +export const read = query(knex, Account).withRowSchema().select(a => ({ id: a.id, balance: a.balance })); +const PublicAccount = object({ id: string(), balance: string().nullable() }); +export const convert = mapper().configure(read.rowSchema, PublicAccount, m => m) + .getSyncMapper(read.rowSchema, PublicAccount); +const row: InferType = { id: '9007199254740993', balance: null }; +const dto: { id: string; balance: string | null } = convert(row); +// @ts-expect-error exact numeric storage is text, never a rounded JS number +const invalid: InferType = { id: 1, balance: 2 }; +` + ); + write( + 'function-consumer.ts', + ` +import { ServiceCollection } from '@cleverbrush/di'; +import { func, number, string } from '@cleverbrush/schema'; +const Dependency = string(); +const Result = number(); +const factory = func().addParameter(Dependency).hasReturnType(Result); +const services = new ServiceCollection().addSingleton(Dependency, 'hello'); +services.addSingletonFromSchema(Result, factory, text => text.length); +services.addScopedFromSchema(Result, factory, text => text.length); +services.addTransientFromSchema(Result, factory, text => text.length); +const provider = services.buildServiceProvider(); +export const result: number = provider.invoke(factory, text => text.length); +const scope = provider.createScope(); +export const scoped: number = scope.serviceProvider.invoke(factory, text => text.length); +// @ts-expect-error dependency types must not widen to any +provider.invoke(factory, (value: number) => value); +// @ts-expect-error implementation must honor the declared return schema +provider.invoke(factory, text => text); +// @ts-expect-error schema-based registration retains parameter types +services.addSingletonFromSchema(Result, factory, (value: number) => value); +` + ); + write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + skipLibCheck: false, + declaration: true, + emitDeclarationOnly: true, + outDir: 'dist', + types: ['node'], + lib: ['ES2022', 'DOM', 'ESNext.Disposable'] + }, + include: ['*.ts'] + }) + ); + const result = spawnSync( + process.execPath, + [ + join(repository, 'node_modules/typescript/lib/tsc.js'), + '-p', + join(directory, 'tsconfig.json') + ], + { encoding: 'utf8', timeout: 60000 } + ); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect( + readFileSync(join(directory, 'dist/storage.d.ts'), 'utf8') + ).toContain('Account'); + for (const text of [llms, queryDocs]) { + expect(text).not.toContain('READ-SCHEMAS.md'); + expect(text).not.toContain('COMPOSABLE_QUERIES.md'); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}, 90000); diff --git a/libs/knex-schema/src/read-entity.ts b/libs/knex-schema/src/read-entity.ts new file mode 100644 index 00000000..6d31a2d2 --- /dev/null +++ b/libs/knex-schema/src/read-entity.ts @@ -0,0 +1,72 @@ +import type { RelationInfo } from './entity.js'; +import type { ReadObject } from './read-schema.js'; + +/** Type-only entity graph metadata retained through optional/nullable navigation schemas. */ +export const READ_ENTITY: unique symbol = Symbol.for( + '@cleverbrush/knex-schema:read-entity' +); +/** A declared polymorphic storage branch used to derive database-read schemas. */ +export interface ReadVariant { + /** Variant object schema, including its relation metadata. */ + schema: ReadObject; + /** Storage layout for this branch. */ + storage: 'cti' | 'sti'; + /** CTI parent-reference property, omitted from the public read row. */ + foreignKey?: string; + /** Whether a missing CTI body produces nullable fields instead of an error. */ + allowOrphan?: boolean; +} +/** Type-only declaration of the discriminator and available polymorphic branches. */ +export interface ReadVariants { + /** Name of the discriminator property. */ + discriminator?: string; + /** Branch descriptions keyed by discriminator literal. */ + variants?: Record; +} +/** Attach entity graph information to schema extension types without changing validation. */ +export type EntityReadSchema = { + readonly [READ_ENTITY]: { relations: Relations; polymorphic: Variants }; + optional(): EntityReadSchema< + ReturnType, + Relations, + Variants + >; + nullable(): EntityReadSchema< + ReturnType, + Relations, + Variants + >; + required(): EntityReadSchema< + ReturnType, + Relations, + Variants + >; + notNullable(): EntityReadSchema< + ReturnType, + Relations, + Variants + >; +} & S; +/** Relations available from an entity's schema, including nested navigation schemas. */ +export type ReadRelations = S extends { + readonly [READ_ENTITY]: { + relations: infer R extends Record; + }; +} + ? R + : {}; +/** Polymorphic declarations available from an entity's schema. */ +export type ReadVariantMetadata = S extends { + readonly [READ_ENTITY]: { polymorphic: infer V }; +} + ? V + : {}; +/** Accumulate a variant while retaining its literal key and storage schema. */ +export type WithReadVariant< + V extends ReadVariants, + K extends string, + Branch extends ReadVariant +> = { + discriminator: V['discriminator']; + variants: Omit, K> & Record; +}; diff --git a/libs/knex-schema/src/read-graph.test-d.ts b/libs/knex-schema/src/read-graph.test-d.ts new file mode 100644 index 00000000..25ec2482 --- /dev/null +++ b/libs/knex-schema/src/read-graph.test-d.ts @@ -0,0 +1,94 @@ +import type { InferType } from '@cleverbrush/schema'; +import Knex from 'knex'; +import { expectTypeOf, test } from 'vitest'; +import { defineEntity } from './entity.js'; +import { array, date, number, object, string } from './extension.js'; +import { query } from './SchemaQueryBuilder.js'; + +const Label = object({ + id: number().primaryKey(), + assetId: number(), + label: string() +}).hasTableName('labels'); +const Photo = defineEntity( + object({ + assetId: number().primaryKey(), + size: number().bigint(), + takenAt: date(), + labels: array(Label).optional() + }).hasTableName('photos') +).hasMany( + p => p.labels, + p => p.assetId, + l => l.assetId +); +const Asset = defineEntity( + object({ + id: number().primaryKey(), + kind: string(), + albumId: number(), + createdAt: date() + }).hasTableName('assets') +) + .discriminator(a => a.kind) + .ctiVariant('photo', Photo, p => p.assetId) + .stiVariant('text', object({ body: string() })); +const Album = defineEntity( + object({ + id: number().primaryKey(), + assets: array(Asset.schema).optional() + }).hasTableName('albums') +).hasMany( + a => a.assets, + a => a.id, + a => a.albumId +); +const knex = Knex({ client: 'pg' }); + +test('polymorphic read branches retain discriminators and nested relation types', async () => { + const read = query(knex, Asset.schema) + .withRowSchema() + .forVariant('photo', q => + q.include( + r => r.labels, + labels => labels.select(l => ({ text: l.label })) + ) + ); + type PhotoRow = InferType; + type Expected = { + id: number; + kind: 'photo'; + albumId: number; + createdAt: Date; + size: string; + takenAt: Date; + labels: { text: string }[]; + }; + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + const rows = await read; + if (rows[0].kind === 'photo') { + expectTypeOf(rows[0].size).toEqualTypeOf(); + expectTypeOf(rows[0].labels[0].text).toEqualTypeOf(); + const labels = rows[0].labels; + expectTypeOf().toEqualTypeOf<'text'>(); + // @ts-expect-error the other branch's fields are unavailable after narrowing + rows[0].body; + } + // @ts-expect-error undeclared variants are not accepted + read.forVariant('unknown', q => q); +}); + +test('polymorphic reads compose inside an ordinary relation', async () => { + const read = query(knex, Album.schema) + .withRowSchema() + .include( + r => r.assets, + assets => assets.forVariant('photo', q => q.include(r => r.labels)) + ); + const rows = await read; + const asset = rows[0].assets[0]; + if (asset.kind === 'photo') + expectTypeOf(asset.labels[0].label).toEqualTypeOf(); + else expectTypeOf(asset.body).toEqualTypeOf(); +}); diff --git a/libs/knex-schema/src/read-mapping.test.ts b/libs/knex-schema/src/read-mapping.test.ts new file mode 100644 index 00000000..c138e1bc --- /dev/null +++ b/libs/knex-schema/src/read-mapping.test.ts @@ -0,0 +1,76 @@ +import { mapper } from '@cleverbrush/mapper'; +import Knex from 'knex'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { defineEntity } from './entity.js'; +import { array, date, number, object, string } from './extension.js'; +import { query } from './SchemaQueryBuilder.js'; + +describe('query schemas as reusable mapping sources', () => { + it('maps projected nested rows without duplicate schemas or promises', async () => { + const Note = object({ + id: number(), + taskId: number(), + body: string() + }).hasTableName('notes'); + const Task = defineEntity( + object({ + id: number().primaryKey(), + amount: number().decimal(24, 6).optional(), + done: date().optional(), + notes: array(Note).optional() + }).hasTableName('tasks') + ).hasMany( + t => t.notes, + t => t.id, + n => n.taskId + ); + const read = query(Knex({ client: 'pg' }), Task.schema) + .withRowSchema() + .select(t => ({ id: t.id, amount: t.amount, done: t.done })) + .include( + r => r.notes, + q => q.select(n => ({ body: n.body })) + ); + const Source = read.rowSchema; + const NoteSource = + Source.introspect().properties.notes.introspect().elementSchema!; + const PublicNote = object({ text: string() }); + const Target = object({ + id: number(), + amount: string().optional(), + done: date().optional(), + notes: array(PublicNote) + }); + const registry = mapper() + .configure(NoteSource, PublicNote, m => + m.for(t => t.text).from(s => s.body) + ) + .configure(Source, Target, m => + m + .for(t => t.amount) + .compute(s => s.amount ?? undefined) + .for(t => t.done) + .compute(s => s.done ?? undefined) + ); + const toPublic = registry.getSyncMapper(Source, Target); + const row = Source.parse({ + id: 1, + amount: '9007199254740993.000001', + done: null, + notes: [{ body: 'hello' }] + }); + const result = toPublic(row); + expectTypeOf(result.notes[0].text).toEqualTypeOf(); + expectTypeOf(result.done).toEqualTypeOf(); + // @ts-expect-error the selected projection has no unselected entity field + row.secret; + expect(result).toEqual({ + id: 1, + amount: '9007199254740993.000001', + done: undefined, + notes: [{ text: 'hello' }] + }); + expect(result).toEqual(await registry.getMapper(Source, Target)(row)); + expect(read.where(t => t.id, 1).rowSchema).toBe(Source); + }); +}); diff --git a/libs/knex-schema/src/read-projection.ts b/libs/knex-schema/src/read-projection.ts new file mode 100644 index 00000000..3d1251ff --- /dev/null +++ b/libs/knex-schema/src/read-projection.ts @@ -0,0 +1,87 @@ +import { number, string } from '@cleverbrush/schema'; +import type { Knex } from 'knex'; +import { + type AggregateExpression, + type AliasedColumn, + compileAggregate, + EXPRESSION, + isAggregate +} from './expressions.js'; +import { + compileReadSchema, + type ReadNode, + type ReadSchema, + ReadSchemaError, + readExpression +} from './read-schema.js'; + +/** @internal One selected field drives SQL, decoding and exposed schema metadata. */ +export type ReadField = { + node: ReadNode; + expression: (knex: Knex) => Knex.Raw; + aggregate?: boolean; +}; +/** @internal Compile typed columns and aggregates using a caller-owned column resolver. */ +export function compileReadProjection( + knex: Knex, + selection: Record | AggregateExpression>, + resolve: (column: AliasedColumn) => { name: string; node: ReadNode } +): Record { + if (!Object.keys(selection).length) + throw new ReadSchemaError('A non-empty projection is required'); + return Object.fromEntries( + Object.entries(selection).map(([key, expression]) => { + if (!isAggregate(expression)) { + const { name, node } = resolve(expression); + return [ + key, + { + node, + expression: (db: Knex) => readExpression(db, node, name) + } + ]; + } + const { kind, column, output } = expression[EXPRESSION]; + const compiled = compileAggregate( + knex, + expression, + c => resolve(c as AliasedColumn).name + ); + let node: ReadNode; + if (output) { + if (!('introspect' in output)) + throw new ReadSchemaError( + 'Aggregate output requires an introspectable Framework schema' + ); + node = compileReadSchema(output as ReadSchema, false); + } else if (kind === 'count' || kind === 'countDistinct') + node = compileReadSchema(number()); + else if (kind === 'sum' || kind === 'avg') + node = compileReadSchema(string().nullable()); + else + node = compileReadSchema( + resolve(column as AliasedColumn).node.schema.nullable() + ); + const dateResult = + !output && node.schema.introspect().type === 'date'; + return [ + key, + { + node: { + ...node, + decode: (value: unknown, path: string) => + node.decode( + dateResult ? value : compiled.decode(value), + path + ) + }, + expression: () => + dateResult + ? knex.raw('cast(? as text)', [compiled.native]) + : compiled.sql, + aggregate: true + } + ]; + }) + ); +} diff --git a/libs/knex-schema/src/read-schema.test-d.ts b/libs/knex-schema/src/read-schema.test-d.ts new file mode 100644 index 00000000..21da993e --- /dev/null +++ b/libs/knex-schema/src/read-schema.test-d.ts @@ -0,0 +1,85 @@ +import type { InferType } from '@cleverbrush/schema'; +import Knex from 'knex'; +import { expectTypeOf, test } from 'vitest'; +import { alias, eq } from './aliased-query.js'; +import { defineEntity } from './entity.js'; +import { date, number, object, string } from './extension.js'; +import { query } from './SchemaQueryBuilder.js'; + +const Task = object({ + id: number().primaryKey(), + title: string(), + amount: number().decimal(24, 6).optional(), + done: date().optional() +}).hasTableName('tasks'); +test('projection row type equals its runtime schema inference', async () => { + const read = query(Knex({ client: 'pg' }), Task) + .withRowSchema() + .select(t => ({ id: t.id, amount: t.amount, done: t.done })); + type Row = { id: number; amount: string | null; done: Date | null }; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(await read).toEqualTypeOf(); + // @ts-expect-error unknown columns are never accepted + read.select(t => ({ secret: t.secret })); +}); + +test('flat joins retain exact storage and left join nullability', async () => { + const read = query(Knex({ client: 'pg' }), alias(Task, 'task')) + .withRowSchema() + .leftJoin(alias(Task, 'other'), t => eq(t.task.id, t.other.id)) + .select(t => ({ + amount: t.task.amount, + otherId: t.other.id, + otherDate: t.other.done + })); + type Row = { + amount: string | null; + otherId: number | null; + otherDate: Date | null; + }; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(await read).toEqualTypeOf(); +}); + +test('optional declared joins and explicit relation customizers retain their shape', async () => { + const Owner = object({ + id: number().primaryKey(), + name: string() + }).hasTableName('owners'); + const WithOwner = Task.addProp('ownerId', number()).addProp( + 'owner', + Owner.optional() + ); + const entity = defineEntity(WithOwner).belongsTo( + t => t.owner, + t => t.ownerId, + u => u.id, + { optional: true } + ); + const read = query(Knex({ client: 'pg' }), entity.schema) + .withRowSchema() + .include( + r => r.owner, + q => q.select(u => ({ name: u.name })) + ); + const row = (await read)[0]; + expectTypeOf(row.owner).toExtend<{ name: string } | null>(); + // @ts-expect-error the relation may be null + const _required: { name: string } = row.owner; + const explicit = query(Knex({ client: 'pg' }), Task) + .withRowSchema() + .select(t => ({ title: t.title })) + .joinMany( + { + as: 'users', + localColumn: t => t.id, + foreignColumn: u => u.id, + foreignSchema: Owner + }, + q => q.select(u => ({ label: u.name })) + ); + const value = (await explicit)[0]; + expectTypeOf(value.users[0].label).toEqualTypeOf(); + // @ts-expect-error omitted foreign fields are not part of the selected result + value.users[0].id; +}); diff --git a/libs/knex-schema/src/read-schema.test.ts b/libs/knex-schema/src/read-schema.test.ts new file mode 100644 index 00000000..41db89f3 --- /dev/null +++ b/libs/knex-schema/src/read-schema.test.ts @@ -0,0 +1,163 @@ +import Knex from 'knex'; +import { describe, expect, it } from 'vitest'; +import { alias, eq } from './aliased-query.js'; +import { aggregate } from './expressions.js'; +import { date, number, object, string } from './extension.js'; +import { compileReadSchema } from './read-schema.js'; +import { query } from './SchemaQueryBuilder.js'; + +const Task = object({ + id: number().primaryKey(), + title: string(), + amount: number().decimal(24, 6).optional(), + completedAt: date().optional().hasColumnName('completed_at') +}).hasTableName('tasks'); +const knex = Knex({ client: 'pg' }); + +describe('schema-aware read metadata', () => { + it('describes only projected fields, SQL nulls and exact storage', () => { + const read = query(knex, Task) + .withRowSchema() + .select(t => ({ + title: t.title, + amount: t.amount, + done: t.completedAt + })); + const properties = read.rowSchema.introspect().properties; + expect(Object.keys(properties)).toEqual(['title', 'amount', 'done']); + expect( + read.rowSchema.validate({ + title: 'task', + amount: '9007199254740993.000001', + done: null + }).valid + ).toBe(true); + expect( + // @ts-expect-error Intentionally validate an invalid wire value. + read.rowSchema.validate({ title: 'task', amount: 1, done: null }) + .valid + ).toBe(false); + expect(read.toQuery()).toContain('cast('); + expect(read.toQuery()).not.toContain('"id" as'); + }); + it('preserves schema identity across immutable filters and transactions', () => { + const read = query(knex, Task).withRowSchema(); + const one = read.where(t => t.id, 1).limit(1); + const two = read.where(t => t.id, 2).offset(1); + expect(one.rowSchema).toBe(read.rowSchema); + expect(two.rowSchema).toBe(read.rowSchema); + expect(one.toQuery()).toContain('= 1'); + expect(two.toQuery()).toContain('= 2'); + expect(read.toQuery()).not.toContain('where'); + }); + it('does not apply entity input defaults or preprocessing during reads', () => { + const schema = string().default('input default'); + const node = compileReadSchema(schema); + expect(() => node.decode(undefined, 'value')).toThrow(); + expect(node.decode('stored', 'value')).toBe('stored'); + expect(() => + compileReadSchema(string().optional().default('input')) + ).toThrow(/ambiguous read nullability/); + expect(() => + compileReadSchema( + object({ value: string().optional().default('input') }) + ) + ).toThrow(/ambiguous read nullability/); + }); + it('decodes dates in nested JSON and rejects invalid values', () => { + const node = compileReadSchema( + object({ seen: date(), optional: date().optional() }) + ); + expect(node.decode({ seen: '2026-01-01T00:00:00Z' }, 'row')).toEqual({ + seen: new Date('2026-01-01T00:00:00Z') + }); + expect(() => node.decode({ seen: 'not a date' }, 'row')).toThrow( + /row.seen/ + ); + }); + it('interprets timezone-less dates consistently as UTC', () => { + const node = compileReadSchema(date()); + expect(node.decode('2026-01-02', 'date').toISOString()).toBe( + '2026-01-02T00:00:00.000Z' + ); + expect( + node.decode('2026-01-02 03:04:05.123456', 'date').toISOString() + ).toBe('2026-01-02T03:04:05.123Z'); + expect( + node.decode('2026-01-02T03:04:05+02:00', 'date').toISOString() + ).toBe('2026-01-02T01:04:05.000Z'); + }); + it('retains aggregate output schemas and rejects opaque parsers', () => { + const read = query(knex, Task) + .withRowSchema() + .select(t => ({ + count: aggregate.count(), + total: aggregate.sum(t.amount) + })); + expect(read.rowSchema.validate({ count: 3, total: null }).valid).toBe( + true + ); + expect(() => + query(knex, Task) + .withRowSchema() + .select(() => ({ + count: aggregate.count(undefined, { + output: { parse: () => 1 } + }) + })) + ).toThrow(/introspectable/); + }); + it('rejects entry after legacy shape changes', () => { + expect(() => + query(knex, Task) + .orderBy(t => t.id) + .withRowSchema() + ).toThrow(/before ordering/); + expect(() => query(knex, Task).limit(1).withRowSchema()).toThrow( + /before ordering/ + ); + expect(() => query(knex, Task).offset(1).withRowSchema()).toThrow( + /before ordering/ + ); + expect(() => + query(knex, Task) + .select(t => ({ id: t.id })) + .withRowSchema() + ).toThrow(/before/); + expect(() => + query(knex, Task).selectRaw('1 as other').withRowSchema() + ).toThrow(); + expect(() => + query(knex, Task) + .apply(q => q.whereRaw('true')) + .withRowSchema() + ).toThrow(/before/); + expect(() => + query(knex, alias(Task, 'task')) + .apply(q => q.select('id')) + .withRowSchema() + ).toThrow(/before/); + }); + it('describes immutable flat left joins with SQL nulls', () => { + const base = query(knex, alias(Task, 'task')).withRowSchema(); + expect(() => base.rowSchema).toThrow(/explicit select/); + const joined = base + .leftJoin(alias(Task, 'other'), t => eq(t.task.id, t.other.id)) + .select(t => ({ + id: t.task.id, + amount: t.other.amount, + otherId: t.other.id + })); + expect( + joined.rowSchema.validate({ id: 1, amount: null, otherId: null }) + .valid + ).toBe(true); + expect(joined.where(t => t.task.id, 1).rowSchema).toBe( + joined.rowSchema + ); + expect(joined.toQuery()).toContain('left join'); + expect(base.select(t => ({ id: t.task.id })).toQuery()).not.toContain( + 'join' + ); + }); +}); diff --git a/libs/knex-schema/src/read-schema.ts b/libs/knex-schema/src/read-schema.ts new file mode 100644 index 00000000..a075e2f2 --- /dev/null +++ b/libs/knex-schema/src/read-schema.ts @@ -0,0 +1,306 @@ +import { + type ArraySchemaBuilder, + array, + boolean, + date, + type InferExtensionMetadata, + type InferType, + number, + type ObjectSchemaBuilder, + object, + type SchemaBuilder, + string, + union +} from '@cleverbrush/schema'; +import type { Knex } from 'knex'; + +/** A schema builder accepted by the database-read schema compiler. */ +export type ReadSchema = SchemaBuilder; +/** A table/object schema accepted by schema-aware read queries. */ +export type ReadObject = ObjectSchemaBuilder; +/** Reconstruct structural schema types without discarding nested property schemas. */ +export type SchemaForValue = 0 extends 1 & T + ? ReadSchema + : NonNullable extends Date + ? SchemaBuilder< + Date, + undefined extends T ? false : true, + null extends T ? true : false + > + : NonNullable extends readonly (infer E)[] + ? ArraySchemaBuilder< + SchemaForValue, + undefined extends T ? false : true, + null extends T ? true : false, + undefined, + false, + {}, + NonNullable + > + : NonNullable extends object + ? ObjectSchemaBuilder< + { + [K in keyof NonNullable & string]-?: SchemaForValue< + NonNullable[K] + >; + }, + undefined extends T ? false : true, + null extends T ? true : false + > + : SchemaBuilder< + NonNullable, + undefined extends T ? false : true, + null extends T ? true : false + >; + +type StorageValue = + InferExtensionMetadata extends { + columnType: infer SQL extends string; + } + ? string extends SQL + ? string | NonNullable> + : Lowercase extends + | 'bigint' + | 'bigserial' + | 'int8' + | `decimal${string}` + | `numeric${string}` + ? string + : NonNullable> + : NonNullable>; +/** Database columns are present; optional persisted values are represented by SQL null. */ +export type ReadValue = + | StorageValue + | (undefined extends InferType ? null : never) + | (null extends InferType ? null : never); +/** Structural schema for one decoded database column. */ +export type ColumnReadSchema = SchemaForValue>; +/** Derive a row's scalar properties, excluding explicitly declared navigation keys. */ +export type ObjectReadSchema = + S extends ObjectSchemaBuilder + ? ObjectSchemaBuilder<{ + [K in Exclude & string]: ColumnReadSchema< + P[K] + >; + }> + : never; + +/** Invalid or opaque query shapes are rejected instead of receiving invented schemas. */ +export class ReadSchemaError extends Error { + /** Describe an unsupported read shape or a decoded-value contract violation. */ + constructor(message: string) { + super(message); + this.name = 'ReadSchemaError'; + } +} + +/** @internal One shared description drives SQL projection, decoding and row metadata. */ +export interface ReadNode { + schema: ReadSchema; + exact: boolean; + decode(value: unknown, path: string): any; +} + +const exactSql = /^(numeric|decimal|bigint|bigserial|int8)(\b|\()/i; +const allowedSql: Record = { + string: /^(text|varchar|character varying|char|character|uuid|citext)(\b|\()/i, + number: /^(numeric|decimal|bigint|bigserial|int8|smallint|int2|integer|int|int4|serial|smallserial|real|float|float4|float8|double precision)(\b|\()/i, + boolean: /^(boolean|bool)$/i, + date: /^(date|timestamp|timestamptz)(\b|\()/i, + object: /^jsonb?$/i, + array: /^jsonb?$/i +}; + +/** @internal Compile output-only metadata, never replaying input preprocessors/defaults. */ +export function compileReadSchema(source: ReadSchema, column = true): ReadNode { + const info = source.introspect() as any; + // default() can promote the inferred input/output requirement without changing + // the underlying optional storage flag. That erased distinction cannot safely + // describe a read where input defaults are intentionally not replayed. + if (info.hasDefault && info.isRequired === false) + throw new ReadSchemaError( + 'Defaulted optional schemas have ambiguous read nullability; declare storage without input defaults' + ); + const sqlType = info.extensions?.columnType as string | undefined; + if (sqlType && !allowedSql[info.type]?.test(sqlType)) + throw new ReadSchemaError( + `Unsupported SQL type "${sqlType}" for ${info.type}` + ); + const nullable = info.isNullable || (column && info.isRequired === false); + const optional = !column && info.isRequired === false; + const exact = column && !!sqlType && exactSql.test(sqlType); + if (!column && sqlType && exactSql.test(sqlType)) + throw new ReadSchemaError( + 'Exact SQL numeric hints inside stored JSON cannot preserve precision; model those JSON values as strings' + ); + let schema: any; + let convert: (value: any, path: string) => any; + switch (info.type) { + case 'string': + schema = + info.equalsTo === undefined ? string() : string(info.equalsTo); + convert = value => value; + break; + case 'number': + schema = exact + ? string() + : info.equalsTo === undefined + ? number().isFloat() + : number(info.equalsTo); + convert = (value, path) => { + if (exact) { + if (typeof value !== 'string') + throw new ReadSchemaError( + `${path}: exact numeric text was not preserved by SQL` + ); + return value; + } + if (typeof value !== 'number' || !Number.isFinite(value)) + throw new ReadSchemaError( + `${path}: expected a finite database number` + ); + return value; + }; + break; + case 'boolean': + schema = boolean(); + convert = value => value; + break; + case 'date': + schema = date(); + convert = (value, path) => { + // Native dates/timestamps are projected as text at every SQL depth. + // Interpret timezone-less persisted values as UTC consistently; + // explicit offsets retain their meaning and input Date objects + // keep their instant. Do not depend on the driver's local timezone. + const text = + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)?$/.test( + value + ) + ? `${value.length === 10 ? `${value}T00:00:00` : value.replace(' ', 'T')}Z` + : value; + const result = + value instanceof Date + ? value + : typeof text === 'string' + ? new Date(text) + : null; + if (!result || !Number.isFinite(result.getTime())) + throw new ReadSchemaError(`${path}: invalid database date`); + return result; + }; + break; + case 'object': { + const children = Object.fromEntries( + Object.entries(info.properties ?? {}).map(([key, child]) => [ + key, + compileReadSchema(child as ReadSchema, false) + ]) + ); + schema = object( + Object.fromEntries( + Object.entries(children).map(([key, child]) => [ + key, + child.schema + ]) + ) + ); + convert = (value, path) => decodeObject(children, value, path); + break; + } + case 'array': { + if (!info.elementSchema) + throw new ReadSchemaError( + 'Array read schema requires an element schema' + ); + const child = compileReadSchema(info.elementSchema, false); + schema = array(child.schema); + convert = (value, path) => { + if (!Array.isArray(value)) + throw new ReadSchemaError(`${path}: expected an array`); + return value.map((item, index) => + child.decode(item, `${path}[${index}]`) + ); + }; + break; + } + case 'union': { + const children: ReadNode[] = info.options.map( + (option: ReadSchema) => compileReadSchema(option, column) + ); + schema = children + .slice(1) + .reduce( + (s: any, child) => s.or(child.schema), + union(children[0].schema) + ); + convert = (value, path) => { + for (const child of children) { + try { + return child.decode(value, path); + } catch (error) { + if (!(error instanceof ReadSchemaError)) throw error; + } + } + throw new ReadSchemaError( + `${path}: no matching database union member` + ); + }; + break; + } + default: + throw new ReadSchemaError( + `Cannot derive a database read schema for ${info.type}` + ); + } + if (nullable) schema = schema.nullable(); + if (optional) schema = schema.optional(); + return { + schema, + exact, + decode(value, path) { + if (value === null && nullable) return null; + if (value === undefined && optional) return undefined; + const converted = convert(value, path); + if (!schema.validate(converted).valid) + throw new ReadSchemaError( + `${path}: database value does not match the read schema` + ); + return converted; + } + }; +} + +/** @internal Decode exactly the described fields, excluding internal or unselected columns. */ +export function decodeObject( + children: Record, + value: any, + path: string +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new ReadSchemaError(`${path}: expected a database object`); + const result: Record = {}; + for (const [key, child] of Object.entries(children)) { + const decoded = child.decode(value[key], `${path}.${key}`); + if (decoded !== undefined) + Object.defineProperty(result, key, { + value: decoded, + enumerable: true, + configurable: true, + writable: true + }); + } + return result; +} + +/** @internal Cast exact values before PostgreSQL/JSON parsing can round them. */ +export function readExpression( + knex: Knex, + node: ReadNode, + column: string +): Knex.Raw { + return node.exact || node.schema.introspect().type === 'date' + ? knex.raw('cast(?? as text)', [column]) + : knex.raw('??', [column]); +} diff --git a/libs/knex-schema/src/read-storage.test-d.ts b/libs/knex-schema/src/read-storage.test-d.ts new file mode 100644 index 00000000..251b5178 --- /dev/null +++ b/libs/knex-schema/src/read-storage.test-d.ts @@ -0,0 +1,62 @@ +import { + type InferExtensionMetadata, + type InferType, + number as plainNumber +} from '@cleverbrush/schema'; +import { expectTypeOf, test } from 'vitest'; +import { number, type PRIMARY_KEY_BRAND } from './extension.js'; +import type { ReadValue } from './read-schema.js'; + +test('numeric storage metadata survives schema modifiers', () => { + const price = number() + .decimal(20, 4) + .optional() + .hasColumnName('price_value'); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + InferExtensionMetadata['columnType'] + >().toEqualTypeOf<`decimal(${number},${number})`>(); + const named = number() + .hasColumnName('price_value') + .decimal(20, 4) + .nullable() + .index(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + InferExtensionMetadata['columnType'] + >().toEqualTypeOf<`decimal(${number},${number})`>(); + const overridden = named.columnType('integer').required().notNullable(); + expectTypeOf< + InferExtensionMetadata['columnType'] + >().toEqualTypeOf<'integer'>(); + expectTypeOf>().toEqualTypeOf(); +}); + +test('storage inference retains precision, dynamic types and other brands', () => { + const id = number().primaryKey().bigint().optional().hasColumnName('key'); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + NonNullable<(typeof id)[typeof PRIMARY_KEY_BRAND]> + >().toEqualTypeOf(); + const small = id.smallint().required(); + expectTypeOf>().toEqualTypeOf(); + const exact = small.columnType('NUMERIC(24,6)').nullable().index(); + expectTypeOf>().toEqualTypeOf(); + const dynamic = (type: string) => number().columnType(type).optional(); + expectTypeOf>>().toEqualTypeOf< + number | string | null + >(); + // @ts-expect-error storage types must be strings + number().columnType(5); +}); + +test('database methods do not augment plain schema numbers', () => { + // @ts-expect-error database storage methods require the database factory + plainNumber().bigint(); + // @ts-expect-error database storage methods require the database factory + plainNumber().decimal(10, 2); + // @ts-expect-error database storage methods require the database factory + plainNumber().columnType('integer'); + // @ts-expect-error database storage methods require the database factory + plainNumber().smallint(); +}); diff --git a/libs/knex-schema/src/read-storage.test.ts b/libs/knex-schema/src/read-storage.test.ts new file mode 100644 index 00000000..c97559bc --- /dev/null +++ b/libs/knex-schema/src/read-storage.test.ts @@ -0,0 +1,25 @@ +import { number as plainNumber } from '@cleverbrush/schema'; +import { expect, test } from 'vitest'; +import { number } from './extension.js'; +import { compileReadSchema } from './read-schema.js'; + +test('native storage metadata is immutable and does not install global methods', () => { + const initial = number().decimal(24, 6).optional().hasColumnName('amount'); + const changed = initial.columnType('integer').required().index(); + expect(initial.introspect().extensions).toMatchObject({ + columnType: 'decimal(24,6)', + columnName: 'amount' + }); + expect(changed.introspect().extensions).toMatchObject({ + columnType: 'integer', + columnName: 'amount', + index: true + }); + expect( + compileReadSchema(initial).decode('123456789012345678.123456', 'amount') + ).toBe('123456789012345678.123456'); + expect(compileReadSchema(changed).decode(42, 'amount')).toBe(42); + for (const method of ['columnType', 'bigint', 'smallint', 'decimal']) { + expect(method in plainNumber()).toBe(false); + } +}); diff --git a/libs/knex-schema/src/types.ts b/libs/knex-schema/src/types.ts index 9832f2af..cd070d25 100644 --- a/libs/knex-schema/src/types.ts +++ b/libs/knex-schema/src/types.ts @@ -536,6 +536,11 @@ export interface RelationSpec { name: string; schema: any; foreignKey?: any; + /** Explicit schema property names retained for opt-in read correlation. */ + localKey?: string; + remoteKey?: string; + /** Nullable belongs-to relations do not filter out their parent rows. */ + optional?: boolean; through?: { table: string; localKey: string; foreignKey: string }; } diff --git a/libs/knex-schema/src/unit.test.ts b/libs/knex-schema/src/unit.test.ts index 70ece32b..18a70a4e 100644 --- a/libs/knex-schema/src/unit.test.ts +++ b/libs/knex-schema/src/unit.test.ts @@ -104,12 +104,12 @@ describe('schema extension', () => { it('hasColumnName survives .optional() chaining', () => { const schema = string().hasColumnName('col_name').optional(); - expect(schema.getExtension('columnName')).toBe('col_name'); + expect(schema.introspect().extensions.columnName).toBe('col_name'); }); it('hasColumnName survives .required() chaining', () => { const schema = number().hasColumnName('col_a').optional().required(); - expect(schema.getExtension('columnName')).toBe('col_a'); + expect(schema.introspect().extensions.columnName).toBe('col_a'); }); it('hasTableName survives .optional()/.required() chaining', () => { @@ -117,7 +117,7 @@ describe('schema extension', () => { .hasTableName('my_table') .optional() .required(); - expect(schema.getExtension('tableName')).toBe('my_table'); + expect(schema.introspect().extensions.tableName).toBe('my_table'); }); }); diff --git a/libs/knex-schema/tsconfig.typecheck.json b/libs/knex-schema/tsconfig.typecheck.json index c24a9fd6..fed7da48 100644 --- a/libs/knex-schema/tsconfig.typecheck.json +++ b/libs/knex-schema/tsconfig.typecheck.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.build.json", "compilerOptions": { "noEmit": true, "strict": true }, - "include": ["src/**/*.test-d.ts"], + "include": ["src/**/*.test-d.ts", "src/read-*.test.ts"], "exclude": [] } diff --git a/libs/knex-schema/vitest.config.mts b/libs/knex-schema/vitest.config.mts index c2329925..045819a5 100644 --- a/libs/knex-schema/vitest.config.mts +++ b/libs/knex-schema/vitest.config.mts @@ -5,7 +5,7 @@ export default defineConfig({ include: ['src/**/*.test.ts'], typecheck: { enabled: true, - include: ['src/**/*.test-d.ts'], + include: ['src/**/*.test-d.ts', 'src/read-*.test.ts'], tsconfig: './tsconfig.typecheck.json' } } diff --git a/libs/mapper/README.md b/libs/mapper/README.md index b8d83a78..4f0a7a5a 100644 --- a/libs/mapper/README.md +++ b/libs/mapper/README.md @@ -86,7 +86,7 @@ const dto = await mapFn({ 1. **Define schemas** — use `@cleverbrush/schema` to define source and target shapes 2. **Configure mappings** — use `.for()` to select a target property, then `.from()`, `.compute()`, or `.ignore()` to define how it's populated 3. **Auto-mapping fills the gaps** — properties with the same name and compatible type are mapped automatically -4. **Get a mapper function** — `registry.getMapper(from, to)` returns an async function that transforms objects +4. **Get a mapper function** — use `getMapper(from, to)` for an async function or `getSyncMapper(from, to)` when all steps are synchronous 5. **TypeScript enforces completeness** — if any target property is unmapped, you get a compile-time error ## Compile-Time Safety @@ -225,6 +225,32 @@ Every non-auto-mappable target property must be either mapped or explicitly igno - A **compile-time TypeScript type error** — a type-assignability mismatch that includes the unmapped property names in the type parameters - A **runtime `MapperConfigurationError`** if type checks are bypassed +## Synchronous mapping + +Use the same `configure()` API and retrieve a synchronous mapper when every final +step and nested mapping is synchronous: + +```ts +const registry = mapper().configure(Source, Target, m => m + .for(t => t.label).compute(s => s.name.toUpperCase())); +const toTarget = registry.getSyncMapper(Source, Target); +const results = rows.map(toTarget); // ordinary values, not promises +``` + +Completeness checking, `.from()`, ignores, nested objects/arrays and compatible +auto-mapping still apply. Async computations or nested async mappings reject +`getSyncMapper()` at compile time; runtime guards cover JavaScript and unsafe +casts too. Callbacks are never probed during configuration. A function falsely +typed as synchronous that returns a promise/thenable throws when invoked. +`getMapper()` still always returns an async function. There is no separate +`configureSync()` API. + +Configure once and reuse the same source/target schema instances. Queries can +provide their projection schema through `.withRowSchema().rowSchema`; see the +[projection-aware read guide](../knex-schema/README.md#projection-aware-reads), including separate +definition/mapping/service files and explicit polymorphic dispatch. The mapper +performs no database calls or application enrichment. + ## API ### `mapper()` @@ -252,6 +278,13 @@ const mapFn = registry.getMapper(ApiUser, DomainUser); const result = await mapFn(sourceObject); ``` +### `registry.getSyncMapper(fromSchema, toSchema)` + +Retrieves an ordinary function for a complete, synchronous mapping. It returns +the target value directly and can be passed to `rows.map(...)`. Async final steps +or nested async mappings are rejected. See [Synchronous mapping](#synchronous-mapping) +for runtime guards and schema identity requirements. + ### `Mapper` A fluent builder for configuring how each target property is populated: diff --git a/libs/mapper/src/MappingRegistry.ts b/libs/mapper/src/MappingRegistry.ts index c0a97b79..e07113fb 100644 --- a/libs/mapper/src/MappingRegistry.ts +++ b/libs/mapper/src/MappingRegistry.ts @@ -19,21 +19,129 @@ const SYMBOL_TARGET_PROPERTY_KEY: unique symbol = Symbol('targetPropertyKey'); * unmapped target properties. Using a symbol keeps it out of IntelliSense. */ const SYMBOL_UNMAPPED: unique symbol = Symbol('unmapped'); +const SYMBOL_STEPS: unique symbol = Symbol('mappingSteps'); + +type Step = Omit & + Record; +type IsAsync = 0 extends 1 & T + ? true + : [Extract>] extends [never] + ? false + : true; +// Inspect metadata instead of recursively comparing the entire fluent builder. +type ArraySchemaShape = { introspect(): { elementSchema: unknown } }; +type ObjectSchemaShape = { introspect(): { properties: unknown } }; +type SameProperties = [keyof A, keyof B] extends [keyof B, keyof A] + ? [A, B] extends [B, A] + ? true + : false + : false; +// Runtime registration still uses schema identity. At compile time compare the +// declared fields, not every recursive builder/validation method. Exact key sets +// prevent an empty or smaller schema from matching an unrelated registration. +type RegisteredMatch = S extends ObjectSchemaShape + ? T extends ObjectSchemaShape + ? Registered extends [infer From, infer To] + ? [InferType, InferType] extends [ + InferType, + InferType + ] + ? [InferType, InferType] extends [ + InferType, + InferType + ] + ? SameProperties< + ExtractSchemaProperties, + ExtractSchemaProperties + > extends true + ? SameProperties< + ExtractSchemaProperties, + ExtractSchemaProperties + > + : false + : false + : false + : false + : false + : false; +type NestedAsync = S extends ArraySchemaShape + ? T extends ArraySchemaShape + ? NestedAsync< + ExtractArrayElementSchema, + ExtractArrayElementSchema, + Registered + > + : false + : true extends RegisteredMatch + ? true + : false; +type AsyncKeys = { + [K in keyof ExtractSchemaProperties & string]: K extends keyof Steps + ? Steps[K] extends false + ? never + : K + : K extends keyof ExtractSchemaProperties + ? NestedAsync< + ExtractSchemaProperties[K], + ExtractSchemaProperties[K], + Registered + > extends false + ? never + : K + : never; +}[keyof ExtractSchemaProperties & string]; + +/** Compile-time diagnostics for incomplete or asynchronous mapping steps. */ +type SyncMappingArguments = [Unmapped] extends [ + never +] + ? [Async] extends [never] + ? [] + : [error: 'Mapping contains asynchronous steps'] + : [error: `Unmapped properties: ${Unmapped}`]; + +/** Compile-time diagnostics for registered synchronous mapping pairs. */ +type RegisteredSyncArguments = Pair extends Registered + ? Pair extends Async + ? [error: 'Mapping contains asynchronous steps'] + : [] + : [error: 'Mapping is not registered']; + +const syncImplementations = new WeakMap any>(); + +function assertSync(value: any, key: string): any { + if (value != null && typeof value.then === 'function') { + throw new MapperConfigurationError( + `Synchronous mapping returned a thenable for property "${key}"` + ); + } + return value; +} + +function synchronous(fn: Function): (source: any) => any { + const result = syncImplementations.get(fn); + if (!result) + throw new MapperConfigurationError( + 'Mapping contains an asynchronous computation or nested mapper' + ); + return result; +} // ── Helper Types ────────────────────────────────────────────────────── /** * Extracts the properties record from an ObjectSchemaBuilder. */ -type ExtractSchemaProperties = - T extends ObjectSchemaBuilder - ? TProperties - : never; +type ExtractSchemaProperties = T extends { + introspect(): { properties: infer TProperties }; +} + ? TProperties + : never; /** * Gets all top-level property key names of an ObjectSchemaBuilder. */ -type SchemaKeys> = +type SchemaKeys = keyof ExtractSchemaProperties & string; /** @@ -53,7 +161,7 @@ type TargetPropertyKey = { * type of the selector callback. */ type TargetPropertyTree< - TSchema extends ObjectSchemaBuilder, + TSchema extends ObjectSchemaBuilder, TAllowedKeys extends string > = { [K in SchemaKeys & TAllowedKeys]: TargetPropertyKey & @@ -65,7 +173,7 @@ type TargetPropertyTree< * by its key name. */ type SchemaPropertyInferredType< - TSchema extends ObjectSchemaBuilder, + TSchema extends ObjectSchemaBuilder, K extends string > = K extends keyof ExtractSchemaProperties ? InferType[K]> @@ -76,7 +184,7 @@ type SchemaPropertyInferredType< * ObjectSchemaBuilder by its key name. */ type TargetPropertySchema< - TSchema extends ObjectSchemaBuilder, + TSchema extends ObjectSchemaBuilder, K extends string > = K extends keyof ExtractSchemaProperties ? ExtractSchemaProperties[K] @@ -85,10 +193,11 @@ type TargetPropertySchema< /** * Extracts the element schema from an ArraySchemaBuilder. */ -type ExtractArrayElementSchema = - T extends ArraySchemaBuilder - ? TElementSchema - : never; +type ExtractArrayElementSchema = T extends { + introspect(): { elementSchema: infer TElementSchema }; +} + ? NonNullable + : never; /** * Extracts the property schema from a PropertyDescriptor's `getSchema()` @@ -117,19 +226,23 @@ type ExtractPropertySchema = T extends { * - Neither is ObjectSchemaBuilder + incompatible InferType → `true` */ type NeedsMapping = - TSourcePropSchema extends ArraySchemaBuilder - ? TTargetPropSchema extends ArraySchemaBuilder + TSourcePropSchema extends ArraySchemaShape + ? TTargetPropSchema extends ArraySchemaShape ? NeedsMapping< ExtractArrayElementSchema, ExtractArrayElementSchema, TRegistered > : true - : TTargetPropSchema extends ArraySchemaBuilder + : TTargetPropSchema extends ArraySchemaShape ? true - : TSourcePropSchema extends ObjectSchemaBuilder - ? TTargetPropSchema extends ObjectSchemaBuilder - ? [TSourcePropSchema, TTargetPropSchema] extends TRegistered + : TSourcePropSchema extends ObjectSchemaShape + ? TTargetPropSchema extends ObjectSchemaShape + ? true extends RegisteredMatch< + TSourcePropSchema, + TTargetPropSchema, + TRegistered + > ? false : InferType extends InferType ? InferType extends InferType @@ -137,7 +250,7 @@ type NeedsMapping = : true : true : true - : TTargetPropSchema extends ObjectSchemaBuilder + : TTargetPropSchema extends ObjectSchemaShape ? true : InferType extends InferType ? false @@ -149,8 +262,8 @@ type NeedsMapping = * Keys where `NeedsMapping` is `false` can be auto-mapped via the registry. */ type KeysNeedingMapping< - TFromSchema extends ObjectSchemaBuilder, - TToSchema extends ObjectSchemaBuilder, + TFromSchema extends ObjectSchemaBuilder, + TToSchema extends ObjectSchemaBuilder, TRegistered > = { [K in SchemaKeys]: K extends SchemaKeys @@ -176,8 +289,8 @@ type CheckSchemaCompatible = [InferType] extends [InferType] ? true : // Both arrays → recurse into element schemas - TSourceSchema extends ArraySchemaBuilder - ? TTargetSchema extends ArraySchemaBuilder + TSourceSchema extends ArraySchemaShape + ? TTargetSchema extends ArraySchemaShape ? CheckSchemaCompatible< ExtractArrayElementSchema, ExtractArrayElementSchema, @@ -185,9 +298,13 @@ type CheckSchemaCompatible = > : false : // Both objects → check registration - TSourceSchema extends ObjectSchemaBuilder - ? TTargetSchema extends ObjectSchemaBuilder - ? [TSourceSchema, TTargetSchema] extends TRegistered + TSourceSchema extends ObjectSchemaShape + ? TTargetSchema extends ObjectSchemaShape + ? true extends RegisteredMatch< + TSourceSchema, + TTargetSchema, + TRegistered + > ? true : false : false @@ -204,7 +321,7 @@ type CheckSchemaCompatible = */ type IsFromCompatible< TReturn, - TToSchema extends ObjectSchemaBuilder, + TToSchema extends ObjectSchemaBuilder, TKey extends string, TRegistered > = [ExtractPropertySchema] extends [never] @@ -217,11 +334,18 @@ type IsFromCompatible< // ── Mapper Result Type ──────────────────────────────────────────────── +/** A mapping function that always returns a Promise, including for pure mappings. */ export type SchemaToSchemaMapperResult< - TFromSchema extends ObjectSchemaBuilder, - TToSchema extends ObjectSchemaBuilder + TFromSchema extends ObjectSchemaBuilder, + TToSchema extends ObjectSchemaBuilder > = (from: InferType) => Promise>; +/** A complete synchronous mapping; unexpected thenables throw instead of leaking into DTOs. */ +export type SyncSchemaToSchemaMapperResult< + TFromSchema extends ObjectSchemaBuilder, + TToSchema extends ObjectSchemaBuilder +> = (from: InferType) => InferType; + // ── Error Class ─────────────────────────────────────────────────────── export class MapperConfigurationError extends Error { @@ -264,7 +388,7 @@ type MappingEntry = { function resolveElementMapper( sourceElementSchema: SchemaBuilder, targetElementSchema: SchemaBuilder, - registry: MappingRegistry | undefined + registry: MappingRegistry | undefined ): ((element: any) => Promise) | null { // Both element schemas are ObjectSchemaBuilder: look up registered mapper if ( @@ -290,7 +414,9 @@ function resolveElementMapper( fromKeys.length === toKeys.length && fromKeys.every((k, i) => k === toKeys[i]) ) { - return async (element: any) => element; + const copy = async (element: any) => element; + syncImplementations.set(copy, element => element); + return copy; } return null; } @@ -311,11 +437,19 @@ function resolveElementMapper( registry ); if (innerMapper) { - return async (arr: any) => { + const mapArray = async (arr: any) => { if (arr == null) return undefined; if (!Array.isArray(arr)) return arr; return Promise.all(arr.map(innerMapper)); }; + const sync = syncImplementations.get(innerMapper); + if (sync) + syncImplementations.set(mapArray, arr => { + if (arr == null) return undefined; + if (!Array.isArray(arr)) return arr; + return arr.map(sync); + }); + return mapArray; } } return null; @@ -345,7 +479,9 @@ function resolveElementMapper( ) { return null; } - return async (element: any) => element; + const copy = async (element: any) => element; + syncImplementations.set(copy, element => element); + return copy; } return null; @@ -361,18 +497,34 @@ function resolveElementMapper( * - `ignore()` — explicitly skip the property */ export class PropertyMappingBuilder< - TFromSchema extends ObjectSchemaBuilder, - TToSchema extends ObjectSchemaBuilder, + TFromSchema extends ObjectSchemaBuilder, + TToSchema extends ObjectSchemaBuilder, TKey extends string, TUnmapped extends string, - TRegistered = never + TRegistered = never, + TAsyncRegistered = never, + TSteps = {} > { - private readonly _mapper: Mapper; + private readonly _mapper: Mapper< + TFromSchema, + TToSchema, + any, + TRegistered, + TAsyncRegistered, + TSteps + >; private readonly _targetKey: TKey; /** @internal */ constructor( - mapper: Mapper, + mapper: Mapper< + TFromSchema, + TToSchema, + any, + TRegistered, + TAsyncRegistered, + TSteps + >, targetKey: TKey ) { this._mapper = mapper; @@ -407,11 +559,7 @@ export class PropertyMappingBuilder< } >( selector: ( - tree: PropertyDescriptorTree< - TFromSchema, - TFromSchema, - SchemaPropertyInferredType - > + tree: PropertyDescriptorTree ) => TReturn, ..._args: [TReturn] extends [never] ? [ @@ -427,7 +575,22 @@ export class PropertyMappingBuilder< : [ error: `Property '${TKey}': source property type is not assignable to the target property type. Use compute() instead.` ] - ): Mapper, TRegistered> { + ): Mapper< + TFromSchema, + TToSchema, + Exclude, + TRegistered, + TAsyncRegistered, + Step< + TSteps, + TKey, + NestedAsync< + ExtractPropertySchema, + TargetPropertySchema, + TAsyncRegistered + > + > + > { const sourceTree = ObjectSchemaBuilder.getPropertiesFor( this._mapper['_fromSchema'] ); @@ -466,10 +629,26 @@ export class PropertyMappingBuilder< targetPropSchema instanceof ArraySchemaBuilder ) { const sourceElementSchema = ( - sourceSchema as ArraySchemaBuilder + sourceSchema as ArraySchemaBuilder< + any, + any, + any, + any, + any, + any, + any + > ).introspect().elementSchema; const targetElementSchema = ( - targetPropSchema as ArraySchemaBuilder + targetPropSchema as ArraySchemaBuilder< + any, + any, + any, + any, + any, + any, + any + > ).introspect().elementSchema; if (sourceElementSchema && targetElementSchema) { @@ -501,15 +680,20 @@ export class PropertyMappingBuilder< * Computes the target property value from the entire source object. * Supports both sync and async functions. */ - public compute( - fn: - | (( - obj: InferType - ) => SchemaPropertyInferredType) - | (( - obj: InferType - ) => Promise>) - ): Mapper, TRegistered> { + public compute< + TResult extends + | SchemaPropertyInferredType + | Promise> + >( + fn: (obj: InferType) => TResult + ): Mapper< + TFromSchema, + TToSchema, + Exclude, + TRegistered, + TAsyncRegistered, + Step> + > { this._mapper['_mappings'].set(this._targetKey, { type: 'custom', fn: fn as any @@ -526,7 +710,9 @@ export class PropertyMappingBuilder< TFromSchema, TToSchema, Exclude, - TRegistered + TRegistered, + TAsyncRegistered, + Step > { this._mapper['_mappings'].set(this._targetKey, { type: 'ignore' @@ -555,17 +741,21 @@ export class PropertyMappingBuilder< * @typeParam TUnmapped - union of target property key names not yet mapped */ export class Mapper< - TFromSchema extends ObjectSchemaBuilder, - TToSchema extends ObjectSchemaBuilder, + TFromSchema extends ObjectSchemaBuilder, + TToSchema extends ObjectSchemaBuilder, TUnmapped extends string = SchemaKeys, - TRegistered = never + TRegistered = never, + TAsyncRegistered = never, + TSteps = {} > { /** Phantom property for structural type-checking of TUnmapped. */ declare readonly [SYMBOL_UNMAPPED]: TUnmapped; + /** @internal Tracks final per-property computations without executing them. */ + declare readonly [SYMBOL_STEPS]: TSteps; private readonly _fromSchema: TFromSchema; private readonly _toSchema: TToSchema; - private readonly _registry: MappingRegistry | undefined; + private readonly _registry: MappingRegistry | undefined; private readonly _mappings: Map = new Map(); /** @@ -577,7 +767,7 @@ export class Mapper< public constructor( fromSchema: TFromSchema, toSchema: TToSchema, - registry?: MappingRegistry + registry?: MappingRegistry ) { this._fromSchema = fromSchema; this._toSchema = toSchema; @@ -602,7 +792,9 @@ export class Mapper< TToSchema, TKey, TUnmapped, - TRegistered + TRegistered, + TAsyncRegistered, + TSteps > { // At runtime, use a Proxy to detect which property was accessed let capturedKey: string | undefined; @@ -637,6 +829,29 @@ export class Mapper< return new PropertyMappingBuilder(this, capturedKey as TKey); } + /** + * Compile a synchronous function when the complete mapping has no async steps. + * Eligibility is inferred through compute(), from(), nested mappings and overrides. + * Callbacks are never probed during configuration. A disguised Promise/thenable + * throws at invocation; known async functions/dependencies throw at compilation. + * @throws MapperConfigurationError for incomplete or asynchronous mappings. + */ + public getSyncMapper( + ..._args: SyncMappingArguments< + TUnmapped, + AsyncKeys + > + ): SyncSchemaToSchemaMapperResult { + return synchronous( + ( + this.getMapper as () => SchemaToSchemaMapperResult< + TFromSchema, + TToSchema + > + )() + ); + } + /** * Returns the configured async mapping function. * @@ -852,6 +1067,60 @@ export class Mapper< return result; }; + // The same finalized entries drive the sync executor. Only execution differs; + // descriptor selection, automatic mapping and completeness are shared above. + const syncEntries = [...mappings].map(([key, entry]) => { + const nested = entry.autoMapper ?? entry.elementMapper; + const nestedSync = nested + ? syncImplementations.get(nested) + : undefined; + if ( + (nested && !nestedSync) || + entry.fn?.constructor.name === 'AsyncFunction' + ) + return null; + return { key, entry, nestedSync }; + }); + if (syncEntries.every(entry => entry !== null)) { + syncImplementations.set(mapperFn, source => { + const result: Record = {}; + for (const { key, entry, nestedSync } of syncEntries) { + if (entry.type === 'ignore') continue; + let value: any; + if (entry.type === 'custom') + value = assertSync(entry.fn!(source), key); + else { + const read = + entry.sourceDescriptorInner.getValue(source); + if (!read.success) continue; + value = read.value; + if (entry.type === 'auto') { + if (value === undefined) continue; + value = assertSync(nestedSync!(value), key); + } else if (entry.type === 'autoArray') { + if (value == null) continue; + if (!Array.isArray(value)) + throw new MapperConfigurationError( + `Expected array for property "${key}" but got ${typeof value}` + ); + value = value.map(element => + assertSync(nestedSync!(element), key) + ); + } + } + const target = (targetTree as any)[key]?.[ + SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR + ]; + if (target) + target.setValue(result, value, { + createMissingStructure: true + }); + else result[key] = value; + } + return result; + }); + } + // Auto-register in the registry if available if (this._registry) { this._registry['_mappers'] @@ -865,18 +1134,18 @@ export class Mapper< // ── MappingRegistry ─────────────────────────────────────────────────── -export class MappingRegistry { +export class MappingRegistry { protected readonly _mappers: Map< - ObjectSchemaBuilder, + ObjectSchemaBuilder, Map< - ObjectSchemaBuilder, + ObjectSchemaBuilder, SchemaToSchemaMapperResult > > = new Map(); #ensureObjectSchemas( - fromSchema: ObjectSchemaBuilder, - toSchema: ObjectSchemaBuilder + fromSchema: ObjectSchemaBuilder, + toSchema: ObjectSchemaBuilder ): boolean { return !( !fromSchema || @@ -903,8 +1172,25 @@ export class MappingRegistry { * properties remain that cannot be auto-mapped */ public configure< - TFromSchema extends ObjectSchemaBuilder, - TToSchema extends ObjectSchemaBuilder + TFromSchema extends ObjectSchemaBuilder< + any, + any, + any, + any, + any, + any, + any + >, + TToSchema extends ObjectSchemaBuilder< + any, + any, + any, + any, + any, + any, + any + >, + TSteps = {} >( fromSchema: TFromSchema, toSchema: TToSchema, @@ -913,10 +1199,26 @@ export class MappingRegistry { TFromSchema, TToSchema, KeysNeedingMapping, - TRegistered + TRegistered, + TAsyncRegistered > - ) => Mapper - ): MappingRegistry { + ) => Mapper< + TFromSchema, + TToSchema, + never, + TRegistered, + TAsyncRegistered, + TSteps + > + ): MappingRegistry< + TRegistered | [TFromSchema, TToSchema], + | TAsyncRegistered + | ([ + AsyncKeys + ] extends [never] + ? never + : [TFromSchema, TToSchema]) + > { if (!this.#ensureObjectSchemas(fromSchema, toSchema)) { throw new Error( 'Both fromSchema and toSchema must be instances of ObjectSchemaBuilder' @@ -933,7 +1235,13 @@ export class MappingRegistry { // Create new immutable registry with cloned mappings const newRegistry = new MappingRegistry< - TRegistered | [TFromSchema, TToSchema] + TRegistered | [TFromSchema, TToSchema], + | TAsyncRegistered + | ([ + AsyncKeys + ] extends [never] + ? never + : [TFromSchema, TToSchema]) >(); for (const [from, toMap] of this._mappers) { newRegistry._mappers.set(from, new Map(toMap)); @@ -949,25 +1257,50 @@ export class MappingRegistry { TFromSchema, TToSchema, KeysNeedingMapping, - TRegistered + TRegistered, + TAsyncRegistered >(fromSchema, toSchema, newRegistry); // Configure via user callback const configuredMapper = fn(mapper); // Implicit finalization: auto-map + validate + register - ( - configuredMapper as Mapper< - TFromSchema, - TToSchema, - never, - TRegistered - > - ).getMapper(); + configuredMapper.getMapper(); return newRegistry; } + /** + * Retrieve a registered synchronous mapper inferred from configure(). + * The pair must be registered and its final steps must all be synchronous. + * Async mappings remain usable with getMapper(); configuration never executes + * compute callbacks. Unexpected thenables throw when the returned mapper runs. + * @throws MapperConfigurationError for an asynchronous dependency. + * @throws Error if the schema pair has not been registered. + */ + public getSyncMapper< + TFromSchema extends ObjectSchemaBuilder< + any, + any, + any, + any, + any, + any, + any + >, + TToSchema extends ObjectSchemaBuilder + >( + fromSchema: TFromSchema, + toSchema: TToSchema, + ..._args: RegisteredSyncArguments< + [TFromSchema, TToSchema], + TRegistered, + TAsyncRegistered + > + ): SyncSchemaToSchemaMapperResult { + return synchronous(this.getMapper(fromSchema, toSchema)); + } + /** * Gets a mapper function that will map from the fromSchema to the toSchema. * Throws an error if no mapper is found for the given schemas pair. @@ -977,8 +1310,16 @@ export class MappingRegistry { * return a promise resolving to a value of the toSchema type */ public getMapper< - TFromSchema extends ObjectSchemaBuilder, - TToSchema extends ObjectSchemaBuilder + TFromSchema extends ObjectSchemaBuilder< + any, + any, + any, + any, + any, + any, + any + >, + TToSchema extends ObjectSchemaBuilder >( fromSchema: TFromSchema, toSchema: TToSchema diff --git a/libs/mapper/src/index.ts b/libs/mapper/src/index.ts index 22c3d837..4eb8448c 100644 --- a/libs/mapper/src/index.ts +++ b/libs/mapper/src/index.ts @@ -1,4 +1,7 @@ -export type { SchemaToSchemaMapperResult } from './MappingRegistry.js'; +export type { + SchemaToSchemaMapperResult, + SyncSchemaToSchemaMapperResult +} from './MappingRegistry.js'; export { Mapper, MapperConfigurationError, diff --git a/libs/mapper/src/sync.test.ts b/libs/mapper/src/sync.test.ts new file mode 100644 index 00000000..65a29571 --- /dev/null +++ b/libs/mapper/src/sync.test.ts @@ -0,0 +1,180 @@ +import { array, number, object, string } from '@cleverbrush/schema'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { Mapper, MapperConfigurationError, mapper } from './index.js'; + +const Source = object({ id: number(), title: string() }); +const Target = object({ id: number(), label: string() }); + +describe('inferred synchronous mapping', () => { + it('does not confuse unrelated registered schemas with primitive steps', () => { + const Id = object({ id: number() }); + const IdDto = object({ id: number() }); + const registry = mapper() + .configure(Source, Target, m => + m.for(t => t.label).compute(async s => s.title) + ) + .configure(Id, IdDto, m => m); + expect(registry.getSyncMapper(Id, IdDto)({ id: 1 })).toEqual({ id: 1 }); + const OptionalChild = object({ child: Source.optional() }); + const RequiredChild = object({ child: Target }); + expect(() => { + // @ts-expect-error a required child mapping does not map an optional source + registry.configure(OptionalChild, RequiredChild, m => m); + }).toThrow(/not mapped/); + const Empty = object({}); + const Parent = object({ child: Source }); + const ParentDto = object({ child: Target }); + const emptyRegistry = mapper().configure(Empty, Empty, m => m); + expect(() => { + // @ts-expect-error an empty registration cannot supply the missing child mapping + emptyRegistry.configure(Parent, ParentDto, m => m); + }).toThrow(/not mapped/); + }); + it('returns an ordinary value and preserves the async API', async () => { + const compute = vi.fn((s: { title: string }) => s.title.toUpperCase()); + const registry = mapper().configure(Source, Target, m => + m.for(t => t.label).compute(compute) + ); + const sync = registry.getSyncMapper(Source, Target); + expect(compute).not.toHaveBeenCalled(); + const result = sync({ id: 1, title: 'hello' }); + expectTypeOf(result).toEqualTypeOf<{ id: number; label: string }>(); + expect(result).toEqual({ id: 1, label: 'HELLO' }); + const asyncResult = registry.getMapper( + Source, + Target + )({ id: 1, title: 'hello' }); + expect(asyncResult).toBeInstanceOf(Promise); + expect(await asyncResult).toEqual(result); + }); + + it('rejects async computations at compile time and runtime', () => { + const registry = mapper().configure(Source, Target, m => + m.for(t => t.label).compute(async s => s.title) + ); + expect(() => { + // @ts-expect-error asynchronous mapping cannot be retrieved synchronously + registry.getSyncMapper(Source, Target); + }).toThrow(MapperConfigurationError); + }); + + it('tracks nested objects and arrays including explicit from', () => { + const Parent = object({ child: Source, children: array(Source) }); + const Dto = object({ child: Target, children: array(Target) }); + const registry = mapper() + .configure(Source, Target, m => + m.for(t => t.label).compute(s => s.title) + ) + .configure(Parent, Dto, m => + m.for(t => t.children).from(s => s.children) + ); + expect( + registry.getSyncMapper( + Parent, + Dto + )({ + child: { id: 1, title: 'one' }, + children: [{ id: 2, title: 'two' }] + }) + ).toEqual({ + child: { id: 1, label: 'one' }, + children: [{ id: 2, label: 'two' }] + }); + const asyncRegistry = mapper() + .configure(Source, Target, m => + m.for(t => t.label).compute(async s => s.title) + ) + .configure(Parent, Dto, m => m); + expect(() => { + // @ts-expect-error nested async mapping makes the parent async + asyncRegistry.getSyncMapper(Parent, Dto); + }).toThrow(MapperConfigurationError); + }); + + it('uses final overrides, not discarded async computations', () => { + const registry = mapper().configure(Source, Target, m => + m + .for(t => t.label) + .compute(async s => s.title) + .for(t => t.label) + .compute(s => s.title) + ); + expect( + registry.getSyncMapper(Source, Target)({ id: 3, title: 'three' }) + ).toEqual({ id: 3, label: 'three' }); + }); + + it('guards functions returning disguised thenables without probing them', () => { + const registry = mapper().configure(Source, Target, m => + m + .for(t => t.label) + // biome-ignore lint/suspicious/noThenProperty: test a deliberately disguised thenable + .compute(() => ({ then() {} }) as unknown as string) + ); + expect(() => + registry.getSyncMapper(Source, Target)({ id: 1, title: '' }) + ).toThrow(/thenable.*label/); + }); + + it('retains completeness checks for direct builders', () => { + const direct = new Mapper(Source, Target) + .for(t => t.id) + .from(s => s.id) + .for(t => t.label) + .compute(s => s.title); + expect(direct.getSyncMapper()({ id: 1, title: 'one' })).toEqual({ + id: 1, + label: 'one' + }); + expect(() => { + // @ts-expect-error both target properties are still unmapped + new Mapper(Source, Target).getSyncMapper(); + }).toThrow(/not mapped/); + }); + + it('rejects conditional promises and async nested arrays without probing callbacks', () => { + const compute = vi.fn((s: { title: string }) => + s.title ? s.title : Promise.resolve('fallback') + ); + const registry = mapper().configure(Source, Target, m => + m.for(t => t.label).compute(compute) + ); + const Parent = object({ groups: array(array(Source)) }); + const Dto = object({ groups: array(array(Target)) }); + const nested = registry.configure(Parent, Dto, m => m); + expect(compute).not.toHaveBeenCalled(); + // Non-native async functions cannot be recognized without invoking them; + // the type system rejects retrieval, and invocation guards the false branch. + // @ts-expect-error a conditional Promise is still asynchronous + const unsafe = nested.getSyncMapper(Parent, Dto); + expect(() => unsafe({ groups: [[{ id: 1, title: '' }]] })).toThrow( + /thenable/ + ); + }); + + it('matches async values and errors for optional arrays and thrown computations', async () => { + const From = object({ items: array(Source).optional() }); + const To = object({ items: array(Target).optional() }); + const registry = mapper() + .configure(Source, Target, m => + m + .for(t => t.label) + .compute(s => { + if (!s.title) throw new Error('missing title'); + return s.title; + }) + ) + .configure(From, To, m => m); + const sync = registry.getSyncMapper(From, To); + const async = registry.getMapper(From, To); + for (const value of [ + {}, + { items: [] }, + { items: [{ id: 1, title: 'one' }] } + ]) + expect(sync(value)).toEqual(await async(value)); + const invalid = { items: [{ id: 1, title: '' }] }; + expect(() => sync(invalid)).toThrow('missing title'); + await expect(async(invalid)).rejects.toThrow('missing title'); + }); +}); diff --git a/libs/mapper/vitest.config.mts b/libs/mapper/vitest.config.mts new file mode 100644 index 00000000..2a17c278 --- /dev/null +++ b/libs/mapper/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['src/**/*.test.ts'], + tsconfig: './tsconfig.json' + } + } +}); diff --git a/libs/orm/README.md b/libs/orm/README.md index 35226880..5d78ac10 100644 --- a/libs/orm/README.md +++ b/libs/orm/README.md @@ -21,8 +21,6 @@ npm install @cleverbrush/orm `knex` is a peer dependency (install it alongside `@cleverbrush/orm`). `@cleverbrush/knex-schema` is a direct dependency and is installed automatically. ---- - ## Quick start ### 1. Define a schema and entity @@ -82,6 +80,7 @@ const updated = await db.users.save({ id: 1, email: 'alice@example.com', name: ' | `.include(t => t.rel)` | Eager-loads a relation (chainable) | | `.save(graph)` | Insert or update a row graph (transactional) | | `.ofVariant(key)` | Return a typed `VariantDbSet` scoped to a polymorphic variant | +| `.withRowSchema()` | Creates a detached read-only query with a matching result schema | | `.query()` | Returns the underlying `EntityQuery` for advanced querying | | `.withTransaction(trx)` | Returns a new `DbSet` bound to an existing transaction | @@ -212,6 +211,29 @@ async function updateUser(userId: number) { --- +## Detached reads with result schemas + +`db.users.withRowSchema()` enters an immutable read-only API whose `rowSchema` +matches its decoded selection and includes. Results remain **detached even when +the context uses `{ tracking: true }`**. This avoids attaching partial projections +as incomplete tracked entities. Ordinary entity queries keep their old behavior. + +```ts +const read = db.users.withRowSchema() + .select(u => ({ id: u.id, name: u.name })); +const Source = read.rowSchema; +const toDto = mapper().configure(Source, UserDto, m => m) + .getSyncMapper(Source, UserDto); +const users = (await read).map(toDto); +``` + +Typed relation customizers return their configured query; nested graphs are +decoded in one SQL statement. STI/CTI readers expose `variantRowSchemas` for +explicit application mapping. See the [read-schema consumer guide](../knex-schema/README.md#projection-aware-reads) +for numeric/null/date rules, examples and compatibility boundaries. + +--- + ## Polymorphic entities (STI / CTI) ### Single-Table Inheritance (STI) @@ -371,7 +393,7 @@ schema, including variant queries when the relation schema is known. `paginateAfter({ limit, cursor, orderBy: [...] })` supports non-null scalar sorts with a declared unique tie-breaker; single-column cursor calls are unchanged. -See the [complete query guide](../knex-schema/COMPOSABLE_QUERIES.md) for defaults, +See the [complete query guide](../knex-schema/README.md#composable-read-queries) for defaults, precision policy, grouped aggregates, cursor restrictions, and migration examples. ## Related packages diff --git a/libs/orm/src/dbset.ts b/libs/orm/src/dbset.ts index 2369ce8d..35110ac4 100644 --- a/libs/orm/src/dbset.ts +++ b/libs/orm/src/dbset.ts @@ -15,7 +15,8 @@ import type { Entity, EntityRelations, EntitySchema, - PrimaryKeyValueOf + PrimaryKeyValueOf, + SchemaAwareQuery } from '@cleverbrush/knex-schema'; import { getPrimaryKeyColumns, @@ -46,7 +47,9 @@ import { updateVariant as _updateVariant } from './variant-write.js'; -const scalarAggregateMethods = new Set([ +// These methods return scalars or detached read plans, not tracked entity rows. +const untrackedResultMethods = new Set([ + 'withRowSchema', 'countValue', 'countDistinctValue', 'sumValue', @@ -73,8 +76,13 @@ const scalarAggregateMethods = new Set([ export interface EntityQuery, TResult> extends Omit< SchemaQueryBuilder, TResult>, - 'include' | 'includeVariant' + 'include' | 'includeVariant' | 'withRowSchema' > { + /** + * Start an immutable detached read model with inferred runtime row schemas. + * Enable before select/include operations; results never attach to tracking. + */ + withRowSchema(): SchemaAwareQuery>; /** * Eager-load a relation declared on `TEntity` via `.hasOne()` / * `.hasMany()` / `.belongsTo()` / `.belongsToMany()`. Selector returns @@ -465,7 +473,7 @@ function wrapQuery, TResult>( if ( onResults != null && sqb.returnsEntityRows && - !scalarAggregateMethods.has(prop) && + !untrackedResultMethods.has(prop) && result != null && typeof (result as any).then === 'function' ) { @@ -960,7 +968,7 @@ function wrapVariantQuery< if ( onResults != null && sqb.returnsEntityRows && - !scalarAggregateMethods.has(prop) && + !untrackedResultMethods.has(prop) && result != null && typeof (result as any).then === 'function' ) { diff --git a/libs/schema/README.md b/libs/schema/README.md index e8c5cf37..36bf1af8 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -1565,6 +1565,55 @@ console.log(priceSchema.introspect().extensions.currency); // { maxDecimals: 4 } — structured metadata, not the raw args ``` +### Typed metadata methods + +Use `defineMetadataMethod()` when a method only records metadata and its value +must also be available to TypeScript consumers. It works inside the same +`defineExtension()` / `withExtensions()` system: + +```ts +import { + defineExtension, defineMetadataMethod, withExtensions, + type InferExtensionMetadata, type InferType +} from '@cleverbrush/schema'; + +const labels = defineExtension({ + string: { + label: defineMetadataMethod('label').argument(), + internal: defineMetadataMethod('visibility').value('internal'), + lengthHint: defineMetadataMethod('lengthHint').compute( + (min: number, max: number) => ({ min, max }) + ) + } +}); +const s = withExtensions(labels); +const title = s.string().label('Title').internal().optional(); +type Value = InferType; // string | undefined +type Label = InferExtensionMetadata['label']; // 'Title' + +const renamed = title.label('Display name').lengthHint(1, 80); +type UpdatedLabel = InferExtensionMetadata['label']; // 'Display name' +renamed.introspect().extensions.lengthHint; // { min: 1, max: 80 } +title.introspect().extensions.label; // 'Title' — original is unchanged +``` + +- `.argument()` creates a one-argument method constrained to `T` and retains + the caller's literal type. Omit `T` to accept any metadata value. +- `.value(value)` creates a zero-argument method storing a fixed value. +- `.compute(fn)` retains the callback's parameter and return types. Use a literal + or template-literal return type when that precision matters; arbitrary callback + results cannot be evaluated by TypeScript. + +The key passed to `defineMetadataMethod` controls runtime storage independently +of the method name. A subsequent write replaces that key's value and inferred +type. Metadata and ordinary extension methods compose through native modifiers +without resetting optionality, nullability or defaults. Only factories produced +by `withExtensions()` receive the methods; global builder prototypes are not +modified. Metadata is descriptive: these helpers do not add validators, transform +schema values or assign domain meaning to keys. Consumers decide how to use it. +Ordinary `defineExtension()` methods remain appropriate for validation and other +behavior; use `this.withExtension()` inside them for runtime-only metadata. + ### Stacking Extensions Multiple extensions can be stacked — their methods are merged per builder type. A runtime error is thrown if two extensions define the same method name on the same builder type: diff --git a/libs/schema/src/builders/AnySchemaBuilder.ts b/libs/schema/src/builders/AnySchemaBuilder.ts index 451a85c1..85341c0c 100644 --- a/libs/schema/src/builders/AnySchemaBuilder.ts +++ b/libs/schema/src/builders/AnySchemaBuilder.ts @@ -41,6 +41,16 @@ export class AnySchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'any', + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; + /** * @hidden */ diff --git a/libs/schema/src/builders/ArraySchemaBuilder.ts b/libs/schema/src/builders/ArraySchemaBuilder.ts index 2d41dba6..0cca75e6 100644 --- a/libs/schema/src/builders/ArraySchemaBuilder.ts +++ b/libs/schema/src/builders/ArraySchemaBuilder.ts @@ -112,6 +112,17 @@ export class ArraySchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'array', + TElementSchema, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; + #minLength?: number; #defaultMinLengthErrorMessageProvider: ValidationErrorMessageProvider< ArraySchemaBuilder diff --git a/libs/schema/src/builders/BooleanSchemaBuilder.ts b/libs/schema/src/builders/BooleanSchemaBuilder.ts index 2a3815f4..6b9a83d2 100644 --- a/libs/schema/src/builders/BooleanSchemaBuilder.ts +++ b/libs/schema/src/builders/BooleanSchemaBuilder.ts @@ -55,6 +55,17 @@ export class BooleanSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'boolean', + TResult, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TFinalResult + ]; + #equalsTo?: boolean; #defaultEqualsToErrorMessageProvider: ValidationErrorMessageProvider< BooleanSchemaBuilder diff --git a/libs/schema/src/builders/DateSchemaBuilder.ts b/libs/schema/src/builders/DateSchemaBuilder.ts index 161efd5d..006eb8d7 100644 --- a/libs/schema/src/builders/DateSchemaBuilder.ts +++ b/libs/schema/src/builders/DateSchemaBuilder.ts @@ -86,6 +86,15 @@ export class DateSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'date', + TResult, + TRequired, + TNullable, + THasDefault + ]; + #min?: Date; #defaultMinErrorMessageProvider: ValidationErrorMessageProvider< DateSchemaBuilder diff --git a/libs/schema/src/builders/FunctionSchemaBuilder.ts b/libs/schema/src/builders/FunctionSchemaBuilder.ts index b8aa381b..31d85da2 100644 --- a/libs/schema/src/builders/FunctionSchemaBuilder.ts +++ b/libs/schema/src/builders/FunctionSchemaBuilder.ts @@ -87,6 +87,18 @@ export class FunctionSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'func', + TRequired, + TNullable, + TExplicitType, + THasDefault, + TParameters, + TReturnTypeSchema, + TResult + ]; + #parameters: SchemaBuilder[] = []; #returnType?: SchemaBuilder; diff --git a/libs/schema/src/builders/GenericSchemaBuilder.ts b/libs/schema/src/builders/GenericSchemaBuilder.ts index 54dd9566..4e6b40e2 100644 --- a/libs/schema/src/builders/GenericSchemaBuilder.ts +++ b/libs/schema/src/builders/GenericSchemaBuilder.ts @@ -113,6 +113,17 @@ export class GenericSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'generic', + TFn, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; + #templateFn?: (...args: any[]) => SchemaBuilder; #defaults?: readonly any[]; #cachedDefaultSchema?: SchemaBuilder; diff --git a/libs/schema/src/builders/NumberSchemaBuilder.ts b/libs/schema/src/builders/NumberSchemaBuilder.ts index 951dc148..d15d4b22 100644 --- a/libs/schema/src/builders/NumberSchemaBuilder.ts +++ b/libs/schema/src/builders/NumberSchemaBuilder.ts @@ -63,6 +63,15 @@ export class NumberSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'number', + TResult, + TRequired, + TNullable, + THasDefault + ]; + #min?: number; #defaultMinErrorMessageProvider: ValidationErrorMessageProvider< NumberSchemaBuilder diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.ts b/libs/schema/src/builders/ObjectSchemaBuilder.ts index a21c58a8..3be56a3c 100644 --- a/libs/schema/src/builders/ObjectSchemaBuilder.ts +++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts @@ -378,6 +378,17 @@ export class ObjectSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'object', + TProperties, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TConstructorSchemas + ]; + #properties: TProperties = {} as any; #acceptUnknownProps = false; #propKeys: string[] = []; diff --git a/libs/schema/src/builders/PromiseSchemaBuilder.ts b/libs/schema/src/builders/PromiseSchemaBuilder.ts index b1c2f594..61da354d 100644 --- a/libs/schema/src/builders/PromiseSchemaBuilder.ts +++ b/libs/schema/src/builders/PromiseSchemaBuilder.ts @@ -73,6 +73,17 @@ export class PromiseSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'promise', + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResolvedTypeSchema, + TResult + ]; + #resolvedType?: SchemaBuilder; /** diff --git a/libs/schema/src/builders/RecordSchemaBuilder.ts b/libs/schema/src/builders/RecordSchemaBuilder.ts index 18c6ac3b..ab865908 100644 --- a/libs/schema/src/builders/RecordSchemaBuilder.ts +++ b/libs/schema/src/builders/RecordSchemaBuilder.ts @@ -243,6 +243,18 @@ export class RecordSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'record', + TKeySchema, + TValueSchema, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; + #keySchema!: TKeySchema; #valueSchema!: TValueSchema; diff --git a/libs/schema/src/builders/StringSchemaBuilder.ts b/libs/schema/src/builders/StringSchemaBuilder.ts index 5323b96c..166b9406 100644 --- a/libs/schema/src/builders/StringSchemaBuilder.ts +++ b/libs/schema/src/builders/StringSchemaBuilder.ts @@ -80,6 +80,15 @@ export class StringSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'string', + TResult, + TRequired, + TNullable, + THasDefault + ]; + #minLength?: number; #defaultMinLengthErrorMessageProvider: ValidationErrorMessageProvider< StringSchemaBuilder diff --git a/libs/schema/src/builders/TupleSchemaBuilder.ts b/libs/schema/src/builders/TupleSchemaBuilder.ts index 9f95b748..0fb1c9d1 100644 --- a/libs/schema/src/builders/TupleSchemaBuilder.ts +++ b/libs/schema/src/builders/TupleSchemaBuilder.ts @@ -162,6 +162,18 @@ export class TupleSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'tuple', + TElements, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TRestSchema, + TResult + ]; + #elements!: TElements; #restSchema: | (TRestSchema & SchemaBuilder) diff --git a/libs/schema/src/builders/UnionSchemaBuilder.ts b/libs/schema/src/builders/UnionSchemaBuilder.ts index 2aaf8e4b..ede1d90e 100644 --- a/libs/schema/src/builders/UnionSchemaBuilder.ts +++ b/libs/schema/src/builders/UnionSchemaBuilder.ts @@ -174,6 +174,16 @@ export class UnionSchemaBuilder< THasDefault, TExtensions > { + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + declare readonly __cleverbrush_builder_type__: readonly [ + 'union', + TOptions, + TRequired, + TNullable, + TExplicitType, + THasDefault + ]; + #options!: TOptions; #discriminatorKey: string | null = null; #discriminatorMap: Map< diff --git a/libs/schema/src/core.ts b/libs/schema/src/core.ts index 8a8d60f7..c177c919 100644 --- a/libs/schema/src/core.ts +++ b/libs/schema/src/core.ts @@ -87,13 +87,21 @@ export type { CleanExtended, ExtensionConfig, ExtensionDescriptor, + ExtraProperties, ExtraTypeBrandSymbol, FixedMethods, HiddenExtensionMethods, + InferExtensionMetadata, + MetadataExtended, + MetadataMethod, + MetadataMethodBuilder, + MetadataMethods, + MetadataState, MethodLiteralBrandSymbol } from './extension.js'; export { defineExtension, + defineMetadataMethod, EXTRA_TYPE_BRAND, METHOD_LITERAL_BRAND, withExtensions diff --git a/libs/schema/src/extension.ts b/libs/schema/src/extension.ts index 9569434c..ba53cf5a 100644 --- a/libs/schema/src/extension.ts +++ b/libs/schema/src/extension.ts @@ -91,6 +91,19 @@ import type { import { StringSchemaBuilder, string } from './builders/StringSchemaBuilder.js'; import { TupleSchemaBuilder, tuple } from './builders/TupleSchemaBuilder.js'; import { UnionSchemaBuilder, union } from './builders/UnionSchemaBuilder.js'; +import type { MetadataExtended } from './metadata-extension.js'; +import { isMetadataMethod } from './metadata-extension.js'; + +export type { + ExtraProperties, + InferExtensionMetadata, + MetadataExtended, + MetadataMethod, + MetadataMethodBuilder, + MetadataMethods, + MetadataState +} from './metadata-extension.js'; +export { defineMetadataMethod } from './metadata-extension.js'; // --------------------------------------------------------------------------- // Builder type name mapping @@ -432,9 +445,7 @@ export type FixedMethods< * `withExtension` / `getExtension` overridden to `never` so they * don't appear as callable in consumer code. */ -export type CleanExtended = TBuilder & - FixedMethods & - HiddenExtensionMethods; +export type CleanExtended = MetadataExtended; // -- Factory types that return builders with corrected extension methods ------ @@ -747,6 +758,10 @@ export function defineExtension( `Extension method "${builderName}.${methodName}" must be a function` ); } + if (isMetadataMethod(origMethod)) { + wrappedConfig[builderName][methodName] = origMethod; + continue; + } // Wrap the method to auto-infer extension key if not already set wrappedConfig[builderName][methodName] = function ( this: any, diff --git a/libs/schema/src/metadata-extension.test-d.ts b/libs/schema/src/metadata-extension.test-d.ts new file mode 100644 index 00000000..34c569f2 --- /dev/null +++ b/libs/schema/src/metadata-extension.test-d.ts @@ -0,0 +1,210 @@ +import { expectTypeOf, test } from 'vitest'; +import { + defineExtension, + defineMetadataMethod, + EXTRA_TYPE_BRAND, + type InferExtensionMetadata, + METHOD_LITERAL_BRAND, + withExtensions +} from './extension.js'; +import { + type DateSchemaBuilder, + type InferType, + type NumberSchemaBuilder, + number, + type ObjectSchemaBuilder, + type object, + string +} from './index.js'; + +const tagged = defineExtension({ + number: { + unit: defineMetadataMethod('unit').argument(), + metres: defineMetadataMethod('unit').value('m'), + rangeHint: defineMetadataMethod('range').compute( + (min: number, max: number) => [min, max] as const + ), + positive(this: NumberSchemaBuilder) { + return this.min(0); + } + }, + string: { label: defineMetadataMethod('label').argument() }, + object: { tag: defineMetadataMethod('tag').argument() } +}); + +test('ordinary extensions retain receiver types even without typed metadata methods', () => { + const labels = defineExtension({ + date: { + label( + this: DateSchemaBuilder, + text: string + ) { + return this.withExtension('label', text); + } + } + }); + const value = withExtensions(labels) + .date() + .optional() + .label('Updated') + .nullable(); + expectTypeOf>().toEqualTypeOf< + Date | undefined | null + >(); + expectTypeOf>().not.toBeAny(); +}); + +test('metadata replacement and native modifiers preserve precise value types', () => { + const s = withExtensions(tagged); + const base = s.number().unit('cm').optional().nullable().positive(); + expectTypeOf>().toEqualTypeOf< + number | undefined | null + >(); + expectTypeOf< + InferExtensionMetadata['unit'] + >().toEqualTypeOf<'cm'>(); + const changed = base.metres().rangeHint(0, 10).required().notNullable(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + InferExtensionMetadata['unit'] + >().toEqualTypeOf<'m'>(); + expectTypeOf< + InferExtensionMetadata['range'] + >().toEqualTypeOf(); + const defaulted = base.default(2).unit('km'); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error metadata argument constraints are enforced + base.unit(12); + // @ts-expect-error computed metadata arguments remain typed + base.rangeHint('min', 'max'); +}); + +test('metadata is independent of builder kind and retains structural edits', () => { + const s = withExtensions(tagged); + const text = s.string().label('Title').optional().minLength(1); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + InferExtensionMetadata['label'] + >().toEqualTypeOf<'Title'>(); + const record = s + .object({ name: string() }) + .tag('input') + .addProp('title', text) + .tag('output'); + expectTypeOf>().toEqualTypeOf<{ + name: string; + title?: string; + }>(); + expectTypeOf< + InferExtensionMetadata['tag'] + >().toEqualTypeOf<'output'>(); + expectTypeOf< + InferExtensionMetadata> + >().toEqualTypeOf<{}>(); +}); + +test('metadata works across every extension factory kind', () => { + const methods = { tag: defineMetadataMethod('tag').argument() }; + const s = withExtensions( + defineExtension({ + boolean: methods, + date: methods, + array: methods, + tuple: methods, + record: methods, + union: methods, + any: methods, + promise: methods, + generic: methods, + func: methods + }) + ); + const bool = s.boolean().optional().tag('bool').nullable(); + const date = s.date().tag('date').optional(); + const array = s.array(number()).tag('array').optional(); + const tuple = s.tuple([string(), number()]).tag('tuple').optional(); + const record = s.record(string(), number()).tag('record').optional(); + const union = s.union(string()).or(number()).tag('union').optional(); + const any = s.any().hasType<{ value: string }>().tag('any').optional(); + const promise = s.promise(string()).tag('promise').optional(); + const generic = s.generic((value: string) => string(value)).tag('generic'); + const fn = s + .func() + .addParameter(string()) + .hasReturnType(number()) + .tag('function'); + expectTypeOf>().toEqualTypeOf< + boolean | undefined | null + >(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number[] | undefined + >(); + expectTypeOf>().toEqualTypeOf< + [string, number] | undefined + >(); + expectTypeOf>().toEqualTypeOf< + Record | undefined + >(); + expectTypeOf>().toEqualTypeOf< + string | number | undefined + >(); + expectTypeOf>().toEqualTypeOf< + { value: string } | undefined + >(); + expectTypeOf>().toEqualTypeOf< + Promise | undefined + >(); + expectTypeOf< + InferExtensionMetadata['tag'] + >().toEqualTypeOf<'generic'>(); + expectTypeOf>().toEqualTypeOf< + (arg: string) => number + >(); +}); + +test('metadata composes with existing literal-name and property-list extensions', () => { + const naming = defineExtension({ + object: { + named(this: ObjectSchemaBuilder, name: string) { + return this.withExtension('name', name) as typeof this & { + readonly [METHOD_LITERAL_BRAND]?: string; + }; + }, + fields( + this: ObjectSchemaBuilder, + name: string, + ...fields: string[] + ) { + return this.withExtension(name, fields) as typeof this & { + readonly [EXTRA_TYPE_BRAND]?: Record< + string, + readonly string[] + >; + }; + } + } + }); + const s = withExtensions(tagged, naming) + .object({ name: string(), age: number() }) + .named('first') + .tag('input') + .fields('summary', 'name') + .optional() + .named('second') + .tag('output'); + expectTypeOf< + NonNullable<(typeof s)[typeof METHOD_LITERAL_BRAND]> + >().toEqualTypeOf<'first' | 'second'>(); + expectTypeOf< + NonNullable<(typeof s)[typeof EXTRA_TYPE_BRAND]>['summary'] + >().toEqualTypeOf(); + expectTypeOf< + InferExtensionMetadata['tag'] + >().toEqualTypeOf<'output'>(); + expectTypeOf>().toEqualTypeOf< + { name: string; age: number } | undefined + >(); + // @ts-expect-error extension-author methods stay hidden after fluent calls + s.withExtension('tag', 'unsafe'); +}); diff --git a/libs/schema/src/metadata-extension.test.ts b/libs/schema/src/metadata-extension.test.ts new file mode 100644 index 00000000..46e1bada --- /dev/null +++ b/libs/schema/src/metadata-extension.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + defineExtension, + defineMetadataMethod, + withExtensions +} from './extension.js'; +import { number, type StringSchemaBuilder, string } from './index.js'; + +const labels = defineExtension({ + string: { + label: defineMetadataMethod('label').argument(), + internal: defineMetadataMethod('visibility').value('internal'), + external: defineMetadataMethod('visibility').value('external'), + lengthHint: defineMetadataMethod('lengthHint').compute( + (min: number, max: number) => ({ min, max }) + ), + trim(this: StringSchemaBuilder) { + return this.addPreprocessor(v => + typeof v === 'string' ? v.trim() : v + ); + } + } +}); + +describe('typed metadata extensions', () => { + it('stores only the declared key, including explicit undefined values', () => { + const s = withExtensions( + defineExtension({ + string: { + label: defineMetadataMethod('label').value(undefined), + anonymous: defineMetadataMethod('label').compute( + () => undefined + ), + internal: + defineMetadataMethod('visibility').value('internal') + } + }) + ); + expect(s.string().label().introspect().extensions).toEqual({ + label: undefined + }); + expect( + s.string().anonymous().internal().introspect().extensions + ).toEqual({ label: undefined, visibility: 'internal' }); + }); + it('stores literal, constant and computed values through immutable chains', () => { + const s = withExtensions(labels); + const original = s.string().label('Title').internal(); + const changed = original + .optional() + .nullable() + .lengthHint(1, 20) + .external() + .trim() + .label('Name'); + expect(original.introspect().extensions).toMatchObject({ + label: 'Title', + visibility: 'internal' + }); + expect(changed.introspect().extensions).toMatchObject({ + label: 'Name', + visibility: 'external', + lengthHint: { min: 1, max: 20 } + }); + expect(changed.parse(' Name ')).toBe('Name'); + expect(changed.parse(null)).toBeNull(); + expect(changed.parse(undefined)).toBeUndefined(); + }); + + it('isolates factories and never mutates global builder prototypes', () => { + const tagged = withExtensions(labels).string(); + expect(typeof tagged.label).toBe('function'); + expect('label' in string()).toBe(false); + expect('label' in number()).toBe(false); + expect('label' in withExtensions().string()).toBe(false); + }); + + it('composes with ordinary extensions and overwrites metadata', () => { + const other = defineExtension({ + string: { + searchable: defineMetadataMethod('searchable').value(true) + } + }); + const s = withExtensions(labels, other) + .string() + .label('First') + .searchable() + .trim() + .label('Second'); + expect(s.introspect().extensions).toMatchObject({ + label: 'Second', + searchable: true + }); + expect(s.parse(' x ')).toBe('x'); + }); +}); diff --git a/libs/schema/src/metadata-extension.ts b/libs/schema/src/metadata-extension.ts new file mode 100644 index 00000000..2e727b26 --- /dev/null +++ b/libs/schema/src/metadata-extension.ts @@ -0,0 +1,375 @@ +import type { AnySchemaBuilder } from './builders/AnySchemaBuilder.js'; +import type { ArraySchemaBuilder } from './builders/ArraySchemaBuilder.js'; +import type { BooleanSchemaBuilder } from './builders/BooleanSchemaBuilder.js'; +import type { DateSchemaBuilder } from './builders/DateSchemaBuilder.js'; +import type { FunctionSchemaBuilder } from './builders/FunctionSchemaBuilder.js'; +import type { GenericSchemaBuilder } from './builders/GenericSchemaBuilder.js'; +import type { NumberSchemaBuilder } from './builders/NumberSchemaBuilder.js'; +import type { ObjectSchemaBuilder } from './builders/ObjectSchemaBuilder.js'; +import type { PromiseSchemaBuilder } from './builders/PromiseSchemaBuilder.js'; +import type { RecordSchemaBuilder } from './builders/RecordSchemaBuilder.js'; +import type { + PropertyDescriptorTree, + SchemaBuilder +} from './builders/SchemaBuilder.js'; +import type { StringSchemaBuilder } from './builders/StringSchemaBuilder.js'; +import type { TupleSchemaBuilder } from './builders/TupleSchemaBuilder.js'; +import type { UnionSchemaBuilder } from './builders/UnionSchemaBuilder.js'; +import type { + EXTRA_TYPE_BRAND, + HiddenExtensionMethods, + METHOD_LITERAL_BRAND +} from './extension.js'; + +const metadataMethods = new WeakSet(); + +/** @internal Metadata helpers own their key, including explicitly undefined values. */ +export function isMetadataMethod(method: Function): boolean { + return metadataMethods.has(method); +} + +/** Metadata recorded by typed extension methods; unannotated builders yield `{}`. */ +export type InferExtensionMetadata = S extends { + readonly __cleverbrush_extension_metadata__: infer M; +} + ? M + : {}; + +/** @internal Description carried on a metadata method's type, not on schemas. */ +export type MetadataMethod = (( + this: SchemaBuilder, + ...args: A +) => SchemaBuilder) & { + readonly __cleverbrush_metadata_method__: { key: K; value: V; mode: Mode }; +}; + +/** Defines the source of a typed metadata value for an extension method. */ +export interface MetadataMethodBuilder { + /** Store the caller's argument, retaining its literal type. */ + argument(): MetadataMethod< + K, + [value: TValue], + TValue, + 'argument' + >; + /** Create a zero-argument method that stores a fixed value. */ + value(value: TValue): MetadataMethod; + /** Compute metadata from typed arguments; inference uses the callback's return type. */ + compute( + compute: (...args: TArgs) => TValue + ): MetadataMethod; +} + +/** + * Define an immutable metadata-only method for use inside `defineExtension()`. + * + * Unlike ordinary extension methods, its stored value is also available through + * `InferExtensionMetadata`. Repeated writes replace the value for that key. + * The method does not validate input or change the schema's inferred value type. + * Methods are installed only on factories returned by `withExtensions()`. + * + * @param key - Runtime introspection key, independent of the fluent method name. + * @example + * ```ts + * const labels = defineExtension({ + * string: { + * label: defineMetadataMethod('label').argument(), + * internal: defineMetadataMethod('visibility').value('internal') + * } + * }); + * const s = withExtensions(labels).string().label('Title').optional(); + * type Label = InferExtensionMetadata['label']; // 'Title' + * ``` + */ +export function defineMetadataMethod( + key: K +): MetadataMethodBuilder { + const method = (compute: (...args: any[]) => unknown) => { + const apply = function ( + this: SchemaBuilder, + ...args: any[] + ) { + return this.withExtension(key, compute(...args)); + }; + metadataMethods.add(apply); + return apply; + }; + return { + argument: () => method(value => value), + value: value => method(() => value), + compute: method + } as MetadataMethodBuilder; +} + +/** @internal Rebuild a native builder with new extension state, preserving its value flags. */ +type WithExtensions = S extends { + readonly __cleverbrush_builder_type__: infer P; +} + ? P extends readonly [ + 'number', + infer T, + infer R extends boolean, + infer N extends boolean, + infer D extends boolean + ] + ? NumberSchemaBuilder + : P extends readonly [ + 'string', + infer T, + infer R extends boolean, + infer N extends boolean, + infer D extends boolean + ] + ? StringSchemaBuilder + : P extends readonly [ + 'date', + infer T, + infer R extends boolean, + infer N extends boolean, + infer D extends boolean + ] + ? DateSchemaBuilder + : P extends readonly [ + 'object', + infer P extends Record< + string, + SchemaBuilder + >, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer C extends SchemaBuilder[] + ] + ? ObjectSchemaBuilder + : P extends readonly [ + 'array', + infer P extends SchemaBuilder, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V + ] + ? ArraySchemaBuilder + : P extends readonly [ + 'boolean', + infer T, + infer R extends boolean, + infer N extends boolean, + infer X, + infer D extends boolean, + infer V + ] + ? BooleanSchemaBuilder + : P extends readonly [ + 'tuple', + infer P extends readonly SchemaBuilder< + any, + any, + any, + any, + any + >[], + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer Rest extends + | SchemaBuilder + | undefined, + infer V + ] + ? TupleSchemaBuilder + : P extends readonly [ + 'record', + infer K extends StringSchemaBuilder< + any, + any, + any, + any + >, + infer P extends SchemaBuilder< + any, + any, + any, + any, + any + >, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V + ] + ? RecordSchemaBuilder + : P extends readonly [ + 'union', + infer P extends readonly SchemaBuilder< + any, + any, + any, + any, + any + >[], + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean + ] + ? UnionSchemaBuilder + : P extends readonly [ + 'func', + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer P extends SchemaBuilder< + any, + any, + any, + any, + any + >[], + infer Return extends + | SchemaBuilder + | undefined, + infer V + ] + ? FunctionSchemaBuilder + : P extends readonly [ + 'promise', + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer P extends + | SchemaBuilder + | undefined, + infer V + ] + ? PromiseSchemaBuilder + : P extends readonly [ + 'generic', + infer F extends ( + ...args: any[] + ) => SchemaBuilder, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V + ] + ? GenericSchemaBuilder + : P extends readonly [ + 'any', + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V + ] + ? AnySchemaBuilder + : never + : never; + +/** @internal Named declaration support for metadata-aware fluent builders. */ +export type MetadataState = + MetadataMethods & + HiddenExtensionMethods & + Extras & { + readonly __cleverbrush_extension_metadata__: Metadata; + }; + +/** @internal A metadata-aware factory result; state survives native builder modifiers. */ +export type MetadataExtended< + S, + Methods, + Metadata = {}, + Extras = ExtraProperties +> = WithExtensions> & + MetadataState; + +/** @internal Preserve author-defined type brands without intersecting stale metadata or methods. */ +export type ExtraProperties = Pick< + S, + Exclude< + keyof S, + | keyof WithExtensions + | keyof Methods + | '__cleverbrush_extension_metadata__' + > +>; + +type ExtraTypes = S extends { readonly [EXTRA_TYPE_BRAND]?: infer T } + ? T + : {}; +type LiteralNames = S extends { readonly [METHOD_LITERAL_BRAND]?: infer T } + ? T + : never; +type WithBrand = MetadataExtended< + S, + Methods, + InferExtensionMetadata, + Omit, K> & { readonly [P in K]?: V } +>; + +type SetMetadata = MetadataExtended< + S, + Methods, + Omit, K> & Record +>; + +/** Describe fields without recursively inspecting the receiver's fluent methods. */ +type MetadataPropertyTree = S extends { + readonly __cleverbrush_builder_type__: readonly [ + 'object', + infer P extends Record>, + ...unknown[] + ]; +} + ? PropertyDescriptorTree, ObjectSchemaBuilder

> + : never; + +/** @internal Named declaration support for extension method signatures. */ +export type MetadataMethods = { + [M in keyof Methods]: Methods[M] extends MetadataMethod< + infer K, + infer A, + infer V, + infer Mode + > + ? Mode extends 'argument' + ? ( + this: S, + value: Value + ) => SetMetadata + : (this: S, ...args: A) => SetMetadata + : Methods[M] extends (this: any, ...args: infer A) => infer R + ? typeof EXTRA_TYPE_BRAND extends keyof R + ? ( + this: S, + name: Name, + ...columns: ReadonlyArray< + Key | ((t: MetadataPropertyTree) => any) + > + ) => WithBrand< + S, + Methods, + typeof EXTRA_TYPE_BRAND, + ExtraTypes & Record + > + : typeof METHOD_LITERAL_BRAND extends keyof R + ? ( + this: S, + name: Name, + ...rest: A extends [any, ...infer Rest] ? Rest : [] + ) => WithBrand< + S, + Methods, + typeof METHOD_LITERAL_BRAND, + LiteralNames | Name + > + : (this: S, ...args: A) => S + : Methods[M]; +}; diff --git a/websites/docs/app/knex-schema/page.tsx b/websites/docs/app/knex-schema/page.tsx index 9a3b6fe9..917f8b4a 100644 --- a/websites/docs/app/knex-schema/page.tsx +++ b/websites/docs/app/knex-schema/page.tsx @@ -90,68 +90,6 @@ export default function KnexSchemaPage() { -

-

Composable read queries

-

- Use alias(schema, name) with typed flat - joins and object projections for DTOs. Left-joined - fields include null; source scopes and soft deletion are - retained. -

-
 eq(t.task.ownerId, t.owner.id))
-    .select(t => ({ id: t.task.id, ownerName: t.owner.name }));`)
-                        }}
-                    />
-                    

Aggregates and optional output schemas

-

- Count helpers return checked numbers; sum and average - preserve database numeric text or null. Min/max follow - the column representation. Optional output schemas - replace decoding and receive raw driver values, - including null. Typed aggregate expressions support - grouped results. -

-
 t.estimate, {
-    output: number().isFloat().coerce().nullable()
-});`)
-                        }}
-                    />
-                    

Ordering and composite cursors

-

- Eager loading preserves final parent order and page - size. Composite cursors retain timestamp precision and - require non-null scalar columns with a declared unique - tie-breaker. Reapply access filters; cursors are not - authorization. -

-
 t.createdAt, direction: 'desc' },
-        { column: t => t.id, direction: 'desc' }
-    ]
-});`)
-                        }}
-                    />
-                    

- Existing APIs remain unchanged. Read the{' '} - - complete query guide - {' '} - for examples, precision policy, and restrictions. -

-
- {/* ── Quick Start ──────────────────────────────────── */}

Quick Start

@@ -381,6 +319,99 @@ const activeUsers = await query(db, UserSchema, base)
+
+

Composable read queries

+

+ Use alias(schema, name) with typed flat + joins and object projections for DTOs. Left-joined + fields include null; source scopes and soft deletion are + retained. +

+
 eq(t.task.ownerId, t.owner.id))
+    .select(t => ({ id: t.task.id, ownerName: t.owner.name }));`)
+                        }}
+                    />
+                    

Aggregates and optional output schemas

+

+ Count helpers return checked numbers; sum and average + preserve database numeric text or null. Min/max follow + the column representation. Optional output schemas + replace decoding and receive raw driver values, + including null. Typed aggregate expressions support + grouped results. +

+
 t.estimate, {
+    output: number().isFloat().coerce().nullable()
+});`)
+                        }}
+                    />
+                    

Ordering and composite cursors

+

+ Eager loading preserves final parent order and page + size. Composite cursors retain timestamp precision and + require non-null scalar columns with a declared unique + tie-breaker. Reapply access filters; cursors are not + authorization. +

+
 t.createdAt, direction: 'desc' },
+        { column: t => t.id, direction: 'desc' }
+    ]
+});`)
+                        }}
+                    />
+                    

+ Existing APIs remain unchanged. Read the{' '} + + complete query guide + {' '} + for examples, precision policy, and restrictions. +

+
+ +
+

Opt-in projection-aware reads

+

+ Start with withRowSchema() before selecting or including + fields. The immutable PostgreSQL reader exposes the + actual decoded result schema: SQL null stays null, dates + are Date objects at every depth, and decimal/bigint + values are exact strings before JSON parsing. +

+
+                         ({ title: t.title, amount: t.amount }));
+const Source = read.rowSchema;
+const rows = await read.where(t => t.id, taskId);`)
+                            }}
+                        />
+                    
+

+ Typed aliases, aggregates, named projections and nested + graphs retain their selected shape. Polymorphic readers + expose a union rowSchema and per-variant object schemas. + Raw shapes are rejected; existing query behavior is + unchanged. +

+ + Read representation, pagination and migration details + +
+ {/* ── API Reference ────────────────────────────────── */}

API Reference

diff --git a/websites/docs/app/mapper/page.tsx b/websites/docs/app/mapper/page.tsx index 863c8b9f..fdf8aed4 100644 --- a/websites/docs/app/mapper/page.tsx +++ b/websites/docs/app/mapper/page.tsx @@ -218,6 +218,38 @@ const registry = mapper()
+
+

Synchronous mapping and query row schemas

+

+ Keep the existing configure() API. When the final + computations and nested mappings are synchronous, + getSyncMapper() returns ordinary values while retaining + completeness checks. getMapper() remains asynchronous. +

+
+                         ({ id: u.id, name: u.name }));
+const Source = read.rowSchema; // no SQL
+const toDto = mapper().configure(Source, UserDto, m => m)
+    .getSyncMapper(Source, UserDto);
+const result = (await read).map(toDto);`)
+                            }}
+                        />
+                    
+

+ Known async mappings are rejected; disguised promises or + thenables throw at invocation. Configuration never + probes callbacks. Fetching, enrichment, authorization + and union dispatch remain explicit application code. +

+ + Read the multi-file consumer guide and compatibility + rules + +
+ {/* ── Compile-time safety ─────────────────────────── */}

Compile-time completeness

diff --git a/websites/docs/app/orm/page.tsx b/websites/docs/app/orm/page.tsx index e52c1e89..610c276b 100644 --- a/websites/docs/app/orm/page.tsx +++ b/websites/docs/app/orm/page.tsx @@ -241,6 +241,37 @@ try {
+
+

Detached reads with projection schemas

+

+ db.users.withRowSchema() returns an immutable read-only + query with runtime metadata matching its projection and + relation graph. Results stay detached even in a tracking + context, so partial selections cannot silently become + incomplete tracked entities. +

+
+                         ({ id: p.id, name: p.name }))
+    .include(r => r.tasks, tasks => tasks
+        .select(t => ({ title: t.title })));
+const Source = read.rowSchema; // nested task schema included
+const projects = await read; // one SQL statement`)
+                            }}
+                        />
+                    
+

+ Return child queries from customizers. STI/CTI reads + expose variantRowSchemas for explicit mapper dispatch. + Ordinary entity reads and writes remain unchanged. +

+ + Projection-aware consumer guide + +
+ {/* ── Polymorphic entities ─────────────────────────── */}

Polymorphic Entities (STI / CTI)

diff --git a/websites/docs/public/llms.txt b/websites/docs/public/llms.txt index f11d22d0..a43783c6 100644 --- a/websites/docs/public/llms.txt +++ b/websites/docs/public/llms.txt @@ -1,401 +1,219 @@ # Cleverbrush Framework -> A schema-first TypeScript web framework. Define schemas once — get a type-safe HTTP server, auto-typed client with resilience, OpenAPI 3.1 docs, auth, dependency injection, schema-driven forms, and object mapping. Zero codegen, zero type duplication. - -The key differentiator is **contract-first architecture**: a shared API contract (defined with `@cleverbrush/schema`) is the single source of truth for both server and client. TypeScript enforces exhaustive handler mapping at compile time — add an endpoint to the contract and forget a handler? Compilation fails. - -## Packages - -| Package | Description | Docs | -|---------|-------------|------| -| `@cleverbrush/server` | HTTP server with typed endpoints, middleware, batching, WebSocket | [/server](/server) | -| `@cleverbrush/client` | Auto-typed REST client with retry, timeout, dedup, cache, batching | [/client/getting-started](/client/getting-started) | -| `@cleverbrush/auth` | JWT, cookies, OAuth2, OIDC, policy-based authorization | [/auth](/auth) | -| `@cleverbrush/di` | Dependency injection with schema-as-key tokens | [/di](/di) | -| `@cleverbrush/server-openapi` | OpenAPI 3.1 generation (links, callbacks, webhooks, security) | [/server-openapi](/server-openapi) | -| `@cleverbrush/env` | Typed environment config with schema validation | [/env](/env) | -| `@cleverbrush/knex-schema` | Schema-aware Knex query builder (`.hasTableName()`, `.hasColumnName()`) | [/knex-schema](/knex-schema) | -| `@cleverbrush/react-form` | Headless schema-driven React forms | [/react-form](/react-form) | -| `@cleverbrush/mapper` | Schema-to-schema object mapping with compile-time completeness | [/mapper](/mapper) | -| `@cleverbrush/scheduler` | Job scheduling with worker thread isolation | [/scheduler](/scheduler) | -| `@cleverbrush/schema` | Schema validation library (see [schema.cleverbrush.com](https://schema.cleverbrush.com)) | — | - -## Core Workflow - -### 1. Define schemas (shared) - -```typescript -import { object, string, number, array } from '@cleverbrush/schema'; - -const CreateTodoBody = object({ - title: string().minLength(1).maxLength(200), - description: string().optional(), +> Schema-first TypeScript libraries for validation, HTTP APIs, typed clients, +> database access, forms and object mapping. Libraries compose without codegen. + +Start with schemas, share a browser-safe API contract, implement its handlers on +the server, then create a client from that same contract. Database schemas and +public response schemas can differ; select and map fields explicitly. + +## Package map + +| Area | Package | Documentation | +| --- | --- | --- | +| Validation and extensions | `@cleverbrush/schema` | [Schema](https://schema.cleverbrush.com) | +| JSON Schema conversion | `@cleverbrush/schema-json` | [README](https://github.com/cleverbrush/framework/tree/development/libs/schema-json) | +| HTTP contracts and handlers | `@cleverbrush/server` | [Server](/server) | +| HTTP clients and React hooks | `@cleverbrush/client` | [Client](/client/getting-started) | +| API specifications | `@cleverbrush/server-openapi` | [OpenAPI / AsyncAPI](/server-openapi) | +| Authentication and authorization | `@cleverbrush/auth` | [Auth](/auth) | +| Dependency injection | `@cleverbrush/di` | [DI](/di) | +| Environment configuration | `@cleverbrush/env` | [Env](/env) | +| SQL queries and schema metadata | `@cleverbrush/knex-schema` | [Queries](/knex-schema) | +| Entities, relations and tracking | `@cleverbrush/orm` | [ORM](/orm) | +| Database migrations | `@cleverbrush/orm-cli` | [README](https://github.com/cleverbrush/framework/tree/development/libs/orm-cli) | +| Object transformations | `@cleverbrush/mapper` | [Mapper](/mapper) | +| Schema-driven React forms | `@cleverbrush/react-form` | [Forms](/react-form) | +| Worker-thread jobs | `@cleverbrush/scheduler` | [Scheduler](/scheduler) | + +## Core workflow + +This small example uses an in-memory collection, not persistent storage. Keep +`contracts.ts` shared; do not import server implementations into browser bundles. + +### 1. Define schemas and the shared contract + +```typescript +// contracts.ts +import { defineApi, endpoint } from '@cleverbrush/server/contract'; +import { array, boolean, number, object, string } from '@cleverbrush/schema'; + +export const Todo = object({ + id: number(), + title: string(), + completed: boolean() }); - -const Todo = object({ - id: number().integer(), - title: string(), - description: string().optional(), - completed: boolean(), +const CreateTodo = object({ title: string().minLength(1).maxLength(200) }); + +export const api = defineApi({ + todos: { + list: endpoint.get('/api/todos').responses({ 200: array(Todo) }), + create: endpoint.post('/api/todos') + .body(CreateTodo).responses({ 201: Todo }) + } }); ``` -### 2. Define the API contract (shared) +### 2. Implement the handlers ```typescript -import { defineApi, endpoint } from '@cleverbrush/server'; - -const api = defineApi({ - todos: { - list: endpoint.get('/api/todos').returns(array(Todo)), - create: endpoint.post('/api/todos').body(CreateTodoBody).returns(Todo), - update: endpoint.patch('/api/todos/:id').body(UpdateTodoBody).returns(Todo), - delete: endpoint.delete('/api/todos/:id'), - }, -}); -``` +// todos.ts +import { ActionResult, implement } from '@cleverbrush/server'; +import type { InferType } from '@cleverbrush/schema'; +import { api, Todo } from './contracts.js'; -### 3. Server — exhaustive handler mapping - -```typescript -import { createServer, mapHandlers } from '@cleverbrush/server'; - -const server = createServer() - .useAuthentication({ defaultScheme: 'jwt', schemes: [jwtScheme({ secret })] }) - .useAuthorization() - .services(svc => configureDI(svc)) - .useBatching() - .withHealthcheck(); - -mapHandlers(server, api, { - todos: { - list: async ({ query }) => db.todos.findAll(query), - create: async ({ body }) => db.todos.insert(body), - update: async ({ params, body }) => db.todos.update(params.id, body), - delete: async ({ params }) => db.todos.delete(params.id), - // TypeScript error if any endpoint is missing - }, +const rows: InferType[] = []; +let nextId = 1; +export const todosModule = implement(api).group('todos').withHandlers({ + list: () => rows, + create: ({ body }) => { + const todo = { id: nextId++, title: body.title, completed: false }; + rows.push(todo); + return ActionResult.created(todo); + } }); - -server.listen(3000); -``` - -### 4. Client — typed, resilient - -```typescript -import { createClient, retry, timeout, dedupe, throttlingCache, batching } from '@cleverbrush/client'; - -const client = createClient(api, { - baseUrl: 'http://localhost:3000', - getToken: () => loadToken(), - middlewares: [ - retry({ limit: 3 }), - timeout({ timeout: 10_000 }), - dedupe(), - throttlingCache({ throttle: 2000 }), - batching({ maxSize: 10 }), - ], -}); - -// Fully typed — autocomplete on endpoints and params -const todos = await client.todos.list(); -const newTodo = await client.todos.create({ body: { title: 'Buy milk' } }); -``` - -## @cleverbrush/server - -Install: `npm install @cleverbrush/server @cleverbrush/schema` - -### Endpoint builder - -```typescript -import { endpoint, route } from '@cleverbrush/server'; - -const myEndpoint = endpoint - .get('/api/users/:id') // HTTP method + path (path params auto-typed) - .query(QuerySchema) // query string schema - .body(BodySchema) // request body schema - .headers(HeadersSchema) // typed request headers - .returns(ResponseSchema) // 200 response schema - .responses({ // multi-status responses - 200: SuccessSchema, - 404: NotFoundSchema, - }) - .produces('application/json') // content type(s) - .producesFile() // binary file response - .deprecated() // OpenAPI deprecated flag - .tags('users') // OpenAPI tags - .summary('Get user by ID') // OpenAPI summary - .description('...') // OpenAPI description - .example({ ... }) // OpenAPI request example - .examples([...]) // multiple examples - .links({ ... }) // OpenAPI response links - .callbacks({ ... }) // OpenAPI callbacks (webhooks) - .security([{ bearerAuth: [] }]); // OpenAPI security schemes ``` -### Server setup +For large APIs, export a configured scope and define each handler in its own file +using `Handler`. Compose feature modules at the +root. See the [multi-file example](https://github.com/cleverbrush/framework/blob/development/libs/server/README.md#large-apis-and-shared-error-handling). -```typescript -const server = createServer() - .use(myMiddleware) // global middleware - .services(svc => { svc.addSingleton(Token, impl); }) // DI registration - .useAuthentication({ defaultScheme, schemes }) // auth middleware - .useAuthorization() // authorization middleware - .withHealthcheck() // GET /health - .useBatching(); // request batching support -``` - -### ActionResult helpers +### 3. Register the complete implementation ```typescript -import { ActionResult } from '@cleverbrush/server'; - -// In handlers: -return ActionResult.ok(data); // 200 -return ActionResult.created(data); // 201 -return ActionResult.noContent(); // 204 -return ActionResult.notFound(); // 404 -return ActionResult.conflict(); // 409 -return ActionResult.redirect(url); // 302 -return ActionResult.file(buffer, mime); // binary file -return ActionResult.stream(readable); // streaming response -``` +// server.ts +import { createServer, implement } from '@cleverbrush/server'; +import { api } from './contracts.js'; +import { todosModule } from './todos.js'; -### WebSocket subscriptions - -```typescript -const api = defineApi({ - live: { - updates: endpoint.ws('/ws/updates') - .receives(ClientMessageSchema) - .sends(ServerMessageSchema), - }, -}); +const server = createServer().withHealthcheck(); +server.handleAll(implement(api).use(todosModule).complete()); +await server.listen(3000); ``` -## @cleverbrush/client +`complete()` checks contract coverage. Server request validation and response +types come from the endpoint schemas. Application persistence, permissions and +business rules remain application code. -Install: `npm install @cleverbrush/client @cleverbrush/schema` - -### Client creation +### 4. Call the API from a typed client ```typescript +// client.ts import { createClient } from '@cleverbrush/client'; -// Or for React: import { createClient } from '@cleverbrush/client/react'; +import { timeout } from '@cleverbrush/client/timeout'; +import { api } from './contracts.js'; -const client = createClient(api, { - baseUrl: 'http://localhost:3000', - getToken: () => localStorage.getItem('token'), - middlewares: [...], +export const client = createClient(api, { + baseUrl: 'http://localhost:3000', + middlewares: [timeout({ timeout: 10_000 })] }); -``` - -### Resilience middleware - -| Middleware | Purpose | -|-----------|---------| -| `retry({ limit, backoff, jitter })` | Retry failed requests with exponential backoff | -| `timeout({ timeout })` | Abort requests after N ms | -| `dedupe()` | Deduplicate identical concurrent GET requests | -| `throttlingCache({ throttle })` | TTL cache for GET requests | -| `batching({ maxSize, delay })` | Batch multiple requests into one HTTP call | - -### React integration - -```typescript -import { createClient } from '@cleverbrush/client/react'; - -const client = createClient(api, { baseUrl, getToken }); - -// In components: -const { data, error, loading } = client.todos.list.useQuery({ query: { page: 1 } }); -``` - -## @cleverbrush/auth - -Install: `npm install @cleverbrush/auth @cleverbrush/schema` - -```typescript -import { jwtScheme, signJwt } from '@cleverbrush/auth'; - -// Configure JWT auth scheme -const jwt = jwtScheme({ - secret: process.env.JWT_SECRET, - mapClaims: (claims) => ({ - userId: claims.sub, - role: claims.role, - }), -}); - -// Sign tokens -const token = await signJwt({ sub: user.id, role: 'admin' }, secret, { expiresIn: '1h' }); - -// Server setup -server.useAuthentication({ - defaultScheme: 'jwt', - schemes: [jwt], -}); - -// Per-endpoint authorization -endpoint.get('/admin/users') - .authorize('admin') // role-based - .authorize((principal) => principal.role === 'admin'); // policy-based -``` - -## @cleverbrush/di - -Install: `npm install @cleverbrush/di @cleverbrush/schema` - -Uses schema builders as DI tokens — fully typed, no string keys or decorators. - -```typescript -import { any } from '@cleverbrush/schema'; - -// Define tokens -const DbToken = any().hasType(); -const LoggerToken = any().hasType(); - -// Register services -server.services(svc => { - svc.addSingleton(DbToken, () => new Database(config)); - svc.addScoped(LoggerToken, () => new Logger()); -}); - -// Resolve in handlers (injected via context) -const handler = async ({ services }) => { - const db = services.resolve(DbToken); // typed as Database - const logger = services.resolve(LoggerToken); // typed as Logger -}; -``` - -## @cleverbrush/server-openapi - -Install: `npm install @cleverbrush/server-openapi` - -```typescript -import { generateOpenApiSpec, serveAsyncApi } from '@cleverbrush/server-openapi'; - -// Generate OpenAPI 3.1 spec -const spec = generateOpenApiSpec(server, api, { - info: { title: 'My API', version: '1.0.0' }, - servers: [{ url: 'https://api.example.com' }], -}); - -// Serve at GET /openapi.json -server.get('/openapi.json', () => ActionResult.ok(spec)); - -// AsyncAPI for WebSocket endpoints -const asyncSpec = serveAsyncApi(server, api, { ... }); -``` - -Features: response links, callbacks, webhooks, response headers, security schemes, discriminated union `discriminator` mapping, tags, deprecation. - -## @cleverbrush/env - -Install: `npm install @cleverbrush/env @cleverbrush/schema` - -```typescript -import { env, parseEnv } from '@cleverbrush/env'; -import { string, number } from '@cleverbrush/schema'; - -const config = parseEnv({ - db: { - host: env('DB_HOST', string().default('localhost')), - port: env('DB_PORT', number().coerce().default(5432)), - name: env('DB_NAME', string()), - }, - jwt: { - secret: env('JWT_SECRET', string().minLength(32)), - }, - port: env('PORT', number().coerce().default(3000)), -}); -// config is fully typed: { db: { host: string; port: number; name: string }; ... } -// Throws with clear error messages if env vars are missing or invalid -``` - -## @cleverbrush/knex-schema - -Install: `npm install @cleverbrush/knex-schema @cleverbrush/schema knex` - -Schema-aware Knex query builder. Attach table/column metadata to schemas, then query with type-safe selectors. - -```typescript -import { object, string, number } from '@cleverbrush/schema'; - -// Attach DB metadata to schemas -const UserSchema = object({ - id: number().integer(), - name: string(), - email: string().email(), - createdAt: date().hasColumnName('created_at'), -}).hasTableName('users'); - -// Query with type-safe selectors -import { createQuery } from '@cleverbrush/knex-schema'; - -const db = createQuery(knex); -const users = await db(UserSchema) - .where(u => u.email, 'alice@example.com') - .orderBy(u => u.createdAt, 'desc') - .select(); -``` - -## @cleverbrush/mapper - -Install: `npm install @cleverbrush/mapper @cleverbrush/schema` - -Schema-to-schema object mapping with compile-time completeness. Unmapped target properties are TypeScript errors. - -```typescript -import { mapper } from '@cleverbrush/mapper'; - -const registry = mapper() - .configure(SourceSchema, TargetSchema, (m) => - m - .for(t => t.fullName).compute(s => `${s.firstName} ${s.lastName}`) - .for(t => t.age).from(s => s.birthYear) // auto-transform - .for(t => t.internal).ignore() - // Same-name, same-type properties are auto-mapped - ); - -const mapFn = registry.getMapper(SourceSchema, TargetSchema); -const result = await mapFn(sourceObject); -``` - -## @cleverbrush/scheduler - -Install: `npm install @cleverbrush/scheduler` - -Job scheduling with worker thread isolation, retries, and event streaming. Requires Node.js 16+. - -```typescript -import { JobScheduler } from '@cleverbrush/scheduler'; - -const scheduler = new JobScheduler({ rootFolder: './jobs' }); - -scheduler.addJob({ - id: 'cleanup', - path: 'cleanup.js', - schedule: { every: 'day', interval: 1, hour: 3, minute: 0 }, - timeout: 60_000, - maxRetries: 3, - noConcurrentRuns: true, -}); - -scheduler.on('job:end', ({ jobId }) => console.log(`${jobId} done`)); -scheduler.on('job:error', ({ jobId, error }) => console.error(jobId, error)); - -scheduler.start(); -``` - -Schedule types: `minute`, `day`, `week`, `month`, `year` — each with `interval` and time fields. - -## Links - -- [Getting Started](/getting-started): End-to-end tutorial -- [Why Cleverbrush?](/why): Problem statement and design philosophy -- [Comparisons](/comparisons): Feature matrix vs tRPC, ts-rest, Hono -- [Examples](/examples): Full-stack Todo app demo -- [Schema Library](https://schema.cleverbrush.com): Schema validation docs, playground, benchmarks -- [GitHub](https://github.com/cleverbrush/framework): Source code +const todos = await client.todos.list(); +const created = await client.todos.create({ body: { title: 'Buy milk' } }); +``` + +## Schemas and extension authoring + +Schemas are immutable. Capture the returned builder when adding properties, +validators or modifiers. `number()` validates integers by default; +`.clearIsInteger()` permits fractional values. Use `.coerce()` explicitly when +parsing strings such as query parameters. `InferType` derives the +value type. Named schemas and `schemaRef()` support composition across packages. + +Define custom methods with `defineExtension()` and apply them with +`withExtensions()`. Metadata is available from `schema.introspect().extensions`. +For metadata-only methods whose values must also survive in TypeScript, use +`defineMetadataMethod(key).argument()`, `.value(value)` or `.compute(fn)` and +`InferExtensionMetadata`. These do not alter global builder prototypes or +interpret application-specific metadata. See [schema extensions](https://github.com/cleverbrush/framework/blob/development/libs/schema/README.md#extensions). + +## HTTP APIs and supporting services + +- **Server:** endpoint builders describe request schemas, response statuses, + injection and authorization. `route()` supports schema-typed path parameters. + `implement()` binds modular handlers; `errorMap()` / `withErrors()` translate + known exceptions only into responses declared by the endpoint. `ActionResult` + supplies status-specific, file and streaming results. Unexpected errors retain + the server's Problem Details handling. [Server reference](/server). +- **Subscriptions:** use `endpoint.subscription()` with `.incoming()` and + `.outgoing()` schemas and async-generator handlers. Subscriptions participate + in implementation coverage but do not use HTTP error policies. [Subscriptions](https://github.com/cleverbrush/framework/blob/development/libs/server/README.md#websocket-subscriptions). +- **Client:** `createClient(contract, options)` infers calls from the contract. + Middleware is imported from subpaths such as `/retry`, `/timeout`, `/dedupe`, + `/cache` and `/batching`. Authentication, retry and cache policy must match the + application. The `/react` client adds TanStack Query hooks including + `.useQuery()` and `.useMutation()`; query state uses `isLoading`. + `decodeValidationIssues()` extracts structured request-validation issues. + [Client reference](/client/getting-started). +- **OpenAPI / AsyncAPI:** configure `serveOpenApi({ server, info })` or + `serveAsyncApi({ server, info })` as middleware before listening. For generation + without middleware, pass registration metadata to `generateOpenApiSpec()` or + `generateAsyncApiSpec()`. Named schemas become reusable components. + [Specification generation](/server-openapi). +- **Authentication:** configure authentication schemes and authorization policies + on the server; declare endpoint principal requirements with `.authorize()`. + JWT, cookies, OAuth2 and OIDC are supported. Keep signing keys and server-only + configuration out of shared contracts. [Authentication](/auth). +- **Dependency injection:** schemas act as typed service tokens. Register values + or factories with singleton/scoped/transient lifetimes. Endpoint `.inject()` + declares dependencies supplied as the handler's second argument. + [Dependency injection](/di). +- **Environment:** `env(name, schema)` binds leaves in a nested configuration; + `parseEnv()` validates and resolves it. `parseEnvFlat()` supports flat configs. + Use explicit coercion and `envBoolean()` for environment boolean conventions. + [Environment configuration](/env). + +## Database queries, entities and result mapping + +Import database-capable schema factories from `@cleverbrush/knex-schema` or +`@cleverbrush/orm`, not plain `@cleverbrush/schema`. Table and column metadata +belong to these packages. Knex is a peer dependency; PostgreSQL also needs `pg`. + +- **Queries:** `query(knex, schema)` / `createQuery(knex)` provide typed selectors, + CRUD, scopes, named projections, eager loading and transactions. Aliases express + flat joins; aggregate expressions support summaries and explicit output schemas. + Composite cursors require a deterministic, non-null sort with a unique + tie-breaker. [Query guide](/knex-schema). +- **ORM:** `defineEntity(schema)` declares relations; `createDb(knex, entities)` + exposes typed DbSets. Ordinary entity queries support identity tracking, + change persistence, optimistic concurrency and STI/CTI variants. Use + `@cleverbrush/orm-cli` for migrations. [ORM guide](/orm). +- **Projection-aware reads:** enter `.withRowSchema()` before selecting or + including. It returns an immutable, read-only query. `rowSchema` describes the + decoded selection, not the full table, and accessing it never executes SQL. + Results are detached even in tracking contexts. SQL nulls stay `null`, dates + decode to `Date` throughout nested graphs, and declared decimal/bigint columns + retain exact strings. Input defaults and preprocessors are not replayed. + [Read schemas](/knex-schema#row-schemas), [detached ORM reads](/orm#detached-read-schemas). +- **Nested and polymorphic reads:** child customizers return their configured + queries. Nested includes execute in one SQL statement. STI/CTI readers expose + a union `rowSchema` and per-variant `variantRowSchemas`; DTO selection and + discriminator dispatch remain explicit application code. +- **Mapping:** `mapper().configure(Source, Target, configure)` checks that every + target property is mapped, auto-mapped or ignored. `.from()` copies values; + `.compute()` transforms them. `getMapper()` returns an asynchronous function. + `getSyncMapper()` returns ordinary values when all final steps and nested + mappings are synchronous; known async mappings are rejected and disguised + thenables throw when invoked. Configuration does not probe callbacks. Reuse + schema instances and configured mappings; the registry uses schema identity. + Mapping does not fetch data or authorize access. [Mapper guide](/mapper), + [synchronous mapping](/mapper#synchronous-mapping). + +For separate query, mapping and service files, storage representation rules and +supported read operations, see the [complete query README](https://github.com/cleverbrush/framework/blob/development/libs/knex-schema/README.md#projection-aware-reads). + +## Forms and background work + +- **React Form:** schema-driven fields, validation and submission state. + Structured `FormIssue` values carry server validation errors to nested fields; + application business errors require explicit paths. [Forms](/react-form). +- **Scheduler:** `JobScheduler` runs configured jobs in worker threads with + scheduling, retry, timeout and lifecycle events. Configure job files and + timezone explicitly. [Scheduler](/scheduler). + +## Further reading + +- [Getting Started](/getting-started): full-stack tutorial +- [Examples](/examples): Todo application +- [Why Cleverbrush?](/why): design goals +- [Schema library](https://schema.cleverbrush.com): validation and playground +- [GitHub](https://github.com/cleverbrush/framework): source and tests diff --git a/websites/schema/app/mapper/page.tsx b/websites/schema/app/mapper/page.tsx index 3637b31f..b2d75974 100644 --- a/websites/schema/app/mapper/page.tsx +++ b/websites/schema/app/mapper/page.tsx @@ -17,20 +17,6 @@ export default function MapperPage() {

- - {/* ── Installation ─────────────────────────────────── */} +
+

Synchronous mapping

+

+ Use the existing configure() chain, then + getSyncMapper(Source, Target) for pure synchronous + mappings. Completeness and nested-mapping checks remain. + Async computations are rejected, and a disguised + promise/thenable throws when invoked. getMapper() keeps + its async behavior. +

+

+ Opt-in Framework database reads expose projection-aware + rowSchema metadata, so source schemas need not be copied + by hand. Reuse schema instances and configure once; + fetching and domain conversions remain + application-owned. +

+ + Query mapping examples and consumer guide + +
+ + + {/* ── API Reference ────────────────────────────────── */}

API Reference

diff --git a/websites/schema/app/playground/schemaDeclarations.ts b/websites/schema/app/playground/schemaDeclarations.ts index d94e918d..11108581 100644 --- a/websites/schema/app/playground/schemaDeclarations.ts +++ b/websites/schema/app/playground/schemaDeclarations.ts @@ -25,6 +25,15 @@ type AnySchemaBuilderCreateProps = Partial extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'any', + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; /** * @hidden */ @@ -167,6 +176,16 @@ type ArraySchemaBuilderCreateProps, TRequired extends boolean = true, TNullable extends boolean = false, TExplicitType = undefined, THasDefault extends boolean = false, TExtensions = {}, TResult = TExplicitType extends undefined ? TElementSchema extends undefined ? Array : TElementSchema extends SchemaBuilder ? Array>> : never : TExplicitType> extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'array', + TElementSchema, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; /** * @hidden */ @@ -271,6 +290,7 @@ export declare class ArraySchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -372,6 +392,16 @@ type BooleanSchemaBuilderCreateProps = Partial extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'boolean', + TResult, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TFinalResult + ]; /** * @hidden */ @@ -401,6 +431,7 @@ export declare class BooleanSchemaBuilder TFinalResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TFinalResult | (() => TFinalResult) | undefined; example: unknown; @@ -544,6 +575,14 @@ type DateSchemaBuilderCreateProps = Partial< */ export declare class DateSchemaBuilder extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'date', + TResult, + TRequired, + TNullable, + THasDefault + ]; /** * @hidden */ @@ -625,6 +664,7 @@ export declare class DateSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -908,6 +948,7 @@ export declare class ExternSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -1053,6 +1094,17 @@ type InferParameters[]> = */ export declare class FunctionSchemaBuilder[] = [], TReturnTypeSchema extends SchemaBuilder | undefined = undefined, TResult = TExplicitType extends undefined ? (...args: TParameters extends [] ? any[] : InferParameters) => TReturnTypeSchema extends SchemaBuilder ? InferType : any : TExplicitType> extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'func', + TRequired, + TNullable, + TExplicitType, + THasDefault, + TParameters, + TReturnTypeSchema, + TResult + ]; /** * @hidden */ @@ -1109,6 +1161,7 @@ export declare class FunctionSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -1339,6 +1392,16 @@ type GenericSchemaBuilderCreateProps = Partial */ export declare class GenericSchemaBuilder SchemaBuilder, TRequired extends boolean = true, TNullable extends boolean = false, TExplicitType = undefined, THasDefault extends boolean = false, TExtensions = {}, TResult = TExplicitType extends undefined ? ReturnType extends SchemaBuilder ? R : any : TExplicitType> extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'generic', + TFn, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; /** * Applies the template function with concrete schema arguments, returning * a fully typed concrete schema builder. TypeScript infers the result type @@ -1404,6 +1467,7 @@ export declare class GenericSchemaBuilder Schema defaultValue: TResult | (() => TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -1573,6 +1637,7 @@ export declare class IntersectionSchemaBuilder : TExplicitType) | (() => TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: (TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | (() => TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | undefined; example: unknown; @@ -1741,6 +1806,7 @@ export declare class LazySchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -2044,6 +2110,14 @@ type NumberSchemaBuilderCreateProps = Part */ export declare class NumberSchemaBuilder extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'number', + TResult, + TRequired, + TNullable, + THasDefault + ]; /** * @hidden */ @@ -2123,6 +2197,7 @@ export declare class NumberSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -2523,6 +2598,16 @@ export type ObjectSchemaValidationResult> = {}, TRequired extends boolean = true, TNullable extends boolean = false, TExplicitType = undefined, THasDefault extends boolean = false, TExtensions = {}, TConstructorSchemas extends SchemaBuilder[] = []> extends SchemaBuilder> : TExplicitType, TRequired, TNullable, THasDefault, TExtensions> { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'object', + TProperties, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TConstructorSchemas + ]; /** Marks this builder as having sub-properties for descriptor tree recursion. */ readonly [SYMBOL_HAS_PROPERTIES] = true; /** @@ -2564,6 +2649,7 @@ export declare class ObjectSchemaBuilder> : TExplicitType) | (() => undefined extends TExplicitType ? WithConstructors> : TExplicitType) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: (undefined extends TExplicitType ? WithConstructors> : TExplicitType) | (() => undefined extends TExplicitType ? WithConstructors> : TExplicitType) | undefined; example: unknown; @@ -3120,6 +3206,7 @@ export declare class ParseStringSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -3290,6 +3377,16 @@ type PromiseSchemaBuilderCreateProps = Partial | undefined = undefined, TResult = TExplicitType extends undefined ? TResolvedTypeSchema extends SchemaBuilder ? Promise> : Promise : TExplicitType> extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'promise', + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResolvedTypeSchema, + TResult + ]; /** * @hidden */ @@ -3337,6 +3434,7 @@ export declare class PromiseSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -3716,6 +3814,17 @@ type RecordSchemaBuilderCreateProps, TValueSchema extends SchemaBuilder, TRequired extends boolean = true, TNullable extends boolean = false, TExplicitType = undefined, THasDefault extends boolean = false, TExtensions = {}, TResult = TExplicitType extends undefined ? Record, InferType> : TExplicitType> extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'record', + TKeySchema, + TValueSchema, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TResult + ]; /** * @hidden */ @@ -3781,6 +3890,7 @@ export declare class RecordSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -4066,6 +4176,8 @@ export type SchemaBuilderProps = { hasCatch?: boolean; description?: string; schemaName?: string; + /** @internal Canonical named definition for a use-site-only derivative. */ + referenceTarget?: SchemaBuilder; example?: unknown; }; export type ValidationContext = SchemaBuilder> = { @@ -4278,8 +4390,18 @@ export type PropertyDescriptor, TRootSchema extends ObjectSchemaBuilder = TSchema, TAssignableTo = any, TParentPropertyDescriptor = undefined> = PropertyDescriptor & (TSchema extends ObjectSchemaBuilder ? { - [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder ? PropertyDescriptorTree> : TProperties[K] extends ExternSchemaBuilder ? PropertyDescriptor, K & string> & ExternOutputPropertyDescriptors, K & string>> : TProperties[K] extends ArraySchemaBuilder ? TArrayElement extends ObjectSchemaBuilder ? PropertyDescriptor, K & string> : InferType extends TAssignableTo ? PropertyDescriptor, K & string> : never : InferType extends TAssignableTo ? PropertyDescriptor, K & string> : never; + [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder ? PropertyDescriptorTree> : TProperties[K] extends ExternSchemaBuilder ? PropertyDescriptor, K & string> & ExternOutputPropertyDescriptors, K & string>> : TProperties[K] extends ArraySchemaBuilder ? TArrayElement extends ObjectSchemaBuilder ? IndexedPropertyDescriptor, K & string> : InferType extends TAssignableTo ? IndexedPropertyDescriptor, K & string> : never : InferType extends TAssignableTo ? PropertyDescriptor, K & string> : never; } : never); +/** + * A descriptor that preserves its array binding and exposes lazy indexed child + * descriptors. Indices are non-negative integers; they are positions, not item + * identities. Object and nested-array elements retain their schema types. + * + * @example \`object.getPropertiesFor(schema).addresses[0].city\` + */ +export type IndexedPropertyDescriptor, TSchema, TParent, TKey extends string = string> = PropertyDescriptor & (TSchema extends ObjectSchemaBuilder ? PropertyDescriptorTree : TSchema extends ArraySchemaBuilder ? { + readonly [index: number]: IndexedPropertyDescriptor>; +} : {}); /** * Recursively maps the keys of an extern schema's output type into * property descriptors. When a value is a plain-object type its keys @@ -4340,13 +4462,13 @@ export declare abstract class SchemaBuilder\` format: * - Success: \`{ value: }\` * - Failure: \`{ issues: [{ message: string }, …] }\` @@ -4400,6 +4522,17 @@ export declare abstract class SchemaBuilder | undefined; /** * Whether a catch/fallback value has been set on this schema via \`.catch()\`. */ @@ -4635,7 +4776,10 @@ export declare abstract class SchemaBuilder TResult)): this; + catch(value: ResolvedSchemaType | (() => ResolvedSchemaType)): this; /** * Removes the default value set by a previous call to \`.default()\`. */ @@ -4719,6 +4863,15 @@ export declare abstract class SchemaBuilder, options?: { + addPreprocessor(preprocessor: (object: TResult) => ResolvedSchemaType | Promise>, options?: { mutates?: boolean; }): this; /** @@ -4973,6 +5132,14 @@ type StringSchemaBuilderCreateProps = Part */ export declare class StringSchemaBuilder extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'string', + TResult, + TRequired, + TNullable, + THasDefault + ]; /** * @hidden */ @@ -5053,6 +5220,7 @@ export declare class StringSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -5297,6 +5465,17 @@ export declare class TupleSchemaBuilder; } : TExplicitType> extends SchemaBuilder { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'tuple', + TElements, + TRequired, + TNullable, + TExplicitType, + THasDefault, + TRestSchema, + TResult + ]; /** * @hidden */ @@ -5381,6 +5560,7 @@ export declare class TupleSchemaBuilder TResult) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: TResult | (() => TResult) | undefined; example: unknown; @@ -5553,6 +5733,15 @@ type TakeExceptIndex[], TRequired extends boolean = true, TNullable extends boolean = false, TExplicitType = undefined, THasDefault extends boolean = false, TExtensions = {}> extends SchemaBuilder : TExplicitType, TRequired, TNullable, THasDefault, TExtensions> { #private; + /** Native type parameters for extension rebinding; never emitted at runtime. @internal */ + readonly __cleverbrush_builder_type__: readonly [ + 'union', + TOptions, + TRequired, + TNullable, + TExplicitType, + THasDefault + ]; /** * @hidden */ @@ -5583,6 +5772,7 @@ export declare class UnionSchemaBuilder : TExplicitType) | (() => TExplicitType extends undefined ? SchemaArrayToUnion : TExplicitType) | undefined; description: string | undefined; schemaName: string | undefined; + referenceTarget: SchemaBuilder | undefined; hasCatch: boolean; catchValue: (TExplicitType extends undefined ? SchemaArrayToUnion : TExplicitType) | (() => TExplicitType extends undefined ? SchemaArrayToUnion : TExplicitType) | undefined; example: unknown; @@ -5717,15 +5907,15 @@ export { ParseStringSchemaBuilder, parseString } from './builders/ParseStringSch export { PromiseSchemaBuilder, promise } from './builders/PromiseSchemaBuilder.js'; export type { RecordSchemaValidationResult } from './builders/RecordSchemaBuilder.js'; export { RecordSchemaBuilder, record } from './builders/RecordSchemaBuilder.js'; -export type { NestedValidationResult, PropertyDescriptor, PropertyDescriptorInner, PropertyDescriptorTree, PropertySetterOptions, ValidationErrorMessageProvider } from './builders/SchemaBuilder.js'; +export type { IndexedPropertyDescriptor, NestedValidationResult, PropertyDescriptor, PropertyDescriptorInner, PropertyDescriptorTree, PropertySetterOptions, ValidationErrorMessageProvider } from './builders/SchemaBuilder.js'; export { BRAND, Brand, InferType, MakeOptional, SchemaBuilder, SchemaTypeBrand, SchemaValidationError, SYMBOL_HAS_PROPERTIES, SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR, ValidationError, ValidationResult } from './builders/SchemaBuilder.js'; export { StringSchemaBuilder, string } from './builders/StringSchemaBuilder.js'; export type { TupleElementValidationResults, TupleSchemaValidationResult } from './builders/TupleSchemaBuilder.js'; export { TupleSchemaBuilder, tuple } from './builders/TupleSchemaBuilder.js'; export type { OptionValidationResults, UnionSchemaValidationResult } from './builders/UnionSchemaBuilder.js'; export { UnionSchemaBuilder, union } from './builders/UnionSchemaBuilder.js'; -export type { CleanExtended, ExtensionConfig, ExtensionDescriptor, ExtraTypeBrandSymbol, FixedMethods, HiddenExtensionMethods, MethodLiteralBrandSymbol } from './extension.js'; -export { defineExtension, EXTRA_TYPE_BRAND, METHOD_LITERAL_BRAND, withExtensions } from './extension.js'; +export type { CleanExtended, ExtensionConfig, ExtensionDescriptor, ExtraProperties, ExtraTypeBrandSymbol, FixedMethods, HiddenExtensionMethods, InferExtensionMetadata, MetadataExtended, MetadataMethod, MetadataMethodBuilder, MetadataMethods, MetadataState, MethodLiteralBrandSymbol } from './extension.js'; +export { defineExtension, defineMetadataMethod, EXTRA_TYPE_BRAND, METHOD_LITERAL_BRAND, withExtensions } from './extension.js'; `, "file:///node_modules/@cleverbrush/schema/extension.d.ts": `/** * @module extension @@ -5805,6 +5995,9 @@ import type { PropertyDescriptorTree, SchemaBuilder } from './builders/SchemaBui import { StringSchemaBuilder } from './builders/StringSchemaBuilder.js'; import { TupleSchemaBuilder } from './builders/TupleSchemaBuilder.js'; import { UnionSchemaBuilder } from './builders/UnionSchemaBuilder.js'; +import type { MetadataExtended } from './metadata-extension.js'; +export type { ExtraProperties, InferExtensionMetadata, MetadataExtended, MetadataMethod, MetadataMethodBuilder, MetadataMethods, MetadataState } from './metadata-extension.js'; +export { defineMetadataMethod } from './metadata-extension.js'; /** * Maps each builder type name to the corresponding generic builder class. * @@ -6008,7 +6201,7 @@ export type FixedMethods = TBuilder & FixedMethods & HiddenExtensionMethods; +export type CleanExtended = MetadataExtended; type ExtendedStringFactory = { (): CleanExtended, TExt>; (equals: T): CleanExtended, TExt>; @@ -7283,6 +7476,203 @@ export type { TupleElementValidationResults, TupleSchemaValidationResult } from export { TupleSchemaBuilder } from './builders/TupleSchemaBuilder.js'; export * from './core.js'; export { type ArrayBuiltinExtensions, any, array, arrayExtensions, boolean, date, type ExtendedAny, type ExtendedArray, type ExtendedBoolean, type ExtendedDate, type ExtendedFunc, type ExtendedNumber, type ExtendedObject, type ExtendedPromise, type ExtendedRecord, type ExtendedString, type ExtendedTuple, type ExtendedUnion, enumOf, func, type NumberBuiltinExtensions, type NumberOneOfExtension, number, numberExtensions, object, promise, record, type StringBuiltinExtensions, type StringOneOfExtension, string, stringExtensions, tuple, union } from './extensions/index.js'; +`, + "file:///node_modules/@cleverbrush/schema/metadata-extension.d.ts": `import type { AnySchemaBuilder } from './builders/AnySchemaBuilder.js'; +import type { ArraySchemaBuilder } from './builders/ArraySchemaBuilder.js'; +import type { BooleanSchemaBuilder } from './builders/BooleanSchemaBuilder.js'; +import type { DateSchemaBuilder } from './builders/DateSchemaBuilder.js'; +import type { FunctionSchemaBuilder } from './builders/FunctionSchemaBuilder.js'; +import type { GenericSchemaBuilder } from './builders/GenericSchemaBuilder.js'; +import type { NumberSchemaBuilder } from './builders/NumberSchemaBuilder.js'; +import type { ObjectSchemaBuilder } from './builders/ObjectSchemaBuilder.js'; +import type { PromiseSchemaBuilder } from './builders/PromiseSchemaBuilder.js'; +import type { RecordSchemaBuilder } from './builders/RecordSchemaBuilder.js'; +import type { PropertyDescriptorTree, SchemaBuilder } from './builders/SchemaBuilder.js'; +import type { StringSchemaBuilder } from './builders/StringSchemaBuilder.js'; +import type { TupleSchemaBuilder } from './builders/TupleSchemaBuilder.js'; +import type { UnionSchemaBuilder } from './builders/UnionSchemaBuilder.js'; +import type { EXTRA_TYPE_BRAND, HiddenExtensionMethods, METHOD_LITERAL_BRAND } from './extension.js'; +/** @internal Metadata helpers own their key, including explicitly undefined values. */ +export declare function isMetadataMethod(method: Function): boolean; +/** Metadata recorded by typed extension methods; unannotated builders yield \`{}\`. */ +export type InferExtensionMetadata = S extends { + readonly __cleverbrush_extension_metadata__: infer M; +} ? M : {}; +/** @internal Description carried on a metadata method's type, not on schemas. */ +export type MetadataMethod = ((this: SchemaBuilder, ...args: A) => SchemaBuilder) & { + readonly __cleverbrush_metadata_method__: { + key: K; + value: V; + mode: Mode; + }; +}; +/** Defines the source of a typed metadata value for an extension method. */ +export interface MetadataMethodBuilder { + /** Store the caller's argument, retaining its literal type. */ + argument(): MetadataMethod; + /** Create a zero-argument method that stores a fixed value. */ + value(value: TValue): MetadataMethod; + /** Compute metadata from typed arguments; inference uses the callback's return type. */ + compute(compute: (...args: TArgs) => TValue): MetadataMethod; +} +/** + * Define an immutable metadata-only method for use inside \`defineExtension()\`. + * + * Unlike ordinary extension methods, its stored value is also available through + * \`InferExtensionMetadata\`. Repeated writes replace the value for that key. + * The method does not validate input or change the schema's inferred value type. + * Methods are installed only on factories returned by \`withExtensions()\`. + * + * @param key - Runtime introspection key, independent of the fluent method name. + * @example + * \`\`\`ts + * const labels = defineExtension({ + * string: { + * label: defineMetadataMethod('label').argument(), + * internal: defineMetadataMethod('visibility').value('internal') + * } + * }); + * const s = withExtensions(labels).string().label('Title').optional(); + * type Label = InferExtensionMetadata['label']; // 'Title' + * \`\`\` + */ +export declare function defineMetadataMethod(key: K): MetadataMethodBuilder; +/** @internal Rebuild a native builder with new extension state, preserving its value flags. */ +type WithExtensions = S extends { + readonly __cleverbrush_builder_type__: infer P; +} ? P extends readonly [ + 'number', + infer T, + infer R extends boolean, + infer N extends boolean, + infer D extends boolean +] ? NumberSchemaBuilder : P extends readonly [ + 'string', + infer T, + infer R extends boolean, + infer N extends boolean, + infer D extends boolean +] ? StringSchemaBuilder : P extends readonly [ + 'date', + infer T, + infer R extends boolean, + infer N extends boolean, + infer D extends boolean +] ? DateSchemaBuilder : P extends readonly [ + 'object', + infer P extends Record>, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer C extends SchemaBuilder[] +] ? ObjectSchemaBuilder : P extends readonly [ + 'array', + infer P extends SchemaBuilder, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V +] ? ArraySchemaBuilder : P extends readonly [ + 'boolean', + infer T, + infer R extends boolean, + infer N extends boolean, + infer X, + infer D extends boolean, + infer V +] ? BooleanSchemaBuilder : P extends readonly [ + 'tuple', + infer P extends readonly SchemaBuilder[], + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer Rest extends SchemaBuilder | undefined, + infer V +] ? TupleSchemaBuilder : P extends readonly [ + 'record', + infer K extends StringSchemaBuilder, + infer P extends SchemaBuilder, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V +] ? RecordSchemaBuilder : P extends readonly [ + 'union', + infer P extends readonly SchemaBuilder[], + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean +] ? UnionSchemaBuilder : P extends readonly [ + 'func', + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer P extends SchemaBuilder[], + infer Return extends SchemaBuilder | undefined, + infer V +] ? FunctionSchemaBuilder : P extends readonly [ + 'promise', + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer P extends SchemaBuilder | undefined, + infer V +] ? PromiseSchemaBuilder : P extends readonly [ + 'generic', + infer F extends (...args: any[]) => SchemaBuilder, + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V +] ? GenericSchemaBuilder : P extends readonly [ + 'any', + infer R extends boolean, + infer N extends boolean, + infer T, + infer D extends boolean, + infer V +] ? AnySchemaBuilder : never : never; +/** @internal Named declaration support for metadata-aware fluent builders. */ +export type MetadataState = MetadataMethods & HiddenExtensionMethods & Extras & { + readonly __cleverbrush_extension_metadata__: Metadata; +}; +/** @internal A metadata-aware factory result; state survives native builder modifiers. */ +export type MetadataExtended> = WithExtensions> & MetadataState; +/** @internal Preserve author-defined type brands without intersecting stale metadata or methods. */ +export type ExtraProperties = Pick | keyof Methods | '__cleverbrush_extension_metadata__'>>; +type ExtraTypes = S extends { + readonly [EXTRA_TYPE_BRAND]?: infer T; +} ? T : {}; +type LiteralNames = S extends { + readonly [METHOD_LITERAL_BRAND]?: infer T; +} ? T : never; +type WithBrand = MetadataExtended, Omit, K> & { + readonly [P in K]?: V; +}>; +type SetMetadata = MetadataExtended, K> & Record>; +/** Describe fields without recursively inspecting the receiver's fluent methods. */ +type MetadataPropertyTree = S extends { + readonly __cleverbrush_builder_type__: readonly [ + 'object', + infer P extends Record>, + ...unknown[] + ]; +} ? PropertyDescriptorTree, ObjectSchemaBuilder

> : never; +/** @internal Named declaration support for extension method signatures. */ +export type MetadataMethods = { + [M in keyof Methods]: Methods[M] extends MetadataMethod ? Mode extends 'argument' ? (this: S, value: Value) => SetMetadata : (this: S, ...args: A) => SetMetadata : Methods[M] extends (this: any, ...args: infer A) => infer R ? typeof EXTRA_TYPE_BRAND extends keyof R ? (this: S, name: Name, ...columns: ReadonlyArray) => any)>) => WithBrand & Record> : typeof METHOD_LITERAL_BRAND extends keyof R ? (this: S, name: Name, ...rest: A extends [any, ...infer Rest] ? Rest : []) => WithBrand | Name> : (this: S, ...args: A) => S : Methods[M]; +}; +export {}; `, "file:///node_modules/@cleverbrush/schema/utils/transaction.d.ts": `/** * Options for customizing transaction behavior.