diff --git a/.changeset/add-types-utilities.md b/.changeset/add-types-utilities.md new file mode 100644 index 000000000..874e046f9 --- /dev/null +++ b/.changeset/add-types-utilities.md @@ -0,0 +1,14 @@ +--- +"@evolu/common": minor +--- + +Added new types and utilities to Types.ts: + +- `Awaitable` - type for values that can be sync or async +- `isPromiseLike` - type guard to check if a value is a PromiseLike +- `Digit`, `Digit1To9`, `Digit1To6`, `Digit1To23`, `Digit1To51`, `Digit1To99`, `Digit1To59` - template literal types for numeric validation +- `UnionToIntersection` - converts a union to an intersection + +`Awaitable` represents values that can be either synchronous or asynchronous (`T | PromiseLike`). This type is useful for functions that may complete synchronously or asynchronously depending on runtime conditions. + +`isPromiseLike()` is a type guard to check if an Awaitable value is async, allowing conditional await only when necessary. diff --git a/.changeset/array-module-refactor.md b/.changeset/array-module-refactor.md new file mode 100644 index 000000000..a9ceebb0b --- /dev/null +++ b/.changeset/array-module-refactor.md @@ -0,0 +1,52 @@ +--- +"@evolu/common": major +--- + +Refactored the Array module with breaking changes, better naming, and new helpers. + +### Breaking Changes + +**Removed `isNonEmptyReadonlyArray`** — use `isNonEmptyArray` instead. The function now handles both mutable and readonly arrays via overloads: + +```ts +// Before +if (isNonEmptyReadonlyArray(readonlyArr)) { ... } +if (isNonEmptyArray(mutableArr)) { ... } + +// After — one function for both +if (isNonEmptyArray(readonlyArr)) { ... } +if (isNonEmptyArray(mutableArr)) { ... } +``` + +**Renamed mutation functions** for consistency with the `...Array` suffix pattern: + +- `shiftArray` → `shiftFromArray` +- `popArray` → `popFromArray` + +### New Constants + +- **`emptyArray`** — use as a default or initial value to avoid allocating new empty arrays + +### New Functions + +- **`flatMapArray`** — maps each element to an array and flattens the result, preserving non-empty type when applicable +- **`concatArrays`** — concatenates two arrays, returning non-empty when at least one input is non-empty +- **`sortArray`** — returns a new sorted array (wraps `toSorted`), preserving non-empty type +- **`reverseArray`** — returns a new reversed array (wraps `toReversed`), preserving non-empty type +- **`spliceArray`** — returns a new array with elements removed/replaced (wraps `toSpliced`) + +### Migration + +```ts +// isNonEmptyReadonlyArray → isNonEmptyArray +-import { isNonEmptyReadonlyArray } from "@evolu/common"; ++import { isNonEmptyArray } from "@evolu/common"; + +// shiftArray → shiftFromArray +-import { shiftArray } from "@evolu/common"; ++import { shiftFromArray } from "@evolu/common"; + +// popArray → popFromArray +-import { popArray } from "@evolu/common"; ++import { popFromArray } from "@evolu/common"; +``` diff --git a/.changeset/config.json b/.changeset/config.json index d34de4d36..ccd678c7c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "restricted", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": ["web", "@example/*"] + "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["web", "@example/*"] } diff --git a/.changeset/gentle-pumas-eat.md b/.changeset/gentle-pumas-eat.md new file mode 100644 index 000000000..d93769d8b --- /dev/null +++ b/.changeset/gentle-pumas-eat.md @@ -0,0 +1,12 @@ +--- +"@evolu/react-native": major +"@evolu/react-web": major +"@evolu/common": major +"@evolu/nodejs": major +"@evolu/react": major +"@evolu/vue": major +"@evolu/web": major +"@evolu/relay": major +--- + +Updated minimum Node.js version from 22 to 24 (current LTS) diff --git a/.changeset/global-error-scope.md b/.changeset/global-error-scope.md new file mode 100644 index 000000000..159c3b9b1 --- /dev/null +++ b/.changeset/global-error-scope.md @@ -0,0 +1,12 @@ +--- +"@evolu/common": minor +"@evolu/web": minor +"@evolu/nodejs": minor +--- + +Added `GlobalErrorScope` interface for platform-agnostic global error handling + +- Added `GlobalErrorScope` interface representing execution contexts that capture uncaught errors and unhandled promise rejections +- Added `handleGlobalError` helper to forward errors to scope callbacks +- Added `createGlobalErrorScope` for browser windows in `@evolu/web` +- Added `createGlobalErrorScope` for Node.js processes in `@evolu/nodejs` diff --git a/.changeset/lazy-rename.md b/.changeset/lazy-rename.md new file mode 100644 index 000000000..3b9b8a847 --- /dev/null +++ b/.changeset/lazy-rename.md @@ -0,0 +1,5 @@ +--- +"@evolu/common": major +--- + +Renamed `LazyValue` to `Lazy` for brevity diff --git a/.changeset/listeners-module.md b/.changeset/listeners-module.md new file mode 100644 index 000000000..d2bcfda34 --- /dev/null +++ b/.changeset/listeners-module.md @@ -0,0 +1,19 @@ +--- +"@evolu/common": patch +--- + +Added Listeners module for publish-subscribe notifications + +### Example + +```ts +// Without payload (default) +const listeners = createListeners(); +listeners.subscribe(() => console.log("notified")); +listeners.notify(); + +// With typed payload +const listeners = createListeners<{ id: string }>(); +listeners.subscribe((event) => console.log(event.id)); +listeners.notify({ id: "123" }); +``` diff --git a/.changeset/lovely-aliens-camp.md b/.changeset/lovely-aliens-camp.md new file mode 100644 index 000000000..a6fb66a9c --- /dev/null +++ b/.changeset/lovely-aliens-camp.md @@ -0,0 +1,22 @@ +--- +"@evolu/common": minor +--- + +Added `createObjectURL` helper for safe, disposable `URL.createObjectURL` usage using JS Resource Management so the URL is disposed automatically when the scope ends. + +Example: + +```ts +const handleDownloadDatabaseClick = () => { + void evolu.exportDatabase().then((data) => { + using objectUrl = createObjectURL( + new Blob([data], { type: "application/x-sqlite3" }), + ); + + const link = document.createElement("a"); + link.href = objectUrl.url; + link.download = `${evolu.name}.sqlite3`; + link.click(); + }); +}; +``` diff --git a/.changeset/next-result-done.md b/.changeset/next-result-done.md new file mode 100644 index 000000000..6cf08d274 --- /dev/null +++ b/.changeset/next-result-done.md @@ -0,0 +1,18 @@ +--- +"@evolu/common": minor +--- + +Added pull-based protocol types for modeling three-outcome operations + +New types and utilities for iterators and streams where completion is a normal outcome, not an error: + +- `Done` - Signal type for normal completion with optional summary value +- `done(value)` - Factory function to create Done instances +- `NextResult` - Result that can complete with value, error, or done +- `nextResult(ok, err, done)` - Factory for creating NextResult Type instances +- `UnknownNextResult` - Type instance for runtime `.is()` checks +- `InferDone` - Extracts the done value type from a NextResult +- `NextTask` - Task that can complete with value, error, or done +- `InferTaskDone` - Extracts the done value type from a NextTask + +The naming follows the existing pattern: `Result` → `NextResult`, `Task` → `NextTask`. diff --git a/.changeset/random-number-brand.md b/.changeset/random-number-brand.md new file mode 100644 index 000000000..793e1256a --- /dev/null +++ b/.changeset/random-number-brand.md @@ -0,0 +1,9 @@ +--- +"@evolu/common": minor +--- + +Added `RandomNumber` branded type for type-safe random values + +- `RandomNumber` — branded `number` type for values in [0, 1) range +- `Random.next()` now returns `RandomNumber` instead of `number` +- Prevents accidentally passing arbitrary numbers where random values are expected diff --git a/.changeset/red-wings-itch.md b/.changeset/red-wings-itch.md new file mode 100644 index 000000000..108a01257 --- /dev/null +++ b/.changeset/red-wings-itch.md @@ -0,0 +1,9 @@ +--- +"@evolu/react-native": major +"@evolu/react-web": major +"@evolu/common": major +"@evolu/web": major +--- + +- Merged `@evolu/common/local-first/Platform.ts` into `@evolu/common/Platform.ts` +- Made `@evolu/react-web` re-export everything from `@evolu/web`, allowing React users to install only `@evolu/react-web` diff --git a/.changeset/result-never-inference.md b/.changeset/result-never-inference.md new file mode 100644 index 000000000..b0a9b4f7a --- /dev/null +++ b/.changeset/result-never-inference.md @@ -0,0 +1,5 @@ +--- +"@evolu/common": major +--- + +Changed `ok()` to return `Result` and `err()` to return `Result` for correct type inference. diff --git a/.changeset/schedule-module.md b/.changeset/schedule-module.md new file mode 100644 index 000000000..ced2402e8 --- /dev/null +++ b/.changeset/schedule-module.md @@ -0,0 +1,42 @@ +--- +"@evolu/common": minor +--- + +Added Schedule module for composable scheduling strategies. + +**Schedule** is a composable abstraction for retry, repeat, and rate limiting. Each schedule is a state machine: calling `schedule(deps)` creates a step function, and each `step(input)` returns `Ok([Output, Millis])` or `Err(Done)` to stop. + +**Constructors:** + +- `forever` — never stops, no delay (base for composition) +- `once` — runs exactly once +- `recurs(n)` — runs n times +- `spaced(duration)` — constant delay +- `exponential(base, factor?)` — exponential backoff +- `linear(base)` — linear backoff +- `fibonacci(initial)` — Fibonacci backoff +- `fixed(interval)` — window-aligned intervals +- `windowed(interval)` — sleeps until next window boundary +- `fromDelay(duration)` — single delay +- `fromDelays(...durations)` — sequence of delays +- `elapsed` — outputs elapsed time +- `during(duration)` — runs for specified duration +- `succeed(value)` — constant output +- `unfold(initial, next)` — state machine + +**Combinators:** + +- Limiting: `take`, `maxElapsed`, `maxDelay` +- Delay: `jitter`, `delayed`, `addDelay`, `modifyDelay`, `compensateExecution` +- Filtering: `whileInput`, `untilInput`, `whileOutput`, `untilOutput`, `resetAfter` +- Transform: `map`, `passthrough`, `fold`, `repetitions`, `delays` +- Collection: `collectAllOutputs`, `collectInputs`, `collectWhile`, `collectUntil` +- Composition: `sequence`, `intersect`, `union`, `whenInput` +- Side effects: `tapOutput`, `tapInput` + +**Presets:** + +- `retryStrategyAws` — exponential backoff (100ms base), max 2 retries, 20s cap, full jitter +- `retryStrategyAwsThrottled` — same but with 1s base for rate limiting + +All APIs are marked `@experimental`. diff --git a/.changeset/set-module.md b/.changeset/set-module.md new file mode 100644 index 000000000..8ab764326 --- /dev/null +++ b/.changeset/set-module.md @@ -0,0 +1,28 @@ +--- +"@evolu/common": minor +--- + +Added Set module with type-safe helpers for immutable set operations. + +**Types:** + +- `NonEmptyReadonlySet` — branded type for sets with at least one element (no mutable variant because `clear()`/`delete()` would break the guarantee) + +**Constants:** + +- `emptySet` — singleton empty set to avoid allocations + +**Type Guards:** + +- `isNonEmptySet` — narrows to branded `NonEmptyReadonlySet` + +**Transformations:** + +- `addToSet` — returns branded non-empty set with item added +- `deleteFromSet` — returns new set with item removed +- `mapSet` — maps over set, preserves non-empty type +- `filterSet` — filters set with predicate or refinement + +**Accessors:** + +- `firstInSet` — returns first element by insertion order (requires branded type) diff --git a/.changeset/smart-refs-store.md b/.changeset/smart-refs-store.md new file mode 100644 index 000000000..3074b90da --- /dev/null +++ b/.changeset/smart-refs-store.md @@ -0,0 +1,5 @@ +--- +"@evolu/common": minor +--- + +Added optional equality function to `Ref` and `ReadonlyStore` interface. `Ref.set` and `Ref.modify` now return `boolean` indicating whether state was updated. `Store` now uses `Ref` internally for state management. diff --git a/.changeset/spotty-coats-sort.md b/.changeset/spotty-coats-sort.md new file mode 100644 index 000000000..cd0e2665d --- /dev/null +++ b/.changeset/spotty-coats-sort.md @@ -0,0 +1,11 @@ +--- +"@evolu/common": minor +--- + +Added Resource Management polyfills + +Provides `Symbol.dispose`, `Symbol.asyncDispose`, `DisposableStack`, and `AsyncDisposableStack` for environments without native support (e.g., Safari). This enables the `using` and `await using` declarations for automatic resource cleanup. + +Polyfills are installed automatically when importing `@evolu/common`. + +See `Result.test.ts` for usage patterns combining `Result` with `using`, `DisposableStack`, and `AsyncDisposableStack`. diff --git a/.changeset/time-module-refactor.md b/.changeset/time-module-refactor.md new file mode 100644 index 000000000..f6e151eda --- /dev/null +++ b/.changeset/time-module-refactor.md @@ -0,0 +1,34 @@ +--- +"@evolu/common": major +--- + +Refactored Time module for type safety, consistency, and better abstractions. + +**Type safety:** + +- Changed `Time.now()` return type from `number` to `Millis` +- Added `Millis` branded type with efficient 6-byte serialization (max value: year 10889) +- Added `minMillis` and `maxMillis` constants +- Both `now()` and `nowIso()` now throw on invalid values for consistent error handling + +**Timer abstraction:** + +- Added `Time.setTimeout` and `Time.clearTimeout` for platform-agnostic timers +- Added `TimeoutId` opaque type for timeout handles +- Added `TestTime` interface with `advance()` for controllable time in tests +- Updated `createTestTime` with `startAt` and `autoIncrement` options + +**Duration literals:** + +- Renamed `DurationString` to `DurationLiteral` +- Each duration has exactly one canonical form (e.g., "1000ms" must be written as "1s") +- Added decimal support: "1.5s" (1500ms), "1.5h" (90 minutes) +- Added weeks ("1w" to "51w") and years ("1y" to "99y") +- Removed combination syntax ("1h 30m") in favor of decimals ("1.5h") +- Months not supported (variable length) + +**UI responsiveness constants:** + +- `ms60fps` (16ms frame budget at 60fps) +- `ms120fps` (8ms frame budget at 120fps) +- `msLongTask` (50ms long task threshold for use with `yieldNow`) diff --git a/.changeset/tough-cats-fall.md b/.changeset/tough-cats-fall.md new file mode 100644 index 000000000..b338d635d --- /dev/null +++ b/.changeset/tough-cats-fall.md @@ -0,0 +1,68 @@ +--- +"@evolu/common": major +"@evolu/web": major +--- + +Replaced interface-based symmetric encryption with direct function-based API + +### Breaking Changes + +**Removed:** + +- `SymmetricCrypto` interface +- `SymmetricCryptoDep` interface +- `createSymmetricCrypto()` factory function +- `SymmetricCryptoDecryptError` error type + +**Added:** + +- `encryptWithXChaCha20Poly1305()` - Direct encryption function with explicit algorithm name +- `decryptWithXChaCha20Poly1305()` - Direct decryption function +- `XChaCha20Poly1305Ciphertext` - Branded type for ciphertext +- `Entropy24` - Branded type for 24-byte nonces +- `DecryptWithXChaCha20Poly1305Error` - Algorithm-specific error type +- `xChaCha20Poly1305NonceLength` - Constant for nonce length (24) + +### Migration Guide + +**Before:** + +```ts +const symmetricCrypto = createSymmetricCrypto({ randomBytes }); +const { nonce, ciphertext } = symmetricCrypto.encrypt(plaintext, key); +const result = symmetricCrypto.decrypt(ciphertext, key, nonce); +``` + +**After:** + +```ts +const [ciphertext, nonce] = encryptWithXChaCha20Poly1305({ randomBytes })( + plaintext, + key, +); +const result = decryptWithXChaCha20Poly1305(ciphertext, nonce, key); +``` + +**Error handling:** + +```ts +// Before +if (!result.ok && result.error.type === "SymmetricCryptoDecryptError") { ... } + +// After +if (!result.ok && result.error.type === "DecryptWithXChaCha20Poly1305Error") { ... } +``` + +**Dependency injection:** + +```ts +// Before +interface Deps extends SymmetricCryptoDep { ... } + +// After - only encrypt needs RandomBytesDep +interface Deps extends RandomBytesDep { ... } +``` + +### Rationale + +This change improves API extensibility by using explicit function names instead of a generic interface. Adding new encryption algorithms (e.g., `encryptWithAES256GCM`) is now straightforward without breaking existing code. diff --git a/.changeset/transferable-error-rename.md b/.changeset/transferable-error-rename.md new file mode 100644 index 000000000..03b9991cf --- /dev/null +++ b/.changeset/transferable-error-rename.md @@ -0,0 +1,5 @@ +--- +"@evolu/common": major +--- + +Renamed `TransferableError` to `UnknownError` to better reflect its purpose as a wrapper for unknown errors caught at runtime, not just errors that need to be transferred between contexts diff --git a/.changeset/type-result-factory.md b/.changeset/type-result-factory.md new file mode 100644 index 000000000..b924b160b --- /dev/null +++ b/.changeset/type-result-factory.md @@ -0,0 +1,15 @@ +--- +"@evolu/common": minor +--- + +Added `result` Type factory and `typed` overload for props-less discriminants + +**Result Type factory:** + +- `result(okType, errType)` — creates a Type for validating serialized Results from storage, APIs, or message passing +- `UnknownResult` — validates `Result` for runtime `.is()` checks + +**typed overload:** + +- `typed(tag)` now accepts just a tag without props for simple discriminants like `typed("Pending")` +- Added `TypedType` helper type for the return type of `typed` diff --git a/.changeset/typed-discriminant.md b/.changeset/typed-discriminant.md new file mode 100644 index 000000000..1c93af610 --- /dev/null +++ b/.changeset/typed-discriminant.md @@ -0,0 +1,22 @@ +--- +"@evolu/common": minor +--- + +Added `Typed` interface and `typed` factory for discriminated unions + +Discriminated unions model mutually exclusive states where each variant is a distinct type. This makes illegal states unrepresentable — invalid combinations cannot exist. + +```ts +// Type-only usage for static discrimination +interface Pending extends Typed<"Pending"> { + readonly createdAt: DateIso; +} +interface Shipped extends Typed<"Shipped"> { + readonly trackingNumber: TrackingNumber; +} +type OrderState = Pending | Shipped; + +// Runtime validation with typed() factory +const Pending = typed("Pending", { createdAt: DateIso }); +const Shipped = typed("Shipped", { trackingNumber: TrackingNumber }); +``` diff --git a/.changeset/worker-abstraction-refactor.md b/.changeset/worker-abstraction-refactor.md new file mode 100644 index 000000000..1e4b25a80 --- /dev/null +++ b/.changeset/worker-abstraction-refactor.md @@ -0,0 +1,16 @@ +--- +"@evolu/common": major +"@evolu/web": major +"@evolu/react-native": major +"@evolu/react-web": major +--- + +Refactored worker abstraction to support all platforms uniformly: + +- Added platform-agnostic worker interfaces: `Worker`, `SharedWorker`, `MessagePort`, `MessageChannel` +- Added worker-side interfaces: `WorkerScope` and `SharedWorkerScope` that extend `GlobalErrorScope` for unified error handling +- Changed `onMessage` from a method to a property for consistency with Web APIs +- Made all worker and message port interfaces `Disposable` for proper resource cleanup +- Added default generic parameters (`Output = never`) for simpler one-way communication patterns +- Added complete web platform implementations: `createWorker`, `createSharedWorker`, `createMessageChannel`, `createWorkerScope`, `createSharedWorkerScope`, `createMessagePort` +- Added React Native polyfills for Workers and MessageChannel diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 21ae4cfe6..360e5f422 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,23 +4,35 @@ applyTo: "**/*.{ts,tsx}" # Evolu project guidelines -You are helping with the Evolu project. Follow these specific conventions and patterns: +Follow these specific conventions and patterns: + +## Test-driven development + +- Write a failing test before implementing a new feature or fixing a bug +- Keep test code cleaner than production code — good tests let you refactor production code; nothing protects messy tests ## Code organization & imports - **Use named imports only** - avoid default exports and namespace imports - **Use unique exported members** - avoid namespaces, use descriptive names to prevent conflicts -- **Organize code top-down** - public interfaces first, then implementation, then implementation details +- **Organize code top-down** - public interfaces first, then implementation, then implementation details. If a helper must be defined before the public export that uses it (due to JavaScript hoisting), place it immediately before that export. +- **Reference globals explicitly with `globalThis`** - when a name clashes with global APIs (e.g., `SharedWorker`, `Worker`), use `globalThis.SharedWorker` instead of aliasing imports ```ts -// ✅ Good +// Good import { bar, baz } from "Foo.ts"; export const ok = ...; export const trySync = ...; -// ❌ Avoid +// Avoid import Foo from "Foo.ts"; export const Utils = { ok, trySync }; + +// Good - Avoid naming conflicts with globals +const nativeSharedWorker = new globalThis.SharedWorker(...); + +// Avoid - Aliasing to work around global name clash +import { SharedWorker as SharedWorkerType } from "./Worker.js"; ``` ## Functions @@ -28,13 +40,19 @@ export const Utils = { ok, trySync }; - **Use arrow functions** - avoid the `function` keyword for consistency - **Exception: function overloads** - TypeScript requires the `function` keyword for overloaded signatures -```ts -// ✅ Good - Arrow function -export const createUser = (data: UserData): User => { - // implementation -}; +### Factories + +Use factory functions instead of classes for creating objects, typically named `createX`. Order function contents as follows: -// ✅ Good - Function overloads (requires function keyword) +1. Const setup & invariants (args + derived consts + assertions) +2. Mutable state +3. Owned resources +4. Side-effectful wiring +5. Shared helpers +6. Return object (public operations + disposal/closing) + +```ts +// Good - Function overloads (requires function keyword) export function mapArray( array: NonEmptyReadonlyArray, mapper: (item: T) => U, @@ -50,12 +68,52 @@ export function mapArray( return array.map(mapper) as ReadonlyArray; } -// ❌ Avoid - function keyword without overloads +// Avoid - function keyword without overloads export function createUser(data: UserData): User { // implementation } ``` +### Function options + +For functions with optional configuration, use inline types without `readonly` for single-use options and named interfaces with `readonly` for reusable options. Always destructure immediately. + +```ts +// Good - inline type, single-use +export const race = ( + tasks: Tasks, + { + abortReason = raceLostError, + }: { + abortReason?: unknown; + } = {}, +): Task<...> => ... + +// Good - named interface, reusable +export interface RetryOptions { + readonly maxAttempts?: number; + readonly delay?: Duration; +} +``` + +## Variable shadowing + +- **Shadowing is OK** - since we use `const` everywhere, shadowing avoids artificial names like `innerValue`, `newValue`, `result2` + +```ts +// Good - Shadow in nested scopes +const value = getData(); +items.map((value) => process(value)); // shadowing is fine + +const result = fetchUser(); +if (result.ok) { + const result = fetchProfile(result.value); // shadow in nested block + if (result.ok) { + // ... + } +} +``` + ## Immutability - **Favor immutability** - use `readonly` properties and `ReadonlyArray`/`NonEmptyReadonlyArray` @@ -67,14 +125,59 @@ interface Example { } ``` -## Documentation & JSDoc +## Opaque types + +- **Use `Brand<"Name">`** for values callers cannot inspect or construct—only pass back to the creating API +- Useful for platform abstraction, handle types (timeout IDs, file handles), and type safety + +```ts +type TimeoutId = Brand<"TimeoutId">; +type NativeMessagePort = Brand<"NativeMessagePort">; +``` + +## Object enums + +- **Use PascalCase for keys** - all keys in constant objects should use PascalCase +- **String values match keys** - when using strings, make values match the key names +- **Numeric values for wire protocols** - use numbers for serialization efficiency +- **Export with `as const`** - ensure TypeScript treats values as literals + +```ts +// String values matching PascalCase keys +export const TaskScopeState = { + Open: "Open", + Closing: "Closing", + Closed: "Closed", +} as const; + +export type TaskScopeState = + (typeof TaskScopeState)[keyof typeof TaskScopeState]; + +// Numeric values for wire protocols +export const MessageType = { + Request: 0, + Response: 1, + Broadcast: 2, +} as const; + +export type MessageType = (typeof MessageType)[keyof typeof MessageType]; +``` + +## Documentation style + +- **Be direct and technical** - state facts, avoid conversational style +- **Lead with the key point** - put the most important information first + +## JSDoc & TypeDoc - **Avoid `@param` and `@return` tags** - TypeScript provides type information, focus on describing the function's purpose -- **Use `### Example` instead of `@example`** - for better markdown rendering and consistency +- **Use `### Example` instead of `@example`** - for better markdown rendering and consistency with TypeDoc - **Write clear descriptions** - explain what the function does, not how to use it +- **Use `{@link}` for references** - link to types, interfaces, functions, and exported symbols on first mention for discoverability +- **Avoid pipe characters in first sentence** - TypeDoc extracts the first sentence for table descriptions, and pipe characters (even in inline code like `T | undefined`) break markdown table rendering. Move such details to subsequent sentences. ````ts -// ✅ Good +// Good /** * Creates a new user with the provided data. * @@ -88,7 +191,28 @@ export const createUser = (data: UserData): User => { // implementation }; -// ❌ Avoid +/** + * Dependency wrapper for {@link CreateMessageChannel}. + * + * Used with {@link EvoluPlatformDeps} to provide platform-specific + * MessageChannel creation. + */ +export interface CreateMessageChannelDep { + readonly createMessageChannel: CreateMessageChannel; +} + +// Avoid +/** + * Dependency wrapper for CreateMessageChannel. + * + * Used with EvoluPlatformDeps to provide platform-specific MessageChannel + * creation. + */ +export interface CreateMessageChannelDep { + readonly createMessageChannel: CreateMessageChannel; +} + +// Avoid /** * Creates a new user with the provided data. * @@ -103,6 +227,16 @@ export const createUser = (data: UserData): User => { export const createUser = (data: UserData): User => { // implementation }; + +/** + * Dependency wrapper for CreateMessageChannel. + * + * Used with EvoluPlatformDeps to provide platform-specific MessageChannel + * creation. + */ +export interface CreateMessageChannelDep { + readonly createMessageChannel: CreateMessageChannel; +} ```` ## API stability & experimental APIs @@ -112,7 +246,7 @@ export const createUser = (data: UserData): User => { - **Promote to stable** once confident in the design after real-world usage ```ts -// ✅ Good - Mark new/uncertain APIs as experimental +// Good - Mark new/uncertain APIs as experimental /** * Casts a value to its readonly counterpart. * @@ -127,11 +261,10 @@ This pattern allows iterating on API design without committing to stability too - Use `Result` for business/domain errors in public APIs - Keep implementation-specific errors internal to dependencies -- **Favor imperative patterns** over monadic helpers for readability -- Use **plain objects** for business errors, Error instances only for debugging +- Use **plain objects** for domain errors, Error instances only for debugging ```ts -// ✅ Good - Business error +// Good - Domain error interface ParseJsonError { readonly type: "ParseJsonError"; readonly message: string; @@ -143,7 +276,7 @@ const parseJson = (value: string): Result => (error) => ({ type: "ParseJsonError", message: String(error) }), ); -// ✅ Good - Sequential operations with short-circuiting +// Good - Sequential operations with short-circuiting const processData = (deps: DataDeps) => { const foo = doFoo(deps); if (!foo.ok) return foo; @@ -151,7 +284,7 @@ const processData = (deps: DataDeps) => { return doStep2(deps)(foo.value); }; -// ❌ Avoid - Implementation error in public API +// Avoid - Implementation error in public API export interface Storage { writeMessages: (...) => Result; } @@ -166,7 +299,7 @@ export interface Storage { ```ts // For lazy operations array -const operations: LazyValue>[] = [ +const operations: Lazy>[] = [ () => doSomething(), () => doSomethingElse(), ]; @@ -185,17 +318,17 @@ for (const op of operations) { - **Use Brand types** - for semantic distinctions and constraints ```ts -// ✅ Good - Define typed error +// Good - Define typed error interface CurrencyCodeError extends TypeError<"CurrencyCode"> {} -// ✅ Good - Brand for semantic meaning and validation +// Good - Brand for semantic meaning and validation const CurrencyCode = brand("CurrencyCode", String, (value) => /^[A-Z]{3}$/.test(value) ? ok(value) : err({ type: "CurrencyCode", value }), ); -// ✅ Good - Type factory pattern +// Good - Type factory pattern const minLength: ( min: Min, ) => BrandFactory<`MinLength${Min}`, { length: number }, MinLengthError> = @@ -204,7 +337,7 @@ const minLength: ( value.length >= min ? ok(value) : err({ type: "MinLength", value, min }), ); -// ✅ Good - Error formatter +// Good - Error formatter const formatCurrencyCodeError = createTypeErrorFormatter( (error) => `Invalid currency code: ${error.value}`, ); @@ -278,18 +411,55 @@ const deps: TimeDep & Partial = { - **No global static instances** - avoid service locator pattern - **No generics in dependency interfaces** - keep them implementation-agnostic +## Tasks + +- **Call tasks with `run(task)`** - never call `task(run)` directly in user code +- **Handle Results** - check `result.ok` before using values, short-circuit on error +- **Compose tasks** - use helpers like `timeout`, `race` to combine tasks + +```ts +// Good - Call tasks with run() +const result = await run(sleep("1s")); +if (!result.ok) return result; + +const data = result.value; // only available if ok + +// Good - Compose and short-circuit +const processTask: Task = async (run) => { + const data = await run(fetchData); + if (!data.ok) return data; + + const parsed = await run(timeout("5s", parseData(data.value))); + if (!parsed.ok) return parsed; + + return ok(); +}; + +// Avoid - Calling task directly +const result = await sleep("1s")(run); +``` + ## Testing -- **Run tests using pnpm** - use `pnpm test` from the project root to run all tests -- **Run specific test files** - use `pnpm test --filter @evolu/package-name -- test-file-pattern` from project root (e.g., `pnpm test --filter @evolu/common -- Protocol`) -- **Check compilation** - use `pnpm build` to check TypeScript compilation across all packages -- **Run linting** - use `pnpm lint` to check code style and linting rules - **Leverage `_deps.ts`** - use existing test utilities and mocks from `packages/common/test/_deps.ts` (e.g., `testCreateId`, `testTime`, `testOwner`) - Mock dependencies using the same interfaces - Create test factories (e.g., `createTestTime`) - Never rely on global state - Use assertions in tests for conditions that should never fail +### Vitest filtering (https://vitest.dev/guide/filtering) + +```bash +# Run all tests in a package +pnpm test --filter @evolu/common + +# Run a single file +pnpm test --filter @evolu/common -- Task + +# Run a single test by name (-t flag) +pnpm test --filter @evolu/common -- -t "yields and returns ok" +``` + ```ts import { testCreateId, testTime, testOwner } from "../_deps.js"; @@ -315,12 +485,12 @@ test("timeUntilEvent calculates correctly", () => { - **Be descriptive** - explain what the change does ```bash -# ✅ Good +# Good Add support for custom error formatters Fix memory leak in WebSocket reconnection Update schema validation to handle edge cases -# ❌ Avoid +# Avoid feat: add support for custom error formatters fix: memory leak in websocket reconnection Update schema validation to handle edge cases. @@ -331,11 +501,11 @@ Update schema validation to handle edge cases. - **Write in past tense** - describe what was done, not what will be done ```markdown -# ✅ Good +# Good Added support for custom error formatters -# ❌ Avoid +# Avoid Add support for custom error formatters ``` diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07c82aecd..7a3cca515 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,5 +23,8 @@ jobs: - name: Setup uses: ./.github/actions/setup-node-pnpm-install + - name: Install Playwright browsers + run: npx playwright install --with-deps + - name: Verify run: pnpm verify diff --git a/.github/workflows/web-build.yaml b/.github/workflows/web-build.yaml index fd4adde08..e888dd940 100644 --- a/.github/workflows/web-build.yaml +++ b/.github/workflows/web-build.yaml @@ -1,5 +1,6 @@ name: Web Build +# Disabled per user request on: pull_request: branches: ["*"] @@ -15,13 +16,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -# You can leverage Vercel Remote Caching with Turbo to speed up your builds -# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds env: FORCE_COLOR: 3 jobs: build: + if: false # <--- Disabled runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 54f733244..c0b95e72e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ storybook-static .eslintcache out test-identicons +coverage diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index d6240f889..000000000 --- a/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -pnpm-lock.yaml -apps/web/src/app/docs/api-reference/**/*.mdx -apps/web/src/app/(docs)/docs/api-reference/**/*.mdx -# Contains SQL with runtime kyselyJsonIdentifier that breaks CLI SQL parser. -# File uses // prettier-ignore comments to preserve compact SQL format. -# To format non-SQL code, temporarily uncomment this line, format, re-comment. -packages/common/src/local-first/PublicKysely.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3b5d1e01d..61c8377db 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,3 @@ { - "recommendations": [ - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "unifiedjs.vscode-mdx" - ] -} \ No newline at end of file + "recommendations": ["biomejs.biome", "unifiedjs.vscode-mdx"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index ad4c66ce6..14ad3aa19 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,28 +1,28 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "editor.defaultFormatter": "esbenp.prettier-vscode", - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[yaml]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "files.associations": { - "*.mdx": "markdown" - }, - "[svelte]": { - "editor.defaultFormatter": "svelte.svelte-vscode" - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "typescript.tsdk": "node_modules/typescript/lib", + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.associations": { + "*.mdx": "markdown" + }, + "[svelte]": { + "editor.defaultFormatter": "svelte.svelte-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/README.md b/README.md index 0583389c8..251b0cb42 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,66 @@ # Evolu -Evolu is a TypeScript library and local-first platform. +Evolu is a local-first platform designed for privacy, ease of use, and no vendor lock-in. It provides a set of libraries to build apps that work offline, sync automatically, and encrypt data end-to-end. -## Documentation +[evolu.dev](https://www.evolu.dev) -Please visit [evolu.dev](https://www.evolu.dev). +## Features -## Community - -The Evolu community is on [GitHub Discussions](https://github.com/evoluhq/evolu/discussions), where you can ask questions and voice ideas. - -To chat with other community members, you can join the [Evolu Discord](https://discord.gg/2J8yyyyxtZ). - -[![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/evoluhq.svg?style=social&label=Follow%20%40evoluhq)](https://twitter.com/evoluhq) - -## Hosting Evolu Relay - -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https%3A%2F%2Fgithub.com%2Fevoluhq%2Fevolu) - -We provide a free relay `free.evoluhq.com` for testing and personal usage. +- **Local-First**: Data lives on the device first. +- **Privacy-Centric**: End-to-end encryption by default. +- **Sync**: Automatic sync across devices using CRDTs. +- **Typed**: Built with TypeScript for type safety. +- **SQL**: SQLite support in the browser and on devices. -The Evolu Relay source and Docker files are in the [/apps/relay](/apps/relay) directory. +## Requirements -Alternatively, a pre-built image `evoluhq/relay:latest` is hosted on [Docker Hub](https://hub.docker.com/r/evoluhq/relay). +- [Bun](https://bun.sh) (latest) +- Node.js >= 22 -For more information, reference the [Evolu Relay](https://www.evolu.dev/docs/relay) documentation. +## Development -## Developing +Evolu is a monorepo managed by **Turbo** and **Bun**. We use **Biome** for linting and formatting. -Evolu monorepo uses [pnpm](https://pnpm.io). +### Getting Started Install dependencies: +```bash +bun install ``` -pnpm install -``` - -Build scripts - -- `pnpm build` - Build packages -- `pnpm build:web` - Build web -- `pnpm examples:build` - Build all examples -Start dev +Start the development environment (web docs + examples): -> **Warning**: Run `pnpm build` before running dev. Packages must be built first. - -- `pnpm dev` - Dev server for web -- `pnpm ios` - Run iOS example (requires `pnpm dev` running) -- `pnpm android` - Run Android example (requires `pnpm dev` running) -- `pnpm examples:react-nextjs:dev` - Dev server for React Next.js example -- `pnpm examples:react-vite-pwa:dev` - Dev server for React Vite PWA example -- `pnpm examples:svelte-vite-pwa:dev` - Dev server for Svelte Vite PWA example -- `pnpm examples:vue-vite-pwa:dev` - Dev server for Vue Vite PWA example +```bash +bun dev +``` -Linting +### Scripts -- `pnpm lint` - Lint code -- `pnpm lint-monorepo` - Lint monorepo structure +- **Linting**: `bun run lint` (Check code quality with Biome) +- **Formatting**: `bun run format` (Apply formatting with Biome) +- **Testing**: `bun run test` (Run tests with Vitest) +- **Build**: `bun run build` (Build all packages) +- **Clean**: `bun run clean` (Clean artifacts and node_modules) -Testing +## Project Structure -- `pnpm test` - Run tests +- `packages/` + - `common`: Core logic, platform-agnostic. + - `react`: React hooks and components. + - `react-native`: React Native integration. + - `web`: Web-specific implementations. + - `server`: Sync and signaling server. +- `apps/` + - `web`: Documentation and website (Next.js). +- `examples/`: Sample applications demonstrating usage. -Release +## Community -- `pnpm changeset` - Describe changes for release log +- [GitHub Discussions](https://github.com/evoluhq/evolu/discussions) +- [Discord](https://discord.gg/2J8yyyyxtZ) +- [X (Twitter)](https://x.com/evoluhq) -Verify +## License -- `pnpm verify` - Run all checks (build, lint, test) before commit +MIT diff --git a/apps/relay/package.json b/apps/relay/package.json index 7f2373aaf..cb8bf211d 100644 --- a/apps/relay/package.json +++ b/apps/relay/package.json @@ -1,28 +1,31 @@ { - "name": "@evolu/relay", - "version": "2.0.8", - "private": true, - "type": "module", - "scripts": { - "dev": "bun --watch src/index.ts", - "build": "tsc", - "start": "bun dist/index.js", - "clean": "rimraf .turbo node_modules dist data/evolu-relay.db" - }, - "files": [ - "dist", - "README.md" - ], - "dependencies": { - "@evolu/common": "workspace:*", - "@evolu/nodejs": "workspace:*" - }, - "devDependencies": { - "@evolu/tsconfig": "workspace:*", - "@types/node": "^24.10.3", - "typescript": "^5.9.3" - }, - "engines": { - "node": ">=24.0.0" - } + "name": "@evolu/relay", + "version": "2.0.8", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "build": "tsc", + "start": "bun dist/index.js", + "clean": "rimraf .turbo node_modules dist data/evolu-relay.db", + "format": "biome check . --write", + "lint": "biome check ." + }, + "files": [ + "dist", + "README.md" + ], + "dependencies": { + "@evolu/common": "workspace:*", + "@evolu/nodejs": "workspace:*" + }, + "devDependencies": { + "@evolu/biome-config": "workspace:*", + "@evolu/tsconfig": "workspace:*", + "@types/node": "^24.10.8", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=24.0.0" + } } diff --git a/apps/relay/src/index.ts b/apps/relay/src/index.ts index 1ec2d331a..746151199 100644 --- a/apps/relay/src/index.ts +++ b/apps/relay/src/index.ts @@ -1,30 +1,34 @@ import { createConsole } from "@evolu/common"; import { createNodeJsRelay } from "@evolu/nodejs"; import { mkdirSync } from "fs"; +import { once } from "node:events"; // Ensure the database is created in a predictable location for Docker. mkdirSync("data", { recursive: true }); process.chdir("data"); -const relay = await createNodeJsRelay({ - console: createConsole(), -})({ - port: 4000, - enableLogging: false, +const deps = { + console: createConsole(), +}; - // Note: Relay requires URL in format ws://host:port/ - // isOwnerAllowed: (_ownerId) => true, +const relay = await createNodeJsRelay(deps)({ + port: 4000, + enableLogging: false, - isOwnerWithinQuota: (_ownerId, requiredBytes) => { - const maxBytes = 1024 * 1024; // 1MB - return requiredBytes <= maxBytes; - }, + // Note: Relay requires URL in format ws://host:port/ + // isOwnerAllowed: (_ownerId) => true, + + isOwnerWithinQuota: (_ownerId, requiredBytes) => { + const maxBytes = 1024 * 1024; // 1MB + return requiredBytes <= maxBytes; + }, }); -if (relay.ok) { - process.once("SIGINT", relay.value[Symbol.dispose]); - process.once("SIGTERM", relay.value[Symbol.dispose]); +if (!relay.ok) { + deps.console.error(relay.error); } else { - // eslint-disable-next-line no-console - console.error(relay.error); + // The `using` declaration ensures `relay.value[Symbol.dispose]()` is called + // automatically when the block exits. + using _ = relay.value; + await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } diff --git a/apps/relay/tsconfig.json b/apps/relay/tsconfig.json index 566e2104b..6c78a927f 100644 --- a/apps/relay/tsconfig.json +++ b/apps/relay/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "@evolu/tsconfig/universal-esm.json", - "compilerOptions": { - "outDir": "dist", - "module": "Node16" - }, - "include": ["src"], - "exclude": ["node_modules"] + "extends": "@evolu/tsconfig/universal-esm.json", + "compilerOptions": { + "outDir": "dist", + "module": "Node16" + }, + "include": ["src"], + "exclude": ["node_modules"] } diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5aab42796..a93007063 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -35,5 +35,4 @@ yarn-error.log* next-env.d.ts # api reference -src/app/docs/api-reference/** src/app/(docs)/docs/api-reference/** diff --git a/apps/web/mdx-components.tsx b/apps/web/mdx-components.tsx index b8a4e702f..b75dd0478 100644 --- a/apps/web/mdx-components.tsx +++ b/apps/web/mdx-components.tsx @@ -4,8 +4,8 @@ import * as mdxComponents from "@/components/mdx"; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function useMDXComponents(components: MDXComponents) { - return { - ...components, - ...mdxComponents, - }; + return { + ...components, + ...mdxComponents, + }; } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 0c578d231..09d83389c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -6,48 +6,48 @@ import { remarkPlugins } from "./src/mdx/remark.mjs"; import withSearch from "./src/mdx/search.mjs"; const withMDX = nextMDX({ - options: { - remarkPlugins, - rehypePlugins, - recmaPlugins, - }, + options: { + remarkPlugins, + rehypePlugins, + recmaPlugins, + }, }); /** @type {import("next").NextConfig} */ const nextConfig = { - pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"], - outputFileTracingIncludes: { - "/**/*": ["./src/app/**/*.mdx"], - }, - async redirects() { - return [ - { - source: "/docs/quickstart", - destination: "/docs/local-first", - permanent: true, - }, - { - source: "/docs/installation", - destination: "/docs/local-first", - permanent: true, - }, - { - source: "/docs/evolu-server", - destination: "/docs/relay", - permanent: true, - }, - { - source: "/docs/evolu-relay", - destination: "/docs/relay", - permanent: true, - }, - { - source: "/examples/:path*", - destination: "/docs/examples", - permanent: true, - }, - ]; - }, + pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"], + outputFileTracingIncludes: { + "/**/*": ["./src/app/**/*.mdx"], + }, + async redirects() { + return [ + { + source: "/docs/quickstart", + destination: "/docs/local-first", + permanent: true, + }, + { + source: "/docs/installation", + destination: "/docs/local-first", + permanent: true, + }, + { + source: "/docs/evolu-server", + destination: "/docs/relay", + permanent: true, + }, + { + source: "/docs/evolu-relay", + destination: "/docs/relay", + permanent: true, + }, + { + source: "/examples/:path*", + destination: "/docs/examples", + permanent: true, + }, + ]; + }, }; export default withSearch(withMDX(nextConfig)); diff --git a/apps/web/package.json b/apps/web/package.json index 74eeafed9..7a0bcd5b8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,64 +1,65 @@ { - "name": "web", - "version": "2.0.0", - "private": true, - "scripts": { - "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 next build --webpack", - "clean": "rimraf .turbo .next node_modules", - "dev": "next dev --webpack", - "fix:docs": "bun ./scripts/fix-api-reference.mts", - "lint": "next lint", - "start": "next start" - }, - "browserslist": "defaults, not ie <= 11", - "dependencies": { - "@algolia/autocomplete-core": "^1.19.2", - "@evolu/common": "workspace:*", - "@evolu/react": "workspace:*", - "@evolu/react-web": "workspace:*", - "@headlessui/react": "^2.2.7", - "@headlessui/tailwindcss": "^0.2.2", - "@mdx-js/loader": "^3.1.0", - "@mdx-js/react": "^3.1.0", - "@next/mdx": "^16.1.1", - "@sindresorhus/slugify": "^3.0.0", - "@tabler/icons-react": "^3.35.0", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/postcss": "^4.1.18", - "@tailwindcss/typography": "^0.5.16", - "acorn": "^8.15.0", - "clsx": "^2.1.1", - "fast-glob": "^3.3.3", - "flexsearch": "^0.8.205", - "mdast-util-to-string": "^4.0.0", - "mdx-annotations": "^0.1.4", - "motion": "^12.23.26", - "next": "^16.1.1", - "next-themes": "^0.4.6", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-highlight-words": "^0.21.0", - "remark": "^15.0.1", - "remark-gfm": "^4.0.1", - "remark-mdx": "^3.1.0", - "rss": "^1.2.2", - "shiki": "^3.19.0", - "simple-functional-loader": "^1.2.1", - "tailwindcss": "^4.1.18", - "typescript": "^5.9.3", - "unist-util-filter": "^5.0.1", - "unist-util-visit": "^5.0.0", - "zustand": "^5.0.9" - }, - "devDependencies": { - "@evolu/tsconfig": "workspace:*", - "@types/mdx": "^2.0.13", - "@types/node": "^24.10.3", - "@types/react": "^19.1.17", - "@types/react-dom": "^19.1.11", - "@types/react-highlight-words": "^0.20.1", - "@types/rss": "^0.0.32", - "cross-env": "^10.0.0", - "sharp": "^0.34.3" - } + "name": "web", + "version": "2.0.0", + "private": true, + "scripts": { + "build": "cross-env NODE_OPTIONS='--max-old-space-size-percentage=75' next build --webpack", + "clean": "rimraf .turbo .next node_modules", + "dev": "next dev --webpack", + "fix:docs": "bun ./scripts/fix-api-reference.mts", + "lint": "biome check .", + "start": "next start" + }, + "browserslist": "defaults, not ie <= 11", + "dependencies": { + "@algolia/autocomplete-core": "^1.19.2", + "@evolu/common": "workspace:*", + "@evolu/react": "workspace:*", + "@evolu/react-web": "workspace:*", + "@headlessui/react": "^2.2.7", + "@headlessui/tailwindcss": "^0.2.2", + "@mdx-js/loader": "^3.1.0", + "@mdx-js/react": "^3.1.0", + "@next/mdx": "^16.1.1", + "@sindresorhus/slugify": "^3.0.0", + "@tabler/icons-react": "^3.36.1", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/typography": "^0.5.16", + "acorn": "^8.15.0", + "clsx": "^2.1.1", + "fast-glob": "^3.3.3", + "flexsearch": "^0.8.205", + "mdast-util-to-string": "^4.0.0", + "mdx-annotations": "^0.1.4", + "motion": "^12.26.2", + "next": "^16.1.1", + "next-themes": "^0.4.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-highlight-words": "^0.21.0", + "remark": "^15.0.1", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.0", + "rss": "^1.2.2", + "shiki": "^3.21.0", + "simple-functional-loader": "^1.2.1", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "unist-util-filter": "^5.0.1", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.10" + }, + "devDependencies": { + "@evolu/biome-config": "workspace:*", + "@evolu/tsconfig": "workspace:*", + "@types/mdx": "^2.0.13", + "@types/node": "^24.10.8", + "@types/react": "~19.2.8", + "@types/react-dom": "~19.2.3", + "@types/react-highlight-words": "^0.20.1", + "@types/rss": "^0.0.32", + "cross-env": "^10.0.0", + "sharp": "^0.34.3" + } } diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js index 06f0db34f..37f8550d0 100644 --- a/apps/web/postcss.config.js +++ b/apps/web/postcss.config.js @@ -1,6 +1,6 @@ // eslint-disable-next-line no-undef module.exports = { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + "@tailwindcss/postcss": {}, + }, }; diff --git a/apps/web/scripts/fix-api-reference.mts b/apps/web/scripts/fix-api-reference.mts index 80150cf78..8bf739e4e 100644 --- a/apps/web/scripts/fix-api-reference.mts +++ b/apps/web/scripts/fix-api-reference.mts @@ -3,60 +3,100 @@ import fs from "node:fs"; import path from "node:path"; const reference = path.join( - import.meta.dirname, - "..", - "src/app/(docs)/docs/api-reference", + import.meta.dirname, + "..", + "src/app/(docs)/docs/api-reference", ); -function rearrangeMdxFilesRecursively(dir: string) { - for (const item of fs.readdirSync(dir)) { - const fullPath = path.join(dir, item); - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - rearrangeMdxFilesRecursively(fullPath); - } else if (item.endsWith(".mdx")) { - if (item !== "page.mdx") { - const baseName = path.basename(item, ".mdx"); - const newFolder = path.join(dir, baseName); - fs.mkdirSync(newFolder, { recursive: true }); - fs.renameSync(fullPath, path.join(newFolder, "page.mdx")); - fixLinksInMdxFile( - path.join(newFolder, "page.mdx"), - `${baseName} - API reference`, - ); - } else { - fixLinksInMdxFile(fullPath, "API reference"); - } - } - } -} +const rearrangeMdxFilesRecursively = (dir: string): void => { + for (const item of fs.readdirSync(dir)) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + rearrangeMdxFilesRecursively(fullPath); + } else if (item.endsWith(".mdx")) { + if (item !== "page.mdx") { + const baseName = path.basename(item, ".mdx"); + const newFolder = path.join(dir, baseName); + fs.mkdirSync(newFolder, { recursive: true }); + fs.renameSync(fullPath, path.join(newFolder, "page.mdx")); + fixMdxFile( + path.join(newFolder, "page.mdx"), + `${baseName} - API reference`, + ); + } else { + const title = + dir === reference + ? "API reference" + : `${path.basename(dir)} - API reference`; + fixMdxFile(fullPath, title); + } + } + } +}; -function fixLinksInMdxFile(filePath: string, title: string) { - const content = fs.readFileSync(filePath, "utf-8"); - // first let's replace /page.mdx with / - let newContent = content.replace(/\/page.mdx/g, ""); - newContent = newContent.replace(/\(([^)]+)\.mdx\)/g, "($1)"); +const fixMdxFile = (filePath: string, title: string): void => { + const content = fs.readFileSync(filePath, "utf-8"); + // first let's replace /page.mdx with / + let newContent = content.replace(/\/page\.mdx/g, ""); + // Remove .mdx from Markdown link destinations, preserving query/hash. + // Examples: + // - [X](/docs/Foo.mdx) -> [X](/docs/Foo) + // - [X](/docs/Foo.mdx#bar) -> [X](/docs/Foo#bar) + // - [X](../Foo.mdx?x=1#bar) -> [X](../Foo?x=1#bar) + newContent = newContent.replace(/\]\(([^)]*?)\.mdx(?=[)#?])/g, "]($1"); - // fix API reference breadcrumb link - newContent = newContent.replace( - /\[API Reference\]\([^)]*\)/g, - "[API reference](/docs/api-reference)", - ); + // fix API reference breadcrumb link and separator + // Breadcrumb is the first line starting with `[API` - replace link text and separators + newContent = newContent.replace( + /^(\[API Reference\]\([^)]*\))(.*)/m, + (_match, _apiLink, rest: string) => { + const fixedRest = rest.replace(/ \/ /g, " › "); + return `[API reference](/docs/api-reference)${fixedRest}`; + }, + ); - // Remove call signatures - newContent = newContent.replace( - /##\s*Call Signature\r?\n\s*```ts[\s\S]*?```/g, - "", - ); + // Remove redundant sections (heading + content until next heading of same or higher level) + const lines = newContent.split("\n"); + const result: Array = []; + let skipUntilLevel = 0; // 0 = not skipping, otherwise skip until heading with <= this many # - // add meta tags - newContent = `export const metadata = { title: '${title}' }; -export const sections = []; + for (const line of lines) { + const headingMatch = /^(#{2,4}) /.exec(line); + if (headingMatch) { + const level = headingMatch[1].length; + if ( + line.startsWith("## Type Parameter") || + line.startsWith("## Parameter") || + line.startsWith("## Return") || + line.startsWith("### Type Parameter") || + line.startsWith("### Parameter") || + line.startsWith("### Return") || + line.startsWith("#### Type Parameter") || + line.startsWith("#### Parameter") || + line.startsWith("#### Return") + ) { + skipUntilLevel = level; + continue; + } + if (skipUntilLevel > 0 && level <= skipUntilLevel) { + skipUntilLevel = 0; + } + } + if (skipUntilLevel === 0) result.push(line); + } + newContent = result.join("\n"); + + newContent = newContent + .replace(/^export const metadata = \{ title: [^}]*\};\s*\r?\n\s*/, "") + .replace(/^export const sections = .*;\s*\r?\n\s*/m, ""); + + newContent = `export const metadata = { title: '${title}' }; ${newContent}`; - fs.writeFileSync(filePath, newContent); -} + fs.writeFileSync(filePath, newContent); +}; // Run the script rearrangeMdxFilesRecursively(reference); diff --git a/apps/web/scripts/tsconfig.json b/apps/web/scripts/tsconfig.json index 0e25cbfd6..0b7be643a 100644 --- a/apps/web/scripts/tsconfig.json +++ b/apps/web/scripts/tsconfig.json @@ -3,9 +3,7 @@ "display": "Node 22", "_version": "22.0.0", "compilerOptions": { - "lib": [ - "es2023" - ], + "lib": ["es2023"], "module": "node16", "target": "es2022", "strict": true, @@ -13,4 +11,4 @@ "skipLibCheck": true, "moduleResolution": "node16" } -} \ No newline at end of file +} diff --git a/apps/web/src/app/(docs)/docs/conventions/page.mdx b/apps/web/src/app/(docs)/docs/conventions/page.mdx index 8c1587cc7..bb1f859e4 100644 --- a/apps/web/src/app/(docs)/docs/conventions/page.mdx +++ b/apps/web/src/app/(docs)/docs/conventions/page.mdx @@ -8,39 +8,83 @@ export const metadata = { Conventions minimize decision-making and improve consistency. -## Named imports +## Imports and exports -Use named imports. Refactor modules with excessive imports. +Use named imports. ```ts import { bar, baz } from "Foo.ts"; ``` -## Unique exported members - -Avoid namespaces. Use unique and descriptive names for exported members to prevent conflicts and improve clarity. +Avoid namespaces. Use unique names because Evolu re-exports everything through a single `index.ts`. ```ts +// Use +export const ok = ...; +export const trySync = ...; + // Avoid export const Utils = { ok, trySync }; +``` -// Prefer -export const ok = ...; -export const trySync = ...; +### Subpath exports for specialized modules + +When a module has many exports that would clash with common names or create verbose suffixed names, use [subpath exports](https://nodejs.org/api/packages.html#subpath-exports) instead: + +```ts +// Instead of long suffixed names like takeSchedule, jitterSchedule, etc. +import { take, jitter, exponential } from "@evolu/common/schedule"; +``` -// eqStrict -// eqBoolean -// orderString -// orderNumber -// isBetween -// isBetweenBigInt +This pattern is appropriate when: + +- The module is specialized (not needed by most users) +- Short names would clash with other exports +- Adding suffixes everywhere would hurt readability + +Configure in `package.json`: + +```json +{ + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + }, + "./schedule": { + "types": "./dist/src/schedule/index.d.ts", + "import": "./dist/src/schedule/index.js" + } + }, + "typesVersions": { + "*": { + "schedule": ["./dist/src/schedule/index.d.ts"] + } + } +} ``` +Use lowercase for subpath names (`schedule`, not `Schedule`) following Node.js conventions. + +### Naming conventions + +- **Types** — PascalCase without suffix: `Eq`, `Order`, `Result`, `Millis` +- **Type instances** — type prefix + TypeSuffix: `eqString`, `eqNumber`, `orderString`, `orderBigInt` +- **Operations** — verb + TypeSuffix: `mapArray`, `filterSet`, `sortArray`, `addToSet` +- **Conversions** — `xToY` (often symmetric pairs): `ownerIdToOwnerIdBytes`/`ownerIdBytesToOwnerId`, `durationToMillis` +- **Factories** — `createX`: `createTime`, `createStore`, `createRunner` +- **Empty constants** — `emptyX`: `emptyArray`, `emptySet`, `emptyRecord` +- **Predicates** — `isX`: `isNonEmptyArray`, `isBetween`, `isBetweenBigInt` +- **Accessors** — position + `InX`: `firstInArray`, `lastInArray`, `firstInSet` +- **Dependencies** — `XDep`: `TimeDep`, `RandomDep`, `ConsoleDep` + +Consistent prefixes enable discoverability—type `map` and autocomplete shows `mapArray`, `mapSet`, `mapObject`, `mapSchedule` without importing first. + ## Order (top-down readability) -Many developers naturally write code bottom-up, starting with small helpers and building up to the public API. However, Evolu optimizes for reading, not writing, because source code is read far more often than it is written. By presenting the public API first—interfaces and types—followed by the implementation and implementation details, we ensure that the developer-facing contract is immediately clear, making it easier to understand the purpose and structure of the code. +Many developers naturally write code bottom-up, starting with small helpers and building up to the public API. However, Evolu optimizes for reading, not writing, because source code is read far more often than it is written. By presenting the public API first—interfaces and types—followed by implementation and implementation details, the developer-facing contract is immediately clear. -Another way to think about it is that we approach the code from the whole to the detail, like a painter painting a picture. The painter never starts with details but with the overall layout and gradually adds details. +Think of it like painting—from the whole to the detail. The painter never starts with details, but with the overall composition, then gradually refines. ```ts // Public interface first: the contract developers rely on. @@ -64,50 +108,23 @@ const bar = () => { }; ``` -## Arrow functions +## Immutability -Use arrow functions instead of the `function` keyword. +Immutable values enable **referential transparency**: identity (`===`) implies equality. React and React Compiler rely on this for efficient rendering — `prevValue !== nextValue` detects changes without deep comparison. ```ts -// Prefer -export const createUser = (data: UserData): User => { - // implementation -}; - -// Avoid -export function createUser(data: UserData): User { - // implementation -} +// Mutable: same reference, different content +const mutableItems = [1, 2, 3]; +mutableItems.push(4); +mutableItems === mutableItems; // true, but content changed + +// Immutable: new reference signals change +const items = [1, 2, 3]; +const newItems = [...items, 4]; +items === newItems; // false ``` -Why arrow functions? - -- **No hoisting** - Combined with `const`, arrow functions aren't hoisted, which enforces top-down code organization -- **Consistency** - One way to define functions means less cognitive overhead -- **Currying** - Arrow functions make currying natural for [dependency injection](/docs/dependency-injection) - -**Exception: function overloads.** TypeScript requires the `function` keyword for overloaded signatures: - -```ts -export function mapArray( - array: NonEmptyReadonlyArray, - mapper: (item: T) => U, -): NonEmptyReadonlyArray; -export function mapArray( - array: ReadonlyArray, - mapper: (item: T) => U, -): ReadonlyArray; -export function mapArray( - array: ReadonlyArray, - mapper: (item: T) => U, -): ReadonlyArray { - return array.map(mapper) as ReadonlyArray; -} -``` - -## Immutability - -Mutable state is tricky because it increases the risk of unintended side effects, makes code harder to predict, and complicates debugging—especially in complex applications where data might be shared or modified unexpectedly. Favor immutable values using readonly types to reduce these risks and improve clarity. +Mutation causes unintended side effects, makes code harder to predict, and complicates debugging. Use immutable values with readonly types. ### Readonly types @@ -163,13 +180,11 @@ const lookup = readonly(new Map([["key", "value"]])); // Type: ReadonlyMap ``` -### Immutable helpers - -Evolu provides helpers in the [Array](/docs/api-reference/common/Array) and [Object](/docs/api-reference/common/Object) modules that do not mutate and preserve readonly types. +Evolu also provides helpers in the [Array](/docs/api-reference/common/Array) and [Object](/docs/api-reference/common/Object) modules that do not mutate and preserve readonly types. ## Interface over type -Prefer `interface` over `type` because interfaces always appear by name in error messages and tooltips. +Use `interface` over `type` because interfaces always appear by name in error messages and tooltips. Use `type` only when necessary: @@ -179,3 +194,194 @@ Use `type` only when necessary: > Use `interface` until you need to use features from `type`. > > — [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces) + +## Arrow functions + +Use arrow functions instead of the `function` keyword. + +```ts +// Use +export const createUser = (data: UserData): User => { + // implementation +}; + +// Avoid +export function createUser(data: UserData): User { + // implementation +} +``` + +Why arrow functions? + +- **No hoisting** - Combined with `const`, arrow functions aren't hoisted, which enforces top-down code organization +- **Consistency** - One way to define functions means less cognitive overhead +- **Currying** - Arrow functions make currying natural for [dependency injection](/docs/dependency-injection) + +**Exception: function overloads.** TypeScript requires the `function` keyword for overloaded signatures: + +```ts +export function mapArray( + array: NonEmptyReadonlyArray, + mapper: (item: T) => U, +): NonEmptyReadonlyArray; +export function mapArray( + array: ReadonlyArray, + mapper: (item: T) => U, +): ReadonlyArray; +export function mapArray( + array: ReadonlyArray, + mapper: (item: T) => U, +): ReadonlyArray { + return array.map(mapper) as ReadonlyArray; +} +``` + +**In interfaces too.** Use arrow function syntax for interface methods—otherwise ESLint won't allow passing them as references due to JavaScript's `this` binding issues. + +```ts +// Use arrow function syntax +interface Foo { + readonly bar: (value: string) => void; + readonly baz: () => number; +} + +// Avoid method shorthand syntax +interface FooAvoid { + bar(value: string): void; + baz(): number; +} +``` + +## Function options + +For functions with optional configuration, use inline types without `readonly` for single-use options and named interfaces with `readonly` for reusable options. Always destructure immediately. + +**Inline types** when options are single-use: + +```ts +export const race = ( + tasks: Tasks, + { + abortReason = raceLostError, + }: { + /** The reason to abort losing tasks with. */ + abortReason?: unknown; + } = {}, +): Task<...> => ... +``` + +**Named interfaces** when options are reused: + +```ts +export interface RetryOptions { + readonly maxAttempts?: number; + readonly delay?: Duration; + readonly backoff?: "linear" | "exponential"; +} + +export const retry = ( + { + maxAttempts = 3, + delay = "1s", + backoff = "exponential", + }: RetryOptions, + task: Task, +): Task<...> => ... +``` + +## Avoid getters and setters + +Avoid JavaScript getters and setters. Use simple readonly properties for stable values and explicit methods for values that may change. + +**Getters break the readonly contract.** In Evolu, `readonly` properties signal stable values you can safely cache or pass around. A getter disguised as a readonly property violates this expectation—it looks stable but might return different values on each access. + +**Setters hide mutation and conflict with readonly.** Evolu uses `readonly` properties everywhere for immutability. Setters are incompatible with this approach and make mutation invisible—`obj.value = x` looks like simple assignment but executes arbitrary code. + +**Use explicit methods instead.** When a value can change or requires computation, use a method like `getValue()`. The parentheses signal "this might change or compute something" and make the behavior obvious at the call site. A readonly property like `readonly id: string` communicates stability—you can safely cache, memoize, or pass the value around knowing it won't change behind your back. + +```ts +// Use explicit methods for mutable internal state +interface Counter { + readonly getValue: () => number; + readonly increment: () => void; +} + +// Avoid: This looks stable but if backed by a getter, value might change +interface CounterAvoid { + readonly value: number; + readonly increment: () => void; +} +``` + +## Factory functions instead of classes + +Use interfaces with factory functions instead of classes. Classes have subtle pitfalls: `this` binding is tricky and error-prone, and class inheritance encourages tight coupling. Evolu favors composition over inheritance (though interface inheritance is fine). + +```ts +// Use interface + factory function +interface Counter { + readonly getValue: () => number; + readonly increment: () => void; +} + +const createCounter = (): Counter => { + let value = 0; + return { + getValue: () => value, + increment: () => { + value++; + }, + }; +}; + +// Avoid +class Counter { + value = 0; + increment() { + this.value++; + } +} +``` + +## Opaque types + +Use opaque types when callers should not inspect or construct values directly—they can only pass them back to the API that created them. + +```ts +import { Brand } from "@evolu/common"; + +// Opaque type - callers cannot see the internal structure +type TimeoutId = Brand<"TimeoutId">; + +interface Timer { + readonly setTimeout: (fn: () => void, ms: number) => TimeoutId; + readonly clearTimeout: (id: TimeoutId) => void; +} +``` + +Opaque types are useful for: + +- **Platform abstraction** - Hide platform-specific details (e.g., `NativeMessagePort` wraps browser/Node MessagePort) +- **Handle types** - IDs that should only be passed back to the creating API (e.g., timeout IDs, file handles) +- **Type safety** - Prevent accidental misuse by making internal structure inaccessible + +## Composition without pipe + +Evolu doesn't provide a `pipe` helper. Instead, compose functions directly: + +```ts +// Direct composition +const awsRetry = jitter(1)(maxDelay("20s")(take(3)(exponential("1s")))); +``` + +If nested composition looks ugly, that's a signal we need a semantic helper—a named function that captures the intent: + +```ts +// When nesting gets unwieldy, create a semantic helper +const withAwsBackoff = (schedule: Schedule): Schedule => + jitter(1)(maxDelay("20s")(take(3)(schedule))); + +const retry = withAwsBackoff(exponential("1s")); +``` + +Evolu favors imperative code over pipes. Pipes add indirection. A well-named helper function is more discoverable and self-documenting than a chain of transformations. diff --git a/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx b/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx index d23e5df45..17e379988 100644 --- a/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx +++ b/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx @@ -12,23 +12,28 @@ Also known as "passing arguments" What is Dependency Injection? Someone once called it 'really just a pretentious way to say "taking an argument,"' and while it does involve taking or passing arguments, not every instance of that qualifies as Dependency Injection. -Some function arguments are local—say, the return value of one function passed to another—and often used just once. Others, like a database instance, are global and needed across many functions. - -Traditionally, when something must be shared across functions, we might make it global using a 'service locator,' a well-known antipattern. This approach is problematic because it creates code that’s hard to test and compose (e.g., replacing a dependency becomes difficult). +Some function arguments are local—say, the return value of one function passed to another—and often used just once. Others, like a database instance, are global and needed across many functions. Traditionally, when something must be shared across functions, we might export it from a module and let other modules import it directly: ```ts -// 🚨 Don't do that! It's a 'service locator', a well-known antipattern. -export const db = createDb("..."); +// db.ts +export const db = createDb(); + +// user.ts +import { db } from "./db"; // 🚨 Direct import creates tight coupling ``` -So, what’s the alternative? We can pass the argument manually where it's required or use a framework (an Inversion of Control container). Evolu, however, argues we don’t need a framework for that—all we need is a convention. +This turns `db` into a [service locator](https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/), a well-known anti-pattern—other modules "locate" the service by importing it. The problem? It's hard to test (you can't easily swap `db` for a mock) and hard to refactor (every module that imports `db` is tightly coupled to that specific instance). + +The alternative is to pass dependencies explicitly to where they're needed. But where do we create and wire them together? In a [Composition Root](https://blog.ploeh.dk/2011/07/28/CompositionRoot/)—a single place (typically your app's entry point) where all dependencies are instantiated and composed. From there, dependencies flow down through function arguments. + +Some frameworks use sophisticated DI Containers (Inversion of Control containers) to manage dependencies automatically. Evolu prefers [Pure DI](https://blog.ploeh.dk/2014/06/10/pure-di/)—Dependency Injection using plain, idiomatic JavaScript without a container. All we need is a convention. Imagine we have a function that does something with time: ```ts -// 🚨 Antipattern: Using global Date directly (service locator style) +// 🚨 Implicitly depends on global Date—a service we "locate" from global scope const timeUntilEvent = (eventTimestamp: number): number => { - const currentTime = Date.now(); // Implicitly depends on global Date + const currentTime = Date.now(); return eventTimestamp - currentTime; }; ``` @@ -42,9 +47,9 @@ const timeUntilEvent = (date: Date, eventTimestamp: number): number => { }; ``` -- We are mixing function dependencies (`Date`) with function arguments (`eventTimestamp`) +- We are mixing function dependencies (`date`) with function arguments (`eventTimestamp`) - Passing dependencies like that is tedious and verbose. -- We only need the current time, but we’re using the entire `Date` class (which is hard to mock). +- We only need the current time, but we're using the entire `Date` instance (which is hard to mock). We can do better. Let’s start with a simple interface: @@ -79,7 +84,10 @@ This is better, but what if we need another dependency, like a `Logger`? ```ts export interface Logger { - readonly log: (message?: any, ...optionalParams: Array) => void; + readonly log: ( + message?: unknown, + ...optionalParams: ReadonlyArray + ) => void; } ``` @@ -108,12 +116,12 @@ const timeUntilEvent = }; ``` -The previous example isn't perfect because dependencies with overlapping property names would clash. +The previous example isn't ideal because dependencies with overlapping property names would clash. And we even haven’t yet addressed creating dependencies or making them optional. Long story short, let’s look at the complete example. ## Example -The example demonstrates a simple yet robust approach to Dependency Injection (DI) in TypeScript without relying on a framework. It calculates the time remaining until a given event timestamp using a `Time` dependency, with an optional `Logger` for logging. Dependencies are defined as interfaces (`Time` and `Logger`) and wrapped in distinct types (`TimeDep` and `LoggerDep`) to avoid clashes. +The example demonstrates a simple yet robust approach to Dependency Injection (DI) in TypeScript without relying on a DI Container. It calculates the time remaining until a given event timestamp using a `Time` dependency, with an optional `Logger` for logging. Dependencies are defined as interfaces (`Time` and `Logger`) and wrapped in distinct types (`TimeDep` and `LoggerDep`) to avoid clashes. Factory functions (`createTime` and `createLogger`) instantiate these dependencies, and they’re passed as a single deps object to the `timeUntilEvent` function. The use of `Partial` makes the logger optional. @@ -127,7 +135,10 @@ export interface TimeDep { } export interface Logger { - readonly log: (message?: any, ...optionalParams: Array) => void; + readonly log: ( + message?: unknown, + ...optionalParams: ReadonlyArray + ) => void; } export interface LoggerDep { @@ -157,17 +168,17 @@ export const createLogger = (): Logger => ({ const enableLogging = true; +// Composition Root: where we wire all dependencies together const deps: TimeDep & Partial = { time: createTime(), - // Inject a dependency conditionally + // Inject the dependency conditionally ...(enableLogging && { logger: createLogger() }), }; timeUntilEvent(deps)(1742329310767); ``` -As you can see, we don't need a framework. Evolu prefers simplicity, conventions, and -explicit code. +As you can see, we don't need a framework with a DI Container (like Effect for example)—all we need is a convention. Note that passing `deps` manually isn't as verbose as you might think: @@ -180,8 +191,9 @@ export interface LoggerDep { readonly logger: Logger; } -const app = (deps: TimeDep & LoggerDep) => { - // Over-providing is OK—pass the whole `deps` object +const runApp = (deps: LoggerDep & TimeDep) => { + // Over-providing is OK—doSomethingWithTime needs only TimeDep, + // but passing the whole `deps` object is fine doSomethingWithTime(deps); doSomethingWithLogger(deps); }; @@ -190,19 +202,19 @@ const doSomethingWithTime = (deps: TimeDep) => { deps.time.now(); }; -// Over-depending is not OK—don’t require unused dependencies -const doSomethingWithLogger = (deps: TimeDep & LoggerDep) => { +// Over-depending is not OK—this function requires TimeDep but doesn't use it +const doSomethingWithLogger = (deps: LoggerDep & TimeDep) => { deps.logger.log("foo"); }; -type AppDeps = TimeDep & LoggerDep; +type AppDeps = LoggerDep & TimeDep; const appDeps: AppDeps = { - time: createTime(), logger: createLogger(), + time: createTime(), }; -app(appDeps); +runApp(appDeps); ``` Remember: @@ -220,7 +232,10 @@ export interface LoggerConfig { } export interface Logger { - readonly log: (message?: any, ...optionalParams: Array) => void; + readonly log: ( + message?: unknown, + ...optionalParams: ReadonlyArray + ) => void; } export type CreateLogger = (config: LoggerConfig) => Logger; @@ -235,38 +250,114 @@ export const createLogger: CreateLogger = (config) => ({ }, }); -type AppDeps = TimeDep & CreateLoggerDep; +type AppDeps = CreateLoggerDep & TimeDep; +// Note we pass `createLogger` as a factory, not calling it yet. +// It will be called later when LoggerConfig becomes available. const appDeps: AppDeps = { - time: createTime(), - // Note we haven't run `createLogger` yet; it will be called later. createLogger, + time: createTime(), }; -app(appDeps); +runApp(appDeps); ``` ## Guidelines - Start with an interface or type—everything can be a dependency. - To avoid clashes, wrap dependencies (`TimeDep`, `LoggerDep`). -- Write factory functions (`createTime`, `createTestTime`) +- Write factory functions (`createTime`, `createTestTime`). - Both regular functions and factory functions accept a single argument named `deps`, combining one or more dependencies (e.g., `A & B & C`). -- Sort dependencies alphabetically in ascending order when combining them. +- Sort dependencies alphabetically when combining them, and place `Partial` deps last. - Never create a global instance (e.g., `export const logger = ...`). Developers - might use it instead of proper DI, turning it into a service locator—a code - smell that’s hard to test and refactor. + Never export a global instance from a shared module (e.g., `export const + logger = createLogger()`). Other modules might import it directly instead of + receiving it through proper DI, turning it into a [service + locator](https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/)—a + pattern that's hard to test and refactor. Creating instances at module scope + is fine in the [Composition + Root](https://blog.ploeh.dk/2011/07/28/CompositionRoot/), the application's + entry point where dependencies are wired together. Btw, Evolu provides [Console](/docs/api-reference/common/Console), so you probably don't need a Logger. +## Error Handling + +Dependencies often perform operations that can fail. Use the [`Result`](/docs/api-reference/common/Result/type-aliases/Result) type to make errors explicit and type-safe. The key principle: **expose domain errors, hide implementation errors**. + +```ts +import { Result, ok, err } from "@evolu/common"; + +// Domain errors that callers care about +interface StorageFullError { + readonly type: "StorageFullError"; +} + +interface PermissionError { + readonly type: "PermissionError"; +} + +// Good: Dependency interface exposes domain errors +export interface Storage { + readonly save: ( + data: Data, + ) => Result; +} + +export interface StorageDep { + readonly storage: Storage; +} +``` + +The implementation maps internal errors to domain errors: + +```ts +// Avoid: Leaking implementation error (SqliteError) through interface +export interface Storage { + readonly save: (data: Data) => Result; +} + +// Good: Map implementation errors to domain errors +export const createStorage = (deps: SqliteDep): Storage => ({ + save: (data) => { + const result = deps.sqlite.exec(/* ... */); + if (!result.ok) { + // Map SqliteError to a domain error + if (result.error.code === "SQLITE_FULL") { + return err({ type: "StorageFullError" }); + } + return err({ type: "PermissionError" }); + } + return ok(); + }, +}); +``` + +This approach keeps your domain logic decoupled from implementation details. You can swap SQLite for IndexedDB without changing the `Storage` interface or any code that depends on it. + +### Testing Error Paths + +With typed errors, testing failure scenarios is straightforward: + +```ts +const createFailingStorage = (): Storage => ({ + save: () => err({ type: "StorageFullError" }), +}); + +test("handles storage full error", () => { + const deps = { storage: createFailingStorage() }; + const result = saveUserData(deps)(userData); + expect(result).toEqual(err({ type: "StorageFullError" })); +}); +``` + ## Testing -Avoiding global state makes testing and composition easier. Here’s an example with mocked dependencies: +Avoiding global state makes testing and composition easier. Here's an example with mocked dependencies: ```ts const createTestTime = (): Time => ({ @@ -275,10 +366,12 @@ const createTestTime = (): Time => ({ test("timeUntilEvent calculates correctly", () => { const deps = { time: createTestTime() }; - expect(timeUntilEvent(deps)(1234567990)).toBe(1000); + expect(timeUntilEvent(deps)(1234568890)).toBe(1000); }); ``` +Evolu provides [`createTestTime`](/docs/api-reference/common/Time/functions/createTestTime) out of the box—a `Time` implementation that returns monotonically increasing values, useful for tests that need predictable, ordered timestamps. + ## Tips ### Merging Deps @@ -292,12 +385,24 @@ const appDeps: AppDeps = { }; ``` +### Optional Deps + +Use `Partial` and conditional spreading to make deps optional: + +```ts +const deps: TimeDep & Partial = { + time: createTime(), + // Inject the dependency conditionally + ...(enableLogging && { logger: createLogger() }), +}; +``` + ### Refining Deps To reuse existing deps while swapping specific parts, use `Omit`. For example, if `AppDeps` includes `CreateSqliteDriverDep` and other deps, but you want to replace `CreateSqliteDriverDep` with `SqliteDep`: ```ts -export type AppDeps = CreateSqliteDriverDep & TimeDep & LoggerDep; +export type AppDeps = CreateSqliteDriverDep & LoggerDep & TimeDep; export type AppInstanceDeps = Omit & SqliteDep; @@ -312,21 +417,9 @@ export type TimeOnlyDeps = Omit< >; ``` -### Optional Deps - -Use `Partial` and conditional spreading to make deps optional: - -```ts -const deps: TimeDep & Partial = { - time: createTime(), - // Inject logger only if enabled - ...(enableLogging && { logger: createLogger() }), -}; -``` - ### Handling Clashes -When combining deps with `&` (e.g., `TimeDep & LoggerDep`), property clashes are rare but possible. The fix is simple—use distinct wrappers: +When combining deps with `&` (e.g., `LoggerDep & TimeDep`), property clashes are rare but possible. The fix is simple—use distinct wrappers: ```ts export interface LoggerADep { @@ -340,60 +433,61 @@ export interface LoggerBDep { ## FAQ -**Do I have to pass everything as a dependency?** - -No, not at all! Dependency Injection is about managing things that interact with the outside world—like time (`Date`), logging (`console`), or databases—because these are tricky to test or swap out. Regular function arguments, like a number or a string, don’t need to be dependencies unless they represent something external. +**What qualifies as a dependency?** -Think of your app as having a `composition root`: a central place where you "wire up" all your dependencies and pass them to the functions that need them. This is typically at the top level of your app. From there, you pass the `deps` object down to your functions, but not every argument needs to be part of it. - -For example: +A dependency is anything that interacts with the outside world—like time (`Date`), logging (`console`), databases—or holds shared state, like [`Ref`](/docs/api-reference/common/Ref) and [`Store`](/docs/api-reference/common/Store). Regular function arguments are not dependencies because they are immutable—now you know why Evolu [recommends readonly objects](/docs/conventions#immutability). ```ts -// Composition root (e.g., main.ts) -const deps = { - time: createTime(), - logger: createLogger(), -}; - -// A function with a dependency and a regular argument -const timeUntilEvent = - (deps: TimeDep) => - (eventTimestamp: number): number => { - return eventTimestamp - deps.time.now(); - }; +interface CounterRefDep { + readonly counterRef: Ref; +} -// Usage -const result = timeUntilEvent(deps)(1742329310767); +const increment = (deps: CounterRefDep) => { + deps.counterRef.modify((n) => n + 1); +}; ``` -- `eventTimestamp` is just a number—it's not a dependency because it’s local to the function’s logic. -- `time` is a dependency because it interacts with the outside world (`Date.now()`). - -**Key takeaway**: Use dependencies for external interactions (I/O, side effects) and keep regular arguments for pure, local data. At the composition root, assemble your `deps` object once and pass it where needed—over-providing is fine, as shown in the [Example](#example) section. + + Before reaching for DI, consider if you can restructure your code as an + [impure/pure/impure + sandwich](https://blog.ploeh.dk/2017/02/02/dependency-rejection/)—gather + impure data first, pass it to pure functions, then perform impure effects with + the result. This often eliminates the need for dependencies entirely. + **Why shouldn't dependencies use generic arguments?** -Dependencies must not use generic type parameters because it tightly couples function signatures to specific implementations and leaks implementation details into business logic. This reduces flexibility and composability. +Dependencies must not use generic type parameters—that would leak implementation details into domain logic and tightly couple consumers to specific implementations. -- **Decoupling:** By avoiding generics in dependencies, code remains agnostic to the underlying implementation (e.g., SQLite, IndexedDB, in-memory, etc.). -- **Simplicity:** Consumers of the API must not know about implementation-specific types. -- **Testability:** It is easy to swap or mock dependencies in tests without worrying about matching generic parameters. +```ts +// Avoid: Generic parameter leaks implementation detail +interface Storage { + query: (sql: string) => ReadonlyArray; +} -**Example:** +// Now every function using Storage must know about Row +const getUsers = (deps: { storage: Storage }) => + deps.storage.query("SELECT * FROM users"); +``` -```ts -// ✅ Good: Result with business/domain error -export type BusinessError = { type: "NotFound" } | { type: "PermissionDenied" }; +The problem: `UserRow` might be SQLite-specific. If you switch to IndexedDB, the row shape could differ, breaking all code that depends on `Storage`. -export interface UserService { - getUser: (id: UserId) => Result; +```ts +// Good: No generic, implementation hidden +interface Storage { + getUsers: () => Result, StorageError>; } -// 🚫 Not recommended: Result with implementation error -export interface Storage { - writeMessages: (...) => Result; // Avoid this! -} +// Consumer doesn't know or care how data is stored +const getUsers = (deps: StorageDep) => deps.storage.getUsers(); ``` -**Summary:** -Use `Result` for business/domain errors, but keep implementation errors internal to the dependency implementation. +By hiding the generic, the `Storage` interface becomes implementation-agnostic. You can swap SQLite for IndexedDB without changing any code that depends on `Storage`. + +**Key points:** + +- **Decoupling:** Code remains agnostic to underlying implementation (SQLite, IndexedDB, in-memory, etc.) +- **Simplicity:** Consumers don't need to know implementation-specific types +- **Testability:** Easy to mock without matching generic parameters + +See also [Error Handling](#error-handling) for related guidance on hiding implementation errors. diff --git a/apps/web/src/app/(docs)/docs/library/page.mdx b/apps/web/src/app/(docs)/docs/library/page.mdx index 75e0e120b..44a3e157a 100644 --- a/apps/web/src/app/(docs)/docs/library/page.mdx +++ b/apps/web/src/app/(docs)/docs/library/page.mdx @@ -6,7 +6,7 @@ export const metadata = { # Get started with the library -This guide will get you up and running with Evolu Library. +This guide will help you get started with the Evolu Library. Requirements: `TypeScript 5.7` or later with the `strict` flag enabled in @@ -21,28 +21,36 @@ npm install @evolu/common ## Learning path -We recommend learning Evolu Library in this order: +We recommend learning the Evolu Library in this order: -### 1. Result – Error handling +### 1. Array -Start with [`Result`](/docs/api-reference/common/Result/type-aliases/Result), which provides a type-safe way to handle errors without exceptions. It's the foundation for composable error handling throughout Evolu. +Start with [`Array`](/docs/api-reference/common/Array), which provides helpers for improved type-safety and developer experience. -### 2. Task – Asynchronous operations +### 2. Result -Learn [`Task`](/docs/api-reference/common/Task/interfaces/Task), which represents asynchronous computations in a lazy, composable way. +Learn [`Result`](/docs/api-reference/common/Result/type-aliases/Result), a type-safe way to handle errors. It's the foundation for composable error handling. -### 3. Type – Runtime validation +### 3. Dependency injection -Understand the [`Type`](/docs/api-reference/common/Type) system for runtime validation and parsing. This enables you to enforce constraints at compile-time and validate untrusted data at runtime. +Explore [dependency injection](/docs/dependency-injection), the pattern Evolu uses to swap implementations and simplify testing. -### 4. Dependency injection +### 4. Resource management -Explore the [dependency injection pattern](/docs/dependency-injection) used throughout Evolu for decoupled, testable code. +See [resource management](/docs/resource-management) — `using`, `DisposableStack`, and how it integrates with `Result`. -### 5. Conventions +### 5. Task + +Continue with [`Task`](/docs/api-reference/common/Task/type-aliases/Task) for JavaScript-native structured concurrency (promises that can be aborted, monitored, and more). + +### 6. Type + +Check the [`Type`](/docs/api-reference/common/Type) to enforce constraints at compile-time and validate data at runtime. + +### 7. Conventions Review the [Evolu conventions](/docs/conventions) to understand the codebase style and patterns. ## Exploring the API -After understanding the core concepts, explore the full API in the [API reference](/docs/api-reference/common). All code is commented and test files are written to be read as examples—they demonstrate practical usage patterns and edge cases. +After understanding the core concepts, explore the full API in the [API reference](/docs/api-reference/common). All code is commented, and tests are written to be read as examples—they demonstrate practical usage patterns and edge cases. diff --git a/apps/web/src/app/(docs)/docs/local-first/page.mdx b/apps/web/src/app/(docs)/docs/local-first/page.mdx index 5d68d4044..5a0085ba4 100644 --- a/apps/web/src/app/(docs)/docs/local-first/page.mdx +++ b/apps/web/src/app/(docs)/docs/local-first/page.mdx @@ -4,7 +4,7 @@ export const metadata = { # Get started with local-first -This guide will get you all set up and ready to use Evolu. +This guide will help you get started with the Evolu local-first platform. diff --git a/apps/web/src/app/(docs)/docs/page.mdx b/apps/web/src/app/(docs)/docs/page.mdx index 1f001fa4f..7bcf034e9 100644 --- a/apps/web/src/app/(docs)/docs/page.mdx +++ b/apps/web/src/app/(docs)/docs/page.mdx @@ -4,13 +4,15 @@ export const metadata = { "Learn how to use Evolu, whether you're building with the library or the local-first platform.", }; +export const sections = []; + # Documentation Evolu is both a **TypeScript library** and a **local-first platform**. Choose your path below. ## TypeScript library -For anyone who wants to write TypeScript code that scales. Built on proven design patterns like Result, dependency injection, immutability, and more. Created by someone who spent years with functional programming, but then decided to go back to the simple and idiomatic TypeScript code—no pipes, no black-box abstractions, no unreadable stacktraces. +For anyone who wants to write TypeScript code that scales. Built on proven design patterns like **Result**, **dependency injection**, **structured concurrency**, **immutability** and more. Created by someone who spent years with functional programming, but then [decided to go back](http://localhost:3000/blog/scaling-local-first-software#rewriting-evolu-fp-ts-effect-evolu-library) to the simple and idiomatic TypeScript code—no pipes, no black-box abstractions, no unreadable stacktraces. [**Get started with the library** →](/docs/library) diff --git a/apps/web/src/app/(docs)/docs/privacy/page.mdx b/apps/web/src/app/(docs)/docs/privacy/page.mdx index a96f77429..6e080efe5 100644 --- a/apps/web/src/app/(docs)/docs/privacy/page.mdx +++ b/apps/web/src/app/(docs)/docs/privacy/page.mdx @@ -3,8 +3,6 @@ export const metadata = { description: "Understand how Evolu protects your data and ensures privacy.", }; -export const sections = []; - # Privacy Privacy is fundamental to local-first software, and Evolu takes it seriously. Unlike traditional client-server applications where data lives on someone else's servers, Evolu ensures that data remains under the user's complete control while providing the synchronization and backup benefits needed. @@ -43,7 +41,7 @@ The Evolu Relay is completely blind to user data. What the relay sees: The relay functions purely as a message buffer for synchronization and backup—it stores and forwards encrypted messages without any ability to decrypt, analyze, or understand them. -## Timestamp metadata & activity privacy +## Timestamp metadata Relays and collaborators can see timestamps (user activity). This does not increase risk compared to any real‑time messaging system where traffic timing is observable. @@ -62,12 +60,8 @@ If maximum privacy is required (e.g., hiding interaction cadence), an applicatio ## Post-quantum resistance -### Evolu Relay - The Evolu Relay is post-quantum safe, so "harvest now, decrypt later" attacks (where adversaries collect encrypted data today to decrypt with future quantum computers) are not possible. Unlike public-key cryptography systems that use asymmetric encryption (which quantum computers could potentially break), the relay uses only symmetric encryption. The Evolu Relay never sees or stores public keys—it only handles symmetrically encrypted data. Symmetric encryption algorithms are considered quantum-safe. -### Collaboration - For collaboration, asymmetric cryptography is required, and asymmetric cryptography can be vulnerable to quantum attacks. Detailed documentation will be provided soon. diff --git a/apps/web/src/app/(docs)/docs/relay/page.mdx b/apps/web/src/app/(docs)/docs/relay/page.mdx index 6a447f365..6891f89f7 100644 --- a/apps/web/src/app/(docs)/docs/relay/page.mdx +++ b/apps/web/src/app/(docs)/docs/relay/page.mdx @@ -3,8 +3,6 @@ export const metadata = { description: "Learn Evolu Relay", }; -export const sections = []; - # Evolu Relay Evolu Relay provides sync and backup for Evolu apps. Evolu apps can use multiple relays simultaneously. For resilience, it's recommended to use two relays: a fast primary "home/company" relay (on‑prem or close to users) and a geographically distant secondary relay if the primary relay fails (hardware failure, network issues, etc.). diff --git a/apps/web/src/app/(docs)/docs/resource-management/page.mdx b/apps/web/src/app/(docs)/docs/resource-management/page.mdx new file mode 100644 index 000000000..6b83d4331 --- /dev/null +++ b/apps/web/src/app/(docs)/docs/resource-management/page.mdx @@ -0,0 +1,204 @@ +export const metadata = { + title: "Resource Management", +}; + +# Resource Management + +For automatic cleanup of resources + +## The problem + +Resources like database connections, file handles, and locks need cleanup. Traditional approaches are error-prone: + +```ts +// 🚨 Manual cleanup is easy to forget +const conn = openConnection(); +doWork(conn); +conn.close(); // What if doWork throws? +``` + +```ts +// 🚨 try/finally is verbose and doesn't compose +const conn = openConnection(); +try { + doWork(conn); +} finally { + conn.close(); +} +``` + +## The solution: `using` + +The [`using`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using) declaration automatically disposes resources when they go out of scope: + +```ts +const process = () => { + using conn = openConnection(); + doWork(conn); +}; // conn is automatically disposed here +``` + +This works even if `doWork` throws—disposal is guaranteed. + +## Disposable resources + +A resource is disposable if it has a `[Symbol.dispose]` method: + +```ts +interface Disposable { + [Symbol.dispose](): void; +} +``` + +For async cleanup, use `[Symbol.asyncDispose]` with `await using`: + +```ts +interface AsyncDisposable { + [Symbol.asyncDispose](): PromiseLike; +} +``` + +## Block scopes + +Use block scopes to control exactly when resources are disposed: + +```ts +const createLock = (name: string): Disposable => ({ + [Symbol.dispose]: () => { + console.log(`unlock:${name}`); + }, +}); + +const process = () => { + console.log("start"); + + { + using lock = createLock("a"); + console.log("critical-section-a"); + } // lock "a" released here + + console.log("between"); + + { + using lock = createLock("b"); + console.log("critical-section-b"); + } // lock "b" released here + + console.log("end"); +}; + +// Output: +// "start" +// "critical-section-a" +// "unlock:a" +// "between" +// "critical-section-b" +// "unlock:b" +// "end" +``` + +## Combining with Result + +`Result` and `Disposable` are orthogonal: + +- **Result** answers: "Did the operation succeed?" +- **Disposable** answers: "When do we clean up resources?" + +Early returns from `Result` checks don't bypass `using`—disposal is guaranteed on any exit path (see below). + +## DisposableStack + +When acquiring multiple resources, use [`DisposableStack`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DisposableStack) to ensure all are cleaned up: + +```ts +const processResources = (): Result => { + using stack = new DisposableStack(); + + const db = createResource("db"); + if (!db.ok) return db; // stack disposes nothing yet + + stack.use(db.value); + + const file = createResource("file"); + if (!file.ok) return file; // stack disposes db + + stack.use(file.value); + + return ok("processed"); +}; // stack disposes file, then db (reverse order) +``` + +The pattern is simple: + +1. Create a `DisposableStack` with `using` +2. Try to create a resource (returns `Result`) +3. If failed, return early—stack disposes what's been acquired +4. If succeeded, add to stack with `stack.use()` +5. Repeat for additional resources + +For async resources, use [`AsyncDisposableStack`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack) with `await using`. + +Key methods: + +- `stack.use(resource)` — adds a disposable resource +- `stack.defer(fn)` — adds a cleanup function (like Go's `defer`) +- `stack.adopt(value, cleanup)` — wraps a non-disposable value with cleanup +- `stack.move()` — transfers ownership to caller + +### The use-and-move pattern + +When a factory function creates resources for use elsewhere, use `move()` to transfer ownership: + +```ts +interface OpenFiles extends Disposable { + readonly handles: ReadonlyArray; +} + +const openFiles = ( + paths: ReadonlyArray, +): Result => { + using stack = new DisposableStack(); + + const handles: Array = []; + for (const path of paths) { + const file = open(path); + if (!file.ok) return file; // Error: stack cleans up opened files + + stack.use(file.value); + handles.push(file.value); + } + + // Success: transfer ownership to caller + const cleanup = stack.move(); + return ok({ + handles, + [Symbol.dispose]: () => cleanup.dispose(), + }); +}; + +const processFiles = (): Result => { + const result = openFiles(["a.txt", "b.txt", "c.txt"]); + if (!result.ok) return result; + + using files = result.value; + + // ... use files.handles ... + + return ok(); +}; // files cleaned up here +``` + +Without `move()`, the stack would dispose files when `openFiles` returns—even on success. + +## Ready to use + +Evolu [polyfills](/docs/api-reference/common/Polyfills#resource-management) `Symbol.dispose`, `Symbol.asyncDispose`, `DisposableStack`, and `AsyncDisposableStack` in environments without native support (for example, Safari). + +## Learn more + +- [MDN: Resource management](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management) +- [MDN: using statement](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using) +- [MDN: DisposableStack](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DisposableStack) +- [MDN: AsyncDisposableStack](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack) +- [`Result.test.ts`](https://github.com/evoluhq/evolu/blob/main/packages/common/test/Result.test.ts) for comprehensive usage patterns +- [Resources](/docs/api-reference/common/Resources) for reference-counted shared resources with delayed disposal diff --git a/apps/web/src/app/(docs)/layout.tsx b/apps/web/src/app/(docs)/layout.tsx index 8e1271ca5..d9393898d 100644 --- a/apps/web/src/app/(docs)/layout.tsx +++ b/apps/web/src/app/(docs)/layout.tsx @@ -1,37 +1,44 @@ -import glob from "fast-glob"; +// import glob from "fast-glob"; import { type Metadata } from "next"; import { Providers } from "@/app/providers"; import { Layout } from "@/components/Layout"; -import { type Section } from "@/components/SectionProvider"; +// import { type Section } from "@/components/SectionProvider"; import "@/styles/tailwind.css"; export const metadata: Metadata = { - title: { - template: "%s - Evolu", - default: "TypeScript library and local-first platform", - }, + title: { + template: "%s - Evolu", + default: "TypeScript library and local-first platform", + }, }; +// eslint-disable-next-line @typescript-eslint/require-await export default async function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): Promise { - const pages = await glob("**/*.mdx", { cwd: "src/app/(docs)" }); - const allSectionsEntries = (await Promise.all( - pages.map(async (filename) => [ - "/" + filename.replace(/(^|\/)page\.mdx$/, ""), - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (await import(`./${filename}`)).sections, - ]), - )) as Array<[string, Array
]>; - const allSections = Object.fromEntries(allSectionsEntries); + // Dev note: Do not re-enable glob + importing every MDX file here; + // it makes build and hot reload slow. - return ( - - {children} - - ); + // const pages = await glob("**/*.mdx", { cwd: "src/app/(docs)" }); + // const allSectionsEntries = (await Promise.all( + // pages.map(async (filename) => [ + // "/" + filename.replace(/(^|\/)page\.mdx$/, ""), + // // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // ( + // await import(`./${filename}`) + // ).sections, + // ]), + // )) as Array<[string, Array
]>; + // const allSections = Object.fromEntries(allSectionsEntries); + const allSections = {}; + + return ( + + {children} + + ); } diff --git a/apps/web/src/app/(landing)/blog/page.tsx b/apps/web/src/app/(landing)/blog/page.tsx index 690a19b1e..1ff3991ef 100644 --- a/apps/web/src/app/(landing)/blog/page.tsx +++ b/apps/web/src/app/(landing)/blog/page.tsx @@ -8,61 +8,61 @@ import { type ArticleWithSlug, getAllArticles } from "@/lib/blog"; import { formatDate } from "@/lib/formatDate"; function Article({ article }: { article: ArticleWithSlug }) { - return ( -
- - {article.title} - - {formatDate(article.date)} - - {article.description} - Read article - - - {formatDate(article.date)} - -
- ); + return ( +
+ + {article.title} + + {formatDate(article.date)} + + {article.description} + Read article + + + {formatDate(article.date)} + +
+ ); } export const metadata: Metadata = { - title: "Blog", - description: "Restore data ownership", + title: "Blog", + description: "Restore data ownership", }; export default async function ArticlesIndex(): Promise { - const articles = await getAllArticles(); + const articles = await getAllArticles(); - return ( - -
-
- {articles.map((article) => ( -
- ))} -
-
-
-
- - - RSS Feed - -
-
-
- ); + return ( + +
+
+ {articles.map((article) => ( +
+ ))} +
+
+
+
+ + + RSS Feed + +
+
+
+ ); } diff --git a/apps/web/src/app/(landing)/blog/rss.xml/route.ts b/apps/web/src/app/(landing)/blog/rss.xml/route.ts index c2dd0be6f..46badc454 100644 --- a/apps/web/src/app/(landing)/blog/rss.xml/route.ts +++ b/apps/web/src/app/(landing)/blog/rss.xml/route.ts @@ -2,47 +2,47 @@ import { type ArticleWithSlug, getAllArticles } from "@/lib/blog"; import RSS from "rss"; function getSiteUrl(request: Request): string { - if (process.env.NODE_ENV === "production") { - return "https://www.evolu.dev"; - } + if (process.env.NODE_ENV === "production") { + return "https://www.evolu.dev"; + } - const url = new URL(request.url); - return `${url.protocol}//${url.host}`; + const url = new URL(request.url); + return `${url.protocol}//${url.host}`; } export async function GET(request: Request): Promise { - const articles = await getAllArticles(); - const currentSiteUrl = getSiteUrl(request); + const articles = await getAllArticles(); + const currentSiteUrl = getSiteUrl(request); - const feed = new RSS({ - title: "Evolu Blog", - description: "Restore data ownership", - feed_url: `${currentSiteUrl}/blog/rss.xml`, - site_url: currentSiteUrl, - language: "en", - pubDate: new Date().toISOString(), - copyright: `© ${new Date().getFullYear()} Evolu`, - docs: "https://validator.w3.org/feed/docs/rss2.html", - ttl: 60, - }); + const feed = new RSS({ + title: "Evolu Blog", + description: "Restore data ownership", + feed_url: `${currentSiteUrl}/blog/rss.xml`, + site_url: currentSiteUrl, + language: "en", + pubDate: new Date().toISOString(), + copyright: `© ${new Date().getFullYear()} Evolu`, + docs: "https://validator.w3.org/feed/docs/rss2.html", + ttl: 60, + }); - articles.forEach((article: ArticleWithSlug) => { - feed.item({ - title: article.title, - description: article.description, - url: `${currentSiteUrl}/blog/${article.slug}`, - guid: `${currentSiteUrl}/blog/${article.slug}`, - date: new Date(article.date), - author: article.author, - }); - }); + articles.forEach((article: ArticleWithSlug) => { + feed.item({ + title: article.title, + description: article.description, + url: `${currentSiteUrl}/blog/${article.slug}`, + guid: `${currentSiteUrl}/blog/${article.slug}`, + date: new Date(article.date), + author: article.author, + }); + }); - return new Response(feed.xml(), { - headers: { - "Content-Type": "application/rss+xml; charset=utf-8", - "Cache-Control": "public, max-age=3600, s-maxage=3600", - }, - }); + return new Response(feed.xml(), { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }); } export const dynamic = "force-static"; diff --git a/apps/web/src/app/(landing)/blog/the-copy-paste-typescript-standard-library/page.mdx b/apps/web/src/app/(landing)/blog/the-copy-paste-typescript-standard-library/page.mdx index dc117eac5..dd987160d 100644 --- a/apps/web/src/app/(landing)/blog/the-copy-paste-typescript-standard-library/page.mdx +++ b/apps/web/src/app/(landing)/blog/the-copy-paste-typescript-standard-library/page.mdx @@ -88,7 +88,7 @@ It’s half a joke and half the truth. Programmers should understand the code th - [Brand](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Brand.ts) (prevents mixing incompatible values, e.g., `type UserId = string & Brand<"UserId">`) - [Assert](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Assert.ts) (fail‑fast helpers: `assert`, `assertNonEmptyArray`) - [Array](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Array.ts) (non‑empty arrays and helpers: `NonEmptyArray`, `isNonEmptyArray`, `appendToArray`) -- [Function](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Function.ts) (small function utils: `exhaustiveCheck`, `identity`, `LazyValue`) +- [Function](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Function.ts) (small function utils: `exhaustiveCheck`, `identity`, `Lazy`) - [Object](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Object.ts) (object helpers: `isPlainObject`, `mapObject`, `objectToEntries`, `excludeProp`) - [Order](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Order.ts) (ordering utilities: `orderNumber`, `orderString`, `reverseOrder`, `orderUint8Array`) - [Time](https://github.com/evoluhq/evolu/blob/main/packages/common/src/Time.ts) (DI‑friendly time: `Time`, `createTime`, `createTestTime`) diff --git a/apps/web/src/app/(landing)/blog/you-might-not-need-comlink/page.mdx b/apps/web/src/app/(landing)/blog/you-might-not-need-comlink/page.mdx new file mode 100644 index 000000000..aaa2b5f1e --- /dev/null +++ b/apps/web/src/app/(landing)/blog/you-might-not-need-comlink/page.mdx @@ -0,0 +1,108 @@ +import { ArticleLayout } from "@/components/ArticleLayout"; + +export const article = { + author: "Daniel Steigerwald", + date: "2025-12-09", + title: "You might not need Comlink", + description: + "Why MessageChannel with simple callbacks often beats Comlink's Proxy-based RPC for Web Workers and SharedWorkers.", +}; + +export const metadata = { + title: article.title, + description: article.description, +}; + +export default (props) => ; + +> **Note:** This article is a draft. Examples from the Evolu codebase are coming soon. + +[Comlink](https://github.com/GoogleChromeLabs/comlink) is a popular library (12.5k stars) that makes Web Workers feel like calling async functions. It's well-designed and tiny (~1.1kB). Many projects use it successfully. + +But after evaluating it for [Evolu](https://evolu.dev), I decided to use plain web APIs instead. Here's why. + +## What Comlink does well + +Comlink wraps `postMessage` with ES6 Proxy, so instead of: + +```ts +worker.postMessage({ type: "query", sql: "SELECT * FROM users" }); +worker.onmessage = (e) => handleResult(e.data); +``` + +You write: + +```ts +const result = await workerProxy.query("SELECT * FROM users"); +``` + +It also provides `Comlink.proxy()` for callbacks, `Comlink.transfer()` for transferables, and supports SharedWorker, iframes, and Node's worker_threads. + +## The Proxy abstraction leaks + +### Debugging is harder + +When you `console.log` a Comlink proxy, you don't see the actual object - you see a Proxy. Setting breakpoints and inspecting state requires understanding what's happening under the hood. + +### Performance overhead + +While Proxy performance is fast enough for most use cases, issue [#647](https://github.com/GoogleChromeLabs/comlink/issues/647) showed real bottlenecks under load. Proxies must also be manually released with `proxy[Comlink.releaseProxy]()`, otherwise they leak memory. Comlink uses WeakRef for automatic cleanup, but it doesn't work reliably with SharedWorkers (issue [#673](https://github.com/GoogleChromeLabs/comlink/issues/673)). + +## The alternative: plain web APIs + +Instead of Comlink's Proxy abstraction, Evolu uses [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel) and a simple [`Callbacks`](/docs/api-reference/common/Callbacks) utility for request-response correlation: + +```ts +interface Callbacks { + register: (callback: (arg: T) => void) => CallbackId; + execute: (id: CallbackId, arg: T) => void; +} +``` + +Combined with `Promise.withResolvers`, you get clean RPC: + +```ts +const callbacks = createCallbacks(deps); + +const query = async (sql: string): Promise => { + const { promise, resolve } = Promise.withResolvers(); + const callbackId = callbacks.register(resolve); + queryPort.postMessage({ sql, callbackId }); + return promise; +}; + +// When response arrives: +queryPort.onmessage = (e) => { + callbacks.execute(e.data.callbackId, e.data.result); +}; +``` + +No Proxy, no WeakRef finalization registry, no magic. Just a Map of pending callbacks. It works with any transport - postMessage, WebSocket, whatever. + +TODO: More examples from Evolu codebase showing MessageChannel patterns: + +- init message handshake +- HeartBeat for connection monitoring +- different topologies (notify selected tabs, broadcast to all, etc.) +- helpers + +## The maintenance situation + +Comlink has 76 open issues and 40 open PRs - some from 2022, waiting 3+ years. The maintainers [are too busy](https://github.com/GoogleChromeLabs/comlink/pull/678#issuecomment-2982151350) and looking for help. Even merged PRs [don't get released to npm](https://github.com/GoogleChromeLabs/comlink/pull/678#issuecomment-3207623025). There's a PR [#683](https://github.com/GoogleChromeLabs/comlink/pull/683) that fixes many issues, but it has significant breaking changes and has been blocked on CLA since October 2024. + +## Conclusion + +Comlink is a good library for simple cases - offloading computation to a worker with straightforward request-response patterns. + +But if you need: + +- Explicit initialization handshakes +- SharedWorker with multiple tabs +- Reliable cleanup +- Easy debugging +- Full control over message flow +- Different topologies (broadcast to all tabs, notify selected tabs, etc.) + +...consider using plain web APIs with simple helpers. `MessageChannel` gives you typed, independent pipes. A callbacks Map gives you request-response correlation. It's more code upfront, but it's code you understand and control. + +Sometimes the "boring" approach is the right one. diff --git a/apps/web/src/app/(landing)/layout.tsx b/apps/web/src/app/(landing)/layout.tsx index 6474b2921..b418a677d 100644 --- a/apps/web/src/app/(landing)/layout.tsx +++ b/apps/web/src/app/(landing)/layout.tsx @@ -6,27 +6,27 @@ import { Header } from "@/components/Header"; import "@/styles/tailwind.css"; export const metadata: Metadata = { - title: { - template: "%s - Evolu", - default: "TypeScript library and local-first platform", - }, + title: { + template: "%s - Evolu", + default: "TypeScript library and local-first platform", + }, }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): React.ReactElement { - return ( - -
-
+ return ( + +
+
-
-
{children}
-
-
-
-
- ); +
+
{children}
+
+
+
+
+ ); } diff --git a/apps/web/src/app/(landing)/page.tsx b/apps/web/src/app/(landing)/page.tsx index 239ceec1d..0c3e73cf2 100644 --- a/apps/web/src/app/(landing)/page.tsx +++ b/apps/web/src/app/(landing)/page.tsx @@ -4,30 +4,42 @@ import { Logo } from "@/components/Logo"; import { Metadata } from "next"; export const metadata: Metadata = { - title: "Evolu", - description: "TypeScript library and local-first platform", + title: "Evolu", + description: "TypeScript library and local-first platform", }; export default function Page(): React.ReactElement { - return ( - <> -
- -

- TypeScript library and local‑first platform -

-
- -
- - -
- - ); + return ( + <> +
+ +

+ TypeScript library and local‑first platform +

+
+ +
+ +

+ Own your apps and data. +
+ Work offline, sync online. +
+ No vendor lock‑in. + * +

+

+ *Of course, SQLite and Evolu are kind of lock‑in, but + replaceable because SQL is standard, and Evolu is just a thin layer on + standard APIs. +

+
+ + ); } diff --git a/apps/web/src/app/(llms)/layout.tsx b/apps/web/src/app/(llms)/layout.tsx index 1ee5100f8..ab3be3e95 100644 --- a/apps/web/src/app/(llms)/layout.tsx +++ b/apps/web/src/app/(llms)/layout.tsx @@ -1,11 +1,11 @@ export default function LLMsLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): React.ReactNode { - return ( -
-      {children}
-    
- ); + return ( +
+			{children}
+		
+ ); } diff --git a/apps/web/src/app/(llms)/llms-full.txt/page.tsx b/apps/web/src/app/(llms)/llms-full.txt/page.tsx index 0088d0b24..1669f50d1 100644 --- a/apps/web/src/app/(llms)/llms-full.txt/page.tsx +++ b/apps/web/src/app/(llms)/llms-full.txt/page.tsx @@ -2,24 +2,24 @@ import { Fragment } from "react"; import { fetchProcessedMdxPages } from "../../../lib/llms"; export default async function LLMsFullPage(): Promise { - const pages = await fetchProcessedMdxPages(true); // Pass true to include API reference + const pages = await fetchProcessedMdxPages(true); // Pass true to include API reference - return ( - <> -
-

Evolu Documentation

-
- {pages.map((page, index) => ( - -
-

{page.title}

-
{page.content}
-
-
-
- ))} -
-
- - ); + return ( + <> +
+

Evolu Documentation

+
+ {pages.map((page, index) => ( + +
+

{page.title}

+
{page.content}
+
+
+
+ ))} +
+
+ + ); } diff --git a/apps/web/src/app/(llms)/llms.txt/page.tsx b/apps/web/src/app/(llms)/llms.txt/page.tsx index 647b6140d..fae46166d 100644 --- a/apps/web/src/app/(llms)/llms.txt/page.tsx +++ b/apps/web/src/app/(llms)/llms.txt/page.tsx @@ -2,18 +2,18 @@ import { Fragment } from "react"; import { fetchProcessedMdxPages } from "../../../lib/llms"; export default async function LLMsPage(): Promise { - const pages = await fetchProcessedMdxPages(false); + const pages = await fetchProcessedMdxPages(false); - return ( - <> - # Evolu Documentation -
-
- {pages.map((page, index) => ( - - {page.content}

-
- ))} - - ); + return ( + <> + # Evolu Documentation +
+
+ {pages.map((page, index) => ( + + {page.content}

+
+ ))} + + ); } diff --git a/apps/web/src/app/(playgrounds)/layout.tsx b/apps/web/src/app/(playgrounds)/layout.tsx index 5d4dde023..8b4f99f4f 100644 --- a/apps/web/src/app/(playgrounds)/layout.tsx +++ b/apps/web/src/app/(playgrounds)/layout.tsx @@ -2,9 +2,9 @@ import "@/styles/tailwind.css"; // import "@tailwindcss/forms"; export default function PlaygroundLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }): React.ReactNode { - return children; + return children; } diff --git a/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx index fccd2921f..f4d5725ed 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx @@ -1,52 +1,53 @@ "use client"; import { - booleanToSqliteBoolean, - createEvolu, - createFormatTypeError, - FiniteNumber, - id, - idToIdBytes, - json, - kysely, - maxLength, - MaxLengthError, - MinLengthError, - Mnemonic, - NonEmptyString, - NonEmptyTrimmedString100, - nullOr, - object, - SimpleName, - SqliteBoolean, - sqliteFalse, - sqliteTrue, - timestampBytesToTimestamp, + booleanToSqliteBoolean, + createEvolu, + createFormatTypeError, + createObjectURL, + FiniteNumber, + id, + idToIdBytes, + json, + kysely, + maxLength, + MaxLengthError, + MinLengthError, + Mnemonic, + NonEmptyString, + NonEmptyTrimmedString100, + nullOr, + object, + SimpleName, + SqliteBoolean, + sqliteFalse, + sqliteTrue, + timestampBytesToTimestamp, } from "@evolu/common"; import { timestampToDateIso } from "@evolu/common/local-first"; import { - createUseEvolu, - EvoluProvider, - useQueries, - useQuery, + createUseEvolu, + EvoluProvider, + useQueries, + useQuery, } from "@evolu/react"; -import { evoluReactWebDeps } from "@evolu/react-web"; +import { createEvoluDeps } from "@evolu/react-web"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { - IconChecklist, - IconEdit, - IconHistory, - IconRestore, - IconTrash, + IconChecklist, + IconEdit, + IconHistory, + IconRestore, + IconTrash, } from "@tabler/icons-react"; import clsx from "clsx"; import { - FC, - KeyboardEvent, - startTransition, - Suspense, - use, - useState, + FC, + KeyboardEvent, + startTransition, + Suspense, + use, + useState, } from "react"; // TODO: Epochs and sharing. @@ -67,10 +68,10 @@ type NonEmptyString50 = typeof NonEmptyString50.Type; // or when the schema varies by use case. // Let's create an object to demonstrate it. const Foo = object({ - foo: NonEmptyString50, - // Did you know that JSON.stringify converts NaN (a number) into null? - // To prevent this, use FiniteNumber. - bar: FiniteNumber, + foo: NonEmptyString50, + // Did you know that JSON.stringify converts NaN (a number) into null? + // To prevent this, use FiniteNumber. + bar: FiniteNumber, }); type Foo = typeof Foo.Type; @@ -81,842 +82,843 @@ const [FooJson, fooToFooJson, fooJsonToFoo] = json(Foo, "FooJson"); type FooJson = typeof FooJson.Type; const Schema = { - project: { - id: ProjectId, - name: NonEmptyTrimmedString100, - fooJson: FooJson, - }, - todo: { - id: TodoId, - title: NonEmptyTrimmedString100, - isCompleted: nullOr(SqliteBoolean), - projectId: nullOr(ProjectId), - }, + project: { + id: ProjectId, + name: NonEmptyTrimmedString100, + fooJson: FooJson, + }, + todo: { + id: TodoId, + title: NonEmptyTrimmedString100, + isCompleted: nullOr(SqliteBoolean), + projectId: nullOr(ProjectId), + }, }; -const evolu = createEvolu(evoluReactWebDeps)(Schema, { - name: SimpleName.orThrow("full-example"), +const deps = createEvoluDeps(); - reloadUrl: "/playgrounds/full", +const evolu = createEvolu(deps)(Schema, { + name: SimpleName.orThrow("full-example"), - ...(process.env.NODE_ENV === "development" && { - transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], + // reloadUrl: "/playgrounds/full", - // Empty transports for local-only instance. - // transports: [], - }), + ...(process.env.NODE_ENV === "development" && { + transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], - // https://www.evolu.dev/docs/indexes - indexes: (create) => [ - create("todoCreatedAt").on("todo").column("createdAt"), - create("projectCreatedAt").on("project").column("createdAt"), - create("todoProjectId").on("todo").column("projectId"), - ], + // Empty transports for local-only instance. + // transports: [], + }), - enableLogging: false, + // https://www.evolu.dev/docs/indexes + indexes: (create) => [ + create("todoCreatedAt").on("todo").column("createdAt"), + create("projectCreatedAt").on("project").column("createdAt"), + create("todoProjectId").on("todo").column("projectId"), + ], + + // enableLogging: false, }); const useEvolu = createUseEvolu(evolu); evolu.subscribeError(() => { - const error = evolu.getError(); - if (!error) return; + const error = evolu.getError(); + if (!error) return; - alert("🚨 Evolu error occurred! Check the console."); - // eslint-disable-next-line no-console - console.error(error); + alert("🚨 Evolu error occurred! Check the console."); + // eslint-disable-next-line no-console + console.error(error); }); export const EvoluFullExample: FC = () => { - return ( -
-
- - - - - -
-
- ); + return ( +
+
+ + + + + +
+
+ ); }; const App: FC = () => { - const [activeTab, setActiveTab] = useState< - "home" | "projects" | "account" | "trash" - >("home"); - - const createHandleTabClick = (tab: typeof activeTab) => () => { - // startTransition prevents UI flickers when switching tabs by keeping - // the current view visible while Suspense prepares the next one - // Test: Remove startTransition, add a todo, delete it, click to Trash. - // You will see a visible blink without startTransition. - startTransition(() => { - setActiveTab(tab); - }); - }; - - return ( -
-
-
- - - - -
-
- - {activeTab === "home" && } - {activeTab === "projects" && } - {activeTab === "account" && } - {activeTab === "trash" && } -
- ); + const [activeTab, setActiveTab] = useState< + "home" | "projects" | "account" | "trash" + >("home"); + + const createHandleTabClick = (tab: typeof activeTab) => () => { + // startTransition prevents UI flickers when switching tabs by keeping + // the current view visible while Suspense prepares the next one + // Test: Remove startTransition, add a todo, delete it, click to Trash. + // You will see a visible blink without startTransition. + startTransition(() => { + setActiveTab(tab); + }); + }; + + return ( +
+
+
+ + + + +
+
+ + {activeTab === "home" && } + {activeTab === "projects" && } + {activeTab === "account" && } + {activeTab === "trash" && } +
+ ); }; const projectsWithTodosQuery = evolu.createQuery( - (db) => - db - .selectFrom("project") - .select(["id", "name"]) - // https://kysely.dev/docs/recipes/relations - .select((eb) => [ - kysely - .jsonArrayFrom( - eb - .selectFrom("todo") - .select([ - "todo.id", - "todo.title", - "todo.isCompleted", - "todo.projectId", - ]) - .whereRef("todo.projectId", "=", "project.id") - .where("todo.isDeleted", "is not", sqliteTrue) - .where("todo.title", "is not", null) - .$narrowType<{ title: kysely.NotNull }>() - .orderBy("createdAt"), - ) - .as("todos"), - ]) - .where("project.isDeleted", "is not", sqliteTrue) - .where("name", "is not", null) - .$narrowType<{ name: kysely.NotNull }>() - .orderBy("createdAt"), - { - // Log how long each query execution takes - logQueryExecutionTime: false, - - // Log the SQLite query execution plan for optimization analysis - logExplainQueryPlan: false, - }, + (db) => + db + .selectFrom("project") + .select(["id", "name"]) + // https://kysely.dev/docs/recipes/relations + .select((eb) => [ + kysely + .jsonArrayFrom( + eb + .selectFrom("todo") + .select([ + "todo.id", + "todo.title", + "todo.isCompleted", + "todo.projectId", + ]) + .whereRef("todo.projectId", "=", "project.id") + .where("todo.isDeleted", "is not", sqliteTrue) + .where("todo.title", "is not", null) + .$narrowType<{ title: kysely.NotNull }>() + .orderBy("createdAt"), + ) + .as("todos"), + ]) + .where("project.isDeleted", "is not", sqliteTrue) + .where("name", "is not", null) + .$narrowType<{ name: kysely.NotNull }>() + .orderBy("createdAt"), + { + // Log how long each query execution takes + logQueryExecutionTime: false, + + // Log the SQLite query execution plan for optimization analysis + logExplainQueryPlan: false, + }, ); type ProjectsWithTodosRow = typeof projectsWithTodosQuery.Row; const HomeTab: FC = () => { - const [projectsWithTodos, projects] = useQueries([ - projectsWithTodosQuery, - /** - * Load projects separately for better cache efficiency. Projects change - * less frequently than todos, preventing unnecessary re-renders. Multiple - * queries are fine in local-first - no network overhead. - */ - projectsQuery, - ]); - - const handleAddProjectClick = useAddProject(); - - if (projectsWithTodos.length === 0) { - return ( -
-
- -
-

- No projects yet -

-

- Create your first project to get started -

-
- ); - } - - return ( -
-
- {projectsWithTodos.map((project) => ( - - ))} -
-
- ); + const [projectsWithTodos, projects] = useQueries([ + projectsWithTodosQuery, + /** + * Load projects separately for better cache efficiency. Projects change + * less frequently than todos, preventing unnecessary re-renders. Multiple + * queries are fine in local-first - no network overhead. + */ + projectsQuery, + ]); + + const handleAddProjectClick = useAddProject(); + + if (projectsWithTodos.length === 0) { + return ( +
+
+ +
+

+ No projects yet +

+

+ Create your first project to get started +

+
+ ); + } + + return ( +
+
+ {projectsWithTodos.map((project) => ( + + ))} +
+
+ ); }; const HomeTabProject: FC<{ - project: ProjectsWithTodosRow; - todos: ProjectsWithTodosRow["todos"]; - projects: ReadonlyArray; + project: ProjectsWithTodosRow; + todos: ProjectsWithTodosRow["todos"]; + projects: ReadonlyArray; }> = ({ project, todos, projects }) => { - const { insert } = useEvolu(); - const [newTodoTitle, setNewTodoTitle] = useState(""); - - const addTodo = () => { - const result = insert( - "todo", - { - title: newTodoTitle.trim(), - projectId: project.id, - }, - { - onComplete: () => { - setNewTodoTitle(""); - }, - }, - ); - - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === "Enter") { - addTodo(); - } - }; - - return ( -
-
-

- - {project.name} -

-
- - {todos.length > 0 && ( -
    - {todos.map((todo) => ( - - ))} -
- )} - -
- { - setNewTodoTitle(e.target.value); - }} - data-1p-ignore // ignore this input from 1password, ugly hack but works - onKeyDown={handleKeyPress} - placeholder="Add a new todo..." - className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" - /> -
-
- ); + const { insert } = useEvolu(); + const [newTodoTitle, setNewTodoTitle] = useState(""); + + const addTodo = () => { + const result = insert( + "todo", + { + title: newTodoTitle.trim(), + projectId: project.id, + }, + { + onComplete: () => { + setNewTodoTitle(""); + }, + }, + ); + + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === "Enter") { + addTodo(); + } + }; + + return ( +
+
+

+ + {project.name} +

+
+ + {todos.length > 0 && ( +
    + {todos.map((todo) => ( + + ))} +
+ )} + +
+ { + setNewTodoTitle(e.target.value); + }} + data-1p-ignore // ignore this input from 1password, ugly hack but works + onKeyDown={handleKeyPress} + placeholder="Add a new todo..." + className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" + /> +
+
+ ); }; const HomeTabProjectSectionTodoItem: FC<{ - // [number] extracts the element type from the todos array - row: ProjectsWithTodosRow["todos"][number]; - projects: ReadonlyArray; + // [number] extracts the element type from the todos array + row: ProjectsWithTodosRow["todos"][number]; + projects: ReadonlyArray; }> = ({ row: { id, title, isCompleted, projectId }, projects }) => { - const { update } = useEvolu(); - - const handleToggleCompletedClick = () => { - // No need to check result if a mutation can't fail. - update("todo", { - id, - isCompleted: booleanToSqliteBoolean(!isCompleted), - }); - }; - - const handleProjectChange = (newProjectId: ProjectId) => { - update("todo", { id, projectId: newProjectId }); - }; - - const handleRenameClick = () => { - const newTitle = window.prompt("Edit todo", title); - if (newTitle == null) return; - - const result = update("todo", { id, title: newTitle.trim() }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleDeleteClick = () => { - update("todo", { id, isDeleted: sqliteTrue }); - }; - - // Demonstrate history tracking. Evolu automatically tracks all changes - // in the evolu_history table, making it easy to build audit logs or undo features. - const titleHistoryQuery = evolu.createQuery((db) => - db - .selectFrom("evolu_history") - .select(["value", "timestamp"]) - .where("table", "==", "todo") - .where("id", "==", idToIdBytes(id)) - .where("column", "==", "title") - // The value isn't typed; this is how we can cast it. - .$narrowType<{ value: (typeof Schema)["todo"]["title"]["Type"] }>() - .orderBy("timestamp", "desc"), - ); - - const handleHistoryClick = () => { - void evolu.loadQuery(titleHistoryQuery).then((rows) => { - const rowsWithTimestamp = rows.map((row) => ({ - value: row.value, - timestamp: timestampToDateIso(timestampBytesToTimestamp(row.timestamp)), - })); - alert(JSON.stringify(rowsWithTimestamp, null, 2)); - }); - }; - - return ( -
  • - -
    -
    - - - - - -
    - {projects.map((project) => ( - - - - ))} -
    -
    -
    - - - -
    -
    -
  • - ); + const { update } = useEvolu(); + + const handleToggleCompletedClick = () => { + // No need to check result if a mutation can't fail. + update("todo", { + id, + isCompleted: booleanToSqliteBoolean(!isCompleted), + }); + }; + + const handleProjectChange = (newProjectId: ProjectId) => { + update("todo", { id, projectId: newProjectId }); + }; + + const handleRenameClick = () => { + const newTitle = window.prompt("Edit todo", title); + if (newTitle == null) return; + + const result = update("todo", { id, title: newTitle.trim() }); + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + const handleDeleteClick = () => { + update("todo", { id, isDeleted: sqliteTrue }); + }; + + // Demonstrate history tracking. Evolu automatically tracks all changes + // in the evolu_history table, making it easy to build audit logs or undo features. + const titleHistoryQuery = evolu.createQuery((db) => + db + .selectFrom("evolu_history") + .select(["value", "timestamp"]) + .where("table", "==", "todo") + .where("id", "==", idToIdBytes(id)) + .where("column", "==", "title") + // The value isn't typed; this is how we can cast it. + .$narrowType<{ value: (typeof Schema)["todo"]["title"]["Type"] }>() + .orderBy("timestamp", "desc"), + ); + + const handleHistoryClick = () => { + void evolu.loadQuery(titleHistoryQuery).then((rows) => { + const rowsWithTimestamp = rows.map((row) => ({ + value: row.value, + timestamp: timestampToDateIso(timestampBytesToTimestamp(row.timestamp)), + })); + alert(JSON.stringify(rowsWithTimestamp, null, 2)); + }); + }; + + return ( +
  • + +
    +
    + + + + + +
    + {projects.map((project) => ( + + + + ))} +
    +
    +
    + + + +
    +
    +
  • + ); }; const projectsQuery = evolu.createQuery((db) => - db - .selectFrom("project") - .select(["id", "name", "fooJson"]) - .where("isDeleted", "is not", sqliteTrue) - .where("name", "is not", null) - .$narrowType<{ name: kysely.NotNull }>() - .where("fooJson", "is not", null) - .$narrowType<{ fooJson: kysely.NotNull }>() - .orderBy("createdAt"), + db + .selectFrom("project") + .select(["id", "name", "fooJson"]) + .where("isDeleted", "is not", sqliteTrue) + .where("name", "is not", null) + .$narrowType<{ name: kysely.NotNull }>() + .where("fooJson", "is not", null) + .$narrowType<{ fooJson: kysely.NotNull }>() + .orderBy("createdAt"), ); type ProjectsRow = typeof projectsQuery.Row; const useAddProject = () => { - const { insert } = useEvolu(); - - return () => { - const name = window.prompt("What's the project name?"); - if (name == null) return; - - // Demonstrate JSON usage. - const foo = Foo.from({ foo: "baz", bar: 42 }); - if (!foo.ok) return; - - const result = insert("project", { - name: name.trim(), - fooJson: fooToFooJson(foo.value), - }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; + const { insert } = useEvolu(); + + return () => { + const name = window.prompt("What's the project name?"); + if (name == null) return; + + // Demonstrate JSON usage. + const foo = Foo.from({ foo: "baz", bar: 42 }); + if (!foo.ok) return; + + const result = insert("project", { + name: name.trim(), + fooJson: fooToFooJson(foo.value), + }); + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; }; const ProjectsTab: FC = () => { - const projects = useQuery(projectsQuery); - const handleAddProjectClick = useAddProject(); - - return ( -
    -
    - {projects.map((project) => ( - - ))} -
    -
    -
    -
    - ); + const projects = useQuery(projectsQuery); + const handleAddProjectClick = useAddProject(); + + return ( +
    +
    + {projects.map((project) => ( + + ))} +
    +
    +
    +
    + ); }; const ProjectsTabProjectItem: FC<{ - project: ProjectsRow; + project: ProjectsRow; }> = ({ project }) => { - const { update } = useEvolu(); - - const handleRenameClick = () => { - const newName = window.prompt("Edit project name", project.name); - if (newName == null) return; - - const result = update("project", { id: project.id, name: newName.trim() }); - if (!result.ok) { - alert(formatTypeError(result.error)); - } - }; - - const handleDeleteClick = () => { - if (confirm(`Are you sure you want to delete project "${project.name}"?`)) { - /** - * In a classic centralized client-server app, we would fetch all todos - * for this project and delete them too. But that approach is wrong for - * distributed eventually consistent systems for two reasons: - * - * 1. Sync overhead scales with todo count (a project with 10k todos would - * generate 10k sync messages instead of just 1 for the project) - * 2. It wouldn't delete todos from other devices before they sync - * - * The correct approach for local-first systems: handle cascading logic in - * the UI layer. Queries filter out deleted projects, so their todos - * naturally become hidden. If a todo detail view is needed, it should - * check whether its parent project was deleted. - */ - update("project", { - id: project.id, - isDeleted: sqliteTrue, - }); - } - }; - - // Demonstrate JSON deserialization. Because FooJson is a branded type, - // we can safely deserialize without validation - TypeScript guarantees - // only validated JSON strings can have the FooJson brand. - const _foo = fooJsonToFoo(project.fooJson); - - return ( -
    -
    - -
    -

    {project.name}

    -
    -
    -
    - - -
    -
    - ); + const { update } = useEvolu(); + + const handleRenameClick = () => { + const newName = window.prompt("Edit project name", project.name); + if (newName == null) return; + + const result = update("project", { id: project.id, name: newName.trim() }); + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + const handleDeleteClick = () => { + if (confirm(`Are you sure you want to delete project "${project.name}"?`)) { + /** + * In a classic centralized client-server app, we would fetch all todos + * for this project and delete them too. But that approach is wrong for + * distributed eventually consistent systems for two reasons: + * + * 1. Sync overhead scales with todo count (a project with 10k todos would + * generate 10k sync messages instead of just 1 for the project) + * 2. It wouldn't delete todos from other devices before they sync + * + * The correct approach for local-first systems: handle cascading logic in + * the UI layer. Queries filter out deleted projects, so their todos + * naturally become hidden. If a todo detail view is needed, it should + * check whether its parent project was deleted. + */ + update("project", { + id: project.id, + isDeleted: sqliteTrue, + }); + } + }; + + // Demonstrate JSON deserialization. Because FooJson is a branded type, + // we can safely deserialize without validation - TypeScript guarantees + // only validated JSON strings can have the FooJson brand. + const _foo = fooJsonToFoo(project.fooJson); + + return ( +
    +
    + +
    +

    {project.name}

    +
    +
    +
    + + +
    +
    + ); }; const AccountTab: FC = () => { - const evolu = useEvolu(); - const appOwner = use(evolu.appOwner); - - const [showMnemonic, setShowMnemonic] = useState(false); - - const handleRestoreAppOwnerClick = () => { - const mnemonic = window.prompt("Enter your mnemonic to restore your data:"); - if (mnemonic == null) return; - - const result = Mnemonic.from(mnemonic.trim()); - if (!result.ok) { - alert(formatTypeError(result.error)); - return; - } - - void evolu.restoreAppOwner(result.value); - }; - - const handleResetAppOwnerClick = () => { - if (confirm("Are you sure? This will delete all your local data.")) { - void evolu.resetAppOwner(); - } - }; - - const handleDownloadDatabaseClick = () => { - void evolu.exportDatabase().then((array) => { - const blob = new Blob([array], { - type: "application/x-sqlite3", - }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "todos.sqlite3"; - a.click(); - window.URL.revokeObjectURL(url); - }); - }; - - return ( -
    -

    - Todos are stored in local SQLite. When you sync across devices, your - data is end-to-end encrypted using your mnemonic. -

    - -
    -