---
description: Rules for declaring backend endpoints to be called, useful for integration testing of deployed/localhost services and for automation.
alwaysApply: false
---

# Cascade Module Documentation

## Overview

Cascade is a declarative endpoint execution module that allows chaining API calls with state management, environment configs, authentication, and error handling. It enables automation of complex workflows by declaring endpoints, methods, and data transformations in a structured format.

## Key Concepts

### Environment Configuration

Each project using Cascade needs an environment configuration file (e.g., `cascade.env.js`) that defines:
- **Services**: Microservices with their base URIs and optional default auth
- **Authentication**: Login endpoints, token/user extraction paths, and user credentials
- **Config**: Timeout, retry settings, etc.

### Datasets

Datasets are JavaScript files that export a default function returning an object with a `cascade` array. Each command in the cascade can:
- Call API endpoints
- Import other datasets (with parameters)
- Save responses to state
- Handle errors gracefully

### State Management

State is passed through execution and contains:
- `users`: Authenticated user objects
- `params`: Parameters from imports
- `saved`: Explicitly saved response data (via `saveAs`)

## Usage

### CLI Command

```bash
glint cascade --dataset ./datasets/my-flow.js --env ./cascade.env.js
```

Options:
- `--dataset, -d`: Path to dataset file or directory (required)
- `--env, -e`: Path to environment config (required)
- `--option, -o`: Pass options as key=value (repeatable)
- `--log-level, -l`: Log level (error, info, debug)
- `--dry-run`: Print commands without executing

### Programmatic API

```javascript
import { execute, loadEnvironment } from "glint-js-kit/cascade";

const env = await loadEnvironment("./cascade.env.js");
const state = await execute("./datasets/my-flow.js", env, {
  logLevel: "info",
  options: { email: "test@example.com" }
});
```

## Environment Configuration Format

```javascript
import dotenv from "dotenv";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Load environment variables from .env file
dotenv.config({ path: join(__dirname, "../.env.localhost") });

export default {
  name: "localhost",
  services: {
    main: {
      baseUri: "http://localhost:8080",
      defaultAuth: "authorityUser"  // Optional service-level default auth
    }
  },
  authentication: {
    service: "main",               // Default service for auth
    defaultAuth: "authorityUser",  // Global default auth (lowest priority)
    users: {
      // Option 1: Direct credentials
      testUser: {
        username: "myuser",
        password: "mypass"
      },
      // Option 2: Environment variables
      authorityUser: {
        usernameEnvKey: "CASCADE_AUTHORITY_USERNAME",
        passwordEnvKey: "CASCADE_AUTHORITY_PASSWORD"
      },
      // Option 3: Mixed (direct username, env password)
      mixedUser: {
        username: "myuser",
        passwordEnvKey: "API_PASSWORD"
      }
    }
  },
  config: {
    timeout: 30000
  }
};
```

### Authentication Credential Options

Cascade supports three ways to provide user credentials:

**Option 1: Direct credentials in env file**
```javascript
users: {
  testUser: {
    username: "myuser",
    password: "mypass"
  }
}
```

**Option 2: Environment variables (existing behavior)**
```javascript
users: {
  authorityUser: {
    usernameEnvKey: "CASCADE_AUTHORITY_USERNAME",
    passwordEnvKey: "CASCADE_AUTHORITY_PASSWORD"
  }
}
```

**Option 3: Mixed (direct username, env password)**
```javascript
users: {
  mixedUser: {
    username: "myuser",
    passwordEnvKey: "API_PASSWORD"
  }
}
```

### Environment Variable Naming Convention

- Use role-based names, not environment-specific: `CASCADE_AUTHORITY_USERNAME` instead of `CASCADE_LOCALHOST_USERNAME`
- The environment is obvious from the filename (e.g., `localhost.env.js`)
- Format: `CASCADE_{ROLE}_{KEY}` (e.g., `CASCADE_AUTHORITY_USERNAME`, `CASCADE_AUTHORITY_PASSWORD`)
- Store credentials in `.env.{environment}` files (gitignored)
- Create `.env.{environment}.example` files as templates (committed to git)
- For direct credentials, you can hardcode them in the env file (useful for test users)

## Dataset Format

```javascript
// Simple dataset without state or options
export default function () {
  return {
    cascade: [
      {
        endpoint: "/user/create",
        service: "main",
        dtoIn: { email: "test@example.com" },
        saveAs: "newUser"
      }
    ]
  };
}

// Dataset using state to conditionally generate commands
export default function (_env, state) {
  const commands = [];
  for (const user of state.saved?.userList?.users || []) {
    commands.push({
      endpoint: "/user/activate",
      service: "main",
      dtoIn: { userId: user._id },
      saveAs: `activatedUser_${user._id}`
    });
  }
  return { cascade: commands };
}

// Dataset with function-based dtoIn using state
export default function () {
  return {
    cascade: [
      {
        endpoint: "/race/list",
        service: "main",
        method: "GET",
        dtoIn: {},
        saveAs: "raceList"
      },
      {
        endpoint: "/registration/list",
        service: "main",
        method: "GET",
        dtoIn: (state) => {
          // Access previously saved data from state.saved
          const races = state.saved?.raceList?.races || [];
          const firstRace = races[0];
          return firstRace ? { raceId: firstRace._id } : null;
        },
        saveAs: "firstRaceRegistrations"
      }
    ]
  };
}

// Dataset with options (when needed)
export default function (_env, state, options = {}) {
  return {
    cascade: [
      {
        endpoint: "/user/create",
        service: "main",
        dtoIn: { email: options.email || "default@example.com" },
        saveAs: "newUser"
      },
      {
        import: "./common/send-email.js",
        params: {
          subject: "Welcome!",
          body: (state) => `Hello ${state.saved.newUser.name}!`
        }
      }
    ]
  };
}
```

### Function Signature Guidelines

- **No parameters**: Use `function ()` when you don't need `env`, `state`, or `options`
- **State only**: Use `function (_env, state)` when you need to access `state.saved` to conditionally generate commands
- **With options**: Use `function (_env, state, options = {})` only when you need to accept options from CLI or imports
- **Remove unused parameters**: Never include `options = {}` if not used, and prefix unused parameters with `_` (e.g., `_env`)

## Command Schema

| Property | Type | Required | Description |
|---------|------|----------|-------------|
| `endpoint` | string | Yes* | API endpoint path |
| `service` | string | Yes* | Service name from env config |
| `auth` | string | No | User key (overrides defaultAuth) |
| `method` | string | No | HTTP method (default: POST). Omit for POST requests |
| `dtoIn` | object/function | No | Request body or function `(state) => object` that receives state |
| `saveAs` | string | No | Key to save response.data under in state |
| `registerAs` | string/object | No | Register dtoIn credentials as dynamic user for subsequent auth |
| `allowedErrorCodes` | string[] | No | Array of error codes to allow |
| `allowedError` | function | No | Custom `(error, response) => boolean` |
| `expect` | object | No | Assertions for successful responses (dot-notation paths) |
| `expectError` | object | No | Assertions for error responses (dot-notation paths, auto-allows error) |
| `import` | string | Yes* | Relative path to dataset file or directory |
| `params` | object | No | Parameters passed to imported dataset |

*Either `endpoint`+`service` OR `import` is required.

## Assertions

Cascade supports declarative assertions using Chai matchers (similar to Jest). Assertions are defined directly in dataset files and run automatically when executing commands. No test runner required - assertions work in CLI, scripts, or any Node.js environment.

### Success Assertions (`expect`)

Use `expect` to validate successful responses (2xx status codes):

```javascript
{
  endpoint: "/user/create",
  service: "main",
  dtoIn: { name: "Test" },
  expect: {
    status: 200,
    "data.id": { toBeGreaterThan: 0 },
    "data.name": "Created User",                    // Direct value = toEqual
    "data.email": { toMatch: "@example" },
    "data.createdAt": { toBeDefined: true },
    "data.id": { $any: Number }                     // Asymmetric matcher
  }
}
```

### Error Assertions (`expectError`)

Use `expectError` to validate error responses (4xx/5xx). This automatically allows the error (no need for `allowedErrorCodes`):

```javascript
{
  endpoint: "/user/delete",
  service: "main",
  dtoIn: { id: 999 },
  expectError: {
    status: 404,
    "data.code": "notFound",
    "data.message": { toContain: "not found" }
  }
}
```

### Supported Matchers

- **Direct values**: `"data.name": "Test"` - Deep equality (toEqual)
- **toBe**: `{ toBe: value }` - Strict equality (===)
- **toEqual**: `{ toEqual: value }` - Deep equality
- **toBeDefined**: `{ toBeDefined: true }`
- **toBeGreaterThan**: `{ toBeGreaterThan: n }`
- **toContain**: `{ toContain: item }` - For arrays/strings
- **toMatch**: `{ toMatch: pattern }` - Regex/string match (strings converted to RegExp)
- **toHaveProperty**: `{ toHaveProperty: "key" }` or `{ toHaveProperty: ["key", value] }`
- **toBeCloseTo**: `{ toBeCloseTo: n }` or `{ toBeCloseTo: [n, digits] }` - Default delta: 0.01
- And more - see [Chai documentation](https://www.chaijs.com/api/bdd/) for full API

### Asymmetric Matchers

- **$any**: `{ $any: Number }` - Accept any instance of constructor (Number, String, Boolean, Object, Array, Function, Date, RegExp)
- **$anything**: `{ $anything: true }` - Accept any value (checks existence)
- **$arrayContaining**: `{ $arrayContaining: [1, 2] }` - Array contains these items
- **$objectContaining**: `{ $objectContaining: { id: 1 } }` - Object contains these properties (supports nested asymmetric matchers)
- **$stringContaining**: `{ $stringContaining: "@example" }` - String contains substring
- **$stringMatching**: `{ $stringMatching: /^[A-Z]+/ }` or `{ $stringMatching: "^[A-Z]+" }` - String matches regex
- **$closeTo**: `{ $closeTo: 3.14 }` or `{ $closeTo: [3.14, 2] }` - Number close to value (default delta: 0.01)

### Negation

Use `not` modifier to negate any matcher:

```javascript
"data.status": { not: { toBe: "deleted" } }
```

### Path Notation

Use dot-notation for nested properties:

```javascript
expect: {
  status: 200,                    // response.status
  "data.user.id": 123,            // response.data.user.id
  "data.items[0].name": "First"   // response.data.items[0].name
}
```

### Combining with allowedErrorCodes

You can use both `allowedErrorCodes` and `expectError` together:

```javascript
{
  endpoint: "/error/allowed",
  service: "main",
  allowedErrorCodes: ["allowedError"],
  expectError: {
    status: 400,
    "data.code": "allowedError"
  }
}
```

## Authentication Priority

1. Command-level `auth` field (highest priority)
2. Service-level `defaultAuth` in env config
3. Global `defaultAuth` in authentication config (lowest priority)

## Dynamic User Registration

Use `registerAs` to programmatically register a user during cascade execution and use their credentials for subsequent authenticated requests. This is useful for testing user registration flows where you need to:

1. Register a new user via API
2. Authenticate with those credentials for subsequent requests

### Simple Syntax

When your dtoIn uses standard field names (`username` and `password`):

```javascript
{
  endpoint: "/user/register",
  service: "main",
  dtoIn: { username: "testuser", password: "Test123!" },
  registerAs: "newUser",
  saveAs: "registeredUser"
}
```

The simple syntax uses:
- `username` field for login username
- `password` field for login password

### Explicit Syntax

For custom field names, use an object with explicit paths:

```javascript
{
  endpoint: "/user/register",
  service: "main",
  dtoIn: { login: "testuser", secret: "Test123!" },
  registerAs: {
    userKey: "newUser",
    usernamePath: "login",
    passwordPath: "secret"
  }
}
```

### Using Registered Users

After registration, use the dynamic user in subsequent commands:

```javascript
{
  endpoint: "/protected/resource",
  service: "main",
  auth: "newUser"  // Uses dynamically registered credentials
}
```

### Complete Example

```javascript
export default function () {
  return {
    cascade: [
      // Step 1: Register a new user
      {
        endpoint: "/user/register",
        service: "main",
        dtoIn: {
          username: "newuser",
          password: "SecurePass123!",
          name: "New User"
        },
        registerAs: "newUser",
        saveAs: "registeredUser",
        expect: {
          status: 200,
          "data.id": { $any: Number }
        }
      },
      // Step 2: Call protected endpoint as the new user
      {
        endpoint: "/user/profile",
        service: "main",
        method: "GET",
        auth: "newUser",  // Authenticates with registered credentials
        expect: {
          status: 200,
          "data.username": "newuser"
        }
      },
      // Step 3: Update the user's profile
      {
        endpoint: "/user/profile",
        service: "main",
        auth: "newUser",
        dtoIn: { bio: "Hello world!" },
        expect: {
          status: 200
        }
      }
    ]
  };
}
```

### Notes

- `registerAs` only works on successful responses (2xx status codes)
- Credentials are extracted from the resolved `dtoIn`, not the response
- Dynamic users are stored in memory and cleared between cascade executions
- The registered user uses the same service as the registration endpoint for login

## Import Parameters

When importing datasets, use the `params` property to pass data:

```javascript
{
  import: "./common/email.js",
  params: {
    subject: "Hello",
    body: (state) => `User ID: ${state.saved.userId}`
  }
}
```

Imported datasets access params via `state.params`.

## Directory Support

Both dataset paths and imports can point to directories. All `.js` files in the directory are loaded and executed alphabetically.

## Error Handling

By default, any 4xx/5xx response stops execution. To allow specific errors:

```javascript
{
  endpoint: "/user/activate",
  service: "main",
  allowedErrorCodes: ["glint-js/userNotFound"],  // Use full error code format
  allowedError: (error, response) => {
    // Only use allowedError for complex conditions
    // Prefer allowedErrorCodes for simple notFound errors
    return response?.data?.code === "glint-js/verificationPending";
  }
}
```

### Error Handling Best Practices

- **Only skip notFound errors**: Use `allowedErrorCodes: ["glint-js/entityNotFound"]` for expected not-found scenarios
- **Don't skip all 400 errors**: A 400 status can indicate validation errors or business logic errors that should fail
- **Use allowedError sparingly**: Only for complex conditions that can't be expressed with error codes
- **Error code format**: Use full error codes like `"glint-js/userNotFound"` instead of just `"userNotFound"`

## Logging

Cascade uses Winston for logging. Levels:
- `error`: Only errors
- `info`: Progress updates (default)
- `debug`: Detailed request/response info

## State-Based Command Generation

You can use `state.saved` to conditionally generate commands based on previous responses:

```javascript
export default function (_env, state) {
  const commands = [];
  
  // Generate commands for each item in saved state
  for (const race of state.saved?.raceList?.races || []) {
    commands.push({
      endpoint: "/registration/list",
      service: "main",
      method: "GET",
      dtoIn: { raceId: race._id },
      saveAs: `raceRegistrations_${race._id}`
    });
  }
  
  return { cascade: commands };
}
```

This pattern is more efficient than generating fixed-size arrays and returning `null` from `dtoIn` functions.

## Function-Based dtoIn

You can use a function for `dtoIn` that receives the current state. This is useful when you need to reference previously saved data:

```javascript
export default function () {
  return {
    cascade: [
      {
        endpoint: "/race/list",
        service: "main",
        method: "GET",
        dtoIn: {},
        saveAs: "raceList"
      },
      {
        endpoint: "/registration/list",
        service: "main",
        method: "GET",
        dtoIn: (state) => {
          // Access previously saved data from state
          const races = state.saved?.raceList?.races || [];
          const firstRace = races[0];
          return firstRace ? { raceId: firstRace._id } : null;
        },
        saveAs: "firstRaceRegistrations"
      },
      {
        endpoint: "/user/get",
        service: "main",
        method: "GET",
        dtoIn: (state) => {
          // Use data from any previously saved response
          return { _id: state.saved?.raceList?.races?.[0]?.userId };
        }
      }
    ]
  };
}
```

**Note**: When using function-based `dtoIn`, the function receives the current state object which contains:
- `state.saved` - All responses saved via `saveAs`
- `state.users` - Authenticated user objects
- `state.params` - Parameters passed from imports

## Modular Dataset Structure

Split complex operations into separate files for better organization:

```
datasets/
├── wipe/
│   ├── wipe-registrations.js    # Main orchestrator
│   ├── wipe-races.js            # Main orchestrator
│   └── tools/
│       ├── list-races.js        # Lists races
│       ├── list-registrations.js # Lists registrations per race
│       ├── delete-registrations.js # Deletes registrations
│       └── delete-races.js     # Deletes races
```

Each tool file handles one responsibility and can be imported independently.

## Common Helpers

Extract reusable logic to helper files:

```javascript
// datasets/common/race-helpers.js
export function generateRaceDates(options = {}) {
  // Common date calculation logic
}

export function generateStandardCategories() {
  // Common category generation
}
```

Import helpers in your datasets:

```javascript
import { generateRaceDates, generateStandardCategories } from "../common/race-helpers.js";
```

## Best Practices

1. **Keep datasets focused and reusable** - Each file should do one thing well
2. **Use imports for common workflows** - Import directories or specific files
3. **Store credentials in `.env` files** - Use dotenv to load from `.env.{environment}` files (gitignored)
4. **Use `saveAs` sparingly** - Only save what's needed for subsequent commands
5. **Leverage state.saved for conditional generation** - Generate commands based on actual data, not fixed arrays
6. **Remove unused parameters** - Don't include `options = {}` or parameters you don't use
7. **Omit default method** - Don't specify `method: "POST"` as it's the default
8. **Use role-based env var names** - `CASCADE_AUTHORITY_USERNAME` not `CASCADE_LOCALHOST_USERNAME`
9. **Extract common logic** - Create helper files for shared functionality
10. **Modular structure** - Split complex operations into separate tool files
11. **Error handling** - Only skip notFound errors, not all 400 errors
