Skip to content

Dependencies are duplicated in the schema.mjs and .output/server/node_modules files. #916

Description

@cany748

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:

  1. A Nuxt project with hub.db.dialect: "postgresql", driver: "postgres-js".
  2. 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).
  3. nuxt build.
  4. 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

  1. 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.
  2. 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.
  3. Startup cost. Node parses and instantiates drizzle-orm twice.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions