Skip to content

Client bundles retain the entire ORM (incl. zod, ~420 KB) via a bare import "@zenstackhq/orm" in client-helpers #2763

Description

@ErikDakoda

Version

@zenstackhq/orm, @zenstackhq/client-helpers, @zenstackhq/tanstack-query — all 3.8.3 (current latest).

Summary

Any browser bundle that uses the TanStack Query client ends up containing the entire ORM runtime, including zod, even though the client never executes it. In a bundle of the generated hooks for our schema this is ~421 KB minified (~64% of the bundle), of which zod alone is ~320 KB.

There are two independent causes, and the first looks like a simple oversight.

Cause 1 — a type-only import written as a value import

packages/clients/client-helpers/src/types.ts:1

import { ExtQueryArgsMarker, ExtResultMarker, type QueryOptions } from '@zenstackhq/orm';

ExtQueryArgsMarker and ExtResultMarker are used only in type positions in that file:

export type InferExtQueryArgs<T> = T extends { [ExtQueryArgsMarker]?: infer E } ? ... : {};
export type InferExtResult<T>    = T extends { [ExtResultMarker]?: infer E }    ? ... : {};

Because the bindings are erased but the specifier is preserved, the emitted output contains a bare side-effect import — packages/clients/client-helpers/dist/index.mjs:3:

import "@zenstackhq/orm";

That is the one import form a bundler cannot drop, so the whole ORM graph is retained. Note QueryOptions on the same line is already marked type, and the sibling file client-helpers/src/transaction.ts imports the same module with a correct import type { ... } — which is why this reads as an oversight rather than a deliberate choice.

Fix:

import type { ExtQueryArgsMarker, ExtResultMarker, QueryOptions } from '@zenstackhq/orm';

Cause 2 — a runtime constant only reachable through the barrel

packages/clients/tanstack-query/src/common/transaction.ts:5

import { CoreReadOperations } from '@zenstackhq/orm';

This one is a genuine runtime value, used at a single site:

if (CoreReadOperations.includes(op.op)) { ... }

It is a 7-element string array (findMany, findUnique, findFirst, count, aggregate, groupBy, exists) defined in packages/orm/src/client/crud/operations/base.ts, so importing it pulls the full barrel. Fixing Cause 1 alone therefore still leaves the ORM in any bundle that includes transaction.ts.

Possible fixes, in rough order of preference:

  1. Expose the operation-name constants from a leaf entry point (e.g. a new @zenstackhq/orm/constants) and import them from there. packages/orm/src/common-types.ts is already exactly such a leaf — pure types plus small sentinel classes, no ORM imports — so re-exporting from there would also work with a smaller diff.
  2. Additionally declare "sideEffects": false on the client-facing packages. Currently no @zenstackhq/* package declares sideEffects, which prevents bundlers from eliding side-effect-only imports on their own.

Measurement

Synthetic harness over the client-reachable graph (not a full framework build): esbuild, bundle, minify, format=esm, platform=browser, with react, react-dom, @tanstack/react-query and pg marked external. Entry point is a generated hooks tree (55 modules) built on useModelQueries.

bundle size
as published 662.1 KB
with the exact specifier @zenstackhq/orm aliased to a stub exporting only CoreReadOperations 240.6 KB
difference −421.4 KB (−63.7%)

Top contributors in the unaliased bundle: zod 319.7 KB, schema 113.7 KB, @zenstackhq/orm 48.0 KB.

The aliased build was checked for correctness, not just size: all seven CoreReadOperations members are present, and @zenstackhq/orm/common-types (DbNull / JsonNull / AnyNull, which are real runtime values used by client-helpers/fetch.ts) still resolves and is still bundled. Only the exact specifier can be aliased — aliasing the subpaths too would break those values.

Why an alias is not a good workaround downstream

Aliasing the bare specifier in application config works, but it has to be scoped to the client only (the same package is used for real on the server), and it needs re-verification on every @zenstackhq/* upgrade, since a newly added named import from the barrel would silently become undefined at runtime. Fixing it in the packages removes that burden for everyone.

Offer

Happy to contribute a PR for both causes if you agree with the approach — just let me know which shape you'd prefer for Cause 2 (new constants leaf vs. re-export from common-types), since that's an entry-point decision I'd rather not guess at.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions