Skip to content

feat: add projection-aware reads and inferred synchronous mapping - #233

Merged
andrewzolotukhin merged 3 commits into
developmentfrom
feat/projection-aware-sync-mapping
Sep 30, 2026
Merged

andrewzolotukhin merged 3 commits into
developmentfrom
feat/projection-aware-sync-mapping

Conversation

@andrewzolotukhin

@andrewzolotukhin andrewzolotukhin commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Original request

Implement point 1, “Projection-aware and synchronous mapping,” from the consumer-experience proposal in Framework, as a separate PR against development, with JSDoc, documentation, README updates, tests, and a minor changeset. Xpenser adoption follows publication and is not part of this PR.

What changed

  • Added opt-in withRowSchema() readers for ordinary queries, aliased joins, and ORM entity sets. Immutable, detached query plans expose the selected runtime rowSchema without executing SQL.
  • Included typed projections, aggregates, nested relation customizers, explicit joins, and STI/CTI graphs. Polymorphic reads expose a runtime union plus variantRowSchemas for application-owned mapper dispatch.
  • Preserved SQL nulls, decoded dates at every graph depth, and cast decimal/bigint values to text before driver/JSON parsing can lose precision. Composite cursors retain private native-precision values.
  • Added getSyncMapper() with eligibility inferred through the existing configure() chain, including nested arrays, final overrides, completeness checks, and runtime thenable guards. No second configuration API; getMapper() remains async.
  • Corrected mapper constraints for strict consumers of the built declarations: schema matching uses field metadata rather than recursively comparing entire fluent APIs. Regression tests retain completeness and async checks for unrelated/empty registrations. Transaction clones retain the raw-query safety guard.
  • Added application-agnostic defineMetadataMethod() and InferExtensionMetadata to native schema extensions. Literal, constant, and computed metadata retain their types through immutable chains; numeric SQL classification stays in knex-schema. Removed the new global numeric-storage augmentation/prototype patch entirely (the pre-existing primary-key implementation is unchanged).
  • Preserved ordinary extension receiver types, named scopes/projections, primary-key brands, and declaration emission. Corrected DI's broad function-schema constraints to accept explicit return schemas. Added strict multi-file consumer checks against built packages, including actual README and llms.txt examples.
  • Consolidated both standalone query guides into the knex-schema README after the basics, reordered mapper/ORM READMEs and website sections, and rewrote llms.txt around a current, complete multi-file workflow. Refreshed the schema playground declarations for the new native API.
  • Added runtime/type regression tests, PostgreSQL integration tests, a prepared-mapper benchmark, JSDoc, package READMEs, and website guides.
  • Added minor-only changesets for @cleverbrush/mapper, @cleverbrush/knex-schema, @cleverbrush/orm, @cleverbrush/schema, and @cleverbrush/di. No package publishing, Xpenser changes, or proposal-file edits.

Reasoning and boundaries

The opt-in surface keeps legacy query mutability, driver representations, and entity tracking unchanged. One shared field description drives SQL projection, decoding, and metadata; source schemas no longer need to be duplicated in application mappers. Pure mappings can be prepared once and reused without per-row promises, while fetching, authorization, enrichment, DTO conversions, and union dispatch remain explicit application concerns.

Native extensions already stored runtime metadata; the missing capability was retaining its literal type through fluent modifiers. Generic metadata methods close that gap without adding database concepts to schema or patching global numeric builders. Documentation teaches the package basics before advanced reading/mapping and describes the current API rather than PR history.

The read API targets PostgreSQL. Enter it before legacy shape changes, ordering/pagination, or raw callbacks. Unsupported opaque shapes fail explicitly. Timezone-less SQL dates/timestamps use UTC in this mode; JavaScript dates retain millisecond precision. Optional schemas with input defaults are rejected because their inferred requirement can differ from persisted nullability; use separate input and storage schemas. Exact numeric hints within already-stored JSON are likewise rejected: represent those JSON values as strings.

Blog post

Skipped: this is Framework library infrastructure, not an Xpenser product feature. Consumer-facing package and website documentation is included instead.

Screenshots / preview evidence

Manually verified locally with agent-browser: docs /mapper, /knex-schema, /orm, /llms.txt, and schema-site /mapper; the relocated guide sections follow introductory material, code examples display, and no browser errors were reported. Screenshots below show the current advanced sections. This repository does not configure a PR application deployment, so there is no hosted preview URL.

Synchronous mapping guide

Projection-aware read guide

Validation

  • npm run lint
  • npm run build — 21 tasks
  • npm run test — 4,276 unit and compile-time tests across 201 passing test files; no type errors
  • npx tsc --noEmit --incremental false -p libs/knex-schema/tsconfig.typecheck.json — clean strict consumer check against built package declarations
  • npm run test:queries:integration — 37 PostgreSQL tests, including the real read-schema-to-mapper workflow and transaction safety guards
  • npm run typecheck:schema-site and npm run typecheck:docs-site
  • npm run docs — generated API documentation, no errors; 288 non-fatal reference warnings remain
  • npm run bench -- --project benchmarks mapper.bench.ts — initial implementation benchmark: prepared synchronous mapping was 2.01× / 2.53× / 2.70× faster for flat / computed / nested fixtures. These are mapping-only results, not endpoint-latency claims. Mapper runtime is unchanged by this review update.
  • GitHub checks: both passed on review-update commit 6f7fc689 — Lint, Build & Test (Node 24) and PostgreSQL Query Integration.
  • Hosted application preview/e2e and SigNoz: not applicable; no deployed app or telemetry environment is configured for this library PR. Real PostgreSQL integration and local documentation browser QA were performed.

Comment thread libs/knex-schema/READ-SCHEMAS.md Outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this file but instead update README.md file. Also we should not have before and after because the reader doesn't know how it was before, he must know only current state of the affairs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6f7fc689. Removed READ-SCHEMAS.md and moved its consumer examples, decoding rules, polymorphic dispatch, and limitations into README.md under “Projection-aware reads,” after the prerequisites. Removed the before/after narrative; the guide describes the current API, including separate query/mapping/service files. A regression test now compiles those actual multi-file README examples against the built package declarations.

Comment thread libs/knex-schema/README.md Outdated
Comment on lines +13 to +30
## Opt-in projection-aware reads

Call `.withRowSchema()` before selecting or including fields to get an immutable,
detached PostgreSQL read query. Its `rowSchema` describes the actual decoded
projection, including SQL nulls, nested dates and exact decimal/bigint strings.

```ts
const read = query(knex, UserSchema).withRowSchema()
.select(u => ({ id: u.id, createdAt: u.createdAt }));
const SourceSchema = read.rowSchema; // metadata only; no SQL
const rows = await read.where(u => u.id, 42);
```

Supports typed flat joins/aggregates, declared and explicit nested relations,
named projections, composite cursors and STI/CTI branch schemas. Existing query
behavior is unchanged. See the [consumer guide](./READ-SCHEMAS.md) for multi-file
mapping examples, representation rules, supported operations and migration limits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this part doesn't seem to be correctly placed, why this is in the top of the file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6f7fc689. The README now introduces installation, quick start, schema definition, CRUD, filtering, relations, scopes, and projections before composable and projection-aware reads. I also moved the corresponding website sections after the basic examples and before the API reference.

Comment thread libs/knex-schema/package.json Outdated
Comment on lines +16 to +17
"COMPOSABLE_QUERIES.md",
"READ-SCHEMAS.md"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should not have this, instead place these files to readme with correct order and good readability for first-time user

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6f7fc689. Both COMPOSABLE_QUERIES.md and READ-SCHEMAS.md have been removed, with their content consolidated into the README in prerequisite order. The package files list contains only dist again; npm includes the README automatically. Links from ORM, mapper, and the website now point to the relevant README anchors.

Comment thread libs/knex-schema/src/read-storage.ts Outdated
Comment on lines +27 to +77
declare module '@cleverbrush/schema' {
interface NumberSchemaBuilder<
TResult,
TRequired extends boolean,
TNullable extends boolean,
THasDefault extends boolean,
TExtensions
> {
/** Set a SQL storage type; schema-aware reads retain its representation. */
columnType<const SQL extends string>(
type: SQL
): SqlNumber<
TResult,
TRequired,
TNullable,
THasDefault,
TExtensions,
SQL
>;
/** Set bigint storage; schema-aware reads return exact strings. */
bigint(): SqlNumber<
TResult,
TRequired,
TNullable,
THasDefault,
TExtensions,
'bigint'
>;
/** Set smallint storage; schema-aware reads return numbers. */
smallint(): SqlNumber<
TResult,
TRequired,
TNullable,
THasDefault,
TExtensions,
'smallint'
>;
/** Set decimal storage; schema-aware reads return exact strings. */
decimal(
precision: number,
scale: number
): SqlNumber<
TResult,
TRequired,
TNullable,
THasDefault,
TExtensions,
'decimal'
>;
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need it? is it something that we can't do with native @cleverbrush/schema?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native schema extensions already support storing this metadata at runtime. The missing piece was retaining the exact metadata type through fluent calls, so read types can distinguish bigint/decimal strings from ordinary numbers. The earlier module augmentation was a workaround for that typing gap, not a necessary database mechanism.

Addressed in 6f7fc689 with application-agnostic defineMetadataMethod() and InferExtensionMetadata in @cleverbrush/schema. Extension authors can record an argument, fixed value, or computed value through the native immutable extension mechanism. knex-schema uses that for columnType/bigint/smallint/decimal; all SQL interpretation stays in knex-schema. Schema itself has no knex or SQL dependency. Tests cover all supported factory kinds, native modifiers, ordinary extensions, metadata replacement, and strict cross-file declaration consumers.

Comment thread libs/knex-schema/src/read-storage.ts Outdated
Comment on lines +99 to +105
for (const [name, method] of Object.entries(methods))
if (typeof prototype[name] !== 'function')
Object.defineProperty(prototype, name, {
value: method,
configurable: true,
writable: true
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks like a hack, why do we need it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The patch was compensating for the type-inference gap described in the other thread; it was not needed for runtime storage. I agree it was the wrong boundary. Removed read-storage.ts, its numeric module augmentation, and its prototype patch in 6f7fc689. The four storage methods are now normal factory-local native extensions. Runtime and compile-time regressions verify that importing knex-schema does not add bigint/decimal/columnType/smallint to plain schema numbers. The separate pre-existing primary-key implementation is unchanged in scope.

Comment thread libs/mapper/README.md Outdated
Comment on lines +43 to +68
## 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/READ-SCHEMAS.md), including separate
definition/mapping/service files and explicit polymorphic dispatch. The mapper
performs no database calls or application enrichment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, why it's at the top of the file? how new user is supposed to read README.md then? if he even doesn't know what this library for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6f7fc689. Synchronous mapping now follows the introduction, installation, quick start, compile-time safety, auto-mapping, and mapping strategies. Added getSyncMapper to the API reference and updated the introductory workflow to mention both async and synchronous retrieval. The documentation websites follow the same basics-before-advanced order.

Comment thread libs/orm/README.md Outdated
Comment on lines +24 to +44
## 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/READ-SCHEMAS.md)
for numeric/null/date rules, examples and compatibility boundaries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, why at the top of the file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6f7fc689. Detached result-schema reads now follow setup, DbSet basics, relations, change tracking, and row versioning, so the reader has the context to understand “detached.” Added withRowSchema to the DbSet API table and updated links to the consolidated knex-schema README.

Comment thread websites/docs/public/llms.txt Outdated
Comment on lines +23 to +39
## Projection-aware reads and synchronous mapping

`query(knex, Schema).withRowSchema()` and `db.entities.withRowSchema()` opt into
immutable detached PostgreSQL reads. `rowSchema` matches the selected decoded
shape, including nested relations, SQL nulls, Date values and exact decimal/bigint
strings. Metadata access never executes SQL. STI/CTI readers expose
`variantRowSchemas` for explicit application discriminator dispatch. Legacy reads
are unchanged. See [/knex-schema#row-schemas](/knex-schema#row-schemas) and
[/orm#detached-read-schemas](/orm#detached-read-schemas).

`mapper().configure(Source, Target, configure).getSyncMapper(Source, Target)`
returns a synchronous complete mapping when all final steps and nested mappings
are synchronous. Async or disguised thenables are rejected; callbacks are not
probed at configuration time. `getMapper()` remains asynchronous. Reuse schemas
and prepared mappings; do not fetch data inside pure mapping. See
[/mapper#synchronous-mapping](/mapper#synchronous-mapping).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why at the top? look at this file and make it actual and to have logically correct sequence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6f7fc689. Reorganized llms.txt as an overview/package map, a complete multi-file schema → contract → handlers → registration → client workflow, then schema authoring, HTTP services, database/mapping, forms/jobs, and reference links. Updated stale APIs and removed incomplete examples; projection-aware reads and synchronous mapping now live in their relevant data section. The actual four-file workflow and native metadata README example are compiled in a strict built-package consumer regression test, including declaration emission.

@andrewzolotukhin
andrewzolotukhin merged commit 0ab4cfd into development Sep 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant