Skip to content

Latest commit

 

History

History
494 lines (379 loc) · 12.6 KB

File metadata and controls

494 lines (379 loc) · 12.6 KB

Contributing

Pre-requisites

  1. Bun
curl -fsSL https://bun.sh/install | bash

Install

We use bun workspaces, so a simple bun i installs all the dependencies:

bun i

Build

To compile all typescript and bundle the CLI, run:

bun run build

Check

Type-check and lint (with oxfmt):

bun check

Test

All tests are in ./alchemy/test/* or ./alchemy/test/{providerName}/* and we use Vitest as our test runner.

Caution

Each provider requires its own credentials, and there are roughly 22 of them. The authoritative list can be found in the Repository Stack.

When making a change, you can just run the relevant tests instead of setting up every secret.

E.g. to run the Cloudflare tests, you'll need to configure the following environment variables

  • CLOUDFLARE_ACCOUNT_ID
  • CLOUDFLARE_API_KEY
  • CLOUDFLARE_EMAIL

And then run the tests:

bun vitest run ./alchemy/test/cloudflare

Or run a specific test suite:

bun vitest run ./alchemy/test/cloudflare/worker.test.ts

Or run a specific test:

bun vitest run ./alchemy/test/cloudflare/worker.test.ts -t "create, update, and delete worker (ESM format)"

Writing Providers

Alchemy is a TypeScript-native Infrastructure-as-Code repository where your job is to implement "Resource" providers for various cloud services following strict conventions and patterns.

Provider Structure

Each provider follows a consistent directory layout:

alchemy/
  src/
    {provider}/
      README.md
      {resource}.ts
  test/
    {provider}/
      {resource}.test.ts
alchemy-web/
  guides/
    {provider}.md # guide on how to get started with the {provider}
  docs/
    providers/
      {provider}/
        index.md # overview of usage and link to all the resources for the provider
        {resource}.md # example-oriented reference docs for the resource
examples/
  {provider}-{qualifier?}/ # only add a qualifier if there are more than one example for this {provider}
    package.json
    tsconfig.json
    alchemy.run.ts
    README.md
    src/
      # source code

Note: Each Resource has one .ts file, one test suite, and one documentation page.

Creating a New Resource

1. Define Resource Interfaces

Create your resource file in alchemy/src/{provider}/{resource}.ts:

import { Context } from "../context.ts";

export interface {Resource}Props {
    // input props with JSDoc comments
}

export interface {Resource} extends Resource<"{provider}::{resource}"> {
    // output props including input props plus additional fields like id, createdAt
}

2. Implement the Resource

Use the pseudo-class pattern with proper Context typing:

/**
 * {overview}
 *
 * @example
 * ## {Example Title}
 *
 * {concise description}
 *
 * {example snippet}
 */
export const {Resource} = Resource(
  "{provider}::{resource}",
  async function (this: Context<>, id: string, props: {Resource}Props): Promise<{Resource}> {
    if (this.phase === "delete") {
      // Handle deletion
      if (this.output?.id) {
        // Call API to delete resource
        // Log errors but don't throw on 404s
      }
      return this.destroy();
    } else {
      let response;

      if (this.phase === "update" && this.output?.id) {
        // Update existing resource
        response = await api.put(/* update call */);
      } else {
        // Create new resource
        response = await api.post(/* create call */);
      }

      if (!response.ok) {
        throw new Error(`API error: ${response.statusText}`);
      }

      const data = await response.json();
      return {
        id: data.id,
        ...props,
        // other computed properties
      };
    }
  }
);

3. Key Resource Implementation Guidelines

  • Validate immutable properties during updates
  • Use this.phase to determine operation type ("create", "update", "delete")
  • Return this.destroy() for deletion
  • Return {...} for creation/update with all required properties
  • Check response status directly instead of relying on exceptions
  • Handle 404s gracefully during deletion

4. Advanced Resource Patterns

Input Normalization with Wrapper Functions

When resources accept multiple flexible input types (e.g., string | Secret, string | Resource), use a public wrapper function to normalize inputs before passing to the internal Resource.

Note: This pattern is only needed when your Props interface has properties with union types that require normalization. If all props accept single types, skip this pattern and use the Resource directly.

// Public interface - accepts flexible types
export function MyResource(id: string, props: MyResourceProps): Promise<MyResource> {
  return _MyResource(id, {
    ...props,
    secret: typeof props.secret === "string" 
      ? secret(props.secret) 
      : props.secret,
    database: typeof props.database === "string"
      ? props.database
      : props.database
  });
}

// Internal implementation - guaranteed normalized types
const _MyResource = Resource(
  "provider::MyResource",
  async function (
    this: Context<MyResource>,
    id: string,
    props: Omit<MyResourceProps, "secret"> & { secret: Secret }
  ): Promise<MyResource> {
    // Implementation with guaranteed types
  }
);

This pattern enables:

  • Flexible API for users (accepts string or Resource/Secret)
  • Type-safe implementation (guaranteed normalized types)
  • Clear separation of concerns
Type Guard Functions (Required)

Every resource MUST export a type guard function using ResourceKind:

import { ResourceKind } from "../resource.ts";

export function isMyResource(resource: any): resource is MyResource {
  return resource?.[ResourceKind] === "provider::MyResource";
}

Use in conditional logic:

function processBinding(binding: any) {
  if (isMyResource(binding)) {
    // TypeScript now knows this is MyResource
    console.log(binding.id);
  }
}
Output Type Pattern with Omit

Prefer Omit over extends for output types to cleanly separate input and computed properties:

// ✅ PREFERRED: Clear separation of input vs output
export type MyResource = Omit<MyResourceProps, "delete" | "secret"> & {
  /**
   * The ID assigned by the provider
   */
  id: string;

  /**
   * Secret value (guaranteed wrapped)
   */
  secret: Secret;

  /**
   * Resource type identifier
   */
  type: "my-resource";

  /**
   * Creation timestamp
   */
  createdAt: number;
};

// ❌ AVOID: Mixing input and output concerns
export interface MyResource extends MyResourceProps {
  id: string;
  // Input props are now part of the output type
}
Physical Name Generation with Scope

Use the scope to generate deterministic physical names with defaults:

const name = props.name 
  ?? this.output?.name  // Preserve on update
  ?? this.scope.createPhysicalName(id);  // Default: {app}-{stage}-{id}
Resource Replacement for Immutable Properties

When an immutable property changes, signal replacement via this.replace():

if (this.phase === "update" && this.output.name !== name) {
  return this.replace(); // Deletes old, creates new
}
Conditional Deletion Pattern

Support opt-out deletion with a delete?: boolean property.

Note: This pattern is typically used for data resources only (databases, storage buckets, key-value stores, etc.). Compute resources (workers, functions, containers) should always be deleted when removed from Alchemy without an opt-out option.

export interface MyResourceProps {
  /**
   * Whether to delete the resource when removed from Alchemy
   * @default true
   */
  delete?: boolean;
}

if (this.phase === "delete") {
  if (props.delete !== false && this.output?.id) {
    try {
      await api.delete(`/resources/${this.output.id}`);
    } catch (error) {
      if (error.status !== 404) throw error; 
    }
  }
  return this.destroy();
}
Internal API Types Convention

Mark internal types used only for API serialization with JSDoc @internal:

/**
 * Raw provider API response for resource creation
 * @internal
 */
interface ResourceApiResponse {
  id: string;
  name: string;
  created_at: number;
  // API field names may differ from output
}
Retry Patterns with Exponential Backoff

Use exponential backoff for transient errors:

import { withExponentialBackoff } from "../util/retry.ts";

const result = await withExponentialBackoff(
  async () => {
    return await extractProviderResult<ApiResponse>(
      `create resource "${name}"`,
      api.post("/resources", requestBody)
    );
  },
  (error) => {
    // Retry condition: specific transient errors
    return error.code === 1002 || error instanceof TimeoutError;
  },
  30,    // maximum attempts
  100,   // initial delay in ms
);

5. Resource Property References

When designing input props, if you have a property that references another entity by ID (e.g., tableId, bucketArn), instead use:

// ✅ Good: Lift the Resource into alchemy abstraction
table: string | Table

// ❌ Avoid: Raw ID references
tableId: string

This allows referencing both external entities by name and alchemy resources directly.

Testing Resources

Test Structure

Create comprehensive end-to-end tests in alchemy/test/{provider}/{resource}.test.ts:

import { describe, expect } from "vitest";
import { destroy } from "../../src/destroy.ts"
import { BRANCH_PREFIX } from "../util.ts";
import "../../src/test/vitest.ts";

const test = alchemy.test(import.meta, {
  prefix: BRANCH_PREFIX,
});

describe("{Provider}", () => {
  test("{test case}", async (scope) => {
    const resourceId = `${BRANCH_PREFIX}-{id}` // deterministic, unique ID
    let resource: {Resource}
    try {
      // CREATE
      resource = await {Resource}("{id}", {
        // props
      })

      expect(resource).toMatchObject({
        // assertions
      })

      // UPDATE
      resource = await {Resource}("{id}", {
        // updated props
      })

      expect(resource).toMatchObject({
        // updated assertions
      })
    } finally {
      await destroy(scope);
      await assert{Resource}DoesNotExist(resource)
    }
  })
});

async function assert{Resource}DoesNotExist(resource: {Resource}) {
    // Call API to verify resource no longer exists
    // Throw test error if it still exists
}

Testing Best Practices

  1. Always use try-finally: Ensure cleanup happens even if assertions fail
  2. Destroy scope in finally: Call destroy(scope) to clean up all resources
  3. Make tests idempotent: Use deterministic, non-random IDs so failed tests can be re-run
  4. Test create, update, delete: Cover the full resource lifecycle
  5. Test failed cases: Include negative test cases for error conditions
  6. Use direct API verification: Verify changes using the provider's API client
  7. Use BRANCH_PREFIX: Creates unique test resource names across all tests

Test Naming

  • Use BRANCH_PREFIX for deterministic, non-colliding resource names
  • Pattern: ${BRANCH_PREFIX}-test-resource-type
  • Keep names consistent and descriptive

API Design Principles

When implementing resources that interact with external APIs:

  1. Minimal abstraction: Use thin wrappers around fetch rather than complex SDK clients
  2. Explicit path construction: Build API paths explicitly at call sites
  3. Direct HTTP status handling: Check response status codes directly
  4. Explicit JSON parsing: Parse JSON responses explicitly where needed
  5. Public properties: Expose properties like api.accountId publicly
  6. Minimal error transformation: Preserve original error details

Documentation Requirements

Provider README.md

Provide comprehensive documentation of all Resources for the provider with relevant links. This serves as design and internal documentation.

Resource Documentation

Each resource requires:

  • Examples: Multiple @example blocks showing distinct use cases
  • JSDoc comments: For all properties and interfaces
  • Clear descriptions: Of what the resource does and when to use it

Provider Guide

Create a getting started guide in ./alchemy-web/docs/guides/{provider}.md that walks users through:

  • Installation and setup
  • Credential configuration
  • Creating their first resource
  • Deploying and testing
  • Cleanup/teardown

Before Committing

Always run these commands before committing:

# Fix code formatting and linting
bun format

# Run tests (targets changed files vs main)
bun run test

# Or run specific tests during development
bun vitest ./alchemy/test/... -t "..."