History

Add an injectable blob backend with an R2 store for the Cloudflare hosts (#958)

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.

Rhys Sullivan committed 5 months ago

Commit 4400c541 parent e3c49ef12 files · +231 −5Browse files

Unified · Split

12 files
  1. apps/cloud/src/db/fuma.ts +6 −0
  2. apps/cloud/src/env-augment.d.ts +8 −0
  3. apps/cloud/wrangler.jsonc +8 −0
  4. apps/host-cloudflare/src/db/d1.ts +5 −0
  5. bun.lock +1 −0
  6. packages/core/api/src/server/scoped-executor.ts +2 −1
  7. packages/core/sdk/src/executor-fuma-db.ts +8 −0
  8. packages/core/sdk/src/executor.ts +9 −2
  9. packages/core/sdk/src/index.ts +4 −2
  10. packages/hosts/cloudflare/package.json +5 −0
  11. packages/hosts/cloudflare/src/blob-store.test.ts +105 −0
  12. packages/hosts/cloudflare/src/blob-store.ts +70 −0
apps/cloud/src/db/fuma.ts+6−0
@@ -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 { · 5 unmodified lines
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 = ( · 51 unmodified lines
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+8−0
@@ -20,6 +20,14 @@ declare global { · 19 unmodified lines
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+8−0
@@ -43,6 +43,14 @@ · 42 unmodified lines
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+5−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
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 ( · 51 unmodified lines
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+1−0
@@ -628,6 +628,7 @@ · 627 unmodified lines
628628 "@executor-js/api": "workspace:*",
629629 "@executor-js/execution": "workspace:*",
630630 "@executor-js/host-mcp": "workspace:*",
631+ "@executor-js/sdk": "workspace:*",
631632 "@modelcontextprotocol/sdk": "^1.29.0",
632633 "agents": "^0.10.0",
633634 "effect": "catalog:",
packages/core/api/src/server/scoped-executor.ts+2−1
@@ -178,7 +178,7 @@ export const makeScopedExecutor = < · 177 unmodified lines
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 = < · 34 unmodified lines
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+8−0
@@ -23,6 +23,7 @@ import { type DrizzleRuntimeProvider } from "@executor-js/fumadb/adapters/drizzl · 22 unmodified lines
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< · 77 unmodified lines
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+9−2
@@ -16,7 +16,7 @@ import { · 15 unmodified lines
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 · 305 unmodified lines
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 · 883 unmodified lines
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+4−2
@@ -166,8 +166,10 @@ export { · 165 unmodified lines
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+5−0
@@ -4,6 +4,10 @@ · 3 unmodified lines
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 @@ · 28 unmodified lines
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:"
packages/hosts/cloudflare/src/blob-store.test.ts+105−0
@@ -0,0 +1,105 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import type { R2Bucket } from "@cloudflare/workers-types";
4+
5+import { StorageError, pluginBlobStore } from "@executor-js/sdk";
6+
7+import { makeR2BlobStore } from "./blob-store";
8+
9+// Minimal in-memory R2 bucket double — covers the get/put/delete/head slice
10+// the store uses. Typed via the boundary cast because a full R2Bucket carries
11+// a dozen unrelated methods.
12+const makeFakeBucket = (): { bucket: R2Bucket; objects: Map<string, string> } => {
13+ const objects = new Map<string, string>();
14+ // oxlint-disable-next-line executor/no-double-cast -- test double: only the slice the store calls is implemented
15+ const bucket = {
16+ get: async (key: string) => {
17+ const value = objects.get(key);
18+ return value === undefined ? null : { text: async () => value };
19+ },
20+ put: async (key: string, value: string) => {
21+ objects.set(key, value);
22+ },
23+ delete: async (key: string) => {
24+ objects.delete(key);
25+ },
26+ head: async (key: string) => (objects.has(key) ? {} : null),
27+ } as unknown as R2Bucket;
28+ return { bucket, objects };
29+};
30+
31+describe("makeR2BlobStore", () => {
32+ it.effect("round-trips a value and names objects `namespace/key`", () =>
33+ Effect.gen(function* () {
34+ const { bucket, objects } = makeFakeBucket();
35+ const store = makeR2BlobStore(bucket);
36+
37+ yield* store.put("o:t1/openapi", "spec/abc123", "spec-text");
38+ expect(yield* store.get("o:t1/openapi", "spec/abc123")).toBe("spec-text");
39+ expect(objects.has("o:t1/openapi/spec/abc123")).toBe(true);
40+ }),
41+ );
42+
43+ it.effect("get returns null for a missing object", () =>
44+ Effect.gen(function* () {
45+ const { bucket } = makeFakeBucket();
46+ const store = makeR2BlobStore(bucket);
47+ expect(yield* store.get("o:t1/openapi", "missing")).toBeNull();
48+ }),
49+ );
50+
51+ it.effect("getMany returns hits keyed by namespace", () =>
52+ Effect.gen(function* () {
53+ const { bucket } = makeFakeBucket();
54+ const store = makeR2BlobStore(bucket);
55+ yield* store.put("u:t1:s1/openapi", "k", "user-value");
56+ yield* store.put("o:t1/openapi", "k", "org-value");
57+
58+ const hits = yield* store.getMany(["u:t1:s1/openapi", "o:t1/openapi", "o:t2/openapi"], "k");
59+ expect(hits.size).toBe(2);
60+ expect(hits.get("u:t1:s1/openapi")).toBe("user-value");
61+ expect(hits.get("o:t1/openapi")).toBe("org-value");
62+ }),
63+ );
64+
65+ it.effect("delete and has agree", () =>
66+ Effect.gen(function* () {
67+ const { bucket } = makeFakeBucket();
68+ const store = makeR2BlobStore(bucket);
69+ yield* store.put("ns/p", "k", "v");
70+ expect(yield* store.has("ns/p", "k")).toBe(true);
71+ yield* store.delete("ns/p", "k");
72+ expect(yield* store.has("ns/p", "k")).toBe(false);
73+ expect(yield* store.get("ns/p", "k")).toBeNull();
74+ }),
75+ );
76+
77+ it.effect("bucket failures surface as StorageError", () =>
78+ Effect.gen(function* () {
79+ // oxlint-disable-next-line executor/no-double-cast -- test double: only the slice the store calls is implemented
80+ const failing = {
81+ get: async () => {
82+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test double simulating a bucket outage
83+ throw { name: "R2Error", message: "bucket unavailable" };
84+ },
85+ } as unknown as R2Bucket;
86+ const store = makeR2BlobStore(failing);
87+ const err = yield* store.get("ns", "k").pipe(Effect.flip);
88+ expect(err).toBeInstanceOf(StorageError);
89+ }),
90+ );
91+
92+ it.effect("works behind pluginBlobStore owner partitioning", () =>
93+ Effect.gen(function* () {
94+ const { bucket } = makeFakeBucket();
95+ const store = makeR2BlobStore(bucket);
96+ const plugin = pluginBlobStore(store, { org: "o:t1", user: "u:t1:s1" }, "openapi");
97+
98+ yield* plugin.put("spec/h1", "org-spec", { owner: "org" });
99+ expect(yield* plugin.get("spec/h1")).toBe("org-spec");
100+
101+ yield* plugin.put("spec/h1", "user-spec", { owner: "user" });
102+ expect(yield* plugin.get("spec/h1")).toBe("user-spec");
103+ }),
104+ );
105+});
packages/hosts/cloudflare/src/blob-store.ts+70−0
@@ -0,0 +1,70 @@
1+// ---------------------------------------------------------------------------
2+// BlobStore over a Cloudflare R2 bucket — the object-store backend for the
3+// SDK's blob seam on the Cloudflare hosts. The SDK defines only the
4+// `BlobStore` contract (plus its FumaDB/in-memory defaults); this R2 binding
5+// lives here so the SDK stays platform-agnostic.
6+//
7+// Object name: `${namespace}/${key}`. Unambiguous because a namespace is
8+// always `partition/pluginId` (exactly one slash; partitions use `:`
9+// separators, plugin ids contain no slash), so the first two segments always
10+// recover the namespace and the rest is the key.
11+//
12+// Unlike `makeFumaBlobStore`, writes do NOT participate in FumaDB
13+// transactions — a rolled-back transaction leaves the blob behind. Callers
14+// should use idempotent (content-derived) keys so orphaned writes are
15+// harmless and re-puts are no-ops in effect.
16+// ---------------------------------------------------------------------------
17+
18+import { Effect } from "effect";
19+import type { R2Bucket } from "@cloudflare/workers-types";
20+
21+import { StorageError, type BlobStore } from "@executor-js/sdk";
22+
23+const objectName = (namespace: string, key: string): string => `${namespace}/${key}`;
24+
25+const storeError = (op: string) => (cause: unknown) =>
26+ new StorageError({ message: `R2 blob ${op} failed`, cause });
27+
28+export const makeR2BlobStore = (bucket: R2Bucket): BlobStore => ({
29+ get: (namespace, key) =>
30+ Effect.tryPromise({
31+ try: async () => {
32+ const object = await bucket.get(objectName(namespace, key));
33+ return object == null ? null : await object.text();
34+ },
35+ catch: storeError("get"),
36+ }),
37+ // R2 has no multi-get; fetch the (at most two — user + org partition)
38+ // namespaces concurrently.
39+ getMany: (namespaces, key) =>
40+ Effect.tryPromise({
41+ try: async () => {
42+ const hits = new Map<string, string>();
43+ await Promise.all(
44+ namespaces.map(async (namespace) => {
45+ const object = await bucket.get(objectName(namespace, key));
46+ if (object != null) hits.set(namespace, await object.text());
47+ }),
48+ );
49+ return hits;
50+ },
51+ catch: storeError("getMany"),
52+ }),
53+ put: (namespace, key, value) =>
54+ Effect.tryPromise({
55+ try: async () => {
56+ await bucket.put(objectName(namespace, key), value);
57+ },
58+ catch: storeError("put"),
59+ }),
60+ delete: (namespace, key) =>
61+ Effect.tryPromise({
62+ try: () => bucket.delete(objectName(namespace, key)),
63+ catch: storeError("delete"),
64+ }),
65+ has: (namespace, key) =>
66+ Effect.tryPromise({
67+ try: async () => (await bucket.head(objectName(namespace, key))) != null,
68+ catch: storeError("has"),
69+ }),
70+});