Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3a319b1
feat(action): local bindings + Output accessors in Actions
sam-goodwin Jul 11, 2026
ca6eb42
feat(cloudflare): Local bindings for KV, R2, Queues
sam-goodwin Jul 11, 2026
dff1302
feat(cloudflare): Local bindings for DNS, Vectorize, Tunnel, AI Searc…
sam-goodwin Jul 11, 2026
c41cc63
refactor(cloudflare/vectorize): use distilled insert/upsert, drop For…
sam-goodwin Jul 11, 2026
031d538
docs(action): document Local bindings, Output accessors, and value-fo…
sam-goodwin Jul 11, 2026
ca38ed4
fix(cloudflare/d1): normalize native D1 bind values in QueryDatabaseL…
sam-goodwin Jul 15, 2026
5e72363
test(cloudflare/d1): cover binary BLOB bind in QueryDatabaseLocal
sam-goodwin Jul 15, 2026
b2c3fab
chore(distilled): repoint submodule to main-based d1/vectorize fixes
sam-goodwin Jul 15, 2026
0336b25
Merge remote-tracking branch 'origin/main' into claude/alchemy-action…
sam-goodwin Jul 15, 2026
93af3f2
chore(distilled): bump to D1 value-union params fix
sam-goodwin Jul 15, 2026
83491e3
fix(cloudflare/vectorize): send raw ndjson body via distilled binary op
sam-goodwin Jul 15, 2026
5d240ac
docs(cloudflare/vectorize): fix stale multipart comment on toNdjsonBlob
sam-goodwin Jul 15, 2026
1a8413c
refactor(cloudflare/vectorize): extract shared HTTP client builder
sam-goodwin Jul 15, 2026
4cf2e06
refactor(cloudflare): extract shared HTTP client builders for D1, Fla…
sam-goodwin Jul 15, 2026
1eef6b5
merge main; resolve distilled submodule to merged d1/vectorize-fixes …
sam-goodwin Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions packages/alchemy/src/Action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import type { Pipeable } from "effect/Pipeable";
import { makeCaptureContext } from "./ActionRuntimeContext.ts";
import { toFqn } from "./FQN.ts";
import type { Input } from "./Input.ts";
import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
import * as Output from "./Output.ts";
import { RuntimeContext } from "./RuntimeContext.ts";
import { Stack } from "./Stack.ts";

/**
Expand Down Expand Up @@ -35,6 +37,14 @@ export interface ActionLike<
readonly Type: Type;
readonly LogicalId: string;
readonly Input: In;
/**
* Resource Outputs referenced via `yield* output` inside the init Effect,
* keyed by the Output's sanitized key. They become dependency edges (the
* Action waits for these upstreams) and are resolved against the tracker at
* apply time, then exposed to the body through the resolve
* {@link RuntimeContext}. Empty when the Action captures nothing.
*/
readonly Captures: Record<string, Output.Output>;
/** Resolved runner — populated by the init effect (if any). */
readonly Run: (input: In) => Effect.Effect<Out, any, any>;
/** @internal phantom */
Expand Down Expand Up @@ -83,9 +93,14 @@ export type ActionInit<In extends object | undefined, Out, Req> = Effect.Effect<
// const rows = yield* Sync({ table: bucket.name });
// // rows: Output<{ rows: number }, never>
//
// Tagged form for split contract/implementation:
// Tagged form for split contract/implementation — declare the type with an
// interface, build the value with the no-arg overload, then supply the runner
// via `.make` (add the returned Layer to the stack's `providers`, or provide it
// locally with `Effect.provide`). The init passed to `.make` runs under the
// same capture context as the inline form, so `yield* output` accessors work:
//
// class Sync extends Action<Sync, { table: string }, { rows: number }>()("Sync") {}
// interface Sync extends Action<"Sync", { table: string }, { rows: number }> {}
// const Sync = Action<Sync, { table: string }, { rows: number }>()("Sync");
// const SyncLive = Sync.make(Effect.gen(function* () { /* ... */ }));

export function Action<
Expand Down Expand Up @@ -187,14 +202,23 @@ const makeActionClass = (
`alchemy/Action<${type}>`,
);

// Outputs referenced via `yield* output` inside the init Effect land here,
// recorded by the capture RuntimeContext. Shared per definition — the init
// runs at most once (Effect.cached), so captures are inherently
// per-definition, matching how the init's `Req` bubbles per definition.
const captures: Record<string, Output.Output> = {};
const captureContext = makeCaptureContext(captures);

const constructor = (...args: [any] | [string, any]) => {
const [id, input] =
args.length === 1 ? [type, args[0]] : (args as [string, any]);
return Effect.gen(function* () {
const run = resolveRunner
? yield* resolveRunner
? yield* resolveRunner.pipe(
Effect.provideService(RuntimeContext, captureContext),
)
: ((yield* SelfTag!) as ActionRunner<any, any, any>);
return yield* registerAction(type, id, input, run);
return yield* registerAction(type, id, input, run, captures);
});
};

Expand All @@ -203,12 +227,19 @@ const makeActionClass = (
extra.Self = SelfTag;
// `.make(initOrRun)` — accepts either a direct runner or an init Effect.
// For init form we use `Layer.effect` so the init's Req surfaces on the
// Layer; for runners we use `Layer.succeed`.
// Layer, and run it under the capture RuntimeContext so `yield* output`
// accessors are recorded just like the inline form; for runners we use
// `Layer.succeed` (nothing to capture).
extra.make = <R>(
initOrRun: ActionRunner<any, any, R> | ActionInit<any, any, R>,
) =>
isRunnerEffect(initOrRun)
? Layer.effect(SelfTag, initOrRun as any)
? Layer.effect(
SelfTag,
(initOrRun as ActionInit<any, any, R>).pipe(
Effect.provideService(RuntimeContext, captureContext),
),
)
: Layer.succeed(SelfTag, initOrRun as ActionRunner<any, any, any>);
}
return Object.assign(constructor, extra);
Expand All @@ -223,6 +254,7 @@ const registerAction = <
id: string,
input: any,
run: ActionRunner<In, Out, any>,
captures: Record<string, Output.Output>,
): Effect.Effect<Output.ToOutput<Out, never>, never, Stack> =>
Effect.gen(function* () {
const stack = yield* Stack;
Expand Down Expand Up @@ -255,6 +287,7 @@ const registerAction = <
FQN: fqn,
LogicalId: id,
Input: input,
Captures: captures,
Run: run,
Output: undefined as any,
};
Expand Down
68 changes: 68 additions & 0 deletions packages/alchemy/src/ActionRuntimeContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as Effect from "effect/Effect";
import type { Output } from "./Output.ts";
import { type BaseRuntimeContext, RuntimeContext } from "./RuntimeContext.ts";

/**
* Runtime context bridge that lets an {@link Action} read Resource Outputs.
*
* Actions are peculiar: their init Effect runs at plan/stack-eval time (before
* any resource exists) while their body runs at apply time (after upstreams are
* materialized). To make `yield* db.databaseId` work inside an Action we split
* the {@link RuntimeContext} into two cooperating halves that share nothing but
* a sanitized key:
*
* - {@link makeCaptureContext} is provided while the init Effect runs. Its
* `set(key, output)` records the referenced {@link Output} (so the engine can
* add a dependency edge and later resolve it) and its `get(key)` hands back a
* *deferred* accessor — an Effect that re-reads whatever `RuntimeContext` is
* ambient when it actually runs.
* - {@link makeResolveContext} is provided around the Action body at apply
* time. Its `get(key)` returns the already-resolved value for that key.
*
* Because the accessor produced at init re-reads `RuntimeContext`, yielding it
* inside the body resolves against the apply-time context — no shared mutable
* state, no infinite recursion (the resolve context's `get` is terminal).
*/

const base = (id: string): Omit<BaseRuntimeContext, "get" | "set"> => ({
Type: "Action",
id,
env: {},
});

/**
* Capture context — provided while an Action's init Effect runs. Records every
* Output referenced via `yield* output` into `captures`, keyed by the Output's
* sanitized key, and returns deferred accessors.
*/
export const makeCaptureContext = (
captures: Record<string, Output>,
): BaseRuntimeContext => ({
...base("capture"),
set: (key, output) =>
Effect.sync(() => {
captures[key] = output as unknown as Output;
return key;
}),
// Deferred: re-read the ambient RuntimeContext at run time. At apply this is
// the resolve context below; its `get` returns the materialized value. The
// `RuntimeContext` requirement is erased at the Action boundary (`Run` is
// typed `Effect<Out, any, any>`) and satisfied by the resolve context, so
// the accessor presents as `Effect<T | undefined>`.
get: (<T>(key: string) =>
Effect.flatMap(RuntimeContext, (ctx) =>
ctx.get<T>(key),
)) as BaseRuntimeContext["get"],
});

/**
* Resolve context — provided around an Action body at apply time. `resolved`
* maps each captured key to its value (already evaluated against the tracker).
*/
export const makeResolveContext = (
resolved: Record<string, unknown>,
): BaseRuntimeContext => ({
...base("resolve"),
set: (key) => Effect.succeed(key),
get: <T>(key: string) => Effect.succeed(resolved[key] as T | undefined),
});
35 changes: 30 additions & 5 deletions packages/alchemy/src/Apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import type { Simplify } from "effect/Types";
import type { ActionLike } from "./Action.ts";
import { makeResolveContext } from "./ActionRuntimeContext.ts";
import { RuntimeContext } from "./RuntimeContext.ts";
import {
Artifacts,
ArtifactStore,
Expand Down Expand Up @@ -1124,6 +1127,25 @@ const executeNode = (
// `--force` is set). The output value is written to `tracker[fqn].output`
// so downstream Output evaluation works identically to resource attrs.

/**
* Run an Action body, resolving any Outputs it captured via `yield* output`
* during init against the current tracker and exposing them to the body through
* the resolve {@link RuntimeContext}. See {@link makeCaptureContext}.
*/
const runAction = Effect.fn("apply.runAction")(function* (
task: ActionLike,
input: any,
outputs: Record<string, any>,
) {
const resolved: Record<string, unknown> = {};
for (const [key, output] of Object.entries(task.Captures)) {
resolved[key] = yield* Output.evaluate(output, outputs);
}
return yield* task
.Run(input)
.pipe(Effect.provideService(RuntimeContext, makeResolveContext(resolved)));
});

const executeActionNode = (
fqn: string,
node: ActionApply,
Expand Down Expand Up @@ -1202,9 +1224,12 @@ const executeActionNode = (
// until its run actually starts.
yield* report("pending");
yield* waitForDeps(
Object.keys(Output.resolveUpstream(node.input)).filter(
(f) => f in readyStable,
),
[
...new Set([
...Object.keys(Output.resolveUpstream(node.input)),
...Object.keys(Output.upstreamAny(task.Captures)),
]),
].filter((f) => f in readyStable),
);

const outputs = getOutputs();
Expand All @@ -1223,7 +1248,7 @@ const executeActionNode = (
});
yield* report("running");

const result = yield* task.Run(resolvedInput);
const result = yield* runAction(task, resolvedInput, outputs);

yield* commit<RanActionState>({
kind: "action",
Expand Down Expand Up @@ -1447,7 +1472,7 @@ const converge = Effect.fn(function* (
} satisfies RunningActionState,
});

const result = yield* node.def.Run(newInput);
const result = yield* runAction(node.def, newInput, outputs);

yield* state.set({
stack: stackName,
Expand Down
66 changes: 66 additions & 0 deletions packages/alchemy/src/Cloudflare/AI/QuerySearchLocal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import type * as HttpClient from "effect/unstable/http/HttpClient";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import type { Credentials } from "../Credentials.ts";
import { QuerySearch } from "./QuerySearch.ts";
import { makeLocalSearchClient, type SearchAuth } from "./SearchHttpClient.ts";
import type { SearchInstance } from "./SearchInstance.ts";

/**
* Local implementation of the {@link QuerySearch} binding — queries an AI
* Search instance over the Cloudflare HTTP API using the **current
* credentials** instead of a native Worker binding (`QuerySearchBinding`).
*
* Provide it on an {@link Action} (or any deploy-time Effect) to run
* `search` / `chatCompletions` / `info` / `stats` against an instance with the
* same client you'd use inside a Worker. The instance id and namespace are
* resolved at apply time through the ambient RuntimeContext, so
* `QuerySearch(instance)` works even when the instance is created in the same
* deploy.
*
* `raw` (the native `AiSearchInstance` runtime handle) is unavailable outside a
* deployed Worker and dies if forced.
*
* @example Read an instance's status from an Action
* ```typescript
* const Probe = Alchemy.Action(
* "Probe",
* Effect.gen(function* () {
* const search = yield* Cloudflare.AI.QuerySearch(instance);
* return Effect.fn(function* () {
* const info = yield* search.info();
* const stats = yield* search.stats();
* return { status: info.status, indexed: stats.completed };
* });
* }).pipe(Effect.provide(Cloudflare.AI.QuerySearchLocal)),
* );
* ```
*/
export const QuerySearchLocal = Layer.effect(
QuerySearch,
Effect.gen(function* () {
// Account + credentials are ambient during stack-eval (the stack's
// providers layer). Capture the full context so each HTTP op can be run
// with the current credentials; no `host.bind`, no minted token.
const { accountId } = yield* yield* CloudflareEnvironment;
const context = yield* Effect.context<
Credentials | HttpClient.HttpClient
>();

const auth: SearchAuth = {
authorize: (eff) => eff.pipe(Effect.provideContext(context)),
accountId,
};

return Effect.fn(function* (instance: SearchInstance) {
// Deferred accessors — resolved against the tracker at apply time.
const instanceId = yield* instance.instanceId;
const namespace = yield* instance.namespace;
return makeLocalSearchClient(
auth,
Effect.all({ id: instanceId, name: namespace }),
);
});
}),
);
60 changes: 60 additions & 0 deletions packages/alchemy/src/Cloudflare/AI/QuerySearchNamespaceLocal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import type * as HttpClient from "effect/unstable/http/HttpClient";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import type { Credentials } from "../Credentials.ts";
import { QuerySearchNamespace } from "./QuerySearchNamespace.ts";
import {
makeLocalSearchNamespaceClient,
type SearchAuth,
} from "./SearchHttpClient.ts";
import type { SearchNamespace } from "./SearchNamespace.ts";

/**
* Local implementation of the {@link QuerySearchNamespace} binding — queries an
* AI Search namespace over the Cloudflare HTTP API using the **current
* credentials** instead of a native Worker binding
* (`QuerySearchNamespaceBinding`).
*
* Provide it on an {@link Action} (or any deploy-time Effect) to `list`
* instances, run a multi-instance `search`, or `.get(instanceName)` a
* single-instance client — the same client you'd use inside a Worker. The
* namespace name is resolved at apply time through the ambient RuntimeContext.
*
* `raw` (the native `AiSearchNamespace` runtime handle) is unavailable outside a
* deployed Worker and dies if forced.
*
* @example List a namespace's instances from an Action
* ```typescript
* const Probe = Alchemy.Action(
* "Probe",
* Effect.gen(function* () {
* const ns = yield* Cloudflare.AI.QuerySearchNamespace(namespace);
* return Effect.fn(function* () {
* const { result } = yield* ns.list();
* return result.map((i) => i.id);
* });
* }).pipe(Effect.provide(Cloudflare.AI.QuerySearchNamespaceLocal)),
* );
* ```
*/
export const QuerySearchNamespaceLocal = Layer.effect(
QuerySearchNamespace,
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
const context = yield* Effect.context<
Credentials | HttpClient.HttpClient
>();

const auth: SearchAuth = {
authorize: (eff) => eff.pipe(Effect.provideContext(context)),
accountId,
};

return Effect.fn(function* (namespace: SearchNamespace) {
// Deferred accessor — resolved against the tracker at apply time.
const name = yield* namespace.name;
return makeLocalSearchNamespaceClient(auth, name);
});
}),
);
Loading
Loading