feat: rebuild website with full learning funnel
Website rebuilt from scratch based on analysis of 11 beloved language websites (Elm, Zig, Gleam, Swift, Kotlin, Haskell, OCaml, Crystal, Roc, Rust, Go). New website structure: - Homepage with hero, playground, three pillars, install guide - Language Tour with interactive lessons (hello world, types, effects) - Examples cookbook with categorized sidebar - API documentation index - Installation guide (Nix and source) - Sleek/noble design (black/gold, serif typography) Also includes: - New stdlib/json.lux module for JSON serialization - Enhanced stdlib/http.lux with middleware and routing - New string functions (charAt, indexOf, lastIndexOf, repeat) - LSP improvements (rename, signature help, formatting) - Package manager transitive dependency resolution - Updated documentation for effects and stdlib - New showcase example (task_manager.lux) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
400
docs/COMPILER_OPTIMIZATIONS.md
Normal file
400
docs/COMPILER_OPTIMIZATIONS.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Compiler Optimizations from Behavioral Types
|
||||
|
||||
This document describes optimization opportunities enabled by Lux's behavioral type system. When functions are annotated with properties like `is pure`, `is total`, `is idempotent`, `is deterministic`, or `is commutative`, the compiler gains knowledge that enables aggressive optimizations.
|
||||
|
||||
## Overview
|
||||
|
||||
| Property | Key Optimizations |
|
||||
|----------|-------------------|
|
||||
| `is pure` | Memoization, CSE, dead code elimination, auto-parallelization |
|
||||
| `is total` | No exception handling, aggressive inlining, loop unrolling |
|
||||
| `is deterministic` | Result caching, test reproducibility, parallel execution |
|
||||
| `is idempotent` | Duplicate call elimination, retry optimization |
|
||||
| `is commutative` | Argument reordering, parallel reduction, algebraic simplification |
|
||||
|
||||
## Pure Function Optimizations
|
||||
|
||||
When a function is marked `is pure`:
|
||||
|
||||
### 1. Memoization (Automatic Caching)
|
||||
|
||||
```lux
|
||||
fn fib(n: Int): Int is pure =
|
||||
if n <= 1 then n else fib(n - 1) + fib(n - 2)
|
||||
```
|
||||
|
||||
**Optimization**: The compiler can automatically memoize results. Since `fib` is pure, `fib(10)` will always return the same value, so we can cache it.
|
||||
|
||||
**Implementation approach**:
|
||||
- Maintain a hash map of argument → result mappings
|
||||
- Before computing, check if result exists
|
||||
- Store results after computation
|
||||
- Use LRU eviction for memory management
|
||||
|
||||
**Impact**: Reduces exponential recursive calls to linear time.
|
||||
|
||||
### 2. Common Subexpression Elimination (CSE)
|
||||
|
||||
```lux
|
||||
fn compute(x: Int): Int is pure =
|
||||
expensive(x) + expensive(x) // Same call twice
|
||||
```
|
||||
|
||||
**Optimization**: The compiler recognizes both calls are identical and computes `expensive(x)` only once.
|
||||
|
||||
**Transformed to**:
|
||||
```lux
|
||||
fn compute(x: Int): Int is pure =
|
||||
let temp = expensive(x)
|
||||
temp + temp
|
||||
```
|
||||
|
||||
**Impact**: Eliminates redundant computation.
|
||||
|
||||
### 3. Dead Code Elimination
|
||||
|
||||
```lux
|
||||
fn example(): Int is pure = {
|
||||
let unused = expensiveComputation() // Result not used
|
||||
42
|
||||
}
|
||||
```
|
||||
|
||||
**Optimization**: Since `expensiveComputation` is pure (no side effects), and its result is unused, the entire call can be eliminated.
|
||||
|
||||
**Impact**: Removes unnecessary work.
|
||||
|
||||
### 4. Auto-Parallelization
|
||||
|
||||
```lux
|
||||
fn processAll(items: List<Item>): List<Result> is pure =
|
||||
List.map(items, processItem) // processItem is pure
|
||||
```
|
||||
|
||||
**Optimization**: Since `processItem` is pure, each invocation is independent. The compiler can automatically parallelize the map operation.
|
||||
|
||||
**Implementation approach**:
|
||||
- Detect pure functions in map/filter/fold operations
|
||||
- Split work across available cores
|
||||
- Merge results (order-preserving for map)
|
||||
|
||||
**Impact**: Linear speedup with core count for CPU-bound operations.
|
||||
|
||||
### 5. Speculative Execution
|
||||
|
||||
```lux
|
||||
fn decide(cond: Bool, a: Int, b: Int): Int is pure =
|
||||
if cond then computeA(a) else computeB(b)
|
||||
```
|
||||
|
||||
**Optimization**: Both branches can be computed in parallel before the condition is known, since neither has side effects.
|
||||
|
||||
**Impact**: Reduced latency when condition evaluation is slow.
|
||||
|
||||
## Total Function Optimizations
|
||||
|
||||
When a function is marked `is total`:
|
||||
|
||||
### 1. Exception Handling Elimination
|
||||
|
||||
```lux
|
||||
fn safeCompute(x: Int): Int is total =
|
||||
complexCalculation(x)
|
||||
```
|
||||
|
||||
**Optimization**: No try/catch blocks needed around calls to `safeCompute`. The compiler knows it will never throw or fail.
|
||||
|
||||
**Generated code difference**:
|
||||
```c
|
||||
// Without is total - needs error checking
|
||||
Result result = safeCompute(x);
|
||||
if (result.is_error) { handle_error(); }
|
||||
|
||||
// With is total - direct call
|
||||
int result = safeCompute(x);
|
||||
```
|
||||
|
||||
**Impact**: Reduced code size, better branch prediction.
|
||||
|
||||
### 2. Aggressive Inlining
|
||||
|
||||
```lux
|
||||
fn square(x: Int): Int is total = x * x
|
||||
|
||||
fn sumOfSquares(a: Int, b: Int): Int is total =
|
||||
square(a) + square(b)
|
||||
```
|
||||
|
||||
**Optimization**: Total functions are safe to inline aggressively because:
|
||||
- They won't change control flow unexpectedly
|
||||
- They won't introduce exception handling complexity
|
||||
- Their termination is guaranteed
|
||||
|
||||
**Impact**: Eliminates function call overhead, enables further optimizations.
|
||||
|
||||
### 3. Loop Unrolling
|
||||
|
||||
```lux
|
||||
fn sumList(xs: List<Int>): Int is total =
|
||||
List.fold(xs, 0, fn(acc: Int, x: Int): Int is total => acc + x)
|
||||
```
|
||||
|
||||
**Optimization**: When the list size is known at compile time and the fold function is total, the loop can be fully unrolled.
|
||||
|
||||
**Impact**: Eliminates loop overhead, enables vectorization.
|
||||
|
||||
### 4. Termination Assumptions
|
||||
|
||||
```lux
|
||||
fn processRecursive(data: Tree): Result is total =
|
||||
match data {
|
||||
Leaf(v) => Result.single(v),
|
||||
Node(left, right) => {
|
||||
let l = processRecursive(left)
|
||||
let r = processRecursive(right)
|
||||
Result.merge(l, r)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Optimization**: The compiler can assume this recursion terminates, allowing optimizations like:
|
||||
- Converting recursion to iteration
|
||||
- Allocating fixed stack space
|
||||
- Tail call optimization
|
||||
|
||||
**Impact**: Stack safety, predictable memory usage.
|
||||
|
||||
## Deterministic Function Optimizations
|
||||
|
||||
When a function is marked `is deterministic`:
|
||||
|
||||
### 1. Compile-Time Evaluation
|
||||
|
||||
```lux
|
||||
fn hashConstant(s: String): Int is deterministic = computeHash(s)
|
||||
|
||||
let key = hashConstant("api_key") // Constant input
|
||||
```
|
||||
|
||||
**Optimization**: Since the input is a compile-time constant and the function is deterministic, the result can be computed at compile time.
|
||||
|
||||
**Transformed to**:
|
||||
```lux
|
||||
let key = 7823491 // Pre-computed
|
||||
```
|
||||
|
||||
**Impact**: Zero runtime cost for constant computations.
|
||||
|
||||
### 2. Result Caching Across Runs
|
||||
|
||||
```lux
|
||||
fn parseConfig(path: String): Config is deterministic with {File} =
|
||||
Json.parse(File.read(path))
|
||||
```
|
||||
|
||||
**Optimization**: Results can be cached persistently. If the file hasn't changed, the cached result is valid.
|
||||
|
||||
**Implementation approach**:
|
||||
- Hash inputs (including file contents)
|
||||
- Store results in persistent cache
|
||||
- Validate cache on next run
|
||||
|
||||
**Impact**: Faster startup times, reduced I/O.
|
||||
|
||||
### 3. Reproducible Parallel Execution
|
||||
|
||||
```lux
|
||||
fn renderImages(images: List<Image>): List<Bitmap> is deterministic =
|
||||
List.map(images, render)
|
||||
```
|
||||
|
||||
**Optimization**: Deterministic parallel execution guarantees same results regardless of scheduling order. This enables:
|
||||
- Work stealing without synchronization concerns
|
||||
- Speculative execution without rollback complexity
|
||||
- Distributed computation across machines
|
||||
|
||||
**Impact**: Easier parallelization, simpler distributed systems.
|
||||
|
||||
## Idempotent Function Optimizations
|
||||
|
||||
When a function is marked `is idempotent`:
|
||||
|
||||
### 1. Duplicate Call Elimination
|
||||
|
||||
```lux
|
||||
fn setFlag(config: Config, flag: Bool): Config is idempotent =
|
||||
{ ...config, enabled: flag }
|
||||
|
||||
fn configure(c: Config): Config is idempotent =
|
||||
c |> setFlag(true) |> setFlag(true) |> setFlag(true)
|
||||
```
|
||||
|
||||
**Optimization**: Multiple consecutive calls with the same arguments can be collapsed to one.
|
||||
|
||||
**Transformed to**:
|
||||
```lux
|
||||
fn configure(c: Config): Config is idempotent =
|
||||
setFlag(c, true)
|
||||
```
|
||||
|
||||
**Impact**: Eliminates redundant operations.
|
||||
|
||||
### 2. Retry Optimization
|
||||
|
||||
```lux
|
||||
fn sendRequest(data: Request): Response is idempotent with {Http} =
|
||||
Http.put("/api/resource", data)
|
||||
|
||||
fn reliableSend(data: Request): Response with {Http} =
|
||||
retry(3, fn(): Response => sendRequest(data))
|
||||
```
|
||||
|
||||
**Optimization**: The retry mechanism knows the operation is safe to retry without side effects accumulating.
|
||||
|
||||
**Implementation approach**:
|
||||
- No need for transaction logs
|
||||
- No need for "already processed" checks
|
||||
- Simple retry loop
|
||||
|
||||
**Impact**: Simpler error recovery, reduced complexity.
|
||||
|
||||
### 3. Convergent Computation
|
||||
|
||||
```lux
|
||||
fn normalize(value: Float): Float is idempotent =
|
||||
clamp(round(value, 2), 0.0, 1.0)
|
||||
```
|
||||
|
||||
**Optimization**: In iterative algorithms, the compiler can detect when a value has converged (applying the function no longer changes it).
|
||||
|
||||
```lux
|
||||
// Can terminate early when values stop changing
|
||||
fn iterateUntilStable(values: List<Float>): List<Float> =
|
||||
let normalized = List.map(values, normalize)
|
||||
if normalized == values then values
|
||||
else iterateUntilStable(normalized)
|
||||
```
|
||||
|
||||
**Impact**: Early termination of iterative algorithms.
|
||||
|
||||
## Commutative Function Optimizations
|
||||
|
||||
When a function is marked `is commutative`:
|
||||
|
||||
### 1. Argument Reordering
|
||||
|
||||
```lux
|
||||
fn multiply(a: Int, b: Int): Int is commutative = a * b
|
||||
|
||||
// In a computation
|
||||
multiply(expensiveA(), cheapB())
|
||||
```
|
||||
|
||||
**Optimization**: Evaluate the cheaper argument first to enable short-circuit optimizations or better register allocation.
|
||||
|
||||
**Impact**: Improved instruction scheduling.
|
||||
|
||||
### 2. Parallel Reduction
|
||||
|
||||
```lux
|
||||
fn add(a: Int, b: Int): Int is commutative = a + b
|
||||
|
||||
fn sum(xs: List<Int>): Int =
|
||||
List.fold(xs, 0, add)
|
||||
```
|
||||
|
||||
**Optimization**: Since `add` is commutative (and associative), the fold can be parallelized:
|
||||
|
||||
```
|
||||
[1, 2, 3, 4, 5, 6, 7, 8]
|
||||
↓ parallel reduce
|
||||
[(1+2), (3+4), (5+6), (7+8)]
|
||||
↓ parallel reduce
|
||||
[(3+7), (11+15)]
|
||||
↓ parallel reduce
|
||||
[36]
|
||||
```
|
||||
|
||||
**Impact**: O(log n) parallel reduction instead of O(n) sequential.
|
||||
|
||||
### 3. Algebraic Simplification
|
||||
|
||||
```lux
|
||||
fn add(a: Int, b: Int): Int is commutative = a + b
|
||||
|
||||
// Expression: add(x, add(y, z))
|
||||
```
|
||||
|
||||
**Optimization**: Commutative operations can be reordered for simplification:
|
||||
- `add(x, 0)` → `x`
|
||||
- `add(add(x, 1), add(y, 1))` → `add(add(x, y), 2)`
|
||||
|
||||
**Impact**: Constant folding, strength reduction.
|
||||
|
||||
## Combined Property Optimizations
|
||||
|
||||
Properties can be combined for even more powerful optimizations:
|
||||
|
||||
### Pure + Deterministic + Total
|
||||
|
||||
```lux
|
||||
fn computeKey(data: String): Int
|
||||
is pure
|
||||
is deterministic
|
||||
is total = {
|
||||
// Hash computation
|
||||
List.fold(String.chars(data), 0, fn(acc: Int, c: Char): Int =>
|
||||
acc * 31 + Char.code(c))
|
||||
}
|
||||
```
|
||||
|
||||
**Enabled optimizations**:
|
||||
- Compile-time evaluation for constants
|
||||
- Automatic memoization at runtime
|
||||
- Parallel execution in batch operations
|
||||
- No exception handling needed
|
||||
- Safe to inline anywhere
|
||||
|
||||
### Idempotent + Commutative
|
||||
|
||||
```lux
|
||||
fn setUnionItem<T>(set: Set<T>, item: T): Set<T>
|
||||
is idempotent
|
||||
is commutative = {
|
||||
Set.add(set, item)
|
||||
}
|
||||
```
|
||||
|
||||
**Enabled optimizations**:
|
||||
- Parallel set building (order doesn't matter)
|
||||
- Duplicate insertions are free (idempotent)
|
||||
- Reorder insertions for cache locality
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Optimization | Status |
|
||||
|--------------|--------|
|
||||
| Pure: CSE | Planned |
|
||||
| Pure: Dead code elimination | Partial (basic) |
|
||||
| Pure: Auto-parallelization | Planned |
|
||||
| Total: Exception elimination | Planned |
|
||||
| Total: Aggressive inlining | Partial |
|
||||
| Deterministic: Compile-time eval | Planned |
|
||||
| Idempotent: Duplicate elimination | Planned |
|
||||
| Commutative: Parallel reduction | Planned |
|
||||
|
||||
## Adding New Optimizations
|
||||
|
||||
When implementing new optimizations based on behavioral types:
|
||||
|
||||
1. **Verify the property is correct**: The optimization is only valid if the property holds
|
||||
2. **Consider combinations**: Multiple properties together enable more optimizations
|
||||
3. **Measure impact**: Profile before and after to ensure benefit
|
||||
4. **Handle `assume`**: Functions using `assume` bypass verification but still enable optimizations (risk is on the programmer)
|
||||
|
||||
## Future Work
|
||||
|
||||
1. **Inter-procedural analysis**: Track properties across function boundaries
|
||||
2. **Automatic property inference**: Derive properties when not explicitly stated
|
||||
3. **Profile-guided optimization**: Use runtime data to decide when to apply optimizations
|
||||
4. **LLVM integration**: Pass behavioral hints to LLVM for backend optimizations
|
||||
1048
docs/COMPREHENSIVE_ROADMAP.md
Normal file
1048
docs/COMPREHENSIVE_ROADMAP.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@
|
||||
| SQL effect (query, execute) | P1 | 2 weeks | ✅ Complete |
|
||||
| Transaction effect | P2 | 1 week | ✅ Complete |
|
||||
| Connection pooling | P2 | 1 week | ❌ Missing |
|
||||
| PostgreSQL support | P1 | 2 weeks | ✅ Complete |
|
||||
|
||||
### Phase 1.3: Web Server Framework
|
||||
|
||||
@@ -207,8 +208,11 @@
|
||||
|------|----------|--------|--------|
|
||||
| Package manager (lux pkg) | P1 | 3 weeks | ✅ Complete |
|
||||
| Module loader integration | P1 | 1 week | ✅ Complete |
|
||||
| Package registry | P2 | 2 weeks | ✅ Complete (server + CLI commands) |
|
||||
| Dependency resolution | P2 | 2 weeks | ❌ Missing |
|
||||
| Package registry server | P2 | 2 weeks | ✅ Complete |
|
||||
| Registry CLI (search, publish) | P2 | 1 week | ✅ Complete |
|
||||
| Lock file generation | P1 | 1 week | ✅ Complete |
|
||||
| Version constraint parsing | P1 | 1 week | ✅ Complete |
|
||||
| Transitive dependency resolution | P2 | 2 weeks | ⚠️ Basic (direct deps only) |
|
||||
|
||||
**Package Manager Features:**
|
||||
- `lux pkg init` - Initialize project with lux.toml
|
||||
@@ -300,6 +304,8 @@
|
||||
- ✅ Random effect (int, float, range, bool)
|
||||
- ✅ Time effect (now, sleep)
|
||||
- ✅ Test effect (assert, assertEqual, assertTrue, assertFalse)
|
||||
- ✅ SQL effect (SQLite with transactions)
|
||||
- ✅ Postgres effect (PostgreSQL connections)
|
||||
|
||||
**Module System:**
|
||||
- ✅ Imports, exports, aliases
|
||||
@@ -319,7 +325,7 @@
|
||||
- ✅ C backend (functions, closures, pattern matching, lists)
|
||||
- ✅ JS backend (full language support, browser & Node.js)
|
||||
- ✅ REPL with history
|
||||
- ✅ Basic LSP server
|
||||
- ✅ LSP server (diagnostics, hover, completions, go-to-definition, references, symbols)
|
||||
- ✅ Formatter
|
||||
- ✅ Watch mode
|
||||
- ✅ Debugger (basic)
|
||||
|
||||
330
docs/SQL_DESIGN_ANALYSIS.md
Normal file
330
docs/SQL_DESIGN_ANALYSIS.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# SQL in Lux: Built-in Effect vs Package
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document analyzes whether SQL database access should be a built-in language feature (as it currently is) or a separate package. After comparing approaches across 12+ languages, the recommendation is:
|
||||
|
||||
**Keep SQL as a built-in effect, but refactor the implementation to be more modular.**
|
||||
|
||||
## Current Implementation
|
||||
|
||||
Lux currently implements SQL as a built-in effect:
|
||||
|
||||
```lux
|
||||
fn main(): Unit with {Console, Sql} = {
|
||||
let db = Sql.openMemory()
|
||||
Sql.execute(db, "CREATE TABLE users (...)")
|
||||
let users = Sql.query(db, "SELECT * FROM users")
|
||||
Sql.close(db)
|
||||
}
|
||||
```
|
||||
|
||||
The implementation uses rusqlite (SQLite) compiled directly into the Lux binary.
|
||||
|
||||
## How Other Languages Handle Database Access
|
||||
|
||||
### Languages with Built-in Database Support
|
||||
|
||||
| Language | Approach | Notes |
|
||||
|----------|----------|-------|
|
||||
| **Python** | `sqlite3` in stdlib | Most languages have SQLite in stdlib |
|
||||
| **Ruby** | `sqlite3` gem + AR are common | ActiveRecord is de facto standard |
|
||||
| **Go** | `database/sql` interface in stdlib | Drivers are packages |
|
||||
| **Elixir** | Ecto as separate package | But universally used |
|
||||
| **PHP** | PDO in core | Multiple backends |
|
||||
|
||||
### Languages with Package-Only Database Support
|
||||
|
||||
| Language | Approach | Notes |
|
||||
|----------|----------|-------|
|
||||
| **Rust** | rusqlite, diesel, sqlx packages | No stdlib database |
|
||||
| **Node.js** | pg, mysql2, better-sqlite3 | Packages only |
|
||||
| **Haskell** | postgresql-simple, persistent | Packages only |
|
||||
| **OCaml** | caqti, postgresql-ocaml | Packages only |
|
||||
|
||||
### Analysis of Each Approach
|
||||
|
||||
#### Go's Model: Interface in Stdlib + Driver Packages
|
||||
|
||||
```go
|
||||
import (
|
||||
"database/sql"
|
||||
_ "github.com/lib/pq" // PostgreSQL driver
|
||||
)
|
||||
|
||||
db, _ := sql.Open("postgres", "...")
|
||||
rows, _ := db.Query("SELECT * FROM users")
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Standard interface for all databases
|
||||
- Type-safe at compile time
|
||||
- Drivers are swappable
|
||||
|
||||
**Cons:**
|
||||
- Requires understanding interfaces
|
||||
- Need external packages for actual database
|
||||
|
||||
#### Python's Model: SQLite in Stdlib
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('example.db')
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT * FROM users')
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Zero dependencies for getting started
|
||||
- Great for learning/prototyping
|
||||
- Always available
|
||||
|
||||
**Cons:**
|
||||
- Other databases need packages
|
||||
- stdlib vs package API differences
|
||||
|
||||
#### Rust's Model: Everything is Packages
|
||||
|
||||
```rust
|
||||
use rusqlite::{Connection, Result};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let conn = Connection::open("test.db")?;
|
||||
conn.execute("CREATE TABLE users (...)", [])?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Minimal core language
|
||||
- Best-in-class implementations
|
||||
- Clear ownership
|
||||
|
||||
**Cons:**
|
||||
- Cargo.toml management
|
||||
- Version conflicts possible
|
||||
- Learning curve for package ecosystem
|
||||
|
||||
#### Elixir's Model: Strong Package Ecosystem
|
||||
|
||||
```elixir
|
||||
# Ecto is technically a package but universally used
|
||||
Repo.all(from u in User, where: u.age > 18)
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Best API emerges naturally
|
||||
- Core team can focus on language
|
||||
- Community ownership
|
||||
|
||||
**Cons:**
|
||||
- Package can become outdated
|
||||
- Multiple competing solutions
|
||||
|
||||
## Arguments For Built-in SQL
|
||||
|
||||
### 1. Effect System Integration
|
||||
|
||||
The most compelling argument: **SQL fits naturally into Lux's effect system.**
|
||||
|
||||
```lux
|
||||
// The effect signature documents database access
|
||||
fn fetchUser(id: Int): User with {Sql} = { ... }
|
||||
|
||||
// Handlers enable testing without mocks
|
||||
handler testDatabase(): Sql { ... }
|
||||
```
|
||||
|
||||
This is harder to achieve with packages - they'd need to integrate deeply with the effect system.
|
||||
|
||||
### 2. Zero-Dependency Getting Started
|
||||
|
||||
New users can immediately:
|
||||
- Follow tutorials that use databases
|
||||
- Build real applications
|
||||
- Learn effects with practical examples
|
||||
|
||||
```bash
|
||||
lux run database_example.lux
|
||||
# Just works - no package installation
|
||||
```
|
||||
|
||||
### 3. Guaranteed API Stability
|
||||
|
||||
Built-in effects have stable, documented APIs. Package APIs can change between versions.
|
||||
|
||||
### 4. Teaching Functional Effects
|
||||
|
||||
SQL is an excellent teaching example for effects:
|
||||
- Clear side effects (I/O to database)
|
||||
- Handler swapping for testing
|
||||
- Transaction scoping
|
||||
|
||||
### 5. Practical Utility
|
||||
|
||||
90%+ of real applications need database access. Making it trivial benefits most users.
|
||||
|
||||
## Arguments For SQL as Package
|
||||
|
||||
### 1. Smaller Binary Size
|
||||
|
||||
rusqlite adds significant binary size (~2-3MB). Package-based approach lets users opt-in.
|
||||
|
||||
### 2. Database Backend Choice
|
||||
|
||||
Currently locked to SQLite. A package ecosystem could offer:
|
||||
- `lux-sqlite`
|
||||
- `lux-postgres`
|
||||
- `lux-mysql`
|
||||
- `lux-mongodb`
|
||||
|
||||
### 3. Faster Core Language Evolution
|
||||
|
||||
Core team focuses on language; community builds integrations.
|
||||
|
||||
### 4. Better Specialization
|
||||
|
||||
Dedicated package maintainers might build better database tooling than core team.
|
||||
|
||||
### 5. Multiple Competing Implementations
|
||||
|
||||
Competition drives quality. The best SQL package wins adoption.
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Factor | Built-in | Package |
|
||||
|--------|----------|---------|
|
||||
| Effect integration | Excellent | Needs design work |
|
||||
| Learning curve | Low | Medium |
|
||||
| Binary size | Larger | User controls |
|
||||
| Database options | Limited | Unlimited |
|
||||
| API stability | Guaranteed | Version-dependent |
|
||||
| Getting started | Instant | Requires install |
|
||||
| Testing story | Built-in handlers | Package-specific |
|
||||
| Maintenance burden | Core team | Community |
|
||||
|
||||
## Recommendation
|
||||
|
||||
### Keep SQL as Built-in Effect, With Changes
|
||||
|
||||
**Rationale:**
|
||||
|
||||
1. **Effect system is Lux's differentiator** - SQL showcases it perfectly
|
||||
2. **Practicality matters** - 90% of apps need databases
|
||||
3. **Teaching value** - SQL is ideal for learning effects
|
||||
4. **Handler testing** - Built-in integration enables powerful testing
|
||||
|
||||
### Proposed Architecture
|
||||
|
||||
```
|
||||
Core Lux
|
||||
├── Sql effect (interface only)
|
||||
│ ├── open/close
|
||||
│ ├── execute/query
|
||||
│ └── transaction operations
|
||||
│
|
||||
└── Default SQLite handler (built-in)
|
||||
└── Uses rusqlite
|
||||
|
||||
Future packages (optional)
|
||||
├── lux-postgres -- PostgreSQL handler
|
||||
├── lux-mysql -- MySQL handler
|
||||
└── lux-redis -- Redis (key-value, not Sql)
|
||||
```
|
||||
|
||||
### Specific Changes to Consider
|
||||
|
||||
1. **Make SQLite compilation optional**
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[features]
|
||||
default = ["sqlite"]
|
||||
sqlite = ["rusqlite"]
|
||||
```
|
||||
|
||||
2. **Define stable Sql effect interface**
|
||||
```lux
|
||||
effect Sql {
|
||||
fn open(path: String): SqlConn
|
||||
fn close(conn: SqlConn): Unit
|
||||
fn execute(conn: SqlConn, sql: String): Int
|
||||
fn query(conn: SqlConn, sql: String): List<SqlRow>
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
3. **Allow package handlers to implement Sql**
|
||||
```lux
|
||||
// In lux-postgres package
|
||||
handler postgresHandler(connStr: String): Sql { ... }
|
||||
|
||||
// Usage
|
||||
run myApp() with {
|
||||
Sql -> postgresHandler("postgres://...")
|
||||
}
|
||||
```
|
||||
|
||||
4. **Add connection pooling to core**
|
||||
Important for production, should be standard.
|
||||
|
||||
## Comparison to Similar Decisions
|
||||
|
||||
### Console Effect
|
||||
|
||||
Console is built-in. Nobody questions this because:
|
||||
- Universally needed
|
||||
- Simple interface
|
||||
- Hard to get wrong
|
||||
|
||||
SQL is similar but more complex.
|
||||
|
||||
### HTTP Effect
|
||||
|
||||
HTTP client is built-in in Lux. This was the right call because:
|
||||
- Most apps need HTTP
|
||||
- Complex to implement well
|
||||
- Effect system integration important
|
||||
|
||||
SQL follows same reasoning.
|
||||
|
||||
### File Effect
|
||||
|
||||
File I/O is built-in. Same rationale applies.
|
||||
|
||||
## What Other Effect-System Languages Do
|
||||
|
||||
| Language | Database | Built-in? |
|
||||
|----------|----------|-----------|
|
||||
| **Koka** | No database support | N/A |
|
||||
| **Eff** | No database support | N/A |
|
||||
| **Frank** | No database support | N/A |
|
||||
| **Unison** | Abilities + packages | Both |
|
||||
|
||||
Lux is pioneering practical effects. Built-in SQL makes sense.
|
||||
|
||||
## Conclusion
|
||||
|
||||
SQL should remain a built-in effect in Lux because:
|
||||
|
||||
1. It demonstrates the power of effects for real-world use
|
||||
2. It enables the handler-based testing story
|
||||
3. It removes friction for most applications
|
||||
4. It serves as a teaching example for effects
|
||||
|
||||
However, the implementation should evolve to:
|
||||
- Support multiple database backends via handlers
|
||||
- Make SQLite optional for minimal binaries
|
||||
- Provide connection pooling
|
||||
- Add parameterized query support
|
||||
|
||||
This hybrid approach gives users the best of both worlds: immediate productivity with built-in SQLite, and flexibility through package-provided handlers for other databases.
|
||||
|
||||
---
|
||||
|
||||
## Future Work
|
||||
|
||||
1. **Parameterized queries** - Critical for SQL injection prevention
|
||||
2. **Connection pooling** - Required for production servers
|
||||
3. **PostgreSQL handler** - Most requested database
|
||||
4. **Migration support** - Schema evolution tooling
|
||||
5. **Type-safe queries** - Compile-time SQL checking (ambitious)
|
||||
1322
docs/WEBSITE_PLAN.md
1322
docs/WEBSITE_PLAN.md
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,10 @@ Lux provides several built-in effects:
|
||||
| `Process` | `exec`, `env`, `args`, `cwd`, `exit` | System processes |
|
||||
| `Http` | `get`, `post`, `put`, `delete` | HTTP client |
|
||||
| `HttpServer` | `listen`, `accept`, `respond`, `stop` | HTTP server |
|
||||
| `Sql` | `open`, `openMemory`, `close`, `execute`, `query`, `queryOne`, `beginTx`, `commit`, `rollback` | SQLite database |
|
||||
| `Postgres` | `connect`, `close`, `execute`, `query`, `queryOne` | PostgreSQL database |
|
||||
| `Concurrent` | `spawn`, `await`, `yield`, `sleep`, `cancel`, `isRunning`, `taskCount` | Concurrent tasks |
|
||||
| `Channel` | `create`, `send`, `receive`, `tryReceive`, `close` | Inter-task communication |
|
||||
| `Test` | `assert`, `assertEqual`, `assertTrue`, `assertFalse` | Testing |
|
||||
|
||||
Example usage:
|
||||
|
||||
@@ -320,6 +320,114 @@ fn example(): Int with {Fail} = {
|
||||
}
|
||||
```
|
||||
|
||||
### Sql (SQLite)
|
||||
|
||||
```lux
|
||||
fn example(): Unit with {Sql, Console} = {
|
||||
let conn = Sql.open("mydb.sqlite") // Open database file
|
||||
// Or: let conn = Sql.openMemory() // In-memory database
|
||||
|
||||
// Execute statements (returns row count)
|
||||
Sql.execute(conn, "CREATE TABLE users (id INTEGER, name TEXT)")
|
||||
Sql.execute(conn, "INSERT INTO users VALUES (1, 'Alice')")
|
||||
|
||||
// Query returns list of rows
|
||||
let rows = Sql.query(conn, "SELECT * FROM users")
|
||||
|
||||
// Query for single row
|
||||
let user = Sql.queryOne(conn, "SELECT * FROM users WHERE id = 1")
|
||||
|
||||
// Transactions
|
||||
Sql.beginTx(conn)
|
||||
Sql.execute(conn, "UPDATE users SET name = 'Bob' WHERE id = 1")
|
||||
Sql.commit(conn) // Or: Sql.rollback(conn)
|
||||
|
||||
Sql.close(conn)
|
||||
}
|
||||
```
|
||||
|
||||
### Postgres (PostgreSQL)
|
||||
|
||||
```lux
|
||||
fn example(): Unit with {Postgres, Console} = {
|
||||
let conn = Postgres.connect("postgres://user:pass@localhost/mydb")
|
||||
|
||||
// Execute statements
|
||||
Postgres.execute(conn, "INSERT INTO users (name) VALUES ('Alice')")
|
||||
|
||||
// Query returns list of rows
|
||||
let rows = Postgres.query(conn, "SELECT * FROM users")
|
||||
|
||||
// Query for single row
|
||||
let user = Postgres.queryOne(conn, "SELECT * FROM users WHERE id = 1")
|
||||
|
||||
Postgres.close(conn)
|
||||
}
|
||||
```
|
||||
|
||||
### Concurrent (Parallel Tasks)
|
||||
|
||||
```lux
|
||||
fn example(): Unit with {Concurrent, Console} = {
|
||||
// Spawn concurrent tasks
|
||||
let task1 = Concurrent.spawn(fn(): Int => expensiveComputation(1))
|
||||
let task2 = Concurrent.spawn(fn(): Int => expensiveComputation(2))
|
||||
|
||||
// Do other work while tasks run
|
||||
Console.print("Tasks spawned, doing other work...")
|
||||
|
||||
// Wait for tasks to complete
|
||||
let result1 = Concurrent.await(task1)
|
||||
let result2 = Concurrent.await(task2)
|
||||
|
||||
Console.print("Results: " + toString(result1) + ", " + toString(result2))
|
||||
|
||||
// Check task status
|
||||
if Concurrent.isRunning(task1) then
|
||||
Concurrent.cancel(task1)
|
||||
|
||||
// Non-blocking sleep
|
||||
Concurrent.sleep(100) // 100ms
|
||||
|
||||
// Yield to allow other tasks to run
|
||||
Concurrent.yield()
|
||||
|
||||
// Get active task count
|
||||
let count = Concurrent.taskCount()
|
||||
}
|
||||
```
|
||||
|
||||
### Channel (Inter-Task Communication)
|
||||
|
||||
```lux
|
||||
fn example(): Unit with {Concurrent, Channel, Console} = {
|
||||
// Create a channel for communication
|
||||
let ch = Channel.create()
|
||||
|
||||
// Spawn producer task
|
||||
let producer = Concurrent.spawn(fn(): Unit => {
|
||||
Channel.send(ch, 1)
|
||||
Channel.send(ch, 2)
|
||||
Channel.send(ch, 3)
|
||||
Channel.close(ch)
|
||||
})
|
||||
|
||||
// Consumer receives values
|
||||
match Channel.receive(ch) {
|
||||
Some(value) => Console.print("Received: " + toString(value)),
|
||||
None => Console.print("Channel closed")
|
||||
}
|
||||
|
||||
// Non-blocking receive
|
||||
match Channel.tryReceive(ch) {
|
||||
Some(value) => Console.print("Got: " + toString(value)),
|
||||
None => Console.print("No value available")
|
||||
}
|
||||
|
||||
Concurrent.await(producer)
|
||||
}
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
Native testing framework:
|
||||
@@ -360,6 +468,10 @@ fn main(): Unit with {Console} = {
|
||||
| Random | int, float, bool |
|
||||
| State | get, put |
|
||||
| Fail | fail |
|
||||
| Sql | open, openMemory, close, execute, query, queryOne, beginTx, commit, rollback |
|
||||
| Postgres | connect, close, execute, query, queryOne |
|
||||
| Concurrent | spawn, await, yield, sleep, cancel, isRunning, taskCount |
|
||||
| Channel | create, send, receive, tryReceive, close |
|
||||
| Test | assert, assertEqual, assertTrue, assertFalse |
|
||||
|
||||
## Next
|
||||
|
||||
449
docs/guide/12-behavioral-types.md
Normal file
449
docs/guide/12-behavioral-types.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Chapter 12: Behavioral Types
|
||||
|
||||
Lux's behavioral types let you make **compile-time guarantees** about function behavior. Unlike comments or documentation, these are actually verified by the compiler.
|
||||
|
||||
## Why Behavioral Types Matter
|
||||
|
||||
Consider these real-world scenarios:
|
||||
|
||||
1. **Payment processing**: You retry a failed charge. If the function isn't idempotent, you might charge the customer twice.
|
||||
|
||||
2. **Caching**: You cache a computation. If the function isn't deterministic, you'll serve stale/wrong results.
|
||||
|
||||
3. **Parallelization**: You run tasks in parallel. If they aren't pure, you'll have race conditions.
|
||||
|
||||
4. **Infinite loops**: A function never returns. If it was supposed to be total, you have a bug.
|
||||
|
||||
**Behavioral types catch these bugs at compile time.**
|
||||
|
||||
## The Five Properties
|
||||
|
||||
### 1. Pure (`is pure`)
|
||||
|
||||
A pure function has **no side effects**. It only depends on its inputs.
|
||||
|
||||
```lux
|
||||
// GOOD: No effects, just computation
|
||||
fn add(a: Int, b: Int): Int is pure = a + b
|
||||
|
||||
fn double(x: Int): Int is pure = x * 2
|
||||
|
||||
fn greet(name: String): String is pure = "Hello, " + name
|
||||
|
||||
// ERROR: Pure function cannot have effects
|
||||
fn impure(x: Int): Int is pure with {Console} =
|
||||
Console.print("x = " + toString(x)) // Compiler error!
|
||||
x
|
||||
```
|
||||
|
||||
**What the compiler checks:**
|
||||
- Function must have an empty effect set
|
||||
- No calls to effectful operations
|
||||
|
||||
**When to use `is pure`:**
|
||||
- Mathematical functions
|
||||
- Data transformations
|
||||
- Any function that should be cacheable
|
||||
|
||||
**Compiler optimizations enabled:**
|
||||
- Memoization (cache results)
|
||||
- Common subexpression elimination
|
||||
- Parallel execution
|
||||
- Dead code elimination (if result unused)
|
||||
|
||||
### 2. Total (`is total`)
|
||||
|
||||
A total function **always terminates** and **never fails**. It produces a value for every valid input.
|
||||
|
||||
```lux
|
||||
// GOOD: Always terminates (structural recursion)
|
||||
fn factorial(n: Int): Int is total =
|
||||
if n <= 1 then 1 else n * factorial(n - 1)
|
||||
|
||||
// GOOD: Non-recursive is always total
|
||||
fn max(a: Int, b: Int): Int is total =
|
||||
if a > b then a else b
|
||||
|
||||
// GOOD: List operations that terminate
|
||||
fn length<T>(list: List<T>): Int is total =
|
||||
match list {
|
||||
[] => 0,
|
||||
[_, ...rest] => 1 + length(rest) // Structurally decreasing
|
||||
}
|
||||
|
||||
// ERROR: Uses Fail effect
|
||||
fn divide(a: Int, b: Int): Int is total with {Fail} =
|
||||
if b == 0 then Fail.fail("division by zero") // Compiler error!
|
||||
else a / b
|
||||
|
||||
// ERROR: May not terminate (not structurally decreasing)
|
||||
fn collatz(n: Int): Int is total =
|
||||
if n == 1 then 1
|
||||
else if n % 2 == 0 then collatz(n / 2)
|
||||
else collatz(3 * n + 1) // Not structurally smaller!
|
||||
```
|
||||
|
||||
**What the compiler checks:**
|
||||
- No `Fail` effect used
|
||||
- Recursive calls must have at least one structurally decreasing argument
|
||||
|
||||
**When to use `is total`:**
|
||||
- Core business logic that must never crash
|
||||
- Mathematical functions
|
||||
- Data structure operations
|
||||
|
||||
**Compiler optimizations enabled:**
|
||||
- No exception handling overhead
|
||||
- Aggressive inlining
|
||||
- Removal of termination checks
|
||||
|
||||
### 3. Deterministic (`is deterministic`)
|
||||
|
||||
A deterministic function produces the **same output for the same input**, every time.
|
||||
|
||||
```lux
|
||||
// GOOD: Same input = same output
|
||||
fn hash(s: String): Int is deterministic =
|
||||
List.fold(String.chars(s), 0, fn(acc: Int, c: String): Int => acc * 31 + charCode(c))
|
||||
|
||||
fn formatDate(year: Int, month: Int, day: Int): String is deterministic =
|
||||
toString(year) + "-" + padZero(month) + "-" + padZero(day)
|
||||
|
||||
// ERROR: Random is non-deterministic
|
||||
fn generateId(): String is deterministic with {Random} =
|
||||
"id-" + toString(Random.int(0, 1000000)) // Compiler error!
|
||||
|
||||
// ERROR: Time is non-deterministic
|
||||
fn timestamp(): Int is deterministic with {Time} =
|
||||
Time.now() // Compiler error!
|
||||
```
|
||||
|
||||
**What the compiler checks:**
|
||||
- No `Random` effect
|
||||
- No `Time` effect
|
||||
|
||||
**When to use `is deterministic`:**
|
||||
- Hashing functions
|
||||
- Serialization/formatting
|
||||
- Test helpers
|
||||
|
||||
**Compiler optimizations enabled:**
|
||||
- Result caching
|
||||
- Parallel execution with consistent results
|
||||
- Test reproducibility
|
||||
|
||||
### 4. Idempotent (`is idempotent`)
|
||||
|
||||
An idempotent function satisfies: `f(f(x)) == f(x)`. Applying it multiple times has the same effect as applying it once.
|
||||
|
||||
```lux
|
||||
// GOOD: Pattern 1 - Constants
|
||||
fn alwaysZero(x: Int): Int is idempotent = 0
|
||||
|
||||
// GOOD: Pattern 2 - Identity
|
||||
fn identity<T>(x: T): T is idempotent = x
|
||||
|
||||
// GOOD: Pattern 3 - Projection
|
||||
fn getName(person: Person): String is idempotent = person.name
|
||||
|
||||
// GOOD: Pattern 4 - Clamping
|
||||
fn clampPositive(x: Int): Int is idempotent =
|
||||
if x < 0 then 0 else x
|
||||
|
||||
// GOOD: Pattern 5 - Absolute value
|
||||
fn abs(x: Int): Int is idempotent =
|
||||
if x < 0 then 0 - x else x
|
||||
|
||||
// ERROR: Not idempotent (increment changes value each time)
|
||||
fn increment(x: Int): Int is idempotent = x + 1 // f(f(1)) = 3, not 2
|
||||
|
||||
// If you're certain a function is idempotent but the compiler can't verify:
|
||||
fn normalize(s: String): String assume is idempotent =
|
||||
String.toLower(String.trim(s))
|
||||
```
|
||||
|
||||
**What the compiler checks:**
|
||||
- Pattern recognition: constants, identity, projections, clamping, abs
|
||||
|
||||
**When to use `is idempotent`:**
|
||||
- Setting configuration
|
||||
- Database upserts
|
||||
- API PUT/DELETE operations (REST semantics)
|
||||
- Retry-safe operations
|
||||
|
||||
**Real-world example - safe retries:**
|
||||
|
||||
```lux
|
||||
// Payment processing with safe retries
|
||||
fn chargeCard(amount: Int, cardId: String): Receipt
|
||||
is idempotent
|
||||
with {Payment, Logger} = {
|
||||
Logger.log("Charging card " + cardId)
|
||||
Payment.charge(amount, cardId)
|
||||
}
|
||||
|
||||
// Safe to retry because chargeCard is idempotent
|
||||
fn processWithRetry(amount: Int, cardId: String): Receipt with {Payment, Logger, Fail} = {
|
||||
let result = retry(3, fn(): Receipt => chargeCard(amount, cardId))
|
||||
match result {
|
||||
Ok(receipt) => receipt,
|
||||
Err(e) => Fail.fail("Payment failed after 3 attempts: " + e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Commutative (`is commutative`)
|
||||
|
||||
A commutative function satisfies: `f(a, b) == f(b, a)`. The order of arguments doesn't matter.
|
||||
|
||||
```lux
|
||||
// GOOD: Addition is commutative
|
||||
fn add(a: Int, b: Int): Int is commutative = a + b
|
||||
|
||||
// GOOD: Multiplication is commutative
|
||||
fn multiply(a: Int, b: Int): Int is commutative = a * b
|
||||
|
||||
// GOOD: Min/max are commutative
|
||||
fn minimum(a: Int, b: Int): Int is commutative =
|
||||
if a < b then a else b
|
||||
|
||||
// ERROR: Subtraction is not commutative (3 - 2 != 2 - 3)
|
||||
fn subtract(a: Int, b: Int): Int is commutative = a - b // Compiler error!
|
||||
|
||||
// ERROR: Wrong number of parameters
|
||||
fn triple(a: Int, b: Int, c: Int): Int is commutative = a + b + c // Must have exactly 2
|
||||
```
|
||||
|
||||
**What the compiler checks:**
|
||||
- Must have exactly 2 parameters
|
||||
- Body must be a commutative operation (+, *, min, max, ==, !=, &&, ||)
|
||||
|
||||
**When to use `is commutative`:**
|
||||
- Mathematical operations
|
||||
- Set operations (union, intersection)
|
||||
- Merging/combining functions
|
||||
|
||||
**Compiler optimizations enabled:**
|
||||
- Argument reordering for efficiency
|
||||
- Parallel reduction
|
||||
- Algebraic simplifications
|
||||
|
||||
## Combining Properties
|
||||
|
||||
Properties can be combined for stronger guarantees:
|
||||
|
||||
```lux
|
||||
// Pure + deterministic + total = perfect for caching
|
||||
fn computeHash(data: String): Int
|
||||
is pure
|
||||
is deterministic
|
||||
is total = {
|
||||
List.fold(String.chars(data), 0, fn(acc: Int, c: String): Int =>
|
||||
acc * 31 + charCode(c)
|
||||
)
|
||||
}
|
||||
|
||||
// Pure + idempotent = safe transformation
|
||||
fn normalizeEmail(email: String): String
|
||||
is pure
|
||||
is idempotent = {
|
||||
String.toLower(String.trim(email))
|
||||
}
|
||||
|
||||
// Commutative + pure = parallel reduction friendly
|
||||
fn merge(a: Record, b: Record): Record
|
||||
is pure
|
||||
is commutative = {
|
||||
{ ...a, ...b } // Last wins, but both contribute
|
||||
}
|
||||
```
|
||||
|
||||
## Property Constraints in Where Clauses
|
||||
|
||||
You can require function arguments to have certain properties:
|
||||
|
||||
```lux
|
||||
// Higher-order function that requires a pure function
|
||||
fn map<T, U>(list: List<T>, f: fn(T): U is pure): List<U> is pure =
|
||||
match list {
|
||||
[] => [],
|
||||
[x, ...rest] => [f(x), ...map(rest, f)]
|
||||
}
|
||||
|
||||
// Only accepts idempotent functions - safe to retry
|
||||
fn retry<T>(times: Int, action: fn(): T is idempotent): Result<T, String> = {
|
||||
if times <= 0 then Err("No attempts left")
|
||||
else {
|
||||
match tryCall(action) {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => retry(times - 1, action) // Safe because action is idempotent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only accepts deterministic functions - safe to cache
|
||||
fn memoize<K, V>(f: fn(K): V is deterministic): fn(K): V = {
|
||||
let cache = HashMap.new()
|
||||
fn(key: K): V => {
|
||||
match cache.get(key) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let v = f(key)
|
||||
cache.set(key, v)
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
let cachedHash = memoize(computeHash) // OK: computeHash is deterministic
|
||||
let badCache = memoize(generateRandom) // ERROR: generateRandom is not deterministic
|
||||
```
|
||||
|
||||
## The `assume` Escape Hatch
|
||||
|
||||
Sometimes you know a function has a property but the compiler can't verify it. Use `assume`:
|
||||
|
||||
```lux
|
||||
// Compiler can't verify this is idempotent, but we know it is
|
||||
fn setUserStatus(userId: String, status: String): Unit
|
||||
assume is idempotent
|
||||
with {Database} = {
|
||||
Database.execute("UPDATE users SET status = ? WHERE id = ?", [status, userId])
|
||||
}
|
||||
|
||||
// Use assume sparingly - it bypasses compiler checks!
|
||||
// If you're wrong, you may have subtle bugs.
|
||||
```
|
||||
|
||||
**Warning**: `assume` tells the compiler to trust you. If you're wrong, the optimization or guarantee may be invalid.
|
||||
|
||||
## Compiler Optimizations
|
||||
|
||||
When the compiler knows behavioral properties, it can optimize aggressively:
|
||||
|
||||
| Property | Optimizations |
|
||||
|----------|---------------|
|
||||
| `is pure` | Memoization, CSE, dead code elimination, parallelization |
|
||||
| `is total` | No exception handling, aggressive inlining |
|
||||
| `is deterministic` | Result caching, parallel execution |
|
||||
| `is idempotent` | Retry optimization, duplicate call elimination |
|
||||
| `is commutative` | Argument reordering, parallel reduction |
|
||||
|
||||
### Example: Automatic Memoization
|
||||
|
||||
```lux
|
||||
fn expensiveComputation(n: Int): Int
|
||||
is pure
|
||||
is deterministic
|
||||
is total = {
|
||||
// Complex calculation...
|
||||
fib(n)
|
||||
}
|
||||
|
||||
// The compiler may automatically cache results because:
|
||||
// - pure: no side effects, so caching is safe
|
||||
// - deterministic: same input = same output
|
||||
// - total: will always return a value
|
||||
```
|
||||
|
||||
### Example: Safe Parallelization
|
||||
|
||||
```lux
|
||||
fn processItems(items: List<Item>): List<Result>
|
||||
is pure = {
|
||||
List.map(items, processItem)
|
||||
}
|
||||
|
||||
// If processItem is pure, the compiler can parallelize this automatically
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: Financial Calculations
|
||||
|
||||
```lux
|
||||
// Interest calculation - pure, deterministic, total
|
||||
fn calculateInterest(principal: Int, rate: Float, years: Int): Float
|
||||
is pure
|
||||
is deterministic
|
||||
is total = {
|
||||
let r = rate / 100.0
|
||||
Float.fromInt(principal) * Math.pow(1.0 + r, Float.fromInt(years))
|
||||
}
|
||||
|
||||
// Transaction validation - pure, total
|
||||
fn validateTransaction(tx: Transaction): Result<Transaction, String>
|
||||
is pure
|
||||
is total = {
|
||||
if tx.amount <= 0 then Err("Amount must be positive")
|
||||
else if tx.from == tx.to then Err("Cannot transfer to self")
|
||||
else Ok(tx)
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Data Processing Pipeline
|
||||
|
||||
```lux
|
||||
// Each step is pure and deterministic
|
||||
fn cleanData(raw: String): String is pure is deterministic =
|
||||
raw |> String.trim |> String.toLower
|
||||
|
||||
fn parseRecord(line: String): Result<Record, String> is pure is deterministic =
|
||||
match String.split(line, ",") {
|
||||
[name, age, email] => Ok({ name, age: parseInt(age), email }),
|
||||
_ => Err("Invalid format")
|
||||
}
|
||||
|
||||
fn validateRecord(record: Record): Bool is pure is deterministic is total =
|
||||
String.length(record.name) > 0 && record.age > 0
|
||||
|
||||
// Pipeline can be parallelized because all functions are pure + deterministic
|
||||
fn processFile(contents: String): List<Record> is pure is deterministic = {
|
||||
contents
|
||||
|> String.lines
|
||||
|> List.map(cleanData)
|
||||
|> List.map(parseRecord)
|
||||
|> List.filterMap(fn(r: Result<Record, String>): Option<Record> =>
|
||||
match r { Ok(v) => Some(v), Err(_) => None })
|
||||
|> List.filter(validateRecord)
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Idempotent API Handlers
|
||||
|
||||
```lux
|
||||
// PUT /users/:id - idempotent by REST semantics
|
||||
fn handlePutUser(id: String, data: UserData): Response
|
||||
is idempotent
|
||||
with {Database, Logger} = {
|
||||
Logger.log("PUT /users/" + id)
|
||||
Database.upsert("users", id, data)
|
||||
Response.ok({ id, ...data })
|
||||
}
|
||||
|
||||
// DELETE /users/:id - idempotent by REST semantics
|
||||
fn handleDeleteUser(id: String): Response
|
||||
is idempotent
|
||||
with {Database, Logger} = {
|
||||
Logger.log("DELETE /users/" + id)
|
||||
Database.delete("users", id) // Safe to call multiple times
|
||||
Response.noContent()
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Property | Meaning | Compiler Checks | Use Case |
|
||||
|----------|---------|-----------------|----------|
|
||||
| `is pure` | No effects | Empty effect set | Caching, parallelization |
|
||||
| `is total` | Always terminates | No Fail, structural recursion | Core logic |
|
||||
| `is deterministic` | Same in = same out | No Random/Time | Caching, testing |
|
||||
| `is idempotent` | f(f(x)) = f(x) | Pattern recognition | Retries, APIs |
|
||||
| `is commutative` | f(a,b) = f(b,a) | 2 params, commutative op | Math, merging |
|
||||
|
||||
## What's Next?
|
||||
|
||||
- [Chapter 13: Schema Evolution](./13-schema-evolution.md) - Version your data types
|
||||
- [Tutorials](../tutorials/README.md) - Practical projects
|
||||
573
docs/guide/13-schema-evolution.md
Normal file
573
docs/guide/13-schema-evolution.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# Chapter 13: Schema Evolution
|
||||
|
||||
Data structures change over time. Fields get added, removed, or renamed. Types get split or merged. Without careful handling, these changes break systems—old data can't be read, services fail, migrations corrupt data.
|
||||
|
||||
Lux's **schema evolution** system makes these changes safe and automatic.
|
||||
|
||||
## The Problem
|
||||
|
||||
Consider a real scenario:
|
||||
|
||||
```lux
|
||||
// Version 1: Simple user
|
||||
type User {
|
||||
name: String
|
||||
}
|
||||
|
||||
// Later, you need email addresses
|
||||
type User {
|
||||
name: String,
|
||||
email: String // Breaking change! Old data doesn't have this.
|
||||
}
|
||||
```
|
||||
|
||||
In most languages, this breaks everything. Existing users in your database don't have email addresses. Deserializing old data fails. Services crash.
|
||||
|
||||
Lux solves this with **versioned types** and **automatic migrations**.
|
||||
|
||||
## Versioned Types
|
||||
|
||||
Add a version annotation to any type:
|
||||
|
||||
```lux
|
||||
// Version 1: Original definition
|
||||
type User @v1 {
|
||||
name: String
|
||||
}
|
||||
|
||||
// Version 2: Added email field
|
||||
type User @v2 {
|
||||
name: String,
|
||||
email: String,
|
||||
|
||||
// How to migrate from v1
|
||||
from @v1 = { name: old.name, email: "unknown@example.com" }
|
||||
}
|
||||
|
||||
// Version 3: Split name into first/last
|
||||
type User @v3 {
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
email: String,
|
||||
|
||||
// How to migrate from v2
|
||||
from @v2 = {
|
||||
firstName: String.split(old.name, " ") |> List.head |> Option.getOrElse(""),
|
||||
lastName: String.split(old.name, " ") |> List.tail |> List.head |> Option.getOrElse(""),
|
||||
email: old.email
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `@latest` alias always refers to the most recent version:
|
||||
|
||||
```lux
|
||||
type User @latest {
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
email: String,
|
||||
|
||||
from @v2 = { ... }
|
||||
}
|
||||
|
||||
// These are equivalent:
|
||||
fn createUser(first: String, last: String, email: String): User@latest = ...
|
||||
fn createUser(first: String, last: String, email: String): User@v3 = ...
|
||||
```
|
||||
|
||||
## Migration Syntax
|
||||
|
||||
### Basic Migration
|
||||
|
||||
```lux
|
||||
type Config @v2 {
|
||||
theme: String,
|
||||
fontSize: Int,
|
||||
|
||||
// 'old' refers to the v1 value
|
||||
from @v1 = {
|
||||
theme: old.theme,
|
||||
fontSize: 14 // New field with default
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Computed Fields
|
||||
|
||||
```lux
|
||||
type Order @v2 {
|
||||
items: List<Item>,
|
||||
total: Int,
|
||||
itemCount: Int, // New computed field
|
||||
|
||||
from @v1 = {
|
||||
items: old.items,
|
||||
total: old.total,
|
||||
itemCount: List.length(old.items)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Removing Fields
|
||||
|
||||
When removing fields, simply don't include them in the new version:
|
||||
|
||||
```lux
|
||||
type Settings @v1 {
|
||||
theme: String,
|
||||
legacyMode: Bool, // To be removed
|
||||
volume: Int
|
||||
}
|
||||
|
||||
type Settings @v2 {
|
||||
theme: String,
|
||||
volume: Int,
|
||||
|
||||
// legacyMode is dropped - just don't migrate it
|
||||
from @v1 = {
|
||||
theme: old.theme,
|
||||
volume: old.volume
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Renaming Fields
|
||||
|
||||
```lux
|
||||
type Product @v1 {
|
||||
name: String,
|
||||
cost: Int // Old field name
|
||||
}
|
||||
|
||||
type Product @v2 {
|
||||
name: String,
|
||||
price: Int, // Renamed from 'cost'
|
||||
|
||||
from @v1 = {
|
||||
name: old.name,
|
||||
price: old.cost // Map old field to new name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Transformations
|
||||
|
||||
```lux
|
||||
type Address @v1 {
|
||||
fullAddress: String // "123 Main St, New York, NY 10001"
|
||||
}
|
||||
|
||||
type Address @v2 {
|
||||
street: String,
|
||||
city: String,
|
||||
state: String,
|
||||
zip: String,
|
||||
|
||||
from @v1 = {
|
||||
let parts = String.split(old.fullAddress, ", ")
|
||||
{
|
||||
street: List.get(parts, 0) |> Option.getOrElse(""),
|
||||
city: List.get(parts, 1) |> Option.getOrElse(""),
|
||||
state: List.get(parts, 2)
|
||||
|> Option.map(fn(s: String): String => String.split(s, " ") |> List.head |> Option.getOrElse(""))
|
||||
|> Option.getOrElse(""),
|
||||
zip: List.get(parts, 2)
|
||||
|> Option.map(fn(s: String): String => String.split(s, " ") |> List.last |> Option.getOrElse(""))
|
||||
|> Option.getOrElse("")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Working with Versioned Values
|
||||
|
||||
The `Schema` module provides runtime operations for versioned values:
|
||||
|
||||
### Creating Versioned Values
|
||||
|
||||
```lux
|
||||
// Create a value tagged with a specific version
|
||||
let userV1 = Schema.versioned("User", 1, { name: "Alice" })
|
||||
let userV2 = Schema.versioned("User", 2, { name: "Alice", email: "alice@example.com" })
|
||||
```
|
||||
|
||||
### Checking Versions
|
||||
|
||||
```lux
|
||||
let user = Schema.versioned("User", 1, { name: "Alice" })
|
||||
let version = Schema.getVersion(user) // Returns 1
|
||||
|
||||
// Version-aware logic
|
||||
if version < 2 then
|
||||
Console.print("Legacy user format")
|
||||
else
|
||||
Console.print("Modern user format")
|
||||
```
|
||||
|
||||
### Migrating Values
|
||||
|
||||
```lux
|
||||
// Migrate to a specific version
|
||||
let userV1 = Schema.versioned("User", 1, { name: "Alice" })
|
||||
let userV2 = Schema.migrate(userV1, 2) // Uses declared migration
|
||||
|
||||
let version = Schema.getVersion(userV2) // Now 2
|
||||
|
||||
// Chain migrations (v1 -> v2 -> v3)
|
||||
let userV3 = Schema.migrate(userV1, 3) // Applies v1->v2, then v2->v3
|
||||
```
|
||||
|
||||
## Auto-Generated Migrations
|
||||
|
||||
For simple changes, Lux can **automatically generate** migrations:
|
||||
|
||||
```lux
|
||||
type Profile @v1 {
|
||||
name: String
|
||||
}
|
||||
|
||||
// Adding a field with a default? Migration is auto-generated
|
||||
type Profile @v2 {
|
||||
name: String,
|
||||
bio: String = "" // Default value provided
|
||||
}
|
||||
|
||||
// The compiler generates this for you:
|
||||
// from @v1 = { name: old.name, bio: "" }
|
||||
```
|
||||
|
||||
Auto-migration works for:
|
||||
- Adding fields with default values
|
||||
- Keeping existing fields unchanged
|
||||
|
||||
You must write explicit migrations for:
|
||||
- Field renaming
|
||||
- Field removal (to confirm intent)
|
||||
- Type changes
|
||||
- Computed/derived fields
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: API Response Versioning
|
||||
|
||||
```lux
|
||||
type ApiResponse @v1 {
|
||||
status: String,
|
||||
data: String
|
||||
}
|
||||
|
||||
type ApiResponse @v2 {
|
||||
status: String,
|
||||
data: String,
|
||||
meta: { timestamp: Int, version: String },
|
||||
|
||||
from @v1 = {
|
||||
status: old.status,
|
||||
data: old.data,
|
||||
meta: { timestamp: 0, version: "legacy" }
|
||||
}
|
||||
}
|
||||
|
||||
// Version-aware API client
|
||||
fn handleResponse(raw: ApiResponse@v1): ApiResponse@v2 = {
|
||||
Schema.migrate(Schema.versioned("ApiResponse", 1, raw), 2)
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Database Record Evolution
|
||||
|
||||
```lux
|
||||
// Original schema
|
||||
type Customer @v1 {
|
||||
name: String,
|
||||
address: String
|
||||
}
|
||||
|
||||
// Split address into components
|
||||
type Customer @v2 {
|
||||
name: String,
|
||||
street: String,
|
||||
city: String,
|
||||
country: String,
|
||||
|
||||
from @v1 = {
|
||||
let parts = String.split(old.address, ", ")
|
||||
{
|
||||
name: old.name,
|
||||
street: List.get(parts, 0) |> Option.getOrElse(old.address),
|
||||
city: List.get(parts, 1) |> Option.getOrElse("Unknown"),
|
||||
country: List.get(parts, 2) |> Option.getOrElse("Unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load and migrate on read
|
||||
fn loadCustomer(id: String): Customer@v2 with {Database} = {
|
||||
let record = Database.query("SELECT * FROM customers WHERE id = ?", [id])
|
||||
let version = record.schema_version // Stored version
|
||||
|
||||
if version == 1 then
|
||||
let v1 = Schema.versioned("Customer", 1, {
|
||||
name: record.name,
|
||||
address: record.address
|
||||
})
|
||||
Schema.migrate(v1, 2)
|
||||
else
|
||||
{ name: record.name, street: record.street, city: record.city, country: record.country }
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Configuration Files
|
||||
|
||||
```lux
|
||||
type AppConfig @v1 {
|
||||
debug: Bool,
|
||||
port: Int
|
||||
}
|
||||
|
||||
type AppConfig @v2 {
|
||||
debug: Bool,
|
||||
port: Int,
|
||||
logLevel: String, // New in v2
|
||||
|
||||
from @v1 = {
|
||||
debug: old.debug,
|
||||
port: old.port,
|
||||
logLevel: if old.debug then "debug" else "info"
|
||||
}
|
||||
}
|
||||
|
||||
type AppConfig @v3 {
|
||||
environment: String, // Replaces debug flag
|
||||
port: Int,
|
||||
logLevel: String,
|
||||
|
||||
from @v2 = {
|
||||
environment: if old.debug then "development" else "production",
|
||||
port: old.port,
|
||||
logLevel: old.logLevel
|
||||
}
|
||||
}
|
||||
|
||||
// Load config with automatic migration
|
||||
fn loadConfig(path: String): AppConfig@v3 with {File} = {
|
||||
let json = File.read(path)
|
||||
let parsed = Json.parse(json)
|
||||
let version = Json.getInt(parsed, "version") |> Option.getOrElse(1)
|
||||
|
||||
match version {
|
||||
1 => {
|
||||
let v1 = Schema.versioned("AppConfig", 1, {
|
||||
debug: Json.getBool(parsed, "debug") |> Option.getOrElse(false),
|
||||
port: Json.getInt(parsed, "port") |> Option.getOrElse(8080)
|
||||
})
|
||||
Schema.migrate(v1, 3)
|
||||
},
|
||||
2 => {
|
||||
let v2 = Schema.versioned("AppConfig", 2, {
|
||||
debug: Json.getBool(parsed, "debug") |> Option.getOrElse(false),
|
||||
port: Json.getInt(parsed, "port") |> Option.getOrElse(8080),
|
||||
logLevel: Json.getString(parsed, "logLevel") |> Option.getOrElse("info")
|
||||
})
|
||||
Schema.migrate(v2, 3)
|
||||
},
|
||||
_ => {
|
||||
// Already v3
|
||||
{
|
||||
environment: Json.getString(parsed, "environment") |> Option.getOrElse("production"),
|
||||
port: Json.getInt(parsed, "port") |> Option.getOrElse(8080),
|
||||
logLevel: Json.getString(parsed, "logLevel") |> Option.getOrElse("info")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Event Sourcing
|
||||
|
||||
```lux
|
||||
// Event types evolve over time
|
||||
type UserCreated @v1 {
|
||||
userId: String,
|
||||
name: String,
|
||||
timestamp: Int
|
||||
}
|
||||
|
||||
type UserCreated @v2 {
|
||||
userId: String,
|
||||
name: String,
|
||||
email: String,
|
||||
createdAt: Int, // Renamed from timestamp
|
||||
|
||||
from @v1 = {
|
||||
userId: old.userId,
|
||||
name: old.name,
|
||||
email: "", // Not captured in v1
|
||||
createdAt: old.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// Process events regardless of version
|
||||
fn processEvent(event: UserCreated@v1 | UserCreated@v2): Unit with {Console} = {
|
||||
let normalized = Schema.migrate(event, 2) // Always work with v2
|
||||
Console.print("User created: " + normalized.name + " at " + toString(normalized.createdAt))
|
||||
}
|
||||
```
|
||||
|
||||
## Compile-Time Safety
|
||||
|
||||
The compiler catches schema evolution errors:
|
||||
|
||||
```lux
|
||||
type User @v2 {
|
||||
name: String,
|
||||
email: String
|
||||
|
||||
// ERROR: Migration references non-existent field
|
||||
from @v1 = { name: old.username, email: old.email }
|
||||
// ^^^^^^^^ 'username' does not exist in User@v1
|
||||
}
|
||||
```
|
||||
|
||||
```lux
|
||||
type User @v2 {
|
||||
name: String,
|
||||
email: String
|
||||
|
||||
// ERROR: Migration missing required field
|
||||
from @v1 = { name: old.name }
|
||||
// ^ Missing 'email' field
|
||||
}
|
||||
```
|
||||
|
||||
```lux
|
||||
type User @v2 {
|
||||
name: String,
|
||||
age: Int
|
||||
|
||||
// ERROR: Type mismatch in migration
|
||||
from @v1 = { name: old.name, age: old.birthYear }
|
||||
// ^^^^^^^^^^^^^ Expected Int, found String
|
||||
}
|
||||
```
|
||||
|
||||
## Compatibility Checking
|
||||
|
||||
Lux tracks compatibility between versions:
|
||||
|
||||
| Change Type | Backward Compatible | Forward Compatible |
|
||||
|-------------|--------------------|--------------------|
|
||||
| Add optional field (with default) | Yes | Yes |
|
||||
| Add required field | No | Yes (with migration) |
|
||||
| Remove field | Yes (with migration) | No |
|
||||
| Rename field | No | No (need migration) |
|
||||
| Change field type | No | No (need migration) |
|
||||
|
||||
The compiler warns about breaking changes:
|
||||
|
||||
```lux
|
||||
type User @v1 {
|
||||
name: String,
|
||||
email: String
|
||||
}
|
||||
|
||||
type User @v2 {
|
||||
name: String
|
||||
// Warning: Removing 'email' is a breaking change
|
||||
// Existing v2 consumers expect this field
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Version Production Types
|
||||
|
||||
```lux
|
||||
// Good: Versioned from the start
|
||||
type Order @v1 {
|
||||
id: String,
|
||||
items: List<Item>,
|
||||
total: Int
|
||||
}
|
||||
|
||||
// Bad: Unversioned type is hard to evolve
|
||||
type Order {
|
||||
id: String,
|
||||
items: List<Item>,
|
||||
total: Int
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Keep Migrations Simple
|
||||
|
||||
```lux
|
||||
// Good: Simple, direct mapping
|
||||
from @v1 = {
|
||||
name: old.name,
|
||||
email: old.email |> Option.getOrElse("")
|
||||
}
|
||||
|
||||
// Avoid: Complex logic in migrations
|
||||
from @v1 = {
|
||||
name: old.name,
|
||||
email: {
|
||||
// Don't put complex business logic here
|
||||
let domain = inferDomainFromName(old.name)
|
||||
let local = String.toLower(String.replace(old.name, " ", "."))
|
||||
local + "@" + domain
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Migrations
|
||||
|
||||
```lux
|
||||
fn testUserMigration(): Unit with {Test} = {
|
||||
let v1User = Schema.versioned("User", 1, { name: "Alice" })
|
||||
let v2User = Schema.migrate(v1User, 2)
|
||||
|
||||
Test.assertEqual(v2User.name, "Alice")
|
||||
Test.assertEqual(v2User.email, "unknown@example.com")
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Document Breaking Changes
|
||||
|
||||
```lux
|
||||
type User @v3 {
|
||||
// BREAKING: 'name' split into firstName/lastName
|
||||
// Migration: name.split(" ")[0] -> firstName, name.split(" ")[1] -> lastName
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
email: String,
|
||||
|
||||
from @v2 = { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Module Reference
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `Schema.versioned(typeName, version, value)` | Create a versioned value |
|
||||
| `Schema.getVersion(value)` | Get the version of a value |
|
||||
| `Schema.migrate(value, targetVersion)` | Migrate to a target version |
|
||||
| `Schema.isCompatible(v1, v2)` | Check if versions are compatible |
|
||||
|
||||
## Summary
|
||||
|
||||
Schema evolution in Lux provides:
|
||||
|
||||
- **Versioned types** with `@v1`, `@v2`, `@latest` annotations
|
||||
- **Explicit migrations** with `from @vN = { ... }` syntax
|
||||
- **Automatic migrations** for simple field additions with defaults
|
||||
- **Runtime operations** via the `Schema` module
|
||||
- **Compile-time safety** catching migration errors early
|
||||
- **Migration chaining** for multi-step upgrades
|
||||
|
||||
This system ensures your data can evolve safely over time, without breaking existing code or losing information.
|
||||
|
||||
## What's Next?
|
||||
|
||||
- [Tutorials](../tutorials/README.md) - Build real projects
|
||||
- [Standard Library Reference](../stdlib/README.md) - Complete API docs
|
||||
Reference in New Issue
Block a user