Anti-patterns (deliberately excluded)Supported
This manual covers the union of four doc surfaces — Drizzle ORM, MikroORM, Typia and NestJS — because zmdb is meant to replace all four. Coverage is checked mechanically: yarn verify:docs-coverage walks the upstream page inventory and fails if any page is neither documented here nor listed as out of scope with a reason.
Fourteen upstream pages are out of scope. They are not gaps and they are not roadmap — a page marked ToDo in this manual is a capability we intend to build; a page on this list is one we have decided against. Almost all of them share a root cause: they document machinery that exists to recover, cache or track information that zmdb resolves at compile time and then keeps in plain data.
Disagreeing with an entry here is a design argument, not a bug report — but do make it. Each reason below is falsifiable, and if the trade-off stops holding the right response is to build the feature, not to soften the wording.
What lint can enforce#
Most entries on this page are architectural choices, not syntax a linter can identify from one file. The shipped Lint Rules deliberately cover only six precise local mistakes: nullable tag distribution, erased JSON shapes, interpolated SQL sinks, ambiguous numeric columns, unbounded reads and empty update patches. They do not pretend to detect an identity map, a unit of work or another application-level design from an AST node.
Loaders and caches do not make rows live#
The supported DataLoader and result cache retain read values, but neither is the identity map rejected below:
| Property | Request loader | Result cache | Identity map |
|---|---|---|---|
| Consulted by | Explicit load() | A read with cache | Every entity read |
| Lifetime | One explicit request scope | Explicit TTL/store | ORM session/context |
| Row identity | Fresh shallow copy | Fresh shallow copy | Shared object reference |
| Write behavior | No tracking or invalidation | Invalidates table + caller tags | Tracks objects for flush/coherence |
The distinction is not the word “cache”. It is whether the ORM owns a canonical live object graph. zmdb does not: ordinary reads bypass both mechanisms, mutating a returned row never schedules SQL, and every write remains an explicit repository call.
Application-level cascade emulation hides writes#
zmdb emits ON DELETE and ON UPDATE actions into database constraints; it does not make a repository walk an object graph and delete or persist related rows on the caller's behalf. Database actions are atomic with the parent write, apply to every database client and do not turn one repository call into an unbounded series of queries.
When deleting related rows also has application side effects, write those operations explicitly inside a transaction. See Cascading for the generated constraints and the explicit transaction pattern.
MikroORM#
collections#
A Collection is a lazy-loading proxy: reading book.tags can issue a query, so the cost of a property access depends on hydration state that is invisible in the type. zmdb returns plain arrays that are already fully materialised — a relation you did not request is absent from the type, not silently fetched.
Instead: inert-rows
entity-manager#
The EntityManager is the front door to the identity map and unit of work: it holds references to every entity you have touched and decides what to flush. zmdb has no session to hold, so there is no object to inject — a repository takes a connection and returns data.
Instead: repository
unit-of-work#
A unit of work batches your mutations until flush() and infers the SQL from diffing tracked objects. The write that runs is therefore not visible at the call site, and ordering bugs surface at flush time rather than where they were caused. zmdb writes when you call the write method, and the statement is the one the compiler emitted for that call.
Instead: transactions
identity-map#
An identity map guarantees reference equality across a session by caching every loaded entity. That cache is correctness-critical (two reads must not diverge) and unbounded (it grows with the session), which makes long-lived contexts leak and request-scoped ones require careful clearing. zmdb returns a fresh value per read; equality is structural. Its request loader and result cache are explicit read optimisations, not object ownership: both hand out fresh shallow copies, no ordinary read consults either unless asked, and neither tracks mutation or turns it into a write. The loader dies with its request; the result cache uses TTL and explicit tags. Remove those boundaries and it is an identity map under another name.
Instead: inert-rows
propagation#
Setting one side of a bidirectional relation and having the other side update itself requires the ORM to own both objects and watch them. It is convenient until the propagated write is the one you did not want. zmdb has no live objects to propagate between; a foreign key is a column you set.
Instead: inert-rows
wrap-helper#
wrap(entity).isInitialized() / .init() / .toJSON() exist because entities are proxies whose real state is not what the type says. Needing a helper to ask an object whether it is really loaded is the tell. zmdb rows carry no hidden state, so there is nothing to unwrap.
Instead: inert-rows
entity-constructors#
MikroORM documents which constructor runs during hydration and which does not, because entities are both your domain classes and the ORM's row containers. zmdb never constructs your classes: a read returns data shaped by the schema, and what you build from it is yours.
Instead: inert-rows
metadata-cache#
A metadata cache exists to amortise the cost of discovering entity metadata at runtime — reading decorators, walking source files, resolving types. zmdb resolves all of that at compile time, so there is no discovery step to cache and no cache to invalidate when it goes stale against your source.
Instead: jit-vs-aot
metadata-providers#
ReflectMetadataProvider and TsMorphMetadataProvider are two strategies for recovering type information that the runtime has thrown away — one guesses from design:type, the other re-parses your TypeScript at boot. zmdb reads the real checker types during compilation, so the information never has to be recovered.
Instead: aot-setup
usage-with-js#
MikroORM supports schemas written in plain JavaScript via EntitySchema, because its metadata comes from decorators and options objects either way. zmdb derives the schema, the DTOs and the validators from TypeScript types, so a .js file has nothing for it to read. TypeScript is the input format, not a preference.
Instead: pure-typescript
Typia#
setup/legacy#
Typia's legacy mode runs a generator that writes the validators into your repo as source, for build pipelines that cannot host a transformer. The checked-in artefact then has to be regenerated whenever a type changes, and nothing fails if you forget — you ship a validator for last week's type. zmdb requires the transformer so that the validator cannot be out of date with the type it came from.
Instead: aot-setup
NestJS#
openapi/cli-plugin#
The Swagger CLI plugin re-parses your TypeScript during the build to recover the property types, optionality and comments that @ApiProperty() would otherwise make you retype by hand. It is a workaround for decorators not being able to see the type they are attached to. zmdb's transformer already has the checker types, so the schema comes from the type with no second parse and no annotations to drift.
Instead: web-openapi
graphql/cli-plugin#
Same workaround as the Swagger plugin, in the GraphQL SDL generator: a build-time re-parse of your source to recover types the decorators could not see. With the AOT transformer the SDL is derived from the checker types directly.
Instead: web-graphql
observability/dashboard#
A hosted dashboard is a product, not a framework capability, and documenting one in the framework manual ties your telemetry to a vendor. zmdb emits OpenTelemetry spans and metrics; point them at whatever backend you already run.
Instead: web-observability
What is _not_ on this list#
Three exclusions people expect to find here, and why they are not:
- Active Record. Not an upstream page in its own right; the underlying objection is inert rows — a row has no
save()because it carries no persistence state. - Protocol Buffers. Typia's protobuf codec is documented, not excluded — see Protobuf Message, Encode and Decode.
- Runtime schema sync.
updateSchema()-style live DDL is documented as a deliberately narrow tool in Migrations andcli-push, which is the reviewable,
diffed path.
---
See also: Why zmdb · Architecture · Lint Rules · Inert Rows · JIT vs AOT