diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7905de2cc..d3d5df325 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ # $ cd /PATH/TO/REPO # $ pre-commit install -exclude: '^telemetry/ui|^burr/tracking/server/demo_data(/|$)' +exclude: '^telemetry/ui|^typescript/|^burr/tracking/server/demo_data(/|$)' repos: - repo: https://github.com/ambv/black rev: 23.11.0 @@ -56,6 +56,26 @@ repos: rev: 6.1.0 hooks: - id: flake8 + # ESLint for TypeScript + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.56.0 + hooks: + - id: eslint + files: ^typescript/.*\.[jt]sx?$ + types: [file] + args: ['--fix'] + additional_dependencies: + - eslint@8.56.0 + - '@typescript-eslint/parser@6.21.0' + - '@typescript-eslint/eslint-plugin@6.21.0' + - typescript@5.3.3 + # Prettier for TypeScript + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + files: ^typescript/.*\.(ts|tsx|js|jsx|json|md)$ + args: ['--write'] - repo: local # This is a bit of a hack, but its the easiest way to get it to all run together # https://stackoverflow.com/questions/64001471/pylint-with-pre-commit-and-esllint-with-husky diff --git a/typescript/.eslintrc.json b/typescript/.eslintrc.json new file mode 100644 index 000000000..c06b1c01f --- /dev/null +++ b/typescript/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "plugins": ["@typescript-eslint"], + "env": { + "node": true, + "es6": true + }, + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "rules": { + "@typescript-eslint/explicit-function-return-type": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + } +} + diff --git a/typescript/.gitignore b/typescript/.gitignore new file mode 100644 index 000000000..895f3e432 --- /dev/null +++ b/typescript/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Testing +coverage/ +.nyc_output/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.*.local + diff --git a/typescript/.prettierrc.json b/typescript/.prettierrc.json new file mode 100644 index 000000000..053c69d3d --- /dev/null +++ b/typescript/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} + diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 000000000..5c114a3f8 --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,247 @@ + + +# Apache Burr (TypeScript) + +TypeScript implementation of Apache Burr - a framework for building applications that make decisions (chatbots, agents, simulations, etc.) from simple building blocks. + +## Status + +🚧 **Work in Progress** - This is an active port of the Python implementation. APIs may change. + +## Structure + +- `packages/burr-core/` - Core library (state machine, actions, application) +- `examples/` - TypeScript examples +- `tests/` - Integration tests + +## Getting Started + +```bash +# Install dependencies +npm install + +# Build all packages +npm run build + +# Run tests +npm test +``` + +## Documentation + +See the main [Burr documentation](https://burr.apache.org/) for concepts and guides. TypeScript-specific documentation coming soon. + +## Compatibility + +This implementation aims to match the Python version's core functionality with TypeScript idioms and best practices. + +## Feature Parity + +### State API + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| `State()` constructor | βœ… | βœ… | | +| `state.get(key)` | βœ… | βœ… | TS throws on missing key; Python returns None | +| `state.get(key, default)` | βœ… | ❌ | Python supports default values | +| `state["key"]` access | βœ… | ❌ | Python dict syntax; TS uses `get()` | +| `state.has(key)` / `key in state` | βœ… | βœ… | | +| `state.keys()` | βœ… | βœ… | | +| `state.getAll()` | βœ… | βœ… | | +| `state.update(**kwargs)` | βœ… | βœ… | Python uses kwargs; TS uses object | +| `state.append(key=val)` | βœ… | βœ… | Python: multiple keys; TS: single key | +| `state.extend(key=vals)` | βœ… | βœ… | Python: multiple keys; TS: single key | +| `state.increment(key=delta)` | βœ… | βœ… | Python: multiple keys; TS: single key | +| `state.subset(*keys)` | βœ… | βœ… | TS version is strict (throws on missing keys) | +| `state.merge(other)` | βœ… | βœ… | | +| `state.wipe(delete/keep)` | βœ… | ❌ | Delete operations not yet implemented | +| `state.serialize()` | βœ… | βœ… | Basic JSON serialization | +| `state.deserialize()` | βœ… | βœ… | Basic JSON deserialization | +| Custom field serialization | βœ… | ❌ | `register_field_serde()` not implemented | +| Typing system | βœ… | ❌ | Python has pluggable typing; TS uses generics | +| Type safety | ❌ | βœ… | TS has compile-time type checking | + +### Actions + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| `@action` decorator | βœ… | ❌ | TS uses `action()` function instead | +| `Action` class | βœ… | βœ… | | +| `action()` helper function | βœ… | βœ… | Primary way to create actions in TS | +| `reads` / `writes` specification | βœ… | βœ… | Uses Zod schemas in TS | +| `inputs` specification | βœ… | βœ… | Uses Zod schemas in TS | +| Sync actions | βœ… | ❌ | TS is async-only | +| Async actions | βœ… | βœ… | All TS actions are async | +| Streaming actions | βœ… | ❌ | Not yet implemented | +| Action validation (inputs/reads/writes) | βœ… | βœ… | Runtime validation with Zod | +| `result` type specification | βœ… | βœ… | Uses Zod schemas in TS | +| Separate run/update phases | βœ… | βœ… | | +| Single-step actions | βœ… | ❌ | TS requires separate run/update | + +### Application + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| `ApplicationBuilder` | βœ… | βœ… | | +| `Application.step()` | βœ… | βœ… | Async only in TS | +| `Application.run()` | βœ… | βœ… | Async only in TS | +| `Application.iterate()` | βœ… | βœ… | Async generator in TS | +| `Application.astep()` | βœ… | ❌ | TS step() is always async | +| `Application.arun()` | βœ… | ❌ | TS run() is always async | +| `Application.aiterate()` | βœ… | ❌ | TS iterate() is always async | +| Initial state | βœ… | βœ… | | +| Entrypoint specification | βœ… | βœ… | | +| Halt conditions (before/after) | βœ… | βœ… | `haltBefore` / `haltAfter` | +| Application state access | βœ… | βœ… | `app.state` property | +| Initial state access | ❌ | ❌ | Removed for Python parity | +| Application ID | βœ… | βœ… | `uid` in Python, `appId` in TS | +| Partition key | βœ… | βœ… | | +| Sequence ID access | βœ… | βœ… | Stored in `state.executionMetadata.sequenceId` | +| Forkβ†’Launchβ†’Gatherβ†’Commit pattern | ❌ | βœ… | TS uses 4-phase execution with defense-in-depth validation | +| Framework metadata in state | βœ… | βœ… | TS: `appMetadata`/`executionMetadata`, Python: `__*` fields | +| Application context | βœ… | ❌ | Not yet implemented | +| `has_next_action()` | βœ… | ❌ | Not yet implemented | +| `get_next_action()` | βœ… | ❌ | Internal in TS | +| `update_state()` | βœ… | ❌ | Not yet implemented | +| `reset_to_entrypoint()` | βœ… | ❌ | Not yet implemented | +| Streaming actions | βœ… | ❌ | Not yet implemented | +| `visualize()` | βœ… | ❌ | Not yet implemented | +| Parent/spawning pointers | βœ… | ❌ | Not yet implemented | + +### Graph + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| `Graph` class | βœ… | βœ… | | +| `GraphBuilder` | βœ… | βœ… | | +| Transitions (unconditional) | βœ… | βœ… | | +| Conditional transitions | βœ… | βœ… | Function-based conditions | +| Default/fallback transitions | βœ… | βœ… | | +| Action tags | βœ… | ❌ | Not yet implemented | +| Graph validation | βœ… | ❌ | Not yet implemented | +| Cycle detection | βœ… | ❌ | Not yet implemented | +| Graph visualization | βœ… | ❌ | Not yet implemented | +| `getTransitionsFrom()` | βœ… | βœ… | | +| `getAction()` | βœ… | βœ… | | +| `hasAction()` | βœ… | βœ… | | + +### Persistence + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| `Persister` interface | βœ… | ❌ | Not yet implemented | +| In-memory persister | βœ… | ❌ | Not yet implemented | +| File-based persister | βœ… | ❌ | Not yet implemented | +| SQLite persister | βœ… | ❌ | Not yet implemented | +| PostgreSQL persister | βœ… | ❌ | Not yet implemented | +| Redis persister | βœ… | ❌ | Not yet implemented | +| MongoDB persister | βœ… | ❌ | Not yet implemented | +| Custom persisters | βœ… | ❌ | Not yet implemented | +| State snapshots | βœ… | ❌ | Not yet implemented | +| State history | βœ… | ❌ | Not yet implemented | + +### Lifecycle & Hooks + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| Lifecycle hooks interface | βœ… | ❌ | Not yet implemented | +| Pre-run hooks | βœ… | ❌ | Not yet implemented | +| Post-run hooks | βœ… | ❌ | Not yet implemented | +| Pre-action hooks | βœ… | ❌ | Not yet implemented | +| Post-action hooks | βœ… | ❌ | Not yet implemented | +| Error hooks | βœ… | ❌ | Not yet implemented | +| Multiple hooks composition | βœ… | ❌ | Not yet implemented | + +### Tracking & Observability + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| Tracking client | βœ… | ❌ | Not yet implemented | +| Local tracking | βœ… | ❌ | Not yet implemented | +| Remote tracking | βœ… | ❌ | Not yet implemented | +| S3 tracking | βœ… | ❌ | Not yet implemented | +| Tracing/spans | βœ… | ❌ | Not yet implemented | +| OpenTelemetry integration | βœ… | ❌ | Not yet implemented | + +### Integrations + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| Hamilton integration | βœ… | ❌ | Not yet implemented | +| LangChain integration | βœ… | ❌ | Not yet implemented | +| Haystack integration | βœ… | ❌ | Not yet implemented | +| Pydantic integration | βœ… | ❌ | Not yet implemented | +| Streamlit integration | βœ… | ❌ | Not yet implemented | +| Ray integration | βœ… | ❌ | Not yet implemented | +| Custom integrations | βœ… | ❌ | Not yet implemented | + +### Core Abstractions + +| Feature | Python | TypeScript | Notes | +|---------|--------|------------|-------| +| Operation/StateDelta pattern | βœ… | βœ… | Implemented for state mutations | +| Immutable state | βœ… | βœ… | | +| Copy-on-write optimization | βœ… | βœ… | Uses `structuredClone` | +| Generic type support | ❌ | βœ… | TypeScript generics provide type safety | +| Serializable operations | βœ… | βœ… | Operations can be serialized to JSON | +| Async-first design | ❌ | βœ… | All TS actions/execution is async | +| Schema validation (Zod) | ❌ | βœ… | TS uses Zod for runtime validation | +| Framework metadata in state | βœ… | βœ… | `appMetadata` / `executionMetadata` | + +### Legend +- βœ… **Implemented** - Feature is available and tested +- 🚧 **Partial** - Feature is partially implemented or in progress +- ❌ **Not Implemented** - Feature not yet available + +### Implementation Priority + +**Phase 1 (βœ… COMPLETED):** +- βœ… State API core operations +- βœ… State immutability & operations (update, append, extend, increment, subset) +- βœ… Strict subset validation (throws on missing keys) +- βœ… Basic serialization +- βœ… Actions with Zod validation +- βœ… Application & ApplicationBuilder +- βœ… Graph & transitions +- βœ… Execution engine (step/run/iterate) +- βœ… Forkβ†’Launchβ†’Gatherβ†’Commit execution pattern +- βœ… Defense-in-depth validation +- βœ… Framework metadata (appMetadata/executionMetadata) +- βœ… Halt conditions (haltBefore/haltAfter) +- βœ… Error propagation with context + +**Phase 2 (Current - Core Extensions):** +- Streaming actions +- Lifecycle hooks (pre/post action) +- Application context (dependency injection) +- Graph validation & cycle detection + +**Phase 3 (Future - Developer Experience):** +- Action tags +- Helper methods (reset_to_entrypoint, has_next_action, etc.) +- Graph visualization +- Better error messages + +**Phase 4 (Long Term - Production Features):** +- Persistence adapters +- Tracking & observability +- Parent/spawning pointers +- Integrations (LangChain, etc.) + diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 000000000..94e036c2f --- /dev/null +++ b/typescript/package.json @@ -0,0 +1,33 @@ +{ + "name": "@apache-burr/workspace", + "version": "0.1.0", + "private": true, + "description": "Apache Burr TypeScript implementation workspace", + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "npm run build --workspaces", + "test": "npm run test --workspaces", + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write \"**/*.{ts,tsx,json,md}\"" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.0.0", + "prettier": "^3.0.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/apache/burr.git", + "directory": "typescript" + } +} + diff --git a/typescript/packages/burr-core/README.md b/typescript/packages/burr-core/README.md new file mode 100644 index 000000000..8fc0c3203 --- /dev/null +++ b/typescript/packages/burr-core/README.md @@ -0,0 +1,310 @@ + + +# @apache-burr/core + +Core TypeScript library for Apache Burr - build state machines with simple functions. + +## Status + +🚧 **Active Development** - Core APIs implemented, execution engine coming soon. + +### Implemented +- βœ… State management with immutability & event sourcing +- βœ… Actions (two-step: run + update) +- βœ… Graph builder with type-safe transitions +- βœ… Application builder with hybrid typing & state validation +- βœ… Compile-time type safety with Zod +- βœ… Graph-state compatibility validation at compile-time + +### Not Yet Implemented +- ⏳ Execution engine (run, step, stream) +- ⏳ Persistence +- ⏳ Lifecycle hooks +- ⏳ Telemetry & tracking +- ⏳ Streaming actions + +## Installation + +```bash +npm install @apache-burr/core zod +``` + +## Quick Start + +```typescript +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState, createStateWithDefaults } from '@apache-burr/core'; + +// 1. Define actions +const increment = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) +}); + +const reset = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: () => createState( + z.object({ count: z.number() }), + { count: 0 } + ) +}); + +// 2. Build graph +const graph = new GraphBuilder() + .withActions({ increment, reset }) + .withTransitions( + ['increment', 'increment', (state) => state.count < 10], + ['increment', 'reset', (state) => state.count >= 10] + ) + .build(); + +// 3. Build application (safe mode - explicit data) +const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('increment') + .withState(createState( + z.object({ count: z.number() }), + { count: 0 } + )) + .build(); + +// 3b. Or use power-user mode with Zod defaults +const appWithDefaults = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('increment') + .withState(createStateWithDefaults( + z.object({ count: z.number().default(0) }) + // No data param needed! Zod fills defaults at runtime + )) + .build(); + +// ❌ This would fail at compile-time: +// .withState(createState(z.object({ wrong: z.string() }), { wrong: 'oops' })) +// Error: State is missing required fields from graph + +// 4. Run (coming soon) +// const result = await app.run(); +``` + +## Feature Parity with Python + +### State APIs + +| Feature | Python | TypeScript | Status | Notes | +|---------|--------|------------|--------|-------| +| **Immutable state** | βœ… | βœ… | **Complete** | Copy-on-write with structural sharing | +| **createState()** | βœ… | βœ… | **Complete** | Safe mode - explicit data required | +| **createStateWithDefaults()** | ❌ | βœ… | **TS-only** | Power mode - Zod defaults optional data | +| **State.update()** | βœ… | βœ… | **Complete** | Type-safe, dynamic schema extension | +| **State.get()** | βœ… | βœ… | **Complete** | Plus direct property access via Proxy | +| **State.has()** | βœ… | βœ… | **Complete** | Runtime key existence check | +| **State.subset()** | βœ… | ❌ | Not implemented | May not be needed with TS typing | +| **State.merge()** | βœ… | ❌ | Not implemented | | +| **State.wipe()** | βœ… | ❌ | Not implemented | | +| **State.increment()** | βœ… | βœ… | **Complete** | Multi-field support with object params | +| **State.append()** | βœ… | βœ… | **Complete** | Multi-field support with object params | +| **State.extend()** | βœ… | βœ… | **Complete** | Multi-field support with object params | +| **Operations/StateDelta** | βœ… | βœ… | **Complete** | Schema-aware, type-parameterized | +| **Custom serialization** | βœ… | ❌ | Not implemented | JSON-only for now | +| **History tracking** | βœ… | ❌ | Intentionally omitted | Event sourcing at app level instead | +| **Read/write restrictions** | ❌ | βœ… | **TS-only** | Compile-time + runtime enforcement | +| **Zod schema validation** | ❌ (Pydantic) | βœ… | **Complete** | Zod is required, not optional | + +### Action APIs + +| Feature | Python | TypeScript | Status | Notes | +|---------|--------|------------|--------|-------| +| **Two-step actions** | βœ… (`@action`) | βœ… (`action`) | **Complete** | run + update separation | +| **Reads/writes metadata** | βœ… | βœ… | **Complete** | Via Zod schemas | +| **Input validation** | βœ… | βœ… | **Complete** | Via Zod schemas | +| **Result schema** | βœ… | βœ… | **Complete** | Via Zod, object or void | +| **Streaming actions** | βœ… (`@streaming_action`) | ❌ | Not implemented | Coming soon | +| **Reducers** | βœ… | ❌ | Not implemented | May not be needed | +| **Single function** | βœ… | ❌ | Not implemented | Only two-step for now | +| **Decorators** | βœ… | ❌ | **TS uses factories** | `action` instead of `@action` | +| **Type inference** | ❌ | βœ… | **TS-only** | Full compile-time type safety | +| **Optional run()** | ❌ | βœ… | **TS enhancement** | Defaults to empty result | +| **Options object params** | ❌ | βœ… | **TS enhancement** | `{ state, inputs }` pattern | + +### Graph APIs + +| Feature | Python | TypeScript | Status | Notes | +|---------|--------|------------|--------|-------| +| **Graph builder** | βœ… | βœ… | **Complete** | Immutable builder pattern | +| **Add actions** | βœ… (`with_actions`) | βœ… (`withActions`) | **Complete** | Type-safe action names | +| **Add transitions** | βœ… (`with_transitions`) | βœ… (`withTransitions`) | **Complete** | Type-safe conditions | +| **Conditional transitions** | βœ… | βœ… | **Complete** | State-aware predicates | +| **Terminal transitions** | βœ… (null) | βœ… (null) | **Complete** | `to: null` for terminal | +| **Subgraphs** | βœ… | ❌ | Not implemented | | +| **Parallel execution** | βœ… | ❌ | Not implemented | | +| **Bottom-up typing** | ❌ | βœ… | **TS-only** | Infer state from actions | +| **Top-down typing** | ❌ | βœ… | **TS-only** | Enforce global state schema | +| **Generic Graph** | ❌ | βœ… | **TS-only** | Compile-time state typing | + +### Application APIs + +| Feature | Python | TypeScript | Status | Notes | +|---------|--------|------------|--------|-------| +| **Application builder** | βœ… | βœ… | **Complete** | Immutable builder pattern | +| **with_graph** | βœ… | βœ… (`withGraph`) | **Complete** | Primary API | +| **with_actions** | βœ… | ❌ | **TS different** | Must use GraphBuilder first | +| **with_transitions** | βœ… | ❌ | **TS different** | Must use GraphBuilder first | +| **with_entrypoint** | βœ… | βœ… (`withEntrypoint`) | **Complete** | Action name validation | +| **with_state** | βœ… | βœ… (`withState`) | **Complete** | Initial state + validation | +| **State validation** | ❌ | βœ… | **TS-only** | Compile-time graph compatibility | +| **with_identifiers** | βœ… | ❌ | Not implemented | app_id, partition_key | +| **with_tracker** | βœ… | ❌ | Not implemented | Telemetry | +| **with_hooks** | βœ… | ❌ | Not implemented | Lifecycle hooks | +| **initialize_from** | βœ… | ❌ | Not implemented | Load from persister | +| **run()** | βœ… | ❌ | Not implemented | Execute to completion | +| **step()** | βœ… | ❌ | Not implemented | Single step execution | +| **stream_result()** | βœ… | ❌ | Not implemented | Async iteration | +| **iterate()** | βœ… | ❌ | Not implemented | Generator pattern | +| **Generic Application** | ❌ | βœ… | **TS-only** | Compile-time state typing | +| **Hybrid type inference** | ❌ | βœ… | **TS-only** | Infer from graph or state | +| **Both build orders** | ❌ | βœ… | **TS-only** | Stateβ†’Graph or Graphβ†’State | + +### Persistence APIs + +| Feature | Python | TypeScript | Status | Notes | +|---------|--------|------------|--------|-------| +| **Base persister** | βœ… | ❌ | Not implemented | | +| **SQLite persister** | βœ… | ❌ | Not implemented | | +| **PostgreSQL persister** | βœ… | ❌ | Not implemented | | +| **In-memory persister** | βœ… | ❌ | Not implemented | | +| **Custom persisters** | βœ… | ❌ | Not implemented | | + +### Tracking/Telemetry APIs + +| Feature | Python | TypeScript | Status | Notes | +|---------|--------|------------|--------|-------| +| **Local tracker** | βœ… | ❌ | Not implemented | | +| **OpenTelemetry** | βœ… | ❌ | Not implemented | | +| **Custom trackers** | βœ… | ❌ | Not implemented | | +| **Lifecycle hooks** | βœ… | ❌ | Not implemented | | + +### Serialization APIs + +| Feature | Python | TypeScript | Status | Notes | +|---------|--------|------------|--------|-------| +| **JSON serialization** | βœ… | βœ… | **Partial** | Basic support, no custom | +| **Custom serializers** | βœ… | ❌ | Not implemented | | +| **Pickle support** | βœ… | N/A | Not applicable | JS doesn't have pickle | + +## TypeScript-Specific Features + +### Unique to TypeScript (Not in Python) + +1. **Compile-time Type Safety** + - Full type inference from Zod schemas + - Catch errors at build time, not runtime + - IDE autocomplete for state fields + +2. **Read/Write Restrictions** + - Actions can only read from `reads` schema + - Actions can only write to `writes` schema + - Enforced at both compile-time and runtime + +3. **Dynamic Schema Extension** + - `state.update({ newField: value })` extends the schema + - Type system tracks new fields automatically + - Runtime Zod schema stays compatible + +4. **Immutable Builder Pattern** + - GraphBuilder and ApplicationBuilder are immutable + - Each method returns a new instance + - Type information preserved through chaining + +5. **Proxy-based State Access** + - Direct property access: `state.count` instead of `state.get('count')` + - Still maintains immutability guarantees + - Validates against schema at runtime + +6. **Generic Type Parameters** + - `Graph`, `Application` + - Type-level state tracking + - Enables compile-time compatibility checks + +7. **Hybrid Typing Modes** + - Bottom-up: Infer state from actions + - Top-down: Enforce global state schema + - Same API supports both patterns + +8. **Graph-State Compatibility Validation** + - ApplicationBuilder validates state matches graph requirements + - Works in both orders: `withState()` β†’ `withGraph()` or `withGraph()` β†’ `withState()` + - Descriptive compile-time errors show exactly what's missing + - State must be a superset of graph requirements (can have extra fields) + +9. **Dual State Creation Modes** + - **Safe mode** (`createState`): Explicit data required at compile-time + - **Power mode** (`createStateWithDefaults`): Optional data, Zod defaults fill at runtime + - Opt-in convenience without sacrificing safety by default + ```typescript + // Safe: compile error if data missing + createState(schema, { count: 0 }) + + // Power: no data needed if schema has defaults + createStateWithDefaults(z.object({ count: z.number().default(0) })) + ``` + +## Design Principles + +### TypeScript Port Goals + +1. **Type Safety First**: Leverage TypeScript's type system for compile-time guarantees +2. **Zod Integration**: Use Zod throughout for runtime validation and type inference +3. **Immutability**: Immutable data structures with structural sharing +4. **Event Sourcing**: Operations are first-class, serializable objects +5. **Async-Only**: All actions are async (no sync operations) +6. **Clean API**: No decorators (use factory functions instead) + +### Key Differences from Python + +| Aspect | Python | TypeScript | Rationale | +|--------|--------|------------|-----------| +| **Schema library** | Pydantic (optional) | Zod (required) | Type erasure requires runtime metadata | +| **Type safety** | Runtime + mypy | Compile-time + runtime | TypeScript's type system is more powerful | +| **State validation** | Runtime only | Compile-time | Graph-state compatibility checked at build time | +| **Decorators** | `@action` | `action()` | Factory pattern is more idiomatic in TS | +| **Builder pattern** | Mutable | Immutable | Preserves type information through chaining | +| **State access** | `state.get()` | `state.field` + `state.get()` | Proxy enables both patterns | +| **Execution** | Sync + async | Async only | Modern JS is async-first | + +## Contributing + +This is an active port of Python Burr to TypeScript. We're focusing on: + +1. βœ… Core APIs (state, actions, graph, application) +2. ⏳ Execution engine +3. ⏳ Persistence layer +4. ⏳ Telemetry & tracking +5. ⏳ Streaming actions + +## Documentation + +See the [implementation summary](./APPLICATION_IMPLEMENTATION_SUMMARY.md) for detailed architecture notes. + +## License + +Apache License 2.0 + diff --git a/typescript/packages/burr-core/jest.config.js b/typescript/packages/burr-core/jest.config.js new file mode 100644 index 000000000..d08458c38 --- /dev/null +++ b/typescript/packages/burr-core/jest.config.js @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'], + testPathIgnorePatterns: ['/node_modules/', '\\.test-d\\.ts$'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/*.test.ts', '!src/**/*.test-d.ts'], +}; + diff --git a/typescript/packages/burr-core/package.json b/typescript/packages/burr-core/package.json new file mode 100644 index 000000000..db8e63ed3 --- /dev/null +++ b/typescript/packages/burr-core/package.json @@ -0,0 +1,42 @@ +{ + "name": "@apache-burr/core", + "version": "0.1.0", + "description": "Core state machine library for Apache Burr (TypeScript)", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build", + "test": "jest", + "test:watch": "jest --watch", + "test:types": "jest src/__tests__/type-tests/runner.test.ts --no-coverage", + "test:types:single": "jest src/__tests__/type-tests/runner.test.ts --no-coverage", + "lint": "eslint src --ext .ts", + "clean": "rm -rf dist" + }, + "keywords": [ + "state-machine", + "workflow", + "llm", + "agent", + "burr" + ], + "author": "Apache Burr Contributors", + "license": "Apache-2.0", + "devDependencies": { + "@types/jest": "^29.5.14", + "jest": "^29.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.3.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/apache/burr.git", + "directory": "typescript/packages/burr-core" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "zod": "^4.2.1" + } +} diff --git a/typescript/packages/burr-core/scratch-examples/counter-hw.ts b/typescript/packages/burr-core/scratch-examples/counter-hw.ts new file mode 100644 index 000000000..d18927de3 --- /dev/null +++ b/typescript/packages/burr-core/scratch-examples/counter-hw.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; +import { action, ApplicationBuilder, GraphBuilder, createState, createStateWithDefaults } from "../src"; + +const counter = action({ + reads: z.object({ counter: z.number() }), + writes: z.object({ counter: z.number() }), + update: ({ state }) => state.update({ counter: state.counter + 1 }) +}); + +// Build graph (bottom-up: infers state schema from actions) +const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + +// ❌ This should fail - state has WRONG but graph needs counter +export const appWithError = new ApplicationBuilder() + .withEntrypoint('counter') + .withState(createState( + z.object({ WRONG: z.number() }), + { WRONG: 0 } + )) + // @ts-expect-error - Intentional: Graph requires { counter } but state has { WRONG } + .withGraph(graph) + .build(); + +// βœ… This works - state has counter as required +export const appCorrect = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(createState( + z.object({ counter: z.number() }), + { counter: 0 } + )) + .build(); + +console.log('Application built successfully:', { + entrypoint: appCorrect.entrypoint, + initialState: appCorrect.initialState.counter +}); + +// ✨ Power-user mode: Use defaults from schema + +export const appWithDefaults = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(createStateWithDefaults(z.object({ counter: z.number().default(0) }))) // No data param needed! + .build(); + +console.log('Application with defaults:', { + entrypoint: appWithDefaults.entrypoint, + initialState: appWithDefaults.initialState.counter +}); + + + +import { Action} from '../src'; + +// ============================================================================ +// Reproduce the EXACT pattern from email-assistant +// ============================================================================ + +// 1. Define global state schema +const EmailAssistantState = z.object({ + a: z.string(), + b: z.number(), + c: z.boolean(), +}); + +// 2. Create actions using .pick() +const action1 = action({ + reads: EmailAssistantState.pick({ a: true }), + writes: EmailAssistantState.pick({ b: true }), + update: ({ state }) => state.update({ b: 42 }) +}); + +const action2 = action({ + reads: EmailAssistantState.pick({ b: true }), + writes: EmailAssistantState.pick({ c: true }), + update: ({ state }) => state.update({ c: true }).update({d: false}) +}); + +// 3. Build graph - this creates the complex UnionOfActionStates type +const graph2 = new GraphBuilder() + .withActions({ action1, action2 }) + .build(); + +// 4. Create state with full schema +const state = createState(EmailAssistantState, { + a: 'test', + b: 0, + c: false, +}); + +// 5. Build application - THIS IS WHERE IT SHOULD WORK BUT DOESN'T +const app = new ApplicationBuilder() + .withGraph(graph2) + .withEntrypoint('action1') + .withState(state) // <-- Should this compile? + .build(); + +console.log('If this compiles, our validation works!'); +console.log('App:', app); \ No newline at end of file diff --git a/typescript/packages/burr-core/src/__tests__/TYPE_TESTS_README.md b/typescript/packages/burr-core/src/__tests__/TYPE_TESTS_README.md new file mode 100644 index 000000000..7a077f217 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/TYPE_TESTS_README.md @@ -0,0 +1,74 @@ +# Compile-Time Type Safety Tests + +This directory contains compile-time type tests that verify TypeScript correctly catches type errors at definition time. + +## Setup + +Install `tsd` as a dev dependency: + +```bash +npm install --save-dev tsd +``` + +## Running Tests + +```bash +npm run test:types +``` + +This will: +1. Build the project (`npm run build`) +2. Copy `index.test-d.ts` to `dist/` +3. Run `tsd` which validates that: + - `expectError()` calls actually produce TypeScript errors + - `expectType()` calls match the expected type + - `expectAssignable()` calls are valid + +## Test File + +- `index.test-d.ts` - Type tests for Actions and State + - Write restrictions (can't write to non-writable fields) + - Result + Run consistency (run required when result specified) + - Run return type matching result schema + - Covariance (update can return extra fields) + - Multi-field operations (increment, append with multiple fields) + +**Note:** Some type constraints (like excess property checking and NumberKeys) are primarily enforced at runtime by Zod, as TypeScript's structural typing makes compile-time enforcement complex in all scenarios. + +## Why `tsd`? + +Unlike runtime tests, these tests verify the TypeScript compiler's behavior: +- Ensures type errors are caught **before** code runs +- Documents expected type behavior +- Prevents regressions in type safety +- Validates complex generic types + +## Example + +```typescript +// βœ… This should compile +const state = createState(z.object({ count: z.number() }), { count: 0 }); +state.increment({ count: 1 }); + +// ❌ This should NOT compile (count is not writable) +const restrictedState = State.forAction( + z.object({ count: z.number() }), // reads + z.object({ result: z.number() }), // writes + { count: 0 } +); +expectError(restrictedState.increment({ count: 1 })); // Error caught by tsd! +``` + +## Writing New Tests + +1. Edit `index.test-d.ts` in this directory +2. Use `expectError()` for code that should NOT compile +3. Use `expectAssignable()` or `expectType()` for code that should compile +4. Run `npm run test:types` to validate + +See https://github.com/SamVerschueren/tsd for full documentation. + +## Implementation Note + +The test file lives in `src/__tests__/` and imports from `./index` (which resolves to `dist/index.d.ts`). During test runs, it's copied to `dist/` where tsd expects it. This keeps test files organized with other tests while working with tsd's conventions. + diff --git a/typescript/packages/burr-core/src/__tests__/action.test.ts b/typescript/packages/burr-core/src/__tests__/action.test.ts new file mode 100644 index 000000000..7cac9f102 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/action.test.ts @@ -0,0 +1,668 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { z } from 'zod'; +import { action } from '../action'; +import { createState } from '../state'; + +describe('Action - Construction', () => { + test('action creates action with full configuration', () => { + // Action increments a counter by a delta + // Pass: Action object created with run and update methods + const incrementAction = action({ + reads: z.object({ + count: z.number(), + userId: z.string() + }), + writes: z.object({ + count: z.number() + }), + inputs: z.object({ + delta: z.number() + }), + result: z.object({ + newCount: z.number(), + timestamp: z.string() + }), + run: async ({ state, inputs }) => ({ + newCount: state.count + inputs.delta, + timestamp: new Date().toISOString() + }), + update: ({ result, state }) => state.update({ + count: result.newCount + }) + }); + + expect(incrementAction).toBeDefined(); + expect(typeof incrementAction.run).toBe('function'); + expect(typeof incrementAction.update).toBe('function'); + }); + + test('action with minimal configuration (no inputs, no result)', () => { + // Side-effect action that just updates state + // Pass: Action works with optional inputs and result omitted + const sideEffectAction = action({ + reads: z.object({ + userId: z.string() + }), + writes: z.object({ + lastRun: z.string() + }), + run: async ({ state: _state }) => { + // Simulate side effect (e.g., send notification) + return {}; + }, + update: ({ state }) => state.update({ + lastRun: new Date().toISOString() + }) + }); + + expect(sideEffectAction).toBeDefined(); + expect(sideEffectAction.inputs).toEqual([]); + }); + + test('action with inputs but no result', () => { + // Action that takes input but returns void + // Pass: Inputs metadata extracted even when result is void + const doubleAction = action({ + reads: z.object({ + value: z.number() + }), + writes: z.object({ + doubled: z.number() + }), + inputs: z.object({ + multiplier: z.number() + }), + run: async ({ state: _state, inputs: _inputs }) => ({}), + update: ({ state }) => state.update({ + doubled: state.value * 2 + }) + }); + + expect(doubleAction.inputs).toEqual(['multiplier']); + }); +}); + +describe('Action - Metadata Extraction', () => { + test('reads keys extracted from Zod object schema', () => { + // Pass: Reads array contains all top-level keys from reads schema + const incrementAction = action({ + reads: z.object({ + count: z.number(), + userId: z.string(), + settings: z.object({ theme: z.string() }) + }), + writes: z.object({ count: z.number() }), + result: z.object({ newCount: z.number() }), + run: async ({ state }) => ({ newCount: state.count + 1 }), + update: ({ result, state }) => state.update({ count: result.newCount }) + }); + + expect(incrementAction.reads).toEqual(['count', 'userId', 'settings']); + }); + + test('writes keys extracted from Zod object schema', () => { + // Pass: Writes array contains all top-level keys from writes schema + const computeAction = action({ + reads: z.object({ value: z.number() }), + writes: z.object({ + result: z.number(), + status: z.string(), + metadata: z.object({ processed: z.boolean() }) + }), + result: z.object({ computed: z.number() }), + run: async ({ state }) => ({ computed: state.value * 2 }), + update: ({ result, state }) => state.update({ + result: result.computed, + status: 'complete', + metadata: { processed: true } + }) + }); + + expect(computeAction.writes).toEqual(['result', 'status', 'metadata']); + }); + + test('inputs keys extracted when provided', () => { + // Pass: Inputs array contains all top-level keys from inputs schema + const calculateAction = action({ + reads: z.object({ base: z.number() }), + writes: z.object({ total: z.number() }), + inputs: z.object({ + multiplier: z.number(), + offset: z.number(), + label: z.string() + }), + result: z.object({ value: z.number() }), + run: async ({ state, inputs }) => ({ + value: state.base * inputs.multiplier + inputs.offset + }), + update: ({ result, state }) => state.update({ total: result.value }) + }); + + expect(calculateAction.inputs).toEqual(['multiplier', 'offset', 'label']); + }); + + test('inputs empty array when not provided', () => { + // Pass: Inputs defaults to empty array when omitted from config + const doubleAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state }) => ({ value: state.x * 2 }), + update: ({ result, state }) => state.update({ y: result.value }) + }); + + expect(doubleAction.inputs).toEqual([]); + }); + + test('schema property returns all four schemas', () => { + // Pass: Schema getter provides access to all original Zod schemas + const readsSchema = z.object({ a: z.number() }); + const writesSchema = z.object({ b: z.number() }); + const inputsSchema = z.object({ c: z.number() }); + const resultSchema = z.object({ d: z.number() }); + + const transformAction = action({ + reads: readsSchema, + writes: writesSchema, + inputs: inputsSchema, + result: resultSchema, + run: async ({ state }) => ({ d: state.a }), + update: ({ result, state }) => state.update({ b: result.d }) + }); + + expect(transformAction.schema.reads).toBe(readsSchema); + expect(transformAction.schema.writes).toBe(writesSchema); + expect(transformAction.schema.inputs).toBe(inputsSchema); + expect(transformAction.schema.result).toBe(resultSchema); + }); +}); + +describe('Action - Execution (run)', () => { + test('run executes async function and returns result', async () => { + // Pass: Run method executes user function and returns result object + const incrementAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + result: z.object({ newCount: z.number() }), + run: async ({ state }) => { + // Simulate async work + await new Promise(resolve => setTimeout(resolve, 1)); + return { newCount: state.count + 1 }; + }, + update: ({ result, state }) => state.update({ count: result.newCount }) + }); + + const readsSchema = z.object({ count: z.number() }); + const result = await incrementAction.run({ + state: createState(readsSchema, { count: 5 }), + inputs: undefined + }); + + expect(result).toEqual({ newCount: 6 }); + }); + + test('run receives correct state subset', async () => { + // Pass: User function receives state matching reads schema + const multiplyAction = action({ + reads: z.object({ + counter: z.number(), + multiplier: z.number() + }), + writes: z.object({ result: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state }) => ({ + value: state.counter * state.multiplier + }), + update: ({ result, state }) => state.update({ result: result.value }) + }); + + const readsSchema = z.object({ counter: z.number(), multiplier: z.number() }); + const result = await multiplyAction.run({ + state: createState(readsSchema, { counter: 10, multiplier: 3 }) as any, + inputs: undefined + }); + + expect(result).toEqual({ value: 30 }); + }); + + test('run receives and uses inputs', async () => { + // Pass: User function receives both state and inputs correctly + const addAction = action({ + reads: z.object({ base: z.number() }), + writes: z.object({ total: z.number() }), + inputs: z.object({ addition: z.number() }), + result: z.object({ sum: z.number() }), + run: async ({ state, inputs }) => ({ + sum: state.base + inputs.addition + }), + update: ({ result, state }) => state.update({ total: result.sum }) + }); + + const readsSchema = z.object({ base: z.number() }); + const result = await addAction.run({ + state: createState(readsSchema, { base: 10 }) as any, + inputs: { addition: 5 } + }); + + expect(result).toEqual({ sum: 15 }); + }); + + test('run does not validate reads (application handles subsetting)', async () => { + // Note: Reads validation is handled by the Application during FORK phase + // The action receives a pre-subsetted state and trusts it's correct + // This test verifies that action.run() doesn't validate reads itself + const incrementAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + result: z.object({ newCount: z.number() }), + run: async ({ state }) => ({ newCount: state.count + 1 }), + update: ({ result, state }) => state.update({ count: result.newCount }) + }); + + // Even with invalid state type, action doesn't validate reads + // (It will fail during execution or result validation instead) + const invalidState = createState(z.object({ count: z.any() }), { count: 'invalid' }) as any; + + // The action executes, but result validation catches the type error + await expect( + incrementAction.run({ state: invalidState, inputs: undefined }) + ).rejects.toThrow('Action validation failed for result'); + }); + + test('run validates inputs before execution', async () => { + // Pass: Throws error when inputs don't match inputs schema + const addAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + inputs: z.object({ delta: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state, inputs }) => ({ value: state.x + inputs.delta }), + update: ({ result, state }) => state.update({ y: result.value }) + }); + + // Pass invalid inputs (delta should be number, not string) + const readsSchema = z.object({ x: z.number() }); + await expect( + addAction.run({ + state: createState(readsSchema, { x: 10 }) as any, + inputs: { delta: 'bad' } as any + }) + ).rejects.toThrow('Action validation failed for inputs'); + }); + + test('run validates result after execution', async () => { + // Pass: Throws error when user function returns wrong shape + const invalidResultAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state: _state }) => { + // Return wrong shape + return { wrongKey: 123 } as any; + }, + update: ({ result, state }) => state.update({ y: result.value }) + }); + + const readsSchema = z.object({ x: z.number() }); + await expect( + invalidResultAction.run({ state: createState(readsSchema, { x: 10 }) as any, inputs: undefined }) + ).rejects.toThrow('Action validation failed for result'); + }); +}); + +describe('Action - Execution (update)', () => { + test('update transforms result into state writes', () => { + // Pass: Update method transforms result to writes correctly + const uppercaseAction = action({ + reads: z.object({ input: z.string() }), + writes: z.object({ + output: z.string(), + length: z.number() + }), + result: z.object({ + processed: z.string(), + charCount: z.number() + }), + run: async ({ state }) => ({ + processed: state.input.toUpperCase(), + charCount: state.input.length + }), + update: ({ result }) => { + const writesSchema = z.object({ output: z.string(), length: z.number() }); + return createState(writesSchema, { + output: result.processed, + length: result.charCount + }); + } + }); + + const readsSchema = z.object({ input: z.string() }); + const writes = uppercaseAction.update({ + result: { processed: 'HELLO', charCount: 5 }, + state: createState(readsSchema, { input: 'hello' }) as any, + inputs: undefined + }); + + expect(writes.data.output).toBe('HELLO'); + expect(writes.data.length).toBe(5); + }); + + test('update can reference original state', () => { + // Useful for relative updates or conditional logic + // Pass: Update function receives both result and original state + const randomIncrementAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + result: z.object({ increment: z.number() }), + run: async ({ state: _state }) => ({ + increment: Math.floor(Math.random() * 10) + }), + update: ({ result, state }) => state.update({ + count: state.count + result.increment + }) + }); + + const readsSchema = z.object({ count: z.number() }); + const writes = randomIncrementAction.update({ + result: { increment: 3 }, + state: createState(readsSchema, { count: 10 }), + inputs: undefined + }); + + expect(writes.data.count).toBe(13); + }); + + test('update validates result before transformation', () => { + // Pass: Throws error when result doesn't match result schema + const doubleAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state }) => ({ value: state.x * 2 }), + update: ({ result, state }) => state.update({ y: result.value }) + }); + + // Pass invalid result + const readsSchema = z.object({ x: z.number() }); + expect(() => + doubleAction.update({ + result: { value: 'bad' } as any, + state: createState(readsSchema, { x: 10 }) as any, + inputs: undefined + }) + ).toThrow('Action validation failed for result'); + }); + + test('update validates state before transformation', () => { + // Pass: Throws error when state doesn't match reads schema + const doubleAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state }) => ({ value: state.x * 2 }), + update: ({ result, state }) => state.update({ y: result.value }) + }); + + // Pass invalid state + const invalidState = createState(z.object({ x: z.any() }), { x: 'bad' }) as any; + expect(() => + doubleAction.update({ result: { value: 20 }, state: invalidState, inputs: undefined }) + ).toThrow('Action validation failed for state (reads)'); + }); + + test('update validates writes after transformation', () => { + // Pass: Throws error when update returns wrong shape + const invalidWriteAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state }) => ({ value: state.x * 2 }), + update: ({ state }) => { + // Return wrong shape - update with wrong key + // @ts-expect-error - intentionally bypassing type safety for test + return state.update({ wrongKey: 123 }) as any; + } + }); + + const readsSchema = z.object({ x: z.number() }); + expect(() => + invalidWriteAction.update({ + result: { value: 20 }, + state: createState(readsSchema, { x: 10 }) as any, + inputs: undefined + }) + ).toThrow('Action validation failed for writes'); + }); +}); + +describe('Action - Integration', () => { + test('run then update produces correct final state', async () => { + // Full two-step workflow + // Pass: Run result correctly feeds into update to produce writes + const aggregateAction = action({ + reads: z.object({ + items: z.array(z.number()) + }), + writes: z.object({ + sum: z.number(), + count: z.number() + }), + result: z.object({ + total: z.number(), + itemCount: z.number() + }), + run: async ({ state }) => { + const total = state.items.reduce((a, b) => a + b, 0); + return { + total, + itemCount: state.items.length + }; + }, + update: ({ result }) => { + const writesSchema = z.object({ sum: z.number(), count: z.number() }); + return createState(writesSchema, { + sum: result.total, + count: result.itemCount + }); + } + }); + + const readsSchema = z.object({ items: z.array(z.number()) }); + const stateSubset = createState(readsSchema, { items: [1, 2, 3, 4, 5] }) as any; + + // Step 1: Run computation + const result = await aggregateAction.run({ state: stateSubset, inputs: undefined }); + expect(result).toEqual({ total: 15, itemCount: 5 }); + + // Step 2: Transform to writes + const writes = aggregateAction.update({ result, state: stateSubset, inputs: undefined }); + expect(writes.data.sum).toBe(15); + expect(writes.data.count).toBe(5); + }); + + test('action with no result (void) works end-to-end', async () => { + // Action that just logs and updates timestamp + // Pass: Void result flows through run and update correctly + const logActivityAction = action({ + reads: z.object({ userId: z.string() }), + writes: z.object({ lastActivity: z.string() }), + result: z.void(), // Explicitly specify void for side-effect actions + run: async ({ state }) => { + // Side effect only + console.log(`Activity by user: ${state.userId}`); + return undefined; + }, + update: () => { + const writesSchema = z.object({ lastActivity: z.string() }); + return createState(writesSchema, { + lastActivity: '2025-12-25T10:00:00.000Z' + }); + } + }); + + const readsSchema = z.object({ userId: z.string() }); + const stateSubset = createState(readsSchema, { userId: 'user-123' }) as any; + const result = await logActivityAction.run({ state: stateSubset, inputs: undefined }); + expect(result).toBeUndefined(); + + const writes = logActivityAction.update({ result, state: stateSubset, inputs: undefined }); + expect(writes.data.lastActivity).toBe('2025-12-25T10:00:00.000Z'); + }); +}); + +describe('Action - Type Safety with Zod', () => { + test('schema.pick() creates subset schema for reads', () => { + // Central app state + // Pass: Action operates on state subset with full type safety + const AppStateSchema = z.object({ + userId: z.string(), + userName: z.string(), + count: z.number(), + email: z.string(), + preferences: z.object({ + theme: z.string() + }) + }); + + // Action only operates on subset of state + const incrementCountAction = action({ + reads: AppStateSchema.pick({ count: true, userId: true }), + writes: AppStateSchema.pick({ count: true }), + result: z.object({ newCount: z.number() }), + run: async ({ state }) => { + // Type-safe: state is { count: number, userId: string } + return { newCount: state.count + 1 }; + }, + update: ({ result, state }) => state.update({ count: result.newCount }) + }); + + expect(incrementCountAction.reads).toEqual(['count', 'userId']); + expect(incrementCountAction.writes).toEqual(['count']); + }); + + test('complex nested schemas work correctly', () => { + // Pass: Nested objects in schemas are handled and metadata extracted + const generateSummaryAction = action({ + reads: z.object({ + user: z.object({ + id: z.string(), + profile: z.object({ + name: z.string(), + age: z.number() + }) + }), + settings: z.object({ + notifications: z.boolean() + }) + }), + writes: z.object({ + processed: z.boolean() + }), + result: z.object({ + summary: z.string() + }), + run: async ({ state }) => ({ + summary: `User ${state.user.profile.name} (${state.user.id}), age ${state.user.profile.age}` + }), + update: ({ state }) => state.update({ processed: true }) + }); + + expect(generateSummaryAction.reads).toEqual(['user', 'settings']); + }); + + test('result constrained to object or void', () => { + // This test documents the type constraint + // Result must be z.object() or z.void() + // Pass: Object and void results compile; primitives would fail + + // Valid: object result + const objectResultAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.object({ value: z.number() }), + run: async ({ state }) => ({ value: state.x }), + update: ({ result }) => createState(z.object({ y: z.number() }), { y: result.value }) + }); + + // Valid: void result + const voidResultAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.void(), + run: async ({ state: _state }) => undefined, + update: ({ state }) => state.update({ y: 0 }) + }); + + // Invalid: primitives not allowed (compile-time error) + // const primitiveResultAction = action({ + // reads: z.object({ x: z.number() }), + // writes: z.object({ y: z.number() }), + // result: z.number(), // ❌ TypeScript error + // run: async (state) => 42, + // update: (result) => ({ y: result }) + // }); + + expect(objectResultAction).toBeDefined(); + expect(voidResultAction).toBeDefined(); + }); +}); + +// describe('Action - Playground / Template', () => { +// test('template action for experimentation', async () => { +// // Test 1: Write restrictions now work! TypeScript catches invalid writes at definition time. +// // This SHOULD error because 'count' is not in writes schema +// // @ts-expect-error - unused variable for demonstration +// const actionDemoErrors = action({ +// reads: z.object({count: z.number(), wrongType: z.string()}), +// writes: z.object({count: z.number(), requiredButNotAdded: z.number(), wrongType: z.string()},), +// update: ({ state }) => state +// // @ts-expect-error - wrongType is a string field, not a number (demonstrates type checking) +// .increment({wrongType: "string" , count: 1}) +// // @ts-expect-error - notDeclaredWrite is not in writes schema (demonstrates excess property checking) +// .update({requiredButNotAdded: state.count, notDeclaredWrite: "string"}) +// }); + +// // @ts-expect-error - unused for demonstration +// const actionMissingRunImplementation = action({ +// reads: z.object({count: z.number()}), +// writes: z.object({count: z.number()}), +// result: z.object({incrementBy: z.number()}), +// // @ts-expect-error - wrong type: "hello" is not a number (demonstrates result type checking) +// run: async ({ }) => ({ incrementBy: "hello" }), +// update: ({ state, result }) => state +// .increment({ count: result.incrementBy}) +// }); + +// // Test 2: With explicit return type - SHOULD ERROR! +// // const writesSchema = z.object({count: z.number(), requiredButNotAdded: z.number()}); +// // const actionWithAnnotation = action({ +// // reads: z.object({count: z.number()}), +// // writes: writesSchema, +// // update: ({ state }): StateInstance => { +// // return state.increment({ count: 1 }); // Should error here! +// // } +// // }); + +// // expect(actionNoAnnotation).toBeDefined(); +// // expect(actionWithAnnotation).toBeDefined(); +// }); +// }); + diff --git a/typescript/packages/burr-core/src/__tests__/application.test.ts b/typescript/packages/burr-core/src/__tests__/application.test.ts new file mode 100644 index 000000000..4525ee97f --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/application.test.ts @@ -0,0 +1,450 @@ +/** + * Copyright (c) 2024-2025 Elijah ben Izzy + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { action } from '../action'; +import { GraphBuilder } from '../graph'; +import { ApplicationBuilder } from '../application-builder'; +import { Application } from '../application'; +import { createState } from '../state'; + +describe('ApplicationBuilder', () => { + // Test fixtures + const action1 = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const action2 = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ done: z.boolean() }), + update: ({ state }) => state.update({ done: true }) + }); + + const testGraph = new GraphBuilder() + .withActions({ action1, action2 }) + .withTransitions(['action1', 'action2']) + .build(); + + const testState = createState( + z.object({ count: z.number(), done: z.boolean() }), + { count: 0, done: false } + ); + + describe('withGraph', () => { + test('sets the graph', () => { + const builder = new ApplicationBuilder().withGraph(testGraph); + const app = builder.withEntrypoint('action1').withState(testState).build(); + + expect(app.graph).toBe(testGraph); + }); + + test('throws if graph already set', () => { + const builder = new ApplicationBuilder().withGraph(testGraph); + + expect(() => { + builder.withGraph(testGraph); + }).toThrow('Graph is already set'); + }); + + test('returns new builder instance (immutable)', () => { + const builder1 = new ApplicationBuilder(); + const builder2 = builder1.withGraph(testGraph); + + expect(builder1).not.toBe(builder2); + }); + }); + + describe('withEntrypoint', () => { + test('sets the entrypoint', () => { + const builder = new ApplicationBuilder() + .withGraph(testGraph) + .withEntrypoint('action1'); + + const app = builder.withState(testState).build(); + expect(app.entrypoint).toBe('action1'); + }); + + test('throws if entrypoint already set', () => { + const builder = new ApplicationBuilder() + .withGraph(testGraph) + .withEntrypoint('action1'); + + expect(() => { + builder.withEntrypoint('action2'); + }).toThrow('Entrypoint is already set'); + }); + + test('throws if graph not set', () => { + const builder = new ApplicationBuilder(); + + expect(() => { + builder.withEntrypoint('action1'); + }).toThrow('Graph must be set before entrypoint'); + }); + + test('throws if entrypoint action not in graph', () => { + const builder = new ApplicationBuilder().withGraph(testGraph); + + expect(() => { + builder.withEntrypoint('nonexistent'); + }).toThrow("Entrypoint action 'nonexistent' not found in graph"); + }); + + test('returns new builder instance (immutable)', () => { + const builder1 = new ApplicationBuilder().withGraph(testGraph); + const builder2 = builder1.withEntrypoint('action1'); + + expect(builder1).not.toBe(builder2); + }); + }); + + describe('withState', () => { + test('throws if state already set', () => { + const builder = new ApplicationBuilder() + .withGraph(testGraph) + .withEntrypoint('action1') + .withState(testState); + + expect(() => { + builder.withState(testState); + }).toThrow('Initial state is already set'); + }); + + test('returns new builder instance (immutable)', () => { + const builder1 = new ApplicationBuilder() + .withGraph(testGraph) + .withEntrypoint('action1'); + const builder2 = builder1.withState(testState); + + expect(builder1).not.toBe(builder2); + }); + }); + + describe('build', () => { + test('creates application with all components', () => { + const app = new ApplicationBuilder() + .withGraph(testGraph) + .withEntrypoint('action1') + .withState(testState) + .build(); + + expect(app).toBeInstanceOf(Application); + expect(app.graph).toBe(testGraph); + expect(app.entrypoint).toBe('action1'); + }); + + test('throws if graph not set', () => { + const builder = new ApplicationBuilder(); + + expect(() => { + builder.build(); + }).toThrow('Cannot build application without graph'); + }); + + test('throws if entrypoint not set', () => { + const builder = new ApplicationBuilder().withGraph(testGraph); + + expect(() => { + builder.build(); + }).toThrow('Cannot build application without entrypoint'); + }); + + test('throws if state not set', () => { + const builder = new ApplicationBuilder() + .withGraph(testGraph) + .withEntrypoint('action1'); + + expect(() => { + builder.build(); + }).toThrow('Cannot build application without initial state'); + }); + }); + + describe('method chaining', () => { + test('can chain all methods in order', () => { + const app = new ApplicationBuilder() + .withGraph(testGraph) + .withEntrypoint('action1') + .withState(testState) + .build(); + + expect(app).toBeInstanceOf(Application); + }); + + test('can chain methods in different order', () => { + const app = new ApplicationBuilder() + .withGraph(testGraph) + .withState(testState) + .withEntrypoint('action1') + .build(); + + expect(app).toBeInstanceOf(Application); + }); + + test('state can be set before entrypoint', () => { + const app = new ApplicationBuilder() + .withGraph(testGraph) + .withState(testState) + .withEntrypoint('action1') + .build(); + + expect(app).toBeInstanceOf(Application); + }); + }); +}); + +describe('Application', () => { + test('stores graph, entrypoint, and initial state', () => { + const copyAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => state.update({ y: state.x }) + }); + + const graph = new GraphBuilder() + .withActions({ copyAction }) + .build(); + + const state = createState( + z.object({ x: z.number(), y: z.number() }), + { x: 5, y: 0 } + ); + + // Use ApplicationBuilder instead of direct construction (recommended pattern) + const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('copyAction') + .withState(state) + .build(); + + expect(app.graph).toBe(graph); + expect(app.entrypoint).toBe('copyAction'); + }); +}); + +describe('Application.runStep (Forkβ†’Launchβ†’Gatherβ†’Commit)', () => { + test('FORK phase: action receives only declared reads', async () => { + // Action that only reads count, not name + const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + run: async ({ state }) => { + // Should only have access to count, not name + expect(state.data).toHaveProperty('count'); + expect(state.data).not.toHaveProperty('name'); + return {}; + }, + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + + const initialState = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(initialState) + .withIdentifiers('test-app') + .build(); + + await app.step(); + }); + + test('COMMIT phase: preserves unwritten fields', async () => { + // Action that writes count but not name + const partialWriter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const graph = new GraphBuilder() + .withActions({ partialWriter }) + .build(); + + const initialState = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('partialWriter') + .withState(initialState) + .withIdentifiers('test-app') + .build(); + + const result = await app.step(); + + // Verify count was updated + expect(result?.state.data.count).toBe(1); + // Verify name was preserved (not written by action) + expect(result?.state.data.name).toBe('Alice'); + }); + + test('COMMIT phase: merges writes into full state including metadata', async () => { + const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + + const initialState = createState( + z.object({ count: z.number() }), + { count: 0 } + ); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(initialState) + .withIdentifiers('test-app', 'partition-1') + .build(); + + const result = await app.step(); + + // Verify user state was updated + expect(result?.state.data.count).toBe(1); + + // Verify metadata was preserved + expect(result?.state.data.appMetadata).toEqual({ + appId: 'test-app', + partitionKey: 'partition-1', + entrypoint: 'counter' + }); + + expect(result?.state.data.executionMetadata.sequenceId).toBe(1); + expect(result?.state.data.executionMetadata.priorStep).toBe('counter'); + }); +}); + +describe('Application.commitWrites', () => { + test('merges writes into committed state', () => { + const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + + const committedState = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(committedState) + .withIdentifiers('test-app') + .build(); + + const writes = createState( + z.object({ count: z.number() }), + { count: 1 } + ); + + // Access private method using bracket notation + const merged = (app as any).commitWrites(app.state, writes, counter); + + // Verify count was updated + expect(merged.data.count).toBe(1); + // Verify name was preserved + expect(merged.data.name).toBe('Alice'); + // Verify metadata was preserved + expect(merged.data.appMetadata).toBeDefined(); + }); + + test('rejects writes to reserved metadata keys', () => { + const badAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const graph = new GraphBuilder() + .withActions({ badAction }) + .build(); + + const committedState = createState( + z.object({ count: z.number() }), + { count: 0 } + ); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('badAction') + .withState(committedState) + .withIdentifiers('test-app') + .build(); + + // Create writes that attempt to modify reserved metadata + const badWrites = createState( + z.object({ count: z.number(), appMetadata: z.any() }), + { count: 1, appMetadata: { appId: 'hacked' } } + ); + + // Access private method using bracket notation + expect(() => { + (app as any).commitWrites(app.state, badWrites, badAction); + }).toThrow(/reserved metadata keys/); + expect(() => { + (app as any).commitWrites(app.state, badWrites, badAction); + }).toThrow(/appMetadata/); + }); + + test('rejects writes to any key ending in Metadata', () => { + const badAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const graph = new GraphBuilder() + .withActions({ badAction }) + .build(); + + const committedState = createState( + z.object({ count: z.number() }), + { count: 0 } + ); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('badAction') + .withState(committedState) + .withIdentifiers('test-app') + .build(); + + // Try to write to custom metadata key + const badWrites = createState( + z.object({ count: z.number(), customMetadata: z.any() }), + { count: 1, customMetadata: { foo: 'bar' } } + ); + + expect(() => { + (app as any).commitWrites(app.state, badWrites, badAction); + }).toThrow(/reserved metadata keys/); + expect(() => { + (app as any).commitWrites(app.state, badWrites, badAction); + }).toThrow(/customMetadata/); + }); +}); + diff --git a/typescript/packages/burr-core/src/__tests__/execution.test.ts b/typescript/packages/burr-core/src/__tests__/execution.test.ts new file mode 100644 index 000000000..182fb48f1 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/execution.test.ts @@ -0,0 +1,870 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * E2E Execution Tests + * + * Contract tests for Application execution engine: + * - app.step() - Single step execution + * - app.run() - Run to completion + * - app.iterate() - Iterator pattern + * - State management + * - Graph transitions + */ + +import { z } from 'zod'; +import { action, createState, GraphBuilder, ApplicationBuilder } from '../index'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) +}); + +const counterWithInputs = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + inputs: z.object({ additional: z.number() }), + update: ({ state, inputs }) => state.update({ + count: state.count + 1 + inputs.additional + }) +}); + +const result = action({ + reads: z.object({ count: z.number() }), + writes: z.object({}), + result: z.object({ value: z.number() }), + run: async ({ state }) => ({ value: state.count }), + // @ts-expect-error - Empty writes is valid for read-only actions + update: ({ state }) => state +}); + + +// ============================================================================ +// Core Execution - app.step() +// ============================================================================ + +describe('app.step() - Basic Execution', () => { + // Tests basic single step execution with simple self-looping graph. + // Create graph with one action + self-loop transition, execute step, verify state incremented and next action returned. + test('executes single action and advances state', async () => { + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + const result = await app.step(); + + expect(result).not.toBeNull(); + expect(result!.state.count).toBe(1); + }); + + // Tests that step() correctly passes runtime inputs to actions requiring them. + // Create graph with input-requiring action, call step() with inputs object, verify inputs used in state computation. + test('passes inputs to action', async () => { + const graph = new GraphBuilder() + .withActions({ counterWithInputs }) + .withTransitions(['counterWithInputs', 'counterWithInputs']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counterWithInputs') + .build(); + + const result = await app.step({ inputs: { additional: 5 } }); + + expect(result).not.toBeNull(); + expect(result!.state.count).toBe(6); // 0 + 1 + 5 + }); + + // Tests terminal state detection when action has no outgoing transitions. + // Create graph with no transitions from entrypoint, execute two steps, second step returns null at terminal. + test('returns null when no next actions', async () => { + const graph = new GraphBuilder() + .withActions({ counter }) + .build(); // No transitions = terminal + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + await app.step(); // First step succeeds + const result = await app.step(); // Second step hits terminal + + expect(result).toBeNull(); + }); + + // Tests that errors thrown during action execution propagate to caller. + // Create action that throws error in run(), execute step, expect error to bubble up with action context. + test('action errors bubble up', async () => { + const brokenAction = action({ + reads: z.object({}), + writes: z.object({}), + run: async () => { + throw new Error('Action failed!'); + }, + update: ({ state }) => state + }); + + const graph = new GraphBuilder() + .withActions({ brokenAction }) + .build(); // No transitions = terminal + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({}), {})) + .withEntrypoint('brokenAction') + .build(); + + await expect(app.step()).rejects.toThrow('Action failed!'); + }); +}); + +// ============================================================================ +// Core Execution - app.run() +// ============================================================================ + +describe('app.run() - Run to Completion', () => { + // Tests run() executes steps until reaching action with no outgoing transitions. + // Create graph with conditional loop and terminal action, call run(), verify final state after all steps executed. + test('runs until terminal state', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 10], + ['counter', 'result'] + // result has no outgoing transition = terminal + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + const final = await app.run(); + + expect(final.state.count).toBe(10); + expect(final.result).toHaveProperty('value', 10); + }); + + // Tests haltAfter stops execution immediately after specified action completes. + // Run with haltAfter targeting terminal action, verify action executed and result captured before stopping. + test('stops after executing specified action', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 10], + ['counter', 'result'] + // result has no outgoing transition = terminal + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + const final = await app.run({ haltAfter: ['result'] }); + + expect(final.state.count).toBe(10); + expect(final.result).toHaveProperty('value', 10); + }); + + // Tests haltBefore stops execution before specified action runs. + // Run with haltBefore targeting specific action, verify execution stops without running that action (result is null). + test('stops before executing specified action', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 10], + ['counter', 'result'] + // result has no outgoing transition = terminal + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + const final = await app.run({ haltBefore: ['result'] }); + + expect(final.state.count).toBe(10); + expect(final.result).toBeNull(); // Didn't execute result (halted before) + }); + + // Tests that inputs passed to run() are available to all actions throughout execution. + // Run with global inputs, verify each action in sequence receives and uses the inputs in computation. + test('global inputs available to all actions', async () => { + const graph = new GraphBuilder() + .withActions({ counterWithInputs, result }) + .withTransitions( + ['counterWithInputs', 'counterWithInputs', (state) => state.count < 10], + ['counterWithInputs', 'result'] + // result has no outgoing transition = terminal + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counterWithInputs') + .build(); + + const final = await app.run({ inputs: { additional: 4 }, haltAfter: ['result'] }); + + // Each step: count + 1 + 4 = count + 5 + // Step 1: 0 + 5 = 5 + // Step 2: 5 + 5 = 10 + expect(final.state.count).toBe(10); + }); + +}); + +// ============================================================================ +// Core Execution - app.iterate() +// ============================================================================ + +describe('app.iterate() - Iterator Pattern', () => { + // Tests iterate() async generator yields each step result until terminal state. + // Create graph that loops N times then terminates, iterate collecting all steps, verify total count matches expected. + test('yields each step until completion', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 5], + ['counter', 'result'] + // result has no outgoing transition = terminal + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + let stepCount = 0; + for await (const _step of app.iterate()) { + stepCount++; + } + + // counter runs 5 times (0β†’1β†’2β†’3β†’4β†’5), then result = 6 total steps + expect(stepCount).toBe(6); + }); + + // Tests that user can manually break from iterate() loop before completion. + // Create infinite loop graph, iterate with conditional break statement, verify execution stopped early at correct count. + test('user can break out of iteration', async () => { + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + let stepCount = 0; + for await (const step of app.iterate()) { + stepCount++; + if (step.state.count === 5) { + break; // User-controlled break + } + } + + expect(stepCount).toBe(5); + }); +}); + +// ============================================================================ +// Graph & Transitions +// ============================================================================ + +describe('Graph & Transitions', () => { + // Tests that transition conditions are evaluated in declaration order and first match is taken. + // Create graph with multiple overlapping conditional transitions, execute with different states, verify correct transition selected based on order. + test('transitions_evaluated_in_order: first match wins', async () => { + const low = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ level: z.string() }), + update: ({ state }) => state.update({ level: 'low' }) + }); + const high = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ level: z.string() }), + update: ({ state }) => state.update({ level: 'high' }) + }); + + const graph = new GraphBuilder() + .withActions({ counter, low, high }) + .withTransitions( + ['counter', 'low', (state) => state.count < 5], // Check first + ['counter', 'high', (state) => state.count >= 5] // Check second + // low and high have no outgoing transitions = terminal + ) + .build(); + + const app1 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState( + z.object({ count: z.number(), level: z.string().optional() }), + { count: 0, level: "hello" } + )) + .withEntrypoint('counter') + .build(); + + const result1 = await app1.step(); + expect(result1).not.toBeNull(); + // First transition matches (count < 5), will go to 'low' on next step + + const app2 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState( + z.object({ count: z.number(), level: z.string().optional() }), + { count: 5 } + )) + .withEntrypoint('counter') + .build(); + + const result2 = await app2.step(); + expect(result2).not.toBeNull(); + // First transition fails (count >= 5), second matches, will go to 'high' on next step + }); + + // Tests that transition conditions evaluate using state after action execution. + // Create graph with conditional loop checking counter, run to completion, verify condition controlled flow using updated state. + test('transitions_conditional: conditions evaluate on current state', async () => { + const setLevel = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ level: z.string() }), + update: ({ state }) => state.update({ + level: state.count < 5 ? 'low' : 'high' + }) + }); + + const graph = new GraphBuilder() + .withActions({ counter, setLevel }) + .withTransitions( + ['counter', 'counter', (state) => state.count! < 10], + ['counter', 'setLevel'] + // setLevel has no outgoing transition = terminal + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState( + z.object({ count: z.number(), level: z.string().optional() }), + { count: 0 } + )) + .withEntrypoint('counter') + .build(); + + const result = await app.run(); + + expect(result.state.count).toBe(10); + expect(result.state.level).toBe('high'); // 10 >= 5 + }); + +}); + +// ============================================================================ +// Integration Scenarios +// ============================================================================ + +describe('Integration Scenarios', () => { + // Tests multi-step execution sequence with state evolution through multiple actions. + // Execute multiple manual steps through conditional loop then terminal action, verify state progression at each step. + test('multi_action_sequence: counter β†’ result β†’ terminal', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 3], + ['counter', 'result'] + // result has no outgoing transition = terminal + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + // Step 1: counter (0 β†’ 1) + const step1 = await app.step(); + expect(step1?.state.count).toBe(1); + + // Step 2: counter (1 β†’ 2) + const step2 = await app.step(); + expect(step2?.state.count).toBe(2); + + // Step 3: counter (2 β†’ 3) + const step3 = await app.step(); + expect(step3?.state.count).toBe(3); + + // Step 4: result (extracts count) + const step4 = await app.step(); + expect(step4?.result).toHaveProperty('value', 3); + + // Step 5: terminal + const step5 = await app.step(); + expect(step5).toBeNull(); + }); + + // Tests that actions with separate run/update phases execute both correctly. + // Create action with both run() producing result and update() using result, execute step, verify both run output and state update applied. + test('action_with_result: run/update phases work correctly', async () => { + const multiPhase = action({ + reads: z.object({ input: z.string() }), + writes: z.object({ output: z.string() }), + result: z.object({ processed: z.string() }), + run: async ({ state }) => ({ + processed: state.input.toUpperCase() + }), + update: ({ state, result }) => state.update({ + output: result.processed + }) + }); + + const graph = new GraphBuilder() + .withActions({ multiPhase }) + .build(); // No transitions = terminal + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState( + z.object({ input: z.string(), output: z.string().optional() }), + { input: 'hello' } + )) + .withEntrypoint('multiPhase') + .build(); + + const result = await app.step(); + + expect(result).not.toBeNull(); + expect(result!.result).toHaveProperty('processed', 'HELLO'); // run() output + expect(result!.state.output).toBe('HELLO'); // update() applied it + }); +}); + +// ============================================================================ +// Critical Production Tests +// ============================================================================ + +describe('Critical Production Tests', () => { + // Tests that sequence ID correctly increments with each step execution. + // Verifies internal execution tracking is maintained across multiple steps for replay/debugging. + test('sequence ID increments correctly across multiple steps', async () => { + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('test-app') + .build(); + + // Initial sequence ID should be 0 + expect(app.state.data.executionMetadata.sequenceId).toBe(0); + + // Verify sequence ID increments with each step + for (let i = 1; i <= 3; i++) { + await app.step(); + expect(app.state.data.executionMetadata.sequenceId).toBe(i); + } + }); + + // Tests that framework metadata (appMetadata and executionMetadata) persists correctly during run(). + // Verifies metadata doesn't get lost during state merges throughout entire execution lifecycle. + test('framework metadata persists correctly through run()', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 5], + ['counter', 'result'] + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('my-app', 'partition-123') + .build(); + + const final = await app.run(); + + // App metadata should be unchanged + expect(final.state.data.appMetadata).toEqual({ + appId: 'my-app', + partitionKey: 'partition-123', + entrypoint: 'counter' + }); + + // Execution metadata should be updated + expect(final.state.data.executionMetadata.sequenceId).toBeGreaterThan(0); + expect(final.state.data.executionMetadata.priorStep).toBe('result'); + }); + + // Tests that actions cannot declare writes to reserved framework metadata keys. + // Verifies defense-in-depth validation prevents metadata corruption. + test('actions cannot write to framework metadata', async () => { + const maliciousAction = action({ + reads: z.object({ count: z.number() }), + // Malicious action declares it will write to metadata + writes: z.object({ count: z.number(), appMetadata: z.any() }), + update: ({ state }) => { + // Try to write to both count and metadata + return state.update({ + count: state.count + 1, + appMetadata: { appId: 'hacked' } + } as any); + } + }); + + const graph = new GraphBuilder() + .withActions({ maliciousAction }) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('maliciousAction') + .withIdentifiers('test-app') + .build(); + + // Should throw during COMMIT phase when validating write keys + await expect(app.step()).rejects.toThrow(/reserved metadata keys/i); + }); + + // Tests complex graph with multiple conditional branches (3+ outgoing transitions). + // Verifies transition evaluation order and correct path selection in realistic decision trees. + test('complex branching with multiple transitions works correctly', async () => { + const low = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ level: z.string() }), + update: ({ state }) => state.update({ level: 'low' }) + }); + + const medium = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ level: z.string() }), + update: ({ state }) => state.update({ level: 'medium' }) + }); + + const high = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ level: z.string() }), + update: ({ state }) => state.update({ level: 'high' }) + }); + + const graph = new GraphBuilder() + .withActions({ counter, low, medium, high }) + .withTransitions( + ['counter', 'low', (state) => state.count < 3], // Priority 1 + ['counter', 'medium', (state) => state.count < 7], // Priority 2 + ['counter', 'high', (state) => state.count >= 7] // Priority 3 + ) + .build(); + + // Test low path + const app1 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState( + z.object({ count: z.number(), level: z.string().optional() }), + { count: 1 } + )) + .withEntrypoint('counter') + .build(); + + await app1.step(); // counter: 1 β†’ 2 + const step2 = await app1.step(); // should go to 'low' + expect(step2?.action.name).toBe('low'); + + // Test medium path + const app2 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState( + z.object({ count: z.number(), level: z.string().optional() }), + { count: 5 } + )) + .withEntrypoint('counter') + .build(); + + await app2.step(); // counter: 5 β†’ 6 + const step2b = await app2.step(); // should go to 'medium' + expect(step2b?.action.name).toBe('medium'); + + // Test high path + const app3 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState( + z.object({ count: z.number(), level: z.string().optional() }), + { count: 7 } + )) + .withEntrypoint('counter') + .build(); + + await app3.step(); // counter: 7 β†’ 8 + const step2c = await app3.step(); // should go to 'high' + expect(step2c?.action.name).toBe('high'); + }); + + // Tests that run() produces identical state to manually calling step() in sequence. + // Verifies run() is truly just a loop over step() with no hidden behavior or side effects. + test('run() state matches manual step() sequence', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 3], + ['counter', 'result'] + ) + .build(); + + // App 1: Use run() + const app1 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('app1') + .build(); + + const runResult = await app1.run(); + + // App 2: Manual step() calls + const app2 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('app2') + .build(); + + let lastStep = await app2.step(); // counter: 0 β†’ 1 + lastStep = await app2.step(); // counter: 1 β†’ 2 + lastStep = await app2.step(); // counter: 2 β†’ 3 + lastStep = await app2.step(); // result + const finalStep = await app2.step(); // terminal + + // States should be identical (except appId) + expect(runResult.state.data.count).toBe(lastStep!.state.data.count); + expect(runResult.state.data.executionMetadata.priorStep).toBe(lastStep!.state.data.executionMetadata.priorStep); + expect(runResult.result).toEqual(lastStep!.result); + expect(finalStep).toBeNull(); // Both should hit terminal + }); +}); + +// ============================================================================ +// Resumption Tests +// ============================================================================ + +describe('Resumption Tests', () => { + // Tests that new application instance can resume from existing state mid-execution. + // Verifies state handoff between application instances with correct metadata tracking. + test('CRITICAL: application can resume from existing state', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 10], + ['counter', 'result'] + ) + .build(); + + // ============================================ + // PHASE 1: Initial execution (run 3 steps) + // ============================================ + const app1 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('workflow-123', 'user-456') + .build(); + + await app1.step(); // 0 β†’ 1 + await app1.step(); // 1 β†’ 2 + await app1.step(); // 2 β†’ 3 + + // Capture state after 3 steps + const intermediateState = app1.state; + + // CRITICAL: Verify metadata is present + expect(intermediateState.data.count).toBe(3); + expect(intermediateState.data.executionMetadata.sequenceId).toBe(3); + expect(intermediateState.data.executionMetadata.priorStep).toBe('counter'); + expect(intermediateState.data.appMetadata.appId).toBe('workflow-123'); + + // ============================================ + // PHASE 2: Create NEW application with existing state + // ============================================ + const app2 = new ApplicationBuilder() + .withGraph(graph) + .withState(intermediateState as any) + .withEntrypoint('counter') // Should be ignored - priorStep determines next + .withIdentifiers('workflow-123', 'user-456') + .build(); + + // CRITICAL: Metadata should be preserved + expect(app2.state.data.count).toBe(3); + expect(app2.state.data.executionMetadata.sequenceId).toBe(3); + expect(app2.state.data.executionMetadata.priorStep).toBe('counter'); + expect(app2.state.data.appMetadata.appId).toBe('workflow-123'); + + // ============================================ + // PHASE 3: Resume execution + // ============================================ + await app2.step(); // 3 β†’ 4 (sequence ID should be 4) + expect(app2.state.data.count).toBe(4); + expect(app2.state.data.executionMetadata.sequenceId).toBe(4); + + await app2.step(); // 4 β†’ 5 + await app2.step(); // 5 β†’ 6 + + // ============================================ + // PHASE 4: Run to completion + // ============================================ + const final = await app2.run(); + + expect(final.state.data.count).toBe(10); + expect(final.state.data.executionMetadata.sequenceId).toBeGreaterThan(3); + expect(final.state.data.executionMetadata.priorStep).toBe('result'); + expect(final.result).toHaveProperty('value', 10); + }); + + // Tests that resuming from a halted execution continues correctly. + // Verifies human-in-loop pattern: halt for approval, create new app, resume. + test('resume from halt continues correctly', async () => { + const graph = new GraphBuilder() + .withActions({ counter, result }) + .withTransitions( + ['counter', 'counter', (state) => state.count < 10], + ['counter', 'result'] + ) + .build(); + + // ============================================ + // PHASE 1: Run until haltAfter + // ============================================ + const app1 = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('workflow-123') + .build(); + + // Run until we've executed counter once + const halted = await app1.run({ + haltAfter: ['counter'], + }); + + expect(halted.state.data.count).toBe(1); + expect(halted.action?.name).toBe('counter'); + + // ============================================ + // PHASE 2: Create new app with halted state + // ============================================ + const app2 = new ApplicationBuilder() + .withGraph(graph) + .withState(app1.state as any) + .withEntrypoint('counter') + .withIdentifiers('workflow-123') + .build(); + + // ============================================ + // PHASE 3: Continue execution + // ============================================ + // Should continue from count=1, not re-execute the halted action + await app2.step(); // 1 β†’ 2 + expect(app2.state.data.count).toBe(2); + + // Run to completion + const final = await app2.run(); + expect(final.state.data.count).toBe(10); + }); + + // Tests that multiple app restarts maintain state consistency. + // Verifies long-running workflows can be handed off between app instances many times. + test('multiple app restarts maintain state consistency', async () => { + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + // Initialize with metadata + const initialApp = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('app-123', 'partition-1') + .build(); + + let currentState = initialApp.state; + + // ============================================ + // Simulate 5 app restart cycles + // ============================================ + for (let cycle = 0; cycle < 5; cycle++) { + // Create new app instance with existing state + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(currentState as any) + .withEntrypoint('counter') + .withIdentifiers('app-123', 'partition-1') + .build(); + + // Run 2 steps + await app.step(); + await app.step(); + + // Save state for next cycle + currentState = app.state; + + // Verify sequence ID is correct + const expectedSequenceId = (cycle + 1) * 2; + expect(currentState.data.executionMetadata.sequenceId).toBe(expectedSequenceId); + expect(currentState.data.count).toBe(expectedSequenceId); + } + + // After 5 cycles Γ— 2 steps = 10 steps total + expect(currentState.data.count).toBe(10); + expect(currentState.data.executionMetadata.sequenceId).toBe(10); + expect(currentState.data.appMetadata.appId).toBe('app-123'); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/graph.test.ts b/typescript/packages/burr-core/src/__tests__/graph.test.ts new file mode 100644 index 000000000..7e36dd5d4 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/graph.test.ts @@ -0,0 +1,210 @@ +/** + * Copyright (c) 2024-2025 Elijah ben Izzy + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { action } from '../action'; +import { Graph, GraphBuilder } from '../graph'; + +describe('GraphBuilder', () => { + // Test actions + const action1 = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number(), result: z.string() }), + update: ({ state }) => state.update({ count: state.count + 1, result: 'done' }) + }); + + const action2 = action({ + reads: z.object({ result: z.string() }), + writes: z.object({ final: z.boolean() }), + update: ({ state }) => state.update({ final: true }) + }); + + const action3 = action({ + reads: z.object({ final: z.boolean() }), + writes: z.object({ message: z.string() }), + update: ({ state }) => state.update({ message: 'complete' }) + }); + + describe('withActions', () => { + test('adds single action', () => { + const builder = new GraphBuilder().withActions({ action1 }); + const graph = builder.build(); + + expect(graph.hasAction('action1')).toBe(true); + expect(graph.actionCount).toBe(1); + }); + + test('adds multiple actions in single call', () => { + const builder = new GraphBuilder().withActions({ action1, action2 }); + const graph = builder.build(); + + expect(graph.hasAction('action1')).toBe(true); + expect(graph.hasAction('action2')).toBe(true); + expect(graph.actionCount).toBe(2); + }); + + test('chains multiple withActions calls', () => { + const builder = new GraphBuilder() + .withActions({ action1 }) + .withActions({ action2 }) + .withActions({ action3 }); + + const graph = builder.build(); + + expect(graph.hasAction('action1')).toBe(true); + expect(graph.hasAction('action2')).toBe(true); + expect(graph.hasAction('action3')).toBe(true); + expect(graph.actionCount).toBe(3); + }); + + test('allows custom action names', () => { + const builder = new GraphBuilder().withActions({ + first: action1, + second: action2 + }); + + const graph = builder.build(); + + expect(graph.hasAction('first')).toBe(true); + expect(graph.hasAction('second')).toBe(true); + expect(graph.hasAction('action1')).toBe(false); + }); + + test('throws on duplicate action names', () => { + const builder = new GraphBuilder().withActions({ action1 }); + + expect(() => { + builder.withActions({ action1 }); + }).toThrow('Duplicate action names: action1'); + }); + }); + + describe('withTransitions', () => { + test('adds transition without condition', () => { + const builder = new GraphBuilder() + .withActions({ action1, action2 }) + .withTransitions(['action1', 'action2']); + + const graph = builder.build(); + + expect(graph.transitionCount).toBe(1); + const transitions = graph.getTransitionsFrom('action1'); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('action1'); + expect(transitions[0].to).toBe('action2'); + expect(transitions[0].condition).toBeUndefined(); + }); + + test('adds transition with condition', () => { + const condition = (state: any) => state.count > 5; + + const builder = new GraphBuilder() + .withActions({ action1, action2 }) + .withTransitions(['action1', 'action2', condition]); + + const graph = builder.build(); + + const transitions = graph.getTransitionsFrom('action1'); + expect(transitions[0].condition).toBe(condition); + }); + + test('allows null as terminal target', () => { + const builder = new GraphBuilder() + .withActions({ action1 }) + .withTransitions(['action1', null]); + + const graph = builder.build(); + + const transitions = graph.getTransitionsFrom('action1'); + expect(transitions[0].to).toBeNull(); + }); + + test('throws if from action not found', () => { + const builder = new GraphBuilder() + .withActions({ action1 }); + + expect(() => { + builder.withTransitions(['nonexistent', 'action1'] as any); + }).toThrow("Transition source 'nonexistent' not found in actions"); + }); + + test('throws if to action not found', () => { + const builder = new GraphBuilder() + .withActions({ action1 }); + + expect(() => { + builder.withTransitions(['action1', 'nonexistent'] as any); + }).toThrow("Transition target 'nonexistent' not found in actions"); + }); + }); + + describe('build', () => { + test('creates graph with actions and transitions', () => { + const builder = new GraphBuilder() + .withActions({ action1, action2 }) + .withTransitions(['action1', 'action2']); + + const graph = builder.build(); + + expect(graph).toBeInstanceOf(Graph); + expect(graph.actionCount).toBe(2); + expect(graph.transitionCount).toBe(1); + }); + + test('throws if no actions added', () => { + const builder = new GraphBuilder(); + + expect(() => { + builder.build(); + }).toThrow('Cannot build graph with no actions'); + }); + }); +}); + +describe('Graph', () => { + const action1 = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) + }); + + const action2 = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ result: z.string() }), + update: ({ state }) => state.update({ result: 'done' }) + }); + + test('hasAction works correctly', () => { + const graph = new GraphBuilder() + .withActions({ action1 }) + .build(); + + expect(graph.hasAction('action1')).toBe(true); + expect(graph.hasAction('nonexistent')).toBe(false); + }); + + test('getAction works correctly', () => { + const graph = new GraphBuilder() + .withActions({ action1 }) + .build(); + + const retrieved = graph.getAction('action1'); + expect(retrieved).toBeDefined(); + expect(retrieved?.name).toBe('action1'); // GraphBuilder sets the name + expect(graph.getAction('nonexistent')).toBeUndefined(); + }); + + test('getTransitionsFrom works correctly', () => { + const graph = new GraphBuilder() + .withActions({ action1, action2 }) + .withTransitions(['action1', 'action2']) + .build(); + + const transitions = graph.getTransitionsFrom('action1'); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('action1'); + expect(transitions[0].to).toBe('action2'); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/lifecycle-extended.test.ts b/typescript/packages/burr-core/src/__tests__/lifecycle-extended.test.ts new file mode 100644 index 000000000..a909cd694 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/lifecycle-extended.test.ts @@ -0,0 +1,221 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Extended lifecycle hook tests: execute-call hooks and stream hooks. + */ + +import { z } from 'zod'; +import { action, createState, GraphBuilder, ApplicationBuilder } from '../index'; +import { streamingAction } from '../streaming'; +import { + type LifecycleAdapter, + type ExecuteMethod, + type PostStreamItemParams, +} from '../lifecycle'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }), +}); + +// ============================================================================ +// Execute-call hooks +// ============================================================================ + +describe('PreExecuteCall / PostExecuteCall hooks', () => { + test('step() fires pre/post execute-call hooks', async () => { + const methods: ExecuteMethod[] = []; + const adapter: LifecycleAdapter = { + async preExecuteCall({ method }) { methods.push(method); }, + async postExecuteCall({ method }) { methods.push(method); }, + }; + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withHooks(adapter) + .build(); + + await app.step(); + expect(methods).toEqual(['step', 'step']); + }); + + test('run() fires pre/post execute-call hooks once', async () => { + const calls: string[] = []; + const adapter: LifecycleAdapter = { + async preExecuteCall({ method }) { calls.push(`pre:${method}`); }, + async postExecuteCall({ method }) { calls.push(`post:${method}`); }, + }; + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions( + ['counter', 'counter', (s: any) => s.count < 3], + ['counter', null, (s: any) => s.count >= 3] + ) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withHooks(adapter) + .build(); + + await app.run(); + // run() fires execute-call hooks once (not per step) + expect(calls).toEqual(['pre:run', 'post:run']); + }); + + test('iterate() fires pre/post execute-call hooks once', async () => { + const calls: string[] = []; + const adapter: LifecycleAdapter = { + async preExecuteCall({ method }) { calls.push(`pre:${method}`); }, + async postExecuteCall({ method }) { calls.push(`post:${method}`); }, + }; + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withHooks(adapter) + .build(); + + let stepCount = 0; + for await (const _step of app.iterate()) { + stepCount++; + if (stepCount >= 2) break; + } + + expect(calls).toEqual(['pre:iterate', 'post:iterate']); + }); + + test('postExecuteCall receives exception on failure', async () => { + let capturedError: Error | null = null; + const adapter: LifecycleAdapter = { + async postExecuteCall({ exception }) { capturedError = exception; }, + }; + + const failingAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + result: z.object({}), + run: async () => { throw new Error('boom'); }, + update: ({ state }) => state, + }); + + const graph = new GraphBuilder() + .withActions({ failingAction }) + .withTransitions(['failingAction', null]) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('failingAction') + .withHooks(adapter) + .build(); + + await expect(app.step()).rejects.toThrow('boom'); + expect(capturedError).toBeInstanceOf(Error); + expect(capturedError!.message).toContain('boom'); + }); +}); + +// ============================================================================ +// Stream hooks +// ============================================================================ + +describe('Stream lifecycle hooks', () => { + test('streaming action fires preStartStream, postStreamItem, postEndStream', async () => { + const events: string[] = []; + const items: PostStreamItemParams[] = []; + const adapter: LifecycleAdapter = { + async preStartStream() { events.push('preStartStream'); }, + async postStreamItem(params) { events.push(`item:${params.itemIndex}`); items.push(params); }, + async postEndStream() { events.push('postEndStream'); }, + }; + + const streamCounter = streamingAction({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number(), tokens: z.string() }), + result: z.object({ token: z.string(), accumulated: z.string() }), + async *streamRun() { + yield { token: 'a', accumulated: 'a' }; + yield { token: 'b', accumulated: 'ab' }; + yield { token: 'c', accumulated: 'abc' }; + }, + update: ({ result, state }) => + state.update({ count: state.count + 1, tokens: result.accumulated }), + }); + + const graph = new GraphBuilder() + .withActions({ streamCounter }) + .withTransitions(['streamCounter', null]) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState( + createState( + z.object({ count: z.number(), tokens: z.string().optional() }), + { count: 0 } + ) + ) + .withEntrypoint('streamCounter') + .withHooks(adapter) + .build(); + + const result = await app.streamStep(); + expect(result).not.toBeNull(); + + // Consume the stream + for await (const _chunk of result!.stream) { /* consume */ } + await result!.stream.get(); + + expect(events).toEqual([ + 'preStartStream', + 'item:0', + 'item:1', + 'item:2', + 'postEndStream', + ]); + + // Verify item params + expect(items[0].item.token).toBe('a'); + expect(items[0].itemIndex).toBe(0); + expect(items[0].streamInitializeTime).toBeInstanceOf(Date); + expect(items[1].firstStreamItemStartTime).toBeInstanceOf(Date); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/lifecycle.test.ts b/typescript/packages/burr-core/src/__tests__/lifecycle.test.ts new file mode 100644 index 000000000..cffd1719f --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/lifecycle.test.ts @@ -0,0 +1,377 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Lifecycle Hooks Tests + * + * Tests for: + * - LifecycleAdapterSet dispatch + * - PreRunStepHook / PostRunStepHook wired into Application.step() + * - PostApplicationCreateHook wired into ApplicationBuilder.buildAsync() + * - Error handling (hook failures, action failures with hooks) + */ + +import { z } from 'zod'; +import { action, createState, GraphBuilder, ApplicationBuilder } from '../index'; +import { + LifecycleAdapterSet, + type LifecycleAdapter, + type PreRunStepParams, + type PostRunStepParams, + type PostApplicationCreateParams, +} from '../lifecycle'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }), +}); + +const failingAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + run: async () => { + throw new Error('action failed'); + }, + result: z.object({}), + update: ({ state }) => state, +}); + +function buildCounterApp(adapters: LifecycleAdapter[] = []) { + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + return new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withHooks(...adapters) + .build(); +} + +// ============================================================================ +// LifecycleAdapterSet - Unit Tests +// ============================================================================ + +describe('LifecycleAdapterSet', () => { + test('calls all preRunStep hooks in order', async () => { + const order: number[] = []; + const adapter1: LifecycleAdapter = { + async preRunStep() { order.push(1); }, + }; + const adapter2: LifecycleAdapter = { + async preRunStep() { order.push(2); }, + }; + + const set = new LifecycleAdapterSet([adapter1, adapter2]); + await set.callPreRunStep({ + appId: 'test', + partitionKey: undefined, + sequenceId: 1, + state: {} as any, + action: {} as any, + inputs: {}, + }); + + expect(order).toEqual([1, 2]); + }); + + test('calls all postRunStep hooks in order', async () => { + const order: number[] = []; + const adapter1: LifecycleAdapter = { + async postRunStep() { order.push(1); }, + }; + const adapter2: LifecycleAdapter = { + async postRunStep() { order.push(2); }, + }; + + const set = new LifecycleAdapterSet([adapter1, adapter2]); + await set.callPostRunStep({ + appId: 'test', + partitionKey: undefined, + sequenceId: 1, + state: {} as any, + action: {} as any, + result: null, + exception: null, + }); + + expect(order).toEqual([1, 2]); + }); + + test('skips adapters that do not implement the hook', async () => { + const called: string[] = []; + const preOnly: LifecycleAdapter = { + async preRunStep() { called.push('pre'); }, + }; + const postOnly: LifecycleAdapter = { + async postRunStep() { called.push('post'); }, + }; + + const set = new LifecycleAdapterSet([preOnly, postOnly]); + await set.callPreRunStep({ + appId: 'test', partitionKey: undefined, sequenceId: 1, + state: {} as any, action: {} as any, inputs: {}, + }); + + expect(called).toEqual(['pre']); + }); + + test('collects errors from hooks without stopping dispatch', async () => { + const called: number[] = []; + const adapter1: LifecycleAdapter = { + async preRunStep() { called.push(1); throw new Error('hook1 failed'); }, + }; + const adapter2: LifecycleAdapter = { + async preRunStep() { called.push(2); }, + }; + + const set = new LifecycleAdapterSet([adapter1, adapter2]); + await expect(set.callPreRunStep({ + appId: 'test', partitionKey: undefined, sequenceId: 1, + state: {} as any, action: {} as any, inputs: {}, + })).rejects.toThrow('1 preRunStep hook(s) failed'); + + // Both hooks were called despite first throwing + expect(called).toEqual([1, 2]); + }); + + test('empty adapter set dispatches without error', async () => { + const set = new LifecycleAdapterSet([]); + await set.callPreRunStep({ + appId: 'test', partitionKey: undefined, sequenceId: 1, + state: {} as any, action: {} as any, inputs: {}, + }); + // No error thrown + }); +}); + +// ============================================================================ +// Application Integration - PreRunStep / PostRunStep +// ============================================================================ + +describe('Lifecycle hooks wired into Application.step()', () => { + test('preRunStep is called before action execution', async () => { + const hookCalls: PreRunStepParams[] = []; + const adapter: LifecycleAdapter = { + async preRunStep(params) { hookCalls.push(params); }, + }; + + const app = buildCounterApp([adapter]); + await app.step(); + + expect(hookCalls).toHaveLength(1); + expect(hookCalls[0].action.name).toBe('counter'); + expect(hookCalls[0].sequenceId).toBe(1); + }); + + test('postRunStep is called after successful action execution', async () => { + const hookCalls: PostRunStepParams[] = []; + const adapter: LifecycleAdapter = { + async postRunStep(params) { hookCalls.push(params); }, + }; + + const app = buildCounterApp([adapter]); + await app.step(); + + expect(hookCalls).toHaveLength(1); + expect(hookCalls[0].action.name).toBe('counter'); + expect(hookCalls[0].exception).toBeNull(); + expect(hookCalls[0].sequenceId).toBe(1); + }); + + test('postRunStep receives exception when action fails', async () => { + const hookCalls: PostRunStepParams[] = []; + const adapter: LifecycleAdapter = { + async postRunStep(params) { hookCalls.push(params); }, + }; + + const graph = new GraphBuilder() + .withActions({ failingAction }) + .withTransitions(['failingAction', 'failingAction']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('failingAction') + .withHooks(adapter) + .build(); + + await expect(app.step()).rejects.toThrow('action failed'); + + expect(hookCalls).toHaveLength(1); + expect(hookCalls[0].exception).toBeInstanceOf(Error); + expect(hookCalls[0].exception!.message).toBe('action failed'); + expect(hookCalls[0].result).toBeNull(); + }); + + test('hooks fire on each step during iterate()', async () => { + const preCalls: string[] = []; + const postCalls: string[] = []; + const adapter: LifecycleAdapter = { + async preRunStep({ action }) { preCalls.push(action.name!); }, + async postRunStep({ action }) { postCalls.push(action.name!); }, + }; + + const app = buildCounterApp([adapter]); + + let stepCount = 0; + for await (const _step of app.iterate()) { + stepCount++; + if (stepCount >= 3) break; + } + + expect(preCalls).toEqual(['counter', 'counter', 'counter']); + expect(postCalls).toEqual(['counter', 'counter', 'counter']); + }); + + test('multiple adapters are all called', async () => { + const calls: string[] = []; + const adapter1: LifecycleAdapter = { + async preRunStep() { calls.push('adapter1'); }, + }; + const adapter2: LifecycleAdapter = { + async preRunStep() { calls.push('adapter2'); }, + }; + + const app = buildCounterApp([adapter1, adapter2]); + await app.step(); + + expect(calls).toEqual(['adapter1', 'adapter2']); + }); + + test('preRunStep receives correct appId and partitionKey', async () => { + let capturedParams: PreRunStepParams | null = null; + const adapter: LifecycleAdapter = { + async preRunStep(params) { capturedParams = params; }, + }; + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('my-app', 'my-partition') + .withHooks(adapter) + .build(); + + await app.step(); + + expect(capturedParams!.appId).toBe('my-app'); + expect(capturedParams!.partitionKey).toBe('my-partition'); + }); +}); + +// ============================================================================ +// PostApplicationCreateHook +// ============================================================================ + +describe('PostApplicationCreateHook via buildAsync()', () => { + test('postApplicationCreate is called on buildAsync', async () => { + const hookCalls: PostApplicationCreateParams[] = []; + const adapter: LifecycleAdapter = { + async postApplicationCreate(params) { hookCalls.push(params); }, + }; + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + await new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('test-app') + .withHooks(adapter) + .buildAsync(); + + expect(hookCalls).toHaveLength(1); + expect(hookCalls[0].appId).toBe('test-app'); + expect(hookCalls[0].entrypoint).toBe('counter'); + }); + + test('buildAsync propagates postApplicationCreate errors', async () => { + const adapter: LifecycleAdapter = { + async postApplicationCreate() { throw new Error('create hook failed'); }, + }; + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + await expect( + new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withHooks(adapter) + .buildAsync() + ).rejects.toThrow('postApplicationCreate hook(s) failed'); + }); +}); + +// ============================================================================ +// withHooks() Builder API +// ============================================================================ + +describe('ApplicationBuilder.withHooks()', () => { + test('withHooks accumulates adapters across multiple calls', async () => { + const calls: string[] = []; + const adapter1: LifecycleAdapter = { + async preRunStep() { calls.push('a1'); }, + }; + const adapter2: LifecycleAdapter = { + async preRunStep() { calls.push('a2'); }, + }; + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withHooks(adapter1) + .withHooks(adapter2) + .build(); + + await app.step(); + expect(calls).toEqual(['a1', 'a2']); + }); + + test('application works normally without any hooks', async () => { + const app = buildCounterApp(); + const result = await app.step(); + expect(result).not.toBeNull(); + expect(result!.state.count).toBe(1); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/parallelism-convenience.test.ts b/typescript/packages/burr-core/src/__tests__/parallelism-convenience.test.ts new file mode 100644 index 000000000..24c85e701 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/parallelism-convenience.test.ts @@ -0,0 +1,249 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Tests for MapActionsAndStates, MapActions, MapStates convenience classes. + */ + +import { z } from 'zod'; +import { action, createState, GraphBuilder } from '../index'; +import { + MapActionsAndStates, + MapActions, + MapStates, + RunnableGraph, + type ApplicationContext, +} from '../parallelism'; +import { StateInstance } from '../state'; +import { type ActionLike } from '../types'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const doubler = action({ + reads: z.object({ value: z.number() }), + writes: z.object({ value: z.number() }), + update: ({ state }) => state.update({ value: state.value * 2 }), +}); + +const tripler = action({ + reads: z.object({ value: z.number() }), + writes: z.object({ value: z.number() }), + update: ({ state }) => state.update({ value: state.value * 3 }), +}); + +const testContext: ApplicationContext = { + appId: 'parent-app', + partitionKey: 'test-pk', + sequenceId: 1, +}; + +// ============================================================================ +// MapActionsAndStates +// ============================================================================ + +describe('MapActionsAndStates', () => { + test('cartesian product of actions x states', async () => { + class DoublerTripler extends MapActionsAndStates { + get reads() { return ['values'] as const; } + get writes() { return ['results'] as const; } + + actions(): (ActionLike)[] { + return [doubler, tripler]; + } + + states( + state: StateInstance, + ): StateInstance[] { + const values: number[] = state.values; + return values.map((v: number) => + createState(z.object({ value: z.number() }), { value: v }) + ); + } + + reduce( + state: StateInstance, + resultStates: StateInstance[] + ): StateInstance { + return state.update({ results: resultStates.map(s => s.value) }); + } + } + + const parallelAction = new DoublerTripler(); + const state = createState( + z.object({ values: z.array(z.number()), results: z.array(z.number()).optional() }), + { values: [2, 5] } + ); + + const { state: finalState } = await parallelAction.execute(state, testContext); + // 2 actions x 2 states = 4 results + // doubler(2)=4, doubler(5)=10, tripler(2)=6, tripler(5)=15 + expect([...finalState.results].sort((a: number, b: number) => a - b)).toEqual([4, 6, 10, 15]); + }); +}); + +// ============================================================================ +// MapActions +// ============================================================================ + +describe('MapActions', () => { + test('runs multiple actions over same state', async () => { + class MultiTransform extends MapActions { + get reads() { return ['value'] as const; } + get writes() { return ['results'] as const; } + + actions(): ActionLike[] { + return [doubler, tripler]; + } + + reduce( + state: StateInstance, + resultStates: StateInstance[] + ): StateInstance { + return state.update({ results: resultStates.map(s => s.value) }); + } + } + + const parallelAction = new MultiTransform(); + const state = createState( + z.object({ value: z.number(), results: z.array(z.number()).optional() }), + { value: 10 } + ); + + const { state: finalState } = await parallelAction.execute(state, testContext); + // doubler(10)=20, tripler(10)=30 + expect(finalState.results.sort()).toEqual([20, 30]); + }); + + test('state() override transforms input state', async () => { + class TransformFirst extends MapActions { + get reads() { return ['value'] as const; } + get writes() { return ['results'] as const; } + + actions(): ActionLike[] { + return [doubler]; + } + + // Override state to modify before passing to action + state( + state: StateInstance, + ): StateInstance { + return state.update({ value: state.value + 100 }); + } + + reduce( + state: StateInstance, + resultStates: StateInstance[] + ): StateInstance { + return state.update({ results: resultStates.map(s => s.value) }); + } + } + + const parallelAction = new TransformFirst(); + const state = createState( + z.object({ value: z.number(), results: z.array(z.number()).optional() }), + { value: 5 } + ); + + const { state: finalState } = await parallelAction.execute(state, testContext); + // state.value was 5, transformed to 105, doubled to 210 + expect(finalState.results).toEqual([210]); + }); +}); + +// ============================================================================ +// MapStates +// ============================================================================ + +describe('MapStates', () => { + test('runs single action over multiple state variants', async () => { + class BatchDouble extends MapStates { + get reads() { return ['items'] as const; } + get writes() { return ['results'] as const; } + + action(): ActionLike { + return doubler; + } + + states( + state: StateInstance, + ): StateInstance[] { + const items: number[] = state.items; + return items.map(v => + createState(z.object({ value: z.number() }), { value: v }) + ); + } + + reduce( + state: StateInstance, + resultStates: StateInstance[] + ): StateInstance { + return state.update({ results: resultStates.map(s => s.value) }); + } + } + + const parallelAction = new BatchDouble(); + const state = createState( + z.object({ items: z.array(z.number()), results: z.array(z.number()).optional() }), + { items: [1, 2, 3, 4] } + ); + + const { state: finalState } = await parallelAction.execute(state, testContext); + expect(finalState.results).toEqual([2, 4, 6, 8]); + }); +}); + +// ============================================================================ +// SubGraphTask persistence cascading +// ============================================================================ + +describe('SubGraphTask persistence cascading', () => { + test('SubGraphTask cascades persister from parent context', async () => { + const { InMemoryPersister } = await import('../persistence'); + const persister = new InMemoryPersister(); + await persister.initialize(); + + const graph = new GraphBuilder() + .withActions({ doubler }) + .withTransitions(['doubler', null]) + .build(); + + const runnable = RunnableGraph.create(graph, 'doubler', ['doubler']); + + const { SubGraphTask } = await import('../parallelism'); + const task = new SubGraphTask({ + graph: runnable, + state: createState(z.object({ value: z.number() }), { value: 5 }), + applicationId: 'child-with-persist', + }); + + // Parent context has a persister + const context: ApplicationContext = { + appId: 'parent', + partitionKey: 'pk', + sequenceId: 1, + statePersister: persister, + }; + + await task.run(context); + + // The child app should have cascaded the persister + expect(persister.records.length).toBeGreaterThan(0); + expect(persister.records[0].appId).toBe('child-with-persist'); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/parallelism.test.ts b/typescript/packages/burr-core/src/__tests__/parallelism.test.ts new file mode 100644 index 000000000..755e6727d --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/parallelism.test.ts @@ -0,0 +1,268 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { z } from 'zod'; +import { action, createState, GraphBuilder } from '../index'; +import { + RunnableGraph, + SubGraphTask, + TaskBasedParallelAction, + type ApplicationContext, +} from '../parallelism'; +import { StateInstance } from '../state'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const doubler = action({ + reads: z.object({ value: z.number() }), + writes: z.object({ value: z.number() }), + update: ({ state }) => state.update({ value: state.value * 2 }), +}); + +const adder = action({ + reads: z.object({ value: z.number() }), + writes: z.object({ value: z.number() }), + inputs: z.object({ amount: z.number() }), + update: ({ state, inputs }) => state.update({ value: state.value + inputs.amount }), +}); + +function makeSimpleGraph() { + return new GraphBuilder() + .withActions({ doubler }) + .withTransitions(['doubler', null]) + .build(); +} + +const testContext: ApplicationContext = { + appId: 'parent-app', + partitionKey: 'test-pk', + sequenceId: 1, +}; + +// ============================================================================ +// RunnableGraph +// ============================================================================ + +describe('RunnableGraph', () => { + test('create wraps graph with entrypoint and haltAfter', () => { + const graph = makeSimpleGraph(); + const runnable = RunnableGraph.create(graph, 'doubler', ['doubler']); + + expect(runnable.graph).toBe(graph); + expect(runnable.entrypoint).toBe('doubler'); + expect(runnable.haltAfter).toEqual(['doubler']); + }); +}); + +// ============================================================================ +// SubGraphTask +// ============================================================================ + +describe('SubGraphTask', () => { + test('runs a single-action graph to completion', async () => { + const graph = makeSimpleGraph(); + const runnable = RunnableGraph.create(graph, 'doubler', ['doubler']); + const state = createState(z.object({ value: z.number() }), { value: 5 }); + + const task = new SubGraphTask({ + graph: runnable, + state, + applicationId: 'child-1', + }); + + const resultState = await task.run(testContext); + expect(resultState.value).toBe(10); + }); + + test('runs with inputs', async () => { + const graph = new GraphBuilder() + .withActions({ adder }) + .withTransitions(['adder', null]) + .build(); + + const runnable = RunnableGraph.create(graph, 'adder', ['adder']); + const state = createState(z.object({ value: z.number() }), { value: 3 }); + + const task = new SubGraphTask({ + graph: runnable, + state, + inputs: { amount: 7 }, + applicationId: 'child-2', + }); + + const resultState = await task.run(testContext); + expect(resultState.value).toBe(10); + }); + + test('runs multi-step graph', async () => { + const increment = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }), + }); + + const graph = new GraphBuilder() + .withActions({ increment }) + .withTransitions( + ['increment', 'increment', (s: any) => s.count < 3], + ['increment', null, (s: any) => s.count >= 3] + ) + .build(); + + const runnable = RunnableGraph.create(graph, 'increment', []); + const state = createState(z.object({ count: z.number() }), { count: 0 }); + + const task = new SubGraphTask({ + graph: runnable, + state, + applicationId: 'child-multi', + }); + + const resultState = await task.run(testContext); + expect(resultState.count).toBe(3); + }); +}); + +// ============================================================================ +// TaskBasedParallelAction +// ============================================================================ + +describe('TaskBasedParallelAction', () => { + test('executes multiple tasks in parallel and reduces', async () => { + const graph = makeSimpleGraph(); + const runnable = RunnableGraph.create(graph, 'doubler', ['doubler']); + + class FanOutDoubler extends TaskBasedParallelAction { + get reads() { return ['values'] as const; } + get writes() { return ['results'] as const; } + + tasks( + state: StateInstance, + context: ApplicationContext + ): SubGraphTask[] { + const values: number[] = state.values; + return values.map((v: number, i: number) => + new SubGraphTask({ + graph: runnable, + state: createState(z.object({ value: z.number() }), { value: v }), + applicationId: `${context.appId}-child-${i}`, + }) + ); + } + + reduce( + state: StateInstance, + states: StateInstance[] + ): StateInstance { + const results = states.map(s => s.value); + return state.update({ results }); + } + } + + const parallelAction = new FanOutDoubler(); + const state = createState( + z.object({ values: z.array(z.number()), results: z.array(z.number()).optional() }), + { values: [1, 2, 3, 4, 5] } + ); + + const { state: finalState } = await parallelAction.execute(state, testContext); + expect(finalState.results).toEqual([2, 4, 6, 8, 10]); + }); + + test('handles empty task list', async () => { + class EmptyTasks extends TaskBasedParallelAction { + get reads() { return [] as const; } + get writes() { return [] as const; } + + tasks(): SubGraphTask[] { + return []; + } + + reduce( + state: StateInstance, + _states: StateInstance[] + ): StateInstance { + return state; + } + } + + const parallelAction = new EmptyTasks(); + const state = createState(z.object({ x: z.number() }), { x: 42 }); + + const { state: finalState } = await parallelAction.execute(state, testContext); + expect(finalState.x).toBe(42); + }); + + test('tasks run concurrently (not sequentially)', async () => { + // Create an action with a small delay to verify concurrent execution + const slowAction = action({ + reads: z.object({ value: z.number() }), + writes: z.object({ value: z.number() }), + result: z.object({ computed: z.number() }), + run: async ({ state }) => { + await new Promise(r => setTimeout(r, 50)); + return { computed: state.value * 10 }; + }, + update: ({ result, state }) => state.update({ value: result.computed }), + }); + + const graph = new GraphBuilder() + .withActions({ slowAction }) + .withTransitions(['slowAction', null]) + .build(); + + const runnable = RunnableGraph.create(graph, 'slowAction', ['slowAction']); + + class ConcurrentTest extends TaskBasedParallelAction { + get reads() { return [] as const; } + get writes() { return ['results'] as const; } + + tasks(): SubGraphTask[] { + return [1, 2, 3].map((v, i) => + new SubGraphTask({ + graph: runnable, + state: createState(z.object({ value: z.number() }), { value: v }), + applicationId: `concurrent-${i}`, + }) + ); + } + + reduce( + state: StateInstance, + states: StateInstance[] + ): StateInstance { + return state.update({ results: states.map(s => s.value) }); + } + } + + const parallelAction = new ConcurrentTest(); + const state = createState( + z.object({ results: z.array(z.number()).optional() }), + {} + ); + + const start = Date.now(); + const { state: finalState } = await parallelAction.execute(state, testContext); + const elapsed = Date.now() - start; + + expect(finalState.results).toEqual([10, 20, 30]); + // If sequential: ~150ms. If concurrent: ~50ms. Allow margin. + expect(elapsed).toBeLessThan(120); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/persistence.test.ts b/typescript/packages/burr-core/src/__tests__/persistence.test.ts new file mode 100644 index 000000000..6373edad2 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/persistence.test.ts @@ -0,0 +1,258 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { z } from 'zod'; +import { action, createState, GraphBuilder, ApplicationBuilder } from '../index'; +import { InMemoryPersister } from '../persistence'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }), +}); + +// ============================================================================ +// InMemoryPersister - Unit Tests +// ============================================================================ + +describe('InMemoryPersister', () => { + test('save and load round-trip', async () => { + const persister = new InMemoryPersister(); + await persister.initialize(); + + await persister.save({ + partitionKey: 'pk', + appId: 'app1', + sequenceId: 1, + position: 'counter', + state: { count: 42 }, + status: 'completed', + }); + + const loaded = await persister.load('pk', 'app1'); + expect(loaded).not.toBeNull(); + expect(loaded!.state).toEqual({ count: 42 }); + expect(loaded!.sequenceId).toBe(1); + expect(loaded!.position).toBe('counter'); + expect(loaded!.status).toBe('completed'); + }); + + test('load returns latest completed state', async () => { + const persister = new InMemoryPersister(); + + await persister.save({ + partitionKey: 'pk', appId: 'app1', sequenceId: 1, + position: 'a', state: { count: 1 }, status: 'completed', + }); + await persister.save({ + partitionKey: 'pk', appId: 'app1', sequenceId: 2, + position: 'b', state: { count: 2 }, status: 'completed', + }); + await persister.save({ + partitionKey: 'pk', appId: 'app1', sequenceId: 3, + position: 'c', state: { count: 3 }, status: 'failed', + }); + + const loaded = await persister.load('pk', 'app1'); + expect(loaded!.sequenceId).toBe(2); // Latest completed, not the failed one + }); + + test('load at specific sequenceId', async () => { + const persister = new InMemoryPersister(); + + await persister.save({ + partitionKey: 'pk', appId: 'app1', sequenceId: 1, + position: 'a', state: { count: 1 }, status: 'completed', + }); + await persister.save({ + partitionKey: 'pk', appId: 'app1', sequenceId: 2, + position: 'b', state: { count: 2 }, status: 'completed', + }); + + const loaded = await persister.load('pk', 'app1', 1); + expect(loaded!.state).toEqual({ count: 1 }); + }); + + test('load returns null when not found', async () => { + const persister = new InMemoryPersister(); + const loaded = await persister.load('pk', 'nonexistent'); + expect(loaded).toBeNull(); + }); + + test('listAppIds returns unique app IDs for partition', async () => { + const persister = new InMemoryPersister(); + + await persister.save({ + partitionKey: 'pk1', appId: 'app1', sequenceId: 1, + position: 'a', state: {}, status: 'completed', + }); + await persister.save({ + partitionKey: 'pk1', appId: 'app2', sequenceId: 1, + position: 'a', state: {}, status: 'completed', + }); + await persister.save({ + partitionKey: 'pk2', appId: 'app3', sequenceId: 1, + position: 'a', state: {}, status: 'completed', + }); + + const ids = await persister.listAppIds('pk1'); + expect(ids.sort()).toEqual(['app1', 'app2']); + }); + + test('clear removes all records', async () => { + const persister = new InMemoryPersister(); + await persister.save({ + partitionKey: 'pk', appId: 'app1', sequenceId: 1, + position: 'a', state: {}, status: 'completed', + }); + + persister.clear(); + expect(persister.records).toHaveLength(0); + const loaded = await persister.load('pk', 'app1'); + expect(loaded).toBeNull(); + }); +}); + +// ============================================================================ +// PersisterHook - Integration with Application +// ============================================================================ + +describe('PersisterHook with Application', () => { + test('saves state after each step', async () => { + const persister = new InMemoryPersister(); + await persister.initialize(); + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .withIdentifiers('test-app', 'test-pk') + .withStatePersister(persister) + .build(); + + await app.step(); + await app.step(); + + expect(persister.records).toHaveLength(2); + expect(persister.records[0].appId).toBe('test-app'); + expect(persister.records[0].position).toBe('counter'); + expect(persister.records[0].status).toBe('completed'); + expect(persister.records[1].sequenceId).toBeGreaterThan(persister.records[0].sequenceId); + }); + + test('saves with failed status when action throws', async () => { + const persister = new InMemoryPersister(); + + const failingAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + result: z.object({}), + run: async () => { throw new Error('boom'); }, + update: ({ state }) => state, + }); + + const graph = new GraphBuilder() + .withActions({ failingAction }) + .withTransitions(['failingAction', 'failingAction']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('failingAction') + .withStatePersister(persister) + .build(); + + await expect(app.step()).rejects.toThrow('boom'); + + expect(persister.records).toHaveLength(1); + expect(persister.records[0].status).toBe('failed'); + }); +}); + +// ============================================================================ +// initializeFrom +// ============================================================================ + +describe('ApplicationBuilder.initializeFrom()', () => { + test('loads persisted state and resumes', async () => { + const persister = new InMemoryPersister(); + + // Save some state + await persister.save({ + partitionKey: 'pk', appId: 'app1', sequenceId: 3, + position: 'counter', state: { count: 10 }, status: 'completed', + }); + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const defaultState = createState(z.object({ count: z.number() }), { count: 0 }); + + const builder = await new ApplicationBuilder() + .withGraph(graph) + .initializeFrom({ + loader: persister, + partitionKey: 'pk', + appId: 'app1', + defaultState, + defaultEntrypoint: 'counter', + resumeAtNextAction: true, + }); + + // initializeFrom already sets entrypoint from defaultEntrypoint + const app = builder.build(); + + // State should be loaded from persistence + expect(app.state.count).toBe(10); + }); + + test('falls back to defaults when no persisted state', async () => { + const persister = new InMemoryPersister(); + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const defaultState = createState(z.object({ count: z.number() }), { count: 0 }); + + const builder = await new ApplicationBuilder() + .withGraph(graph) + .initializeFrom({ + loader: persister, + partitionKey: 'pk', + appId: 'nonexistent', + defaultState, + defaultEntrypoint: 'counter', + }); + + const app = builder.build(); + expect(app.state.count).toBe(0); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/serde.test.ts b/typescript/packages/burr-core/src/__tests__/serde.test.ts new file mode 100644 index 000000000..756642e1d --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/serde.test.ts @@ -0,0 +1,182 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + serializeValue, + deserializeValue, + serializeState, + deserializeState, +} from '../serde'; + +describe('serde - primitives', () => { + test('null and undefined pass through', () => { + expect(serializeValue(null)).toBeNull(); + expect(serializeValue(undefined)).toBeUndefined(); + expect(deserializeValue(null)).toBeNull(); + expect(deserializeValue(undefined)).toBeUndefined(); + }); + + test('strings, numbers, booleans pass through', () => { + expect(serializeValue('hello')).toBe('hello'); + expect(serializeValue(42)).toBe(42); + expect(serializeValue(true)).toBe(true); + expect(deserializeValue('hello')).toBe('hello'); + expect(deserializeValue(42)).toBe(42); + expect(deserializeValue(false)).toBe(false); + }); +}); + +describe('serde - Date', () => { + test('round-trips Date', () => { + const date = new Date('2025-01-15T10:30:00.000Z'); + const serialized = serializeValue(date); + expect(serialized).toEqual({ __serde_type: 'Date', value: '2025-01-15T10:30:00.000Z' }); + const deserialized = deserializeValue(serialized); + expect(deserialized).toBeInstanceOf(Date); + expect(deserialized.toISOString()).toBe('2025-01-15T10:30:00.000Z'); + }); +}); + +describe('serde - Map', () => { + test('round-trips Map', () => { + const map = new Map([['a', 1], ['b', 2]]); + const serialized = serializeValue(map); + expect(serialized.__serde_type).toBe('Map'); + const deserialized = deserializeValue(serialized); + expect(deserialized).toBeInstanceOf(Map); + expect(deserialized.get('a')).toBe(1); + expect(deserialized.get('b')).toBe(2); + }); + + test('Map with nested complex values', () => { + const map = new Map([['date', new Date('2025-01-01')]]); + const serialized = serializeValue(map); + const deserialized = deserializeValue(serialized); + expect(deserialized.get('date')).toBeInstanceOf(Date); + }); +}); + +describe('serde - Set', () => { + test('round-trips Set', () => { + const set = new Set([1, 2, 3]); + const serialized = serializeValue(set); + expect(serialized.__serde_type).toBe('Set'); + const deserialized = deserializeValue(serialized); + expect(deserialized).toBeInstanceOf(Set); + expect(deserialized.has(1)).toBe(true); + expect(deserialized.size).toBe(3); + }); +}); + +describe('serde - RegExp', () => { + test('round-trips RegExp', () => { + const regex = /foo.*bar/gi; + const serialized = serializeValue(regex); + expect(serialized).toEqual({ + __serde_type: 'RegExp', + value: { source: 'foo.*bar', flags: 'gi' }, + }); + const deserialized = deserializeValue(serialized); + expect(deserialized).toBeInstanceOf(RegExp); + expect(deserialized.source).toBe('foo.*bar'); + expect(deserialized.flags).toBe('gi'); + }); +}); + +describe('serde - BigInt', () => { + test('round-trips BigInt', () => { + const big = BigInt('9007199254740993'); + const serialized = serializeValue(big); + expect(serialized).toEqual({ __serde_type: 'BigInt', value: '9007199254740993' }); + const deserialized = deserializeValue(serialized); + expect(deserialized).toBe(BigInt('9007199254740993')); + }); +}); + +describe('serde - arrays', () => { + test('recursively serializes array contents', () => { + const arr = [1, new Date('2025-01-01'), 'hello']; + const serialized = serializeValue(arr); + expect(serialized[0]).toBe(1); + expect(serialized[1].__serde_type).toBe('Date'); + expect(serialized[2]).toBe('hello'); + const deserialized = deserializeValue(serialized); + expect(deserialized[1]).toBeInstanceOf(Date); + }); +}); + +describe('serde - nested objects', () => { + test('recursively serializes object values', () => { + const obj = { + name: 'test', + created: new Date('2025-01-01'), + tags: new Set(['a', 'b']), + nested: { deep: new Map([['k', 42]]) }, + }; + const serialized = serializeValue(obj); + const deserialized = deserializeValue(serialized); + expect(deserialized.name).toBe('test'); + expect(deserialized.created).toBeInstanceOf(Date); + expect(deserialized.tags).toBeInstanceOf(Set); + expect(deserialized.nested.deep).toBeInstanceOf(Map); + expect(deserialized.nested.deep.get('k')).toBe(42); + }); +}); + +describe('serde - state helpers', () => { + test('serializeState / deserializeState round-trip', () => { + const state = { + count: 42, + label: 'test', + timestamp: new Date('2025-06-01'), + items: [1, 2, 3], + }; + const serialized = serializeState(state); + const deserialized = deserializeState(serialized); + expect(deserialized.count).toBe(42); + expect(deserialized.label).toBe('test'); + expect(deserialized.timestamp).toBeInstanceOf(Date); + expect(deserialized.items).toEqual([1, 2, 3]); + }); +}); + +describe('serde - custom serializers', () => { + test('custom serializer overrides built-in', () => { + const options = { + customSerializers: new Map([ + ['Date', (v: Date) => ({ __serde_type: 'Date', value: v.getTime() })], + ]), + customDeserializers: new Map([ + ['Date', (v: number) => new Date(v)], + ]), + }; + const date = new Date('2025-01-01T00:00:00.000Z'); + const serialized = serializeValue(date, options); + expect(serialized.value).toBe(date.getTime()); + const deserialized = deserializeValue(serialized, options); + expect(deserialized).toBeInstanceOf(Date); + expect(deserialized.getTime()).toBe(date.getTime()); + }); +}); + +describe('serde - unknown tagged types', () => { + test('unknown tagged value is returned as-is', () => { + const tagged = { __serde_type: 'FutureType', value: { foo: 'bar' } }; + const result = deserializeValue(tagged); + expect(result).toEqual(tagged); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/state.test.ts b/typescript/packages/burr-core/src/__tests__/state.test.ts new file mode 100644 index 000000000..b70162930 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/state.test.ts @@ -0,0 +1,527 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { z } from 'zod'; +import { State, createState, createStateWithDefaults } from '../state'; + +// Test schema for structured state tests +const TestStateSchema = z.object({ + foo: z.string(), + bar: z.string().optional(), + count: z.number(), + messages: z.array(z.string()), + numbers: z.array(z.number()), +}); + +describe('State', () => { + // ========================================================================== + // Basic Access & Retrieval + // Matches Python: test_state_access, test_state_get, test_state_in + // ========================================================================== + + test('test_state_access', () => { + // Demonstrates: Direct property access via Proxy with typed state and runtime validation + const state = createState(TestStateSchema, { foo: 'bar', count: 0, messages: [], numbers: [] }); + expect(state.foo).toBe('bar'); + }); + + test('test_state_access_missing', () => { + const state = createState(TestStateSchema, { foo: 'bar', count: 0, messages: [], numbers: [] }); + // TS: Missing key returns undefined at runtime + expect((state as any).baz).toBeUndefined(); + }); + + test('test_state_in', () => { + const state = createState(TestStateSchema, { foo: 'bar', count: 0, messages: [], numbers: [] }); + // TS: has() works with Proxy for runtime existence checks (checks _data) + expect('foo' in state.data).toBe(true); + expect('baz' in state.data).toBe(false); + }); + + test('test_state_get_all', () => { + const state = createState( + z.object({ foo: z.string(), baz: z.string() }), + { foo: 'bar', baz: 'qux' } + ); + expect(state.data).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + test('test_state_keys_returns_list', () => { + // Matches Python: test_state_keys_returns_list + const state = createState( + z.object({ a: z.number(), b: z.number(), c: z.number() }), + { a: 1, b: 2, c: 3 } + ); + const keys = state.keys(); + + expect(Array.isArray(keys)).toBe(true); + expect(keys).toEqual(['a', 'b', 'c']); + + // Test with empty state + const emptyState = new State(z.object({}), {}); + expect(emptyState.keys()).toEqual([]); + }); + + // ========================================================================== + // State Mutations + // Matches Python: test_state_update, test_state_append, test_state_extend + // ========================================================================== + + test('test_state_init', () => { + const state = createState( + z.object({ foo: z.string(), baz: z.string() }), + { foo: 'bar', baz: 'qux' } + ); + expect(state.data).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + test('test_state_update', () => { + const state = createState( + z.object({ foo: z.string(), baz: z.string() }), + { foo: 'bar', baz: 'qux' } + ); + const updated = state.update({ foo: 'baz' }); + expect(updated.data).toEqual({ foo: 'baz', baz: 'qux' }); + }); + + test('test_state_append', () => { + // TS: Type-safe - can only append to array fields + const state = createState(TestStateSchema, { foo: 'bar', count: 0, messages: ['hello'], numbers: [] }); + const appended = state.append({ messages: 'world' }); + expect(appended.data).toEqual({ + foo: 'bar', + count: 0, + messages: ['hello', 'world'], + numbers: [], + }); + }); + + test('test_state_extend', () => { + // TS: Type-safe - can only extend array fields + const state = createState(TestStateSchema, { foo: 'bar', count: 0, messages: ['hello'], numbers: [] }); + const extended = state.extend({ messages: ['world', 'typescript'] }); + expect(extended.data).toEqual({ + foo: 'bar', + count: 0, + messages: ['hello', 'world', 'typescript'], + numbers: [], + }); + }); + + test('test_state_increment', () => { + // TS: Type-safe - can only increment number fields + const state = createState(TestStateSchema, { foo: 'bar', count: 1, messages: [], numbers: [] }); + const incremented = state.increment({ count: 2 }); + expect(incremented.count).toBe(3); + }); + + test('test_state_increment_creates_if_missing', () => { + // Demonstrates: increment is an upsert - creates field if missing (like Python) + // count is NOT in the initial schema - increment adds it dynamically + const state = createState( + z.object({ foo: z.any() }), + { foo: 'bar' } + ); + // increment() upserts: creates count with value 5 (field didn't exist before) + const incremented = state.increment({ count: 5 }); + expect(incremented.count).toBe(5); + }); + + // ========================================================================== + // Advanced Operations + // Matches Python: test_state_merge, test_state_subset + // ========================================================================== + + test('test_state_merge', () => { + // Use passthrough() to allow extra properties for testing merge + const state = createState( + z.object({ foo: z.string(), baz: z.string() }).passthrough(), + { foo: 'bar', baz: 'qux' } + ); + const other = createState( + z.object({ foo: z.string() }).passthrough(), + { foo: 'baz', quux: 'corge' } as any + ); + const merged = state.merge(other as any); + expect(merged.data).toEqual({ foo: 'baz', baz: 'qux', quux: 'corge' }); + }); + + // ========================================================================== + // Validation & Error Handling + // Matches Python: test_state_append_validate_failure, etc. + // ========================================================================== + + test('test_state_append_validate_failure', () => { + // TS: Runtime validation catches type errors + // Use passthrough() to test runtime validation (bypasses compile-time checks) + const state = createState(z.object({ foo: z.any() }).passthrough(), { foo: 'bar' }); + expect(() => state.append({ foo: 'baz' } as any)).toThrow("Cannot append to non-array field 'foo'"); + }); + + test('test_state_extend_validate_failure', () => { + // Use passthrough() to test runtime validation + const state = createState(z.object({ foo: z.any() }).passthrough(), { foo: 'bar' }); + expect(() => state.extend({ foo: ['baz', 'qux'] } as any)).toThrow("Cannot extend non-array field 'foo'"); + }); + + test('test_state_increment_validate_failure', () => { + // Use passthrough() to test runtime validation + const state = createState(z.object({ foo: z.any() }).passthrough(), { foo: 'bar' }); + expect(() => state.increment({ foo: 1 } as any)).toThrow("Cannot increment non-numeric field 'foo'"); + }); + + // ========================================================================== + // Immutability (TypeScript-specific) + // ========================================================================== + + test('state_mutations_preserve_immutability', () => { + // TS-specific: Demonstrates immutability + const original = createState( + z.object({ foo: z.string(), count: z.number(), messages: z.array(z.string()) }), + { foo: 'bar', count: 0, messages: ['hello'] } + ); + const updated = original.update({ foo: 'baz' }); + + // Original unchanged + expect(original.foo).toBe('bar'); + expect(updated.foo).toBe('baz'); + }); + + test('state_mutations_preserve_structural_sharing', () => { + // TS-specific: Tests copy-on-write behavior + // Note: structuredClone creates deep copies, so we test that unread fields + // are not modified during operations that don't touch them + const original = createState( + z.object({ + unchanged: z.object({ value: z.string() }), + modified: z.string() + }), + { unchanged: { value: 'test' }, modified: 'old' } + ); + const updated = original.update({ modified: 'new' }); + + // Values should be equal (deep equality) + expect(updated.unchanged).toEqual({ value: 'test' }); + expect(updated.modified).toBe('new'); + + // Original unchanged + expect(original.modified).toBe('old'); + }); + + test('state_append_creates_array_if_missing', () => { + // Demonstrates: append creates array if field doesn't exist + const state = createState(TestStateSchema, { foo: 'bar', count: 0, messages: [], numbers: [] }); + const appended = state.append({ numbers: 42 }); + expect(appended.numbers).toEqual([42]); + }); + + // ========================================================================== + // Serialization + // ========================================================================== + + test('test_state_serialize_deserialize', () => { + const schema = z.object({ + foo: z.string(), + count: z.number(), + items: z.array(z.number()) + }); + const state = createState(schema, { foo: 'bar', count: 42, items: [1, 2, 3] }); + const serialized = state.serialize(); + const deserialized = State.deserialize(schema, serialized); + + expect(deserialized.data).toEqual({ foo: 'bar', count: 42, items: [1, 2, 3] }); + }); + + test('test_state_serialize_complex_types', () => { + // TS: structuredClone handles Date, nested objects, etc. + const now = new Date(); + const state = createState( + z.object({ + timestamp: z.date(), + nested: z.object({ deep: z.object({ value: z.string() }) }), + array: z.array(z.number()) + }), + { + timestamp: now, + nested: { deep: { value: 'test' } }, + array: [1, 2, 3], + } + ); + + const serialized = state.serialize(); + expect(serialized.timestamp).toEqual(now); + expect(serialized.nested).toEqual({ deep: { value: 'test' } }); + }); + + // ========================================================================== + // Type Safety Demonstrations (compile-time, shown through usage) + // ========================================================================== + + test('type_safety_demonstrations', () => { + // This test demonstrates TypeScript's compile-time type safety + // The following would NOT compile (commented out to show): + + const StrictStateSchema = z.object({ + name: z.string(), + age: z.number(), + tags: z.array(z.string()), + }); + + const state = createState(StrictStateSchema, { name: 'Alice', age: 30, tags: [] }); + + // βœ… Valid: append string to tags (array of strings) + const s1 = state.append({ tags: 'typescript' }); + expect(s1.tags).toEqual(['typescript']); + + // βœ… Valid: increment age (number field) + const s2 = state.increment({ age: 1 }); + expect(s2.age).toBe(31); + + // ❌ Would NOT compile: append to non-array field + // const s3 = state.append({ age: 1 }); // TypeScript error! + + // ❌ Would NOT compile: increment non-number field + // const s4 = state.increment({ name: 1 }); // TypeScript error! + + // ❌ Would NOT compile: append wrong type to array + // const s5 = state.append({ tags: 123 }); // TypeScript error! + + // This test passes because the valid operations work correctly + expect(true).toBe(true); + }); + + // ========================================================================== + // Chaining Operations + // ========================================================================== + + test('test_state_chaining', () => { + // Demonstrates: fluent API with immutable operations + const state = createState(TestStateSchema, { foo: 'bar', count: 0, messages: [], numbers: [] }); + + const result = state + .update({ foo: 'baz' }) + .increment({ count: 5 }) + .append({ messages: 'hello' }) + .append({ messages: 'world' }); + + expect(result.data).toEqual({ + foo: 'baz', + count: 5, + messages: ['hello', 'world'], + numbers: [], + }); + + // Original unchanged + expect(state.foo).toBe('bar'); + expect(state.count).toBe(0); + }); + + // ========================================================================== + // createStateWithDefaults Tests + // Demonstrates: Power-user mode with Zod defaults + // ========================================================================== + + test('test_createStateWithDefaults_no_data', () => { + // Demonstrates: State created with Zod defaults, no explicit data needed + const SchemaWithDefaults = z.object({ + counter: z.number().default(0), + name: z.string().default('untitled'), + tags: z.array(z.string()).default([]), + }); + + const state = createStateWithDefaults(SchemaWithDefaults); + + expect(state.counter).toBe(0); + expect(state.name).toBe('untitled'); + expect(state.tags).toEqual([]); + }); + + test('test_createStateWithDefaults_partial_override', () => { + // Demonstrates: Partial data overrides some defaults + const SchemaWithDefaults = z.object({ + counter: z.number().default(0), + name: z.string().default('untitled'), + tags: z.array(z.string()).default([]), + }); + + const state = createStateWithDefaults(SchemaWithDefaults, { counter: 42 }); + + expect(state.counter).toBe(42); // Overridden + expect(state.name).toBe('untitled'); // Default + expect(state.tags).toEqual([]); // Default + }); + + test('test_createStateWithDefaults_full_data', () => { + // Demonstrates: Can still provide full data + const SchemaWithDefaults = z.object({ + counter: z.number().default(0), + name: z.string().default('untitled'), + }); + + const state = createStateWithDefaults(SchemaWithDefaults, { + counter: 99, + name: 'custom', + }); + + expect(state.counter).toBe(99); + expect(state.name).toBe('custom'); + }); + + // ========================================================================== + // State Subsetting + // ========================================================================== + + describe('subset', () => { + test('creates state with only specified keys', () => { + const state = createState( + z.object({ count: z.number(), name: z.string(), age: z.number() }), + { count: 0, name: 'Alice', age: 30 } + ); + + const subset = state.subset(['count', 'name']); + + expect(subset.data).toEqual({ count: 0, name: 'Alice' }); + expect(subset.data).not.toHaveProperty('age'); + }); + + test('handles empty key list', () => { + const state = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + const subset = state.subset([]); + + expect(subset.data).toEqual({}); + }); + + test('handles single key', () => { + const state = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + const subset = state.subset(['count']); + + expect(subset.data).toEqual({ count: 0 }); + }); + + test('throws when subsetting missing keys', () => { + const state = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + expect(() => { + state.subset(['count', 'nonexistent'] as any); + }).toThrow(/missing required keys.*\[nonexistent\]/i); + }); + + test('subset is independent from original', () => { + const state = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + const subset = state.subset(['count']); + + // Modifying subset doesn't affect original (both are immutable anyway) + const updated = subset.update({ count: 10 }); + + expect(updated.data.count).toBe(10); + expect(state.data.count).toBe(0); // Original unchanged + }); + + test('can chain subset operations', () => { + const state = createState( + z.object({ a: z.number(), b: z.number(), c: z.number(), d: z.number() }), + { a: 1, b: 2, c: 3, d: 4 } + ); + + const subset1 = state.subset(['a', 'b', 'c']); + const subset2 = subset1.subset(['a', 'b']); + + expect(subset2.data).toEqual({ a: 1, b: 2 }); + }); + + test('subset preserves data types', () => { + const state = createState( + z.object({ + count: z.number(), + name: z.string(), + active: z.boolean(), + tags: z.array(z.string()) + }), + { count: 42, name: 'Alice', active: true, tags: ['a', 'b'] } + ); + + const subset = state.subset(['count', 'tags']); + + expect(subset.data.count).toBe(42); + expect(subset.data.tags).toEqual(['a', 'b']); + expect(Array.isArray(subset.data.tags)).toBe(true); + }); + + test('throws with all missing keys listed', () => { + const state = createState( + z.object({ count: z.number() }), + { count: 0 } + ); + + expect(() => { + state.subset(['name', 'age', 'level'] as any); + }).toThrow(/missing required keys.*\[name, age, level\]/i); + }); + + test('throws when some keys present and some missing', () => { + const state = createState( + z.object({ count: z.number(), name: z.string() }), + { count: 0, name: 'Alice' } + ); + + expect(() => { + state.subset(['count', 'age', 'level'] as any); + }).toThrow(/missing required keys.*\[age, level\]/i); + }); + + test('does not throw when all keys present', () => { + const state = createState( + z.object({ count: z.number(), name: z.string(), age: z.number() }), + { count: 0, name: 'Alice', age: 30 } + ); + + expect(() => { + state.subset(['count', 'name']); + }).not.toThrow(); + }); + + test('subset result only contains requested keys', () => { + const state = createState( + z.object({ count: z.number(), name: z.string(), age: z.number() }), + { count: 0, name: 'Alice', age: 30 } + ); + + const subset = state.subset(['count', 'name']); + + expect(Object.keys(subset.data)).toEqual(['count', 'name']); + expect(subset.data).not.toHaveProperty('age'); + }); + }); +}); + diff --git a/typescript/packages/burr-core/src/__tests__/streaming.test.ts b/typescript/packages/burr-core/src/__tests__/streaming.test.ts new file mode 100644 index 000000000..4af5c177b --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/streaming.test.ts @@ -0,0 +1,297 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Streaming Actions Tests + * + * Tests for: + * - StreamingAction class and streamingAction() factory + * - StreamingResultContainer iteration and .get() + * - Application.streamStep() with streaming and non-streaming actions + * - Integration with lifecycle hooks + */ + +import { z } from 'zod'; +import { action, createState, GraphBuilder, ApplicationBuilder } from '../index'; +import { + StreamingAction, + streamingAction, + StreamingResultContainer, + isStreamingAction, +} from '../streaming'; + +// ============================================================================ +// StreamingAction Unit Tests +// ============================================================================ + +describe('StreamingAction', () => { + test('streamingAction factory creates a StreamingAction', () => { + const sa = streamingAction({ + reads: z.object({ prompt: z.string() }), + writes: z.object({ response: z.string() }), + result: z.object({ token: z.string(), full: z.string() }), + async *streamRun() { + let full = ''; + for (const token of ['Hello', ' ', 'World']) { + full += token; + yield { token, full }; + } + }, + update: ({ result, state }) => state.update({ response: result.full }), + }); + + expect(sa).toBeInstanceOf(StreamingAction); + expect(sa.streaming).toBe(true); + expect(sa.reads).toEqual(['prompt']); + expect(sa.writes).toEqual(['response']); + }); + + test('isStreamingAction type guard', () => { + const sa = streamingAction({ + result: z.object({ x: z.number() }), + async *streamRun() { yield { x: 1 }; }, + update: ({ state }) => state, + }); + + const regular = action({ + update: ({ state }) => state, + }); + + expect(isStreamingAction(sa)).toBe(true); + expect(isStreamingAction(regular)).toBe(false); + }); + + test('run() consumes generator and returns final result', async () => { + const sa = streamingAction({ + reads: z.object({ input: z.string() }), + writes: z.object({}), + result: z.object({ value: z.string() }), + async *streamRun() { + yield { value: 'partial' }; + yield { value: 'final' }; + }, + // @ts-expect-error - empty writes + update: ({ state }) => state, + }); + + const state = createState(z.object({ input: z.string() }), { input: 'test' }); + const result = await sa.run({ state: state as any, inputs: undefined }); + expect(result.value).toBe('final'); + }); + + test('withName creates a named copy', () => { + const sa = streamingAction({ + result: z.object({ x: z.number() }), + async *streamRun() { yield { x: 1 }; }, + update: ({ state }) => state, + }); + + const named = sa.withName('myStream'); + expect(named.name).toBe('myStream'); + expect(sa.name).toBeUndefined(); + }); +}); + +// ============================================================================ +// StreamingResultContainer Unit Tests +// ============================================================================ + +describe('StreamingResultContainer', () => { + test('iterates over intermediate results', async () => { + async function* gen(): AsyncGenerator<{ n: number }, void, undefined> { + yield { n: 1 }; + yield { n: 2 }; + yield { n: 3 }; + } + + const state = createState(z.object({}), {}); + const container = new StreamingResultContainer( + gen(), + state as any, + (_result, state) => state, + ); + + const results: number[] = []; + for await (const item of container) { + results.push(item.n); + } + + expect(results).toEqual([1, 2, 3]); + }); + + test('.get() returns final result and state', async () => { + async function* gen(): AsyncGenerator<{ n: number }, void, undefined> { + yield { n: 1 }; + yield { n: 2 }; + } + + const state = createState(z.object({ total: z.number() }), { total: 0 }); + const container = new StreamingResultContainer( + gen(), + state as any, + (result, state) => state.update({ total: result.n }), + ); + + const { result, state: finalState } = await container.get(); + expect(result.n).toBe(2); + expect(finalState.total).toBe(2); + }); + + test('.get() auto-consumes if not iterated', async () => { + async function* gen(): AsyncGenerator<{ value: string }, void, undefined> { + yield { value: 'a' }; + yield { value: 'b' }; + } + + const state = createState(z.object({}), {}); + const container = new StreamingResultContainer( + gen(), + state as any, + (_result, state) => state, + ); + + // Call .get() directly without iterating + const { result } = await container.get(); + expect(result.value).toBe('b'); + }); + + test('passThrough wraps a non-streaming result', async () => { + const state = createState(z.object({ x: z.number() }), { x: 42 }); + const container = StreamingResultContainer.passThrough( + { value: 'done' }, + state as any + ); + + const results: string[] = []; + for await (const item of container) { + results.push(item.value); + } + + expect(results).toEqual(['done']); + const { result, state: finalState } = await container.get(); + expect(result.value).toBe('done'); + expect(finalState.x).toBe(42); + }); +}); + +// ============================================================================ +// Application.streamStep() Integration +// ============================================================================ + +describe('Application.streamStep()', () => { + test('streaming action yields intermediate results via streamStep', async () => { + const streamCounter = streamingAction({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number(), tokens: z.string() }), + result: z.object({ token: z.string(), accumulated: z.string() }), + async *streamRun() { + let acc = ''; + for (const t of ['a', 'b', 'c']) { + acc += t; + yield { token: t, accumulated: acc }; + } + }, + update: ({ result, state }) => + state.update({ count: state.count + 1, tokens: result.accumulated }), + }); + + const graph = new GraphBuilder() + .withActions({ streamCounter }) + .withTransitions(['streamCounter', 'streamCounter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState( + createState( + z.object({ count: z.number(), tokens: z.string().optional() }), + { count: 0 } + ) + ) + .withEntrypoint('streamCounter') + .build(); + + const result = await app.streamStep(); + expect(result).not.toBeNull(); + + const intermediates: string[] = []; + for await (const item of result!.stream) { + intermediates.push(item.token); + } + + expect(intermediates).toEqual(['a', 'b', 'c']); + + const { result: finalResult, state } = await result!.stream.get(); + expect(finalResult.accumulated).toBe('abc'); + expect(state.count).toBe(1); + expect(state.tokens).toBe('abc'); + }); + + test('non-streaming action works with streamStep', async () => { + const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }), + }); + + const graph = new GraphBuilder() + .withActions({ counter }) + .withTransitions(['counter', 'counter']) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('counter') + .build(); + + const result = await app.streamStep(); + expect(result).not.toBeNull(); + + // Non-streaming action: passThrough container yields the result once + const { state } = await result!.stream.get(); + expect(state.count).toBe(1); + }); + + test('streamStep returns null at terminal state', async () => { + const terminal = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }), + }); + + const graph = new GraphBuilder() + .withActions({ terminal }) + .withTransitions(['terminal', null]) + .build(); + + const app = new ApplicationBuilder() + .withGraph(graph) + .withState(createState(z.object({ count: z.number() }), { count: 0 })) + .withEntrypoint('terminal') + .build(); + + // First streamStep executes + const first = await app.streamStep(); + expect(first).not.toBeNull(); + await first!.stream.get(); + + // Second streamStep: terminal + const second = await app.streamStep(); + expect(second).toBeNull(); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/tracing.test.ts b/typescript/packages/burr-core/src/__tests__/tracing.test.ts new file mode 100644 index 000000000..44f317fc2 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/tracing.test.ts @@ -0,0 +1,259 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + ActionSpan, + TracerFactory, + ActionSpanTracer, + getCurrentTracer, + runWithTracer, + trace, + type PreStartSpanHook, + type PostEndSpanHook, + type DoLogAttributeHook, +} from '../tracing'; + +// ============================================================================ +// ActionSpan +// ============================================================================ + +describe('ActionSpan', () => { + test('creates span with uid', () => { + const span = new ActionSpan('myAction', 1, 'root'); + expect(span.action).toBe('myAction'); + expect(span.actionSequenceId).toBe(1); + expect(span.name).toBe('root'); + expect(span.parent).toBeNull(); + expect(span.uid).toContain('myAction:1:root'); + }); + + test('spawn creates child span', () => { + const root = new ActionSpan('act', 1, 'root'); + const child = root.spawn('child1'); + + expect(child.parent).toBe(root); + expect(child.name).toBe('child1'); + expect(child.action).toBe('act'); + expect(root.childCount).toBe(1); + }); + + test('spawn multiple children increments childCount', () => { + const root = new ActionSpan('act', 1, 'root'); + root.spawn('c1'); + root.spawn('c2'); + root.spawn('c3'); + expect(root.childCount).toBe(3); + }); +}); + +// ============================================================================ +// TracerFactory +// ============================================================================ + +describe('TracerFactory', () => { + test('creates span tracers with root span as parent', () => { + const factory = new TracerFactory('myAction', 5, 'app1', 'pk'); + const tracer = factory.createSpan('sub_operation'); + + expect(tracer.span.name).toBe('sub_operation'); + expect(tracer.span.parent).toBe(factory.rootSpan); + }); + + test('createSpan increments root child count', () => { + const factory = new TracerFactory('act', 1, 'app1', undefined); + factory.createSpan('s1'); + factory.createSpan('s2'); + expect(factory.rootSpan.childCount).toBe(2); + }); +}); + +// ============================================================================ +// ActionSpanTracer with hooks +// ============================================================================ + +describe('ActionSpanTracer', () => { + test('calls preStartSpan hook on start()', async () => { + const started: string[] = []; + const hook: PreStartSpanHook = { + async preStartSpan({ span }) { started.push(span.name); }, + }; + + const span = new ActionSpan('act', 1, 'test_span'); + const tracer = new ActionSpanTracer(span, [hook], 'app1', undefined); + + await tracer.start(); + expect(started).toEqual(['test_span']); + }); + + test('calls postEndSpan hook on end()', async () => { + const ended: string[] = []; + const hook: PostEndSpanHook = { + async postEndSpan({ span }) { ended.push(span.name); }, + }; + + const span = new ActionSpan('act', 1, 'test_span'); + const tracer = new ActionSpanTracer(span, [hook], 'app1', undefined); + + await tracer.start(); + await tracer.end(); + expect(ended).toEqual(['test_span']); + }); + + test('calls doLogAttributes hook', async () => { + const logged: Record[] = []; + const hook: DoLogAttributeHook = { + async doLogAttributes({ attributes }) { logged.push(attributes); }, + }; + + const span = new ActionSpan('act', 1, 'test_span'); + const tracer = new ActionSpanTracer(span, [hook], 'app1', undefined); + + await tracer.logAttributes({ key: 'value' }); + expect(logged).toEqual([{ key: 'value' }]); + }); + + test('start and end are idempotent', async () => { + let startCount = 0; + let endCount = 0; + const hook: PreStartSpanHook & PostEndSpanHook = { + async preStartSpan() { startCount++; }, + async postEndSpan() { endCount++; }, + }; + + const span = new ActionSpan('act', 1, 'test_span'); + const tracer = new ActionSpanTracer(span, [hook], 'app1', undefined); + + await tracer.start(); + await tracer.start(); // second call should be no-op + await tracer.end(); + await tracer.end(); // second call should be no-op + + expect(startCount).toBe(1); + expect(endCount).toBe(1); + }); +}); + +// ============================================================================ +// AsyncLocalStorage context +// ============================================================================ + +describe('Tracer context (AsyncLocalStorage)', () => { + test('getCurrentTracer returns undefined outside context', () => { + expect(getCurrentTracer()).toBeUndefined(); + }); + + test('runWithTracer sets tracer in context', () => { + const factory = new TracerFactory('act', 1, 'app1', undefined); + + runWithTracer(factory, () => { + const current = getCurrentTracer(); + expect(current).toBe(factory); + }); + + // Outside context, undefined again + expect(getCurrentTracer()).toBeUndefined(); + }); + + test('nested runWithTracer scopes correctly', () => { + const outer = new TracerFactory('outer', 1, 'app1', undefined); + const inner = new TracerFactory('inner', 2, 'app1', undefined); + + runWithTracer(outer, () => { + expect(getCurrentTracer()).toBe(outer); + + runWithTracer(inner, () => { + expect(getCurrentTracer()).toBe(inner); + }); + + expect(getCurrentTracer()).toBe(outer); + }); + }); +}); + +// ============================================================================ +// trace() wrapper +// ============================================================================ + +describe('trace() wrapper', () => { + test('runs function without tracer in context', async () => { + const fn = trace(async (x: number) => x * 2); + const result = await fn(5); + expect(result).toBe(10); + }); + + test('creates span when tracer is in context', async () => { + const started: string[] = []; + const ended: string[] = []; + const hooks: (PreStartSpanHook & PostEndSpanHook)[] = [{ + async preStartSpan({ span }) { started.push(span.name); }, + async postEndSpan({ span }) { ended.push(span.name); }, + }]; + + const factory = new TracerFactory('act', 1, 'app1', undefined, hooks); + + const tracedFn = trace( + async (x: number) => x + 1, + { spanName: 'compute' } + ); + + const result = await runWithTracer(factory, () => tracedFn(10)); + expect(result).toBe(11); + expect(started).toEqual(['compute']); + expect(ended).toEqual(['compute']); + }); + + test('captures inputs and outputs when configured', async () => { + const logged: Record[] = []; + const hooks: DoLogAttributeHook[] = [{ + async doLogAttributes({ attributes }) { logged.push(attributes); }, + }]; + + const factory = new TracerFactory('act', 1, 'app1', undefined, hooks); + + const tracedFn = trace( + async (a: number, b: number) => a + b, + { spanName: 'add', captureInputs: true, captureOutputs: true } + ); + + await runWithTracer(factory, () => tracedFn(3, 4)); + expect(logged).toEqual([ + { inputs: [3, 4] }, + { output: 7 }, + ]); + }); + + test('ends span even on error', async () => { + const ended: string[] = []; + const hooks: (PreStartSpanHook & PostEndSpanHook)[] = [{ + async preStartSpan() {}, + async postEndSpan({ span }) { ended.push(span.name); }, + }]; + + const factory = new TracerFactory('act', 1, 'app1', undefined, hooks); + + const tracedFn = trace( + async () => { throw new Error('boom'); }, + { spanName: 'failing' } + ); + + await expect( + runWithTracer(factory, () => tracedFn()) + ).rejects.toThrow('boom'); + + expect(ended).toEqual(['failing']); + }); +}); diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/README.md b/typescript/packages/burr-core/src/__tests__/type-tests/README.md new file mode 100644 index 000000000..b0eb8a324 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/README.md @@ -0,0 +1,77 @@ +# Type Tests + +Tests that verify compile-time type safety using TypeScript's compiler API. + +## Running Tests + +```bash +npm run test:types +``` + +## Writing a Test + +Create a `.ts` file in the appropriate category directory (`actions/`, `state/`, `graph/`, etc.): + +```typescript +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", // "pass" or "fail" + errorCode: "TS2769", // Required for "fail" tests + errorPattern: "Property 'z' is missing", // Substring to match + category: "actions", + description: "Action must write all fields" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +const bad = action({ + writes: z.object({ y: z.number(), z: z.number() }), + update: ({ state }) => state.update({ y: 1 }) // Missing 'z' +}); +``` + +**Important:** Everything before `// START_TEST` is metadata. Everything after is compiled and type-checked. + +**Pass tests** should compile without errors. + +**Fail tests** must specify: +- `errorCode`: TypeScript error code (e.g. "TS2769") +- `errorPattern`: Substring that must appear in error message + +## Test Organization + +Tests are organized by subject area, not pass/fail: +- `actions/` - Action definitions, reads/writes validation +- `state/` - State mutations, type narrowing, restrictions +- `graph/` - Graph builder, transitions +- `application/` - Application builder API + +File names should be descriptive (e.g. `missing-writes.ts`, not `test1.ts`). + +## How It Works + +1. Framework discovers all `.ts` files in this directory +2. Parses `TEST_META` to get expectations +3. Compiles code after `// START_TEST` using TypeScript Compiler API +4. Validates diagnostics match expectations +5. Reports results as Jest tests + +Tests run in parallel for speed (~2-3s for all tests). + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/actions/chained-update-type-error.ts b/typescript/packages/burr-core/src/__tests__/type-tests/actions/chained-update-type-error.ts new file mode 100644 index 000000000..30bcf34aa --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/actions/chained-update-type-error.ts @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2769", + errorPattern: "not assignable", + category: "actions", + description: "Type mismatch shows actual type error (string vs boolean, not undefined)" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +// This should error with: Type 'string' is not assignable to type 'boolean' +// NOT: Type 'undefined' is not assignable to type 'boolean' +action({ + reads: z.object({ a: z.string() }), + writes: z.object({ b: z.number(), c: z.boolean() }), + update: ({ state }) => { + return state.update({ b: 42 }).update({ c: 'wrong' }); + } +}); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/actions/missing-writes.ts b/typescript/packages/burr-core/src/__tests__/type-tests/actions/missing-writes.ts new file mode 100644 index 000000000..1355eafa8 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/actions/missing-writes.ts @@ -0,0 +1,35 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2769", + errorPattern: "Property 'z' is missing", + category: "actions", + description: "Action must write all declared fields in writes schema" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +// This should fail: update only returns { y } but writes declares { y, z } +const actionMissingWrites = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number(), z: z.number() }), + update: ({ state }) => state.update({ y: state.x }) +}); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/actions/must-provide-run-with-result.ts b/typescript/packages/burr-core/src/__tests__/type-tests/actions/must-provide-run-with-result.ts new file mode 100644 index 000000000..e617668bc --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/actions/must-provide-run-with-result.ts @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2769", + errorPattern: "run", + category: "actions", + description: "Must provide run when result is specified" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + result: z.object({ value: z.number() }), + update: ({ state }) => state.update({ y: 0 }) + // Missing run function +}); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/actions/run-optional-without-result.ts b/typescript/packages/burr-core/src/__tests__/type-tests/actions/run-optional-without-result.ts new file mode 100644 index 000000000..70d7ec5de --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/actions/run-optional-without-result.ts @@ -0,0 +1,34 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "actions", + description: "Can omit run when result is not specified" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +const simpleAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => state.update({ y: state.x }) +}); + +const _unused = simpleAction; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/actions/update-covariance.ts b/typescript/packages/burr-core/src/__tests__/type-tests/actions/update-covariance.ts new file mode 100644 index 000000000..248d98441 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/actions/update-covariance.ts @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "actions", + description: "Action update can return state with extra fields beyond writes" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +// Covariance allows extra fields +const covariantAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => state.update({ y: state.x }) + // Returns {x, y} but writes only requires {y} - this is OK! +}); + +const _unused = covariantAction; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/actions/update-preserves-writable-schema.ts b/typescript/packages/burr-core/src/__tests__/type-tests/actions/update-preserves-writable-schema.ts new file mode 100644 index 000000000..d4fde4bc7 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/actions/update-preserves-writable-schema.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "actions", + description: "Action update function preserves writable schema type" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +const testAction = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => { + const updated = state.update({ y: 5 }); + return updated; + } +}); + +const _unused = testAction; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/actions/wrong-result-type.ts b/typescript/packages/burr-core/src/__tests__/type-tests/actions/wrong-result-type.ts new file mode 100644 index 000000000..ca0c37873 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/actions/wrong-result-type.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2769", + errorPattern: "Type 'string' is not assignable to type 'number'", + category: "actions", + description: "Action run function must return correct result type" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +// This should fail: run returns string but result schema expects number +const actionWrongType = action({ + reads: z.object({}), + writes: z.object({ x: z.number() }), + result: z.object({ value: z.number() }), + run: async () => ({ value: 'wrong' }), + update: ({ result }) => ({ x: result.value }) +}); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/builder-immutability.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/builder-immutability.ts new file mode 100644 index 000000000..a0f3550f3 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/builder-immutability.ts @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "application", + description: "Method chaining is immutable - each call returns new instance" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const action1 = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => state.update({ y: state.x }) +}); + +const graph = new GraphBuilder() + .withActions({ action1 }) + .build(); + +const state = createState( + z.object({ x: z.number(), y: z.number() }), + { x: 5, y: 0 } +); + +const builder1 = new ApplicationBuilder(); +const builder2 = builder1.withGraph(graph); +const builder3 = builder2.withEntrypoint('action1'); +const builder4 = builder3.withState(state); + +// Each builder is a different instance +const _b1 = builder1; +const _b2 = builder2; +const _b3 = builder3; +const _b4 = builder4; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/compatible-state-superset.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/compatible-state-superset.ts new file mode 100644 index 000000000..c50d0bdb9 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/compatible-state-superset.ts @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "application", + description: "withState then withGraph with superset state should pass" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const counter = action({ + reads: z.object({ counter: z.number() }), + writes: z.object({ counter: z.number() }), + update: ({ state }) => state.update({ counter: state.counter + 1 }) +}); + +const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + +const app = new ApplicationBuilder() + .withState(createState( + z.object({ counter: z.number(), extra: z.string() }), + { counter: 0, extra: 'test' } + )) + .withGraph(graph); // State has counter + extra, graph only needs counter - OK! + +const _unused = app; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/graph-state-exact-match.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/graph-state-exact-match.ts new file mode 100644 index 000000000..f474d85d9 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/graph-state-exact-match.ts @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "application", + description: "withGraph then withState with exact match should pass" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const counter = action({ + reads: z.object({ counter: z.number() }), + writes: z.object({ counter: z.number() }), + update: ({ state }) => state.update({ counter: state.counter + 1 }) +}); + +const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + +const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(createState( + z.object({ counter: z.number() }), + { counter: 0 } + )); + +const _unused = app; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/graph-state-incompatible-reverse.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/graph-state-incompatible-reverse.ts new file mode 100644 index 000000000..215815785 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/graph-state-incompatible-reverse.ts @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2345", + errorPattern: "State schema must extend graph requirements", + category: "application", + description: "withGraph then withState with incompatible state should fail" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const counter = action({ + reads: z.object({ counter: z.number() }), + writes: z.object({ counter: z.number() }), + update: ({ state }) => state.update({ counter: state.counter + 1 }) +}); + +const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + +new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(createState( + z.object({ WRONG: z.number() }), + { WRONG: 0 } + )); // Error: state has WRONG but graph needs counter + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/incompatible-state-graph.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/incompatible-state-graph.ts new file mode 100644 index 000000000..33b6b863e --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/incompatible-state-graph.ts @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2345", + errorPattern: "State schema must extend graph requirements", + category: "application", + description: "withState then withGraph with incompatible state should fail" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const counter = action({ + reads: z.object({ counter: z.number() }), + writes: z.object({ counter: z.number() }), + update: ({ state }) => state.update({ counter: state.counter + 1 }) +}); + +const graph = new GraphBuilder() + .withActions({ counter }) + .build(); + +new ApplicationBuilder() + .withState(createState( + z.object({ WRONG: z.number() }), + { WRONG: 0 } + )) + .withGraph(graph); // Error: state has WRONG but graph needs counter + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/state-all-fields-some-optional.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/state-all-fields-some-optional.ts new file mode 100644 index 000000000..be6de8b40 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/state-all-fields-some-optional.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2024-2025 Elijah ben Izzy + * SPDX-License-Identifier: Apache-2.0 + */ +export const TEST_META = { + type: "pass", + category: "application", + description: "State can declare all graph fields with some as optional" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const counter = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) +}); + +const setLevel = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ level: z.string() }), + update: ({ state }) => state.update({ level: 'newLevel' }) +}); + +const graph = new GraphBuilder() + .withActions({ counter, setLevel }) + .build(); + +// Graph type: { count: number, level: string } (both required in graph) +// State declares both but level is optional (will be created by setLevel) +const state = createState( + z.object({ + count: z.number(), + level: z.string().optional() // Declared but optional + }), + { count: 0 } // level is undefined initially +); + +const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('counter') + .withState(state) + .build(); + +const _unused = app; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/type-inference-from-graph.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/type-inference-from-graph.ts new file mode 100644 index 000000000..99b107a49 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/type-inference-from-graph.ts @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "application", + description: "Type inference from withGraph works" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const action1 = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.string() }), + update: ({ state }) => state.update({ y: 'test' }) +}); + +const graph = new GraphBuilder() + .withActions({ action1 }) + .build(); + +const state = createState( + z.object({ x: z.number(), y: z.string() }), + { x: 5, y: 'initial' } +); + +const app = new ApplicationBuilder() + .withGraph(graph) + .withEntrypoint('action1') + .withState(state) + .build(); + +const _unused = app; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/application/type-inference-from-state.ts b/typescript/packages/burr-core/src/__tests__/type-tests/application/type-inference-from-state.ts new file mode 100644 index 000000000..b05b335b5 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/application/type-inference-from-state.ts @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "application", + description: "Type inference from withState works" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder, ApplicationBuilder, createState } from '../../../index'; + +const action1 = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) +}); + +const graph = new GraphBuilder() + .withActions({ action1 }) + .build(); + +const state = createState( + z.object({ count: z.number() }), + { count: 0 } +); + +const app = new ApplicationBuilder() + .withState(state) + .withGraph(graph) + .withEntrypoint('action1') + .build(); + +const _unused = app; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/framework.ts b/typescript/packages/burr-core/src/__tests__/type-tests/framework.ts new file mode 100644 index 000000000..b6eaeb347 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/framework.ts @@ -0,0 +1,454 @@ +/** + * Type Testing Framework + * + * Uses TypeScript Compiler API to validate type tests. + * Each test file has JSON metadata on line 1, followed by "// START_TEST", then TypeScript code. + */ + +import * as ts from 'typescript'; +import * as fs from 'fs'; +import * as path from 'path'; + +export interface TestMetadata { + type: 'pass' | 'fail'; + errorCode?: string; // e.g. "TS2769" - required for "fail" + errorPattern?: string; // Substring to match - required for "fail" + category: string; // e.g. "actions", "state", "graph" + description?: string; // Human-readable description +} + +export interface TestResult { + filepath: string; + passed: boolean; + message: string; + duration: number; + metadata: TestMetadata; +} + +/** + * Parse a test file into metadata and code + * + * Expected format: + * // Optional: Apache license header (ignored) + * export const TEST_META = { type: "fail", ... }; + * // START_TEST + * ... TypeScript code ... + */ +export function parseTestFile(filepath: string): { metadata: TestMetadata; code: string } { + const content = fs.readFileSync(filepath, 'utf-8'); + + // Find TEST_META export (skip any comments/license headers before it) + const metaMatch = content.match(/export\s+const\s+TEST_META\s*=\s*({[^;]+});/s); + if (!metaMatch) { + throw new Error( + `❌ INVALID TEST FILE: ${filepath}\n` + + ` Must export TEST_META constant.\n` + + ` Expected format:\n` + + ` export const TEST_META = { type: "pass", category: "...", ... };` + ); + } + + // Parse the metadata object + const metadataStr = metaMatch[1]; + let metadata: TestMetadata; + try { + // Use Function constructor to safely eval the object literal + metadata = new Function(`return ${metadataStr}`)(); + } catch (e) { + throw new Error( + `❌ INVALID TEST_META in ${filepath}\n` + + ` Failed to parse metadata object: ${e instanceof Error ? e.message : String(e)}\n` + + ` Metadata string: ${metadataStr.substring(0, 100)}...` + ); + } + + // Validate metadata structure + if (!metadata.type) { + throw new Error( + `❌ INVALID TEST_META in ${filepath}\n` + + ` Missing required field: "type" (must be "pass" or "fail")` + ); + } + + if (metadata.type !== 'pass' && metadata.type !== 'fail') { + throw new Error( + `❌ INVALID TEST_META in ${filepath}\n` + + ` Invalid type: "${metadata.type}" (must be "pass" or "fail")` + ); + } + + if (!metadata.category) { + throw new Error( + `❌ INVALID TEST_META in ${filepath}\n` + + ` Missing required field: "category" (e.g. "actions", "state", "graph")` + ); + } + + if (metadata.type === 'fail') { + if (!metadata.errorCode) { + throw new Error( + `❌ INVALID TEST_META in ${filepath}\n` + + ` "fail" tests require "errorCode" field (e.g. "TS2769")` + ); + } + if (!metadata.errorPattern) { + throw new Error( + `❌ INVALID TEST_META in ${filepath}\n` + + ` "fail" tests require "errorPattern" field (substring to match in error message)` + ); + } + if (!metadata.errorCode.match(/^TS\d+$/)) { + throw new Error( + `❌ INVALID TEST_META in ${filepath}\n` + + ` Invalid errorCode format: "${metadata.errorCode}" (must be like "TS2769")` + ); + } + } + + // Find START_TEST marker + const startTestIndex = content.indexOf('// START_TEST'); + if (startTestIndex === -1) { + throw new Error( + `❌ INVALID TEST FILE: ${filepath}\n` + + ` Missing "// START_TEST" marker.\n` + + ` This marker separates metadata from test code.` + ); + } + + // Extract code after START_TEST (this is what gets compiled) + const code = content.substring(startTestIndex + '// START_TEST'.length).trim(); + + if (code.length === 0) { + throw new Error( + `❌ INVALID TEST FILE: ${filepath}\n` + + ` No test code found after "// START_TEST" marker.` + ); + } + + return { metadata, code }; +} + +/** + * Compile TypeScript code and return diagnostics + */ +export function compileTypeScriptCode(code: string, testFilePath: string): ts.Diagnostic[] { + // Get TypeScript config from the project root + const projectRoot = path.resolve(__dirname, '../../..'); + const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) { + throw new Error('Could not find tsconfig.json'); + } + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + const compilerOptions = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(configPath) + ).options; + + // Override some options for testing + compilerOptions.noEmit = true; + compilerOptions.skipLibCheck = true; + compilerOptions.noUnusedLocals = false; // Don't complain about unused vars in tests + compilerOptions.noUnusedParameters = false; + + // Use the actual test file path so imports resolve correctly + const virtualFileName = testFilePath.replace(/\.ts$/, '.virtual.ts'); + + // Create a virtual source file with proper path + const sourceFile = ts.createSourceFile( + virtualFileName, + code, + ts.ScriptTarget.Latest, + true + ); + + // Create a compiler host that includes our virtual file + const host = ts.createCompilerHost(compilerOptions); + const originalGetSourceFile = host.getSourceFile; + + host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { + // Use our virtual file + if (fileName === virtualFileName) { + return sourceFile; + } + // Resolve other imports normally from filesystem + return originalGetSourceFile.call(host, fileName, languageVersion, onError, shouldCreateNewSourceFile); + }; + + // Include both our virtual file and the main index file so imports resolve + const indexPath = path.resolve(projectRoot, 'src/index.ts'); + const program = ts.createProgram([virtualFileName, indexPath], compilerOptions, host); + const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile); + + // Filter diagnostics to only those in our test file + return diagnostics.filter(d => { + const file = d.file; + if (!file) return false; // Skip global diagnostics + return file.fileName === virtualFileName; + }); +} + +/** + * Validate test results against metadata expectations + */ +export function validateTest( + diagnostics: ts.Diagnostic[], + metadata: TestMetadata +): { passed: boolean; message: string } { + if (metadata.type === 'pass') { + // Should have no errors + if (diagnostics.length === 0) { + return { passed: true, message: 'βœ“ Compiled successfully' }; + } else { + const errors = diagnostics.map(d => { + const message = ts.flattenDiagnosticMessageText(d.messageText, '\n'); + return ` TS${d.code}: ${message}`; + }).join('\n'); + return { + passed: false, + message: `βœ— Expected to pass but got errors:\n${errors}` + }; + } + } else { + // Should have specific error + if (diagnostics.length === 0) { + return { + passed: false, + message: `βœ— Expected error ${metadata.errorCode} but code compiled successfully` + }; + } + + // Check if we have the expected error code + const expectedCode = parseInt(metadata.errorCode!.replace('TS', ''), 10); + const hasExpectedError = diagnostics.some(d => d.code === expectedCode); + + if (!hasExpectedError) { + const actualErrors = diagnostics.map(d => `TS${d.code}`).join(', '); + return { + passed: false, + message: `βœ— Expected error ${metadata.errorCode} but got: ${actualErrors}` + }; + } + + // Check if error message matches pattern + const matchingDiagnostic = diagnostics.find(d => { + if (d.code !== expectedCode) return false; + const message = ts.flattenDiagnosticMessageText(d.messageText, '\n'); + return message.includes(metadata.errorPattern!); + }); + + if (!matchingDiagnostic) { + const actualMessages = diagnostics + .filter(d => d.code === expectedCode) + .map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')) + .join('\n '); + return { + passed: false, + message: `βœ— Error ${metadata.errorCode} found but message doesn't match pattern "${metadata.errorPattern}"\nActual messages:\n ${actualMessages}` + }; + } + + return { + passed: true, + message: `βœ“ Got expected error ${metadata.errorCode}: ${metadata.errorPattern}` + }; + } +} + +/** + * Run a single test file + */ +export function runTestFile(filepath: string): TestResult { + const startTime = Date.now(); + + try { + const { metadata, code } = parseTestFile(filepath); + const diagnostics = compileTypeScriptCode(code, filepath); + const validation = validateTest(diagnostics, metadata); + + return { + filepath, + passed: validation.passed, + message: validation.message, + duration: Date.now() - startTime, + metadata + }; + } catch (error) { + return { + filepath, + passed: false, + message: `βœ— Test execution error: ${error instanceof Error ? error.message : String(error)}`, + duration: Date.now() - startTime, + metadata: { type: 'pass', category: 'unknown' } + }; + } +} + +/** + * Discover all test files in a directory recursively + */ +export function discoverTestFiles(directory: string): string[] { + const files: string[] = []; + + function walk(dir: string) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip framework files and node_modules + if (entry.name !== 'node_modules' && entry.name !== 'dist') { + walk(fullPath); + } + } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.includes('framework') && !entry.name.includes('runner')) { + files.push(fullPath); + } + } + } + + walk(directory); + return files.sort(); // Lexical sort including directory path +} + +/** + * Run all tests in a directory using a single shared TypeScript Program + * This is much faster than compiling each test separately + */ +export async function runAllTests( + directory: string +): Promise { + const testFiles = discoverTestFiles(directory); + + // Parse all test files first + const parsedTests = testFiles.map(filepath => { + try { + const { metadata, code } = parseTestFile(filepath); + return { filepath, metadata, code, error: null }; + } catch (error) { + return { + filepath, + metadata: { type: 'pass' as const, category: 'unknown' }, + code: '', + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + // Create a single TypeScript Program for all test files + const diagnosticsByFile = compileAllTests(parsedTests); + + // Process results + const results: TestResult[] = parsedTests.map((test) => { + const testStartTime = Date.now(); + + if (test.error) { + return { + filepath: test.filepath, + passed: false, + message: `βœ— Test execution error: ${test.error}`, + duration: 0, + metadata: test.metadata + }; + } + + const diagnostics = diagnosticsByFile.get(test.filepath) || []; + const validation = validateTest(diagnostics, test.metadata); + + return { + filepath: test.filepath, + passed: validation.passed, + message: validation.message, + duration: Date.now() - testStartTime, + metadata: test.metadata + }; + }); + + return results; +} + +/** + * Compile all test files in a single TypeScript Program for performance + */ +function compileAllTests( + tests: Array<{ filepath: string; code: string; metadata: TestMetadata; error: string | null }> +): Map { + // Get TypeScript config + const projectRoot = path.resolve(__dirname, '../../..'); + const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) { + throw new Error('Could not find tsconfig.json'); + } + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + const compilerOptions = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(configPath) + ).options; + + // Override options for testing + compilerOptions.noEmit = true; + compilerOptions.skipLibCheck = true; + compilerOptions.noUnusedLocals = false; + compilerOptions.noUnusedParameters = false; + + // Create virtual file names and source files + const virtualFiles = new Map(); + const fileToOriginal = new Map(); + + for (const test of tests) { + if (test.error) continue; + + const virtualFileName = test.filepath.replace(/\.ts$/, '.virtual.ts'); + const sourceFile = ts.createSourceFile( + virtualFileName, + test.code, + ts.ScriptTarget.Latest, + true + ); + virtualFiles.set(virtualFileName, sourceFile); + fileToOriginal.set(virtualFileName, test.filepath); + } + + // Create compiler host with all virtual files + const host = ts.createCompilerHost(compilerOptions); + const originalGetSourceFile = host.getSourceFile; + + host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { + // Check if it's one of our virtual files + if (virtualFiles.has(fileName)) { + return virtualFiles.get(fileName)!; + } + // Otherwise load from filesystem + return originalGetSourceFile.call(host, fileName, languageVersion, onError, shouldCreateNewSourceFile); + }; + + // Create single program with all test files + const indexPath = path.resolve(projectRoot, 'src/index.ts'); + const allFiles = [indexPath, ...Array.from(virtualFiles.keys())]; + const program = ts.createProgram(allFiles, compilerOptions, host); + + // Extract diagnostics per test file + const diagnosticsByFile = new Map(); + + for (const [virtualFileName, originalPath] of fileToOriginal.entries()) { + const sourceFile = virtualFiles.get(virtualFileName); + if (!sourceFile) continue; + + const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile); + + // Filter to only diagnostics in this specific file + const filtered = diagnostics.filter(d => { + const file = d.file; + if (!file) return false; + return file.fileName === virtualFileName; + }); + + diagnosticsByFile.set(originalPath, filtered); + } + + return diagnosticsByFile; +} + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/graph/accumulate-actions.ts b/typescript/packages/burr-core/src/__tests__/type-tests/graph/accumulate-actions.ts new file mode 100644 index 000000000..989e68419 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/graph/accumulate-actions.ts @@ -0,0 +1,48 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "graph", + description: "Actions accumulate across multiple withActions calls" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder } from '../../../index'; + +const action1 = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => state.update({ y: state.x }) +}); + +const action2 = action({ + reads: z.object({ y: z.number() }), + writes: z.object({ z: z.number() }), + update: ({ state }) => state.update({ z: state.y }) +}); + +const builder = new GraphBuilder() + .withActions({ action1 }) + .withActions({ action2 }); + +// Both action1 and action2 should be valid +builder.withTransitions( + ['action1', 'action2'], + ['action2', null] +); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/graph/custom-action-names.ts b/typescript/packages/burr-core/src/__tests__/type-tests/graph/custom-action-names.ts new file mode 100644 index 000000000..10a967c39 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/graph/custom-action-names.ts @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "graph", + description: "Custom action names work correctly" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder } from '../../../index'; + +const myAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) +}); + +const builder = new GraphBuilder() + .withActions({ customName: myAction }); + +// customName is the valid key +builder.withTransitions(['customName', null]); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/graph/invalid-action-name.ts b/typescript/packages/burr-core/src/__tests__/type-tests/graph/invalid-action-name.ts new file mode 100644 index 000000000..d26429f23 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/graph/invalid-action-name.ts @@ -0,0 +1,39 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2322", + errorPattern: "not assignable", + category: "graph", + description: "Action names in transitions must exist" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder } from '../../../index'; + +const action1 = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => state.update({ y: state.x }) +}); + +const builder = new GraphBuilder() + .withActions({ action1 }); + +builder.withTransitions(['action1', 'nonexistent']); // Error: nonexistent not valid + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/graph/valid-builder.ts b/typescript/packages/burr-core/src/__tests__/type-tests/graph/valid-builder.ts new file mode 100644 index 000000000..4b9428210 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/graph/valid-builder.ts @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "graph", + description: "GraphBuilder accepts valid actions and transitions" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder } from '../../../index'; + +// This should pass: valid graph with two actions +const action1 = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.string() }), + update: ({ state }) => state.update({ y: 'test' }) +}); + +const action2 = action({ + reads: z.object({ y: z.string() }), + writes: z.object({ z: z.boolean() }), + update: ({ state }) => state.update({ z: true }) +}); + +const graph = new GraphBuilder() + .withActions({ action1, action2 }) + .withTransitions(['action1', 'action2'], ['action2', null]) + .build(); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/graph/wrong-custom-action-name.ts b/typescript/packages/burr-core/src/__tests__/type-tests/graph/wrong-custom-action-name.ts new file mode 100644 index 000000000..beee1410f --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/graph/wrong-custom-action-name.ts @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2322", + errorPattern: "not assignable", + category: "graph", + description: "Using variable name instead of custom key fails" +}; +// START_TEST +import { z } from 'zod'; +import { action, GraphBuilder } from '../../../index'; + +const myAction = action({ + reads: z.object({ count: z.number() }), + writes: z.object({ count: z.number() }), + update: ({ state }) => state.update({ count: state.count + 1 }) +}); + +const builder = new GraphBuilder() + .withActions({ customName: myAction }); + +// Error: 'myAction' is not a valid key, only 'customName' is +builder.withTransitions(['myAction', null]); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/runner.test.ts b/typescript/packages/burr-core/src/__tests__/type-tests/runner.test.ts new file mode 100644 index 000000000..6cfb0eabf --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/runner.test.ts @@ -0,0 +1,96 @@ +/** + * Type Test Runner + * + * This Jest test discovers and runs all type tests using the framework. + * Each test file becomes a separate Jest test case. + */ + +import { runAllTests, discoverTestFiles } from './framework'; +import * as path from 'path'; + +// Support running a single test: TEST_FILE=actions/missing-writes.ts npm run test:types +const singleTestFile = process.env.TEST_FILE; + +describe('Type Tests', () => { + // Discover all test files first + const testDir = __dirname; + let testFiles = discoverTestFiles(testDir); + + // Filter to single test if requested + if (singleTestFile) { + testFiles = testFiles.filter(filepath => { + const relativePath = path.relative(testDir, filepath); + return relativePath === singleTestFile || relativePath.endsWith(singleTestFile); + }); + + if (testFiles.length === 0) { + throw new Error(`No test file found matching: ${singleTestFile}`); + } + console.log(`\n🎯 Running single test: ${path.relative(testDir, testFiles[0])}\n`); + } + + if (testFiles.length === 0) { + test('no tests found', () => { + throw new Error(`No type test files found in ${testDir}`); + }); + return; + } + + // Run all tests once before creating Jest test cases + let allResults: Awaited>; + + beforeAll(async () => { + const startTime = Date.now(); + allResults = await runAllTests(testDir); + const duration = Date.now() - startTime; + + if (!singleTestFile) { + console.log(`\nπŸ“Š Type Test Summary:`); + console.log(` Total: ${allResults.length} tests`); + console.log(` Duration: ${duration}ms`); + + // Group by category + const byCategory = new Map(); + allResults.forEach(r => { + const count = byCategory.get(r.metadata.category) || 0; + byCategory.set(r.metadata.category, count + 1); + }); + + console.log(` Categories:`); + Array.from(byCategory.entries()).sort().forEach(([cat, count]) => { + console.log(` - ${cat}: ${count} tests`); + }); + console.log(''); + } + }, 30000); // 30s timeout for compilation + + // Create a Jest test for each type test file + testFiles.forEach(filepath => { + const relativePath = path.relative(testDir, filepath); + + test(relativePath, () => { + // Find the result for this file + const result = allResults.find(r => r.filepath === filepath); + + if (!result) { + throw new Error(`No result found for ${relativePath}`); + } + + // Jest assertion + expect({ + passed: result.passed, + message: result.message, + duration: result.duration, + category: result.metadata.category, + description: result.metadata.description + }).toEqual({ + passed: true, + message: expect.stringContaining('βœ“'), + duration: expect.any(Number), + category: result.metadata.category, + description: result.metadata.description + }); + }); + }); +}); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/append-multiple-arrays.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/append-multiple-arrays.ts new file mode 100644 index 000000000..8cbd94cd2 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/append-multiple-arrays.ts @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Append accepts multiple array fields" +}; +// START_TEST +import { z } from 'zod'; +import { createState } from '../../../index'; + +const state = createState( + z.object({ + items: z.array(z.string()), + tags: z.array(z.string()) + }), + { items: [], tags: [] } +); + +const result = state.append({ items: 'item1', tags: 'tag1' }); +const items: string[] = result.items; +const tags: string[] = result.tags; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/chained-increment-upsert.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/chained-increment-upsert.ts new file mode 100644 index 000000000..4fe091d40 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/chained-increment-upsert.ts @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Chained increment upserts work correctly" +}; +// START_TEST +import { z } from 'zod'; +import { createState } from '../../../index'; + +const state = createState( + z.object({ foo: z.string() }), + { foo: 'bar' } +); + +const incremented = state.increment({ count: 5 }); +const incrementedAgain = incremented.increment({ count: 3 }); + +const count: number = incrementedAgain.count; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/chained-updates.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/chained-updates.ts new file mode 100644 index 000000000..666171e68 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/chained-updates.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Chained updates work correctly" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ a: z.string() }), + z.object({ b: z.number(), c: z.boolean() }), + { a: 'test' } +); + +const chained = state.update({ b: 42 }).update({ c: true }); +const a: string = chained.a; +const b: number = chained.b; +const c: boolean = chained.c; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/excess-property-increment.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/excess-property-increment.ts new file mode 100644 index 000000000..5be92222d --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/excess-property-increment.ts @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Increment compiles (excess property checking is runtime via Zod)" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ count: z.number(), score: z.number() }), + z.object({ count: z.number() }), + { count: 0, score: 0 } +); + +// Excess property checking is validated at runtime by Zod +const result = state.increment({ count: 1 }); +const count: number = result.count; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/excess-property-update.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/excess-property-update.ts new file mode 100644 index 000000000..aa8c98c26 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/excess-property-update.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Update compiles (excess property checking is runtime via Zod)" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ a: z.number() }), + z.object({ a: z.number() }), + { a: 1 } +); + +// Excess property checking is validated at runtime by Zod +// TypeScript's structural typing makes compile-time excess property checking complex +const result = state.update({ a: 2 }); +const a: number = result.a; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-multiple-all-numbers.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-multiple-all-numbers.ts new file mode 100644 index 000000000..870147d07 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-multiple-all-numbers.ts @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Increment accepts multiple number fields" +}; +// START_TEST +import { z } from 'zod'; +import { createState } from '../../../index'; + +const state = createState( + z.object({ count: z.number(), score: z.number(), lives: z.number() }), + { count: 0, score: 0, lives: 3 } +); + +const result = state.increment({ count: 1, score: 10, lives: -1 }); +const count: number = result.count; +const score: number = result.score; +const lives: number = result.lives; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-multiple-fields.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-multiple-fields.ts new file mode 100644 index 000000000..6dea79277 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-multiple-fields.ts @@ -0,0 +1,35 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Multiple fields in increment compiles successfully" +}; +// START_TEST +import { z } from 'zod'; +import { createState } from '../../../index'; + +const state = createState( + z.object({ count: z.number(), score: z.number() }), + { count: 0, score: 0 } +); + +const result = state.increment({ count: 1, score: 5 }); +const count: number = result.count; +const score: number = result.score; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-restricted-field.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-restricted-field.ts new file mode 100644 index 000000000..75f0d0bf8 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-restricted-field.ts @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2322", + errorPattern: "not assignable to type 'never'", + category: "state", + description: "Cannot increment field not in writes schema" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ count: z.number() }), + z.object({ result: z.number() }), + { count: 5 } +); + +state.increment({ count: 1 }); // Error: count not in writes + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-upsert.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-upsert.ts new file mode 100644 index 000000000..63c2ca0ec --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/increment-upsert.ts @@ -0,0 +1,33 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Increment can create new fields (upsert behavior)" +}; +// START_TEST +import { z } from 'zod'; +import { createState } from '../../../index'; + +// This should pass: increment creates 'count' field if it doesn't exist +const state = createState(z.object({ foo: z.string() }), { foo: 'bar' }); +const incremented = state.increment({ count: 5 }); + +// Type should be narrowed to include count +const count: number = incremented.count; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-literal-chained.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-literal-chained.ts new file mode 100644 index 000000000..10cdd2cc8 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-literal-chained.ts @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Chained updates preserve narrow literal types" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ a: z.string() }), + z.object({ b: z.number(), c: z.boolean() }), + { a: 'test' } +); + +const updated = state.update({ b: 42 }).update({ c: true }); + +// Each update should narrow: { a: string } & { b: 42 } & { c: true } +// NOT: { a: string } & Partial<{ b: number, c: boolean }> +const a: string = updated.data.a; +const b: 42 = updated.data.b; +const c: true = updated.data.c; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-literal-single-update.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-literal-single-update.ts new file mode 100644 index 000000000..efab79e8d --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-literal-single-update.ts @@ -0,0 +1,39 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Single update preserves narrow literal types (not Partial)" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ a: z.number() }), + z.object({ b: z.number(), c: z.boolean() }), + { a: 0 } +); + +const updated = state.update({ c: true }); + +// Type should be narrow: { a: number } & { c: true } +// NOT: { a: number } & Partial<{ b: number, c: boolean }> +const a: number = updated.data.a; +const c: true = updated.data.c; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-update-not-partial.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-update-not-partial.ts new file mode 100644 index 000000000..49ff5995b --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrow-update-not-partial.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Update captures narrow literal types without widening to Partial" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ a: z.string() }), + z.object({ b: z.number(), c: z.boolean() }), + { a: 'test' } +); + +// Update should work and not widen to Partial +const updated = state.update({ b: 42 }); +const a: string = updated.a; +const b: number = updated.b; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-multiple-optional.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-multiple-optional.ts new file mode 100644 index 000000000..c5378d363 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-multiple-optional.ts @@ -0,0 +1,51 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Multiple optional fields can be narrowed in single update" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ + a: z.string().optional(), + b: z.number().optional(), + c: z.boolean().optional() + }), + z.object({ + a: z.string(), + b: z.number(), + c: z.boolean() + }), + {} +); + +// All optional fields provided with concrete values +const updated = state.update({ + a: 'hello', + b: 42, + c: true +}); + +const a: string = updated.a; +const b: number = updated.b; +const c: boolean = updated.c; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-nested-optional.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-nested-optional.ts new file mode 100644 index 000000000..7edfa9aac --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-nested-optional.ts @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Narrowing works with nested optional fields" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ + data: z.object({ + items: z.array(z.number()).optional() + }) + }), + z.object({ + data: z.object({ + items: z.array(z.number()) + }) + }), + { data: {} } +); + +// Should narrow optional field to required +const updated = state.update({ + data: { items: [1, 2, 3] } +}); + +const items: number[] = updated.data.items; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-optional-field.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-optional-field.ts new file mode 100644 index 000000000..6e41bc655 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/narrowing-optional-field.ts @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Updating optional field with concrete value narrows type" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ + count: z.number(), + history: z.array(z.string()).optional() // Optional + }), + z.object({ + count: z.number(), + history: z.array(z.string()) // Required in writes + }), + { count: 0 } +); + +const updated = state.update({ + count: 1, + history: ['item1', 'item2'] +}); + +const count: number = updated.count; +const history: string[] = updated.history; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/restricted-excess.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/restricted-excess.ts new file mode 100644 index 000000000..c51081718 --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/restricted-excess.ts @@ -0,0 +1,35 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2322", + errorPattern: "is not assignable to type 'never'", + category: "state", + description: "Cannot write to fields not in writable schema on restricted state" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +// This should fail: 'forbidden' is not in writes schema +const actionExcess = action({ + reads: z.object({ x: z.number() }), + writes: z.object({ y: z.number() }), + update: ({ state }) => state.update({ y: 1, forbidden: 2 }) +}); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/restricted-state-type-check.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/restricted-state-type-check.ts new file mode 100644 index 000000000..2e3205c3e --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/restricted-state-type-check.ts @@ -0,0 +1,39 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Type check: restricted state should be properly typed, not any" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const readsSchema = z.object({ count: z.number(), score: z.number() }); +const writesSchema = z.object({ count: z.number() }); + +const state = State.forAction( + readsSchema, + writesSchema, // writes: only count allowed + { count: 0, score: 0 } +); + +// Type check: state should be properly typed, not any +const count: number = state.count; +const score: number = state.score; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/update-type-mismatch.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/update-type-mismatch.ts new file mode 100644 index 000000000..53b03b9fe --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/update-type-mismatch.ts @@ -0,0 +1,34 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2769", + errorPattern: "not assignable", + category: "state", + description: "Type mismatch in update shows clear error" +}; +// START_TEST +import { z } from 'zod'; +import { action } from '../../../index'; + +action({ + reads: z.object({ a: z.string() }), + writes: z.object({ b: z.boolean() }), + update: ({ state }) => state.update({ b: 'wrong_type' }) +}); + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/update-valid.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/update-valid.ts new file mode 100644 index 000000000..1f18e997b --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/update-valid.ts @@ -0,0 +1,35 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "pass", + category: "state", + description: "Update with valid field compiles successfully" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ a: z.number() }), + z.object({ a: z.number() }), + { a: 1 } +); + +const result = state.update({ a: 2 }); +const value: number = result.a; + diff --git a/typescript/packages/burr-core/src/__tests__/type-tests/state/write-restricted-field.ts b/typescript/packages/burr-core/src/__tests__/type-tests/state/write-restricted-field.ts new file mode 100644 index 000000000..13d37fa8b --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-tests/state/write-restricted-field.ts @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export const TEST_META = { + type: "fail", + errorCode: "TS2322", + errorPattern: "not assignable to type 'never'", + category: "state", + description: "Cannot write to field not in writes schema" +}; +// START_TEST +import { z } from 'zod'; +import { State } from '../../../index'; + +const state = State.forAction( + z.object({ count: z.number() }), // reads + z.object({ result: z.number() }), // writes + { count: 5 } +); + +state.update({ count: 10 }); // Error: count not in writes + diff --git a/typescript/packages/burr-core/src/__tests__/type-utils.test-d.ts b/typescript/packages/burr-core/src/__tests__/type-utils.test-d.ts new file mode 100644 index 000000000..af3c90dff --- /dev/null +++ b/typescript/packages/burr-core/src/__tests__/type-utils.test-d.ts @@ -0,0 +1,249 @@ +/** + * Copyright (c) 2024-2025 Elijah ben Izzy + * SPDX-License-Identifier: Apache-2.0 + * + * Compile-time type safety tests for generic type utilities using tsd. + * Run with: npm run test:types + * + * These tests verify that generic type utilities work correctly at compile time. + * tsd will verify that expectError() directives actually produce errors. + */ + +import { z } from 'zod'; +import { expectError, expectAssignable } from 'tsd'; +import { + UseIfNotSet, + EnsureRecordSchema, + ValidateSchemaExtends, + ConditionalValidate +} from '../type-utils'; + +// ============================================================================ +// UseIfNotSet Tests +// ============================================================================ + +// βœ… If Existing is ZodNever, use New +{ + type NewSchema = z.ZodObject<{ a: z.ZodNumber }>; + type Result = UseIfNotSet; + expectAssignable({} as Result); +} + +// βœ… If Existing is set, keep Existing (ignore New) +{ + type Existing = z.ZodObject<{ count: z.ZodNumber }>; + type New = z.ZodObject<{ name: z.ZodString }>; + type Result = UseIfNotSet; + expectAssignable({} as Result); + // Should NOT be New + expectError(expectAssignable({} as Result)); +} + +// βœ… Works with different schema types +{ + type ArraySchema = z.ZodArray; + type Result = UseIfNotSet; + expectAssignable({} as Result); +} + +// βœ… Chaining works correctly +{ + type First = z.ZodObject<{ a: z.ZodNumber }>; + type Second = z.ZodObject<{ b: z.ZodString }>; + type Third = z.ZodObject<{ c: z.ZodBoolean }>; + + type Step1 = UseIfNotSet; // => First + type Step2 = UseIfNotSet; // => First (keeps existing) + type Step3 = UseIfNotSet; // => First (keeps existing) + + expectAssignable({} as Step3); +} + +// ============================================================================ +// EnsureRecordSchema Tests +// ============================================================================ + +// βœ… ZodNever converts to Record schema +{ + type Result = EnsureRecordSchema; + expectAssignable>>({} as Result); +} + +// βœ… Valid Record schema passes through unchanged +{ + const schema = z.object({ a: z.number(), b: z.string() }); + type Result = EnsureRecordSchema; + expectAssignable({} as Result); +} + +// βœ… Empty object schema passes through +{ + const emptySchema = z.object({}); + type Result = EnsureRecordSchema; + expectAssignable({} as Result); +} + +// βœ… Nested object schema passes through +{ + const nestedSchema = z.object({ + user: z.object({ + name: z.string(), + age: z.number() + }) + }); + type Result = EnsureRecordSchema; + expectAssignable({} as Result); +} + +// βœ… Array schema converts to Record (not a Record, so gets converted) +{ + const arraySchema = z.array(z.string()); + type Result = EnsureRecordSchema; + expectAssignable>>({} as Result); +} + +// ============================================================================ +// ValidateSchemaExtends Tests +// ============================================================================ + +// βœ… Superset extends subset - returns TNew +{ + type Superset = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Subset = z.ZodObject<{ a: z.ZodNumber }>; + type Result = ValidateSchemaExtends; + expectAssignable({} as Result); +} + +// βœ… Exact match - returns TNew +{ + type Exact = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Result = ValidateSchemaExtends; + expectAssignable({} as Result); +} + +// βœ… Subset does not extend superset - returns error type +{ + type Subset = z.ZodObject<{ a: z.ZodNumber }>; + type Superset = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Result = ValidateSchemaExtends; + expectAssignable<{ '❌ Schema constraint violation': z.infer }>({} as Result); +} + +// βœ… Custom error message works +{ + type Subset = z.ZodObject<{ a: z.ZodNumber }>; + type Superset = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Result = ValidateSchemaExtends; + expectAssignable<{ 'Custom error message': z.infer }>({} as Result); +} + +// βœ… Works with optional fields (superset has optional, subset doesn't) +{ + type Superset = z.ZodObject<{ + a: z.ZodNumber; + b: z.ZodOptional; + }>; + type Subset = z.ZodObject<{ a: z.ZodNumber }>; + type Result = ValidateSchemaExtends; + expectAssignable({} as Result); +} + +// βœ… Fails when subset has required field that superset doesn't +{ + type Superset = z.ZodObject<{ a: z.ZodNumber }>; + type Subset = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Result = ValidateSchemaExtends; + expectAssignable<{ '❌ Schema constraint violation': z.infer }>({} as Result); +} + +// ============================================================================ +// ConditionalValidate Tests +// ============================================================================ + +// βœ… If TExisting is ZodNever, allow TNew (no validation) +{ + type New = z.ZodObject<{ a: z.ZodNumber }>; + type Result = ConditionalValidate; + expectAssignable({} as Result); +} + +// βœ… If TExisting is set and compatible, allow TNew +{ + type New = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Existing = z.ZodObject<{ a: z.ZodNumber }>; + type Result = ConditionalValidate; + expectAssignable({} as Result); +} + +// βœ… If TExisting is set and incompatible, return error type +{ + type New = z.ZodObject<{ a: z.ZodNumber }>; + type Existing = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Result = ConditionalValidate; + expectAssignable<{ '❌ Schema constraint violation': z.infer }>({} as Result); +} + +// βœ… Custom error message works +{ + type New = z.ZodObject<{ a: z.ZodNumber }>; + type Existing = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Result = ConditionalValidate; + expectAssignable<{ 'Custom validation error': z.infer }>({} as Result); +} + +// βœ… Works with exact match +{ + type Exact = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Result = ConditionalValidate; + expectAssignable({} as Result); +} + +// βœ… Empty schema validation +{ + type New = z.ZodObject<{ a: z.ZodNumber }>; + type Empty = z.ZodObject<{}>; + type Result = ConditionalValidate; + expectAssignable({} as Result); +} + +// ============================================================================ +// Integration Tests: Combining Utilities +// ============================================================================ + +// βœ… UseIfNotSet + EnsureRecordSchema +{ + type Schema = z.ZodObject<{ count: z.ZodNumber }>; + type Selected = UseIfNotSet; + type Ensured = EnsureRecordSchema; + expectAssignable({} as Ensured); +} + +// βœ… ConditionalValidate + UseIfNotSet pattern +{ + type New = z.ZodObject<{ a: z.ZodNumber; b: z.ZodString }>; + type Existing = z.ZodObject<{ a: z.ZodNumber }>; + + // First validate + type Validated = ConditionalValidate; + // Then select (if validated is not error type) + type Selected = UseIfNotSet; + expectAssignable({} as Selected); +} + +// βœ… Real-world builder pattern simulation +{ + // Simulate: builder.withGraph() then builder.withState() + type GraphSchema = z.ZodObject<{ count: z.ZodNumber }>; + type StateSchema = z.ZodObject<{ count: z.ZodNumber; name: z.ZodString }>; + + // Step 1: Set graph (no existing app schema) + type AfterGraph = UseIfNotSet; // => GraphSchema + + // Step 2: Set state (graph schema exists, validate compatibility) + type ValidatedState = ConditionalValidate; + type AfterState = UseIfNotSet; + + // Final state should be StateSchema (superset of GraphSchema) + expectAssignable({} as AfterState); +} + diff --git a/typescript/packages/burr-core/src/action.ts b/typescript/packages/burr-core/src/action.ts new file mode 100644 index 000000000..b4c995ee0 --- /dev/null +++ b/typescript/packages/burr-core/src/action.ts @@ -0,0 +1,455 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { z } from 'zod'; +import { State, StateInstance } from './state'; + +/** + * Helper type to enforce strict return type checking for update functions. + * Forces TypeScript to validate the return type at definition time, not usage time. + */ +/** + * Update function return type. + * + * The returned state must contain at least the writes (validated at runtime). + * The writable schema reflects what was written, allowing subsequent operations + * on those fields (useful for testing and chaining). + * + * We use z.ZodType> instead of TWritesSchema directly + * to allow type narrowing from state.update() while maintaining the constraint + * that the schema must at least include the writes. + */ +type UpdateFunction< + TReadsSchema extends z.ZodObject, + TWritesSchema extends z.ZodObject, + TInputsSchema extends z.ZodType, + TResultSchema extends z.ZodObject | z.ZodVoid +> = (params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; +}) => StateInstance< + z.ZodType>, // Main schema: must at least include writes + any, // Readable: flexible for narrowing + z.ZodType> // Writable: includes what was written (enables subsequent ops) +>; + +/** + * Two-step action with separate run and update phases. + * + * Actions are the core execution units in Burr. They: + * - Read from state (subset defined by reads schema) + * - Execute async logic (run method) + * - Transform results into state writes (update method) + * + * The two-step pattern enables: + * - Event sourcing: store results and replay updates + * - Testing: test computation and state transformation separately + * - Audit trails: track what was computed vs. what was stored + * + * **Requires Zod object schemas for reads/writes** - this ensures runtime key extraction works correctly. + */ +export class Action< + TReadsSchema extends z.ZodObject, + TWritesSchema extends z.ZodObject, + TInputsSchema extends z.ZodType, + TResultSchema extends z.ZodObject | z.ZodVoid +> { + // Metadata + private readonly _name?: string; + + // Schemas + private readonly _reads: TReadsSchema; + private readonly _writes: TWritesSchema; + private readonly _inputs: TInputsSchema; + private readonly _result: TResultSchema; + + // User-provided functions + private readonly _runFn: (params: { + state: StateInstance; + inputs: z.infer; + }) => Promise>; + + private readonly _updateFn: (params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; + }) => StateInstance>, any, z.ZodType>>; + + // Cached metadata + private readonly _readsKeys: readonly string[]; + private readonly _writesKeys: readonly string[]; + private readonly _inputsKeys: readonly string[]; + + constructor(config: { + name?: string; + reads: TReadsSchema; + writes: TWritesSchema; + inputs: TInputsSchema; + result: TResultSchema; + run: (params: { + state: StateInstance; + inputs: z.infer; + }) => Promise>; + update: (params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; + }) => StateInstance>, any, z.ZodType>>; + }) { + this._name = config.name; + this._reads = config.reads; + this._writes = config.writes; + this._inputs = config.inputs; + this._result = config.result; + this._runFn = config.run; + this._updateFn = config.update; + + // Extract and cache metadata + this._readsKeys = this.extractKeys(config.reads); + this._writesKeys = this.extractKeys(config.writes); + this._inputsKeys = this.extractKeys(config.inputs); + } + + /** + * Extract keys from Zod schema. + * Returns empty array for non-object schemas (e.g., z.void() for inputs). + */ + private extractKeys(schema: z.ZodType): readonly string[] { + if (schema instanceof z.ZodObject) { + return Object.keys(schema.shape); + } + return []; // z.void() or other non-object schemas + } + + /** + * Validate data against schema with contextual error messages. + * Special handling: void schemas always pass (inputs are ignored for void actions). + */ + private validate(data: unknown, schema: z.ZodType, context: string): void { + try { + // Special case: void schemas don't validate inputs (they're ignored) + // This allows global inputs to be passed without breaking void-input actions + if (schema instanceof z.ZodVoid) { + return; // Skip validation for void schemas + } + + schema.parse(data); + } catch (error) { + if (error instanceof z.ZodError) { + // Provide context-specific error messages + const issues = error.issues; + // Check if any issues are about missing fields (received type is 'undefined') + const hasMissingFields = issues.some( + issue => { + if (issue.code !== 'invalid_type') return false; + const received = (issue as any).received; + // Check if received is the string 'undefined' or refers to an undefined value + return received === 'undefined' || received === undefined; + } + ); + + if (hasMissingFields) { + if (context === 'inputs') { + throw new Error(`Action validation failed for ${context}: Missing required input fields`); + } else if (context === 'writes') { + throw new Error(`Action validation failed for ${context}: Missing required write fields`); + } + } + + throw new Error(`Action validation failed for ${context}: ${error.message}`); + } + throw error; + } + } + + /** + * Name of this action (optional, for debugging/logging) + */ + get name(): string | undefined { + return this._name; + } + + /** + * Create a new Action with a name set (immutable operation). + * This allows actions to be reusable - the same action can be added to + * different graphs with different names. + * + * @param name - The name for this action + * @returns A new Action instance with the name set + */ + withName(name: string): Action { + return new Action({ + name, + reads: this._reads, + writes: this._writes, + inputs: this._inputs, + result: this._result, + run: this._runFn, + update: this._updateFn, + }); + } + + /** + * Keys that this action reads from state + */ + get reads(): readonly string[] { + return this._readsKeys; + } + + /** + * Keys that this action writes to state + */ + get writes(): readonly string[] { + return this._writesKeys; + } + + /** + * Keys for runtime inputs + */ + get inputs(): readonly string[] { + return this._inputsKeys; + } + + /** + * Schemas for validation + */ + get schema() { + return { + reads: this._reads, + writes: this._writes, + inputs: this._inputs, + result: this._result, + } as const; + } + + /** + * Execute the action's computation. + * + * Validates inputs, calls user's run function, validates result. + * Note: State is already subsetted to reads by Application (FORK phase), + * so no reads validation is needed here. + * + * @param params - Parameters object + * @param params.state - State instance subsetted to reads (provided by Application) + * @param params.inputs - Runtime inputs that match inputs schema + * @returns Result object that matches result schema + */ + async run(params: { + state: StateInstance; + inputs: z.infer; + }): Promise> { + const { state, inputs } = params; + + // Validate inputs (state already subsetted by Application) + this.validate(inputs, this._inputs, 'inputs'); + + // Execute user function + const result = await this._runFn({ state, inputs }); + + // Validate result + this.validate(result, this._result, 'result'); + + return result; + } + + /** + * Transform result into state writes. + * Validates result and state, calls user's update function, validates writes. + * + * The returned state is guaranteed to contain at least the writes schema fields, + * and those fields can be used in subsequent operations. + * + * @param params - Parameters object + * @param params.result - Result from run method + * @param params.state - State instance (for reference) + * @param params.inputs - Runtime inputs (for convenience) + * @returns State with writes applied (writable schema = writes for subsequent ops) + */ + update(params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; + }): StateInstance>, any, z.ZodType>> { + const { result, state, inputs } = params; + + // Validate inputs + this.validate(result, this._result, 'result'); + this.validate(state.data, this._reads, 'state (reads)'); + this.validate(inputs, this._inputs, 'inputs'); + + // Execute user function + const updatedState = this._updateFn({ result, state, inputs }); + + // Validate that the returned state contains the required writes + this.validate(updatedState.data, this._writes, 'writes'); + + // TODO: Add validation that actions don't write to reserved metadata keys + // This should be checked at Application level: + // 1. Action declares outputs (writes schema) + // 2. Build-time validation catches reserved key declarations + // 3. Runtime validation ensures action adheres to declared writes + + return updatedState; + } + + /** + * Execute the full action (run + update) with a full application state. + * This is the method applications use to execute actions. + * + * The returned state contains at least the writes schema fields, + * and those fields can be used in subsequent operations. + * + * @param params - Parameters object + * @param params.state - The full application state (unrestricted) + * @param params.inputs - Runtime inputs + * @returns State containing the writes (writable schema = writes for subsequent ops) + */ + async execute(params: { + state: StateInstance; + inputs: z.infer; + }): Promise>, any, z.ZodType>>> { + const { state: fullAppState, inputs } = params; + + // Extract reads subset from full app state + const readsData = this._reads.parse(fullAppState.data); + + // Create action-scoped restricted state + const actionState = State.forAction(this._reads, this._writes, readsData) as StateInstance< + TReadsSchema, + TReadsSchema, + TWritesSchema + >; + + // Run the action + const result = await this.run({ state: actionState, inputs }); + + // Update and return writes + const writesState = this.update({ result, state: actionState, inputs }); + + return writesState; + } +} + +/** + * Creates a two-step action with separate run and update phases. + * + * Actions work with State instances, providing direct property access and + * immutable updates. Result must be an object (z.object) or void (z.void). + * + * @example + * ```typescript + * // Full action with run and update + * const myAction = action({ + * reads: z.object({ count: z.number() }), + * writes: z.object({ count: z.number() }), + * inputs: z.object({ delta: z.number() }), + * result: z.object({ newCount: z.number() }), + * + * run: async ({ state, inputs }) => ({ + * newCount: state.count + inputs.delta // Direct property access + * }), + * + * update: ({ result, state }) => { + * return state.update({ count: result.newCount }); // Returns State + * } + * }); + * + * // Simple action without run (for direct state transformations) + * const incrementAction = action({ + * reads: z.object({ count: z.number() }), + * writes: z.object({ count: z.number() }), + * // No result specified, run defaults to () => ({}) + * update: ({ state }) => state.update({ count: state.count + 1 }) + * }); + * ``` + */ + +// Overload 1: When result is specified, run is required +export function action< + TReadsSchema extends z.ZodObject = z.ZodObject<{}>, + TWritesSchema extends z.ZodObject = z.ZodObject<{}>, + TInputsSchema extends z.ZodType = z.ZodVoid, + TResultSchema extends z.ZodObject | z.ZodVoid = z.ZodObject<{}> +>(config: { + reads?: TReadsSchema; + writes?: TWritesSchema; + inputs?: TInputsSchema; + result: TResultSchema; + run: (params: { + state: StateInstance; + inputs: z.infer; + }) => Promise>; + update: UpdateFunction; +}): Action; + +// Overload 2: When result is NOT specified, run is optional (defaults to empty object) +export function action< + TReadsSchema extends z.ZodObject = z.ZodObject<{}>, + TWritesSchema extends z.ZodObject = z.ZodObject<{}>, + TInputsSchema extends z.ZodType = z.ZodVoid +>(config: { + reads?: TReadsSchema; + writes?: TWritesSchema; + inputs?: TInputsSchema; + result?: never; + run?: (params: { + state: StateInstance; + inputs: z.infer; + }) => Promise>; + update: UpdateFunction>; +}): Action>; + +// Implementation +export function action< + TReadsSchema extends z.ZodObject = z.ZodObject<{}>, + TWritesSchema extends z.ZodObject = z.ZodObject<{}>, + TInputsSchema extends z.ZodType = z.ZodVoid, + TResultSchema extends z.ZodObject | z.ZodVoid = z.ZodObject<{}> +>(config: { + reads?: TReadsSchema; + writes?: TWritesSchema; + inputs?: TInputsSchema; + result?: TResultSchema; + run?: (params: { + state: StateInstance; + inputs: z.infer; + }) => Promise>; + update: UpdateFunction; +}): Action { + // Defaults for optional parameters + const reads = (config.reads ?? z.object({})) as TReadsSchema; + const writes = (config.writes ?? z.object({})) as TWritesSchema; + const inputs = (config.inputs ?? z.void()) as TInputsSchema; + const result = (config.result ?? z.object({})) as TResultSchema; + + // Default run function returns empty object for simple actions + const defaultRun = async () => ({}) as z.infer; + + return new Action({ + reads, + writes, + inputs, + result, + run: (config.run ?? defaultRun) as typeof config.run extends undefined + ? typeof defaultRun + : NonNullable, + update: config.update, + }); +} diff --git a/typescript/packages/burr-core/src/application-builder.ts b/typescript/packages/burr-core/src/application-builder.ts new file mode 100644 index 000000000..68ce0e3c1 --- /dev/null +++ b/typescript/packages/burr-core/src/application-builder.ts @@ -0,0 +1,567 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Application builder with fluent API + +import { z } from 'zod'; +import { Graph } from './graph'; +import { StateInstance } from './state'; +import { Application } from './application'; +import { + UseIfNotSet, + EnsureRecordSchema, + ConditionalValidate +} from './type-utils'; +import { type LifecycleAdapter, LifecycleAdapterSet, isPostApplicationCreateHook } from './lifecycle'; +import { PersisterHook, type StateSaver, type StateLoader } from './persistence'; +import { deserializeState, type SerdeOptions } from './serde'; + +/** + * Selects final schema: if app schema not set, use graph schema; otherwise use app schema. + * Domain-specific utility for ApplicationBuilder.build() method. + */ +type SelectFinalSchema< + TAppSchema extends z.ZodType, + TGraphSchema extends z.ZodType +> = [TAppSchema] extends [z.ZodNever] + ? [TGraphSchema] extends [z.ZodNever] + ? z.ZodNever + : TGraphSchema + : TAppSchema; + +/** + * Validates schema compatibility and returns either SuccessType or error type. + * Avoids duplication of ConditionalValidate calls in method signatures. + * + * @param AllowOptional - If true, allows TNew to have optional fields where TExisting has required fields + */ +type ValidatedOrError< + TNew extends z.ZodType, + TExisting extends z.ZodType, + SuccessType, + ErrorMsg extends string = '❌ Schema constraint violation', + AllowOptional extends boolean = false +> = ConditionalValidate extends z.ZodType + ? SuccessType + : ConditionalValidate; + +/** + * Immutable builder for constructing applications. + * Each method returns a new builder instance. + * + * Separates concerns: + * - Graph defines structure (actions + transitions) and computes required state schema + * - ApplicationBuilder defines runtime (entrypoint + initial state) and validates state + * + * Type safety: + * - TAppStateSchema: The application's state schema (from explicit generic or withState) + * - TGraphStateSchema: The graph's required state schema (computed from actions) + * - Validation: TAppStateSchema must extend TGraphStateSchema (application state is superset of graph requirements) + * + * @template TAppStateSchema - Application state schema type (defaults to never for inference) + * @template TGraphStateSchema - Graph's required state schema type (internal, set by withGraph) + */ +export class ApplicationBuilder< + TAppStateSchema extends z.ZodType | z.ZodNever = z.ZodNever, + TGraphStateSchema extends z.ZodType | z.ZodNever = z.ZodNever +> { + private readonly _graph: Graph | null; + private readonly _entrypoint: string | null; + private readonly _initialState: StateInstance | null; + private readonly _appId: string | null; + private readonly _partitionKey: string | undefined; + private readonly _initialSequenceId: number | undefined; + private readonly _lifecycleAdapters: readonly LifecycleAdapter[]; + + constructor( + graph: Graph | null = null, + entrypoint: string | null = null, + initialState: StateInstance | null = null, + appId: string | null = null, + partitionKey: string | undefined = undefined, + initialSequenceId: number | undefined = undefined, + lifecycleAdapters: readonly LifecycleAdapter[] = [] + ) { + this._graph = graph; + this._entrypoint = entrypoint; + this._initialState = initialState; + this._appId = appId; + this._partitionKey = partitionKey; + this._initialSequenceId = initialSequenceId; + this._lifecycleAdapters = lifecycleAdapters; + } + + /** + * Set the graph for this application. + * The graph defines the structure (actions and transitions) and required state schema. + * + * When TAppStateSchema is not set (never), infers from graph. + * Otherwise, validates at compile-time that TAppStateSchema's inferred type extends TNewGraphStateSchema's inferred type. + * + * @param graph - Graph built with GraphBuilder + * @returns New ApplicationBuilder instance with graph set + * @throws Error if graph is already set or state incompatible with graph + * + * @example + * ```typescript + * const app = new ApplicationBuilder() + * .withGraph(myGraph) + * .withEntrypoint('start') + * .withState(initialState) + * .build(); + * ``` + */ + withGraph>>( + graph: ValidatedOrError< + TAppStateSchema, + TNewGraphStateSchema, + Graph, + '❌ State schema must extend graph requirements' + > + ): ApplicationBuilder< + UseIfNotSet, + TNewGraphStateSchema + > { + if (this._graph !== null) { + throw new Error( + 'Graph is already set. ApplicationBuilder.withGraph() can only be called once.' + ); + } + + // Type guard to ensure graph is actually a Graph, not an error type + if (!('actions' in graph)) { + throw new Error('Invalid graph provided'); + } + + return new ApplicationBuilder< + UseIfNotSet, + TNewGraphStateSchema + >( + graph as Graph, + this._entrypoint, + this._initialState, + this._appId, + this._partitionKey, + this._initialSequenceId, + this._lifecycleAdapters + ); + } + + /** + * Set the entrypoint action for this application. + * This is the first action that will be executed. + * + * @param actionName - Name of the action to start at + * @returns New ApplicationBuilder instance with entrypoint set + * @throws Error if entrypoint is already set or if graph is not set + * + * @example + * ```typescript + * builder.withEntrypoint('myStartAction') + * ``` + */ + withEntrypoint(actionName: string): ApplicationBuilder { + if (this._entrypoint !== null) { + throw new Error( + 'Entrypoint is already set. ApplicationBuilder.withEntrypoint() can only be called once.' + ); + } + + if (this._graph === null) { + throw new Error( + 'Graph must be set before entrypoint. Call withGraph() first.' + ); + } + + // Validate entrypoint exists in graph + if (!this._graph.hasAction(actionName)) { + const availableActions = this._graph.getActionNames(); + throw new Error( + `Entrypoint action '${actionName}' not found in graph. ` + + `Available actions: ${availableActions.join(', ')}` + ); + } + + return new ApplicationBuilder( + this._graph, + actionName, + this._initialState, + this._appId, + this._partitionKey, + this._initialSequenceId, + this._lifecycleAdapters + ); + } + + /** + * Set the initial state for this application. + * + * When TAppStateSchema is not set (never), infers from state schema. + * Validates at compile-time that state schema has all graph fields (if graph is set). + * Allows state to have optional fields where graph requires them (e.g., fields created by actions). + * + * @param initialState - State instance created with createState() + * @returns New ApplicationBuilder instance with state set + * @throws Error if state is already set or state doesn't match graph requirements + * + * @example + * ```typescript + * // State can have optional fields that graph requires + * const state = createState( + * z.object({ count: z.number(), level: z.string().optional() }), + * { count: 0 } // level will be created by an action + * ); + * builder.withState(state) + * ``` + */ + withState>>( + initialState: ValidatedOrError< + TNewStateSchema, + TGraphStateSchema, + StateInstance, + '❌ State schema must extend graph requirements', + true // Allow optional fields in state + > + ): ApplicationBuilder< + UseIfNotSet, + TGraphStateSchema + > { + if (this._initialState !== null) { + throw new Error( + 'Initial state is already set. ApplicationBuilder.withState() can only be called once.' + ); + } + + return new ApplicationBuilder< + UseIfNotSet, + TGraphStateSchema + >( + this._graph, + this._entrypoint, + initialState as any, + this._appId, + this._partitionKey, + this._initialSequenceId, + this._lifecycleAdapters + ); + } + + /** + * Set application identifiers (appId, partitionKey, initialSequenceId). + * + * @param appId - Unique identifier for this application instance (auto-generated if not provided) + * @param partitionKey - Optional partition key for grouping/querying application runs + * @param initialSequenceId - Optional initial sequence ID (defaults to 0) + * + * @example + * ```typescript + * const app = new ApplicationBuilder() + * .withIdentifiers('my-app-123', 'user-456') + * .withGraph(graph) + * .withState(initialState) + * .withEntrypoint('start') + * .build(); + * ``` + */ + withIdentifiers( + appId?: string, + partitionKey?: string, + initialSequenceId?: number + ): ApplicationBuilder { + return new ApplicationBuilder( + this._graph, + this._entrypoint, + this._initialState, + appId ?? this._appId, + partitionKey ?? this._partitionKey, + initialSequenceId ?? this._initialSequenceId, + this._lifecycleAdapters + ); + } + + /** + * Add lifecycle hooks to the application. + * + * Hooks are called at specific points during execution: + * - preRunStep: before each action executes + * - postRunStep: after each action completes (or fails) + * - postApplicationCreate: after build() constructs the Application + * + * Multiple calls accumulate adapters (does not replace). + * + * @param adapters - One or more lifecycle adapters + * @returns New ApplicationBuilder instance with hooks added + * + * @example + * ```typescript + * const logger: LifecycleAdapter = { + * async preRunStep({ action }) { console.log(`Running ${action.name}`); }, + * async postRunStep({ action, exception }) { + * if (exception) console.error(`Failed: ${action.name}`); + * else console.log(`Done: ${action.name}`); + * } + * }; + * builder.withHooks(logger) + * ``` + */ + withHooks( + ...adapters: LifecycleAdapter[] + ): ApplicationBuilder { + return new ApplicationBuilder( + this._graph, + this._entrypoint, + this._initialState, + this._appId, + this._partitionKey, + this._initialSequenceId, + [...this._lifecycleAdapters, ...adapters] + ); + } + + /** + * Add a state persister that saves state after each step. + * + * This wraps the StateSaver as a PostRunStepHook using PersisterHook. + * The persister saves serialized state after each action execution. + * + * @param saver - StateSaver implementation (e.g., InMemoryPersister) + * @param serdeOptions - Optional custom serialization options + * @returns New ApplicationBuilder instance with persister added + */ + withStatePersister( + saver: StateSaver, + serdeOptions?: SerdeOptions + ): ApplicationBuilder { + const hook = new PersisterHook(saver, serdeOptions); + return this.withHooks(hook); + } + + /** + * Initialize application state from a persisted checkpoint. + * + * Loads state from the given loader. If state is found, uses it as initial state + * and optionally resumes at the next action. If not found, falls back to defaults. + * + * Mirrors Python's ApplicationBuilder.initialize_from(). + * + * @param params.loader - StateLoader to load from + * @param params.appId - App ID to load (uses builder's appId if not provided) + * @param params.partitionKey - Partition key to load from + * @param params.defaultState - Fallback state if nothing is persisted + * @param params.defaultEntrypoint - Fallback entrypoint if nothing is persisted + * @param params.resumeAtNextAction - If true, resume execution at the next action after the persisted position + * @param params.forkFromAppId - Fork from a different app ID's state + * @param params.forkFromSequenceId - Fork at a specific sequence ID + * @param params.serdeOptions - Custom deserialization options + */ + async initializeFrom(params: { + loader: StateLoader; + partitionKey: string; + appId?: string; + defaultState: StateInstance; + defaultEntrypoint: string; + resumeAtNextAction?: boolean; + forkFromAppId?: string; + forkFromSequenceId?: number; + serdeOptions?: SerdeOptions; + }): Promise> { + const loadAppId = params.forkFromAppId ?? params.appId ?? this._appId; + const loaded = await params.loader.load( + params.partitionKey, + loadAppId, + params.forkFromSequenceId + ); + + if (!loaded) { + // No persisted state -- use defaults + return new ApplicationBuilder( + this._graph, + params.defaultEntrypoint, + params.defaultState as any, + params.appId ?? this._appId, + params.partitionKey ?? this._partitionKey, + undefined, + this._lifecycleAdapters + ); + } + + // Deserialize persisted state + const deserializedData = deserializeState(loaded.state, params.serdeOptions); + + // Create state from deserialized data + const { createState } = await import('./state'); + const { z } = await import('zod'); + // Create a permissive schema that accepts any record + const restoredState = createState(z.object({}).passthrough(), deserializedData); + + // Determine entrypoint + let entrypoint: string; + if (params.resumeAtNextAction) { + // The persisted position is the last completed action. + // We want to resume at whatever comes next, which means + // setting priorStep so getNextAction() works correctly. + // The entrypoint is not used when priorStep is set. + entrypoint = params.defaultEntrypoint; + } else { + entrypoint = params.defaultEntrypoint; + } + + return new ApplicationBuilder( + this._graph, + entrypoint, + restoredState as any, + params.forkFromAppId ? (params.appId ?? this._appId) : loadAppId, + params.partitionKey ?? this._partitionKey, + params.resumeAtNextAction ? loaded.sequenceId : undefined, + this._lifecycleAdapters + ); + } + + /** + * Build the final application. + * Validates that all required components are set. + * + * If appId is not set, a random UUID will be generated. + * + * @returns Immutable Application instance with typed state schema + * @throws Error if graph, entrypoint, or state is not set + * + * @example + * ```typescript + * const app = new ApplicationBuilder() + * .withGraph(graph) + * .withEntrypoint('start') + * .withState(initialState) + * .build(); + * ``` + */ + build(): Application>> { + // Validate all required components are set + if (this._graph === null) { + throw new Error( + 'Cannot build application without graph. Call withGraph() before build().' + ); + } + + if (this._entrypoint === null) { + throw new Error( + 'Cannot build application without entrypoint. Call withEntrypoint() before build().' + ); + } + + if (this._initialState === null) { + throw new Error( + 'Cannot build application without initial state. Call withState() before build().' + ); + } + + // TODO: Validate initial state has entrypoint.reads fields + // Current limitation: Graph fields are all optional, so we can't enforce at compile-time + // that initial state has the fields required by entrypoint. + // Runtime validation would catch this, but we'd lose IDE errors. + // Future enhancement: Add runtime check or improve type system to track entrypoint schema. + + // Generate default appId if not provided + const appId = this._appId ?? `app-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + // At runtime, we've validated that state and graph are set + // Type assertion is safe because withState/withGraph enforce the constraint at the API boundary + // EnsureRecordSchema ensures the constraint is satisfied + type FinalStateSchema = EnsureRecordSchema>; + + const app = new Application( + this._graph! as Graph, + this._entrypoint!, + this._initialState! as StateInstance, + appId, + this._partitionKey, + this._initialSequenceId, + [...this._lifecycleAdapters] + ) as Application; + + // Fire postApplicationCreate hooks (async, but build() is sync -- schedule and don't await). + // Callers who need to await this should use buildAsync(). + const adapterSet = new LifecycleAdapterSet([...this._lifecycleAdapters]); + const hasPostCreate = this._lifecycleAdapters.some(isPostApplicationCreateHook); + if (hasPostCreate) { + // Schedule async hook dispatch -- errors are intentionally unhandled here. + // Use buildAsync() if you need to catch postApplicationCreate errors. + void adapterSet.callPostApplicationCreate({ + appId, + partitionKey: this._partitionKey, + state: app.state, + graph: this._graph!, + entrypoint: this._entrypoint!, + }); + } + + return app; + } + + /** + * Build the application and await postApplicationCreate lifecycle hooks. + * + * Use this instead of build() when you have PostApplicationCreateHook adapters + * and need to ensure they complete before proceeding. + */ + async buildAsync(): Promise>>> { + // Validate all required components are set + if (this._graph === null) { + throw new Error( + 'Cannot build application without graph. Call withGraph() before build().' + ); + } + if (this._entrypoint === null) { + throw new Error( + 'Cannot build application without entrypoint. Call withEntrypoint() before build().' + ); + } + if (this._initialState === null) { + throw new Error( + 'Cannot build application without initial state. Call withState() before build().' + ); + } + + const appId = this._appId ?? `app-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + type FinalStateSchema = EnsureRecordSchema>; + + const app = new Application( + this._graph! as Graph, + this._entrypoint!, + this._initialState! as StateInstance, + appId, + this._partitionKey, + this._initialSequenceId, + [...this._lifecycleAdapters] + ) as Application; + + // Await postApplicationCreate hooks + const adapterSet = new LifecycleAdapterSet([...this._lifecycleAdapters]); + await adapterSet.callPostApplicationCreate({ + appId, + partitionKey: this._partitionKey, + state: app.state, + graph: this._graph!, + entrypoint: this._entrypoint!, + }); + + return app; + } +} + diff --git a/typescript/packages/burr-core/src/application.ts b/typescript/packages/burr-core/src/application.ts new file mode 100644 index 000000000..4fd732d5d --- /dev/null +++ b/typescript/packages/burr-core/src/application.ts @@ -0,0 +1,839 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Application runtime and execution engine + +import { Graph } from './graph'; +import { StateInstance, isReservedMetadataKey } from './state'; +import { Action } from './action'; +import { z } from 'zod'; +import { LifecycleAdapterSet, type LifecycleAdapter, type ExecuteMethod } from './lifecycle'; +import { isStreamingAction, StreamingResultContainer } from './streaming'; +import { type ActionLike } from './types'; + +// ============================================================================ +// Framework Metadata Schemas +// ============================================================================ + +/** + * Application metadata - frozen for the lifecycle of this execution. + * Set once at application initialization, never changes during execution. + * + * Stored in state as: state.appMetadata + */ +export const AppMetadataSchema = z.object({ + /** Unique identifier for this application instance */ + appId: z.string(), + + /** Optional partition key for grouping/querying application runs */ + partitionKey: z.string().optional(), + + /** The entrypoint action name where execution starts */ + entrypoint: z.string(), +}); + +export type AppMetadata = z.infer; + +/** + * Execution metadata - changes on every step. + * Tracks the runtime state of execution flow. + * + * Stored in state as: state.executionMetadata + */ +export const ExecutionMetadataSchema = z.object({ + /** Current sequence number (increments on each step, starts at 0) */ + sequenceId: z.number(), + + /** + * Name of the last executed action. + * Used by graph to determine next action via transitions. + * Undefined at start (before first action is executed). + */ + priorStep: z.string().optional(), +}); + +export type ExecutionMetadata = z.infer; + +/** + * Combined framework metadata that gets merged into user state. + * Application state will include: UserData & FrameworkMetadata + */ +export interface FrameworkMetadata { + /** Application-level metadata (immutable during execution) */ + appMetadata: AppMetadata; + + /** Execution-level metadata (updates each step) */ + executionMetadata: ExecutionMetadata; +} + +// ============================================================================ +// Type Helpers for State with Metadata +// ============================================================================ + +/** + * Application's internal state schema = user's state schema + framework metadata. + * This is what Application works with internally. + */ +type TApplicationStateSchema>> = + z.ZodType & FrameworkMetadata>; + +/** + * StateInstance with framework metadata included. + * This is the type of state that Application manages and returns to users. + */ +type ApplicationStateInstance>> = + StateInstance< + TApplicationStateSchema, + TApplicationStateSchema, + TApplicationStateSchema + >; + +// ============================================================================ +// Execution Result Types +// ============================================================================ + +/** + * Base execution result structure. + * Used by both step() and run() to return consistent data. + * + * @template TStateSchema - The user's state schema (without metadata) + */ +export interface ExecutionResult>> { + /** The action that was executed (null if halted before execution) */ + action: ActionLike | null; + + /** The result returned from action.run() (null if halted before execution or no result) */ + result: Record | void | null; + + /** + * The state after the action. + * Includes user data + framework metadata (appMetadata, executionMetadata) + */ + state: ApplicationStateInstance; +} + +/** + * Result of executing a single step. + * Extends ExecutionResult with next action information. + * + * @template TStateSchema - The user's state schema (without metadata) + */ +export interface StepResult>> + extends ExecutionResult { + /** + * The action that was executed. + * Non-null for StepResult (step() returns null instead of a result when terminal) + */ + action: ActionLike; + + /** The result returned from action.run() */ + result: Record | void; +} + +/** + * Result of running the application to completion. + * Same as ExecutionResult - no additional fields needed. + * + * @template TStateSchema - The user's state schema (without metadata) + */ +export type RunResult>> = + ExecutionResult; + +/** + * Options for controlling execution. + * + * Matches Python's API: halt_before, halt_after, inputs. + * Note: maxSteps, timeout, and haltCondition are TypeScript-only extensions + * and not part of the Python API. + */ +export interface ExecutionOptions { + /** Runtime inputs to pass to actions */ + inputs?: Record; + + /** Halt before executing these actions (by name or tag like "@tag:myTag") */ + haltBefore?: string[]; + + /** Halt after executing these actions (by name or tag like "@tag:myTag") */ + haltAfter?: string[]; +} + +/** + * Represents a runnable application. + * An application combines a graph structure with runtime configuration. + * + * @template TStateSchema - The user's state schema (without framework metadata) + */ +export class Application> = z.ZodType>> { + /** The graph defining the structure of the application */ + readonly graph: Graph; + + /** The name of the action to start execution at */ + readonly entrypoint: string; + + /** Application unique identifier */ + readonly appId: string; + + /** Optional partition key for grouping/querying application runs */ + readonly partitionKey?: string; + + /** Internal runtime state (includes user data + framework metadata) */ + private _state: ApplicationStateInstance; + + /** Lifecycle hook dispatcher */ + private readonly _adapterSet: LifecycleAdapterSet; + + /** @internal Type-level field for state schema tracking (not used at runtime) */ + // @ts-expect-error - This field is only for type-level tracking, not used at runtime + private readonly _stateSchema!: TStateSchema; + + constructor( + graph: Graph, + entrypoint: string, + initialState: StateInstance, + appId: string, + partitionKey?: string, + initialSequenceId?: number, + lifecycleAdapters?: LifecycleAdapter[] + ) { + this.graph = graph; + this.entrypoint = entrypoint; + this.appId = appId; + this.partitionKey = partitionKey; + this._adapterSet = new LifecycleAdapterSet(lifecycleAdapters ?? []); + + // Check if state already has metadata (resumption case) + const existingData = initialState.data as any; + const hasExistingMetadata = existingData.executionMetadata !== undefined; + + // Extend user's state with framework metadata + // Preserve existing metadata if present (for resumption), otherwise initialize + this._state = initialState.update({ + appMetadata: hasExistingMetadata ? existingData.appMetadata : { + appId, + partitionKey, + entrypoint, + }, + executionMetadata: hasExistingMetadata ? existingData.executionMetadata : { + sequenceId: initialSequenceId ?? 0, + // priorStep starts undefined + }, + } as any) as ApplicationStateInstance; + } + + /** + * Get the current state (includes metadata). + */ + get state(): ApplicationStateInstance { + return this._state; + } + + /** + * Get current execution metadata. + */ + private get executionMetadata() { + return (this._state.data).executionMetadata; + } + + /** + * Increment the sequence ID. + */ + private incrementSequenceId(): void { + this._state = this._state.update({ + executionMetadata: { + ...this.executionMetadata, + sequenceId: this.executionMetadata.sequenceId + 1, + } + } as any) as ApplicationStateInstance; + } + + /** + * Set the prior step (last executed action name). + */ + private setPriorStep(actionName: string): void { + this._state = this._state.update({ + executionMetadata: { + ...this.executionMetadata, + priorStep: actionName, + } + } as any) as ApplicationStateInstance; + } + + /** + * Get the next action to execute based on current state. + * @internal + */ + private getNextAction(): ActionLike | null { + const priorStep = this.executionMetadata.priorStep; + + // If no prior step, start at entrypoint + if (!priorStep) { + const action = this.graph.getAction(this.entrypoint); + return action || null; + } + + // Get transitions from the prior action + const transitions = this.graph.getTransitionsFrom(priorStep); + + // Evaluate transitions in order until one matches + for (const transition of transitions) { + // No condition = always transition (default condition) + const conditionMet = !transition.condition || transition.condition(this._state.data); + + if (conditionMet) { + // Check if this is a terminal transition + if (transition.to === null) { + return null; + } + + const action = this.graph.getAction(transition.to); + return action || null; + } + } + + // No transitions found - terminal state + return null; + } + + /** + * Core execution unit: Fork β†’ Launch β†’ Gather β†’ Commit + * + * Executes a single action through four distinct phases: + * 1. FORK: Subset state to action's declared reads (copy-on-write view) + * 2. LAUNCH: Execute action's run phase with forked state + * 3. GATHER: Execute action's update phase to collect writes + * 4. COMMIT: Merge writes back into committed state + * + * @internal + */ + private async runStep( + action: ActionLike, + inputs: Record + ): Promise<{ + action: ActionLike; + result: Record | void; + newState: ApplicationStateInstance; + }> { + // Snapshot committed state + const committedState = this._state; + // Cast to Action for run/update methods (runStep is only called for non-streaming actions) + const concreteAction = action as Action; + + // ==================================== + // PHASE 1: FORK + // ==================================== + // Subset state to reads (copy-on-write view) + const forkedState = committedState.subset(action.reads) as StateInstance; + + // ==================================== + // PHASE 2: LAUNCH + // ==================================== + // Execute action with subsetted state + const result = await concreteAction.run({ + state: forkedState, + inputs + }); + + // ==================================== + // PHASE 3: GATHER + // ==================================== + // Collect writes, validate against schema + const writesState = concreteAction.update({ + result, + state: forkedState, + inputs + }); + + // Subset writes to only declared write fields + const writeKeys = action.writes; + const writes = writesState.subset(writeKeys); + + // ==================================== + // PHASE 4: COMMIT + // ==================================== + // Merge writes back to committed state + const newState = this.commitWrites(committedState, writes, action); + + return { action, result, newState }; + } + + /** + * Commit writes to state (PHASE 4 of execution). + * + * Merges action writes back into the committed state. + * Validates that writes don't include reserved metadata keys. + * + * Uses simple overwrite strategy: writes take precedence over existing values. + * Future: Support parallel merge strategies with conflict resolution. + * + * @internal + */ + private commitWrites( + committedState: ApplicationStateInstance, + writes: StateInstance, + action: ActionLike + ): ApplicationStateInstance { + // Validate no reserved metadata keys in writes + const writeKeys = Object.keys(writes.data); + const reservedWrites = writeKeys.filter(isReservedMetadataKey); + if (reservedWrites.length > 0) { + throw new Error( + `Action '${action.name}' attempted to write to reserved metadata keys: ${reservedWrites.join(', ')}. ` + + `Keys ending in 'Metadata' are reserved for framework use.` + ); + } + + // Simple overwrite merge: writes take precedence + const mergedData = { + ...committedState.data, + ...writes.data + }; + + return committedState.update(mergedData as any) as ApplicationStateInstance; + } + + /** + * Executes a single step of the application. + * + * Advances the state machine by one action, executing the next action + * based on the current state and transitions. + * + * @param options - Execution options (inputs, halt conditions) + * @returns StepResult containing the action, result, and new state. + * Returns null if there is no next action to execute. + * + * @example + * ```typescript + * const step = await app.step({ inputs: { userId: '123' } }); + * if (step) { + * console.log(`Action:`, step.action.name); + * console.log(`Result:`, step.result); + * } + * ``` + */ + async step(options?: ExecutionOptions): Promise | null> { + return this._withExecuteCall('step', () => this._stepInternal(options)); + } + + /** @internal */ + private async _stepInternal(options?: ExecutionOptions): Promise | null> { + const inputs = options?.inputs || {}; + + // Increment sequence ID before execution + this.incrementSequenceId(); + + // Get next action + const nextAction = this.getNextAction(); + if (!nextAction) { + return null; // Terminal state + } + + const sequenceId = this.executionMetadata.sequenceId; + + // Pre-run hook + await this._adapterSet.callPreRunStep({ + appId: this.appId, + partitionKey: this.partitionKey, + sequenceId, + state: this._state, + action: nextAction, + inputs, + }); + + let exception: Error | null = null; + try { + // Execute the four-phase cycle: fork β†’ launch β†’ gather β†’ commit + const { action, result, newState } = await this.runStep(nextAction, inputs); + + // Update application state + this._state = newState; + this.setPriorStep(action.name || 'unknown'); + + // Post-run hook (success) + await this._adapterSet.callPostRunStep({ + appId: this.appId, + partitionKey: this.partitionKey, + sequenceId, + state: this._state, + action, + result, + exception: null, + }); + + return { + action, + result, + state: this._state + }; + } catch (error) { + exception = error instanceof Error ? error : new Error(String(error)); + + // Post-run hook (failure) + await this._adapterSet.callPostRunStep({ + appId: this.appId, + partitionKey: this.partitionKey, + sequenceId, + state: this._state, + action: nextAction, + result: null, + exception, + }); + + const actionName = nextAction.name || 'unknown'; + throw new Error(`Error executing action '${actionName}': ${exception.message}`, { cause: exception }); + } + } + + /** + * Runs the application to completion. + * + * Executes steps until a terminal state is reached or a halt condition is met. + * Does not provide intermediate state access - use iterate() if you need that. + * + * @param options - Execution options (inputs, haltBefore, haltAfter) + * @returns RunResult containing the final action, result, and state + * + * @example + * ```typescript + * const result = await app.run({ + * inputs: { userId: '123' }, + * haltAfter: ['final_action'] + * }); + * console.log(`Final state:`, result.state.data); + * ``` + */ + async run(options?: ExecutionOptions): Promise> { + return this._withExecuteCall('run', () => this._runInternal(options)); + } + + /** @internal */ + private async _runInternal(options?: ExecutionOptions): Promise> { + const haltBefore = options?.haltBefore || []; + const haltAfter = options?.haltAfter || []; + const inputs = options?.inputs || {}; + + let lastAction: ActionLike | null = null; + let lastResult: Record | void | null = null; + + while (true) { + // Check halt_before condition + const nextAction = this.getNextAction(); + if (nextAction && haltBefore.includes(nextAction.name || '')) { + // Halt before executing this action + return { + action: nextAction, + result: null, // Didn't execute, so no result + state: this._state + }; + } + + // Execute a step (use _stepInternal to avoid double execute-call hooks) + const stepResult = await this._stepInternal({ inputs }); + + // If terminal (no more actions), return + if (!stepResult) { + return { + action: lastAction, + result: lastResult, + state: this._state + }; + } + + // Update tracking + lastAction = stepResult.action; + lastResult = stepResult.result; + + // Check halt_after condition + if (haltAfter.includes(stepResult.action.name || '')) { + return { + action: stepResult.action, + result: stepResult.result, + state: stepResult.state + }; + } + } + } + + /** + * Iterates through the application execution, yielding each step. + * + * Returns an async iterable that yields StepResult for each executed action. + * This allows you to observe state changes as they happen. + * + * @param options - Execution options (inputs, halt conditions) + * @returns AsyncIterable that yields StepResult for each step + * + * @example + * ```typescript + * for await (const step of app.iterate({ + * inputs: { userId: '123' }, + * haltAfter: ['final_action'] + * })) { + * console.log(`State:`, step.state.data); + * console.log(`Next:`, step.next); + * } + * ``` + */ + async *iterate(options?: ExecutionOptions): AsyncIterable> { + // Pre execute-call hook + await this._adapterSet.callPreExecuteCall({ + appId: this.appId, partitionKey: this.partitionKey, + state: this._state, method: 'iterate', + }); + + let iterateException: Error | null = null; + try { + const haltBefore = options?.haltBefore || []; + const haltAfter = options?.haltAfter || []; + const inputs = options?.inputs || {}; + + while (true) { + const nextAction = this.getNextAction(); + if (nextAction && haltBefore.includes(nextAction.name || '')) { + break; + } + + // Use _stepInternal to avoid double execute-call hooks + const stepResult = await this._stepInternal({ inputs }); + + if (!stepResult) { + break; + } + + yield stepResult; + + if (haltAfter.includes(stepResult.action.name || '')) { + break; + } + } + } catch (e) { + iterateException = e instanceof Error ? e : new Error(String(e)); + throw e; + } finally { + await this._adapterSet.callPostExecuteCall({ + appId: this.appId, partitionKey: this.partitionKey, + state: this._state, method: 'iterate', exception: iterateException, + }); + } + } + + /** + * Execute a streaming step. + * + * If the next action is a StreamingAction, returns a StreamingResultContainer + * that can be iterated over for intermediate results. After iteration completes, + * the state is committed. + * + * If the next action is a regular (non-streaming) Action, runs it to completion + * and wraps the result in a StreamingResultContainer for a uniform API. + * + * @param options - Execution options (inputs) + * @returns Object with action and streaming container, or null if terminal + * + * @example + * ```typescript + * const streamResult = await app.streamStep(); + * if (streamResult) { + * for await (const chunk of streamResult.stream) { + * console.log(chunk.token); + * } + * const { result, state } = await streamResult.stream.get(); + * } + * ``` + */ + async streamStep(options?: ExecutionOptions): Promise<{ + action: ActionLike; + stream: StreamingResultContainer; + } | null> { + return this._withExecuteCall('streamStep', () => this._streamStepInternal(options)); + } + + /** @internal */ + private async _streamStepInternal(options?: ExecutionOptions): Promise<{ + action: ActionLike; + stream: StreamingResultContainer; + } | null> { + const inputs = options?.inputs || {}; + + // Increment sequence ID + this.incrementSequenceId(); + + // Get next action + const nextAction = this.getNextAction(); + if (!nextAction) { + return null; + } + + const sequenceId = this.executionMetadata.sequenceId; + + // Pre-run hook + await this._adapterSet.callPreRunStep({ + appId: this.appId, + partitionKey: this.partitionKey, + sequenceId, + state: this._state, + action: nextAction as Action, + inputs, + }); + + if (isStreamingAction(nextAction)) { + // Streaming path + const committedState = this._state; + const forkedState = committedState.subset(nextAction.reads) as StateInstance; + + const rawGenerator = nextAction.streamRun({ state: forkedState, inputs }); + + // Wrap the generator to fire stream lifecycle hooks + const actionName = nextAction.name || 'unknown'; + const adapterSet = this._adapterSet; + const appId = this.appId; + const partitionKey = this.partitionKey; + const streamInitializeTime = new Date(); + + async function* wrappedGenerator(): AsyncGenerator { + // Pre-start stream hook + await adapterSet.callPreStartStream({ + action: actionName, sequenceId, appId, partitionKey, + }); + + let itemIndex = 0; + let firstItemTime: Date | undefined; + try { + for await (const item of rawGenerator) { + if (itemIndex === 0) firstItemTime = new Date(); + + // Post-stream-item hook + await adapterSet.callPostStreamItem({ + item, itemIndex, + streamInitializeTime, + firstStreamItemStartTime: firstItemTime!, + action: actionName, sequenceId, appId, partitionKey, + }); + + yield item; + itemIndex++; + } + } finally { + // Post-end stream hook + await adapterSet.callPostEndStream({ + action: actionName, sequenceId, appId, partitionKey, + }); + } + } + + const container = new StreamingResultContainer( + wrappedGenerator(), + forkedState, + (result, state) => { + const writesState = nextAction.update({ result, state, inputs }); + const writeKeys = nextAction.writes; + const writes = writesState.subset(writeKeys); + const newState = this.commitWrites(committedState, writes, nextAction as any); + + this._state = newState; + this.setPriorStep(actionName); + + // Post-run hook (fire-and-forget inside container finalization) + void this._adapterSet.callPostRunStep({ + appId: this.appId, + partitionKey: this.partitionKey, + sequenceId, + state: this._state, + action: nextAction as any, + result, + exception: null, + }); + + return this._state; + }, + ); + + return { action: nextAction, stream: container }; + } else { + // Non-streaming path: run to completion, wrap in container + try { + const { action: executedAction, result, newState } = await this.runStep(nextAction, inputs); + this._state = newState; + this.setPriorStep(executedAction.name || 'unknown'); + + await this._adapterSet.callPostRunStep({ + appId: this.appId, + partitionKey: this.partitionKey, + sequenceId, + state: this._state, + action: executedAction, + result, + exception: null, + }); + + // Wrap in a pass-through container + const container = StreamingResultContainer.passThrough(result as any, this._state); + return { action: executedAction, stream: container }; + } catch (error) { + const exception = error instanceof Error ? error : new Error(String(error)); + + await this._adapterSet.callPostRunStep({ + appId: this.appId, + partitionKey: this.partitionKey, + sequenceId, + state: this._state, + action: nextAction, + result: null, + exception, + }); + + throw new Error( + `Error executing action '${nextAction.name || 'unknown'}': ${exception.message}`, + { cause: exception } + ); + } + } + } + + /** + * Wraps an execute method with pre/post execute-call hooks. + * Mirrors Python's pattern of calling pre_run_execute_call / post_run_execute_call + * around step/run/iterate/stream methods. + * @internal + */ + private async _withExecuteCall(method: ExecuteMethod, fn: () => Promise): Promise { + await this._adapterSet.callPreExecuteCall({ + appId: this.appId, + partitionKey: this.partitionKey, + state: this._state, + method, + }); + + let exception: Error | null = null; + try { + return await fn(); + } catch (e) { + exception = e instanceof Error ? e : new Error(String(e)); + throw e; + } finally { + await this._adapterSet.callPostExecuteCall({ + appId: this.appId, + partitionKey: this.partitionKey, + state: this._state, + method, + exception, + }); + } + } +} + diff --git a/typescript/packages/burr-core/src/graph.ts b/typescript/packages/burr-core/src/graph.ts new file mode 100644 index 000000000..5b93c35d2 --- /dev/null +++ b/typescript/packages/burr-core/src/graph.ts @@ -0,0 +1,326 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Graph structure and transition logic + +import { z } from 'zod'; +import { FixEmptySchema, MergeRecordValues } from './type-utils'; +import { type ActionLike } from './types'; + +// ============================================================================ +// Type Utilities +// ============================================================================ + +/** + * Merges all state fields from actions into a single type. + * + * Graph type represents the FINAL state - all fields are REQUIRED. + * This ensures transition conditions can safely access fields without undefined checks. + * + * Initial state can be a subset (some fields optional), but graph defines the complete contract. + * + * Example: + * - Action1 reads/writes: {a: number} + * - Action2 reads/writes: {b: string} + * - Result: {a: number, b: string} (both required in final state) + */ +type MergeActionStates>> = + MergeRecordValues<{ + [K in keyof TActions]: + TActions[K] extends ActionLike + ? FixEmptySchema> & FixEmptySchema> + : never + }>; + +/** + * Infers the state type based on builder mode: + * - Bottom-up (default): Compute from actions + * - Top-down: Use provided schema + */ +type InferStateType< + TStateSchema extends z.ZodType, + TActions extends Record> +> = TStateSchema extends z.ZodNever + ? MergeActionStates + : z.infer; + +/** + * Converts an inferred state type to a Zod schema type. + * For bottom-up mode, creates a type-level schema representation. + */ +type StateTypeToSchema = z.ZodType; + +/** + * Type for transition condition functions. + */ +type TransitionCondition = (state: TState) => boolean | Promise; + +// ============================================================================ +// Transition Interface +// ============================================================================ + +/** + * Represents a transition between actions in the graph. + * Transitions are directed edges with optional conditions. + */ +export interface Transition { + /** Source action name */ + readonly from: string; + + /** Target action name, or null for terminal transitions */ + readonly to: string | null; + + /** Optional condition function that determines if transition should be taken */ + readonly condition?: TransitionCondition; +} + +// ============================================================================ +// Graph Class (Immutable Data Container) +// ============================================================================ + +/** + * Represents a directed graph of actions and transitions. + * This is an immutable data structure - no execution logic, just storage and query. + * + * @template TStateSchema - The Zod object schema type for the state (union of all action reads/writes) + */ +export class Graph { + /** Immutable map of action names to actions */ + readonly actions: ReadonlyMap>; + + /** Immutable array of transitions */ + readonly transitions: readonly Transition[]; + + /** @internal Type-level field for state schema tracking (not used at runtime) */ + // @ts-expect-error - This field is only for type-level tracking, not used at runtime + private readonly _stateSchema!: TStateSchema; + + constructor( + actions: Record>, + transitions: Transition[] + ) { + this.actions = new Map(Object.entries(actions)); + this.transitions = Object.freeze([...transitions]); + } + + /** + * Check if an action exists in the graph. + */ + hasAction(name: string): boolean { + return this.actions.has(name); + } + + /** + * Get an action by name. + */ + getAction(name: string): ActionLike | undefined { + return this.actions.get(name); + } + + /** + * Get all transitions originating from a specific action. + */ + getTransitionsFrom(actionName: string): readonly Transition[] { + return this.transitions.filter(t => t.from === actionName); + } + + /** + * Get all action names in the graph. + */ + getActionNames(): string[] { + return Array.from(this.actions.keys()); + } + + /** + * Get the number of actions in the graph. + */ + get actionCount(): number { + return this.actions.size; + } + + /** + * Get the number of transitions in the graph. + */ + get transitionCount(): number { + return this.transitions.length; + } +} + +// ============================================================================ +// GraphBuilder Class (Immutable Builder) +// ============================================================================ + +/** + * Immutable builder for constructing graphs. + * Each method returns a new builder instance with updated types. + * + * Supports two modes: + * - Bottom-up (default): State type computed from actions + * - Top-down: State type enforced by provided schema (future) + * + * @template TStateSchema - Optional state schema for top-down mode + * @template TActions - Accumulated actions with their types + */ +export class GraphBuilder< + TStateSchema extends z.ZodType = z.ZodNever, + TActions extends Record> = {} +> { + private readonly _actions: TActions; + private readonly _transitions: Array<[string, string | null, TransitionCondition?]>; + + constructor( + actions: TActions = {} as TActions, + transitions: Array<[string, string | null, TransitionCondition?]> = [] + ) { + this._actions = actions; + this._transitions = transitions; + } + + /** + * Add actions to the graph builder. + * Returns a new builder with accumulated action types. + * + * @param actions - Record of action names to action instances + * @throws Error if action names conflict with existing actions + * + * @example + * ```typescript + * const builder = new GraphBuilder() + * .withActions({ action1, action2 }) + * .withActions({ action3 }); // Accumulates types + * ``` + */ + withActions, z.ZodObject, any, any>>>( + actions: TNewActions + ): GraphBuilder { + // Validate: Check for duplicate action names + const existingNames = Object.keys(this._actions); + const newNames = Object.keys(actions); + const duplicates = newNames.filter(name => existingNames.includes(name)); + + if (duplicates.length > 0) { + throw new Error( + `Duplicate action names: ${duplicates.join(', ')}. ` + + `Each action must have a unique name.` + ); + } + + // Set action names from keys (immutable operation via withName()) + // If action already has the correct name, keep it (preserve reference) + const actionsWithNames = Object.fromEntries( + Object.entries(actions).map(([name, action]) => [ + name, + action.name === name ? action : action.withName(name) + ]) + ) as TNewActions; + + // Create new builder with merged actions (immutable) + return new GraphBuilder( + { ...this._actions, ...actionsWithNames } as TActions & TNewActions, + [...this._transitions] + ); + } + + /** + * Add transitions between actions. + * Transition conditions are typed based on the union of all action states. + * + * @param transitions - Array of [from, to] or [from, to, condition] tuples + * @throws Error if from/to action names don't exist + * + * @example + * ```typescript + * builder.withTransitions( + * ['action1', 'action2'], + * ['action2', 'action3', (state) => state.count > 5], + * ['action3', null] // Terminal transition + * ); + * ``` + */ + withTransitions( + ...transitions: Array< + | [from: keyof TActions, to: keyof TActions | null] + | [from: keyof TActions, to: keyof TActions | null, condition: TransitionCondition>] + > + ): this { + const actionNames = Object.keys(this._actions); + + // Validate each transition + for (const transition of transitions) { + const [from, to] = transition; + + // Validate 'from' action exists + if (!actionNames.includes(from as string)) { + throw new Error( + `Transition source '${String(from)}' not found in actions. ` + + `Available actions: ${actionNames.join(', ')}` + ); + } + + // Validate 'to' action exists (if not null) + if (to !== null && !actionNames.includes(to as string)) { + throw new Error( + `Transition target '${String(to)}' not found in actions. ` + + `Available actions: ${actionNames.join(', ')}` + ); + } + } + + // Create new builder with added transitions (immutable) + // Need to cast to mutable temporarily to modify, then return as immutable + const newTransitions = [...this._transitions, ...transitions] as Array< + [string, string | null, TransitionCondition?] + >; + + // Return new instance with same actions but new transitions + return new GraphBuilder( + this._actions, + newTransitions + ) as this; + } + + /** + * Build the final graph. + * Validates completeness and returns an immutable Graph instance. + * The state schema type is computed as the union of all action reads/writes. + * + * @throws Error if no actions have been added + * @returns Immutable Graph instance with computed state schema type + */ + build(): Graph>> { + // Validate: Must have at least one action + const actionNames = Object.keys(this._actions); + if (actionNames.length === 0) { + throw new Error( + 'Cannot build graph with no actions. ' + + 'Add actions using withActions() before calling build().' + ); + } + + // Convert transitions from tuples to Transition objects + const transitions: Transition[] = this._transitions.map(([from, to, condition]) => ({ + from: from as string, + to: to as string | null, + condition + })); + + return new Graph>>(this._actions, transitions); + } +} + diff --git a/typescript/packages/burr-core/src/index.ts b/typescript/packages/burr-core/src/index.ts new file mode 100644 index 000000000..8c64a87f4 --- /dev/null +++ b/typescript/packages/burr-core/src/index.ts @@ -0,0 +1,140 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Public API exports for @apache-burr/core + +// Re-export zod for convenience +export { z } from 'zod'; + +// State management +export { + State, + type StateInstance, + createState, + createStateWithDefaults, + type Operation, + type OperationConstructor, + SetFieldsOperation, + AppendFieldOperation, + ExtendFieldOperation, + IncrementFieldOperation, + OperationRegistry, + type NumberKeys, + type ArrayKeys, + type ArrayElement, +} from './state'; + +// Actions +export { Action, action as action } from './action'; + +// Types +export { type ActionLike } from './types'; + +// Graph +export { Graph, GraphBuilder, type Transition } from './graph'; + +// Application +export { + Application, + type StepResult, + type RunResult, + type ExecutionOptions +} from './application'; +export { ApplicationBuilder } from './application-builder'; + +// Lifecycle hooks +export { + type ExecuteMethod, + type PreRunStepHook, + type PostRunStepHook, + type PostApplicationCreateHook, + type PreStartSpanHook, + type PostEndSpanHook, + type DoLogAttributeHook, + type PreExecuteCallHook, + type PostExecuteCallHook, + type PreStartStreamHook, + type PostStreamItemHook, + type PostEndStreamHook, + type PreRunStepParams, + type PostRunStepParams, + type PostApplicationCreateParams, + type PreStartSpanParams, + type PostEndSpanParams, + type DoLogAttributeParams, + type PreExecuteCallParams, + type PostExecuteCallParams, + type PreStartStreamParams, + type PostStreamItemParams, + type PostEndStreamParams, + type LifecycleAdapter, + LifecycleAdapterSet, +} from './lifecycle'; + +// Serialization +export { + serializeValue, + deserializeValue, + serializeState, + deserializeState, + type Serializer, + type Deserializer, + type SerdeOptions, +} from './serde'; + +// Persistence +export { + type PersistedStateData, + type StateLoader, + type StateSaver, + type StatePersister, + PersisterHook, + InMemoryPersister, +} from './persistence'; + +// Streaming +export { + StreamingAction, + streamingAction, + StreamingResultContainer, + isStreamingAction, +} from './streaming'; + +// Tracing & observability +export { + ActionSpan, + ActionSpanTracer, + TracerFactory, + getCurrentTracer, + runWithTracer, + trace, +} from './tracing'; + +// Parallelism & sub-graphs +export { + RunnableGraph, + SubGraphTask, + TaskBasedParallelAction, + MapActionsAndStates, + MapActions, + MapStates, + type ApplicationContext, + type CascadeBehavior, +} from './parallelism'; + diff --git a/typescript/packages/burr-core/src/lifecycle.ts b/typescript/packages/burr-core/src/lifecycle.ts new file mode 100644 index 000000000..3438b9640 --- /dev/null +++ b/typescript/packages/burr-core/src/lifecycle.ts @@ -0,0 +1,363 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Lifecycle hooks for application and action execution +// +// Mirrors Python's burr/lifecycle/base.py but async-only (all TS execution is async). +// Python has sync + async variants for each hook; we collapse to a single async interface. + +import { StateInstance } from './state'; +import { Graph } from './graph'; +import { type ActionLike } from './types'; +import { type ActionSpan } from './tracing'; + +// ============================================================================ +// Enums +// ============================================================================ + +/** + * Which application method the user called. + * Mirrors Python's ExecuteMethod enum. + */ +export type ExecuteMethod = + | 'step' + | 'run' + | 'iterate' + | 'streamStep'; + +// ============================================================================ +// Hook Parameter Types +// ============================================================================ + +/** Mirrors Python's PreRunStepHook.pre_run_step signature. */ +export interface PreRunStepParams { + appId: string; + partitionKey: string | undefined; + sequenceId: number; + state: StateInstance; + action: ActionLike; + inputs: Record; +} + +/** Mirrors Python's PostRunStepHook.post_run_step signature. */ +export interface PostRunStepParams { + appId: string; + partitionKey: string | undefined; + sequenceId: number; + state: StateInstance; + action: ActionLike; + result: Record | void | null; + exception: Error | null; +} + +/** Mirrors Python's PostApplicationCreateHook.post_application_create signature. */ +export interface PostApplicationCreateParams { + appId: string; + partitionKey: string | undefined; + state: StateInstance; + graph: Graph; + entrypoint: string; + parentPointer?: { appId: string; sequenceId: number; partitionKey?: string } | null; + spawningParentPointer?: { appId: string; sequenceId: number; partitionKey?: string } | null; +} + +/** Mirrors Python's PreStartSpanHook params. */ +export interface PreStartSpanParams { + action: string; + actionSequenceId: number; + span: ActionSpan; + spanDependencies: string[]; + appId: string; + partitionKey: string | undefined; +} + +/** Mirrors Python's PostEndSpanHook params. */ +export interface PostEndSpanParams { + action: string; + actionSequenceId: number; + span: ActionSpan; + spanDependencies: string[]; + appId: string; + partitionKey: string | undefined; +} + +/** Mirrors Python's DoLogAttributeHook params. */ +export interface DoLogAttributeParams { + attributes: Record; + action: string; + actionSequenceId: number; + span: ActionSpan | null; + appId: string; + partitionKey: string | undefined; +} + +/** Mirrors Python's PreApplicationExecuteCallHook params. */ +export interface PreExecuteCallParams { + appId: string; + partitionKey: string | undefined; + state: StateInstance; + method: ExecuteMethod; +} + +/** Mirrors Python's PostApplicationExecuteCallHook params. */ +export interface PostExecuteCallParams { + appId: string; + partitionKey: string | undefined; + state: StateInstance; + method: ExecuteMethod; + exception: Error | null; +} + +/** Mirrors Python's PreStartStreamHook params. */ +export interface PreStartStreamParams { + action: string; + sequenceId: number; + appId: string; + partitionKey: string | undefined; +} + +/** Mirrors Python's PostStreamItemHook params. */ +export interface PostStreamItemParams { + item: any; + itemIndex: number; + streamInitializeTime: Date; + firstStreamItemStartTime: Date; + action: string; + sequenceId: number; + appId: string; + partitionKey: string | undefined; +} + +/** Mirrors Python's PostEndStreamHook params. */ +export interface PostEndStreamParams { + action: string; + sequenceId: number; + appId: string; + partitionKey: string | undefined; +} + +// ============================================================================ +// Hook Interfaces (all 11 types from Python, async-only) +// ============================================================================ + +/** Hook that runs before a step is executed. */ +export interface PreRunStepHook { + preRunStep(params: PreRunStepParams): Promise; +} + +/** Hook that runs after a step is executed (including on failure). */ +export interface PostRunStepHook { + postRunStep(params: PostRunStepParams): Promise; +} + +/** Hook that runs after an Application is constructed (after build()). */ +export interface PostApplicationCreateHook { + postApplicationCreate(params: PostApplicationCreateParams): Promise; +} + +/** Hook that runs before a span starts. */ +export interface PreStartSpanHook { + preStartSpan(params: PreStartSpanParams): Promise; +} + +/** Hook that runs after a span ends. */ +export interface PostEndSpanHook { + postEndSpan(params: PostEndSpanParams): Promise; +} + +/** Hook for logging attributes during a span. */ +export interface DoLogAttributeHook { + doLogAttributes(params: DoLogAttributeParams): Promise; +} + +/** Hook that runs before an application execute method (step/run/iterate/streamStep) is called. */ +export interface PreExecuteCallHook { + preExecuteCall(params: PreExecuteCallParams): Promise; +} + +/** Hook that runs after an application execute method completes. */ +export interface PostExecuteCallHook { + postExecuteCall(params: PostExecuteCallParams): Promise; +} + +/** Hook that runs when a stream starts. */ +export interface PreStartStreamHook { + preStartStream(params: PreStartStreamParams): Promise; +} + +/** Hook that runs after each streamed item is yielded. */ +export interface PostStreamItemHook { + postStreamItem(params: PostStreamItemParams): Promise; +} + +/** Hook that runs after a stream ends. */ +export interface PostEndStreamHook { + postEndStream(params: PostEndStreamParams): Promise; +} + +// ============================================================================ +// LifecycleAdapter +// ============================================================================ + +/** + * Union of all hook interfaces. + * A lifecycle adapter can implement any subset of hooks. + * Adapters are duck-typed by checking for method existence. + */ +export type LifecycleAdapter = Partial< + PreRunStepHook & + PostRunStepHook & + PostApplicationCreateHook & + PreStartSpanHook & + PostEndSpanHook & + DoLogAttributeHook & + PreExecuteCallHook & + PostExecuteCallHook & + PreStartStreamHook & + PostStreamItemHook & + PostEndStreamHook +>; + +// ============================================================================ +// Hook Type Guards +// ============================================================================ + +export function isPreRunStepHook(adapter: LifecycleAdapter): adapter is PreRunStepHook { + return typeof (adapter as any).preRunStep === 'function'; +} +export function isPostRunStepHook(adapter: LifecycleAdapter): adapter is PostRunStepHook { + return typeof (adapter as any).postRunStep === 'function'; +} +export function isPostApplicationCreateHook(adapter: LifecycleAdapter): adapter is PostApplicationCreateHook { + return typeof (adapter as any).postApplicationCreate === 'function'; +} +export function isPreStartSpanHook(adapter: LifecycleAdapter): adapter is PreStartSpanHook { + return typeof (adapter as any).preStartSpan === 'function'; +} +export function isPostEndSpanHook(adapter: LifecycleAdapter): adapter is PostEndSpanHook { + return typeof (adapter as any).postEndSpan === 'function'; +} +export function isDoLogAttributeHook(adapter: LifecycleAdapter): adapter is DoLogAttributeHook { + return typeof (adapter as any).doLogAttributes === 'function'; +} +export function isPreExecuteCallHook(adapter: LifecycleAdapter): adapter is PreExecuteCallHook { + return typeof (adapter as any).preExecuteCall === 'function'; +} +export function isPostExecuteCallHook(adapter: LifecycleAdapter): adapter is PostExecuteCallHook { + return typeof (adapter as any).postExecuteCall === 'function'; +} +export function isPreStartStreamHook(adapter: LifecycleAdapter): adapter is PreStartStreamHook { + return typeof (adapter as any).preStartStream === 'function'; +} +export function isPostStreamItemHook(adapter: LifecycleAdapter): adapter is PostStreamItemHook { + return typeof (adapter as any).postStreamItem === 'function'; +} +export function isPostEndStreamHook(adapter: LifecycleAdapter): adapter is PostEndStreamHook { + return typeof (adapter as any).postEndStream === 'function'; +} + +// ============================================================================ +// LifecycleAdapterSet +// ============================================================================ + +/** + * Generic hook dispatcher. Calls all adapters that implement a given hook, + * collecting errors without stopping dispatch. + */ +async function dispatchHook( + adapters: readonly LifecycleAdapter[], + hookName: string, + guard: (adapter: LifecycleAdapter) => boolean, + params: TParams +): Promise { + const errors: Error[] = []; + for (const adapter of adapters) { + if (guard(adapter)) { + try { + await (adapter as any)[hookName](params); + } catch (e) { + errors.push(e instanceof Error ? e : new Error(String(e))); + } + } + } + if (errors.length > 0) { + throw new AggregateError(errors, `${errors.length} ${hookName} hook(s) failed`); + } +} + +/** + * Dispatches hook calls to all registered adapters that implement the hook. + * + * Mirrors Python's LifecycleAdapterSet (burr/lifecycle/internal.py). + * Uses a generic dispatch pattern so new hook types don't require new methods. + */ +export class LifecycleAdapterSet { + private readonly _adapters: readonly LifecycleAdapter[]; + + constructor(adapters: LifecycleAdapter[] = []) { + this._adapters = Object.freeze([...adapters]); + } + + get adapters(): readonly LifecycleAdapter[] { + return this._adapters; + } + + // Step hooks + async callPreRunStep(params: PreRunStepParams): Promise { + await dispatchHook(this._adapters, 'preRunStep', isPreRunStepHook, params); + } + async callPostRunStep(params: PostRunStepParams): Promise { + await dispatchHook(this._adapters, 'postRunStep', isPostRunStepHook, params); + } + + // Application create hook + async callPostApplicationCreate(params: PostApplicationCreateParams): Promise { + await dispatchHook(this._adapters, 'postApplicationCreate', isPostApplicationCreateHook, params); + } + + // Span hooks + async callPreStartSpan(params: PreStartSpanParams): Promise { + await dispatchHook(this._adapters, 'preStartSpan', isPreStartSpanHook, params); + } + async callPostEndSpan(params: PostEndSpanParams): Promise { + await dispatchHook(this._adapters, 'postEndSpan', isPostEndSpanHook, params); + } + async callDoLogAttributes(params: DoLogAttributeParams): Promise { + await dispatchHook(this._adapters, 'doLogAttributes', isDoLogAttributeHook, params); + } + + // Execute call hooks + async callPreExecuteCall(params: PreExecuteCallParams): Promise { + await dispatchHook(this._adapters, 'preExecuteCall', isPreExecuteCallHook, params); + } + async callPostExecuteCall(params: PostExecuteCallParams): Promise { + await dispatchHook(this._adapters, 'postExecuteCall', isPostExecuteCallHook, params); + } + + // Stream hooks + async callPreStartStream(params: PreStartStreamParams): Promise { + await dispatchHook(this._adapters, 'preStartStream', isPreStartStreamHook, params); + } + async callPostStreamItem(params: PostStreamItemParams): Promise { + await dispatchHook(this._adapters, 'postStreamItem', isPostStreamItemHook, params); + } + async callPostEndStream(params: PostEndStreamParams): Promise { + await dispatchHook(this._adapters, 'postEndStream', isPostEndStreamHook, params); + } +} diff --git a/typescript/packages/burr-core/src/parallelism.ts b/typescript/packages/burr-core/src/parallelism.ts new file mode 100644 index 000000000..0409dce84 --- /dev/null +++ b/typescript/packages/burr-core/src/parallelism.ts @@ -0,0 +1,444 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Sub-graph execution and parallel task support. +// +// Mirrors Python's burr/core/parallelism.py. +// Uses Promise.all() instead of thread/process pools (async I/O bound). + +import { Graph } from './graph'; +import { StateInstance } from './state'; +import { ApplicationBuilder } from './application-builder'; +import { type StateSaver, type StateLoader } from './persistence'; +import { type ActionLike } from './types'; +import { type LifecycleAdapter } from './lifecycle'; + +// ============================================================================ +// ApplicationContext +// ============================================================================ + +/** + * Context passed from parent application to sub-graphs. + * Carries identity and optional persistence/tracking references. + * + * Mirrors Python's ApplicationContext. + */ +export interface ApplicationContext { + appId: string; + partitionKey: string | undefined; + sequenceId: number; + statePersister?: StateSaver; + stateLoader?: StateLoader; + lifecycleAdapters?: LifecycleAdapter[]; +} + +// ============================================================================ +// RunnableGraph +// ============================================================================ + +/** + * A graph bundled with an entrypoint and halt conditions. + * Can be executed as a standalone sub-application. + * + * Mirrors Python's RunnableGraph. + */ +export class RunnableGraph { + readonly graph: Graph; + readonly entrypoint: string; + readonly haltAfter: string[]; + + constructor(graph: Graph, entrypoint: string, haltAfter: string[]) { + this.graph = graph; + this.entrypoint = entrypoint; + this.haltAfter = haltAfter; + } + + /** + * Create a RunnableGraph from an existing graph with explicit config. + */ + static create( + graph: Graph, + entrypoint: string, + haltAfter: string[] + ): RunnableGraph { + return new RunnableGraph(graph, entrypoint, haltAfter); + } +} + +// ============================================================================ +// SubGraphTask +// ============================================================================ + +/** + * A single task that runs a sub-graph to completion. + * + * Mirrors Python's SubGraphTask. + */ +/** + * Cascade behavior for persistence/tracking in sub-graphs. + * Mirrors Python's StatePersisterBehavior / StateInitializerBehavior / TrackerBehavior. + * + * - 'cascade': inherit from parent context + * - null: don't use + * - object: use the specified instance + */ +export type CascadeBehavior = 'cascade' | null | T; + +export class SubGraphTask { + readonly graph: RunnableGraph; + readonly state: StateInstance; + readonly inputs: Record; + readonly applicationId: string; + readonly statePersister?: StateSaver; + readonly stateLoader?: StateLoader; + readonly lifecycleAdapters?: LifecycleAdapter[]; + + constructor(params: { + graph: RunnableGraph; + state: StateInstance; + inputs?: Record; + applicationId: string; + statePersister?: StateSaver; + stateLoader?: StateLoader; + lifecycleAdapters?: LifecycleAdapter[]; + }) { + this.graph = params.graph; + this.state = params.state; + this.inputs = params.inputs ?? {}; + this.applicationId = params.applicationId; + this.statePersister = params.statePersister; + this.stateLoader = params.stateLoader; + this.lifecycleAdapters = params.lifecycleAdapters; + } + + /** + * Run the sub-graph to completion and return the final state. + * Cascades persistence and lifecycle adapters from parent context. + */ + async run(parentContext?: ApplicationContext): Promise> { + let builder = new ApplicationBuilder() + .withGraph(this.graph.graph) + .withState(this.state) + .withEntrypoint(this.graph.entrypoint) + .withIdentifiers( + this.applicationId, + parentContext?.partitionKey + ); + + // Cascade persistence from parent if not explicitly set + const persister = this.statePersister ?? parentContext?.statePersister; + if (persister) { + builder = builder.withStatePersister(persister); + } + + // Cascade lifecycle adapters from parent if not explicitly set + const adapters = this.lifecycleAdapters ?? parentContext?.lifecycleAdapters; + if (adapters && adapters.length > 0) { + builder = builder.withHooks(...adapters); + } + + const app = builder.build(); + + const result = await app.run({ + inputs: this.inputs, + haltAfter: this.graph.haltAfter, + }); + + return result.state; + } +} + +// ============================================================================ +// TaskBasedParallelAction (Abstract Base) +// ============================================================================ + +/** + * Abstract base for actions that spawn and run multiple sub-graphs in parallel. + * + * Users implement: + * 1. `tasks()` - generate the sub-graph tasks to run + * 2. `reduce()` - merge results from all sub-graphs into final state + * + * Execution uses Promise.all() for concurrent I/O-bound tasks. + * + * Mirrors Python's TaskBasedParallelAction. + */ +export abstract class TaskBasedParallelAction { + abstract get reads(): readonly string[]; + abstract get writes(): readonly string[]; + + /** + * Generate the tasks to execute in parallel. + */ + abstract tasks( + state: StateInstance, + context: ApplicationContext, + inputs: Record + ): SubGraphTask[] | Promise; + + /** + * Reduce the results from parallel tasks into the final state. + * + * @param state - The state before parallel execution + * @param states - Array of final states from each sub-graph + * @returns The merged/reduced state + */ + abstract reduce( + state: StateInstance, + states: StateInstance[] + ): StateInstance; + + /** + * Cascade behavior: what persister does the sub-application use? + * 'cascade' = inherit from parent, null = don't use, or provide a specific instance. + */ + statePersister(): CascadeBehavior { return 'cascade'; } + + /** + * Cascade behavior: what loader does the sub-application use? + */ + stateLoader(): CascadeBehavior { return 'cascade'; } + + /** + * Cascade behavior: what lifecycle adapters does the sub-application use? + */ + lifecycleAdapters(): CascadeBehavior { return 'cascade'; } + + /** + * Execute: gather tasks, run in parallel, reduce. + */ + async execute( + state: StateInstance, + context: ApplicationContext, + inputs: Record = {} + ): Promise<{ state: StateInstance }> { + // 1. Generate tasks + const taskList = await this.tasks(state, context, inputs); + + // 2. Run all tasks concurrently + const resultStates = await Promise.all( + taskList.map(task => task.run(context)) + ); + + // 3. Reduce results + const finalState = this.reduce(state, resultStates); + + return { state: finalState }; + } + + /** @internal Resolve cascade behavior for a given field. */ + protected _cascade(behavior: CascadeBehavior, parentValue: T | undefined): T | undefined { + if (behavior === 'cascade') return parentValue; + if (behavior === null) return undefined; + return behavior; + } +} + +// ============================================================================ +// MapActionsAndStates +// ============================================================================ + +/** + * Cartesian product of actions x states. + * Mirrors Python's MapActionsAndStates. + * + * User implements: + * - actions(): yields actions or RunnableGraphs to run + * - states(): yields state variants to run each action with + * - reduce(): merges results + * + * @example + * ```typescript + * class TestModelsOverPrompts extends MapActionsAndStates { + * actions(state, context, inputs) { + * return [gpt4Action, claudeAction, o1Action]; + * } + * states(state, context, inputs) { + * return prompts.map(p => state.update({ prompt: p })); + * } + * reduce(state, states) { + * return state.update({ results: states.map(s => s.output) }); + * } + * get reads() { return ['prompts']; } + * get writes() { return ['results']; } + * } + * ``` + */ +export abstract class MapActionsAndStates extends TaskBasedParallelAction { + /** + * Yields actions (or RunnableGraphs) to run in parallel. + */ + abstract actions( + state: StateInstance, + context: ApplicationContext, + inputs: Record + ): (ActionLike | RunnableGraph)[] | Promise<(ActionLike | RunnableGraph)[]>; + + /** + * Yields state variants to run each action with. + */ + abstract states( + state: StateInstance, + context: ApplicationContext, + inputs: Record + ): StateInstance[] | Promise[]>; + + async tasks( + state: StateInstance, + context: ApplicationContext, + inputs: Record + ): Promise { + const actionList = await this.actions(state, context, inputs); + const stateList = await this.states(state, context, inputs); + const tasks: SubGraphTask[] = []; + + for (let i = 0; i < actionList.length; i++) { + for (let j = 0; j < stateList.length; j++) { + const act = actionList[i]; + const substate = stateList[j]; + let graph: RunnableGraph; + if (act instanceof RunnableGraph) { + graph = act; + } else { + const { graph: singleGraph, name } = _singleActionGraph(act); + graph = RunnableGraph.create(singleGraph, name, [name]); + } + + tasks.push(new SubGraphTask({ + graph, + state: substate, + inputs, + applicationId: _stableAppIdHash(context.appId, `${i}-${j}`), + statePersister: this._cascade(this.statePersister(), context.statePersister) ?? undefined, + stateLoader: this._cascade(this.stateLoader(), context.stateLoader) ?? undefined, + lifecycleAdapters: this._cascade(this.lifecycleAdapters(), context.lifecycleAdapters) ?? undefined, + })); + } + } + + return tasks; + } +} + +// ============================================================================ +// MapActions +// ============================================================================ + +/** + * Run multiple actions over the same state. + * Mirrors Python's MapActions. + * + * User implements: + * - actions(): yields actions to run + * - reduce(): merges results + * - Optionally override state() to transform the input state + */ +export abstract class MapActions extends MapActionsAndStates { + abstract actions( + state: StateInstance, + context: ApplicationContext, + inputs: Record + ): (ActionLike | RunnableGraph)[] | Promise<(ActionLike | RunnableGraph)[]>; + + /** + * The state to use for all actions. Defaults to the input state. + * Override to transform the state before passing to sub-actions. + */ + state( + state: StateInstance, + _inputs: Record + ): StateInstance { + return state; + } + + states( + state: StateInstance, + _context: ApplicationContext, + inputs: Record + ): StateInstance[] { + return [this.state(state, inputs)]; + } +} + +// ============================================================================ +// MapStates +// ============================================================================ + +/** + * Run a single action over multiple state variants. + * Mirrors Python's MapStates. + * + * User implements: + * - action(): returns the single action to run + * - states(): yields state variants + * - reduce(): merges results + */ +export abstract class MapStates extends MapActionsAndStates { + /** + * The single action to apply to each state variant. + */ + abstract action( + state: StateInstance, + inputs: Record + ): ActionLike | RunnableGraph; + + abstract states( + state: StateInstance, + context: ApplicationContext, + inputs: Record + ): StateInstance[] | Promise[]>; + + actions( + state: StateInstance, + _context: ApplicationContext, + inputs: Record + ): (ActionLike | RunnableGraph)[] { + return [this.action(state, inputs)]; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Create a single-action graph for wrapping an action in a RunnableGraph. + * @internal + */ +function _singleActionGraph(act: ActionLike): { graph: Graph; name: string } { + const { GraphBuilder } = require('./graph'); + const name = act.name ?? `action_${_actionCounter++}`; + const namedAct = act.name ? act : act.withName(name); + const graph = new GraphBuilder() + .withActions({ [name]: namedAct }) + .withTransitions([name, null]) + .build(); + return { graph, name }; +} + +let _actionCounter = 0; + +/** + * Stable hash for child application IDs. + * Mirrors Python's _stable_app_id_hash. + * @internal + */ +function _stableAppIdHash(parentAppId: string, key: string): string { + return `${parentAppId}:sub:${key}`; +} diff --git a/typescript/packages/burr-core/src/persistence.ts b/typescript/packages/burr-core/src/persistence.ts new file mode 100644 index 000000000..e6b99c33d --- /dev/null +++ b/typescript/packages/burr-core/src/persistence.ts @@ -0,0 +1,226 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// State persistence interfaces and implementations +// +// Mirrors Python's burr/core/persistence.py. +// All interfaces are async-only (TS port is async throughout). + +import { type PostRunStepHook, type PostRunStepParams } from './lifecycle'; +import { serializeState, type SerdeOptions } from './serde'; + +// ============================================================================ +// Data Types +// ============================================================================ + +/** + * Persisted state record. + * Mirrors Python's PersistedStateData TypedDict. + */ +export interface PersistedStateData { + partitionKey: string; + appId: string; + sequenceId: number; + /** Name of the action that produced this state */ + position: string; + /** Serialized state data */ + state: Record; + /** ISO timestamp */ + createdAt: string; + /** 'completed' after successful action, 'failed' if action threw */ + status: 'completed' | 'failed'; +} + +// ============================================================================ +// Interfaces +// ============================================================================ + +/** + * Loads persisted state. + * Mirrors Python's BaseStateLoader / AsyncBaseStateLoader. + */ +export interface StateLoader { + /** + * Load state for a given app_id. + * + * @param partitionKey - Partition key for this application group + * @param appId - The specific application instance ID (null to get latest) + * @param sequenceId - Optional: load state at a specific point in time. + * If not provided, returns the latest completed state. + * @returns Persisted state data, or null if not found + */ + load( + partitionKey: string, + appId: string | null, + sequenceId?: number + ): Promise; + + /** + * List all app IDs for a given partition key. + */ + listAppIds(partitionKey: string): Promise; +} + +/** + * Saves state to persistent storage. + * Mirrors Python's BaseStateSaver / AsyncBaseStateSaver. + */ +export interface StateSaver { + /** + * One-time initialization (e.g., create tables, connect to database). + */ + initialize(): Promise; + + /** + * Save state checkpoint. + * + * Unique key: (partitionKey, appId, sequenceId, position) + */ + save(params: { + partitionKey: string | undefined; + appId: string; + sequenceId: number; + position: string; + state: Record; + status: 'completed' | 'failed'; + }): Promise; +} + +/** + * Combined loader + saver interface. + */ +export interface StatePersister extends StateLoader, StateSaver {} + +// ============================================================================ +// PersisterHook +// ============================================================================ + +/** + * Wraps a StateSaver as a PostRunStepHook. + * + * This mirrors the Python pattern where persistence is wired as a lifecycle hook. + * After each step, the hook serializes and saves the state. + */ +export class PersisterHook implements PostRunStepHook { + private readonly _saver: StateSaver; + private readonly _serdeOptions?: SerdeOptions; + + constructor(saver: StateSaver, serdeOptions?: SerdeOptions) { + this._saver = saver; + this._serdeOptions = serdeOptions; + } + + async postRunStep(params: PostRunStepParams): Promise { + const status = params.exception ? 'failed' : 'completed'; + const serialized = serializeState(params.state.data, this._serdeOptions); + + await this._saver.save({ + partitionKey: params.partitionKey ?? '', + appId: params.appId, + sequenceId: params.sequenceId, + position: params.action.name ?? 'unknown', + state: serialized, + status, + }); + } +} + +// ============================================================================ +// InMemoryPersister +// ============================================================================ + +/** + * In-memory state persister for testing. + * Not suitable for production use (no durability). + */ +export class InMemoryPersister implements StatePersister { + private _records: PersistedStateData[] = []; + private _initialized = false; + + async initialize(): Promise { + this._initialized = true; + } + + get isInitialized(): boolean { + return this._initialized; + } + + async save(params: { + partitionKey: string | undefined; + appId: string; + sequenceId: number; + position: string; + state: Record; + status: 'completed' | 'failed'; + }): Promise { + this._records.push({ + partitionKey: params.partitionKey ?? '', + appId: params.appId, + sequenceId: params.sequenceId, + position: params.position, + state: params.state, + createdAt: new Date().toISOString(), + status: params.status, + }); + } + + async load( + partitionKey: string, + appId: string | null, + sequenceId?: number + ): Promise { + let candidates = this._records.filter( + (r) => r.partitionKey === partitionKey && r.status === 'completed' + ); + + if (appId !== null) { + candidates = candidates.filter((r) => r.appId === appId); + } + + if (sequenceId !== undefined) { + candidates = candidates.filter((r) => r.sequenceId === sequenceId); + } + + if (candidates.length === 0) return null; + + // Return the one with highest sequenceId + return candidates.reduce((latest, current) => + current.sequenceId > latest.sequenceId ? current : latest + ); + } + + async listAppIds(partitionKey: string): Promise { + const ids = new Set( + this._records + .filter((r) => r.partitionKey === partitionKey) + .map((r) => r.appId) + ); + return [...ids]; + } + + /** Test helper: get all records */ + get records(): readonly PersistedStateData[] { + return this._records; + } + + /** Test helper: clear all records */ + clear(): void { + this._records = []; + } +} diff --git a/typescript/packages/burr-core/src/schema-utils.ts b/typescript/packages/burr-core/src/schema-utils.ts new file mode 100644 index 000000000..a612fc444 --- /dev/null +++ b/typescript/packages/burr-core/src/schema-utils.ts @@ -0,0 +1,211 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Runtime utilities for working with Zod schemas. + * + * This module provides runtime helpers for dynamic schema operations: + * - Extending schemas with new fields + * - Inferring Zod types from runtime values + * - Synchronizing multiple schemas + * + * @module schema-utils + */ + +import { z } from 'zod'; + +/** + * Extends a Zod object schema with new fields, only if they don't already exist. + * + * This is useful for dynamically extending state schemas when new fields are added + * via operations like `update()`, `append()`, etc. + * + * Returns the same schema instance if no extension is needed (performance optimization). + * + * @param schema - Base ZodObject to extend + * @param updates - Object containing the new field values + * @param inferType - Whether to infer Zod types from values (true) or use z.unknown() (false) + * @returns Extended schema or original if no changes needed + * + * @example + * ```typescript + * const baseSchema = z.object({ a: z.number() }); + * const data = { a: 1, b: 'hello' }; + * + * // Extend with z.unknown() for new fields + * const extended = extendSchemaWithFields(baseSchema, data, false); + * // Result: z.object({ a: z.number(), b: z.unknown() }) + * + * // Extend with inferred types + * const inferred = extendSchemaWithFields(baseSchema, data, true); + * // Result: z.object({ a: z.number(), b: z.string() }) + * ``` + */ +export function extendSchemaWithFields>( + schema: z.ZodObject, + updates: T, + inferType: boolean = false +): z.ZodObject { + const extension: Record = {}; + + for (const key in updates) { + // Only add fields that don't exist in the schema + if (!(key in schema.shape)) { + extension[key] = inferType + ? inferZodType(updates[key]) + : z.unknown(); + } + } + + // Only extend if we have new fields (avoid unnecessary work) + return Object.keys(extension).length > 0 + ? schema.extend(extension) + : schema; +} + +/** + * Infers a Zod type from a runtime value. + * + * This provides basic type inference for common JavaScript types. + * For complex objects, it returns a permissive schema with `passthrough()`. + * + * @param value - Runtime value to infer type from + * @returns Zod schema matching the value's type + * + * @example + * ```typescript + * inferZodType('hello') // => z.string() + * inferZodType(42) // => z.number() + * inferZodType(true) // => z.boolean() + * inferZodType([1, 2]) // => z.array(z.unknown()) + * inferZodType({ a: 1 }) // => z.object({}).passthrough() + * ``` + */ +export function inferZodType(value: any): z.ZodTypeAny { + if (typeof value === 'string') return z.string(); + if (typeof value === 'number') return z.number(); + if (typeof value === 'boolean') return z.boolean(); + if (value === null) return z.null(); + if (value === undefined) return z.undefined(); + if (Array.isArray(value)) { + // Try to infer array element type from first element + if (value.length > 0) { + return z.array(inferZodType(value[0])); + } + return z.array(z.unknown()); + } + if (value && typeof value === 'object') { + // For objects, use a permissive schema + // Could be extended to recursively infer field types + return z.object({}).passthrough(); + } + return z.unknown(); +} + +/** + * Extends both a main schema and a readable schema with the same fields. + * + * This is used in State mutation methods to keep the main schema and readable + * schema in sync. When you write a field, it should also become readable. + * + * @param mainSchema - Primary state schema to extend + * @param readableSchema - Readable fields schema to extend + * @param updates - Object containing the new field values + * @param inferType - Whether to infer types or use z.unknown() + * @returns Object with both extended schemas + * + * @example + * ```typescript + * const main = z.object({ a: z.number() }); + * const readable = z.object({ a: z.number() }); + * const updates = { b: 'hello' }; + * + * const { main: newMain, readable: newReadable } = extendBothSchemas( + * main, + * readable, + * updates + * ); + * + * // Both schemas now include 'b' field + * ``` + */ +export function extendBothSchemas( + mainSchema: z.ZodObject, + readableSchema: z.ZodObject, + updates: Record, + inferType: boolean = false +): { main: z.ZodObject; readable: z.ZodObject } { + return { + main: extendSchemaWithFields(mainSchema, updates, inferType), + readable: extendSchemaWithFields(readableSchema, updates, inferType) + }; +} + +/** + * Checks if a Zod schema is a ZodObject. + * + * This is a type guard that can be used to safely narrow schema types + * before accessing `.shape` or calling `.extend()`. + * + * @param schema - Zod schema to check + * @returns True if schema is a ZodObject + * + * @example + * ```typescript + * const schema: z.ZodType = getSchema(); + * + * if (isZodObject(schema)) { + * // TypeScript knows schema is z.ZodObject here + * const keys = Object.keys(schema.shape); + * } + * ``` + */ +export function isZodObject(schema: z.ZodType): schema is z.ZodObject { + return schema instanceof z.ZodObject; +} + +/** + * Safely extends a schema only if it's a ZodObject, otherwise returns original. + * + * This is useful when you're not sure if a schema is extendable, and you want + * to avoid runtime errors from calling `.extend()` on non-object schemas. + * + * @param schema - Schema to extend (may or may not be ZodObject) + * @param updates - Fields to add + * @param inferType - Whether to infer types + * @returns Extended schema or original + * + * @example + * ```typescript + * const schema: z.ZodType = z.string(); // Not an object! + * const result = safeExtendSchema(schema, { a: 1 }); + * // Returns original schema (can't extend z.string()) + * ``` + */ +export function safeExtendSchema( + schema: z.ZodType, + updates: Record, + inferType: boolean = false +): z.ZodType { + if (isZodObject(schema)) { + return extendSchemaWithFields(schema, updates, inferType); + } + return schema; +} + diff --git a/typescript/packages/burr-core/src/serde.ts b/typescript/packages/burr-core/src/serde.ts new file mode 100644 index 000000000..0ef23b258 --- /dev/null +++ b/typescript/packages/burr-core/src/serde.ts @@ -0,0 +1,195 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Serialization and deserialization utilities +// +// Handles JS types that don't survive JSON.stringify (Date, Map, Set, etc.) +// Uses a tagged-value convention: { __serde_type: "Date", value: "..." } + +// ============================================================================ +// Types +// ============================================================================ + +/** Serializes a value into a JSON-safe representation */ +export type Serializer = (value: any) => any; + +/** Deserializes a JSON-safe representation back into the original value */ +export type Deserializer = (data: any) => any; + +export interface SerdeOptions { + customSerializers?: Map; + customDeserializers?: Map; +} + +/** Tagged value for non-JSON types */ +interface TaggedValue { + __serde_type: string; + value: any; +} + +function isTaggedValue(v: any): v is TaggedValue { + return v !== null && typeof v === 'object' && typeof v.__serde_type === 'string'; +} + +// ============================================================================ +// Built-in Serializers +// ============================================================================ + +const BUILTIN_SERIALIZERS = new Map TaggedValue>(); +BUILTIN_SERIALIZERS.set('Date', (v) => ({ __serde_type: 'Date', value: (v as Date).toISOString() })); +BUILTIN_SERIALIZERS.set('Map', (v) => ({ __serde_type: 'Map', value: [...(v as Map).entries()] })); +BUILTIN_SERIALIZERS.set('Set', (v) => ({ __serde_type: 'Set', value: [...(v as Set)] })); +BUILTIN_SERIALIZERS.set('RegExp', (v) => ({ __serde_type: 'RegExp', value: { source: (v as RegExp).source, flags: (v as RegExp).flags } })); +BUILTIN_SERIALIZERS.set('BigInt', (v) => ({ __serde_type: 'BigInt', value: (v as bigint).toString() })); + +const BUILTIN_DESERIALIZERS = new Map([ + ['Date', (data) => new Date(data as string)], + ['Map', (data) => new Map(data as [any, any][])], + ['Set', (data) => new Set(data as any[])], + ['RegExp', (data) => { + const { source, flags } = data as { source: string; flags: string }; + return new RegExp(source, flags); + }], + ['BigInt', (data) => BigInt(data as string)], +]); + +// ============================================================================ +// Core Functions +// ============================================================================ + +/** + * Recursively serialize a value, converting non-JSON types to tagged values. + */ +export function serializeValue(value: any, options?: SerdeOptions): any { + // Primitives pass through + if (value === null || value === undefined) return value; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + // BigInt (typeof check before object checks) + if (typeof value === 'bigint') { + const ser = options?.customSerializers?.get('BigInt') ?? BUILTIN_SERIALIZERS.get('BigInt')!; + return ser(value); + } + + // Date + if (value instanceof Date) { + const ser = options?.customSerializers?.get('Date') ?? BUILTIN_SERIALIZERS.get('Date')!; + return ser(value); + } + + // Map + if (value instanceof Map) { + const serializedEntries = [...value.entries()].map( + ([k, v]) => [serializeValue(k, options), serializeValue(v, options)] + ); + return { __serde_type: 'Map', value: serializedEntries }; + } + + // Set + if (value instanceof Set) { + const serializedValues = [...value].map(v => serializeValue(v, options)); + return { __serde_type: 'Set', value: serializedValues }; + } + + // RegExp + if (value instanceof RegExp) { + const ser = options?.customSerializers?.get('RegExp') ?? BUILTIN_SERIALIZERS.get('RegExp')!; + return ser(value); + } + + // Arrays + if (Array.isArray(value)) { + return value.map(item => serializeValue(item, options)); + } + + // Plain objects -- recurse into values + if (typeof value === 'object') { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = serializeValue(val, options); + } + return result; + } + + // Fallback: return as-is (functions, symbols, etc. will be lost in JSON anyway) + return value; +} + +/** + * Recursively deserialize a value, restoring tagged values to their original types. + */ +export function deserializeValue(value: any, options?: SerdeOptions): any { + if (value === null || value === undefined) return value; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + // Arrays + if (Array.isArray(value)) { + return value.map(item => deserializeValue(item, options)); + } + + // Tagged values + if (isTaggedValue(value)) { + const type = value.__serde_type; + const customDeser = options?.customDeserializers?.get(type); + if (customDeser) { + return customDeser(deserializeValue(value.value, options)); + } + const builtinDeser = BUILTIN_DESERIALIZERS.get(type); + if (builtinDeser) { + return builtinDeser(deserializeValue(value.value, options)); + } + // Unknown tagged type -- return as-is (don't strip the tag) + return value; + } + + // Plain objects -- recurse + if (typeof value === 'object') { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = deserializeValue(val, options); + } + return result; + } + + return value; +} + +/** + * Serialize an entire state data record. + */ +export function serializeState( + data: Record, + options?: SerdeOptions +): Record { + return serializeValue(data, options) as Record; +} + +/** + * Deserialize an entire state data record. + */ +export function deserializeState( + data: Record, + options?: SerdeOptions +): Record { + return deserializeValue(data, options) as Record; +} diff --git a/typescript/packages/burr-core/src/state.ts b/typescript/packages/burr-core/src/state.ts new file mode 100644 index 000000000..5e1f9cd51 --- /dev/null +++ b/typescript/packages/burr-core/src/state.ts @@ -0,0 +1,984 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { z } from 'zod'; +import { NumberKeys, ArrayKeys, ArrayElement, NoExcessProperties } from './type-utils'; +import { extendSchemaWithFields } from './schema-utils'; + +// Re-export type utilities for backwards compatibility +export type { NumberKeys, ArrayKeys, ArrayElement }; + +/** + * Check if a key is reserved for framework metadata. + * Any key ending in "Metadata" is reserved for framework use. + * + * This is exported from state.ts so Action can validate against it, + * but the actual metadata schemas are defined in application.ts. + */ +export function isReservedMetadataKey(key: string): boolean { + return key.endsWith('Metadata'); +} + +// ============================================================================ +// Operation Interface +// ============================================================================ + +/** + * Represents a state transformation operation. + * Type parameters track input and output state shapes for compile-time tracking. + * + * @template TIn - Input state shape + * @template TOut - Output state shape (may be extended with new fields) + */ +export interface Operation< + TIn extends Record, + TOut extends Record = TIn +> { + /** Unique name for this operation type (for serialization) */ + readonly name: string; + + /** Returns the keys this operation reads from state */ + reads(): (keyof TIn)[]; + + /** Returns the keys this operation writes to state */ + writes(): (keyof TOut)[]; + + /** Validates that this operation can be applied to the given state */ + validate(state: TIn): void; + + /** Applies this operation to state, mutating it in place */ + apply(state: TIn): void; + + /** Serializes this operation to a JSON-compatible object */ + serialize(): Record; + + /** Returns Zod schema extensions for new fields (empty object if no extensions) */ + schemaExtensions(): Record; +} + +/** + * Constructor interface for operation deserialization + */ +export interface OperationConstructor< + TIn extends Record, + TOut extends Record = TIn +> { + deserialize(data: Record): Operation; +} + +// ============================================================================ +// Concrete Operation Implementations +// ============================================================================ + +/** + * Operation that sets/updates fields in state. + * TOut = TIn & TUpdates (output includes new fields from updates) + */ +export class SetFieldsOperation< + TIn extends Record, + TUpdates extends Record = Partial +> implements Operation { + readonly name = 'set'; + + constructor(private updates: TUpdates) {} + + reads(): (keyof TIn)[] { + return Object.keys(this.updates) as (keyof TIn)[]; + } + + writes(): (keyof (TIn & TUpdates))[] { + return Object.keys(this.updates) as (keyof (TIn & TUpdates))[]; + } + + schemaExtensions(): Record { + // New fields get z.unknown() schema + const extensions: Record = {}; + for (const key in this.updates) { + extensions[key] = z.unknown(); + } + return extensions; + } + + validate(_state: TIn): void { + // No validation needed for set operations + } + + apply(state: TIn): void { + Object.assign(state, this.updates); + } + + serialize(): Record { + return { + name: this.name, + updates: this.updates, + }; + } + + static deserialize>( + data: Record + ): SetFieldsOperation { + return new SetFieldsOperation(data.updates); + } +} + +/** + * Operation that appends a value to an array field + * Type-safe: only allows appending to array fields with correct element types + */ +export class AppendFieldOperation, K extends ArrayKeys> + implements Operation +{ + readonly name = 'append'; + + constructor( + private key: K, + private value: ArrayElement + ) {} + + reads(): (keyof T)[] { + return [this.key]; + } + + writes(): (keyof T)[] { + return [this.key]; + } + + schemaExtensions(): Record { + // No new fields added + return {}; + } + + validate(state: T): void { + const current = state[this.key]; + if (current !== undefined && !Array.isArray(current)) { + throw new Error( + `Cannot append to non-array field '${String(this.key)}'. Current type: ${typeof current}` + ); + } + } + + apply(state: T): void { + const current = state[this.key] as any[] | undefined; + if (current === undefined) { + (state[this.key] as any) = [this.value]; + } else { + current.push(this.value); + } + } + + serialize(): Record { + return { + name: this.name, + key: this.key, + value: this.value, + }; + } + + static deserialize, K extends ArrayKeys>( + data: Record + ): AppendFieldOperation { + return new AppendFieldOperation(data.key, data.value); + } +} + +/** + * Operation that extends an array field with multiple values + */ +export class ExtendFieldOperation, K extends ArrayKeys> + implements Operation +{ + readonly name = 'extend'; + + constructor( + private key: K, + private values: ArrayElement[] + ) {} + + reads(): (keyof T)[] { + return [this.key]; + } + + writes(): (keyof T)[] { + return [this.key]; + } + + schemaExtensions(): Record { + // No new fields added + return {}; + } + + validate(state: T): void { + const current = state[this.key]; + if (current !== undefined && !Array.isArray(current)) { + throw new Error( + `Cannot extend non-array field '${String(this.key)}'. Current type: ${typeof current}` + ); + } + } + + apply(state: T): void { + const current = state[this.key] as any[] | undefined; + if (current === undefined) { + (state[this.key] as any) = [...this.values]; + } else { + current.push(...this.values); + } + } + + serialize(): Record { + return { + name: this.name, + key: this.key, + values: this.values, + }; + } + + static deserialize, K extends ArrayKeys>( + data: Record + ): ExtendFieldOperation { + return new ExtendFieldOperation(data.key, data.values); + } +} + +/** + * Operation that increments a numeric field + * Type-safe: only allows incrementing number fields + */ +export class IncrementFieldOperation, K extends NumberKeys> + implements Operation +{ + readonly name = 'increment'; + + constructor( + private key: K, + private delta: number = 1 + ) {} + + reads(): (keyof T)[] { + return [this.key]; + } + + writes(): (keyof T)[] { + return [this.key]; + } + + schemaExtensions(): Record { + // No new fields added + return {}; + } + + validate(state: T): void { + const current = state[this.key]; + if (current !== undefined && typeof current !== 'number') { + throw new Error( + `Cannot increment non-numeric field '${String(this.key)}'. Current type: ${typeof current}` + ); + } + } + + apply(state: T): void { + const current = (state[this.key] as number | undefined) ?? 0; + (state[this.key] as any) = current + this.delta; + } + + serialize(): Record { + return { + name: this.name, + key: this.key, + delta: this.delta, + }; + } + + static deserialize, K extends NumberKeys>( + data: Record + ): IncrementFieldOperation { + return new IncrementFieldOperation(data.key, data.delta); + } +} + +// ============================================================================ +// Operation Registry for Deserialization +// ============================================================================ + +/** + * Global registry for operation types + * Allows deserialization of operations from JSON + */ +export class OperationRegistry { + private static registry = new Map>(); + + /** + * Register an operation type for deserialization + */ + static register>( + name: string, + constructor: OperationConstructor + ): void { + this.registry.set(name, constructor); + } + + /** + * Deserialize an operation from JSON data + */ + static deserialize>(data: Record): Operation { + const constructor = this.registry.get(data.name); + if (!constructor) { + throw new Error(`Unknown operation type: ${data.name}`); + } + return constructor.deserialize(data); + } + + /** + * Check if an operation type is registered + */ + static has(name: string): boolean { + return this.registry.has(name); + } +} + +// Register built-in operations +OperationRegistry.register('set', SetFieldsOperation); +OperationRegistry.register('append', AppendFieldOperation); +OperationRegistry.register('extend', ExtendFieldOperation); +OperationRegistry.register('increment', IncrementFieldOperation); + +// ============================================================================ +// Helper Types +// ============================================================================ + +// ============================================================================ +// State Class +// ============================================================================ + +/** + * Immutable state container for Burr applications with optional read/write restrictions. + * + * State is the core data structure that flows through your application. + * All mutations return new State instances, preserving immutability. + * + * Requires a Zod schema for runtime validation. Optionally supports read/write + * restrictions for use in actions. + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * const ChatStateSchema = z.object({ + * messages: z.array(z.string()), + * count: z.number() + * }); + * + * // Unrestricted state (default) + * const state = createState(ChatStateSchema, { messages: [], count: 0 }); + * const newState = state.update({ count: 1 }); + * + * // Restricted state (for actions) + * const restricted = State.forAction( + * z.object({ messages: z.array(z.string()) }), // reads + * z.object({ count: z.number() }), // writes + * { messages: [] } + * ); + * ``` + */ +// State class with Proxy-based property access +// The actual class merges with the readable data type via Proxy +export type StateInstance< + TSchema extends z.ZodType>, + TReadableSchema extends z.ZodType> = TSchema, + TWritableSchema extends z.ZodType> = TSchema +> = State & z.infer; + +export class State< + TSchema extends z.ZodType>, + TReadableSchema extends z.ZodType> = TSchema, + TWritableSchema extends z.ZodType> = TSchema +> { + private readonly _data: z.infer; + private readonly _schema: TSchema; + private readonly _readableSchema: TReadableSchema; + private readonly _writableSchema: TWritableSchema; + + /** + * Creates a new State instance with runtime validation and optional restrictions. + * + * @param schema - Zod schema for the full state data + * @param data - The initial state data + * @param options - Optional read/write restrictions + * @throws {z.ZodError} If the data doesn't match the schema + */ + constructor( + schema: TSchema, + data: z.infer, + options?: { + readable?: TReadableSchema; + writable?: TWritableSchema; + } + ) { + this._schema = schema; + this._readableSchema = (options?.readable ?? schema) as TReadableSchema; + this._writableSchema = (options?.writable ?? schema) as TWritableSchema; + + // Validate restrictions are subsets of main schema + if (options?.readable) { + this.validateSubset(schema, options.readable, 'readable'); + } + if (options?.writable) { + this.validateSubset(schema, options.writable, 'writable'); + } + + // Validate and clone the data + const validatedData = this._schema.parse(data); + this._data = this.deepClone(validatedData); + + // Return Proxy for direct property access + return new Proxy(this, { + get(target, prop) { + // If accessing a State method or private field, return it + if (prop in target) { + return target[prop as keyof typeof target]; + } + // Otherwise, access data property (typed by TReadableSchema) + const data = target._data as Record; + return data[prop as string]; + }, + }) as StateInstance; + } + + /** + * Validates that a subset schema only contains keys present in the parent schema. + * Only validates ZodObject schemas (other types pass through). + */ + private validateSubset( + parentSchema: z.ZodType, + subsetSchema: z.ZodType, + name: string + ): void { + // Only validate for ZodObject types + if (!(parentSchema instanceof z.ZodObject && subsetSchema instanceof z.ZodObject)) { + return; + } + + const parentKeys = Object.keys(parentSchema.shape); + const subsetKeys = Object.keys(subsetSchema.shape); + const invalidKeys = subsetKeys.filter((k) => !parentKeys.includes(k)); + + if (invalidKeys.length > 0) { + throw new Error( + `${name} schema contains keys not in parent schema: ${invalidKeys.join(', ')}` + ); + } + } + + /** + * Applies an operation to this state, returning a new state. + * This is the core method that all mutations go through. + * + * Uses copy-on-write optimization: + * 1. Shallow copy entire state (cheap - just copies references) + * 2. Deep clone ONLY fields that are read (structural sharing for others) + * 3. Mutate in place + * 4. Validate against schema + * + * Note: This is a low-level method. Prefer update(), increment(), append(), extend() + * which provide better type safety and automatically extend the readable schema. + */ + applyOperation>( + operation: Operation, TOut> + ): StateInstance, TReadableSchema, TWritableSchema> { + // Shallow copy - O(n) where n = number of keys, but just copying references + const newData = { ...this._data } as Record; + + // Deep clone only the fields that will be read/modified + // This enables structural sharing: unchanged fields still reference original objects + for (const key of operation.reads()) { + if (key in newData) { + newData[key] = this.deepClone(newData[key]); + } + } + + // Validate before applying + operation.validate(newData); + + // Apply mutation in place (no additional copy!) + operation.apply(newData); + + // Extend schema if operation adds new fields + const extensions = operation.schemaExtensions(); + const extendedSchema = + this._schema instanceof z.ZodObject && Object.keys(extensions).length > 0 + ? this._schema.extend(extensions) + : this._schema; + + // Validate against extended schema after operation + const validatedData = extendedSchema.parse(newData); + + // Return new State instance with extended schema + // Note: Readable schema is NOT extended here - use update() for that + // Cast through unknown: runtime has correct schema, TypeScript can't verify alignment + return new State(extendedSchema, validatedData, { + readable: this._readableSchema, + writable: this._writableSchema, + }) as unknown as StateInstance, TReadableSchema, TWritableSchema>; + } + + /** + * Updates state with new field values (upsert operation). + * Dynamically extends the schema to include new fields, maintaining alignment + * between runtime schema and compile-time types. + * + * Type narrowing: When updating optional fields with concrete values, + * the field type narrows from `T | undefined` to `T`. + * + * Read schema growth: Fields you write become readable. This ensures you can + * read back what you just wrote, which is essential for chained updates. + * + * Only allows updating fields defined in the writable schema. + * Excess properties are rejected at compile-time. + * + * @example + * ```typescript + * // state: { count?: number } + * const updated = state.update({ count: 5 }); + * // updated: { count: number } βœ… Narrowed to required + * // Can now read: updated.count βœ… Added to readable schema + * ``` + */ + update( + updates: TUpdates & NoExcessProperties>, TUpdates> + ): StateInstance< + z.ZodType & TUpdates>, + z.ZodType & TUpdates>, + TWritableSchema + > { + // Runtime validation: ensure updates match writable schema + if (this._writableSchema instanceof z.ZodObject) { + this._writableSchema.partial().parse(updates); + } + + // Extend schemas with new fields (you can read what you wrote) + const extendedSchema: any = this._schema instanceof z.ZodObject + ? extendSchemaWithFields(this._schema, updates) + : this._schema; + + const extendedReadableSchema: any = this._readableSchema instanceof z.ZodObject + ? extendSchemaWithFields(this._readableSchema, updates) + : this._readableSchema; + + // Create new data + const newData = { ...this._data, ...updates }; + + // Return new State with extended schemas + return new State(extendedSchema, newData, { + readable: extendedReadableSchema, + writable: this._writableSchema, + }) as any; + } + + /** + * Appends values to one or more array fields (type-safe). + * Only allows appending to fields defined in the writable schema. + * Excess properties are rejected at compile-time. + * + * The readable schema is extended with appended fields, allowing you to + * read back what you just modified. + * + * @example + * state.append({ items: newItem, tags: newTag }) + */ + append>]?: ArrayElement[K]>; + }>( + updates: NoExcessProperties< + { [K in ArrayKeys>]?: ArrayElement[K]> }, + TUpdates + > + ): StateInstance< + TSchema, + z.ZodType & TUpdates>, + TWritableSchema + > { + let currentState: StateInstance = this as any; + + for (const [key, value] of Object.entries(updates)) { + currentState = currentState.applyOperation>( + new AppendFieldOperation, ArrayKeys>>( + key as ArrayKeys>, + value + ) + ) as StateInstance; + } + + // Extend readable schema with appended fields + const extendedReadableSchema: any = this._readableSchema instanceof z.ZodObject + ? extendSchemaWithFields(this._readableSchema, updates) + : this._readableSchema; + + return new State(currentState._schema, currentState._data, { + readable: extendedReadableSchema, + writable: currentState._writableSchema, + }) as any; + } + + /** + * Extends one or more array fields with multiple values (type-safe). + * Only allows extending fields defined in the writable schema. + * Excess properties are rejected at compile-time. + * + * The readable schema is extended with modified fields, allowing you to + * read back what you just modified. + * + * @example + * state.extend({ items: [item1, item2], tags: [tag1, tag2] }) + */ + extend>]?: ArrayElement[K]>[]; + }>( + updates: NoExcessProperties< + { [K in ArrayKeys>]?: ArrayElement[K]>[] }, + TUpdates + > + ): StateInstance< + TSchema, + z.ZodType & TUpdates>, + TWritableSchema + > { + let currentState: StateInstance = this as any; + + for (const [key, values] of Object.entries(updates)) { + currentState = currentState.applyOperation>( + new ExtendFieldOperation, ArrayKeys>>( + key as ArrayKeys>, + values as any[] + ) + ) as StateInstance; + } + + // Extend readable schema with modified fields + const extendedReadableSchema: any = this._readableSchema instanceof z.ZodObject + ? extendSchemaWithFields(this._readableSchema, updates) + : this._readableSchema; + + return new State(currentState._schema, currentState._data, { + readable: extendedReadableSchema, + writable: currentState._writableSchema, + }) as any; + } + + /** + * Increments one or more numeric fields (type-safe upsert). + * Creates fields if they don't exist (starting from 0). + * Works like update() - extends the schema with new fields. + * + * For restricted states, only allows incrementing fields in writable schema. + * For unrestricted states (createState), allows any field name. + * + * @example + * state.increment({ count: 1, score: 5 }) // Upserts count and score + */ + increment>( + updates: TUpdates & ( + TWritableSchema extends TSchema + ? {} // Unrestricted: allow any field + : NoExcessProperties<{ [K in NumberKeys>]?: number }, TUpdates> + ) + ): StateInstance< + z.ZodType & TUpdates>, + z.ZodType & TUpdates>, + z.ZodType & TUpdates> + > { + // Start with current data + let currentData = { ...this._data } as Record; + + // Apply all increments + for (const [key, delta] of Object.entries(updates)) { + const current = (currentData[key] as number | undefined) ?? 0; + if (currentData[key] !== undefined && typeof currentData[key] !== 'number') { + throw new Error( + `Cannot increment non-numeric field '${key}'. Current type: ${typeof currentData[key]}` + ); + } + currentData[key] = current + delta; + } + + // Extend all schemas with incremented fields (upsert behavior) + const extendedSchema: any = this._schema instanceof z.ZodObject + ? extendSchemaWithFields(this._schema, updates) + : this._schema; + + const extendedReadableSchema: any = this._readableSchema instanceof z.ZodObject + ? extendSchemaWithFields(this._readableSchema, updates) + : this._readableSchema; + + const extendedWritableSchema: any = this._writableSchema instanceof z.ZodObject + ? extendSchemaWithFields(this._writableSchema, updates) + : this._writableSchema; + + return new State(extendedSchema, currentData, { + readable: extendedReadableSchema, + writable: extendedWritableSchema, + }) as any; + } + + /** + * Returns all keys in state + */ + keys(): string[] { + return Object.keys(this._data as Record); + } + + /** + * Returns the underlying data (for internal use by actions/application) + */ + get data(): z.infer { + return this._data; + } + + /** + * Merges another state into this one (other's values win on conflicts) + */ + merge(other: State): StateInstance { + const mergedData = { + ...(this._data as Record), + ...(other._data as Record), + } as z.infer; + return new State(this._schema, mergedData, { + readable: this._readableSchema, + writable: this._writableSchema, + }) as StateInstance; + } + + /** + * Serializes state to JSON-compatible object + */ + serialize(): z.infer { + return this._data; + } + + /** + * Deserializes state from JSON-compatible object with schema validation. + * This ensures that loaded state matches the expected schema. + * + * @param schema - Zod schema to validate against + * @param data - The serialized state data + * @throws {z.ZodError} If the data doesn't match the schema + */ + /** + * Deserializes state from JSON-compatible object with schema validation. + * This ensures that loaded state matches the expected schema. + * + * **Requires a Zod object schema** - this ensures runtime operations like `.extend()` work correctly. + * + * @param schema - Zod schema to validate against + * @param data - The serialized state data + * @throws {z.ZodError} If the data doesn't match the schema + */ + static deserialize>( + schema: TSchema, + data: z.infer + ): StateInstance { + return new State(schema, data) as StateInstance; + } + + /** + * Creates a restricted state for use in actions. + * The state can read from 'reads' schema and write to 'writes' schema. + * + * @param reads - Schema defining readable fields + * @param writes - Schema defining writable fields + * @param data - Initial data matching the reads schema + */ + /** + * Creates a restricted state for use in actions. + * The state can read from 'reads' schema and write to 'writes' schema. + * + * **Requires Zod object schemas** - this ensures runtime operations like `.extend()` work correctly. + * + * @param reads - Schema defining readable fields + * @param writes - Schema defining writable fields + * @param data - Initial data matching the reads schema + */ + static forAction< + TReadsSchema extends z.ZodObject, + TWritesSchema extends z.ZodObject + >( + reads: TReadsSchema, + writes: TWritesSchema, + data: z.infer + ): StateInstance { + return new State(reads, data, { + readable: reads, + writable: writes, + }) as StateInstance; + } + + /** + * Creates a new State containing only the specified keys. + * + * Validates that all requested keys exist in the state, throwing an error + * if any are missing. This provides strict subsetting for defense in depth. + * + * Useful for subsetting state to only what an action needs to read, + * or subsetting writes to only declared fields. + * + * @param keys - Array of keys to include in the subset + * @returns New State instance with only the specified keys + * @throws {Error} If any requested keys are missing from the state + * + * @example + * ```typescript + * const state = createState( + * z.object({ count: z.number(), name: z.string(), age: z.number() }), + * { count: 0, name: 'Alice', age: 30 } + * ); + * + * // Get only count and name + * const subset = state.subset(['count', 'name']); + * // subset.data = { count: 0, name: 'Alice' } + * + * // Throws error if keys are missing + * state.subset(['count', 'missing']); // throws! + * ``` + */ + subset(keys: readonly string[]): StateInstance>, z.ZodType>, z.ZodType>> { + // Validate all requested keys are present + const missing = keys.filter(key => !(key in this._data)); + if (missing.length > 0) { + throw new Error( + `State subset failed: missing required keys [${missing.join(', ')}]` + ); + } + + // Create subset data by picking only specified keys + const subsetData = keys.reduce((acc, key) => { + acc[key] = this._data[key]; + return acc; + }, {} as Record); + + // Create a permissive schema for the subset + // We use z.record since we don't know the exact shape at compile time + const subsetSchema = z.record(z.string(), z.any()); + + return new State(subsetSchema, subsetData) as StateInstance< + z.ZodType>, + z.ZodType>, + z.ZodType> + >; + } + + /** + * Deep clones a value using structuredClone. + * Handles Date, RegExp, Map, Set, circular references, etc. + */ + private deepClone(value: V): V { + return structuredClone(value); + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Helper function to create an unrestricted State with automatic type inference from schema. + * This provides better DX by inferring the type parameter from the schema. + * + * @example + * ```typescript + * const MyStateSchema = z.object({ + * count: z.number(), + * name: z.string() + * }); + * + * const state = createState(MyStateSchema, { count: 0, name: 'test' }); + * // Can read and write all fields + * ``` + */ +/** + * Helper function to create an unrestricted State with automatic type inference from schema. + * This provides better DX by inferring the type parameter from the schema. + * + * **Requires a Zod object schema** - this ensures runtime operations like `.extend()` work correctly. + * + * @example + * ```typescript + * const MyStateSchema = z.object({ + * count: z.number(), + * name: z.string() + * }); + * + * const state = createState(MyStateSchema, { count: 0, name: 'test' }); + * // Can read and write all fields + * ``` + */ +export function createState>( + schema: TSchema, + initialData: z.infer +): StateInstance { + return new State(schema, initialData) as StateInstance; +} + +/** + * Factory function to create a new State instance with defaults (power-user mode) + * + * This function allows you to create state without providing explicit data, + * relying on Zod's `.default()` values to fill in the fields at runtime. + * + * @example + * ```typescript + * const MyStateSchema = z.object({ + * count: z.number().default(0), + * name: z.string().default('untitled') + * }); + * + * // No data parameter needed - Zod fills defaults + * const state = createStateWithDefaults(MyStateSchema); + * + * // Or provide partial data to override some defaults + * const state2 = createStateWithDefaults(MyStateSchema, { count: 5 }); + * ``` + */ +/** + * Factory function to create a new State instance with defaults (power-user mode) + * + * This function allows you to create state without providing explicit data, + * relying on Zod's `.default()` values to fill in the fields at runtime. + * + * **Requires a Zod object schema** - this ensures runtime operations like `.extend()` work correctly. + * + * @example + * ```typescript + * const MyStateSchema = z.object({ + * count: z.number().default(0), + * name: z.string().default('untitled') + * }); + * + * // No data parameter needed - Zod fills defaults + * const state = createStateWithDefaults(MyStateSchema); + * + * // Or provide partial data to override some defaults + * const state2 = createStateWithDefaults(MyStateSchema, { count: 5 }); + * ``` + */ +export function createStateWithDefaults>( + schema: TSchema, + initialData?: Partial> +): StateInstance { + const validatedData = schema.parse(initialData ?? {}); + return new State(schema, validatedData) as StateInstance; +} diff --git a/typescript/packages/burr-core/src/streaming.ts b/typescript/packages/burr-core/src/streaming.ts new file mode 100644 index 000000000..e4074b761 --- /dev/null +++ b/typescript/packages/burr-core/src/streaming.ts @@ -0,0 +1,338 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Streaming actions and result containers +// +// Mirrors Python's StreamingAction/SingleStepStreamingAction/StreamingResultContainer. +// Uses AsyncGenerator (not ReadableStream) to match the Python generator pattern. + +import { z } from 'zod'; +import { StateInstance } from './state'; + +// ============================================================================ +// StreamingAction +// ============================================================================ + +/** + * A streaming action yields intermediate results, then produces a final result. + * + * Two-phase design (matching Python's StreamingAction): + * - streamRun: async generator that yields intermediate results, final yield is the complete result + * - update: transforms the final result into state writes (same as regular Action) + * + * The streaming protocol: + * - Each yield produces an intermediate result dict + * - The last yielded value is the final/complete result + * - After the generator completes, update() is called with the final result + */ +export class StreamingAction< + TReadsSchema extends z.ZodObject, + TWritesSchema extends z.ZodObject, + TInputsSchema extends z.ZodType, + TResultSchema extends z.ZodObject +> { + private readonly _name?: string; + private readonly _reads: TReadsSchema; + private readonly _writes: TWritesSchema; + private readonly _inputs: TInputsSchema; + private readonly _result: TResultSchema; + + private readonly _streamRunFn: (params: { + state: StateInstance; + inputs: z.infer; + }) => AsyncGenerator, void, undefined>; + + private readonly _updateFn: (params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; + }) => StateInstance>, any, z.ZodType>>; + + private readonly _readsKeys: readonly string[]; + private readonly _writesKeys: readonly string[]; + private readonly _inputsKeys: readonly string[]; + + constructor(config: { + name?: string; + reads: TReadsSchema; + writes: TWritesSchema; + inputs: TInputsSchema; + result: TResultSchema; + streamRun: (params: { + state: StateInstance; + inputs: z.infer; + }) => AsyncGenerator, void, undefined>; + update: (params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; + }) => StateInstance>, any, z.ZodType>>; + }) { + this._name = config.name; + this._reads = config.reads; + this._writes = config.writes; + this._inputs = config.inputs; + this._result = config.result; + this._streamRunFn = config.streamRun; + this._updateFn = config.update; + + this._readsKeys = this.extractKeys(config.reads); + this._writesKeys = this.extractKeys(config.writes); + this._inputsKeys = this.extractKeys(config.inputs); + } + + private extractKeys(schema: z.ZodType): readonly string[] { + if (schema instanceof z.ZodObject) { + return Object.keys(schema.shape); + } + return []; + } + + get name(): string | undefined { return this._name; } + get reads(): readonly string[] { return this._readsKeys; } + get writes(): readonly string[] { return this._writesKeys; } + get inputs(): readonly string[] { return this._inputsKeys; } + get streaming(): boolean { return true; } + + get schema() { + return { + reads: this._reads, + writes: this._writes, + inputs: this._inputs, + result: this._result, + } as const; + } + + withName(name: string): StreamingAction { + return new StreamingAction({ + name, + reads: this._reads, + writes: this._writes, + inputs: this._inputs, + result: this._result, + streamRun: this._streamRunFn, + update: this._updateFn, + }); + } + + /** + * Returns the async generator that yields intermediate results. + * The last yielded value is the final result. + */ + streamRun(params: { + state: StateInstance; + inputs: z.infer; + }): AsyncGenerator, void, undefined> { + return this._streamRunFn(params); + } + + /** + * Transform the final result into state writes. + */ + update(params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; + }): StateInstance>, any, z.ZodType>> { + return this._updateFn(params); + } + + /** + * Non-streaming execution: runs the generator to completion and returns + * the final result. This allows a StreamingAction to be used where a + * regular Action is expected. + */ + async run(params: { + state: StateInstance; + inputs: z.infer; + }): Promise> { + const gen = this.streamRun(params); + let lastResult: z.infer | undefined; + for await (const item of gen) { + lastResult = item; + } + return lastResult!; + } +} + +// ============================================================================ +// Factory Function +// ============================================================================ + +/** + * Creates a streaming action. + * + * @example + * ```typescript + * const streamChat = streamingAction({ + * reads: z.object({ prompt: z.string() }), + * writes: z.object({ response: z.string() }), + * inputs: z.void(), + * result: z.object({ token: z.string(), full: z.string() }), + * + * async *streamRun({ state }) { + * let full = ''; + * for await (const token of llmStream(state.prompt)) { + * full += token; + * yield { token, full }; + * } + * // Last yield is the final result + * }, + * + * update: ({ result, state }) => state.update({ response: result.full }), + * }); + * ``` + */ +export function streamingAction< + TReadsSchema extends z.ZodObject = z.ZodObject<{}>, + TWritesSchema extends z.ZodObject = z.ZodObject<{}>, + TInputsSchema extends z.ZodType = z.ZodVoid, + TResultSchema extends z.ZodObject = z.ZodObject<{}> +>(config: { + reads?: TReadsSchema; + writes?: TWritesSchema; + inputs?: TInputsSchema; + result: TResultSchema; + streamRun: (params: { + state: StateInstance; + inputs: z.infer; + }) => AsyncGenerator, void, undefined>; + update: (params: { + result: z.infer; + state: StateInstance; + inputs: z.infer; + }) => StateInstance>, any, z.ZodType>>; +}): StreamingAction { + const reads = (config.reads ?? z.object({})) as TReadsSchema; + const writes = (config.writes ?? z.object({})) as TWritesSchema; + const inputs = (config.inputs ?? z.void()) as TInputsSchema; + + return new StreamingAction({ + reads, + writes, + inputs, + result: config.result, + streamRun: config.streamRun, + update: config.update, + }); +} + +// ============================================================================ +// StreamingResultContainer +// ============================================================================ + +/** + * Container for consuming a streaming action's results. + * + * Mirrors Python's StreamingResultContainer: + * 1. Iterate over intermediate results as they come in + * 2. Get the final result + state after iteration completes + * + * @example + * ```typescript + * const container = new StreamingResultContainer(generator, state, updateFn); + * for await (const intermediate of container) { + * console.log(intermediate.token); + * } + * const { result, state } = await container.get(); + * ``` + */ +export class StreamingResultContainer> { + private readonly _generator: AsyncGenerator; + private readonly _forkedState: StateInstance; + private readonly _updateFn: (result: TResult, state: StateInstance) => StateInstance; + private _finalResult: TResult | undefined; + private _finalState: StateInstance | undefined; + private _done = false; + + /** + * Create a pass-through container for non-streaming actions. + * Wraps a completed result in the streaming API for uniform consumption. + */ + static passThrough( + result: T, + finalState: StateInstance + ): StreamingResultContainer { + async function* singleYield(): AsyncGenerator { + yield result; + } + const container = new StreamingResultContainer( + singleYield(), + finalState, + () => finalState, + ); + return container; + } + + constructor( + generator: AsyncGenerator, + forkedState: StateInstance, + updateFn: (result: TResult, state: StateInstance) => StateInstance, + ) { + this._generator = generator; + this._forkedState = forkedState; + this._updateFn = updateFn; + } + + /** + * Iterate over intermediate results. + * The last value yielded by the generator becomes the final result. + */ + async *[Symbol.asyncIterator](): AsyncGenerator { + let lastResult: TResult | undefined; + for await (const item of this._generator) { + lastResult = item; + yield item; + } + this._finalResult = lastResult; + this._finalState = lastResult !== undefined + ? this._updateFn(lastResult, this._forkedState) + : this._forkedState; + this._done = true; + } + + /** + * Get the final result and state. + * If the iterator hasn't been fully consumed, consumes it first. + */ + async get(): Promise<{ result: TResult; state: StateInstance }> { + if (!this._done) { + // Consume the generator + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _item of this) { + // consume + } + } + return { + result: this._finalResult!, + state: this._finalState!, + }; + } +} + +// ============================================================================ +// Type guard +// ============================================================================ + +/** + * Check if an action is a streaming action. + */ +export function isStreamingAction(action: any): action is StreamingAction { + return action instanceof StreamingAction; +} diff --git a/typescript/packages/burr-core/src/tracing.ts b/typescript/packages/burr-core/src/tracing.ts new file mode 100644 index 000000000..d053a1c4b --- /dev/null +++ b/typescript/packages/burr-core/src/tracing.ts @@ -0,0 +1,291 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Tracking & observability: ActionSpan hierarchy, TracerFactory, and span lifecycle. +// +// Mirrors Python's burr/visibility/tracing.py. +// Uses AsyncLocalStorage for execution context (Node >= 18 guaranteed). + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { + type PreStartSpanHook, + type PostEndSpanHook, + type DoLogAttributeHook, + type LifecycleAdapter, +} from './lifecycle'; + +// Re-export for convenience (these are the canonical definitions from lifecycle.ts) +export type { PreStartSpanHook, PostEndSpanHook, DoLogAttributeHook }; + +// ============================================================================ +// ActionSpan +// ============================================================================ + +let _spanIdCounter = 0; + +/** + * Represents a span in the action execution tree. + * Mirrors Python's ActionSpan. + */ +export class ActionSpan { + readonly action: string; + readonly actionSequenceId: number; + readonly name: string; + readonly parent: ActionSpan | null; + readonly uid: string; + readonly sequenceId: number; + private _childCount = 0; + + constructor( + action: string, + actionSequenceId: number, + name: string, + parent: ActionSpan | null = null, + sequenceId?: number + ) { + this.action = action; + this.actionSequenceId = actionSequenceId; + this.name = name; + this.parent = parent; + this.sequenceId = sequenceId ?? _spanIdCounter++; + this.uid = `${action}:${actionSequenceId}:${this.name}:${this.sequenceId}`; + } + + get childCount(): number { + return this._childCount; + } + + /** + * Create a child span. + */ + spawn(name: string): ActionSpan { + this._childCount++; + return new ActionSpan( + this.action, + this.actionSequenceId, + name, + this + ); + } +} + +// ============================================================================ +// ActionSpanTracer +// ============================================================================ + +/** + * Manages a single span's lifecycle: start, log attributes, end. + */ +export class ActionSpanTracer { + readonly span: ActionSpan; + private readonly _hooks: LifecycleAdapter[]; + private readonly _appId: string; + private readonly _partitionKey: string | undefined; + private readonly _dependencies: string[]; + private _started = false; + private _ended = false; + + constructor( + span: ActionSpan, + hooks: LifecycleAdapter[], + appId: string, + partitionKey: string | undefined, + dependencies: string[] = [] + ) { + this.span = span; + this._hooks = hooks; + this._appId = appId; + this._partitionKey = partitionKey; + this._dependencies = dependencies; + } + + async start(): Promise { + if (this._started) return; + this._started = true; + + for (const hook of this._hooks) { + if ('preStartSpan' in hook && typeof hook.preStartSpan === 'function') { + await hook.preStartSpan({ + action: this.span.action, + actionSequenceId: this.span.actionSequenceId, + span: this.span, + spanDependencies: this._dependencies, + appId: this._appId, + partitionKey: this._partitionKey, + }); + } + } + } + + async logAttributes(attributes: Record): Promise { + for (const hook of this._hooks) { + if ('doLogAttributes' in hook && typeof hook.doLogAttributes === 'function') { + await hook.doLogAttributes({ + attributes, + action: this.span.action, + actionSequenceId: this.span.actionSequenceId, + span: this.span, + appId: this._appId, + partitionKey: this._partitionKey, + }); + } + } + } + + async end(): Promise { + if (this._ended) return; + this._ended = true; + + for (const hook of this._hooks) { + if ('postEndSpan' in hook && typeof hook.postEndSpan === 'function') { + await hook.postEndSpan({ + action: this.span.action, + actionSequenceId: this.span.actionSequenceId, + span: this.span, + spanDependencies: this._dependencies, + appId: this._appId, + partitionKey: this._partitionKey, + }); + } + } + } +} + +// ============================================================================ +// TracerFactory +// ============================================================================ + +/** + * Creates span tracers for an action's execution context. + * Injected into actions that request tracing. + * + * Mirrors Python's TracerFactory. + */ +export class TracerFactory { + private readonly _appId: string; + private readonly _partitionKey: string | undefined; + private readonly _hooks: LifecycleAdapter[]; + private readonly _rootSpan: ActionSpan; + + constructor( + action: string, + actionSequenceId: number, + appId: string, + partitionKey: string | undefined, + hooks: LifecycleAdapter[] = [] + ) { + this._appId = appId; + this._partitionKey = partitionKey; + this._hooks = hooks; + this._rootSpan = new ActionSpan(action, actionSequenceId, action); + } + + get rootSpan(): ActionSpan { + return this._rootSpan; + } + + /** + * Create a span tracer for a named sub-operation. + */ + createSpan(name: string, dependencies: string[] = []): ActionSpanTracer { + const span = this._rootSpan.spawn(name); + return new ActionSpanTracer( + span, + this._hooks, + this._appId, + this._partitionKey, + dependencies + ); + } +} + +// ============================================================================ +// AsyncLocalStorage context for nested tracing +// ============================================================================ + +const tracerStorage = new AsyncLocalStorage(); + +/** + * Get the current TracerFactory from the async context. + * Returns undefined if not within a traced execution. + */ +export function getCurrentTracer(): TracerFactory | undefined { + return tracerStorage.getStore(); +} + +/** + * Run a function within a tracer context. + * Useful for Application to inject the tracer during action execution. + */ +export function runWithTracer(tracer: TracerFactory, fn: () => T): T { + return tracerStorage.run(tracer, fn); +} + +// ============================================================================ +// trace() wrapper +// ============================================================================ + +/** + * Wraps an async function in a span. + * Equivalent of Python's @trace decorator. + * + * @example + * ```typescript + * const tracedFetch = trace(async (url: string) => { + * return fetch(url).then(r => r.json()); + * }, { spanName: 'fetch_data' }); + * ``` + */ +export function trace( + fn: (...args: TArgs) => Promise, + options?: { + spanName?: string; + captureInputs?: boolean; + captureOutputs?: boolean; + } +): (...args: TArgs) => Promise { + const spanName = options?.spanName ?? (fn.name || 'anonymous'); + + return async (...args: TArgs): Promise => { + const tracer = getCurrentTracer(); + if (!tracer) { + // No tracer in context, just run the function + return fn(...args); + } + + const spanTracer = tracer.createSpan(spanName); + await spanTracer.start(); + + if (options?.captureInputs) { + await spanTracer.logAttributes({ inputs: args }); + } + + try { + const result = await fn(...args); + + if (options?.captureOutputs) { + await spanTracer.logAttributes({ output: result }); + } + + return result; + } finally { + await spanTracer.end(); + } + }; +} diff --git a/typescript/packages/burr-core/src/type-utils.ts b/typescript/packages/burr-core/src/type-utils.ts new file mode 100644 index 000000000..fd0559771 --- /dev/null +++ b/typescript/packages/burr-core/src/type-utils.ts @@ -0,0 +1,376 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Central collection of reusable type utilities for Burr. + * + * This module provides compile-time type operations used throughout the codebase: + * - Schema transformations (fix, normalize) + * - Type merging (union to intersection) + * - Field extraction (by value type) + * - Validation (excess properties, constraints) + * - Conditional logic (mode selection) + * + * @module type-utils + */ + +import { z } from 'zod'; + +// ============================================================================ +// Schema Transformations +// ============================================================================ + +/** + * Fixes Zod's empty schema inference to prevent type pollution. + * + * Problem: `z.object({}).pick({})` infers to `Record`, which + * breaks `extends` checks when intersected with other types. + * + * Solution: Convert `Record` to `{}` (empty object type). + * + * @example + * ```typescript + * // Without fix: Record & { a: number } = never (unusable!) + * // With fix: {} & { a: number } = { a: number } (correct!) + * type Fixed = FixEmptySchema>; // => {} + * ``` + */ +export type FixEmptySchema = T extends Record ? {} : T; + +// ============================================================================ +// Type Merging +// ============================================================================ + +/** + * Merges all value types in a record into a single intersection type. + * + * This converts a union of types to an intersection by using distributive + * conditional types and contravariance. The trick works because: + * 1. Function parameters are contravariant + * 2. When inferring from a contravariant position, TypeScript produces an intersection + * + * NOTE: TypeScript's naming is backwards from set theory! + * - TS `&` (intersection) = merge fields (like set union: A βˆͺ B) + * - TS `|` (union) = either/or (like set disjunction: A ∩ B) + * + * @example + * ```typescript + * type Actions = { + * action1: { reads: { x: number } }; + * action2: { reads: { y: string } }; + * }; + * + * type AllReads = MergeRecordValues<{ + * [K in keyof Actions]: Actions[K]['reads'] + * }>; // => { x: number } & { y: string } + * ``` + */ +export type MergeRecordValues> = + (TRecord[keyof TRecord] extends infer U + ? (U extends any ? (x: U) => void : never) extends (x: infer I) => void + ? I + : never + : never); + +// ============================================================================ +// Field Extraction by Value Type +// ============================================================================ + +/** + * Generic utility to extract keys where the value matches a specific type. + * + * This uses mapped types with conditional filtering to extract only the keys + * whose values extend the target type. + * + * @example + * ```typescript + * type Example = { + * a: number; + * b: string; + * c: number; + * d: boolean; + * }; + * + * type NumKeys = KeysWhere; // => 'a' | 'c' + * type StrKeys = KeysWhere; // => 'b' + * ``` + */ +export type KeysWhere = { + [K in keyof T]: T[K] extends ValueType ? K : never; +}[keyof T]; + +/** + * Extract keys with number values. + * Used for operations like `increment()` that only work on numeric fields. + * + * @example + * ```typescript + * type State = { count: number; name: string; score: number }; + * type Nums = NumberKeys; // => 'count' | 'score' + * ``` + */ +export type NumberKeys = KeysWhere; + +/** + * Extract keys with array values. + * Used for operations like `append()` and `extend()` that work on arrays. + * + * @example + * ```typescript + * type State = { items: string[]; count: number; tags: number[] }; + * type Arrays = ArrayKeys; // => 'items' | 'tags' + * ``` + */ +export type ArrayKeys = KeysWhere>; + +/** + * Extract keys with string values. + * Useful for string-specific operations. + * + * @example + * ```typescript + * type State = { name: string; count: number; id: string }; + * type Strings = StringKeys; // => 'name' | 'id' + * ``` + */ +export type StringKeys = KeysWhere; + +/** + * Extract the element type from an array type. + * + * @example + * ```typescript + * type Arr = string[]; + * type Elem = ArrayElement; // => string + * + * type Nested = number[][]; + * type NestedElem = ArrayElement; // => number[] + * ``` + */ +export type ArrayElement = T extends Array ? U : never; + +// ============================================================================ +// Validation & Constraints +// ============================================================================ + +/** + * Ensures `Actual` has only keys from `Allowed`, showing clear error messages for excess properties. + * + * This is used to enforce write restrictions: when a function declares it writes certain fields, + * TypeScript will catch attempts to write to undeclared fields at compile-time. + * + * Usage Pattern: + * ```typescript + * function update>( + * data: T & NoExcessProperties, T> + * ) { + * // T is inferred narrowly first, then validated + * } + * ``` + * + * The `T &` intersection forces TypeScript to infer `T` before applying the constraint, + * which results in precise type inference and helpful error messages. + * + * @example + * ```typescript + * type Allowed = { a: number; b: string }; + * + * // βœ… Valid + * type Valid = { a: number } & NoExcessProperties; + * + * // ❌ Error: Property 'c' is not in writes schema + * type Invalid = { c: boolean } & NoExcessProperties; + * ``` + */ +export type NoExcessProperties = { + [K in keyof Actual]: K extends keyof Allowed + ? Actual[K] + : `❌ ERROR: Property '${K & string}' is not allowed. Remove it or update schema.`; +}; + +/** + * Custom constraint with user-defined error message. + * Useful for creating domain-specific validation with clear error messages. + * + * @example + * ```typescript + * function process( + * data: AssertExtends + * ) { + * // ... + * } + * ``` + */ +export type AssertExtends< + T, + U, + ErrorMsg extends string = 'Type constraint failed' +> = T extends U + ? T + : { [K in ErrorMsg]: { expected: U; got: T } }; + +// ============================================================================ +// Conditional Type Selection +// ============================================================================ + +/** + * Choose between two types based on whether `Condition` extends `Target`. + * + * This is useful for implementing "mode selection" in builders or APIs where + * behavior differs based on whether certain type parameters are provided. + * + * @example + * ```typescript + * // Bottom-up vs top-down mode + * type StateType = ChooseType< + * TProvided, + * never, + * ComputedType, // bottom-up: compute from actions + * TProvided // top-down: use provided type + * >; + * ``` + */ +export type ChooseType< + Condition, + Target, + IfTrue, + IfFalse +> = Condition extends Target ? IfTrue : IfFalse; + +// ============================================================================ +// Builder Pattern Utilities +// ============================================================================ + +/** + * If `Existing` is not set (ZodNever), use `New`. Otherwise, keep `Existing`. + * + * This is the core pattern for builder methods that can be called in any order. + * Useful for tracking which value was set first when multiple optional parameters exist. + * + * @example + * ```typescript + * // First call: Existing = ZodNever, so use New + * type AfterFirst = UseIfNotSet; // => NewSchema + * + * // Second call: Existing = NewSchema, so keep it + * type AfterSecond = UseIfNotSet; // => NewSchema + * ``` + */ +export type UseIfNotSet< + Existing extends z.ZodType, + New extends z.ZodType +> = [Existing] extends [z.ZodNever] ? New : Existing; + +/** + * Ensures a Zod schema satisfies the ZodObject constraint. + * If it's ZodNever (not set), converts to an empty ZodObject schema. + * + * Used when building final types where we need to guarantee the constraint is satisfied. + * + * @example + * ```typescript + * type Safe = EnsureRecordSchema; // => z.ZodObject + * type Safe2 = EnsureRecordSchema; // => z.object({ a: z.number() }) + * ``` + */ +export type EnsureRecordSchema = + T extends z.ZodNever + ? z.ZodObject<{}> + : T extends z.ZodObject + ? T + : z.ZodObject<{}>; + +/** + * Validates that a new schema's inferred type extends an existing schema's inferred type. + * Returns an error type if validation fails. + * + * Used for compile-time validation in builder methods where one schema must be a superset of another. + * + * @param AllowOptional - If true, allows TNew to have optional fields where TExisting has required fields + * + * @example + * ```typescript + * // Strict validation (default) + * type Valid = ValidateSchemaExtends< + * z.object({ a: z.number(), b: z.string() }), + * z.object({ a: z.number() }) + * >; // => Valid (superset extends subset) + * + * type Invalid = ValidateSchemaExtends< + * z.object({ a: z.number() }), + * z.object({ a: z.number(), b: z.string() }) + * >; // => Error type + * + * // Allow optional fields + * type ValidOptional = ValidateSchemaExtends< + * z.object({ a: z.number(), b: z.string().optional() }), + * z.object({ a: z.number(), b: z.string() }), + * '❌ Error', + * true + * >; // => Valid (b is optional in TNew but required in TExisting) + * ``` + */ +export type ValidateSchemaExtends< + TNew extends z.ZodType, + TExisting extends z.ZodType, + ErrorMsg extends string = '❌ Schema constraint violation', + AllowOptional extends boolean = false +> = AllowOptional extends true + ? z.infer extends Partial> + ? TNew + : { [K in ErrorMsg]: Partial> } + : z.infer extends z.infer + ? TNew + : { [K in ErrorMsg]: z.infer }; + +/** + * Conditional validation: if `TExisting` is not set, allow `TNew`. + * Otherwise, validate that `TNew` extends `TExisting`. + * + * Useful for builder patterns where validation should only occur if a constraint has been set. + * + * @param AllowOptional - If true, allows TNew to have optional fields where TExisting has required fields + * + * @example + * ```typescript + * // Constraint not set yet - allow any value + * type Allowed1 = ConditionalValidate; // => NewSchema + * + * // Constraint is set - validate compatibility + * type Allowed2 = ConditionalValidate; // => NewSchema if compatible, Error if not + * + * // Allow optional fields + * type Allowed3 = ConditionalValidate< + * z.object({ a: z.number(), b: z.string().optional() }), + * z.object({ a: z.number(), b: z.string() }), + * '❌ Error', + * true + * >; // => Schema (b is optional in TNew but required in TExisting) + * ``` + */ +export type ConditionalValidate< + TNew extends z.ZodType, + TExisting extends z.ZodType, + ErrorMsg extends string = '❌ Schema constraint violation', + AllowOptional extends boolean = false +> = [TExisting] extends [z.ZodNever] + ? TNew + : ValidateSchemaExtends; + + diff --git a/typescript/packages/burr-core/src/types.ts b/typescript/packages/burr-core/src/types.ts new file mode 100644 index 000000000..dc894a351 --- /dev/null +++ b/typescript/packages/burr-core/src/types.ts @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Core type definitions for Burr + +import { z } from 'zod'; + +/** + * Common interface for both Action and StreamingAction. + * Used by Graph/GraphBuilder to accept either type. + */ +export interface ActionLike< + TReadsSchema extends z.ZodObject = z.ZodObject, + TWritesSchema extends z.ZodObject = z.ZodObject, + TInputsSchema extends z.ZodType = z.ZodType, + TResultSchema extends z.ZodObject | z.ZodVoid = z.ZodObject | z.ZodVoid +> { + readonly name: string | undefined; + readonly reads: readonly string[]; + readonly writes: readonly string[]; + readonly inputs: readonly string[]; + readonly schema: { + readonly reads: TReadsSchema; + readonly writes: TWritesSchema; + readonly inputs: TInputsSchema; + readonly result: TResultSchema; + }; + withName(name: string): ActionLike; +} diff --git a/typescript/packages/burr-core/tsconfig.json b/typescript/packages/burr-core/tsconfig.json new file mode 100644 index 000000000..a748cfc28 --- /dev/null +++ b/typescript/packages/burr-core/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["jest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test-d.ts", "src/__tests__/**/*"] +} + diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 000000000..bd1ea00e1 --- /dev/null +++ b/typescript/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2022"], + "types": ["node"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} +