diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7905de2c..d3d5df32 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 00000000..c06b1c01 --- /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 00000000..895f3e43 --- /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 00000000..053c69d3 --- /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 00000000..5c114a3f --- /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 00000000..94e036c2 --- /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 00000000..8fc0c320 --- /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 00000000..d08458c3 --- /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 00000000..db8e63ed --- /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 00000000..d18927de --- /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 00000000..7a077f21 --- /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 00000000..7cac9f10 --- /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 00000000..4525ee97 --- /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 00000000..182fb48f --- /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 00000000..7e36dd5d --- /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__/state.test.ts b/typescript/packages/burr-core/src/__tests__/state.test.ts new file mode 100644 index 00000000..b7016293 --- /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__/type-tests/README.md b/typescript/packages/burr-core/src/__tests__/type-tests/README.md new file mode 100644 index 00000000..b0eb8a32 --- /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 00000000..30bcf34a --- /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 00000000..1355eafa --- /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 00000000..e617668b --- /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 00000000..70d7ec5d --- /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 00000000..248d9844 --- /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 00000000..d4fde4bc --- /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 00000000..ca0c3787 --- /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 00000000..a0f3550f --- /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 00000000..c50d0bdb --- /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 00000000..f474d85d --- /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 00000000..21581578 --- /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 00000000..33b6b863 --- /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 00000000..be6de8b4 --- /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 00000000..99b107a4 --- /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 00000000..b05b335b --- /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 00000000..b6eaeb34 --- /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 00000000..989e6841 --- /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 00000000..10a967c3 --- /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 00000000..d26429f2 --- /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 00000000..4b942821 --- /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 00000000..beee1410 --- /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 00000000..6cfb0eab --- /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 00000000..8cbd94cd --- /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 00000000..4fe091d4 --- /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 00000000..666171e6 --- /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 00000000..5be92222 --- /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 00000000..aa8c98c2 --- /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 00000000..870147d0 --- /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 00000000..6dea7927 --- /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 00000000..75f0d0bf --- /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 00000000..63c2ca0e --- /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 00000000..10cdd2cc --- /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 00000000..efab79e8 --- /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 00000000..49ff5995 --- /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 00000000..c5378d36 --- /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 00000000..7edfa9aa --- /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 00000000..6e41bc65 --- /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 00000000..c5108171 --- /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 00000000..2e3205c3 --- /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 00000000..53b03b9f --- /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 00000000..1f18e997 --- /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 00000000..13d37fa8 --- /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 00000000..af3c90df --- /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 00000000..b4c995ee --- /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 00000000..fd1d8759 --- /dev/null +++ b/typescript/packages/burr-core/src/application-builder.ts @@ -0,0 +1,350 @@ +/** + * 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'; + +/** + * 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; + + 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 + ) { + this._graph = graph; + this._entrypoint = entrypoint; + this._initialState = initialState; + this._appId = appId; + this._partitionKey = partitionKey; + this._initialSequenceId = initialSequenceId; + } + + /** + * 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 + ); + } + + /** + * 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 + ); + } + + /** + * 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 + ); + } + + /** + * 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 + ); + } + + /** + * 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>; + + return new Application( + this._graph! as Graph, + this._entrypoint!, + this._initialState! as StateInstance, + appId, + this._partitionKey, + this._initialSequenceId + ) as Application; + } +} + diff --git a/typescript/packages/burr-core/src/application.ts b/typescript/packages/burr-core/src/application.ts new file mode 100644 index 00000000..87d1718a --- /dev/null +++ b/typescript/packages/burr-core/src/application.ts @@ -0,0 +1,572 @@ +/** + * 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'; + +// ============================================================================ +// 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: Action, z.ZodObject, z.ZodType, z.ZodObject | z.ZodVoid> | 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: Action, z.ZodObject, z.ZodType, z.ZodObject | z.ZodVoid>; + + /** 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; + + /** @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 + ) { + this.graph = graph; + this.entrypoint = entrypoint; + this.appId = appId; + this.partitionKey = partitionKey; + + // 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(): Action | 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: Action, + inputs: Record + ): Promise<{ + action: Action; + result: Record | void; + newState: ApplicationStateInstance; + }> { + // Snapshot committed state + const committedState = this._state; + + // ==================================== + // 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 action.run({ + state: forkedState, + inputs + }); + + // ==================================== + // PHASE 3: GATHER + // ==================================== + // Collect writes, validate against schema + const writesState = action.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: Action + ): 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> { + const inputs = options?.inputs || {}; + + // Increment sequence ID before execution + this.incrementSequenceId(); + + // Get next action + const nextAction = this.getNextAction(); + if (!nextAction) { + return null; // Terminal state + } + + 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'); + + return { + action, + result, + state: this._state + }; + } catch (error) { + if (error instanceof Error) { + const actionName = nextAction.name || 'unknown'; + throw new Error(`Error executing action '${actionName}': ${error.message}`, { cause: error }); + } + throw error; + } + } + + /** + * 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> { + const haltBefore = options?.haltBefore || []; + const haltAfter = options?.haltAfter || []; + const inputs = options?.inputs || {}; + + let lastAction: Action | 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 + const stepResult = await this.step({ inputs }); + + // If terminal (no more actions), return + if (!stepResult) { + // TODO: Add warning if lastAction is null (no actions were executed) + // Python considers this undefined behavior: app starts at terminal state with no actions available. + // Should warn user to fix state machine or halt conditions. + 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> { + const haltBefore = options?.haltBefore || []; + const haltAfter = options?.haltAfter || []; + const inputs = options?.inputs || {}; + + while (true) { + // Check halt_before condition + const nextAction = this.getNextAction(); + if (nextAction && haltBefore.includes(nextAction.name || '')) { + // Halt before executing this action + break; + } + + // Execute a step + const stepResult = await this.step({ inputs }); + + // If terminal (no more actions), stop + if (!stepResult) { + break; + } + + // Yield the step result + yield stepResult; + + // Check halt_after condition + if (haltAfter.includes(stepResult.action.name || '')) { + break; + } + } + } +} + diff --git a/typescript/packages/burr-core/src/graph.ts b/typescript/packages/burr-core/src/graph.ts new file mode 100644 index 00000000..aa6fbefa --- /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 { Action } from './action'; +import { FixEmptySchema, MergeRecordValues } from './type-utils'; + +// ============================================================================ +// 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 Action + ? 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): Action | 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 00000000..89cb790b --- /dev/null +++ b/typescript/packages/burr-core/src/index.ts @@ -0,0 +1,57 @@ +/** + * 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'; + +// Graph +export { Graph, GraphBuilder, type Transition } from './graph'; + +// Application +export { + Application, + type StepResult, + type RunResult, + type ExecutionOptions +} from './application'; +export { ApplicationBuilder } from './application-builder'; + diff --git a/typescript/packages/burr-core/src/lifecycle.ts b/typescript/packages/burr-core/src/lifecycle.ts new file mode 100644 index 00000000..ef97d6ca --- /dev/null +++ b/typescript/packages/burr-core/src/lifecycle.ts @@ -0,0 +1,21 @@ +/** + * 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 + diff --git a/typescript/packages/burr-core/src/persistence.ts b/typescript/packages/burr-core/src/persistence.ts new file mode 100644 index 00000000..2beec896 --- /dev/null +++ b/typescript/packages/burr-core/src/persistence.ts @@ -0,0 +1,21 @@ +/** + * 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 + 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 00000000..a612fc44 --- /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 00000000..31d25872 --- /dev/null +++ b/typescript/packages/burr-core/src/serde.ts @@ -0,0 +1,21 @@ +/** + * 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 + diff --git a/typescript/packages/burr-core/src/state.ts b/typescript/packages/burr-core/src/state.ts new file mode 100644 index 00000000..5e1f9cd5 --- /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/type-utils.ts b/typescript/packages/burr-core/src/type-utils.ts new file mode 100644 index 00000000..fd055977 --- /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 00000000..956e11a6 --- /dev/null +++ b/typescript/packages/burr-core/src/types.ts @@ -0,0 +1,21 @@ +/** + * 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 + diff --git a/typescript/packages/burr-core/tsconfig.json b/typescript/packages/burr-core/tsconfig.json new file mode 100644 index 00000000..8b435e4b --- /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"] +} + diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 00000000..bd1ea00e --- /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"] +} +