Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions packages/alchemy/src/Cloudflare/Workers/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down
32 changes: 31 additions & 1 deletion packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,7 +55,7 @@ export interface WorkerBundleOptions {
exports: Record<string, DurableObjectExport | WorkflowExport>;
};
stack: { name: string; stage: string };
extraOptions: Bundle.BundleExtraOptions | undefined;
extraOptions: WorkerBuildOptions | undefined;
}

export const WorkerBundle = Effect.gen(function* () {
Expand All @@ -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')`
Expand Down Expand Up @@ -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 '<minified>' 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 };
});
Expand Down
118 changes: 118 additions & 0 deletions packages/alchemy/test/Cloudflare/Workers/DrizzleSchemaChunks.test.ts
Original file line number Diff line number Diff line change
@@ -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
* '<minified>' 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<string>() };
}),
);

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 },
);
Original file line number Diff line number Diff line change
@@ -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 '<minified>' 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.
Original file line number Diff line number Diff line change
@@ -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(),
});
Original file line number Diff line number Diff line change
@@ -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),
});
Original file line number Diff line number Diff line change
@@ -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(),
});
Original file line number Diff line number Diff line change
@@ -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(),
});
Original file line number Diff line number Diff line change
@@ -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),
});
Original file line number Diff line number Diff line change
@@ -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),
});
},
};
Loading