Skip to content

Commit fa6a3ee

Browse files
committed
Add an injectable blob backend with an R2 store for the Cloudflare hosts
The plugin blob seam (StorageDeps.blobs) was hardcoded to the FumaDB blob table, so every blob write landed in the relational tier. createExecutor now accepts a BlobStore, the host DB handle can carry one, and makeScopedExecutor threads it through. The SDK keeps only the platform-neutral contract and backends; the R2 implementation lives with the Cloudflare host package (@executor-js/cloudflare/blob-store), typed against the real R2Bucket. cloud gains a BLOBS R2 binding (bucket must exist before deploy); host-cloudflare reuses its existing BLOBS bucket directly for blob-seam writes, which never enter D1 and so never need the oversized-value offload wrap. Hosts without a bucket keep the FumaDB fallback. The prod blob table is empty, so swapping the backend strands no data.
1 parent e3c49ef commit fa6a3ee

12 files changed

Lines changed: 231 additions & 5 deletions

File tree

apps/cloud/src/db/fuma.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { env } from "cloudflare:workers";
12
import { Effect, Layer } from "effect";
23
import { type FumaDB } from "@executor-js/fumadb";
34
import { type DrizzleConfig } from "@executor-js/fumadb/adapters/drizzle";
@@ -9,6 +10,7 @@ import {
910
type ExecutorDbHandle,
1011
type ExecutorDbProvider,
1112
} from "@executor-js/api/server";
13+
import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store";
1214
import type { FumaDb, FumaTables } from "@executor-js/sdk";
1315

1416
import { DbService } from "./db";
@@ -66,6 +68,10 @@ export const cloudDbProviderLayer = (
6668
db: fuma.db,
6769
fuma: fuma.fuma,
6870
close: async () => {},
71+
// Plugin blobs (multi-MB resolved specs) live in R2, not Postgres.
72+
// Guarded because test workers / local dev may run without the
73+
// binding — the executor then falls back to the FumaDB `blob` table.
74+
blobs: env.BLOBS ? makeR2BlobStore(env.BLOBS) : undefined,
6975
};
7076
}),
7177
);

apps/cloud/src/env-augment.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ declare global {
2020
DATABASE_URL?: string;
2121
EXECUTOR_DIRECT_DATABASE_URL?: string;
2222

23+
// Plugin blob seam backend (wrangler.jsonc `r2_buckets`). Declared here
24+
// (optional) rather than regenerating worker-configuration.d.ts: test
25+
// workers and older local setups run without the binding, and the db
26+
// layer falls back to the Postgres `blob` table when absent. Typed via
27+
// @cloudflare/workers-types (not the wrangler-generated global) to match
28+
// what `@executor-js/cloudflare/blob-store` accepts.
29+
BLOBS?: import("@cloudflare/workers-types").R2Bucket;
30+
2331
// SSRF / private-network egress guard. Unset in production -> the guard is
2432
// ON; the test workers set "true" so fixtures can reach localhost.
2533
ALLOW_LOCAL_NETWORK?: string;

apps/cloud/wrangler.jsonc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
"localConnectionString": "postgresql://postgres:postgres@127.0.0.1:5433/postgres",
4444
},
4545
],
46+
// Plugin blob seam backend (multi-MB resolved specs). Bucket must exist
47+
// before deploy: `wrangler r2 bucket create executor-cloud-blobs`.
48+
"r2_buckets": [
49+
{
50+
"binding": "BLOBS",
51+
"bucket_name": "executor-cloud-blobs",
52+
},
53+
],
4654
"placement": {
4755
"region": "aws:us-east-1",
4856
},

apps/host-cloudflare/src/db/d1.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createExecutorFumaDb,
1313
type ExecutorDbHandle,
1414
} from "@executor-js/api/server";
15+
import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store";
1516

1617
import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config";
1718

@@ -69,5 +70,9 @@ export const createD1ExecutorDb = async (
6970
fuma,
7071
// The D1 binding owns its own lifecycle; nothing to release.
7172
close: async () => {},
73+
// Blob-seam writes go straight to R2 — they never enter D1, so they never
74+
// need the offload wrap above (which remains only for legacy oversized
75+
// values already inlined in D1 rows).
76+
blobs: blobs ? makeR2BlobStore(blobs) : undefined,
7277
};
7378
};

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/api/src/server/scoped-executor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export const makeScopedExecutor = <
178178
_organizationName: string,
179179
): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
180180
Effect.gen(function* () {
181-
const { db } = yield* DbProvider;
181+
const { db, blobs } = yield* DbProvider;
182182
const { plugins: pluginsFactory } = yield* PluginsProvider;
183183
const config = yield* HostConfig;
184184
// Explicit config wins; otherwise fall back to the request origin if a host
@@ -219,6 +219,7 @@ export const makeScopedExecutor = <
219219
tenant: Tenant.make(organizationId),
220220
subject: Subject.make(accountId),
221221
db,
222+
blobs,
222223
plugins,
223224
httpClientLayer,
224225
onElicitation: "accept-all",

packages/core/sdk/src/executor-fuma-db.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { type DrizzleRuntimeProvider } from "@executor-js/fumadb/adapters/drizzl
2323
import { drizzleAdapter } from "@executor-js/fumadb/adapters/drizzle";
2424
import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema";
2525

26+
import type { BlobStore } from "./blob";
2627
import type { FumaDb, FumaTables } from "./fuma-runtime";
2728

2829
// The FumaDB provider both the runtime-schema generator and the drizzle adapter
@@ -106,4 +107,11 @@ export interface ExecutorDbHandle<
106107
TTables extends FumaTables = FumaTables,
107108
> extends ExecutorFumaDb<TTables> {
108109
readonly close: () => Promise<void>;
110+
/**
111+
* Optional blob backend assembled alongside the driver (an R2 bucket on the
112+
* Cloudflare hosts). `makeScopedExecutor` threads it into
113+
* `createExecutor({ blobs })`; absent, the executor defaults to the FumaDB
114+
* `blob` table over `db`.
115+
*/
116+
readonly blobs?: BlobStore;
109117
}

packages/core/sdk/src/executor.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
type FumaTables,
1717
type StorageFailure,
1818
} from "./fuma-runtime";
19-
import { makeFumaBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob";
19+
import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob";
2020
import { coreToolsPlugin } from "./core-tools";
2121
import type {
2222
Connection,
@@ -328,6 +328,13 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
328328
/** The acting member, or omit for a pure-org executor (no `owner:"user"`). */
329329
readonly subject?: Subject;
330330
readonly db?: ExecutorDbInput | ExecutorDbFactory;
331+
/**
332+
* Backend for the plugin blob seam (`StorageDeps.blobs`). Defaults to the
333+
* FumaDB `blob` table over `db`. Hosts with an object store hand one in
334+
* (e.g. the R2 store in `@executor-js/cloudflare/blob-store`) so multi-MB
335+
* values stay out of the relational tier.
336+
*/
337+
readonly blobs?: BlobStore;
331338
readonly plugins?: TPlugins;
332339
/** Config-level credential providers, merged with every
333340
* `plugin.credentialProviders`. Config providers register first, so the
@@ -1217,7 +1224,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
12171224
const rootDb = withQueryContext(rootDbUntyped, ownerContext);
12181225
const fuma = makeFumaClient(rootDb);
12191226
const core = makeCoreDb(fuma);
1220-
const blobs = makeFumaBlobStore(fuma);
1227+
const blobs = config.blobs ?? makeFumaBlobStore(fuma);
12211228
const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect);
12221229

12231230
// Populated once, never mutated after startup.

packages/core/sdk/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,10 @@ export {
166166
type InvokeOptions,
167167
} from "./elicitation";
168168

169-
// Blob store — the plugin-facing CONTRACT only. The concrete makers
170-
// (`makeFumaBlobStore`/`makeInMemoryBlobStore`) are SDK-internal.
169+
// Blob store — the plugin-facing contract (`BlobStore`/`PluginBlobStore`)
170+
// plus the platform-neutral backends (`makeFumaBlobStore` default,
171+
// `makeInMemoryBlobStore` for tests). Platform-specific backends live with
172+
// their host (R2: `@executor-js/cloudflare/blob-store`).
171173
export {
172174
pluginBlobStore,
173175
makeInMemoryBlobStore,

packages/hosts/cloudflare/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"private": true,
55
"type": "module",
66
"exports": {
7+
"./blob-store": {
8+
"types": "./src/blob-store.ts",
9+
"default": "./src/blob-store.ts"
10+
},
711
"./mcp/worker-transport": {
812
"types": "./src/mcp/worker-transport.ts",
913
"default": "./src/mcp/worker-transport.ts"
@@ -38,6 +42,7 @@
3842
"@executor-js/api": "workspace:*",
3943
"@executor-js/execution": "workspace:*",
4044
"@executor-js/host-mcp": "workspace:*",
45+
"@executor-js/sdk": "workspace:*",
4146
"@modelcontextprotocol/sdk": "^1.29.0",
4247
"agents": "^0.10.0",
4348
"effect": "catalog:"

0 commit comments

Comments
 (0)