diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 6c3b47778e..a1fa062fa7 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -7,7 +7,6 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import type * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; -import * as Bundle from "../../Bundle/Bundle.ts"; import { type MemoOptions } from "../../Command/Memo.ts"; import type { Dependencies } from "../../Dependencies.ts"; import type { InputProps } from "../../Input.ts"; @@ -45,7 +44,7 @@ import type { WorkerBindingResource, WorkerBindings, } from "./WorkerBinding.ts"; -import { type ModuleRule } from "./WorkerBundle.ts"; +import { type ModuleRule, type WorkerBuildOptions } from "./WorkerBundle.ts"; import { makeWorkerRuntimeContext, type WorkerRuntimeContext, @@ -478,10 +477,13 @@ export interface WorkerProps< */ routes?: WorkerRouteConfig[]; /** - * Extra bundler options applied on top of the standard rolldown input/output - * options used to build this Worker. See {@link Bundle.BundleExtraOptions}. + * Extra bundler options applied on top of the standard rolldown + * input/output options used to build this Worker. Includes the generic + * bundle extras (pure-annotation packages, bundle analyzer) plus an + * `output` field of rolldown output overrides (e.g. `codeSplitting` + * groups) merged over Alchemy's defaults. See {@link WorkerBuildOptions}. */ - build?: Bundle.BundleExtraOptions; + build?: WorkerBuildOptions; /** * Whether to bundle {@link main} with rolldown before upload. * diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts index aa0e650256..969a5a8497 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts @@ -20,6 +20,25 @@ import { type DurableObjectExport, } from "./DurableObject.ts"; +/** + * Bundler options for a Worker: the generic {@link Bundle.BundleExtraOptions} + * plus rolldown output overrides merged over Alchemy's defaults. + */ +export interface WorkerBuildOptions extends Bundle.BundleExtraOptions { + /** + * Rolldown output options merged over Alchemy's defaults. Use this to + * control chunking (`codeSplitting`), minification, etc. + */ + output?: rolldown.OutputOptions; + /** + * Forwarded to rolldown's `preserveEntrySignatures` input option. Some + * `output.codeSplitting` configurations require relaxing it (e.g. + * `includeDependenciesRecursively: false` needs `"allow-extension"`). + * Workers must keep their entry exports, so never pass `false`. + */ + preserveEntrySignatures?: rolldown.InputOptions["preserveEntrySignatures"]; +} + export interface WorkerBundleOptions { id: string; main: string; @@ -36,7 +55,7 @@ export interface WorkerBundleOptions { exports: Record; }; stack: { name: string; stage: string }; - extraOptions: Bundle.BundleExtraOptions | undefined; + extraOptions: WorkerBuildOptions | undefined; } export const WorkerBundle = Effect.gen(function* () { @@ -48,6 +67,7 @@ export const WorkerBundle = Effect.gen(function* () { const realMain = yield* sanitizeMain(options.main); const inputOptions: rolldown.InputOptions = { input: realMain, + preserveEntrySignatures: options.extraOptions?.preserveEntrySignatures, // Forever-devtool native modules that vite/chokidar reference behind // runtime guards. Rolldown resolves before tree-shaking, so the dead // `require('../pkg')` (lightningcss < 1.32) and `require('fsevents')` @@ -89,7 +109,17 @@ export const WorkerBundle = Effect.gen(function* () { sourcemap: "hidden", minify: true, keepNames: true, + // Rolldown's default chunking can split top-level initializer modules + // (e.g. Drizzle `pgTable` schemas) away from the classes they read, + // and workerd then evaluates a reader before its imported binding is + // initialized — the script fails Cloudflare startup validation with + // `ScriptStartupError: Cannot access '' before + // initialization` (#749). `strictExecutionOrder` wraps cross-chunk + // modules so evaluation follows ESM semantics regardless of how the + // graph was chunked. See DrizzleSchemaChunks.test.ts. + strictExecutionOrder: true, dir: `.alchemy/bundles/${options.id}`, + ...options.extraOptions?.output, }; return { inputOptions, outputOptions, extraOptions: options.extraOptions }; }); diff --git a/packages/alchemy/test/Cloudflare/Workers/DrizzleSchemaChunks.test.ts b/packages/alchemy/test/Cloudflare/Workers/DrizzleSchemaChunks.test.ts new file mode 100644 index 0000000000..f4e22256b3 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/DrizzleSchemaChunks.test.ts @@ -0,0 +1,118 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Alchemy from "@/index.ts"; +import * as Test from "@/Test/Alchemy"; +import { expect } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { fileURLToPath } from "node:url"; + +const fixtureMain = fileURLToPath( + new URL("./fixtures/drizzle-schema-chunks/worker.ts", import.meta.url), +); + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Cloudflare.providers(), +}); + +/** + * Regression stack for https://github.com/alchemy-run/alchemy/issues/749. + * + * The `build` options force the *cyclic* chunk layout from the issue: the + * schema group captures only the schema modules + * (`includeDependenciesRecursively: false`), so `drizzle-orm` stays in the + * entry chunk and the graph becomes `worker.js -> auth-*.js -> worker.js`. + * ESM evaluation then runs the schema chunk before drizzle's class bindings + * initialize. Without WorkerBundle's default `strictExecutionOrder: true`, + * Cloudflare rejects the upload with `ScriptStartupError: Cannot access + * '' before initialization` — so the deploy in `beforeAll` is + * itself the regression assertion. (An acyclic split — e.g. drizzle in its + * own chunk imported by the schema chunk — would NOT regress: plain import + * order already evaluates it correctly.) + */ +const Stack = Alchemy.Stack( + "DrizzleSchemaChunksTestStack", + { providers: Cloudflare.providers(), state: Cloudflare.state() }, + Effect.gen(function* () { + const worker = yield* Cloudflare.Worker("DrizzleSchemaChunks", { + main: fixtureMain, + compatibility: { date: "2026-06-24", flags: ["nodejs_compat"] }, + build: { + // Required by `includeDependenciesRecursively: false`; rolldown + // rejects it under the default "strict". + preserveEntrySignatures: "allow-extension", + output: { + cleanDir: true, + codeSplitting: { + groups: [ + { + name: "auth", + test: "drizzle-schema-chunks/(schema|auth)/", + includeDependenciesRecursively: false, + }, + ], + }, + }, + }, + }); + return { url: worker.url.as() }; + }), +); + +const bundleDir = Effect.gen(function* () { + const path = yield* Path.Path; + return path.resolve(".alchemy/bundles/DrizzleSchemaChunks"); +}); + +// `cleanDir: true` on the stack's output options drops chunks from previous +// runs, so the chunk-layout assertions below can't pass on stale files. +const stack = beforeAll(deploy(Stack)); +afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); + +test( + "code splitting options are applied and produce the cyclic layout", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* bundleDir; + const files = yield* fs.readDirectory(dir); + + // The schema modules landed in their own chunk... + const authChunk = files.find((f) => /^auth-.*\.js$/.test(f)); + expect(authChunk).toBeDefined(); + + // ...which imports the entry back (drizzle-orm stayed in `worker.js`): + // the cyclic `worker.js <-> auth-*.js` graph that TDZ-crashed workerd + // startup in #749 before `strictExecutionOrder` became the default. + const authContent = yield* fs.readFileString(path.join(dir, authChunk!)); + expect(authContent).toMatch(/from\s*"\.\/worker\.js"/); + }), +); + +test( + "worker with drizzle schema modules split into their own chunk deploys and serves (#749)", + Effect.gen(function* () { + const { url } = yield* stack; + + // The worker evaluated its cross-chunk schema at startup and serves. + const client = yield* HttpClient.HttpClient; + const body = yield* client.get(url).pipe( + Effect.flatMap((res) => res.text), + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 5, + }), + // Fresh workers.dev URLs can serve placeholder pages while propagating. + Effect.repeat({ + schedule: Schedule.exponential("500 millis"), + until: (b) => b.includes('"ok":true'), + times: 10, + }), + Effect.orDie, + ); + expect(body).toContain('"ok":true'); + }), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/README.md b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/README.md new file mode 100644 index 0000000000..f22fbd0c9c --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/README.md @@ -0,0 +1,49 @@ +# drizzle-schema-chunks + +Fixture for [#749](https://github.com/alchemy-run/alchemy/issues/749). + +Cloudflare Worker `ScriptStartupError` after Alchemy/Rolldown bundling when +top-level Drizzle schema modules are code-split into a chunk separate from +`drizzle-orm`. Cross-chunk evaluation in workerd leaves class bindings +incomplete (`PgSerialBuilder is not a constructor`, or the classic +`Cannot access '' before initialization` TDZ). + +## Layout + +Mirrors the reporter's monorepo shape: + +- `schema/*` — db package tables (`pgTable` at module scope) +- `auth/*` — auth package tables that cross-import the db schema +- `worker.ts` — Worker entry that imports the full graph + +## How the test pins the fix + +Small graphs stay single-chunk under Alchemy's default Worker bundler, so +`DrizzleSchemaChunks.test.ts` deploys a stack whose `Cloudflare.Worker` +*forces* the issue's chunk layout via `build` options: the schema group +captures only the schema modules (`includeDependenciesRecursively: false`), +leaving `drizzle-orm` in the entry chunk. The resulting graph is **cyclic** +(`worker.js -> auth-*.js -> worker.js`), so ESM evaluation runs the schema +chunk before drizzle's class bindings initialize — the TDZ. Cloudflare's +script-startup validation runs on exactly those chunks at upload, so the +deploy itself is the regression assertion; the test then verifies the +cyclic layout on disk and fetches the worker. + +The cycle is essential: an *acyclic* split (e.g. drizzle in its own chunk +imported by the schema chunk) evaluates correctly under plain import order +and never triggers the bug, with or without `strictExecutionOrder`. Setting +`strictExecutionOrder: false` on the stack's `build.output` restores the +exact issue error: + +``` +ScriptStartupError: Uncaught ReferenceError: Cannot access 'a' before initialization + at auth-BFaahPAe.js:1:110 +``` + +## The fix + +`WorkerBundle` sets `strictExecutionOrder: true` on its rolldown output +options, which wraps cross-chunk modules so evaluation follows ESM semantics +regardless of how the graph was chunked. Before that default, this exact +split failed Cloudflare startup validation. The user-side `advancedChunks` +grouping workaround from the issue is no longer necessary. diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/auth/invitations.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/auth/invitations.ts new file mode 100644 index 0000000000..02e7c6a8cc --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/auth/invitations.ts @@ -0,0 +1,13 @@ +import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; +import { users } from "../schema/users.ts"; + +/** + * Mirrors the issue #749 layout: auth package schema modules that construct + * Drizzle tables at module scope and cross-import the db schema. + */ +export const invitations = pgTable("invitations", { + id: serial("id").primaryKey(), + email: text("email").notNull(), + invitedBy: integer("invited_by").references(() => users.id), + createdAt: timestamp("created_at").defaultNow(), +}); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/auth/workspace.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/auth/workspace.ts new file mode 100644 index 0000000000..afb8d9a754 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/auth/workspace.ts @@ -0,0 +1,15 @@ +import { integer, pgTable, serial, text } from "drizzle-orm/pg-core"; +import { users } from "../schema/users.ts"; +import { invitations } from "./invitations.ts"; + +export const workspaces = pgTable("workspaces", { + id: serial("id").primaryKey(), + name: text("name").notNull(), + ownerId: integer("owner_id").references(() => users.id), +}); + +export const workspaceInvites = pgTable("workspace_invites", { + id: serial("id").primaryKey(), + workspaceId: integer("workspace_id").references(() => workspaces.id), + invitationId: integer("invitation_id").references(() => invitations.id), +}); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/sessions.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/sessions.ts new file mode 100644 index 0000000000..5163989c6f --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/sessions.ts @@ -0,0 +1,10 @@ +import { integer, pgTable, serial, text } from "drizzle-orm/pg-core"; +import { users } from "./users.ts"; + +export const sessions = pgTable("sessions", { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id), + token: text("token").notNull(), +}); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/users.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/users.ts new file mode 100644 index 0000000000..46f250cd5e --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/users.ts @@ -0,0 +1,12 @@ +import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +/** + * Top-level `pgTable(...)` — evaluated at module init. When Rolldown puts + * this module in a separate chunk from `drizzle-orm`, workerd can observe + * incomplete cross-chunk bindings during startup (#749). + */ +export const users = pgTable("users", { + id: serial("id").primaryKey(), + email: text("email").notNull(), + createdAt: timestamp("created_at").defaultNow(), +}); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/waitlist.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/waitlist.ts new file mode 100644 index 0000000000..ffdb124c94 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/schema/waitlist.ts @@ -0,0 +1,9 @@ +import { boolean, integer, pgTable, serial, text } from "drizzle-orm/pg-core"; +import { users } from "./users.ts"; + +export const waitlist = pgTable("waitlist", { + id: serial("id").primaryKey(), + email: text("email").notNull(), + approved: boolean("approved").default(false), + referredBy: integer("referred_by").references(() => users.id), +}); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/worker.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/worker.ts new file mode 100644 index 0000000000..ab80f8901b --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/drizzle-schema-chunks/worker.ts @@ -0,0 +1,31 @@ +/** + * Plain Worker entry that imports a multi-module Drizzle schema graph with + * top-level `pgTable(...)` initializers — the shape that triggers #749 when + * Rolldown splits those schema modules into a chunk away from `drizzle-orm`. + * + * See `DrizzleSchemaChunks.test.ts` for the forced-split repro and the + * grouping workaround. + */ +import { invitations } from "./auth/invitations.ts"; +import { workspaces, workspaceInvites } from "./auth/workspace.ts"; +import { sessions } from "./schema/sessions.ts"; +import { users } from "./schema/users.ts"; +import { waitlist } from "./schema/waitlist.ts"; + +export default { + async fetch() { + const tables = [ + users, + sessions, + waitlist, + invitations, + workspaces, + workspaceInvites, + ]; + return Response.json({ + ok: true, + count: tables.length, + userCols: Object.keys(users), + }); + }, +};