diff --git a/.changeset/array-module-refactor.md b/.changeset/array-module-refactor.md new file mode 100644 index 000000000..63e435d5c --- /dev/null +++ b/.changeset/array-module-refactor.md @@ -0,0 +1,48 @@ +--- +"@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 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/gentle-pumas-eat.md b/.changeset/gentle-pumas-eat.md new file mode 100644 index 000000000..1e5151520 --- /dev/null +++ b/.changeset/gentle-pumas-eat.md @@ -0,0 +1,14 @@ +--- +"@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 +--- + +# Update Node.js requirement + +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/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/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/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/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/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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..b2f690035 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information):** +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 21ae4cfe6..8dfc2488e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,13 +4,20 @@ 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 +- **Avoid `import type`** - use regular imports for consistency - **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 @@ -21,6 +28,12 @@ export const trySync = ...; // ❌ 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,12 +41,18 @@ 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: + +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, @@ -67,18 +86,48 @@ interface Example { } ``` +## 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 & JSDoc - **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 - **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 /** * Creates a new user with the provided data. * - * ### Example + * ## Example * * ```ts * const user = createUser({ name: "John", email: "john@example.com" }); @@ -88,6 +137,27 @@ export const createUser = (data: UserData): User => { // implementation }; +/** + * 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 +173,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 @@ -128,10 +208,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; @@ -166,7 +246,7 @@ export interface Storage { ```ts // For lazy operations array -const operations: LazyValue>[] = [ +const operations: Lazy>[] = [ () => doSomething(), () => doSomethingElse(), ]; diff --git a/.nvmrc b/.nvmrc index 1a2f5bd20..18c92ea98 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/* \ No newline at end of file +v24 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index d6240f889..dd64367b1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,4 @@ 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. diff --git a/.vscode/settings.json b/.vscode/settings.json index ad4c66ce6..a9e3062fc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,11 @@ { "typescript.tsdk": "node_modules/typescript/lib", + "biome.lspBin": "node_modules/@biomejs/biome/bin/biome", "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "never", + "source.organizeImports.biome": "never" + }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, diff --git a/README.md b/README.md index 0583389c8..90d2e94ed 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Evolu is a TypeScript library and local-first platform. ## Documentation -Please visit [evolu.dev](https://www.evolu.dev). +For detailed information and usage examples, please visit [evolu.dev](https://www.evolu.dev). ## Community @@ -12,19 +12,7 @@ The Evolu community is on [GitHub Discussions](https://github.com/evoluhq/evolu/ 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. - -The Evolu Relay source and Docker files are in the [/apps/relay](/apps/relay) directory. - -Alternatively, a pre-built image `evoluhq/relay:latest` is hosted on [Docker Hub](https://hub.docker.com/r/evoluhq/relay). - -For more information, reference the [Evolu Relay](https://www.evolu.dev/docs/relay) documentation. +[![X](https://img.shields.io/twitter/url/https/x.com/evoluhq.svg?style=social&label=Follow%20%40evoluhq)](https://x.com/evoluhq) ## Developing @@ -40,7 +28,6 @@ Build scripts - `pnpm build` - Build packages - `pnpm build:web` - Build web -- `pnpm examples:build` - Build all examples Start dev @@ -49,10 +36,16 @@ Start dev - `pnpm dev` - Dev server for web - `pnpm ios` - Run iOS example (requires `pnpm dev` running) - `pnpm android` - Run Android example (requires `pnpm dev` running) + +Examples + +> **Note**: To work on examples with local packages, run `pnpm examples:toggle-deps` first. + - `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 +- `pnpm examples:build` - Build all examples Linting diff --git a/apps/relay/package.json b/apps/relay/package.json index e156b663c..87b8ce988 100644 --- a/apps/relay/package.json +++ b/apps/relay/package.json @@ -19,10 +19,10 @@ }, "devDependencies": { "@evolu/tsconfig": "workspace:*", - "@types/node": "^22.17.1", - "typescript": "^5.9.2" + "@types/node": "^24.10.3", + "typescript": "^5.9.3" }, "engines": { - "node": ">=22.0.0" + "node": ">=24.0.0" } } diff --git a/apps/relay/src/index.ts b/apps/relay/src/index.ts index 1ec2d331a..5baaf2678 100644 --- a/apps/relay/src/index.ts +++ b/apps/relay/src/index.ts @@ -1,14 +1,17 @@ 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({ +const deps = { console: createConsole(), -})({ +}; + +const relay = await createNodeJsRelay(deps)({ port: 4000, enableLogging: false, @@ -21,10 +24,11 @@ const relay = await createNodeJsRelay({ }, }); -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/web/package.json b/apps/web/package.json index 6c0d3c36d..957ff08d8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,53 +12,54 @@ }, "browserslist": "defaults, not ie <= 11", "dependencies": { - "@algolia/autocomplete-core": "^1.19.2", + "@algolia/autocomplete-core": "^1.19.4", "@evolu/common": "workspace:*", "@evolu/react": "workspace:*", "@evolu/react-web": "workspace:*", - "@headlessui/react": "^2.2.7", + "@evolu/sqlite-wasm": "2.2.4", + "@headlessui/react": "^2.2.9", "@headlessui/tailwindcss": "^0.2.2", - "@mdx-js/loader": "^3.1.0", - "@mdx-js/react": "^3.1.0", - "@next/mdx": "^16.0.0", + "@mdx-js/loader": "^3.1.1", + "@mdx-js/react": "^3.1.1", + "@next/mdx": "^16.1.1", "@sindresorhus/slugify": "^3.0.0", "@tabler/icons-react": "^3.35.0", "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.11", - "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/typography": "^0.5.19", "acorn": "^8.15.0", "clsx": "^2.1.1", "fast-glob": "^3.3.3", - "flexsearch": "^0.8.205", + "flexsearch": "^0.8.212", "mdast-util-to-string": "^4.0.0", "mdx-annotations": "^0.1.4", - "motion": "^12.23.12", - "next": "^16.0.0", + "motion": "^12.23.26", + "next": "^16.1.1", "next-themes": "^0.4.6", "react": "catalog:react19", "react-dom": "catalog:react19", "react-highlight-words": "^0.21.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", - "remark-mdx": "^3.1.0", + "remark-mdx": "^3.1.1", "rss": "^1.2.2", - "shiki": "^3.9.2", + "shiki": "^3.19.0", "simple-functional-loader": "^1.2.1", - "tailwindcss": "^4.1.12", - "typescript": "^5.9.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", "unist-util-filter": "^5.0.1", "unist-util-visit": "^5.0.0", - "zustand": "^5.0.7" + "zustand": "^5.0.9" }, "devDependencies": { "@evolu/tsconfig": "workspace:*", "@types/mdx": "^2.0.13", - "@types/node": "^22.17.1", + "@types/node": "^24.10.3", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", "@types/react-highlight-words": "^0.20.0", "@types/rss": "^0.0.32", - "cross-env": "^10.0.0", - "sharp": "^0.34.3" + "cross-env": "^10.1.0", + "sharp": "^0.34.5" } } diff --git a/apps/web/scripts/fix-api-reference.mts b/apps/web/scripts/fix-api-reference.mts index 80150cf78..d344c5343 100644 --- a/apps/web/scripts/fix-api-reference.mts +++ b/apps/web/scripts/fix-api-reference.mts @@ -8,7 +8,7 @@ const reference = path.join( "src/app/(docs)/docs/api-reference", ); -function rearrangeMdxFilesRecursively(dir: string) { +const rearrangeMdxFilesRecursively = (dir: string): void => { for (const item of fs.readdirSync(dir)) { const fullPath = path.join(dir, item); const stat = fs.statSync(fullPath); @@ -20,43 +20,52 @@ function rearrangeMdxFilesRecursively(dir: string) { const newFolder = path.join(dir, baseName); fs.mkdirSync(newFolder, { recursive: true }); fs.renameSync(fullPath, path.join(newFolder, "page.mdx")); - fixLinksInMdxFile( + fixMdxFile( path.join(newFolder, "page.mdx"), `${baseName} - API reference`, ); } else { - fixLinksInMdxFile(fullPath, "API reference"); + const title = + dir === reference + ? "API reference" + : `${path.basename(dir)} - API reference`; + fixMdxFile(fullPath, title); } } } -} +}; -function fixLinksInMdxFile(filePath: string, title: string) { +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, ""); - newContent = newContent.replace(/\(([^)]+)\.mdx\)/g, "($1)"); + 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 + // 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\]\([^)]*\)/g, - "[API reference](/docs/api-reference)", + /^(\[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, - "", - ); + newContent = newContent + .replace(/^export const metadata = \{ title: [^}]*\};\s*\r?\n\s*/, "") + .replace(/^export const sections = .*;\s*\r?\n\s*/m, ""); - // add meta tags newContent = `export const metadata = { title: '${title}' }; -export const sections = []; ${newContent}`; fs.writeFileSync(filePath, newContent); -} +}; // Run the script rearrangeMdxFilesRecursively(reference); diff --git a/apps/web/src/app/(docs)/docs/conventions/page.mdx b/apps/web/src/app/(docs)/docs/conventions/page.mdx index 8c1587cc7..598f22e24 100644 --- a/apps/web/src/app/(docs)/docs/conventions/page.mdx +++ b/apps/web/src/app/(docs)/docs/conventions/page.mdx @@ -8,7 +8,7 @@ export const metadata = { Conventions minimize decision-making and improve consistency. -## Named imports +## Imports and exports Use named imports. Refactor modules with excessive imports. @@ -16,15 +16,13 @@ Use named imports. Refactor modules with excessive imports. 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. ```ts // Avoid export const Utils = { ok, trySync }; -// Prefer +// Use export const ok = ...; export const trySync = ...; @@ -38,9 +36,9 @@ export const trySync = ...; ## 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 +62,9 @@ const bar = () => { }; ``` -## Arrow functions - -Use arrow functions instead of the `function` keyword. - -```ts -// Prefer -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; -} -``` - ## 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. +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. Use immutable values with readonly types to reduce these risks and improve clarity. ### Readonly types @@ -163,13 +120,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 +134,114 @@ 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 Foo { + bar(value: string): void; + baz(): number; +} +``` + +## 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 mask mutability.** A getter looks like a simple property access (`obj.value`) but might return different values on each call. This violates the principle of least surprise and makes code harder to reason about. + +**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 Counter { + 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++; + } +} +``` 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..c8f146ead 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..a4f5c20ec 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. Task -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. +Continue with [`Task`](/docs/api-reference/common/Task/interfaces/Task) for structured concurrency (promises that can be aborted, monitored, and do not leak). -### 4. Dependency injection +### 4. Type -Explore the [dependency injection pattern](/docs/dependency-injection) used throughout Evolu for decoupled, testable code. +Check the [`Type`](/docs/api-reference/common/Type) to enforce constraints at compile-time and validate data at runtime. -### 5. Conventions +### 5. Dependency injection + +Explore [dependency injection](/docs/dependency-injection), the pattern Evolu uses to swap implementations and simplify testing. + +### 6. Resource management + +See [resource management](/docs/resource-management) — `using`, `DisposableStack`, and how it integrates with `Result` for reliable cleanup. + +### 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..5b0c7b3f8 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, 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/(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/(playgrounds)/playgrounds/full/EvoluFullExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx index fccd2921f..5fdef7e6b 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/full/EvoluFullExample.tsx @@ -4,6 +4,7 @@ import { booleanToSqliteBoolean, createEvolu, createFormatTypeError, + createObjectURL, FiniteNumber, id, idToIdBytes, @@ -30,7 +31,7 @@ import { 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, @@ -94,10 +95,12 @@ const Schema = { }, }; -const evolu = createEvolu(evoluReactWebDeps)(Schema, { +const deps = createEvoluDeps(); + +const evolu = createEvolu(deps)(Schema, { name: SimpleName.orThrow("full-example"), - reloadUrl: "/playgrounds/full", + // reloadUrl: "/playgrounds/full", ...(process.env.NODE_ENV === "development" && { transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], @@ -113,7 +116,7 @@ const evolu = createEvolu(evoluReactWebDeps)(Schema, { create("todoProjectId").on("todo").column("projectId"), ], - enableLogging: false, + // enableLogging: false, }); const useEvolu = createUseEvolu(evolu); @@ -643,26 +646,25 @@ const AccountTab: FC = () => { return; } - void evolu.restoreAppOwner(result.value); + // void evolu.restoreAppOwner(result.value); }; const handleResetAppOwnerClick = () => { if (confirm("Are you sure? This will delete all your local data.")) { - void evolu.resetAppOwner(); + // 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); + 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/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx index d7f26b60c..54f822d9d 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx @@ -2,7 +2,7 @@ import * as Evolu from "@evolu/common"; import { createUseEvolu, EvoluProvider, useQuery } from "@evolu/react"; -import { evoluReactWebDeps } from "@evolu/react-web"; +import { createEvoluDeps } from "@evolu/react-web"; import { IconEdit, IconTrash } from "@tabler/icons-react"; import clsx from "clsx"; import { FC, Suspense, use, useState } from "react"; @@ -24,24 +24,31 @@ const Schema = { }, }; +const deps = createEvoluDeps(); + // Create Evolu instance for the React web platform. -const evolu = Evolu.createEvolu(evoluReactWebDeps)(Schema, { +const evolu = Evolu.createEvolu(deps)(Schema, { name: Evolu.SimpleName.orThrow("minimal-example"), - reloadUrl: "/playgrounds/minimal", + // TODO: Patri do web deps only? hmm, deps jsou sdilene + // tohle musim pak domyslet, callback? webReloadUrl? uvidime + // tohle rozhodne patri se + // reloadUrl: "/playgrounds/minimal", ...(process.env.NODE_ENV === "development" && { transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], }), }); -// Creates a typed React Hook returning an instance of Evolu. +// Creates a typed React Hook for accessing Evolu from EvoluProvider context. +// You can also use `evolu` directly, but the hook enables replacing Evolu +// in tests via the EvoluProvider. const useEvolu = createUseEvolu(evolu); /** - * Subscribe to unexpected Evolu errors (database, network, sync issues). These - * should not happen in normal operation, so always log them for debugging. Show - * users a friendly error message instead of technical details. + * Subscribe to Evolu errors (database, network, sync issues). These should not + * happen in normal operation, so always log them for debugging. Show users a + * friendly error message instead of technical details. */ evolu.subscribeError(() => { const error = evolu.getError(); @@ -106,7 +113,9 @@ const Todos: FC = () => { const addTodo = () => { const result = insert( "todo", - { title: newTodoTitle.trim() }, + { + title: newTodoTitle.trim(), + }, { onComplete: () => { setNewTodoTitle(""); @@ -231,26 +240,25 @@ const OwnerActions: FC = () => { return; } - void evolu.restoreAppOwner(result.value); + // void evolu.restoreAppOwner(result.value); }; const handleResetAppOwnerClick = () => { if (confirm("Are you sure? This will delete all your local data.")) { - void evolu.resetAppOwner(); + // 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); + void evolu.exportDatabase().then((data) => { + using objectUrl = Evolu.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/apps/web/src/app/(playgrounds)/playgrounds/multitenant/EvoluMultitenantExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/multitenant/EvoluMultitenantExample.tsx new file mode 100644 index 000000000..60c1503d5 --- /dev/null +++ b/apps/web/src/app/(playgrounds)/playgrounds/multitenant/EvoluMultitenantExample.tsx @@ -0,0 +1,345 @@ +"use client"; + +import * as Evolu from "@evolu/common"; +import { createUseEvolu, EvoluProvider, useQuery } from "@evolu/react"; +import { createEvoluDeps } from "@evolu/react-web"; +import { IconEdit, IconTrash } from "@tabler/icons-react"; +import clsx from "clsx"; +import { FC, Suspense, use, useState } from "react"; + +const TodoId = Evolu.id("Todo"); +type TodoId = typeof TodoId.Type; + +const Schema = { + todo: { + id: TodoId, + // Branded type ensuring titles are non-empty and ≤100 chars. + title: Evolu.NonEmptyString100, + // SQLite doesn't support the boolean type; it uses 0 and 1 instead. + isCompleted: Evolu.nullOr(Evolu.SqliteBoolean), + }, +}; + +const deps = createEvoluDeps(); + +deps.evoluError.subscribe(() => { + const error = deps.evoluError.get(); + if (!error) return; + + alert("Evolu error occurred. Check the console."); + // eslint-disable-next-line no-console + console.error(error); +}); + +// const syncStats = createSyncStats(deps) + +const evolu = Evolu.createEvolu(deps)(Schema, { + name: Evolu.SimpleName.orThrow("minimal-example"), + + ...(process.env.NODE_ENV === "development" && { + transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], + }), +}); + +const useEvolu = createUseEvolu(evolu); + +evolu.subscribeError(() => { + const error = evolu.getError(); + if (!error) return; + + alert("🚨 Evolu error occurred! Check the console."); + // eslint-disable-next-line no-console + console.error(error); +}); + +export const EvoluMultitenantExample: FC = () => { + return ( +
+
+
+

+ Minimal Todo App +

+
+ + + + + + + +
+
+ ); +}; + +// Evolu uses Kysely for type-safe SQL (https://kysely.dev/). +const todosQuery = evolu.createQuery((db) => + db + // Type-safe SQL: try autocomplete for table and column names. + .selectFrom("todo") + .select(["id", "title", "isCompleted"]) + // Soft delete: filter out deleted rows. + .where("isDeleted", "is not", Evolu.sqliteTrue) + // Like with GraphQL, all columns except id are nullable in queries + // (even if defined without nullOr in the schema) to allow schema + // evolution without migrations. Filter nulls with where + $narrowType. + .where("title", "is not", null) + .$narrowType<{ title: Evolu.kysely.NotNull }>() + // Columns createdAt, updatedAt, isDeleted are auto-added to all tables. + .orderBy("createdAt"), +); + +// Extract the row type from the query for type-safe component props. +type TodosRow = typeof todosQuery.Row; + +const Todos: FC = () => { + // useQuery returns live data - component re-renders when data changes. + const todos = useQuery(todosQuery); + const { insert } = useEvolu(); + const [newTodoTitle, setNewTodoTitle] = useState(""); + + const addTodo = () => { + const result = insert( + "todo", + { + title: newTodoTitle.trim(), + }, + { + onComplete: () => { + setNewTodoTitle(""); + }, + }, + ); + + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + return ( +
+
    + {todos.map((todo) => ( + + ))} +
+ +
+ { + setNewTodoTitle(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") addTodo(); + }} + 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 TodoItem: FC<{ + row: TodosRow; +}> = ({ row: { id, title, isCompleted } }) => { + const { update } = useEvolu(); + + const handleToggleCompletedClick = () => { + update("todo", { + id, + isCompleted: Evolu.booleanToSqliteBoolean(!isCompleted), + }); + }; + + const handleRenameClick = () => { + const newTitle = window.prompt("Edit todo", title); + if (newTitle == null) return; + + const result = update("todo", { id, title: newTitle }); + if (!result.ok) { + alert(formatTypeError(result.error)); + } + }; + + const handleDeleteClick = () => { + update("todo", { + id, + // Soft delete with isDeleted flag (CRDT-friendly, preserves sync history). + isDeleted: Evolu.sqliteTrue, + }); + }; + + return ( +
  • + +
    + + +
    +
  • + ); +}; + +const OwnerActions: 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 = Evolu.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((data) => { + using objectUrl = Evolu.createObjectURL( + new Blob([data], { type: "application/x-sqlite3" }), + ); + + const link = document.createElement("a"); + link.href = objectUrl.url; + link.download = `${evolu.name}.sqlite3`; + link.click(); + }); + }; + + return ( +
    +

    Account

    +

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

    + +
    +