Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions packages/alchemy/src/Cloudflare/Workers/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import type { InputProps } from "../../Input.ts";
import * as ProviderLayer from "../../Local/ProviderLayer.ts";
import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
import * as Provider from "../../Provider.ts";
import { Resource, type ResourceBinding } from "../../Resource.ts";
import {
isResourceEffect,
Resource,
type ResourceBinding,
} from "../../Resource.ts";
import { Stack } from "../../Stack.ts";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import type { HyperdriveDevOrigin } from "../Hyperdrive/Hyperdrive.ts";
Expand Down Expand Up @@ -1182,7 +1186,10 @@ export const LiveWorkerProvider = () =>
: Redacted.isRedacted(value) &&
typeof Redacted.value(value) === "string"
? Redacted.value(value)
: Effect.isEffect(value)
: // Resource references are opaque handles, not env
// var values — running one here would register a
// resource outside the construction phase.
Effect.isEffect(value) && !isResourceEffect(value)
? yield* value as Effect.Effect<any>
: undefined,
];
Expand Down
6 changes: 5 additions & 1 deletion packages/alchemy/src/Diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Redacted from "effect/Redacted";
import type { Input } from "./Input.ts";
import * as Output from "./Output.ts";
import type { BindingNode } from "./Plan.ts";
import type { ResourceBinding } from "./Resource.ts";
import { isResourceEffect, type ResourceBinding } from "./Resource.ts";
import { isPrimitive } from "./Util/data.ts";

export type Diff = NoopDiff | UpdateDiff | ReplaceDiff;
Expand Down Expand Up @@ -45,6 +45,10 @@ export const isResolved = <T>(value: Input<T>): value is T =>

const _hasUnresolved = (value: unknown): boolean => {
if (value == null || isPrimitive(value)) return false;
// Non-class resource references are permanently opaque — `resolveInput`
// never executes them (see Plan.ts) — so they don't make props
// "unresolved": a provider's diff can still run against the rest.
if (isResourceEffect(value)) return false;
if (Output.isExpr(value) || Effect.isEffect(value)) return true;
if (Array.isArray(value)) return value.some(_hasUnresolved);
if (typeof value === "object") {
Expand Down
19 changes: 18 additions & 1 deletion packages/alchemy/src/Output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import type { Pipeable } from "effect/Pipeable";
import * as Redacted from "effect/Redacted";
import { SingleShotGen } from "effect/Utils";
import { getRefMetadata, isRef, type Ref } from "./Ref.ts";
import { isResource, type Resource, type ResourceLike } from "./Resource.ts";
import {
isResource,
isResourceEffect,
type Resource,
type ResourceLike,
} from "./Resource.ts";
import { RuntimeContext, sanitizeKey } from "./RuntimeContext.ts";
import { Stack } from "./Stack.ts";
import { Stage } from "./Stage.ts";
Expand Down Expand Up @@ -650,6 +655,18 @@ export const evaluate: <A, Req = never>(
// Plan.ts for rationale. `Config.redacted` resolves to a `Redacted`,
// which stays opaque via the branch below.
return yield* evaluate(yield* expr, upstream);
} else if (isResourceEffect(expr)) {
// Non-class resource references (`const db = Hyperdrive("db", …)`)
// stay opaque to evaluation — see resolveInput in Plan.ts for
// rationale.
return expr;
} else if (Effect.isEffect(expr) && typeof expr !== "function") {
// Per-field Effect inputs — see resolveInput in Plan.ts for rationale.
// Resolving here keeps apply-time props symmetric with plan-time
// resolution (and stops the object walk below from shredding an Effect
// into a plain `{"~effect/Effect/args": ...}` object). Function-form
// Effects (resource classes, `Binding.Policy` tags) stay opaque.
return yield* evaluate(yield* expr, upstream);
} else if (Duration.isDuration(expr) || Redacted.isRedacted(expr)) {
// Opaque value — see resolveInput in Plan.ts for rationale.
return expr;
Expand Down
26 changes: 25 additions & 1 deletion packages/alchemy/src/Plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "./Provider.ts";
import {
isResource,
isResourceEffect,
type ResourceBinding,
type ResourceLike,
} from "./Resource.ts";
Expand Down Expand Up @@ -374,7 +375,10 @@ export const make = <A>(
));
});

const resolveInput = (input: any): Effect.Effect<any, Config.ConfigError> =>
// The error channel is `unknown` because per-field Effect inputs
// (`Input<T>` admits `Effect<T, any, any>`) surface their own failures,
// which are not statically known here.
const resolveInput = (input: any): Effect.Effect<any, unknown> =>
Effect.gen(function* () {
if (!input) {
return input;
Expand All @@ -388,6 +392,26 @@ export const make = <A>(
// `Config.redacted` resolves to a `Redacted`, which stays opaque via
// the branch below.
return yield* resolveInput(yield* input);
} else if (isResourceEffect(input)) {
// Non-class resource references (`const db = Hyperdrive("db", …)`)
// are Effects too, but they are handles to stack resources, not
// per-field values. Executing one here would re-derive its FQN
// from the ambient namespace (none at plan time) and mint a
// phantom resource. They stay opaque — the construction phase
// (e.g. Worker env binding registration) already consumed them.
return input;
} else if (Effect.isEffect(input) && typeof input !== "function") {
// Per-field Effect inputs (e.g. `Stack.useSync(...)`) — `Input<T>`
// admits `Effect<T>` for any prop, so run the effect and resolve
// into its result, mirroring the Config branch above. Function-form
// Effects (resource classes, `effectClass` constructors,
// `Binding.Policy` tags) stay opaque: they are class references
// carried in props (e.g. Worker `exports`), not per-field values.
// Requirements are erased here — the effect runs against the
// ambient plan context (see the Requirements TODO on `Input`).
return yield* resolveInput(
yield* input as Effect.Effect<unknown, unknown>,
);
} else if (Duration.isDuration(input) || Redacted.isRedacted(input)) {
// Opaque values that are resolved downstream. We don't walk them
// because it would strip their prototype, resulting in a plain object
Expand Down
236 changes: 136 additions & 100 deletions packages/alchemy/src/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ export const isResource = (value: any): value is ResourceLike => {
return typeof value === "object" && value !== null && "Type" in value;
};

// Registered with `Symbol.for` (like `alchemy/Expr`) so the brand survives
// duplicate alchemy module instances in one process.
export const ResourceEffectSymbol = Symbol.for("alchemy/ResourceEffect");

/**
* True for the Effect returned by a non-class resource call
* (`const db = Hyperdrive("db", props)`). Such an Effect is a *reference*
* to a stack resource, not a per-field value: input resolution
* (`resolveInput` in Plan.ts, `Output.evaluate`) must leave it opaque
* instead of executing it — execution outside the construction phase would
* re-derive the FQN from the ambient namespace and mint a phantom resource.
*/
export const isResourceEffect = (value: unknown): boolean =>
typeof value === "object" && value !== null && ResourceEffectSymbol in value;

export type Resource<
Type extends string = any,
Props extends object | undefined = any,
Expand Down Expand Up @@ -174,115 +189,136 @@ export function Resource<R extends ResourceLike>(
const constructor = (
id: string,
props: Props | Effect.Effect<Props> | undefined,
) =>
Effect.gen(function* () {
const stack = yield* Stack;
const namespace = yield* CurrentNamespace;
const fqn = toFqn(namespace, id);

const existing = stack.resources[fqn];
if (existing) {
// // TODO(sam): check if props are different and die
return existing;
}
const bind = (
...args:
| [sid: string, data: R["Binding"]]
| [template: TemplateStringsArray, ...args: any[]]
) =>
typeof args[0] === "string"
? Effect.gen(function* () {
const [sid, data] = args as [sid: string, data: R["Binding"]];
(stack.bindings[fqn] ??= []).push({
sid,
data,
});
return undefined;
})
: (data: R["Binding"]) => {
const stringifyBindArg = (arg: any): string | undefined => {
if (arg === undefined) {
return undefined;
}
) => {
// One constructor call denotes one resource per stack. The FQN-keyed
// memo below only collapses re-executions that see the same ambient
// namespace; this per-effect memo also covers re-executions *outside*
// the construction phase (e.g. a per-field Effect like
// `Effect.map(dbRole, …)` resolved by Plan/Apply re-runs the wrapped
// reference with no namespace in scope), which would otherwise
// re-derive a different FQN and mint a phantom resource.
const memo = new WeakMap<object, R>();
return Object.assign(
Effect.gen(function* () {
const stack = yield* Stack;
const memoized = memo.get(stack);
if (memoized) {
return memoized;
}
const namespace = yield* CurrentNamespace;
const fqn = toFqn(namespace, id);

if (Array.isArray(arg)) {
return arg
.flatMap((item) => {
const stringified = stringifyBindArg(item);
return stringified === undefined ? [] : [stringified];
})
.join(", ");
}
const existing = stack.resources[fqn];
if (existing) {
// // TODO(sam): check if props are different and die
memo.set(stack, existing as R);
return existing;
}
const bind = (
...args:
| [sid: string, data: R["Binding"]]
| [template: TemplateStringsArray, ...args: any[]]
) =>
typeof args[0] === "string"
? Effect.gen(function* () {
const [sid, data] = args as [sid: string, data: R["Binding"]];
(stack.bindings[fqn] ??= []).push({
sid,
data,
});
return undefined;
})
: (data: R["Binding"]) => {
const stringifyBindArg = (arg: any): string | undefined => {
if (arg === undefined) {
return undefined;
}

if (
arg &&
(typeof arg === "object" || typeof arg === "function")
) {
if ("LogicalId" in arg && typeof arg.LogicalId === "string") {
return arg.LogicalId;
if (Array.isArray(arg)) {
return arg
.flatMap((item) => {
const stringified = stringifyBindArg(item);
return stringified === undefined ? [] : [stringified];
})
.join(", ");
}

if ("id" in arg && typeof arg.id === "string") {
return arg.id;
if (
arg &&
(typeof arg === "object" || typeof arg === "function")
) {
if (
"LogicalId" in arg &&
typeof arg.LogicalId === "string"
) {
return arg.LogicalId;
}

if ("id" in arg && typeof arg.id === "string") {
return arg.id;
}
}
}

return String(arg);
};
return String(arg);
};

return bind(
`${(args[0] as TemplateStringsArray)
.flatMap((text, i) => {
const stringified = stringifyBindArg(args[i + 1]);
return stringified !== undefined
? [text, stringified]
: [text];
})
.join("")}`,
data,
);
};
return bind(
`${(args[0] as TemplateStringsArray)
.flatMap((text, i) => {
const stringified = stringifyBindArg(args[i + 1]);
return stringified !== undefined
? [text, stringified]
: [text];
})
.join("")}`,
data,
);
};

const target: any = {
Type: type,
Namespace: namespace,
FQN: fqn,
LogicalId: id,
Props: props,
Provider: ProviderTag as Provider<any>,
RemovalPolicy: yield* Effect.serviceOption(RemovalPolicy).pipe(
Effect.map(Option.getOrElse(() => defaultRemovalPolicy)),
),
Adopt: yield* Effect.serviceOption(AdoptPolicy).pipe(
Effect.map(Option.getOrUndefined),
),
bind,
toString(this: typeof target) {
return `Resource<${this.Type}>(${this.LogicalId})`;
},
[Symbol.toPrimitive](this: typeof target, hint: string) {
return hint === "number" ? NaN : this.toString();
},
};
const target: any = {
Type: type,
Namespace: namespace,
FQN: fqn,
LogicalId: id,
Props: props,
Provider: ProviderTag as Provider<any>,
RemovalPolicy: yield* Effect.serviceOption(RemovalPolicy).pipe(
Effect.map(Option.getOrElse(() => defaultRemovalPolicy)),
),
Adopt: yield* Effect.serviceOption(AdoptPolicy).pipe(
Effect.map(Option.getOrUndefined),
),
bind,
toString(this: typeof target) {
return `Resource<${this.Type}>(${this.LogicalId})`;
},
[Symbol.toPrimitive](this: typeof target, hint: string) {
return hint === "number" ? NaN : this.toString();
},
};

const Resource: R = (stack.resources[fqn] = new Proxy(target, {
set: (t, prop, value) => {
t[prop as keyof typeof t] = value;
return true;
},
get: (t, prop) =>
typeof prop === "symbol" || prop in t
? t[prop as keyof typeof t]
: new Output.PropExpr<any, string>(Output.of(Resource), prop),
})) as R;
Resource.Props = Effect.isEffect(props)
? yield* props.pipe(
Effect.provideService(Self, Resource),
Effect.provideService(Self(type), Resource),
)
: props;
return Resource;
});
const Resource: R = (stack.resources[fqn] = new Proxy(target, {
set: (t, prop, value) => {
t[prop as keyof typeof t] = value;
return true;
},
get: (t, prop) =>
typeof prop === "symbol" || prop in t
? t[prop as keyof typeof t]
: new Output.PropExpr<any, string>(Output.of(Resource), prop),
})) as R;
Resource.Props = Effect.isEffect(props)
? yield* props.pipe(
Effect.provideService(Self, Resource),
Effect.provideService(Self(type), Resource),
)
: props;
memo.set(stack, Resource);
return Resource;
}),
{ [ResourceEffectSymbol]: true },
);
};

const ProviderTag = Provider(type);

Expand Down
Loading