Skip to content

Commit a66ce8f

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. makeR2BlobStore implements the store over an R2 bucket via a structural R2BucketLike type, keeping the SDK free of Cloudflare type dependencies. 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 23b97ef commit a66ce8f

10 files changed

Lines changed: 220 additions & 7 deletions

File tree

apps/cloud/src/db/fuma.ts

Lines changed: 6 additions & 1 deletion
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,7 +10,7 @@ import {
910
type ExecutorDbHandle,
1011
type ExecutorDbProvider,
1112
} from "@executor-js/api/server";
12-
import type { FumaDb, FumaTables } from "@executor-js/sdk";
13+
import { makeR2BlobStore, type FumaDb, type FumaTables } from "@executor-js/sdk";
1314

1415
import { DbService } from "./db";
1516

@@ -66,6 +67,10 @@ export const cloudDbProviderLayer = (
6667
db: fuma.db,
6768
fuma: fuma.fuma,
6869
close: async () => {},
70+
// Plugin blobs (multi-MB resolved specs) live in R2, not Postgres.
71+
// Guarded because test workers / local dev may run without the
72+
// binding — the executor then falls back to the FumaDB `blob` table.
73+
blobs: env.BLOBS ? makeR2BlobStore(env.BLOBS) : undefined,
6974
};
7075
}),
7176
);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ 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.
27+
BLOBS?: R2Bucket;
28+
2329
// SSRF / private-network egress guard. Unset in production -> the guard is
2430
// ON; the test workers set "true" so fixtures can reach localhost.
2531
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/sdk";
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
};

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/blob.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { Effect } from "effect";
33

44
import { StorageError } from "./fuma-runtime";
55

6-
import { makeInMemoryBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob";
6+
import {
7+
makeInMemoryBlobStore,
8+
makeR2BlobStore,
9+
pluginBlobStore,
10+
type OwnerPartitions,
11+
type R2BucketLike,
12+
} from "./blob";
713

814
// v2: owner partitions instead of a scope stack. Reads fall through
915
// [user, org] (user = innermost); writes/deletes name an explicit owner.
@@ -100,6 +106,106 @@ describe("pluginBlobStore", () => {
100106
);
101107
});
102108

109+
// Minimal in-memory R2 bucket double — same observable surface as the
110+
// `R2BucketLike` slice of a real binding.
111+
const makeFakeBucket = (): { bucket: R2BucketLike; objects: Map<string, string> } => {
112+
const objects = new Map<string, string>();
113+
return {
114+
objects,
115+
bucket: {
116+
get: async (key) => {
117+
const value = objects.get(key);
118+
return value === undefined ? null : { text: async () => value };
119+
},
120+
put: async (key, value) => {
121+
objects.set(key, value);
122+
},
123+
delete: async (key) => {
124+
objects.delete(key);
125+
},
126+
head: async (key) => (objects.has(key) ? {} : null),
127+
},
128+
};
129+
};
130+
131+
describe("makeR2BlobStore", () => {
132+
it.effect("round-trips a value and names objects `namespace/key`", () =>
133+
Effect.gen(function* () {
134+
const { bucket, objects } = makeFakeBucket();
135+
const store = makeR2BlobStore(bucket);
136+
137+
yield* store.put("o:t1/openapi", "spec/abc123", "spec-text");
138+
expect(yield* store.get("o:t1/openapi", "spec/abc123")).toBe("spec-text");
139+
expect(objects.has("o:t1/openapi/spec/abc123")).toBe(true);
140+
}),
141+
);
142+
143+
it.effect("get returns null for a missing object", () =>
144+
Effect.gen(function* () {
145+
const { bucket } = makeFakeBucket();
146+
const store = makeR2BlobStore(bucket);
147+
expect(yield* store.get("o:t1/openapi", "missing")).toBeNull();
148+
}),
149+
);
150+
151+
it.effect("getMany returns hits keyed by namespace", () =>
152+
Effect.gen(function* () {
153+
const { bucket } = makeFakeBucket();
154+
const store = makeR2BlobStore(bucket);
155+
yield* store.put("u:t1:s1/openapi", "k", "user-value");
156+
yield* store.put("o:t1/openapi", "k", "org-value");
157+
158+
const hits = yield* store.getMany(["u:t1:s1/openapi", "o:t1/openapi", "o:t2/openapi"], "k");
159+
expect(hits.size).toBe(2);
160+
expect(hits.get("u:t1:s1/openapi")).toBe("user-value");
161+
expect(hits.get("o:t1/openapi")).toBe("org-value");
162+
}),
163+
);
164+
165+
it.effect("delete and has agree", () =>
166+
Effect.gen(function* () {
167+
const { bucket } = makeFakeBucket();
168+
const store = makeR2BlobStore(bucket);
169+
yield* store.put("ns/p", "k", "v");
170+
expect(yield* store.has("ns/p", "k")).toBe(true);
171+
yield* store.delete("ns/p", "k");
172+
expect(yield* store.has("ns/p", "k")).toBe(false);
173+
expect(yield* store.get("ns/p", "k")).toBeNull();
174+
}),
175+
);
176+
177+
it.effect("bucket failures surface as StorageError", () =>
178+
Effect.gen(function* () {
179+
const failing: R2BucketLike = {
180+
get: async () => {
181+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- test double simulating a bucket outage
182+
throw { name: "R2Error", message: "bucket unavailable" };
183+
},
184+
put: async () => {},
185+
delete: async () => {},
186+
head: async () => null,
187+
};
188+
const store = makeR2BlobStore(failing);
189+
const err = yield* store.get("ns", "k").pipe(Effect.flip);
190+
expect(err).toBeInstanceOf(StorageError);
191+
}),
192+
);
193+
194+
it.effect("works behind pluginBlobStore owner partitioning", () =>
195+
Effect.gen(function* () {
196+
const { bucket } = makeFakeBucket();
197+
const store = makeR2BlobStore(bucket);
198+
const plugin = pluginBlobStore(store, partitions("o:t1", "u:t1:s1"), "openapi");
199+
200+
yield* plugin.put("spec/h1", "org-spec", { owner: "org" });
201+
expect(yield* plugin.get("spec/h1")).toBe("org-spec");
202+
203+
yield* plugin.put("spec/h1", "user-spec", { owner: "user" });
204+
expect(yield* plugin.get("spec/h1")).toBe("user-spec");
205+
}),
206+
);
207+
});
208+
103209
describe("BlobStore.getMany", () => {
104210
it.effect("returns hits keyed by namespace", () =>
105211
Effect.gen(function* () {

packages/core/sdk/src/blob.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,71 @@ export const makeInMemoryBlobStore = (): BlobStore => {
157157
};
158158
};
159159

160+
// ---------------------------------------------------------------------------
161+
// R2-backed BlobStore.
162+
//
163+
// Structural view of a Cloudflare R2 bucket binding — only the four calls the
164+
// store needs, so the SDK takes no dependency on `@cloudflare/workers-types`.
165+
// A real `R2Bucket` satisfies this shape as-is.
166+
// ---------------------------------------------------------------------------
167+
168+
export interface R2BucketLike {
169+
get(key: string): Promise<{ text(): Promise<string> } | null>;
170+
put(key: string, value: string): Promise<unknown>;
171+
delete(key: string): Promise<void>;
172+
head(key: string): Promise<unknown | null>;
173+
}
174+
175+
// Object name: `${namespace}/${key}`. Unambiguous because a namespace is
176+
// always `partition/pluginId` (exactly one slash; partitions use `:`
177+
// separators, plugin ids contain no slash), so the first two segments always
178+
// recover the namespace and the rest is the key.
179+
const r2ObjectName = (namespace: string, key: string): string => `${namespace}/${key}`;
180+
181+
/**
182+
* BlobStore over a Cloudflare R2 bucket. The right backend for multi-MB
183+
* values (resolved OpenAPI specs, introspection documents) that have no
184+
* business living in OLTP rows.
185+
*
186+
* Unlike `makeFumaBlobStore`, writes do NOT participate in FumaDB
187+
* transactions — a rolled-back transaction leaves the blob behind. Callers
188+
* should use idempotent (content-derived) keys so orphaned writes are
189+
* harmless and re-puts are no-ops in effect.
190+
*/
191+
export const makeR2BlobStore = (bucket: R2BucketLike): BlobStore => {
192+
const tryR2 = <A>(op: string, run: () => Promise<A>): Effect.Effect<A, StorageError> =>
193+
Effect.tryPromise({
194+
try: run,
195+
catch: (cause) => new StorageError({ message: `R2 blob ${op} failed`, cause }),
196+
});
197+
198+
return {
199+
get: (namespace, key) =>
200+
tryR2("get", async () => {
201+
const object = await bucket.get(r2ObjectName(namespace, key));
202+
return object == null ? null : await object.text();
203+
}),
204+
getMany: (namespaces, key) =>
205+
tryR2("getMany", async () => {
206+
const hits = new Map<string, string>();
207+
await Promise.all(
208+
namespaces.map(async (ns) => {
209+
const object = await bucket.get(r2ObjectName(ns, key));
210+
if (object != null) hits.set(ns, await object.text());
211+
}),
212+
);
213+
return hits;
214+
}),
215+
put: (namespace, key, value) =>
216+
tryR2("put", async () => {
217+
await bucket.put(r2ObjectName(namespace, key), value);
218+
}),
219+
delete: (namespace, key) => tryR2("delete", () => bucket.delete(r2ObjectName(namespace, key))),
220+
has: (namespace, key) =>
221+
tryR2("head", async () => (await bucket.head(r2ObjectName(namespace, key))) != null),
222+
};
223+
};
224+
160225
const blobId = (namespace: string, key: string): string => JSON.stringify([namespace, key]);
161226

162227
type BlobRow = {

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: 8 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,12 @@ 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+
* (`makeR2BlobStore`) so multi-MB values stay out of the relational tier.
335+
*/
336+
readonly blobs?: BlobStore;
331337
readonly plugins?: TPlugins;
332338
/** Config-level credential providers, merged with every
333339
* `plugin.credentialProviders`. Config providers register first, so the
@@ -1217,7 +1223,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
12171223
const rootDb = withQueryContext(rootDbUntyped, ownerContext);
12181224
const fuma = makeFumaClient(rootDb);
12191225
const core = makeCoreDb(fuma);
1220-
const blobs = makeFumaBlobStore(fuma);
1226+
const blobs = config.blobs ?? makeFumaBlobStore(fuma);
12211227
const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect);
12221228

12231229
// Populated once, never mutated after startup.

packages/core/sdk/src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,18 @@ 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 concrete backends hosts compose (`makeFumaBlobStore` default,
171+
// `makeR2BlobStore` for object-store hosts, `makeInMemoryBlobStore` for tests).
171172
export {
172173
pluginBlobStore,
173174
makeInMemoryBlobStore,
174175
makeFumaBlobStore,
176+
makeR2BlobStore,
175177
type BlobStore,
176178
type PluginBlobStore,
177179
type OwnerPartitions,
180+
type R2BucketLike,
178181
} from "./blob";
179182

180183
// Plugin storage.

0 commit comments

Comments
 (0)