Describe the bug
buildDatabaseSchema() runs tsdown with skipNodeModulesBundle: false, so the generated
.output/server/node_modules/@nuxthub/db/schema.mjs contains a full inlined copy of
drizzle-orm and of the database driver (postgres in my case) — and, through a circular import,
a second copy of the @nuxthub/db runtime (db.mjs) itself.
At the same time Nitro externalizes those very same packages into
.output/server/node_modules/drizzle-orm and .output/server/node_modules/postgres, which is
where db.mjs and all server chunks import them from.
Result: every production build ships two independent module instances of drizzle-orm and of
the driver, and the table objects handed to drizzle({ schema }) are created by a different copy
of the library than the one that builds the queries.
Steps to reproduce
Steps to reproduce the behavior:
- A Nuxt project with
hub.db.dialect: "postgresql", driver: "postgres-js".
- Schema files under
server/db/schema/*.ts importing from drizzle-orm/pg-core, plus one
relations.ts that does import { schema } from "@nuxthub/db" (the documented way to declare
relations).
nuxt build.
- Inspect
.output/server/.
Expected behavior
schema.mjs only needs to contain the user's schema definitions. Everything it imports from
node_modules is already present in .output/server/node_modules, so it should stay external:
await build({
entry: { schema: entry },
outDir: join(buildDir, "hub/db"),
...
format: "esm",
skipNodeModulesBundle: true, // ← or an explicit `external` list
external: ["drizzle-orm", /^drizzle-orm\//, "@nuxthub/db", driverPackage],
...
})
Either skipNodeModulesBundle: true or an explicit external covering drizzle-orm,
drizzle-orm/*, the configured driver (postgres / pg / @electric-sql/pglite / …) and
@nuxthub/db itself would:
- cut
schema.mjs down to the ~500 KB of actual schema code (and remove the driver entirely);
- guarantee a single
drizzle-orm instance across the whole server bundle;
- break the
schema.mjs ↔ db.mjs cycle at the module level instead of by duplication;
- remove the reason
platform had to be pinned in the first place.
I'm happy to open a PR if you agree with the direction.
Evidence
schema.mjs is 773 722 bytes / 23 289 lines. The first 7 820 lines (265 068 bytes, ~34 % of the
file) are vendor code — none of it is mine:
$ grep -o '^//#region node_modules/\.pnpm/[^/]*' .output/server/node_modules/@nuxthub/db/schema.mjs \
| sort | uniq -c
82 //#region node_modules/.pnpm/drizzle-orm@0.45.2_...
10 //#region node_modules/.pnpm/postgres@3.4.9
1 //#region node_modules/.pnpm/@evlog+nuxthub@2.0.1_...
The same packages also exist as real, externalized directories:
$ du -sh .output/server/node_modules/drizzle-orm .output/server/node_modules/postgres
780K .output/server/node_modules/drizzle-orm
104K .output/server/node_modules/postgres
…and that is where everything else resolves them from:
// .output/server/node_modules/@nuxthub/db/db.mjs (generated, NOT bundled)
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema.mjs'
// .output/server/chunks/routes/api/sync/upload.post.mjs
import { db, schema } from '@nuxthub/db';
import { eq, and, inArray } from 'drizzle-orm'; // ← the externalized copy
Why this matters
- Correctness is accidental, not guaranteed. The
PgTable objects in schema_exports are
built by the inlined drizzle-orm; eq(), and(), the query builders and the dialect used at
runtime come from the externalized one. This only works because drizzle registers all of its
internal brands via Symbol.for(...) (drizzle:entityKind, drizzle:Columns,
drizzle:IsDrizzleTable, …), which are cross-instance by construction. Any future instanceof
check, module-local Symbol(), WeakMap registry or version-skew between the two copies turns
this into a silent, very hard-to-debug failure. A build should not depend on a library's
internal branding strategy.
- Bloat. ~265 KB of dead vendor JS per build, plus the second driver copy — parsed and kept in
memory on every cold start, and shipped in every Docker layer.
- Startup cost. Node parses and instantiates
drizzle-orm twice.
- It already cost one workaround. The
platform: "node" change in 0.10.8 exists only because
bundling a Node-only driver (postgres imports net, tls, os, fs, stream,
perf_hooks) under the neutral platform floods dev and db:generate with
UNRESOLVED_IMPORT. That was a symptom fix — and my own, so I'd rather say it plainly: if the
driver were never bundled, the platform setting would be moot here.
Describe the bug
buildDatabaseSchema()runstsdownwithskipNodeModulesBundle: false, so the generated.output/server/node_modules/@nuxthub/db/schema.mjscontains a full inlined copy ofdrizzle-ormand of the database driver (postgresin my case) — and, through a circular import,a second copy of the
@nuxthub/dbruntime (db.mjs) itself.At the same time Nitro externalizes those very same packages into
.output/server/node_modules/drizzle-ormand.output/server/node_modules/postgres, which iswhere
db.mjsand all server chunks import them from.Result: every production build ships two independent module instances of
drizzle-ormand ofthe driver, and the table objects handed to
drizzle({ schema })are created by a different copyof the library than the one that builds the queries.
Steps to reproduce
Steps to reproduce the behavior:
hub.db.dialect: "postgresql",driver: "postgres-js".server/db/schema/*.tsimporting fromdrizzle-orm/pg-core, plus onerelations.tsthat doesimport { schema } from "@nuxthub/db"(the documented way to declarerelations).
nuxt build..output/server/.Expected behavior
schema.mjsonly needs to contain the user's schema definitions. Everything it imports fromnode_modulesis already present in.output/server/node_modules, so it should stay external:Either
skipNodeModulesBundle: trueor an explicitexternalcoveringdrizzle-orm,drizzle-orm/*, the configured driver (postgres/pg/@electric-sql/pglite/ …) and@nuxthub/dbitself would:schema.mjsdown to the ~500 KB of actual schema code (and remove the driver entirely);drizzle-orminstance across the whole server bundle;schema.mjs ↔ db.mjscycle at the module level instead of by duplication;platformhad to be pinned in the first place.I'm happy to open a PR if you agree with the direction.
Evidence
schema.mjsis 773 722 bytes / 23 289 lines. The first 7 820 lines (265 068 bytes, ~34 % of thefile) are vendor code — none of it is mine:
The same packages also exist as real, externalized directories:
…and that is where everything else resolves them from:
Why this matters
PgTableobjects inschema_exportsarebuilt by the inlined
drizzle-orm;eq(),and(), the query builders and the dialect used atruntime come from the externalized one. This only works because drizzle registers all of its
internal brands via
Symbol.for(...)(drizzle:entityKind,drizzle:Columns,drizzle:IsDrizzleTable, …), which are cross-instance by construction. Any futureinstanceofcheck, module-local
Symbol(),WeakMapregistry or version-skew between the two copies turnsthis into a silent, very hard-to-debug failure. A build should not depend on a library's
internal branding strategy.
memory on every cold start, and shipped in every Docker layer.
drizzle-ormtwice.platform: "node"change in 0.10.8 exists only becausebundling a Node-only driver (
postgresimportsnet,tls,os,fs,stream,perf_hooks) under theneutralplatform floods dev anddb:generatewithUNRESOLVED_IMPORT. That was a symptom fix — and my own, so I'd rather say it plainly: if thedriver were never bundled, the
platformsetting would be moot here.