diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx
index c441d90..c28dad2 100644
--- a/app/(auth)/sign-in/page.tsx
+++ b/app/(auth)/sign-in/page.tsx
@@ -42,14 +42,26 @@ export default function SignInPage() {
- New to Mymir?{" "}
-
- Create an account
-
+ {process.env.DEPLOY_TARGET === "cloudflare" ? (
+
+ Sign-ups are invite-only
+
+ ) : (
+ <>
+ New to Mymir?{" "}
+
+ Create an account
+
+ >
+ )}
>
}
diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx
index 8730ece..5cc1557 100644
--- a/app/(auth)/sign-up/page.tsx
+++ b/app/(auth)/sign-up/page.tsx
@@ -5,15 +5,16 @@ import { AuthHero } from "@/components/auth/AuthHero";
import { SocialButtons } from "@/components/auth/SocialButtons";
import { SignUpForm } from "@/components/auth/SignUpForm";
+const SIGNUPS_DISABLED = process.env.DEPLOY_TARGET === "cloudflare";
+
/**
- * Sign-up page — mirrors the sign-in two-column shell with the
- * registration form. Same disabled-with-tooltip social buttons (no
- * backend providers wired) and the same static hero on the right.
- *
- * Post-create the user lands on `/`; `requireMembership` then forwards
- * to `/onboarding/team` because a fresh account has zero memberships.
+ * Sign-up page. Renders the registration form on self-host and an
+ * "invite only" notice on the hosted Cloudflare deploy. Post-create the
+ * user lands on `/`; `requireMembership` forwards to `/onboarding/team`
+ * because a fresh account has zero memberships.
*
- * @returns Server-rendered auth shell composing the sign-up form.
+ * @returns Server-rendered auth shell with form or invite-only notice
+ * depending on `DEPLOY_TARGET`.
*/
export default function SignUpPage() {
return (
@@ -25,21 +26,26 @@ export default function SignUpPage() {
className="text-[26px] font-semibold text-text-primary"
style={{ letterSpacing: "-0.01em", lineHeight: 1.15 }}
>
- Create an account.
+ {SIGNUPS_DISABLED ? "Invite only." : "Create an account."}
- Your project graph and decision history live here. Connect agents
- through MCP from your CLI once you’re in.
+ {SIGNUPS_DISABLED
+ ? "Mymir is in a closed beta. New accounts are opening soon — until then, sign-ups are invite-only."
+ : "Your project graph and decision history live here. Connect agents through MCP from your CLI once you’re in."}
- Already have an account?{" "}
+ {SIGNUPS_DISABLED ? "Already invited?" : "Already have an account?"}{" "}
;
- NEXT_CACHE_DO_QUEUE: DurableObjectNamespace;
- WORKER_SELF_REFERENCE: Service;
+ MYMIR_BROKER: DurableObjectNamespace;
+ NEXT_CACHE_DO_QUEUE: DurableObjectNamespace;
+ WORKER_SELF_REFERENCE: Service;
}
declare namespace Cloudflare {
interface GlobalProps {
- mainModule: typeof import("./.open-next/worker");
+ mainModule: typeof import("./worker-cf");
durableNamespaces: "MymirBroker" | "DOQueueHandler";
}
interface Env extends __BaseEnv_CloudflareEnv {}
diff --git a/components/providers/RealtimeBridge.tsx b/components/providers/RealtimeBridge.tsx
index b420aeb..46d93ab 100644
--- a/components/providers/RealtimeBridge.tsx
+++ b/components/providers/RealtimeBridge.tsx
@@ -80,7 +80,11 @@ export function RealtimeBridge() {
es = new EventSource("/api/events");
es.onmessage = (msg) => handle(msg.data);
es.onopen = () => {
+ const isReconnect = backoff !== INITIAL_BACKOFF_MS;
backoff = INITIAL_BACKOFF_MS;
+ if (isReconnect) {
+ qc.invalidateQueries();
+ }
};
es.onerror = () => {
es?.close();
diff --git a/lib/auth.ts b/lib/auth.ts
index 91c3740..936b2e2 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -10,6 +10,16 @@ import { ac, owner, admin, member as memberRole } from "@/lib/auth/permissions";
import { findOrgMemberUserIdsAsAdmin } from "@/lib/data/membership";
import { grantOrgAccess, revokeOrgAccess } from "@/lib/realtime/access";
+const IS_CLOUDFLARE = process.env.DEPLOY_TARGET === "cloudflare";
+
+if (IS_CLOUDFLARE && !process.env.BETTER_AUTH_URL) {
+ throw new Error(
+ "BETTER_AUTH_URL is required on the Cloudflare deploy target. " +
+ "Without it, Better-auth's trustedOrigins falls back to [] and CSRF " +
+ "protection accepts any origin. Set it in wrangler.jsonc env.production.vars.",
+ );
+}
+
/**
* Better Auth server instance with email/password auth and
* organization-based team management. Adapts the `neon_auth` schema via
@@ -24,6 +34,8 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
revokeSessionsOnPasswordReset: true,
+ // Invite-only on hosted Cloudflare; self-host stays open.
+ disableSignUp: IS_CLOUDFLARE,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
@@ -43,7 +55,7 @@ export const auth = betterAuth({
? [process.env.BETTER_AUTH_URL]
: [],
advanced: {
- useSecureCookies: process.env.NODE_ENV === "production",
+ useSecureCookies: process.env.NODE_ENV === "production" || IS_CLOUDFLARE,
defaultCookieAttributes: {
httpOnly: true,
sameSite: "lax",
diff --git a/lib/db/_driver.workers.ts b/lib/db/_driver.workers.ts
index 33353ff..36f6e16 100644
--- a/lib/db/_driver.workers.ts
+++ b/lib/db/_driver.workers.ts
@@ -9,19 +9,37 @@ import type { AppDb, AuthDb, DbBundle } from "./_driver.node";
export type { AppDb, AuthDb, DbBundle, ClosablePool } from "./_driver.node";
/**
- * Per-request Neon Pool tuning. `max: 1` because each pool's lifetime is a
- * single request (created in `withRequestDbCore`, ended via
- * `ctx.waitUntil(pool.end())`); a larger cap would open extra WebSocket
- * connections to Neon that never resolve before teardown. `idleTimeoutMillis`
- * is omitted on purpose for the same reason — idle reaping cannot fire
- * inside a single request lifetime and the default is sufficient.
+ * Per-isolate Neon Pool tuning. The Pool is shared across requests within
+ * one isolate; each connection is single-use (`maxUses: 1`) so the
+ * "WebSocket cannot outlive a single request" constraint is honored at the
+ * connection level — a connection serves one query then is destroyed.
+ *
+ * `max` caps concurrent open connections per isolate. The pattern is the
+ * one recommended by OpenNext (https://opennext.js.org/cloudflare/howtos/db).
+ */
+const NEON_OPTS = { max: 5, maxUses: 1 } as const;
+
+/**
+ * Attach a Pool-level error listener so unhandled idle-client errors from
+ * `pg` do not surface as `EventEmitter` uncaught events (which terminate
+ * the isolate and drop every in-flight request). Logged only; the Pool
+ * itself recovers by creating a fresh connection on the next `connect()`.
+ *
+ * @param pool - Neon pool to instrument.
+ * @param role - Role tag included in the log prefix.
+ * @returns The same pool, with the listener attached.
*/
-const NEON_OPTS = { max: 1 } as const;
+function attachPoolErrorLogger
(pool: P, role: string): P {
+ pool.on("error", (err: unknown) => {
+ console.error(`[db:${role}] neon pool background error`, err);
+ });
+ return pool;
+}
/**
* Build the application Drizzle client backed by `@neondatabase/serverless`.
- * Creates a fresh `NeonPool` per call; callers MUST close it via
- * `ctx.waitUntil(pool.end())` once the request completes.
+ * The underlying `NeonPool` is reused across requests within an isolate;
+ * each query opens a fresh single-use connection (`maxUses: 1`).
*
* @returns Pool + Drizzle instance bound to the public schema.
* @throws Error when `DATABASE_URL` is unset.
@@ -33,7 +51,10 @@ export function buildAppPool(): DbBundle {
"DATABASE_URL is required for the app runtime connection (app_user role).",
);
}
- const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
+ const pool = attachPoolErrorLogger(
+ new NeonPool({ connectionString: url, ...NEON_OPTS }),
+ "app",
+ );
return {
pool,
db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb,
@@ -54,7 +75,10 @@ export function buildAuthPool(): DbBundle {
"(DML on neon_auth.*, no public-schema access).",
);
}
- const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
+ const pool = attachPoolErrorLogger(
+ new NeonPool({ connectionString: url, ...NEON_OPTS }),
+ "auth",
+ );
return {
pool,
db: drizzleNeon(pool, { schema: authSchema }) as unknown as AuthDb,
@@ -74,7 +98,10 @@ export function buildServicePool(): DbBundle {
"DATABASE_SERVICE_ROLE_URL is required for service-role data access",
);
}
- const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
+ const pool = attachPoolErrorLogger(
+ new NeonPool({ connectionString: url, ...NEON_OPTS }),
+ "service",
+ );
return {
pool,
db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb,
diff --git a/lib/db/connection.ts b/lib/db/connection.ts
index ca239fc..a2a43ac 100644
--- a/lib/db/connection.ts
+++ b/lib/db/connection.ts
@@ -6,7 +6,6 @@ import {
buildAuthPool,
buildServicePool,
} from "@/lib/db/_driver";
-import { autoSeedRequestDb } from "@/lib/db/request-scope";
import type { AppDb, AuthDb } from "@/lib/db/_driver.node";
export type { AppDb, AuthDb } from "@/lib/db/_driver.node";
@@ -56,35 +55,47 @@ export interface RequestScopedDb {
/**
* AsyncLocalStorage frame populated by the Workers request-scope helper.
- * On self-host the frame is never entered; the global-cached singletons
- * built lazily on first proxy access are used instead.
+ *
+ * Pinned to a `globalThis` slot keyed by `Symbol.for(...)` so every bundle
+ * that imports this module observes the same instance — on Workers the
+ * final artifact contains two copies of this file (one bundled by Next's
+ * webpack into `.open-next/worker.js`, another by wrangler's esbuild from
+ * `worker-cf.ts`), and without the global pin each would instantiate its
+ * own `AsyncLocalStorage`. OpenNext's `init.js` uses the same pattern for
+ * `__cloudflare-context__`.
*/
-export const requestDbStore = new AsyncLocalStorage();
+const REQUEST_DB_STORE_KEY = Symbol.for("@mymir/db/requestDbStore");
+const symbolKeyedGlobal = globalThis as Record;
+if (!symbolKeyedGlobal[REQUEST_DB_STORE_KEY]) {
+ symbolKeyedGlobal[REQUEST_DB_STORE_KEY] =
+ new AsyncLocalStorage();
+}
+export const requestDbStore = symbolKeyedGlobal[
+ REQUEST_DB_STORE_KEY
+] as AsyncLocalStorage;
type GlobalKey = "__mymirAppDb" | "__mymirAuthDb" | "__mymirServiceRoleDb";
/**
- * Resolve the active Drizzle client for a role: prefer the AsyncLocalStorage
- * frame, fall back per-target (Workers lazy-seeds, self-host caches on
- * `globalThis`).
+ * Resolve the active Drizzle client for a role.
*
- * On Cloudflare Workers, when the proxy is first read in a request without
- * an active `withRequestDb` frame, the {@link autoSeedRequestDb} hook builds
- * the three role pools, registers `ctx.waitUntil(pool.end())` for teardown,
- * and seeds the ALS store for the remainder of the request's async tree. The
- * `globalThis` cache is never consulted on Workers because the Neon
- * serverless `Pool`'s WebSocket cannot span requests.
+ * Both deploy targets use a `globalThis`-cached singleton built lazily on
+ * first read. On self-host the Node `Pool` warms up once and serves every
+ * request via standard pg pooling. On Cloudflare Workers the Neon Pool is
+ * configured with `maxUses: 1` (see `lib/db/_driver.workers.ts`), so each
+ * connection is single-use even though the Pool instance persists across
+ * requests within an isolate — the "WebSocket cannot outlive a request"
+ * constraint is honored at the connection level, not the Pool level.
*
- * On self-host the seeder is a stub (throws); the `globalThis` cache holds
- * a single warm pool per role for the lifetime of the Node process.
+ * The {@link requestDbStore} ALS frame is consulted first for explicit
+ * scoping (e.g. tests that want to inject sentinel clients via
+ * `requestDbStore.run`). Production fetch paths simply hit the cache.
*
- * @param key - Which role to read from the request-scope bundle.
- * @param globalKey - Matching `globalThis.__mymir*` slot for self-host.
+ * @param key - Which role to read from the request-scope bundle (used only
+ * when an ALS frame is explicitly active).
+ * @param globalKey - Matching `globalThis.__mymir*` slot.
* @param builder - Factory invoked at most once to populate the slot.
* @returns Drizzle instance for the role.
- * @throws Error on Workers when the seeder cannot register teardown (no
- * active fetch context, e.g. scheduled handler) — see
- * `autoSeedRequestDb` for the remediation.
*/
function getScopedOrGlobal(
key: keyof RequestScopedDb,
@@ -93,10 +104,6 @@ function getScopedOrGlobal(
): TDb {
const scoped = requestDbStore.getStore();
if (scoped) return scoped[key] as TDb;
- if (process.env.DEPLOY_TARGET === "cloudflare") {
- const seeded = autoSeedRequestDb();
- return seeded[key] as TDb;
- }
const cached = globalThis[globalKey];
if (cached) return cached as TDb;
const built = builder().db;
diff --git a/lib/db/request-scope.node.ts b/lib/db/request-scope.node.ts
index d2f87d6..8d6bb2e 100644
--- a/lib/db/request-scope.node.ts
+++ b/lib/db/request-scope.node.ts
@@ -1,5 +1,4 @@
import "server-only";
-import type { RequestScopedDb } from "./connection";
/**
* Self-host no-op for the per-request DB seeding helper.
@@ -16,19 +15,3 @@ import type { RequestScopedDb } from "./connection";
export async function withRequestDb(fn: () => Promise): Promise {
return fn();
}
-
-/**
- * Self-host stub for {@link autoSeedRequestDb}. The connection proxy uses
- * the globalThis fallback on self-host and never reaches the lazy seeder,
- * so this body must never execute. Throwing rather than no-op keeps a
- * misrouted call surfacing instead of silently corrupting state.
- *
- * @throws Always.
- */
-export function autoSeedRequestDb(): RequestScopedDb {
- throw new Error(
- "autoSeedRequestDb is Workers-only; the self-host build should never " +
- "call it (the connection proxy uses the globalThis cache). If you " +
- "see this on self-host, the webpack alias indirection is broken.",
- );
-}
diff --git a/lib/db/request-scope.workers.ts b/lib/db/request-scope.workers.ts
index 9782ae4..b2dcee9 100644
--- a/lib/db/request-scope.workers.ts
+++ b/lib/db/request-scope.workers.ts
@@ -1,208 +1,17 @@
import "server-only";
-import { getCloudflareContext } from "@opennextjs/cloudflare";
-import type { AppDb, AuthDb, ClosablePool, DbBundle } from "./_driver.node";
-import {
- buildAppPool,
- buildAuthPool,
- buildServicePool,
-} from "./_driver.workers";
-import {
- type AppUserConn,
- type RequestScopedDb,
- type ServiceRoleConn,
- requestDbStore,
-} from "./connection";
-
-/**
- * Minimal `ctx.waitUntil` shape used by {@link withRequestDbCore}. Defined
- * locally so the file does not depend on `@cloudflare/workers-types`
- * (forbidden by `eslint.config.mjs`: pulling its ambient declarations
- * clobbers DOM `Request`/`Response`).
- */
-interface RequestCtx {
- waitUntil: (promise: Promise) => void;
-}
-
-/**
- * Bundle of per-role pool factories injected into {@link withRequestDbCore}.
- * The production wrapper passes the real `_driver.workers` builders; tests
- * substitute fakes that return sentinel `db` handles and instrumented
- * `pool.end()` implementations.
- */
-export interface PoolBuilders {
- buildAppPool: () => DbBundle;
- buildAuthPool: () => DbBundle;
- buildServicePool: () => DbBundle;
-}
-
-/**
- * Build the three role pools, closing any already-created pool if a later
- * builder throws. Returns a {@link RequestScopedDb} ready for ALS seeding
- * plus the underlying pools so the caller can schedule `pool.end()` after
- * the body completes.
- *
- * @param builders - Per-role pool factories.
- * @returns Pools plus the matching `db` clients keyed by role.
- * @throws Whatever a builder throws, with previously-built pools already
- * scheduled for teardown via {@link closePoolsAfterError}.
- */
-function buildRolePools(builders: PoolBuilders): {
- pools: ClosablePool[];
- scoped: RequestScopedDb;
-} {
- const opened: ClosablePool[] = [];
- try {
- const app = builders.buildAppPool();
- opened.push(app.pool);
- const auth = builders.buildAuthPool();
- opened.push(auth.pool);
- const service = builders.buildServicePool();
- opened.push(service.pool);
- return {
- pools: opened,
- scoped: {
- appDb: app.db as AppUserConn,
- authDb: auth.db,
- serviceRoleDb: service.db as ServiceRoleConn,
- },
- };
- } catch (err) {
- closePoolsAfterError(opened);
- throw err;
- }
-}
-
/**
- * Close pools that were opened before a later builder threw. Fire-and-forget
- * by design: if `end()` rejects the only sane outlet is the log, since the
- * surrounding control flow is already unwinding the original failure.
+ * Workers-side counterpart to {@link ./request-scope.node:withRequestDb}.
*
- * @param pools - Pools to close.
- */
-function closePoolsAfterError(pools: ClosablePool[]): void {
- for (const pool of pools) {
- void pool.end().catch((err) => {
- console.error("[db] pool cleanup after build failure failed", err);
- });
- }
-}
-
-/**
- * Schedule `pool.end()` for every role pool through a single `ctx.waitUntil`
- * registration so one promise tracks the entire teardown. Halves the
- * `waitUntil` bookkeeping vs registering three independent promises and
- * keeps the response-blocking budget tight.
- *
- * @param ctx - Cloudflare execution context.
- * @param pools - Pools opened for this request.
- */
-function schedulePoolTeardown(ctx: RequestCtx, pools: ClosablePool[]): void {
- ctx.waitUntil(
- Promise.allSettled(pools.map((pool) => pool.end())).then((results) => {
- for (const result of results) {
- if (result.status === "rejected") {
- console.error("[db] pool end failed", result.reason);
- }
- }
- }),
- );
-}
-
-/**
- * Testable core of {@link withRequestDb}. Takes the Cloudflare execution
- * context and the three pool factories as inputs so unit tests can
- * exercise the lifecycle without booting OpenNext or the Neon driver.
- *
- * Builds fresh Drizzle clients for the three roles via
- * {@link buildRolePools} (which guarantees no leaked pools if a later
- * builder throws), runs `fn` inside an AsyncLocalStorage frame so the
- * proxy exports in `./connection.ts` resolve to those clients, and
- * schedules `pool.end()` for every pool via a single `ctx.waitUntil` so
- * socket teardown does not block the response and only one promise is
- * tracked.
- *
- * @param ctx - Cloudflare execution context exposing `waitUntil`.
- * @param builders - Per-role pool factories.
- * @param fn - The request-handler body.
- * @returns Whatever `fn` returns.
- */
-export async function withRequestDbCore(
- ctx: RequestCtx,
- builders: PoolBuilders,
- fn: () => Promise,
-): Promise {
- const { pools, scoped } = buildRolePools(builders);
- try {
- return await requestDbStore.run(scoped, fn);
- } finally {
- schedulePoolTeardown(ctx, pools);
- }
-}
-
-/**
- * Wrap a request-scoped operation with per-request Pool lifecycle.
- *
- * Cloudflare Workers cannot persist WebSocket connections beyond a single
- * request, so the Neon `Pool` for each role must be created inside the
- * handler and closed before the response is fully delivered. Thin wrapper
- * over {@link withRequestDbCore} that wires in the live Cloudflare context
- * and the real Neon pool builders from `./_driver.workers`.
+ * The Workers DB pool is a per-isolate singleton with single-use
+ * connections (`maxUses: 1`), so request scoping happens at the
+ * connection level inside `lib/db/_driver.workers.ts`; this wrapper is a
+ * pass-through identical to the Node sibling. Kept exported so callers
+ * compile against both targets without target-specific imports.
*
* @param fn - The request-handler body.
* @returns Whatever `fn` returns.
*/
export async function withRequestDb(fn: () => Promise): Promise {
- const { ctx } = getCloudflareContext();
- return withRequestDbCore(
- ctx,
- { buildAppPool, buildAuthPool, buildServicePool },
- fn,
- );
-}
-
-/**
- * Lazy auto-seed entry point for the {@link requestDbStore} proxy fallback.
- *
- * Called from `./connection.ts:getScopedOrGlobal` on Cloudflare Workers
- * when a route reads `appDb` / `authDb` / `serviceRoleDb` outside an
- * explicit {@link withRequestDb} frame. Builds the three role pools,
- * registers a single `ctx.waitUntil(pool.end())` for teardown, then uses
- * `AsyncLocalStorage.enterWith` to seed the frame for the rest of the
- * request's async tree.
- *
- * Each Workers fetch invocation runs in its own root async context, so
- * `enterWith` cannot leak the seeded store across requests. Callers that
- * still want explicit scoping (e.g. background tasks that share a fetch
- * handler) can wrap with {@link withRequestDb} directly.
- *
- * @returns The seeded scope (also entered into the ALS frame).
- * @throws Error when `getCloudflareContext()` is unavailable — running on
- * Workers without an active fetch context (scheduled handler, DO alarm,
- * etc.) means we cannot register `pool.end()` for teardown, and
- * silently seeding without teardown would leak sockets across
- * invocations. The caller is expected to wrap with {@link withRequestDb}
- * explicitly in those contexts.
- */
-export function autoSeedRequestDb(): RequestScopedDb {
- let ctx: RequestCtx;
- try {
- ctx = getCloudflareContext({ async: false }).ctx;
- } catch (err) {
- throw new Error(
- "autoSeedRequestDb: Cloudflare execution context is unavailable " +
- `(${err instanceof Error ? err.message : String(err)}). ` +
- "This usually means a non-fetch Workers handler (scheduled, alarm, " +
- "queue consumer) accessed appDb/authDb/serviceRoleDb without " +
- "wrapping the body in withRequestDb(() => ...).",
- );
- }
- const { pools, scoped } = buildRolePools({
- buildAppPool,
- buildAuthPool,
- buildServicePool,
- });
- schedulePoolTeardown(ctx, pools);
- requestDbStore.enterWith(scoped);
- return scoped;
+ return fn();
}
diff --git a/package.json b/package.json
index c81b2a2..ecfcd38 100644
--- a/package.json
+++ b/package.json
@@ -9,10 +9,10 @@
"build": "next build --webpack",
"postbuild": "bun run scripts/postbuild.ts",
"start": "node --env-file-if-exists=.env.local scripts/start.mjs",
- "build:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && bun run scripts/postbuild-cf.ts",
- "preview:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && bun run scripts/postbuild-cf.ts && DEPLOY_TARGET=cloudflare opennextjs-cloudflare preview",
- "deploy:cf": "bun run scripts/assert-deploy-ready.ts && DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && bun run scripts/postbuild-cf.ts && DEPLOY_TARGET=cloudflare opennextjs-cloudflare deploy --env production",
- "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
+ "build:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build",
+ "preview:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && DEPLOY_TARGET=cloudflare opennextjs-cloudflare preview --env production",
+ "deploy:cf": "bun run scripts/assert-deploy-ready.ts && DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && DEPLOY_TARGET=cloudflare opennextjs-cloudflare deploy --env production",
+ "cf-typegen": "wrangler types --env production --env-interface CloudflareEnv cloudflare-env.d.ts",
"pretest": "[ -n \"$TEST_DATABASE_URL\" ] || bun run db:test:up",
"test": "bun test",
"test:db": "bun test tests/db tests/data tests/auth",
diff --git a/plugins/claude-code/.mcp.json b/plugins/claude-code/.mcp.json
index a53d10f..c9ad9bd 100644
--- a/plugins/claude-code/.mcp.json
+++ b/plugins/claude-code/.mcp.json
@@ -2,7 +2,11 @@
"mcpServers": {
"mymir": {
"type": "http",
- "url": "${MYMIR_URL:-http://localhost:3000}/api/mcp"
+ "url": "${MYMIR_URL:-https://app.mymir.dev}/api/mcp"
+ },
+ "mymir-local": {
+ "type": "http",
+ "url": "http://localhost:3000/api/mcp"
}
}
}
diff --git a/plugins/codex/.mcp.json b/plugins/codex/.mcp.json
index 81caae1..a0bc25f 100644
--- a/plugins/codex/.mcp.json
+++ b/plugins/codex/.mcp.json
@@ -1,6 +1,10 @@
{
"mcpServers": {
"mymir": {
+ "type": "http",
+ "url": "https://app.mymir.dev/api/mcp"
+ },
+ "mymir-local": {
"type": "http",
"url": "http://localhost:3000/api/mcp"
}
diff --git a/plugins/cursor/mcp.json b/plugins/cursor/mcp.json
index 6dd1c6d..8f19995 100644
--- a/plugins/cursor/mcp.json
+++ b/plugins/cursor/mcp.json
@@ -1,6 +1,9 @@
{
"mcpServers": {
"mymir": {
+ "url": "https://app.mymir.dev/api/mcp"
+ },
+ "mymir-local": {
"url": "http://localhost:3000/api/mcp"
}
}
diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json
index bfe55a2..f261ba8 100644
--- a/plugins/gemini/gemini-extension.json
+++ b/plugins/gemini/gemini-extension.json
@@ -4,6 +4,9 @@
"description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions.",
"mcpServers": {
"mymir": {
+ "httpUrl": "https://app.mymir.dev/api/mcp"
+ },
+ "mymir-local": {
"httpUrl": "http://localhost:3000/api/mcp"
}
}
diff --git a/scripts/assert-deploy-ready.ts b/scripts/assert-deploy-ready.ts
index 24d8bc0..dc620ef 100644
--- a/scripts/assert-deploy-ready.ts
+++ b/scripts/assert-deploy-ready.ts
@@ -1,16 +1,23 @@
/**
- * Pre-deploy guard: reject `bun run deploy:cf` while `wrangler.jsonc` still
- * carries placeholder binding IDs. MYMR-165 provisions the real Cloudflare
- * resources; until that lands, every `deploy:cf` should fail loudly here
- * rather than ship a Worker bound to non-existent KV / D1 / R2 resources.
+ * Pre-deploy guard: reject `bun run deploy:cf` while the production
+ * configuration in `wrangler.jsonc` is not ready. `deploy:cf` targets
+ * `--env production`; all production bindings live under `env.production`
+ * so the guard inspects that section (top-level config is reserved for
+ * `wrangler dev` and intentionally has no real binding IDs, which makes
+ * an accidental `wrangler deploy` without `--env` fail fast instead of
+ * misbinding production resources).
*
* Checked invariants:
- * - No KV namespace `id` is all zeros.
- * - No D1 `database_id` is the zero UUID.
- * - No R2 binding still references the `mymir-placeholder-*` bucket name.
- * - `BROKER_DO_SECRET` is registered as a Wrangler secret in the target
- * environment (set via `wrangler secret put`). Cannot be checked from
- * `wrangler.jsonc` alone, so the script shells out to `wrangler secret list`.
+ * - `env.production` exists.
+ * - No KV namespace `id` in `env.production` is all zeros.
+ * - No D1 `database_id` in `env.production` is the zero UUID.
+ * - No R2 binding in `env.production` references a `mymir-placeholder-*` bucket.
+ * - Every required production secret is registered in the production
+ * Wrangler env: BROKER_DO_SECRET (broker DO HMAC key), BETTER_AUTH_SECRET
+ * (Better-auth signing key), DATABASE_URL / DATABASE_SERVICE_ROLE_URL /
+ * DATABASE_AUTH_URL (Neon connection strings for the three DB roles).
+ * Cannot be checked from `wrangler.jsonc` alone, so the script shells out
+ * to `wrangler secret list --env production`.
*
* Run from the `deploy:cf` script chain. Exits with code 1 on any failure
* and prints a remediation hint.
@@ -38,11 +45,14 @@ interface R2Binding {
binding: string;
bucket_name: string;
}
-interface WranglerConfig {
+interface WranglerEnvBindings {
kv_namespaces?: KvBinding[];
d1_databases?: D1Binding[];
r2_buckets?: R2Binding[];
}
+interface WranglerConfig extends WranglerEnvBindings {
+ env?: { production?: WranglerEnvBindings };
+}
/**
* Strip `// line` and `/* block *\/` comments so the JSONC config parses
@@ -69,61 +79,122 @@ async function readWranglerConfig(): Promise {
return JSON.parse(stripJsonc(raw)) as WranglerConfig;
}
-const failures: string[] = [];
+/**
+ * Print the accumulated failures and abort with exit code 1.
+ *
+ * @param failures - Non-empty list of failure messages.
+ */
+function abortWithFailures(failures: string[]): never {
+ console.error("\nDeploy aborted — wrangler.jsonc is not production-ready:\n");
+ for (const f of failures) console.error(` - ${f}`);
+ console.error("");
+ process.exit(1);
+}
const cfg = await readWranglerConfig();
+const prod = cfg.env?.production;
+if (!prod) {
+ abortWithFailures([
+ "No 'env.production' section found in wrangler.jsonc. " +
+ "'deploy:cf' targets '--env production' and the production bindings must live under that section.",
+ ]);
+}
+
+const failures: string[] = [];
-for (const kv of cfg.kv_namespaces ?? []) {
+for (const kv of prod.kv_namespaces ?? []) {
if (kv.id === ZERO_KV_ID) {
failures.push(
- `KV namespace "${kv.binding}" still has placeholder id ${ZERO_KV_ID}. ` +
+ `KV namespace "${kv.binding}" in env.production still has placeholder id ${ZERO_KV_ID}. ` +
`Provision via 'wrangler kv namespace create' then patch wrangler.jsonc.`,
);
}
}
-for (const d1 of cfg.d1_databases ?? []) {
+for (const d1 of prod.d1_databases ?? []) {
if (d1.database_id === ZERO_D1_ID) {
failures.push(
- `D1 database "${d1.binding}" still has placeholder database_id ${ZERO_D1_ID}. ` +
+ `D1 database "${d1.binding}" in env.production still has placeholder database_id ${ZERO_D1_ID}. ` +
`Provision via 'wrangler d1 create ${d1.database_name ?? d1.binding}'.`,
);
}
}
-for (const r2 of cfg.r2_buckets ?? []) {
+for (const r2 of prod.r2_buckets ?? []) {
if (PLACEHOLDER_BUCKET_RE.test(r2.bucket_name)) {
failures.push(
- `R2 binding "${r2.binding}" still references placeholder bucket "${r2.bucket_name}". ` +
+ `R2 binding "${r2.binding}" in env.production still references placeholder bucket "${r2.bucket_name}". ` +
`Provision via 'wrangler r2 bucket create' then patch wrangler.jsonc.`,
);
}
}
-const proc = Bun.spawnSync({
- cmd: ["bunx", "wrangler", "secret", "list", "--env", "production"],
- stdout: "pipe",
- stderr: "pipe",
-});
-const secretListStdout = proc.stdout.toString();
-if (proc.exitCode !== 0) {
- failures.push(
- `Failed to enumerate Wrangler secrets in 'production' env. ` +
- `stderr: ${proc.stderr.toString().trim() || "(empty)"}`,
- );
-} else if (!secretListStdout.includes("BROKER_DO_SECRET")) {
- failures.push(
- `BROKER_DO_SECRET is not registered in the 'production' Wrangler env. ` +
- `Set it via 'wrangler secret put BROKER_DO_SECRET --env production'. ` +
- `Generate a value with 'openssl rand -base64 48'.`,
- );
+const REQUIRED_SECRETS = [
+ "BROKER_DO_SECRET",
+ "BETTER_AUTH_SECRET",
+ "DATABASE_URL",
+ "DATABASE_SERVICE_ROLE_URL",
+ "DATABASE_AUTH_URL",
+] as const;
+
+interface WranglerSecretEntry {
+ name: string;
+ type: string;
+}
+
+/**
+ * Resolve the production secret names registered with Wrangler.
+ *
+ * Parses `wrangler secret list --env production`'s JSON output and returns
+ * the exact secret names — substring matching against raw stdout (the
+ * previous approach) would treat a future `DATABASE_URL_BACKUP` as
+ * satisfying the `DATABASE_URL` check.
+ *
+ * @returns Set of secret names registered in the production env, or null
+ * when the wrangler invocation failed (with reason appended to the
+ * failure list out-of-band).
+ */
+function listProductionSecretNames(failures: string[]): Set | null {
+ const cmdResult = Bun.spawnSync({
+ cmd: ["bunx", "wrangler", "secret", "list", "--env", "production"],
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ if (cmdResult.exitCode !== 0) {
+ failures.push(
+ `Failed to enumerate Wrangler secrets in 'production' env. ` +
+ `stderr: ${cmdResult.stderr.toString().trim() || "(empty)"}`,
+ );
+ return null;
+ }
+ const stdout = cmdResult.stdout.toString().trim();
+ try {
+ const parsed = JSON.parse(stdout) as WranglerSecretEntry[];
+ return new Set(parsed.map((s) => s.name));
+ } catch (err) {
+ failures.push(
+ `Could not parse 'wrangler secret list' output as JSON. ` +
+ `Error: ${err instanceof Error ? err.message : String(err)}. ` +
+ `Raw stdout (first 200 chars): ${stdout.slice(0, 200)}`,
+ );
+ return null;
+ }
+}
+
+const presentSecrets = listProductionSecretNames(failures);
+if (presentSecrets) {
+ for (const name of REQUIRED_SECRETS) {
+ if (!presentSecrets.has(name)) {
+ failures.push(
+ `${name} is not registered in the 'production' Wrangler env. ` +
+ `Set it via 'wrangler secret put ${name} --env production'.`,
+ );
+ }
+ }
}
if (failures.length > 0) {
- console.error("\nDeploy aborted — wrangler.jsonc is not production-ready:\n");
- for (const f of failures) console.error(` - ${f}`);
- console.error("");
- process.exit(1);
+ abortWithFailures(failures);
}
console.log("Deploy guard: wrangler.jsonc bindings + secrets look healthy.");
diff --git a/scripts/postbuild-cf.ts b/scripts/postbuild-cf.ts
deleted file mode 100644
index e08b6ce..0000000
--- a/scripts/postbuild-cf.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * Cloudflare post-build glue: add Mymir-specific Durable Object exports
- * to the OpenNext worker entrypoint.
- *
- * OpenNext's CLI generates `.open-next/worker.js` from a fixed template
- * that exports only its own built-in Durable Objects (DOQueueHandler,
- * DOShardedTagCache, BucketCachePurge). The CLI does not expose an
- * extension hook for user-defined DOs that are referenced from
- * `wrangler.jsonc`. This script bundles `lib/realtime/broker-do.ts` and
- * appends the export to `worker.js` so the `MYMIR_BROKER` binding
- * resolves at startup.
- */
-import path from "node:path";
-import fs from "node:fs/promises";
-
-const ROOT = path.resolve(import.meta.dir, "..");
-const OUT = path.join(ROOT, ".open-next");
-const WORKER = path.join(OUT, "worker.js");
-const DO_DIR = path.join(OUT, ".build", "durable-objects");
-const DO_OUT = path.join(DO_DIR, "mymir-broker.js");
-const DO_SRC = path.join(ROOT, "lib/realtime/broker-do.ts");
-
-await fs.mkdir(DO_DIR, { recursive: true });
-
-const result = await Bun.build({
- entrypoints: [DO_SRC],
- outdir: DO_DIR,
- naming: "mymir-broker.js",
- format: "esm",
- target: "browser",
- external: ["cloudflare:workers"],
- minify: false,
-});
-
-if (!result.success) {
- for (const log of result.logs) console.error(log);
- throw new Error("Failed to bundle MymirBroker Durable Object");
-}
-
-const workerSource = await fs.readFile(WORKER, "utf8");
-const EXPORT_LINE = `export { MymirBroker } from "./.build/durable-objects/mymir-broker.js";\n`;
-if (workerSource.includes('from "./.build/durable-objects/mymir-broker.js"')) {
- console.log("worker.js already exports MymirBroker — skipping patch");
-} else {
- await fs.writeFile(WORKER, `${workerSource.trimEnd()}\n${EXPORT_LINE}`);
- console.log("Patched worker.js to export MymirBroker");
-}
-
-console.log(`Mymir Durable Object bundled at ${path.relative(ROOT, DO_OUT)}`);
diff --git a/scripts/server-only-shim.js b/scripts/server-only-shim.js
new file mode 100644
index 0000000..69b1f51
--- /dev/null
+++ b/scripts/server-only-shim.js
@@ -0,0 +1,15 @@
+/**
+ * Shim for the `server-only` package on the Cloudflare Workers bundle.
+ *
+ * `server-only/index.js` throws at import time so that bundlers building
+ * client-side code error out. Next.js's webpack picks the package's
+ * `react-server` export condition (`empty.js` — a no-op), but wrangler's
+ * esbuild does not honor that condition, so it resolves to the throwing
+ * default and crashes the worker at startup with code 10021.
+ *
+ * Workers always run server-side, so the guard the package provides is
+ * meaningless here. This file replaces `server-only` with a no-op via the
+ * `alias` field in `wrangler.jsonc`.
+ *
+ * Aliased from: `"alias": { "server-only": "./scripts/server-only-shim.js" }`.
+ */
diff --git a/tests/db/connection.cloudflare.test.ts b/tests/db/connection.cloudflare.test.ts
index 01901f7..c730a89 100644
--- a/tests/db/connection.cloudflare.test.ts
+++ b/tests/db/connection.cloudflare.test.ts
@@ -1,26 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
/**
- * Cloudflare branch of `getScopedOrGlobal` in `lib/db/connection.ts`. The
- * proxy must delegate to `autoSeedRequestDb` (from the `request-scope`
- * alias indirection) when `DEPLOY_TARGET === "cloudflare"` and no ALS
- * frame is active. The seeder is mocked at module-import time so this
- * file does not need a live Cloudflare execution context.
+ * Cloudflare branch of `getScopedOrGlobal` in `lib/db/connection.ts`.
+ *
+ * Since the B2 refactor (MYMR-165 follow-up), the Workers path uses the
+ * same `globalThis`-cached singleton as self-host; the Pool is a
+ * per-isolate singleton with `maxUses: 1` connections. The legacy
+ * per-request ALS auto-seed has been removed.
+ *
+ * `requestDbStore.run(...)` still wins when an explicit ALS frame is
+ * active — used by tests that inject sentinels and by any future
+ * background callers that want explicit scoping.
*/
-const seedCalls: number[] = [];
-
mock.module("@/lib/db/request-scope", () => ({
withRequestDb: async (fn: () => Promise): Promise => fn(),
- autoSeedRequestDb: () => {
- seedCalls.push(seedCalls.length);
- const scope = {
- appDb: { marker: "auto-app" },
- authDb: { marker: "auto-auth" },
- serviceRoleDb: { marker: "auto-service" },
- };
- return scope as never;
- },
}));
describe("getScopedOrGlobal on Cloudflare", () => {
@@ -29,7 +23,10 @@ describe("getScopedOrGlobal on Cloudflare", () => {
beforeEach(() => {
originalTarget = process.env.DEPLOY_TARGET;
process.env.DEPLOY_TARGET = "cloudflare";
- seedCalls.length = 0;
+ delete (globalThis as { __mymirAppDb?: unknown }).__mymirAppDb;
+ delete (globalThis as { __mymirAuthDb?: unknown }).__mymirAuthDb;
+ delete (globalThis as { __mymirServiceRoleDb?: unknown })
+ .__mymirServiceRoleDb;
});
afterEach(() => {
@@ -40,22 +37,8 @@ describe("getScopedOrGlobal on Cloudflare", () => {
}
});
- it("auto-seeds via autoSeedRequestDb when no ALS frame is active", async () => {
- const { appDb, authDb, serviceRoleDb } = await import(
- "@/lib/db/connection"
- );
-
- expect((appDb as unknown as { marker: string }).marker).toBe("auto-app");
- expect((authDb as unknown as { marker: string }).marker).toBe("auto-auth");
- expect((serviceRoleDb as unknown as { marker: string }).marker).toBe(
- "auto-service",
- );
- expect(seedCalls.length).toBeGreaterThan(0);
- });
-
- it("does not invoke autoSeedRequestDb when an ALS frame is already active", async () => {
+ it("resolves to the seeded scope when an ALS frame is active", async () => {
const { appDb, requestDbStore } = await import("@/lib/db/connection");
- seedCalls.length = 0;
const sentinel = { marker: "explicit-app" };
requestDbStore.run(
@@ -70,7 +53,5 @@ describe("getScopedOrGlobal on Cloudflare", () => {
);
},
);
-
- expect(seedCalls.length).toBe(0);
});
});
diff --git a/tests/db/request-scope.test.ts b/tests/db/request-scope.test.ts
deleted file mode 100644
index cb6f188..0000000
--- a/tests/db/request-scope.test.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
-import type { AppDb, AuthDb, DbBundle } from "@/lib/db/_driver.node";
-import { requestDbStore } from "@/lib/db/connection";
-import {
- type PoolBuilders,
- withRequestDbCore,
-} from "@/lib/db/request-scope.workers";
-
-interface RecordedCtx {
- waitUntil: ReturnType;
- awaited: () => Promise;
-}
-
-/**
- * Build a stub `ExecutionContext` that records every `waitUntil` argument.
- * The `awaited()` accessor settles each captured promise so individual
- * tests can assert on lifecycle errors without keeping a dangling
- * rejection.
- *
- * @returns Recording context plus a helper that drains the captured promises.
- */
-function makeRecordingCtx(): RecordedCtx {
- const captured: Promise[] = [];
- const waitUntil = mock((p: Promise) => {
- captured.push(p);
- });
- return {
- waitUntil,
- awaited: () => Promise.all(captured.map((p) => p.catch((err) => err))),
- };
-}
-
-interface StubPool {
- end: ReturnType;
-}
-
-/**
- * Build a `PoolBuilders` triple whose `db` handles are sentinel objects
- * and whose `pool.end()` implementations are configurable per role.
- *
- * @param ends - Optional per-role overrides for `pool.end()`.
- * @returns Builder triple plus references to the underlying mocks.
- */
-function makeBuilders(ends?: {
- app?: () => Promise;
- auth?: () => Promise;
- service?: () => Promise;
-}): {
- builders: PoolBuilders;
- pools: { app: StubPool; auth: StubPool; service: StubPool };
- sentinels: { app: object; auth: object; service: object };
-} {
- const appPool: StubPool = {
- end: mock(ends?.app ?? (async () => undefined)),
- };
- const authPool: StubPool = {
- end: mock(ends?.auth ?? (async () => undefined)),
- };
- const servicePool: StubPool = {
- end: mock(ends?.service ?? (async () => undefined)),
- };
-
- const appSentinel = { role: "app" };
- const authSentinel = { role: "auth" };
- const serviceSentinel = { role: "service" };
-
- const builders: PoolBuilders = {
- buildAppPool: () =>
- ({
- pool: appPool,
- db: appSentinel as unknown as AppDb,
- }) as DbBundle,
- buildAuthPool: () =>
- ({
- pool: authPool,
- db: authSentinel as unknown as AuthDb,
- }) as DbBundle,
- buildServicePool: () =>
- ({
- pool: servicePool,
- db: serviceSentinel as unknown as AppDb,
- }) as DbBundle,
- };
-
- return {
- builders,
- pools: { app: appPool, auth: authPool, service: servicePool },
- sentinels: {
- app: appSentinel,
- auth: authSentinel,
- service: serviceSentinel,
- },
- };
-}
-
-describe("withRequestDbCore", () => {
- let unhandled: Array<{ reason: unknown }>;
- let originalHandler: NodeJS.UnhandledRejectionListener[];
-
- beforeEach(() => {
- unhandled = [];
- originalHandler = process.listeners("unhandledRejection");
- process.removeAllListeners("unhandledRejection");
- process.on("unhandledRejection", (reason) => {
- unhandled.push({ reason });
- });
- });
-
- afterEach(() => {
- process.removeAllListeners("unhandledRejection");
- for (const handler of originalHandler) {
- process.on("unhandledRejection", handler);
- }
- });
-
- it("seeds the ALS frame with the three role clients", async () => {
- const ctx = makeRecordingCtx();
- const { builders, sentinels } = makeBuilders();
-
- await withRequestDbCore(ctx, builders, async () => {
- const store = requestDbStore.getStore();
- expect(store).toBeDefined();
- expect(store?.appDb).toBe(sentinels.app as never);
- expect(store?.authDb).toBe(sentinels.auth as never);
- expect(store?.serviceRoleDb).toBe(sentinels.service as never);
- });
- });
-
- it("schedules pool.end() via a single ctx.waitUntil on success", async () => {
- const ctx = makeRecordingCtx();
- const { builders, pools } = makeBuilders();
-
- await withRequestDbCore(ctx, builders, async () => "ok");
-
- expect(ctx.waitUntil).toHaveBeenCalledTimes(1);
- await ctx.awaited();
- expect(pools.app.end).toHaveBeenCalledTimes(1);
- expect(pools.auth.end).toHaveBeenCalledTimes(1);
- expect(pools.service.end).toHaveBeenCalledTimes(1);
- });
-
- it("propagates fn() errors and still closes every pool", async () => {
- const ctx = makeRecordingCtx();
- const { builders, pools } = makeBuilders();
- const sentinelError = new Error("handler exploded");
-
- await expect(
- withRequestDbCore(ctx, builders, async () => {
- throw sentinelError;
- }),
- ).rejects.toBe(sentinelError);
-
- expect(ctx.waitUntil).toHaveBeenCalledTimes(1);
- await ctx.awaited();
- expect(pools.app.end).toHaveBeenCalledTimes(1);
- expect(pools.auth.end).toHaveBeenCalledTimes(1);
- expect(pools.service.end).toHaveBeenCalledTimes(1);
- });
-
- it("swallows pool.end() rejections into console.error without unhandled rejections", async () => {
- const ctx = makeRecordingCtx();
- const { builders } = makeBuilders({
- app: async () => {
- throw new Error("socket teardown failed");
- },
- });
-
- const errorSpy = mock((..._args: unknown[]) => undefined);
- const originalError = console.error;
- console.error = errorSpy as unknown as typeof console.error;
-
- try {
- await withRequestDbCore(ctx, builders, async () => "ok");
- await ctx.awaited();
- } finally {
- console.error = originalError;
- }
-
- await new Promise((r) => setTimeout(r, 0));
- expect(unhandled).toHaveLength(0);
- const calls = errorSpy.mock.calls;
- expect(
- calls.some(
- (args) =>
- typeof args[0] === "string" &&
- (args[0] as string).startsWith("[db] pool end failed"),
- ),
- ).toBe(true);
- });
-
- it("closes earlier pools when a later builder throws", async () => {
- const ctx = makeRecordingCtx();
- const appEnd = mock(async () => undefined);
- const authEnd = mock(async () => undefined);
- const sentinelError = new Error("service builder boom");
-
- const builders = {
- buildAppPool: () =>
- ({
- pool: { end: appEnd },
- db: {} as never,
- }) as never,
- buildAuthPool: () =>
- ({
- pool: { end: authEnd },
- db: {} as never,
- }) as never,
- buildServicePool: () => {
- throw sentinelError;
- },
- };
-
- await expect(
- withRequestDbCore(ctx, builders, async () => "ok"),
- ).rejects.toBe(sentinelError);
-
- // Eager fire-and-forget cleanup for app + auth; service was never built.
- await new Promise((r) => setTimeout(r, 0));
- expect(appEnd).toHaveBeenCalledTimes(1);
- expect(authEnd).toHaveBeenCalledTimes(1);
- // No `ctx.waitUntil` because the body never ran; cleanup is direct.
- expect(ctx.waitUntil).toHaveBeenCalledTimes(0);
- });
-});
diff --git a/tsconfig.json b/tsconfig.json
index 77002e1..9688722 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,5 +30,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": ["node_modules", "cloudflare-env.d.ts"]
+ "exclude": ["node_modules", "cloudflare-env.d.ts", ".open-next"]
}
diff --git a/worker-cf.ts b/worker-cf.ts
new file mode 100644
index 0000000..b30bea2
--- /dev/null
+++ b/worker-cf.ts
@@ -0,0 +1,18 @@
+/**
+ * Cloudflare Workers entry point for Mymir's OpenNext build.
+ *
+ * Re-exports the Durable Object classes wrangler instantiates at runtime
+ * (`MymirBroker`, `DOQueueHandler`) and delegates `fetch` to the
+ * OpenNext-generated handler.
+ */
+import { MymirBroker } from "./lib/realtime/broker-do";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- generated entry; tsc resolves it but @ts-expect-error trips "unused directive".
+// @ts-ignore generated by `opennextjs-cloudflare build`; resolved at bundle time.
+import { default as openNextHandler } from "./.open-next/worker.js";
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- generated entry, see above.
+// @ts-ignore generated by `opennextjs-cloudflare build`.
+export { DOQueueHandler } from "./.open-next/worker.js";
+export { MymirBroker };
+
+export default openNextHandler;
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 782c1b9..0420332 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -1,6 +1,6 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
- "main": ".open-next/worker.js",
+ "main": "./worker-cf.ts",
"name": "mymir",
"compatibility_date": "2026-05-16",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
@@ -8,36 +8,82 @@
"directory": ".open-next/assets",
"binding": "ASSETS"
},
- "services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "mymir" }],
- "kv_namespaces": [
- { "binding": "AUTH_KV", "id": "00000000000000000000000000000000" }
- ],
- "r2_buckets": [
- {
- "binding": "NEXT_INC_CACHE_R2_BUCKET",
- "bucket_name": "mymir-placeholder-cache"
- }
- ],
- "d1_databases": [
- {
- "binding": "NEXT_TAG_CACHE_D1",
- "database_id": "00000000-0000-0000-0000-000000000000",
- "database_name": "mymir-tag-cache"
- }
- ],
- "durable_objects": {
- "bindings": [
- { "name": "MYMIR_BROKER", "class_name": "MymirBroker" },
- { "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" }
- ]
- },
- "migrations": [
- {
- "tag": "v1",
- "new_sqlite_classes": ["MymirBroker", "DOQueueHandler"]
- }
- ],
"vars": {
"DEPLOY_TARGET": "cloudflare"
+ },
+ "alias": {
+ "server-only": "./scripts/server-only-shim.js"
+ },
+ "env": {
+ "production": {
+ "name": "mymir-production",
+ "assets": {
+ "directory": ".open-next/assets",
+ "binding": "ASSETS"
+ },
+ "services": [
+ { "binding": "WORKER_SELF_REFERENCE", "service": "mymir-production" }
+ ],
+ "kv_namespaces": [
+ { "binding": "AUTH_KV", "id": "839e8280f3bf47dd95c0a5c4c244033c" }
+ ],
+ "r2_buckets": [
+ {
+ "binding": "NEXT_INC_CACHE_R2_BUCKET",
+ "bucket_name": "mymir-isr-cache"
+ }
+ ],
+ "d1_databases": [
+ {
+ "binding": "NEXT_TAG_CACHE_D1",
+ "database_id": "a4b3426f-ba2e-4358-936d-594697c16908",
+ "database_name": "mymir-tag-cache"
+ }
+ ],
+ "durable_objects": {
+ "bindings": [
+ { "name": "MYMIR_BROKER", "class_name": "MymirBroker" },
+ { "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" }
+ ]
+ },
+ "migrations": [
+ {
+ "tag": "v1",
+ "new_sqlite_classes": ["MymirBroker", "DOQueueHandler"]
+ }
+ ],
+ "ratelimits": [
+ {
+ "name": "RATE_LIMIT_API",
+ "namespace_id": "1001",
+ "simple": { "limit": 100, "period": 60 }
+ },
+ {
+ "name": "RATE_LIMIT_AUTH",
+ "namespace_id": "1002",
+ "simple": { "limit": 5, "period": 60 }
+ }
+ ],
+ "routes": [{ "pattern": "app.mymir.dev", "custom_domain": true }],
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1,
+ "logs": {
+ "enabled": true,
+ "head_sampling_rate": 1,
+ "persist": true,
+ "invocation_logs": true
+ },
+ "traces": {
+ "enabled": true,
+ "persist": true,
+ "head_sampling_rate": 1
+ }
+ },
+ "vars": {
+ "DEPLOY_TARGET": "cloudflare",
+ "BETTER_AUTH_URL": "https://app.mymir.dev"
+ }
+ }
}
}