Merge pull request #137 from robonen/docs

Docs
This commit is contained in:
2026-06-08 17:46:03 +07:00
committed by GitHub
1722 changed files with 158557 additions and 7462 deletions
@@ -0,0 +1,71 @@
---
description: "Audit and complete Vue/web component test suites. Use when the user asks to check test coverage, find missing test cases, fill gaps in component tests, verify a11y of a component, audit *.test.ts / a11y.test.ts files, or ensure a component is fully covered by vitest. Triggers: \"check tests\", \"complete tests\", \"test coverage\", \"missing test cases\", \"audit a11y\", \"проверь тесты\", \"допиши тесты\", \"покрытие тестов\"."
name: "Component Test Auditor"
tools: [read, search, edit, execute, todo]
model: "Claude Sonnet 4.5 (copilot)"
argument-hint: "Path to a component or test file to audit"
user-invocable: true
---
You are a specialist in Vue/web component test completeness auditing. Your single job: given a component, determine whether its test suite fully covers behavior, props, emits, slots, edge cases, and accessibility — then fill the gaps with additional vitest specs.
## Constraints
- DO NOT modify production component source. ONLY add or refine test files.
- DO NOT introduce new test frameworks. Use the existing `vitest` + `@vue/test-utils` setup found in the repo.
- DO NOT create redundant tests for behavior already covered. Each new spec must close a concrete gap.
- DO NOT skip running the suite — every change must be verified with vitest before reporting back.
- ONLY audit one component (or a small explicit list) per invocation. Stay focused.
## Required Skills
Always load these skills via `read_file` before acting:
1. `/Users/robonen/.agents/skills/vue-testing-best-practices/SKILL.md` — patterns for Vue component tests.
2. `/Users/robonen/.agents/skills/vitest/SKILL.md` — vitest API, mocking, coverage.
3. `/Users/robonen/.agents/skills/accessibility-a11y/SKILL.md` — WCAG checks, ARIA, keyboard nav. Load whenever the component has interactive behavior, focus management, ARIA attributes, or a sibling `a11y.test.ts` file exists.
4. `/Users/robonen/Projects/tools/.github/skills/monorepo/SKILL.md` — for repo-specific commands (`pnpm`, test scoping, workspace layout).
## Approach
1. **Locate the component.** Resolve the target `.vue` / `.ts` source and its `__test__/` folder. Inspect sibling `*.test.ts`, `a11y.test.ts`, and any AGENTS.md / README.md for the package.
2. **Inventory the surface.** From the component source, enumerate: props (with defaults & types), emits, exposed methods, slots (named + scoped), `v-model`s, internal state machines, conditional branches, lifecycle effects, DOM/ARIA attributes, keyboard handlers.
3. **Inventory existing coverage.** Read all related test files and map each `it(...)` to the surface item it covers. Build a gap table.
4. **Decide on a11y scope.** If the component is interactive (focusable, ARIA roles, keyboard, form-related) → run the a11y skill checklist and ensure an `a11y.test.ts` exists with: role, accessible name, keyboard interaction, focus order, `aria-*` invariants, and (where applicable) `axe`-style assertions consistent with neighboring components.
5. **Write the missing specs.** Match the file/folder conventions used by neighbors (e.g. `src/<comp>/__test__/<Component>.test.ts` + `a11y.test.ts`). Mirror style (mount helpers, fixtures, naming). Keep specs deterministic and isolated.
6. **Run vitest.** Use `runTests` when available, otherwise `pnpm --filter <pkg> test -- <pattern>`. Iterate until green. If a failure reveals a real component bug, STOP and report it — do not silently fix production code.
7. **Optionally measure coverage.** If coverage is requested or unclear, run vitest in coverage mode scoped to the component file and report uncovered lines/branches.
## Output Format
Return a concise markdown report:
```
### Component: <name> (<path>)
**Surface inventory**
- props: ...
- emits: ...
- slots: ...
- interactive/a11y: yes/no — <reason>
**Existing coverage**
- <file>: N specs — covers X, Y
- a11y.test.ts: present/absent
**Gaps closed**
- + <spec name> — <what it covers>
- + <spec name> — <what it covers>
**Files changed**
- <path> (+N specs)
**Verification**
- vitest: PASS (M tests) | coverage: L% lines / B% branches
- a11y skill applied: yes/no
**Follow-ups (not done)**
- <anything out of scope or requiring human decision>
```
If no gaps exist, say so explicitly and do not touch any files.
+399
View File
@@ -0,0 +1,399 @@
---
name: monorepo
description: "Manage the @robonen/tools monorepo. Use when: installing dependencies, creating new packages, linting, building, testing, publishing, or scaffolding workspace packages. Covers pnpm catalogs, tsdown builds, eslint presets, vitest projects, JSR/NPM publishing."
---
# Monorepo Management
## Overview
This is a pnpm workspace monorepo (`@robonen/tools`) with shared configs, strict dependency management via catalogs, and automated publishing.
## Workspace Layout
| Directory | Purpose | Examples |
|-----------|---------|---------|
| `configs/*` | Shared tooling configs | `eslint`, `tsconfig`, `tsdown` |
| `core/*` | Platform-agnostic TS libraries | `stdlib`, `platform`, `encoding` |
| `vue/*` | Vue 3 packages | `primitives`, `toolkit` |
| `docs` | Nuxt 4 documentation site | — |
| `infra/*` | Infrastructure configs | `renovate` |
## Installing Dependencies
**Always use pnpm. Never use npm or yarn.**
### Add a dependency to a specific package
```bash
# Runtime dependency
pnpm -C <package-path> add <dep-name>
# Dev dependency
pnpm -C <package-path> add -D <dep-name>
```
Examples:
```bash
pnpm -C core/stdlib add -D eslint
pnpm -C vue/primitives add vue
```
### Use catalogs for shared versions
Versions shared across multiple packages MUST use the pnpm catalog system. The catalog is defined in `pnpm-workspace.yaml`:
```yaml
catalog:
vitest: ^4.0.18
tsdown: ^0.21.0
eslint: ^10.4.1
vue: ^3.5.28
# ... etc
```
In `package.json`, reference catalog versions with the `catalog:` protocol:
```json
{
"devDependencies": {
"vitest": "catalog:",
"eslint": "catalog:"
}
}
```
**When to add to catalog:** If the dependency is used in 2+ packages, add it to `pnpm-workspace.yaml` under `catalog:` and use `catalog:` in each `package.json`.
**When NOT to use catalog:** Package-specific dependencies used in only one package (e.g., `citty` in root).
### Internal workspace dependencies
Reference sibling packages with the workspace protocol:
```json
{
"devDependencies": {
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*"
}
}
```
Nearly every package depends on these three shared config packages.
### After installing
Always run `pnpm install` at the root after editing `pnpm-workspace.yaml` or any `package.json` manually.
## Creating a New Package
> **Note:** The existing `bin/cli.ts` (`pnpm create`) is outdated — it generates Vite configs instead of tsdown and lacks eslint/vitest setup. Follow the manual steps below instead.
### 1. Create the directory
Choose the correct parent based on package type:
- `core/<name>` — Platform-agnostic TypeScript library
- `vue/<name>` — Vue 3 library (needs jsdom, vue deps)
- `configs/<name>` — Shared configuration package
### 2. Create `package.json`
```json
{
"name": "@robonen/<name>",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "",
"packageManager": "pnpm@10.29.3",
"engines": { "node": ">=24.13.1" },
"type": "module",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint:check": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"eslint": "catalog:",
"tsdown": "catalog:"
}
}
```
For Vue packages, also add:
```json
{
"dependencies": {
"vue": "catalog:",
"@vue/shared": "catalog:"
},
"devDependencies": {
"@vue/test-utils": "catalog:"
}
}
```
For packages with sub-path exports (like `core/platform`):
```json
{
"exports": {
"./browsers": {
"types": "./dist/browsers.d.ts",
"import": "./dist/browsers.js",
"require": "./dist/browsers.cjs"
}
}
}
```
### 3. Create `tsconfig.json`
Node/core package:
```json
{
"extends": "@robonen/tsconfig/tsconfig.json"
}
```
Vue package (needs DOM types and path aliases):
```json
{
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": ["DOM"],
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
}
}
```
### 4. Create `tsdown.config.ts`
Standard:
```typescript
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
});
```
Vue package (externalize vue, bundle internal deps):
```typescript
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
deps: {
neverBundle: ['vue'],
alwaysBundle: [/^@robonen\//, '@vue/shared'],
},
inputOptions: {
resolve: {
alias: { '@vue/shared': '@vue/shared/dist/shared.esm-bundler.js' },
},
},
define: { __DEV__: 'false' },
});
```
### 5. Create `eslint.config.ts`
Standard (node packages):
```typescript
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
```
> ESLint auto-discovers `eslint.config.ts` (loaded via `jiti`, which `@robonen/eslint` depends on). No `defineConfig` wrapper is needed — `compose()` returns the flat-config array directly.
### 6. Create `vitest.config.ts`
Node package:
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});
```
Vue package:
```typescript
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
define: { __DEV__: 'true' },
resolve: {
alias: { '@': resolve(__dirname, './src') },
},
test: {
environment: 'jsdom',
},
});
```
### 7. Create `jsr.json` (for publishable packages)
```json
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/<name>",
"version": "0.0.1",
"exports": "./src/index.ts"
}
```
### 8. Create source files
```bash
mkdir -p src
touch src/index.ts
```
### 9. Register with vitest projects
Add the new `vitest.config.ts` path to the root `vitest.config.ts` `projects` array.
### 10. Install dependencies
```bash
pnpm install
```
### 11. Verify
```bash
pnpm -C <package-path> build
pnpm -C <package-path> lint
pnpm -C <package-path> test
```
## Linting
Uses **ESLint** (flat config) with composable presets from `@robonen/eslint`.
### Run linting
```bash
# Check lint errors (no auto-fix)
pnpm -C <package-path> lint:check
# Auto-fix lint errors
pnpm -C <package-path> lint:fix
# Check all packages
pnpm lint:check
# Fix all packages
pnpm lint:fix
```
### Available presets
| Preset | Purpose |
|--------|---------|
| `base` | ESLint core + Unicorn rules + global ignores |
| `typescript` | TypeScript rules (via `typescript-eslint`, on `*.ts` + `*.vue`) |
| `imports` | Import ordering, cycles, duplicates (`eslint-plugin-import-x`) |
| `stylistic` | Code style via `@stylistic/eslint-plugin` |
| `vue` | Vue 3 Composition API rules (`eslint-plugin-vue`) |
| `vitest` | Test file rules (`@vitest/eslint-plugin`) |
| `node` | Node.js-specific rules (`eslint-plugin-n`) |
Compose presets in `eslint.config.ts`:
```typescript
import { base, compose, imports, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports);
```
**Recommended:** Include `stylistic` preset for code formatting:
```typescript
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
```
All plugins (including `@stylistic/eslint-plugin`) ship as dependencies of `@robonen/eslint`, so packages only need `@robonen/eslint` + `eslint` in devDependencies.
## Building
```bash
# Build a specific package
pnpm -C <package-path> build
# Build all packages
pnpm build
```
All packages use `tsdown` with shared config from `@robonen/tsdown`. Output: ESM (`.js`/`.mjs`) + CJS (`.cjs`) + type declarations (`.d.ts`). Every bundle includes an Apache-2.0 license banner.
## Testing
```bash
# Run tests in a specific package
pnpm -C <package-path> test
# Run all tests (via vitest projects)
pnpm test
# Interactive test UI
pnpm test:ui
# Watch mode in a package
pnpm -C <package-path> dev
```
Uses **vitest** with project-based configuration. Root `vitest.config.ts` lists all package vitest configs as projects.
## Publishing
Publishing is **automated** via GitHub Actions on push to `master`:
1. CI builds and tests all packages
2. Publish workflow compares each package's `version` in `package.json` against npm registry
3. If version changed → `pnpm publish --access public`
**To publish:** Bump the `version` in `package.json` (and `jsr.json` if present), then merge to `master`.
**NPM scope:** All packages publish under `@robonen/`.
## Documentation
```bash
pnpm docs:dev # Start Nuxt dev server
pnpm docs:generate # Generate static site
pnpm docs:preview # Preview generated site
pnpm docs:extract # Extract API docs
```
## Key Conventions
- **ESM-first:** All packages use `"type": "module"`
- **Strict TypeScript:** `strict: true`, `noUncheckedIndexedAccess: true`, `verbatimModuleSyntax: true`
- **License:** Apache-2.0 for all published packages
- **Node version:** ≥24.13.1 (set in `engines` and CI)
- **pnpm version:** Pinned in `packageManager` field
- **No barrel re-exports of entire modules** — export explicitly
- **`__DEV__` global:** `false` in builds, `true` in tests (Vue packages only)
+56 -10
View File
@@ -5,21 +5,26 @@ on:
branches:
- master
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: 22.x
NODE_VERSION: 24.x
jobs:
code-quality:
name: Code quality checks
# Enumerate the workspace packages so the matrix below fans out one job per
# package (kept dynamic so new packages are picked up automatically).
discover:
name: Discover packages
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
outputs:
packages: ${{ steps.list.outputs.packages }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v6
with:
run_install: false
@@ -31,11 +36,52 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: List workspace packages
id: list
run: echo "packages=$(pnpm -r ls --depth -1 --json | jq -c '[.[] | select(.name != "tools") | .name]')" >> "$GITHUB_OUTPUT"
# One job per package — build (with its workspace deps), lint and test run in
# parallel across packages. fail-fast: false so every package is reported.
check:
name: ${{ matrix.package }}
needs: discover
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.discover.outputs.packages) }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Build the package and the workspace deps it relies on (incl. @robonen/eslint,
# which every package's lint config imports from its built dist).
- name: Build
run: pnpm build
run: pnpm --filter "${{ matrix.package }}..." --if-present run build
# Only the browser-mode test suites (vitest `instances: chromium`) need a
# browser. playwright is a direct devDep of these packages, so run its CLI
# in the package context (--filter) — it isn't resolvable from the root.
- name: Install Playwright Chromium
if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/editor'
run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium
- name: Lint
run: pnpm lint
run: pnpm --filter "${{ matrix.package }}" --if-present run lint:check
- name: Test
run: pnpm test
run: pnpm --filter "${{ matrix.package }}" --if-present run test
+5 -2
View File
@@ -6,7 +6,7 @@ on:
- master
env:
NODE_VERSION: 22.x
NODE_VERSION: 24.x
jobs:
check-and-publish:
@@ -18,7 +18,7 @@ jobs:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v6
with:
run_install: false
@@ -31,6 +31,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browser
run: pnpm --filter "@robonen/primitives" exec playwright install --with-deps chromium
- name: Build & Test
run: pnpm build && pnpm test
+1
View File
@@ -24,6 +24,7 @@ dist
# test
coverage
**/.vitest-attachments
# env
.env*
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"robonen-docs": {
"type": "http",
"url": "http://localhost:3000/mcp"
}
}
}
+74
View File
@@ -0,0 +1,74 @@
# @robonen/eslint
Composable [ESLint](https://eslint.org) flat-config presets.
## Install
```bash
pnpm install -D @robonen/eslint eslint jiti
```
> `jiti` lets ESLint load a TypeScript `eslint.config.ts`.
## Usage
Create `eslint.config.ts` in your project root:
```ts
import { compose, base, typescript, vue, vitest, imports } from '@robonen/eslint';
export default compose(base, typescript, vue, vitest, imports);
```
Append custom config objects after presets to override them:
```ts
import { compose, base, typescript } from '@robonen/eslint';
export default compose(base, typescript, {
rules: { 'no-console': 'off' },
}, {
files: ['**/*.vue'],
rules: { '@stylistic/no-multiple-empty-lines': 'off' },
});
```
## Presets
| Preset | Plugin(s) | Description |
| ------------ | -------------------------------------- | ---------------------------------------------- |
| `base` | `@eslint/js`, `eslint-plugin-unicorn` | Core eslint + unicorn rules, global ignores |
| `typescript` | `typescript-eslint` | TypeScript rules (`**/*.ts`, `**/*.vue`) |
| `vue` | `eslint-plugin-vue` | Vue 3 Composition API / `<script setup>` rules |
| `vitest` | `@vitest/eslint-plugin` | Test file rules |
| `imports` | `eslint-plugin-import-x` | Import rules (cycles, duplicates, ordering) |
| `node` | `eslint-plugin-n` | Node.js-specific rules |
| `stylistic` | `@stylistic/eslint-plugin` | Formatting rules |
`ignores` is also exported on its own if you only want the global ignore list.
## Migrating from `@robonen/oxlint`
This package replaces `@robonen/oxlint`. The preset names and intent are
preserved, with these mapping notes:
- `eslint/*` rules → ESLint core rules (no prefix), e.g. `eslint/no-console``no-console`.
- `typescript/*``@typescript-eslint/*`.
- `import/*``import-x/*` (via `eslint-plugin-import-x`).
- `node/*``n/*` (via `eslint-plugin-n`).
- `@stylistic/*` and `unicorn/*` are unchanged.
- `oxc/*` rules are oxc-exclusive and have **no ESLint equivalent**; they are
dropped. Their intent is largely covered by `@eslint/js` recommended and
`unicorn`.
- `categories`/`env`/`ignorePatterns` (oxlint config keys) are replaced by flat
config equivalents: `@eslint/js` recommended, `languageOptions.globals`, and
the `ignores` preset.
## API
### `compose(...configs): FlatConfigArray`
Flattens presets (arrays) and inline overrides (single objects) into one ordered
flat config array. Later entries override earlier ones — ESLint flat-config
semantics. Falsy entries (`false`/`null`/`undefined`) are skipped, enabling
conditional composition.
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
const usageExample = `import { compose, base, typescript, vue, vitest, imports } from '@robonen/eslint';
// eslint.config.ts
export default compose(base, typescript, vue, vitest, imports);`;
const overrideExample = `import { compose, base, typescript } from '@robonen/eslint';
export default compose(base, typescript, {
// later entries override earlier ones
rules: { 'no-console': 'off' },
});`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/eslint</h1>
<p class="text-lg text-(--fg-muted)">
Composable ESLint flat-config presets assemble a linting setup from
small, focused building blocks instead of one monolithic config.
</p>
</div>
<div class="prose-docs">
<p>
Modern ESLint flat config is just an ordered array of config objects, but
wiring up plugins, parsers, and rule sets by hand is repetitive and easy
to get wrong. <code>@robonen/eslint</code> ships a curated set of presets
<code>base</code>, <code>typescript</code>, <code>vue</code>,
<code>vitest</code>, <code>imports</code>, <code>node</code>,
<code>regexp</code>, and <code>stylistic</code> and a single
<code>compose()</code> helper that flattens them into one config array.
Pick the presets your project needs, layer your own overrides on top, and
you have a consistent, type-safe lint setup in a few lines.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Composable presets</h3>
<p class="text-sm text-(--fg-muted) m-0">
Mix and match focused presets per language and tool. Each preset is a
plain flat-config array no magic, no hidden state.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Override-friendly</h3>
<p class="text-sm text-(--fg-muted) m-0">
Append inline config objects after presets. Later entries win, exactly
as ESLint flat-config semantics intend.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Conditional by design</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>compose()</code> skips <code>false</code>/<code>null</code>/<code>undefined</code>
entries, so feature flags and conditional spreads just work.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Typed flat config</h3>
<p class="text-sm text-(--fg-muted) m-0">
Exported <code>FlatConfig</code> and <code>Rules</code> types give you
editor autocomplete and type-checked overrides in
<code>eslint.config.ts</code>.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
Install the package alongside ESLint and <code>jiti</code> (so ESLint can
load a TypeScript <code>eslint.config.ts</code>).
</p>
</div>
<DocsCode :code="`pnpm add -D @robonen/eslint eslint jiti`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
Create <code>eslint.config.ts</code> in your project root and compose the
presets you want:
</p>
</div>
<DocsCode :code="usageExample" lang="ts" />
<div class="prose-docs">
<p>
Add inline config objects after the presets to tweak rules later
entries override earlier ones:
</p>
</div>
<DocsCode :code="overrideExample" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
<li>
<NuxtLink to="/eslint/overview" class="text-(--accent-text) hover:underline">compose</NuxtLink>
flatten presets and overrides into one config array.
</li>
<li>
<NuxtLink to="/eslint/base" class="text-(--accent-text) hover:underline">base</NuxtLink>
core ESLint, unicorn, regexp rules and global ignores.
</li>
<li>
<NuxtLink to="/eslint/typescript" class="text-(--accent-text) hover:underline">typescript</NuxtLink>
and
<NuxtLink to="/eslint/vue" class="text-(--accent-text) hover:underline">vue</NuxtLink>
language presets for TS and Vue 3 SFCs.
</li>
<li>
Read the guide sections below for the full preset table and migration
notes from <code>@robonen/oxlint</code>.
</li>
</ul>
</div>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
import { base, compose, imports, stylistic, typescript } from './src';
export default compose(base, typescript, imports, stylistic);
+74
View File
@@ -0,0 +1,74 @@
{
"name": "@robonen/eslint",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "Composable ESLint flat configuration presets",
"keywords": [
"eslint",
"eslint-config",
"flat-config",
"linter",
"config",
"presets"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/eslint"
},
"packageManager": "pnpm@10.33.2",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {
"lint:check": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"dependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "catalog:",
"@vitest/eslint-plugin": "^1.6.19",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-n": "^18.0.1",
"eslint-plugin-regexp": "^3.1.0",
"eslint-plugin-unicorn": "^65.0.0",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"jiti": "^2.7.0",
"typescript-eslint": "^8.60.1",
"vue-eslint-parser": "^10.4.1"
},
"devDependencies": {
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"eslint": "catalog:",
"tsdown": "catalog:"
},
"peerDependencies": {
"eslint": ">=9.0.0"
},
"publishConfig": {
"access": "public"
}
}
+21
View File
@@ -0,0 +1,21 @@
# Rules Reference
Документация по preset-ам `@robonen/eslint`: что включает каждый preset и какие правила чаще всего влияют на код.
## Presets
- [base](./base.md)
- [typescript](./typescript.md)
- [vue](./vue.md)
- [vitest](./vitest.md)
- [imports](./imports.md)
- [node](./node.md)
- [stylistic](./stylistic.md)
## Как читать
- `Purpose` — зачем preset подключать.
- `Key Rules` — ключевые правила из preset-а (не полный dump).
- `Examples` — минимальные `good/bad` примеры.
Для точного источника правил см. файлы в `configs/eslint/src/presets/*.ts`.
+36
View File
@@ -0,0 +1,36 @@
# base preset
## Purpose
Базовый quality-профиль для JS/TS-проектов: корректность, анти-паттерны, безопасные дефолты. Включает `@eslint/js` recommended (аналог oxlint-категории `correctness`) + правила `unicorn`.
## Key Rules
- `eqeqeq`: запрещает `==`, требует `===`.
- `no-unused-vars`: запрещает неиспользуемые переменные (кроме `_name`).
- `no-eval`, `no-var`, `prefer-const`.
- `unicorn/prefer-node-protocol`: требует `node:` для built-in модулей.
- `unicorn/no-thenable`: запрещает thenable-объекты.
> Правила `oxc/*` из `@robonen/oxlint` не имеют аналога в ESLint и были убраны;
> их назначение покрывается `@eslint/js` recommended и `unicorn`.
## Examples
```ts
// ✅ Good
import { readFile } from 'node:fs/promises';
const id = 42;
if (id === 42) {
throw new Error('unexpected');
}
// ❌ Bad
import { readFile } from 'fs/promises';
var id = 42;
if (id == '42') {
throw 'unexpected';
}
```
+27
View File
@@ -0,0 +1,27 @@
# imports preset
## Purpose
Чистые границы модулей и предсказуемые импорты (через `eslint-plugin-import-x`).
## Key Rules
- `import-x/no-duplicates`.
- `import-x/no-self-import`.
- `import-x/no-cycle` (warn).
- `import-x/no-mutable-exports`.
- `import-x/consistent-type-specifier-style`: `prefer-top-level`.
## Examples
```ts
// ✅ Good
import type { User } from './types';
import { getUser } from './service';
// ❌ Bad
import { getUser } from './service';
import { getUser as getUser2 } from './service';
export let state = 0;
```
+22
View File
@@ -0,0 +1,22 @@
# node preset
## Purpose
Node.js-правила (через `eslint-plugin-n`) и Node-глобалы в `languageOptions.globals`.
## Key Rules
- `n/no-exports-assign`: запрещает перезапись `exports`.
- `n/no-new-require`: запрещает `new require(...)`.
## Examples
```ts
// ✅ Good
module.exports = { run };
const mod = require('./mod');
// ❌ Bad
exports = { run };
const bad = new require('./mod');
```
+51
View File
@@ -0,0 +1,51 @@
# stylistic preset
## Purpose
Форматирование через `@stylistic/eslint-plugin` (отступы, пробелы, скобки, переносы, TS/JSX-стиль).
## Defaults
- `indent: 2`
- `quotes: single`
- `semi: always`
- `braceStyle: stroustrup`
- `commaDangle: always-multiline`
- `arrowParens: as-needed`
## Key Rules
- `@stylistic/indent`, `@stylistic/no-tabs`.
- `@stylistic/quotes`, `@stylistic/semi`.
- `@stylistic/object-curly-spacing`, `@stylistic/comma-spacing`.
- `@stylistic/arrow-spacing`, `@stylistic/space-before-function-paren`.
- `@stylistic/max-statements-per-line`.
- `@stylistic/no-mixed-operators`.
- `@stylistic/member-delimiter-style` (TS).
## Examples
```ts
// ✅ Good
type User = {
id: string;
role: 'admin' | 'user';
};
const value = condition
? 'yes'
: 'no';
const sum = (a: number, b: number) => a + b;
// ❌ Bad
type User = {
id: string
role: 'admin' | 'user'
}
const value = condition ? 'yes' : 'no'; const x = 1;
const sum=(a:number,b:number)=>{ return a+b };
```
Полный список правил и их настройки см. в `src/presets/stylistic.ts`.
+33
View File
@@ -0,0 +1,33 @@
# typescript preset
## Purpose
TypeScript-правила для `.ts/.tsx/.mts/.cts` и `<script lang="ts">` в `.vue`. Базируется на `typescript-eslint` recommended (без type-checking).
## Key Rules
- `@typescript-eslint/consistent-type-imports`: выносит типы в `import type`.
- `@typescript-eslint/no-import-type-side-effects`: запрещает сайд-эффекты в type import.
- `@typescript-eslint/prefer-as-const`.
- `@typescript-eslint/no-namespace`, `@typescript-eslint/triple-slash-reference`.
- `@typescript-eslint/no-wrapper-object-types`: запрещает `String`, `Number`, `Boolean`.
## Examples
```ts
// ✅ Good
import type { User } from './types';
const status = 'ok' as const;
interface Payload {
value: string;
}
// ❌ Bad
import { User } from './types';
type Boxed = String;
namespace Legacy {
export const x = 1;
}
```
+34
View File
@@ -0,0 +1,34 @@
# vitest preset
## Purpose
Правила для тестов (`*.test.*`, `*.spec.*`, `test/**`, `__tests__/**`).
## Key Rules
- `vitest/no-conditional-tests`.
- `vitest/no-import-node-test`.
- `vitest/prefer-to-be-truthy`, `vitest/prefer-to-be-falsy`.
- `vitest/prefer-to-have-length`.
- Relaxations: `no-unused-vars` и `@typescript-eslint/no-explicit-any` выключены для тестов.
## Examples
```ts
// ✅ Good
import { describe, it, expect } from 'vitest';
describe('list', () => {
it('has items', () => {
expect([1, 2, 3]).toHaveLength(3);
expect(true).toBeTruthy();
});
});
// ❌ Bad
if (process.env.CI) {
it('conditionally runs', () => {
expect(true).toBe(true);
});
}
```
+30
View File
@@ -0,0 +1,30 @@
# vue preset
## Purpose
Правила для Vue 3 с упором на Composition API и `<script setup>`.
## Key Rules
- `vue/no-export-in-script-setup`.
- `vue/no-import-compiler-macros`.
- `vue/define-props-declaration`: type-based.
- `vue/define-emits-declaration`: type-based.
- `vue/valid-define-props`, `vue/valid-define-emits`.
- `vue/no-lifecycle-after-await`.
## Examples
```vue
<script setup lang="ts">
const props = defineProps<{ id: string }>();
const emit = defineEmits<{ change: [value: string] }>();
</script>
<!-- Bad -->
<script setup lang="ts">
import { defineProps } from 'vue';
export const x = 1;
const props = defineProps({ id: String });
</script>
```
+34
View File
@@ -0,0 +1,34 @@
import type { FlatConfigArray, FlatConfigInput } from './types';
/**
* Compose multiple ESLint flat configurations into a single flat config array.
*
* ESLint flat config is an ordered array where later entries override earlier
* ones, so composition is a flatten: each preset (an array) and each inline
* override (a single object) are concatenated in order. `undefined`/`null`
* inputs are skipped, allowing conditional spreads.
*
* @example
* ```ts
* import { compose, base, typescript, vue } from '@robonen/eslint';
*
* export default compose(base, typescript, vue, {
* rules: { 'no-console': 'off' },
* });
* ```
*/
export function compose(...configs: Array<FlatConfigInput | false | null | undefined>): FlatConfigArray {
const result: FlatConfigArray = [];
for (const config of configs) {
if (!config)
continue;
if (Array.isArray(config))
result.push(...config);
else
result.push(config);
}
return result;
}
+13
View File
@@ -0,0 +1,13 @@
/* Compose */
export { compose } from './compose';
/* Presets */
export { base, ignores, typescript, vue, vitest, imports, node, stylistic, regexp } from './presets';
/* Types */
export type {
FlatConfig,
FlatConfigArray,
FlatConfigInput,
Rules,
} from './types';
+128
View File
@@ -0,0 +1,128 @@
import type { FlatConfigArray } from '../types';
import js from '@eslint/js';
import unicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
import { regexp } from './regexp';
/**
* Globally ignored paths — build output, coverage, generated artifacts.
*
* A flat config entry with only `ignores` acts as a global ignore.
*/
export const ignores: FlatConfigArray = [
{
name: 'robonen/ignores',
ignores: [
'**/dist/**',
'**/coverage/**',
'**/node_modules/**',
'**/.nuxt/**',
'**/.output/**',
'**/storybook-static/**',
'**/*.min.*',
// Hand-authored docs-site content co-located in packages (intro/guide .vue
// pages and per-composable demos). Not shipped source; lints against the
// docs app's own toolchain, and non-Vue packages can't even parse .vue.
'**/docs/**',
'**/demo.vue',
],
},
];
/**
* Base configuration for any JavaScript/TypeScript project.
*
* Includes `@eslint/js` recommended rules (the analog of oxlint's
* `correctness` category) plus opinionated `eslint` core and `unicorn` rules.
*
* > Note: oxlint's `oxc/*` rules have no ESLint equivalent and are dropped in
* > this migration; their intent is largely covered by `@eslint/js` recommended
* > and `unicorn`.
*/
export const base: FlatConfigArray = [
...ignores,
{
name: 'robonen/base/setup',
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: {
...globals.es2024,
...globals.browser,
...globals.node,
},
},
},
js.configs.recommended,
{
name: 'robonen/base/rules',
plugins: {
unicorn,
},
rules: {
/* ── eslint core ──────────────────────────────────────── */
eqeqeq: 'error',
'no-console': 'warn',
'no-debugger': 'error',
'no-eval': 'error',
'no-var': 'error',
'prefer-const': 'error',
'prefer-template': 'error',
'no-useless-constructor': 'error',
'no-useless-rename': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-self-compare': 'error',
'no-template-curly-in-string': 'error',
'no-throw-literal': 'error',
'no-return-assign': 'error',
'no-else-return': 'error',
'no-lonely-if': 'error',
'no-unneeded-ternary': 'error',
'prefer-object-spread': 'error',
'prefer-exponentiation-operator': 'error',
'no-useless-computed-key': 'error',
'no-useless-concat': 'error',
'no-array-constructor': 'error',
'no-new-wrappers': 'error',
'no-useless-return': 'error',
'object-shorthand': ['error', 'always'],
'prefer-spread': 'error',
'prefer-rest-params': 'error',
'symbol-description': 'error',
curly: 'off',
/* ── unicorn ──────────────────────────────────────────── */
'unicorn/prefer-node-protocol': 'error',
'unicorn/no-instanceof-builtins': 'error',
'unicorn/no-new-array': 'error',
'unicorn/prefer-array-flat-map': 'error',
'unicorn/prefer-array-flat': 'error',
'unicorn/prefer-includes': 'error',
'unicorn/prefer-string-slice': 'error',
'unicorn/prefer-string-starts-ends-with': 'error',
'unicorn/throw-new-error': 'error',
'unicorn/error-message': 'error',
'unicorn/no-useless-spread': 'error',
'unicorn/no-useless-undefined': 'off',
'unicorn/prefer-optional-catch-binding': 'error',
'unicorn/prefer-type-error': 'error',
'unicorn/no-thenable': 'error',
'unicorn/prefer-number-properties': 'error',
'unicorn/prefer-global-this': 'error',
'unicorn/prefer-array-some': 'error',
'unicorn/prefer-array-find': 'error',
'unicorn/prefer-array-index-of': 'error',
'unicorn/prefer-date-now': 'error',
'unicorn/prefer-modern-math-apis': 'error',
'unicorn/prefer-negative-index': 'error',
'unicorn/prefer-set-has': 'error',
'unicorn/prefer-string-trim-start-end': 'error',
'unicorn/prefer-regexp-test': 'error',
'unicorn/prefer-string-replace-all': 'error',
'unicorn/no-typeof-undefined': 'error',
'unicorn/no-array-push-push': 'error',
'unicorn/no-useless-promise-resolve-reject': 'error',
},
},
...regexp,
];
+55
View File
@@ -0,0 +1,55 @@
import type { FlatConfigArray } from '../types';
import importX, { createNodeResolver } from 'eslint-plugin-import-x';
/**
* Import configuration for clean module boundaries.
*
* Uses `eslint-plugin-import-x` (the faster, modern fork) under the `import-x`
* namespace, plus the core `sort-imports` rule. The Node resolver is configured
* explicitly (flat config no longer ships a default) with TypeScript/Vue-aware
* extensions so resolution-based rules (`no-cycle`, `no-self-import`) work.
*/
export const imports: FlatConfigArray = [
{
name: 'robonen/imports',
plugins: {
'import-x': importX,
},
settings: {
'import-x/resolver-next': [
createNodeResolver({
extensions: ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.vue', '.json', '.node'],
}),
],
},
rules: {
'import-x/no-duplicates': 'error',
'import-x/no-self-import': 'error',
'import-x/no-cycle': 'error',
'import-x/first': 'error',
'import-x/no-mutable-exports': 'error',
'import-x/no-amd': 'error',
'import-x/no-commonjs': 'error',
'import-x/no-empty-named-blocks': 'error',
'import-x/no-useless-path-segments': ['error', { noUselessIndex: false }],
'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'],
/* Only enforce member order within `{ … }`; declaration order is sorted
by source path across the codebase, which core `sort-imports` (orders
by first member name) would otherwise fight. Kept at `warn` — it is not
autofixable and member order is a soft preference. */
'sort-imports': ['warn', { ignoreDeclarationSort: true }],
},
},
{
/* Vue SFCs may have two <script> blocks (`<script>` + `<script setup>`),
which the parser concatenates — `import-x/first` then wrongly flags the
second block's imports as out of place. Kept here (rather than in the
`vue` preset) so it wins regardless of preset composition order. */
name: 'robonen/imports/vue',
files: ['**/*.vue'],
rules: {
'import-x/first': 'off',
},
},
];
@@ -1,6 +1,8 @@
export { base } from './base';
export { base, ignores } from './base';
export { typescript } from './typescript';
export { vue } from './vue';
export { vitest } from './vitest';
export { imports } from './imports';
export { node } from './node';
export { regexp } from './regexp';
export { stylistic } from './stylistic';
+27
View File
@@ -0,0 +1,27 @@
import type { FlatConfigArray } from '../types';
import nodePlugin from 'eslint-plugin-n';
import globals from 'globals';
/**
* Node.js-specific configuration.
*
* Registers `eslint-plugin-n` (the maintained successor of `eslint-plugin-node`)
* under the `n` namespace and adds Node globals.
*/
export const node: FlatConfigArray = [
{
name: 'robonen/node',
plugins: {
n: nodePlugin,
},
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
'n/no-exports-assign': 'error',
'n/no-new-require': 'error',
},
},
];
+18
View File
@@ -0,0 +1,18 @@
import type { FlatConfigArray } from '../types';
import regexpPlugin from 'eslint-plugin-regexp';
/**
* Regular-expression correctness & optimization rules via
* [`eslint-plugin-regexp`](https://ota-meshi.github.io/eslint-plugin-regexp/).
*
* Applies the plugin's flat `recommended` ruleset — catches buggy/ambiguous
* patterns (control characters, useless quantifiers, ReDoS-prone constructs)
* and pushes toward clearer, faster expressions. Included in {@link base} so it
* applies to every package.
*/
export const regexp: FlatConfigArray = [
{
...regexpPlugin.configs['flat/recommended'],
name: 'robonen/regexp',
},
];
+165
View File
@@ -0,0 +1,165 @@
import type { FlatConfigArray } from '../types';
import stylisticPlugin from '@stylistic/eslint-plugin';
/**
* Stylistic formatting rules via `@stylistic/eslint-plugin`.
*
* Roughly equivalent to the plugin's `customize()` defaults:
* - indent: 2
* - quotes: single
* - semi: true
* - braceStyle: stroustrup
* - commaDangle: always-multiline
* - arrowParens: as-needed
* - blockSpacing: true
* - quoteProps: as-needed
* - jsx: true
*
* @see https://eslint.style/guide/config-presets
*/
export const stylistic: FlatConfigArray = [
{
name: 'robonen/stylistic',
plugins: {
'@stylistic': stylisticPlugin,
},
rules: {
/* ── spacing & layout ─────────────────────────────────── */
'@stylistic/array-bracket-spacing': ['error', 'never'],
'@stylistic/arrow-spacing': ['error', { after: true, before: true }],
'@stylistic/block-spacing': ['error', 'always'],
'@stylistic/comma-spacing': ['error', { after: true, before: false }],
'@stylistic/computed-property-spacing': ['error', 'never', { enforceForClassMembers: true }],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/key-spacing': ['error', { afterColon: true, beforeColon: false }],
'@stylistic/keyword-spacing': ['error', { after: true, before: true }],
'@stylistic/no-mixed-spaces-and-tabs': 'error',
'@stylistic/no-multi-spaces': 'error',
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/no-whitespace-before-property': 'error',
'@stylistic/rest-spread-spacing': ['error', 'never'],
'@stylistic/semi-spacing': ['error', { after: true, before: false }],
'@stylistic/space-before-blocks': ['error', 'always'],
'@stylistic/space-before-function-paren': ['error', { anonymous: 'always', asyncArrow: 'always', named: 'never' }],
'@stylistic/space-in-parens': ['error', 'never'],
'@stylistic/space-infix-ops': 'error',
'@stylistic/space-unary-ops': ['error', { nonwords: false, words: true }],
'@stylistic/template-curly-spacing': 'error',
'@stylistic/template-tag-spacing': ['error', 'never'],
/* ── braces & blocks ──────────────────────────────────── */
'@stylistic/brace-style': ['error', 'stroustrup', { allowSingleLine: true }],
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
'@stylistic/no-extra-parens': ['error', 'functions'],
'@stylistic/no-floating-decimal': 'error',
'@stylistic/wrap-iife': ['error', 'any', { functionPrototypeMethods: true }],
'@stylistic/new-parens': 'error',
'@stylistic/padded-blocks': ['error', { blocks: 'never', classes: 'never', switches: 'never' }],
/* ── punctuation ──────────────────────────────────────── */
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/comma-style': ['error', 'last'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always', avoidEscape: false }],
'@stylistic/quote-props': ['error', 'as-needed'],
/* ── indentation ──────────────────────────────────────── */
'@stylistic/indent': ['error', 2, {
ArrayExpression: 1,
CallExpression: { arguments: 1 },
flatTernaryExpressions: false,
FunctionDeclaration: { body: 1, parameters: 1, returnType: 1 },
FunctionExpression: { body: 1, parameters: 1, returnType: 1 },
ignoreComments: false,
ignoredNodes: [
'TSUnionType',
'TSIntersectionType',
],
ImportDeclaration: 1,
MemberExpression: 1,
ObjectExpression: 1,
offsetTernaryExpressions: true,
outerIIFEBody: 1,
SwitchCase: 1,
tabLength: 2,
VariableDeclarator: 1,
}],
'@stylistic/indent-binary-ops': ['error', 2],
'@stylistic/no-tabs': 'error',
/* ── line breaks ──────────────────────────────────────── */
'@stylistic/eol-last': 'error',
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }],
'@stylistic/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
'@stylistic/max-statements-per-line': ['error', { max: 1 }],
'@stylistic/multiline-ternary': ['error', 'always-multiline'],
'@stylistic/operator-linebreak': ['error', 'before'],
'@stylistic/object-curly-spacing': ['error', 'always'],
/* ── generators ───────────────────────────────────────── */
'@stylistic/generator-star-spacing': ['error', { after: true, before: false }],
'@stylistic/yield-star-spacing': ['error', { after: true, before: false }],
/* ── operators & mixed ────────────────────────────────── */
'@stylistic/no-mixed-operators': ['error', {
allowSamePrecedence: true,
groups: [
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
['&&', '||'],
['in', 'instanceof'],
],
}],
/* ── typescript styling ───────────────────────────────── */
'@stylistic/member-delimiter-style': ['error', {
multiline: { delimiter: 'semi', requireLast: true },
multilineDetection: 'brackets',
overrides: {
interface: {
multiline: { delimiter: 'semi', requireLast: true },
},
},
singleline: { delimiter: 'semi' },
}],
'@stylistic/type-annotation-spacing': ['error', {}],
'@stylistic/type-generic-spacing': 'error',
'@stylistic/type-named-tuple-spacing': 'error',
/* ── comments ─────────────────────────────────────────── */
'@stylistic/spaced-comment': ['error', 'always', {
block: { balanced: true, exceptions: ['*'], markers: ['!'] },
line: { exceptions: ['/', '#'], markers: ['/'] },
}],
/* ── jsx ───────────────────────────────────────────────── */
'@stylistic/jsx-closing-bracket-location': 'error',
'@stylistic/jsx-closing-tag-location': 'error',
'@stylistic/jsx-curly-brace-presence': ['error', { propElementValues: 'always' }],
'@stylistic/jsx-curly-newline': 'error',
'@stylistic/jsx-curly-spacing': ['error', 'never'],
'@stylistic/jsx-equals-spacing': 'error',
'@stylistic/jsx-first-prop-new-line': 'error',
'@stylistic/jsx-function-call-newline': ['error', 'multiline'],
'@stylistic/jsx-indent-props': ['error', 2],
'@stylistic/jsx-max-props-per-line': ['error', { maximum: 1, when: 'multiline' }],
'@stylistic/jsx-one-expression-per-line': ['error', { allow: 'single-child' }],
'@stylistic/jsx-quotes': 'error',
'@stylistic/jsx-tag-spacing': ['error', {
afterOpening: 'never',
beforeClosing: 'never',
beforeSelfClosing: 'always',
closingSlash: 'never',
}],
'@stylistic/jsx-wrap-multilines': ['error', {
arrow: 'parens-new-line',
assignment: 'parens-new-line',
condition: 'parens-new-line',
declaration: 'parens-new-line',
logical: 'parens-new-line',
prop: 'parens-new-line',
propertyValue: 'parens-new-line',
return: 'parens-new-line',
}],
},
},
];
+64
View File
@@ -0,0 +1,64 @@
import type { FlatConfigArray } from '../types';
import tseslint from 'typescript-eslint';
/**
* TypeScript-specific configuration.
*
* Adopts `typescript-eslint`'s **strict** (non type-checked) ruleset plus the
* **stylistic** ruleset — registering the parser/plugin, disabling core rules
* superseded by TS-aware counterparts, and enforcing the full strict + stylistic
* sets at `error`. A small overlay re-tunes a few rules for this monorepo.
*
* Two deliberate carve-outs: `no-explicit-any` is kept at `warn` (the low-level
* stdlib/toolkit does a lot of type-boundary work where `any` is idiomatic), and
* `no-non-null-assertion` is `off` (the `!` operator is how the codebase satisfies
* `noUncheckedIndexedAccess` on provably-bounded indexed access).
*
* `.vue` files are included so the rules apply inside `<script lang="ts">`
* blocks; the `vue` preset assigns the matching parser for them.
*/
export const typescript: FlatConfigArray = [
...tseslint.configs.strict,
...tseslint.configs.stylistic,
{
name: 'robonen/typescript',
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts', '**/*.vue'],
rules: {
/* core no-unused-vars is replaced by the TS-aware version */
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
/* TypeScript already reports undefined names; `no-undef` only adds
false positives (e.g. globals, auto-imports, compiler macros). */
'no-undef': 'off',
/* Deliberate carve-outs from `strict` (see file header). */
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'off',
/* noop/default callbacks (`() => {}`) are an intentional, pervasive pattern. */
'@typescript-eslint/no-empty-function': 'off',
/* The libraries expose deliberate overload signatures (better inference/DX
than a single union signature) — don't force-merge them. */
'@typescript-eslint/unified-signatures': 'off',
/* Plain objects are used as keyed dictionaries (e.g. forms errors/touched
maps) where dynamic `delete` is legitimate. */
'@typescript-eslint/no-dynamic-delete': 'off',
/* Index-based `for` loops are sometimes a deliberate perf choice; this rule
is not autofixable and converting can subtly change semantics. */
'@typescript-eslint/prefer-for-of': 'off',
/* Idiomatic callback return unions (`() => void | false`, `() => void |
Promise<T>`) are pervasive in composables; the rule is hostile to them. */
'@typescript-eslint/no-invalid-void-type': 'off',
/* Allow our type-helper interfaces that extend a single mapped type. */
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
/* House preferences (override the strict/stylistic defaults' options). */
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-useless-empty-export': 'error',
},
},
];
+49
View File
@@ -0,0 +1,49 @@
import type { FlatConfigArray } from '../types';
import vitestPlugin from '@vitest/eslint-plugin';
/**
* Vitest configuration for test files.
*
* Scoped to common test file patterns. Adopts the plugin's full `recommended`
* ruleset, layers extra preference rules at `error`, and relaxes a few strict
* rules that are noisy in tests.
*/
export const vitest: FlatConfigArray = [
{
name: 'robonen/vitest',
files: [
'**/*.test.{ts,tsx,js,jsx}',
'**/*.spec.{ts,tsx,js,jsx}',
'**/test/**/*.{ts,tsx,js,jsx}',
'**/__tests__/**/*.{ts,tsx,js,jsx}',
],
plugins: {
vitest: vitestPlugin,
},
rules: {
...vitestPlugin.configs.recommended.rules,
/* House convention: `describe(useX, …)` / `it(fn, …)` pass a FUNCTION as the
title (nicer reporter output) — valid-title only accepts strings. */
'vitest/valid-title': 'off',
/* Niche stylistic preference; the explicit two-assertion form is clearer. */
'vitest/prefer-called-exactly-once-with': 'off',
'vitest/no-import-node-test': 'error',
'vitest/no-conditional-tests': 'error',
'vitest/prefer-to-be-truthy': 'error',
'vitest/prefer-to-be-falsy': 'error',
'vitest/prefer-to-be-object': 'error',
'vitest/prefer-to-have-length': 'error',
'vitest/consistent-test-filename': 'error',
'vitest/prefer-describe-function-title': 'error',
/* relax strict rules in tests */
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
/* Empty mock/fixture classes (e.g. stubbing `class DeviceOrientationEvent {}`). */
'@typescript-eslint/no-extraneous-class': 'off',
},
},
];
+64
View File
@@ -0,0 +1,64 @@
import type { FlatConfigArray, Rules } from '../types';
import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import vueParser from 'vue-eslint-parser';
/**
* Merge the rule maps from every config object in an eslint-plugin-vue flat
* preset (they ship as arrays) into a single rules record.
*/
function collectRules(configs: Array<{ rules?: unknown }>): Rules {
return configs.reduce<Rules>((rules, config) => ({ ...rules, ...(config.rules as Rules | undefined) }), {});
}
/**
* The Priority-A "Essential" (error-prevention) ruleset from eslint-plugin-vue.
*/
const essentialRules = collectRules(pluginVue.configs['flat/essential'] as Array<{ rules?: unknown }>);
/**
* Vue.js configuration.
*
* Registers `eslint-plugin-vue` with `vue-eslint-parser` (delegating
* `<script lang="ts">` to the TypeScript parser), adopts the plugin's full
* **Essential** (Priority-A) ruleset, and layers opinionated rules that enforce
* the Composition API with `<script setup>` and type-based declarations.
*/
export const vue: FlatConfigArray = [
{
name: 'robonen/vue/setup',
files: ['**/*.vue'],
plugins: {
vue: pluginVue,
},
processor: pluginVue.processors['.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
ecmaFeatures: { jsx: true },
extraFileExtensions: ['.vue'],
sourceType: 'module',
},
},
},
{
name: 'robonen/vue/rules',
files: ['**/*.vue'],
rules: {
...essentialRules,
/* Component library: single-word component names (Primitive, Slot, …) are intentional. */
'vue/multi-word-component-names': 'off',
/* House additions / stricter opinions on top of Essential. */
'vue/no-multiple-slot-args': 'error',
'vue/no-import-compiler-macros': 'error',
'vue/define-emits-declaration': ['error', 'type-based'],
'vue/define-props-declaration': ['error', 'type-based'],
'vue/prefer-import-from-vue': 'error',
'vue/no-required-prop-with-default': 'error',
'vue/require-typed-ref': 'error',
},
},
];
+27
View File
@@ -0,0 +1,27 @@
import type { Linter } from 'eslint';
/**
* A single ESLint flat configuration object.
*
* @see https://eslint.org/docs/latest/use/configure/configuration-files
*/
export type FlatConfig = Linter.Config;
/**
* An array of ESLint flat configuration objects — the shape ESLint
* expects from an `eslint.config.ts` default export.
*/
export type FlatConfigArray = FlatConfig[];
/**
* A flat config rules record (`Partial<Linter.RulesRecord>`).
*/
export type Rules = NonNullable<FlatConfig['rules']>;
/**
* Accepts either a single flat config object or an array of them.
*
* Used by {@link compose} so presets (arrays) and inline overrides
* (single objects) can be passed interchangeably.
*/
export type FlatConfigInput = FlatConfig | FlatConfigArray;
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { compose } from '../src/compose';
import type { FlatConfig } from '../src/types';
describe('compose', () => {
it('should return empty array when no configs provided', () => {
expect(compose()).toEqual([]);
});
it('should wrap a single flat config object into an array', () => {
const config: FlatConfig = {
name: 'a',
rules: { 'no-console': 'warn' },
};
expect(compose(config)).toEqual([config]);
});
it('should flatten preset arrays into a single array', () => {
const preset: FlatConfig[] = [
{ name: 'a', rules: { 'no-console': 'warn' } },
{ name: 'b', rules: { 'no-debugger': 'error' } },
];
expect(compose(preset)).toEqual(preset);
});
it('should preserve order across presets and inline objects', () => {
const presetA: FlatConfig[] = [{ name: 'a' }];
const presetB: FlatConfig[] = [{ name: 'b' }, { name: 'c' }];
const inline: FlatConfig = { name: 'd' };
const result = compose(presetA, presetB, inline);
expect(result.map(c => c.name)).toEqual(['a', 'b', 'c', 'd']);
});
it('should not mutate the input arrays', () => {
const preset: FlatConfig[] = [{ name: 'a' }];
compose(preset, { name: 'b' });
expect(preset).toEqual([{ name: 'a' }]);
});
it('should skip falsy entries for conditional composition', () => {
const result = compose(
{ name: 'a' },
false,
null,
undefined,
{ name: 'b' },
);
expect(result.map(c => c.name)).toEqual(['a', 'b']);
});
it('should compose all presets together preserving order', () => {
const base: FlatConfig[] = [{ name: 'base/setup' }, { name: 'base/rules' }];
const ts: FlatConfig[] = [{ name: 'ts' }];
const custom: FlatConfig = { name: 'custom', rules: { 'no-console': 'off' } };
const result = compose(base, ts, custom);
expect(result.map(c => c.name)).toEqual(['base/setup', 'base/rules', 'ts', 'custom']);
expect(result.at(-1)?.rules).toEqual({ 'no-console': 'off' });
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.src.json" },
{ "path": "./tsconfig.node.json" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@robonen/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["tsdown.config.ts", "vitest.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@robonen/tsconfig/tsconfig.base.json",
"compilerOptions": {
"composite": true,
"types": [],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts", "eslint.config.ts"]
}
@@ -3,5 +3,6 @@ import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
tsconfig: './tsconfig.src.json',
entry: ['src/index.ts'],
});
-54
View File
@@ -1,54 +0,0 @@
# @robonen/oxlint
Composable [oxlint](https://oxc.rs/docs/guide/usage/linter.html) configuration presets.
## Install
```bash
pnpm install -D @robonen/oxlint oxlint
```
## Usage
Create `oxlint.config.ts` in your project root:
```ts
import { defineConfig } from 'oxlint';
import { compose, base, typescript, vue, vitest, imports } from '@robonen/oxlint';
export default defineConfig(
compose(base, typescript, vue, vitest, imports),
);
```
Append custom rules after presets to override them:
```ts
compose(base, typescript, {
rules: { 'eslint/no-console': 'off' },
ignorePatterns: ['dist'],
});
```
## Presets
| Preset | Description |
| ------------ | -------------------------------------------------- |
| `base` | Core eslint, oxc, unicorn rules |
| `typescript` | TypeScript-specific rules (via overrides) |
| `vue` | Vue 3 Composition API / `<script setup>` rules |
| `vitest` | Test file rules (via overrides) |
| `imports` | Import rules (cycles, duplicates, ordering) |
| `node` | Node.js-specific rules |
## API
### `compose(...configs: OxlintConfig[]): OxlintConfig`
Merges multiple configs into one:
- **plugins** — union (deduplicated)
- **rules / categories** — last wins
- **overrides / ignorePatterns** — concatenated
- **env / globals** — shallow merge
- **settings** — deep merge
-4
View File
@@ -1,4 +0,0 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports));
-53
View File
@@ -1,53 +0,0 @@
{
"name": "@robonen/oxlint",
"version": "0.0.2",
"license": "Apache-2.0",
"description": "Composable oxlint configuration presets",
"keywords": [
"oxlint",
"oxc",
"linter",
"config",
"presets"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/oxlint"
},
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"oxlint": "catalog:",
"tsdown": "catalog:"
},
"peerDependencies": {
"oxlint": ">=1.0.0"
},
"publishConfig": {
"access": "public"
}
}
-103
View File
@@ -1,103 +0,0 @@
import type { OxlintConfig } from './types';
/**
* Deep merge two objects. Arrays are concatenated, objects are recursively merged.
*/
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
const result = { ...target };
for (const key of Object.keys(source)) {
const targetValue = target[key];
const sourceValue = source[key];
if (
typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue)
&& typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>,
);
}
else {
result[key] = sourceValue;
}
}
return result;
}
/**
* Compose multiple oxlint configurations into a single config.
*
* - `plugins` — union (deduplicated)
* - `categories` — later configs override earlier
* - `rules` — later configs override earlier
* - `overrides` — concatenated
* - `env` — merged (later overrides earlier)
* - `globals` — merged (later overrides earlier)
* - `settings` — deep-merged
* - `ignorePatterns` — concatenated
*
* @example
* ```ts
* import { compose, base, typescript, vue } from '@robonen/oxlint';
* import { defineConfig } from 'oxlint';
*
* export default defineConfig(
* compose(base, typescript, vue, {
* rules: { 'eslint/no-console': 'off' },
* }),
* );
* ```
*/
export function compose(...configs: OxlintConfig[]): OxlintConfig {
const result: OxlintConfig = {};
for (const config of configs) {
// Plugins — union with dedup
if (config.plugins?.length) {
result.plugins = Array.from(new Set([...(result.plugins ?? []), ...config.plugins]));
}
// Categories — shallow merge
if (config.categories) {
result.categories = { ...result.categories, ...config.categories };
}
// Rules — shallow merge (later overrides earlier)
if (config.rules) {
result.rules = { ...result.rules, ...config.rules };
}
// Overrides — concatenate
if (config.overrides?.length) {
result.overrides = [...(result.overrides ?? []), ...config.overrides];
}
// Env — shallow merge
if (config.env) {
result.env = { ...result.env, ...config.env };
}
// Globals — shallow merge
if (config.globals) {
result.globals = { ...result.globals, ...config.globals };
}
// Settings — deep merge
if (config.settings) {
result.settings = deepMerge(
(result.settings ?? {}) as Record<string, unknown>,
config.settings as Record<string, unknown>,
);
}
// Ignore patterns — concatenate
if (config.ignorePatterns?.length) {
result.ignorePatterns = [...(result.ignorePatterns ?? []), ...config.ignorePatterns];
}
}
return result;
}
-17
View File
@@ -1,17 +0,0 @@
/* Compose */
export { compose } from './compose';
/* Presets */
export { base, typescript, vue, vitest, imports, node } from './presets';
/* Types */
export type {
OxlintConfig,
OxlintOverride,
OxlintEnv,
OxlintGlobals,
AllowWarnDeny,
DummyRule,
DummyRuleMap,
RuleCategories,
} from './types';
-73
View File
@@ -1,73 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Base configuration for any JavaScript/TypeScript project.
*
* Enables `correctness` category and opinionated rules from
* `eslint`, `oxc`, and `unicorn` plugins.
*/
export const base: OxlintConfig = {
plugins: ['eslint', 'oxc', 'unicorn'],
categories: {
correctness: 'error',
},
rules: {
/* ── eslint core ──────────────────────────────────────── */
'eslint/eqeqeq': 'error',
'eslint/no-console': 'warn',
'eslint/no-debugger': 'error',
'eslint/no-eval': 'error',
'eslint/no-var': 'error',
'eslint/prefer-const': 'error',
'eslint/prefer-template': 'warn',
'eslint/no-useless-constructor': 'warn',
'eslint/no-useless-rename': 'warn',
'eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'eslint/no-self-compare': 'error',
'eslint/no-template-curly-in-string': 'warn',
'eslint/no-throw-literal': 'error',
'eslint/no-return-assign': 'warn',
'eslint/no-else-return': 'warn',
'eslint/no-lonely-if': 'warn',
'eslint/no-unneeded-ternary': 'warn',
'eslint/prefer-object-spread': 'warn',
'eslint/prefer-exponentiation-operator': 'warn',
'eslint/no-useless-computed-key': 'warn',
'eslint/no-useless-concat': 'warn',
'eslint/curly': 'off',
/* ── unicorn ──────────────────────────────────────────── */
'unicorn/prefer-node-protocol': 'error',
'unicorn/no-instanceof-array': 'error',
'unicorn/no-new-array': 'error',
'unicorn/prefer-array-flat-map': 'warn',
'unicorn/prefer-array-flat': 'warn',
'unicorn/prefer-includes': 'warn',
'unicorn/prefer-string-slice': 'warn',
'unicorn/prefer-string-starts-ends-with': 'warn',
'unicorn/throw-new-error': 'error',
'unicorn/error-message': 'warn',
'unicorn/no-useless-spread': 'warn',
'unicorn/no-useless-undefined': 'off',
'unicorn/prefer-optional-catch-binding': 'warn',
'unicorn/prefer-type-error': 'warn',
'unicorn/no-thenable': 'error',
'unicorn/prefer-number-properties': 'warn',
'unicorn/prefer-global-this': 'warn',
/* ── oxc ──────────────────────────────────────────────── */
'oxc/no-accumulating-spread': 'warn',
'oxc/bad-comparison-sequence': 'error',
'oxc/bad-min-max-func': 'error',
'oxc/bad-object-literal-comparison': 'error',
'oxc/const-comparisons': 'error',
'oxc/double-comparisons': 'error',
'oxc/erasing-op': 'error',
'oxc/missing-throw': 'error',
'oxc/bad-bitwise-operator': 'error',
'oxc/bad-char-at-comparison': 'error',
'oxc/bad-replace-all-arg': 'error',
},
};
-20
View File
@@ -1,20 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Import plugin rules for clean module boundaries.
*/
export const imports: OxlintConfig = {
plugins: ['import'],
rules: {
'import/no-duplicates': 'error',
'import/no-self-import': 'error',
'import/no-cycle': 'warn',
'import/first': 'warn',
'import/no-mutable-exports': 'error',
'import/no-amd': 'error',
'import/no-commonjs': 'warn',
'import/no-empty-named-blocks': 'warn',
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
},
};
-17
View File
@@ -1,17 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Node.js-specific rules.
*/
export const node: OxlintConfig = {
plugins: ['node'],
env: {
node: true,
},
rules: {
'node/no-exports-assign': 'error',
'node/no-new-require': 'error',
},
};
-39
View File
@@ -1,39 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* TypeScript-specific rules.
*
* Applied via `overrides` for `*.ts`, `*.tsx`, `*.mts`, `*.cts` files.
*/
export const typescript: OxlintConfig = {
plugins: ['typescript'],
overrides: [
{
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
rules: {
'typescript/consistent-type-imports': 'error',
'typescript/no-explicit-any': 'off',
'typescript/no-non-null-assertion': 'off',
'typescript/prefer-as-const': 'error',
'typescript/no-empty-object-type': 'warn',
'typescript/no-wrapper-object-types': 'error',
'typescript/no-duplicate-enum-values': 'error',
'typescript/no-unsafe-declaration-merging': 'error',
'typescript/no-import-type-side-effects': 'error',
'typescript/no-useless-empty-export': 'warn',
'typescript/no-inferrable-types': 'warn',
'typescript/prefer-function-type': 'warn',
'typescript/ban-tslint-comment': 'error',
'typescript/consistent-type-definitions': ['warn', 'interface'],
'typescript/prefer-for-of': 'warn',
'typescript/no-unnecessary-type-constraint': 'warn',
'typescript/adjacent-overload-signatures': 'warn',
'typescript/array-type': ['warn', { default: 'array-simple' }],
'typescript/no-this-alias': 'error',
'typescript/triple-slash-reference': 'error',
'typescript/no-namespace': 'error',
},
},
],
};
-35
View File
@@ -1,35 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Vitest rules for test files.
*
* Applied via `overrides` for common test file patterns.
*/
export const vitest: OxlintConfig = {
plugins: ['vitest'],
overrides: [
{
files: [
'**/*.test.{ts,tsx,js,jsx}',
'**/*.spec.{ts,tsx,js,jsx}',
'**/test/**/*.{ts,tsx,js,jsx}',
'**/__tests__/**/*.{ts,tsx,js,jsx}',
],
rules: {
'vitest/no-conditional-tests': 'warn',
'vitest/no-import-node-test': 'error',
'vitest/prefer-to-be-truthy': 'warn',
'vitest/prefer-to-be-falsy': 'warn',
'vitest/prefer-to-be-object': 'warn',
'vitest/prefer-to-have-length': 'warn',
'vitest/consistent-test-filename': 'warn',
'vitest/prefer-describe-function-title': 'warn',
/* relax strict rules in tests */
'eslint/no-unused-vars': 'off',
'typescript/no-explicit-any': 'off',
},
},
],
};
-26
View File
@@ -1,26 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Vue.js-specific rules.
*
* Enforces Composition API with `<script setup>` and type-based declarations.
*/
export const vue: OxlintConfig = {
plugins: ['vue'],
rules: {
'vue/no-arrow-functions-in-watch': 'error',
'vue/no-deprecated-destroyed-lifecycle': 'error',
'vue/no-export-in-script-setup': 'error',
'vue/no-lifecycle-after-await': 'error',
'vue/no-multiple-slot-args': 'error',
'vue/no-import-compiler-macros': 'error',
'vue/define-emits-declaration': ['error', 'type-based'],
'vue/define-props-declaration': ['error', 'type-based'],
'vue/prefer-import-from-vue': 'error',
'vue/no-required-prop-with-default': 'warn',
'vue/valid-define-emits': 'error',
'vue/valid-define-props': 'error',
'vue/require-typed-ref': 'warn',
},
};
-18
View File
@@ -1,18 +0,0 @@
/**
* Re-exported configuration types from `oxlint`.
*
* Keeps the preset API in sync with the oxlint CLI without
* maintaining a separate copy of the types.
*
* @see https://oxc.rs/docs/guide/usage/linter/config-file-reference.html
*/
export type {
OxlintConfig,
OxlintOverride,
OxlintEnv,
OxlintGlobals,
AllowWarnDeny,
DummyRule,
DummyRuleMap,
RuleCategories,
} from 'oxlint';
-146
View File
@@ -1,146 +0,0 @@
import { describe, expect, it } from 'vitest';
import { compose } from '../src/compose';
import type { OxlintConfig } from '../src/types';
describe('compose', () => {
it('should return empty config when no configs provided', () => {
expect(compose()).toEqual({});
});
it('should return the same config when one config provided', () => {
const config: OxlintConfig = {
plugins: ['eslint'],
rules: { 'eslint/no-console': 'warn' },
};
const result = compose(config);
expect(result.plugins).toEqual(['eslint']);
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
});
it('should merge plugins with dedup', () => {
const a: OxlintConfig = { plugins: ['eslint', 'oxc'] };
const b: OxlintConfig = { plugins: ['oxc', 'typescript'] };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
});
it('should override rules from later configs', () => {
const a: OxlintConfig = { rules: { 'eslint/no-console': 'error', 'eslint/eqeqeq': 'warn' } };
const b: OxlintConfig = { rules: { 'eslint/no-console': 'off' } };
const result = compose(a, b);
expect(result.rules).toEqual({
'eslint/no-console': 'off',
'eslint/eqeqeq': 'warn',
});
});
it('should override categories from later configs', () => {
const a: OxlintConfig = { categories: { correctness: 'error', suspicious: 'warn' } };
const b: OxlintConfig = { categories: { suspicious: 'off' } };
const result = compose(a, b);
expect(result.categories).toEqual({
correctness: 'error',
suspicious: 'off',
});
});
it('should concatenate overrides', () => {
const a: OxlintConfig = {
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
};
const b: OxlintConfig = {
overrides: [{ files: ['**/*.test.ts'], rules: { 'eslint/no-unused-vars': 'off' } }],
};
const result = compose(a, b);
expect(result.overrides).toHaveLength(2);
expect(result.overrides?.[0]?.files).toEqual(['**/*.ts']);
expect(result.overrides?.[1]?.files).toEqual(['**/*.test.ts']);
});
it('should merge env', () => {
const a: OxlintConfig = { env: { browser: true } };
const b: OxlintConfig = { env: { node: true } };
const result = compose(a, b);
expect(result.env).toEqual({ browser: true, node: true });
});
it('should merge globals', () => {
const a: OxlintConfig = { globals: { MY_VAR: 'readonly' } };
const b: OxlintConfig = { globals: { ANOTHER: 'writable' } };
const result = compose(a, b);
expect(result.globals).toEqual({ MY_VAR: 'readonly', ANOTHER: 'writable' });
});
it('should deep merge settings', () => {
const a: OxlintConfig = {
settings: {
react: { version: '18.2.0' },
next: { rootDir: 'apps/' },
},
};
const b: OxlintConfig = {
settings: {
react: { linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }] },
},
};
const result = compose(a, b);
expect(result.settings).toEqual({
react: {
version: '18.2.0',
linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }],
},
next: { rootDir: 'apps/' },
});
});
it('should concatenate ignorePatterns', () => {
const a: OxlintConfig = { ignorePatterns: ['dist'] };
const b: OxlintConfig = { ignorePatterns: ['node_modules', 'coverage'] };
const result = compose(a, b);
expect(result.ignorePatterns).toEqual(['dist', 'node_modules', 'coverage']);
});
it('should handle composing all presets together', () => {
const base: OxlintConfig = {
plugins: ['eslint', 'oxc'],
categories: { correctness: 'error' },
rules: { 'eslint/no-console': 'warn' },
};
const ts: OxlintConfig = {
plugins: ['typescript'],
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
};
const custom: OxlintConfig = {
rules: { 'eslint/no-console': 'off' },
ignorePatterns: ['dist'],
};
const result = compose(base, ts, custom);
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
expect(result.categories).toEqual({ correctness: 'error' });
expect(result.rules).toEqual({ 'eslint/no-console': 'off' });
expect(result.overrides).toHaveLength(1);
expect(result.ignorePatterns).toEqual(['dist']);
});
it('should skip undefined/empty fields', () => {
const a: OxlintConfig = { plugins: ['eslint'] };
const b: OxlintConfig = { rules: { 'eslint/no-console': 'warn' } };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint']);
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
expect(result.overrides).toBeUndefined();
expect(result.env).toBeUndefined();
expect(result.settings).toBeUndefined();
});
});
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "@robonen/tsconfig/tsconfig.json"
}
+85 -10
View File
@@ -1,6 +1,6 @@
# @robonen/tsconfig
Shared base TypeScript configuration.
Shared TypeScript configurations.
## Install
@@ -8,20 +8,95 @@ Shared base TypeScript configuration.
pnpm install -D @robonen/tsconfig
```
## Presets
| Preset | Extends | Use for |
| --- | --- | --- |
| `tsconfig.base.json` | — | Node / isomorphic libraries (`lib: ESNext`, no DOM) |
| `tsconfig.dom.json` | base | Browser libraries (adds `DOM`, `DOM.Iterable`) |
| `tsconfig.vue.json` | dom | Vue SFC libraries / apps (adds `jsx`, `vueCompilerOptions`) |
| `tsconfig.node.json` | base | Build/test tooling files (`*.config.ts`) — adds `types: ["node"]`, no DOM |
| `tsconfig.json` | base | Default alias for `base` (bare `@robonen/tsconfig` import) |
## Usage
Extend from it in your `tsconfig.json`:
Pick the preset that matches the package and extend it:
```json
```jsonc
// Node / isomorphic library
{ "extends": "@robonen/tsconfig/tsconfig.base.json" }
```
```jsonc
// Browser library
{ "extends": "@robonen/tsconfig/tsconfig.dom.json" }
```
```jsonc
// Vue package, with path aliases
{
"extends": "@robonen/tsconfig/tsconfig.json"
"extends": "@robonen/tsconfig/tsconfig.vue.json",
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}
```
## What's Included
> Path aliases resolve relative to the `tsconfig.json` location — `baseUrl` is
> intentionally omitted (deprecated, removed in TypeScript 7.0).
- **Target / Module**: ESNext with Bundler resolution
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`
- **Declarations**: `declaration` enabled
- **Interop**: `esModuleInterop`, `allowJs`, `resolveJsonModule`
## Project references (DOM + Node split)
Most packages contain two environments: browser/library `src` (DOM) and Node
tooling files (`vite.config.ts`, `vitest.config.ts`, `tsdown.config.ts`). They
are split into separate projects wired with references, so `src` never sees Node
globals and config files never see `DOM`:
```jsonc
// tsconfig.json — solution root, tools (tsdown/vitest/editor) target src below
{
"files": [],
"references": [
{ "path": "./tsconfig.src.json" },
{ "path": "./tsconfig.node.json" }
]
}
```
```jsonc
// tsconfig.src.json — the library code
{
"extends": "@robonen/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"composite": true,
"types": [],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
```
```jsonc
// tsconfig.node.json — build/test tooling files
{
"extends": "@robonen/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["*.config.ts"]
}
```
Type-check the whole package (both projects) with `tsc -b` / `vue-tsc -b`.
Point `tsdown` at the src project (`tsconfig: './tsconfig.src.json'`) since the
root has no `compilerOptions`.
## What's included (base)
- **Target / Module**: `ESNext` with `module: Preserve` + `Bundler` resolution
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`, `noImplicitOverride`,
`noImplicitReturns`, `noFallthroughCasesInSwitch`, `noUncheckedSideEffectImports`
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`, `moduleDetection: force`
- **Type-check only**: `noEmit` (declarations/output are produced by `tsdown`)
- **Interop**: `esModuleInterop`, `resolveJsonModule`
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
const nodeExample = `// Node / isomorphic library
{ "extends": "@robonen/tsconfig/tsconfig.base.json" }`;
const vueExample = `// Vue package, with path aliases
{
"extends": "@robonen/tsconfig/tsconfig.vue.json",
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}`;
const splitExample = `// tsconfig.json — solution root
{
"files": [],
"references": [
{ "path": "./tsconfig.src.json" }, // extends tsconfig.dom.json
{ "path": "./tsconfig.node.json" } // *.config.ts, types: ["node"]
]
}`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/tsconfig</h1>
<p class="text-lg text-(--fg-muted)">
Shared, strict TypeScript configurations a small set of layered
presets you extend instead of copying compiler options between packages.
</p>
</div>
<div class="prose-docs">
<p>
Every package in a monorepo wants the same modern, strict TypeScript
baseline, but keeping a dozen <code>tsconfig.json</code> files in sync by
hand drifts almost immediately. <code>@robonen/tsconfig</code> ships one
carefully tuned <code>base</code> config and three environment layers
<code>dom</code>, <code>vue</code>, and <code>node</code> that extend
it. Point your package's <code>extends</code> at the right preset and you
inherit a consistent, bundler-first, type-check-only setup with no local
compiler options to maintain.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Layered presets</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>base</code> &rarr; <code>dom</code> &rarr; <code>vue</code>, plus
a sibling <code>node</code> layer. Extend the one that matches the
environment; everything else is inherited.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Strict by default</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>strict</code> plus <code>noUncheckedIndexedAccess</code>,
<code>noImplicitOverride</code>, <code>noImplicitReturns</code> and
<code>noFallthroughCasesInSwitch</code> are on out of the box.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Bundler-first</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>module: Preserve</code> with <code>Bundler</code> resolution,
<code>verbatimModuleSyntax</code> and <code>isolatedModules</code>.
Emit is <code>noEmit</code> — declarations come from
<code>tsdown</code>.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Env isolation</h3>
<p class="text-sm text-(--fg-muted) m-0">
Browser <code>src</code> (DOM, no Node globals) and tooling files
(<code>node</code> types, no DOM) split into separate projects wired
with project references.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
Add the package as a dev dependency. It ships only JSON presets — no
runtime code.
</p>
</div>
<DocsCode :code="`pnpm add -D @robonen/tsconfig`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
Pick the preset that matches the package and extend it from your
<code>tsconfig.json</code>:
</p>
</div>
<DocsCode :code="nodeExample" lang="json" />
<div class="prose-docs">
<p>
Vue SFC packages extend the <code>vue</code> layer (adds
<code>jsx: preserve</code> and strict
<code>vueCompilerOptions</code>) and can declare path aliases inline:
</p>
</div>
<DocsCode :code="vueExample" lang="json" />
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-4">
<p class="text-sm text-(--fg-muted) m-0">
<strong class="text-(--fg)">Note:</strong> path aliases resolve relative
to the <code class="text-(--fg)">tsconfig.json</code> location —
<code class="text-(--fg)">baseUrl</code> is intentionally omitted
(deprecated, removed in TypeScript 7.0).
</p>
</div>
<div class="prose-docs">
<h2>DOM + Node split</h2>
<p>
Most packages mix browser <code>src</code> with Node tooling files
(<code>vite.config.ts</code>, <code>vitest.config.ts</code>,
<code>tsdown.config.ts</code>). Split them into two projects wired with
references so <code>src</code> never sees Node globals and config files
never see <code>DOM</code>, then type-check the whole package with
<code>tsc -b</code> / <code>vue-tsc -b</code>:
</p>
</div>
<DocsCode :code="splitExample" lang="json" />
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
<li>
<strong class="text-(--fg)">Presets</strong> — the full table of
<code>base</code>, <code>dom</code>, <code>vue</code> and
<code>node</code>, with what each layer adds.
</li>
<li>
<strong class="text-(--fg)">Project references</strong> — the complete
DOM + Node split with <code>composite</code> and
<code>tsBuildInfoFile</code> wiring.
</li>
<li>
<strong class="text-(--fg)">What's included</strong> the exact
compiler options the <code>base</code> preset turns on.
</li>
<li>
See the guide sections in the sidebar for each of the above.
</li>
</ul>
</div>
</div>
</template>
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/tsconfig",
"version": "0.0.2",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Base typescript configuration for projects",
"keywords": [
@@ -15,12 +15,16 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/tsconfig"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.33.2",
"engines": {
"node": ">=24.13.1"
},
"files": [
"**tsconfig.json"
"tsconfig.json",
"tsconfig.base.json",
"tsconfig.dom.json",
"tsconfig.node.json",
"tsconfig.vue.json"
],
"publishConfig": {
"access": "public"
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "@robonen base (type-check, no DOM)",
"compilerOptions": {
/* Modules */
"module": "Preserve",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* Language and environment */
"target": "ESNext",
"lib": ["ESNext"],
"useDefineForClassFields": true,
/* Type checking (strict) */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"forceConsistentCasingInFileNames": true,
/* Emit is delegated to the bundler (tsdown); tsc is type-check only */
"noEmit": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "dist", ".output", "coverage"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "@robonen dom (browser libraries)",
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"]
}
}
+2 -33
View File
@@ -1,36 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Base TypeScript Configuration",
"compilerOptions": {
/* Basic Options */
"module": "ESNext",
"noEmit": true,
"lib": ["ESNext"],
"moduleResolution": "Bundler",
"target": "ESNext",
"outDir": "dist",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"removeComments": false,
"verbatimModuleSyntax": true,
"useDefineForClassFields": true,
/* Strict Type-Checking Options */
"strict": true,
"noUncheckedIndexedAccess": true,
/* Library transpiling */
"declaration": true,
// "composite": true,
"sourceMap": false,
"declarationMap": false
},
"exclude": ["node_modules", "dist", ".output", "coverage"]
"display": "@robonen base (default)",
"extends": "./tsconfig.base.json"
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "@robonen node (build/test tooling files)",
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ESNext"],
"types": ["node"]
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "@robonen vue (Vue SFC libraries / apps)",
"extends": "./tsconfig.dom.json",
"compilerOptions": {
"jsx": "preserve"
},
"vueCompilerOptions": {
"strictTemplates": true,
"fallthroughAttributes": true,
"inferTemplateDollarAttrs": true,
"inferTemplateDollarEl": true,
"inferTemplateDollarRefs": true
}
}
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
const usageExample = `import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
tsconfig: './tsconfig.src.json',
entry: ['src/index.ts'],
});`;
const overrideExample = `import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
import Vue from 'unplugin-vue/rolldown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts', 'src/*/index.ts'],
plugins: [Vue({ isProduction: true })],
// layer on top of the shared defaults
dts: { vue: true },
});`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/tsdown</h1>
<p class="text-lg text-(--fg-muted)">
Shared tsdown build configuration for every <code>@robonen</code>
package one source of truth for output formats, declarations, and
bundle hygiene.
</p>
</div>
<div class="prose-docs">
<p>
Every library in this monorepo ships dual ESM/CJS builds with type
declarations, a clean <code>dist</code>, and a consistent license
banner. Re-declaring that in each package's
<code>tsdown.config.ts</code> is repetitive and drifts over time.
<code>@robonen/tsdown</code> exports a single
<code>sharedConfig</code> object you spread into
<code>defineConfig</code>, then add only what is package-specific
usually just <code>entry</code> and <code>tsconfig</code>.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Dual ESM + CJS</h3>
<p class="text-sm text-(--fg-muted) m-0">
Emits both <code>esm</code> and <code>cjs</code> formats so packages
work in modern bundlers and legacy <code>require</code> setups alike.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Types included</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>dts: true</code> generates <code>.d.ts</code> declarations on
every build no separate type pipeline to maintain.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Clean, stable output</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>clean: true</code> wipes <code>dist</code> first and
<code>hash: false</code> keeps file names deterministic for
predictable publishing.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Spread &amp; override</h3>
<p class="text-sm text-(--fg-muted) m-0">
It is a plain object typed as <code>InlineConfig</code> spread it,
override any field, and let editor autocomplete guide you.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
Add the config package and <code>tsdown</code> itself as dev
dependencies:
</p>
</div>
<DocsCode :code="`pnpm add -D @robonen/tsdown tsdown`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
Create <code>tsdown.config.ts</code> in your package, spread
<code>sharedConfig</code>, and supply your entry points:
</p>
</div>
<DocsCode :code="usageExample" lang="ts" />
<div class="prose-docs">
<p>
Because <code>sharedConfig</code> is a normal object, you can override
or extend any field after spreading add plugins, extra entries, or
tweak the declaration options:
</p>
</div>
<DocsCode :code="overrideExample" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
<li>
<NuxtLink to="/tsdown/overview" class="text-(--accent-text) hover:underline">sharedConfig</NuxtLink>
the full list of defaults and their exact values.
</li>
<li>
Read the guide sections below for the conventions baked into the
shared build and tips for package-specific overrides.
</li>
</ul>
</div>
</div>
</template>
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.33.2",
"engines": {
"node": ">=24.13.1"
},
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Options } from 'tsdown';
import type { InlineConfig } from 'tsdown';
const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */';
@@ -10,4 +10,4 @@ export const sharedConfig = {
outputOptions: {
banner: BANNER,
},
} satisfies Options;
} satisfies InlineConfig;
+60
View File
@@ -0,0 +1,60 @@
# @robonen/crdt
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests.
## Primitives
| Module | Exports | Purpose |
| --- | --- | --- |
| `clock` | `OpId`, `LamportClock`, `VersionVector`, `compareOpId`, `createSiteId` | Causality: per-site Lamport ids with a deterministic total order; version vectors for dedup + deltas. |
| `registers` | `LwwRegister`, `LwwMap` | Last-writer-wins values / maps (conflict resolved by op id). |
| `ordering` | `keyBetween`, `keysBetween` | Fractional indexing — place an item strictly between two neighbors (or move it) with one string key. |
| `sequence` | `Rga` | Replicated Growable Array — a sequence CRDT with tombstones, higher-op-id-first tie-break, causal-buffering API. |
| `marks` | `MarkStore`, `MarkSpan`, `MarkValue` | Lightweight Peritext: formatting spans anchored to character op ids, resolved per character by highest op id. |
| `oplog` | `OpLog` | Append-only op log with a version vector; computes deltas. |
| `sync` | `encodeStateVector`, `encodeDelta`/`encodeOps`, `decode*` | Transport-agnostic wire encoding (JSON-over-bytes in v1). |
| `doc` | `Replica` | Ties a clock + op log + causal buffer together; integrates local/remote ops and exposes deltas. |
## Example: a converging replicated string
```ts
import { Replica, Rga, opId } from '@robonen/crdt';
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<{ id: ReturnType<typeof opId>; originLeft: ReturnType<typeof opId> | null; value: string }>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
const a = makeReplica('a');
const b = makeReplica('b');
// ... A and B make concurrent local edits via replica.commitLocal(...) ...
// Exchange only what each side is missing:
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
```
`Replica.receive` buffers ops whose causal dependencies haven't arrived yet (an insert before its origin, a delete before its target) and retries them automatically.
## Notes
- `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge.
- `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …).
- The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change.
- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic.
## Development
```bash
pnpm --filter @robonen/crdt test # property/convergence tests
pnpm --filter @robonen/crdt build # tsdown (ESM + CJS + dts)
```
+304
View File
@@ -0,0 +1,304 @@
<!-- title: Concepts -->
<!-- order: 1 -->
<script setup lang="ts">
const opIdSrc = `import { opId, opIdEq, opIdToString, createSiteId } from '@robonen/crdt';
// An OpId is just { site, clock } — a per-site Lamport counter
// tagged with the site that produced it.
const id = opId('alice', 3); // { site: 'alice', clock: 3 }
opIdToString(id); // 'alice@3'
opIdEq(id, opId('alice', 3)); // true
// A site id is a per-replica handle. Generate one when a session starts.
const site = createSiteId(); // e.g. 'k3f9a2d1xz'`;
const lamportSrc = `import { LamportClock } from '@robonen/crdt';
const clock = new LamportClock('alice');
clock.tick(); // { site: 'alice', clock: 1 }
clock.tick(); // { site: 'alice', clock: 2 }
// We hear about a remote op from 'bob' at clock 5.
clock.observe({ site: 'bob', clock: 5 });
// Our next local id jumps past it, so it's causally *after* what we've seen.
clock.tick(); // { site: 'alice', clock: 6 }`;
const compareSrc = `import { compareOpId, opId } from '@robonen/crdt';
// Higher clock wins.
compareOpId(opId('alice', 1), opId('alice', 2)); // < 0 (2 is greater)
// Equal clocks → site id breaks the tie, deterministically.
compareOpId(opId('alice', 2), opId('bob', 2)); // < 0 ('alice' < 'bob')
compareOpId(opId('bob', 2), opId('alice', 2)); // > 0
// Identical ids compare equal.
compareOpId(opId('alice', 2), opId('alice', 2)); // 0`;
const vvSrc = `import { VersionVector, opId } from '@robonen/crdt';
const vv = new VersionVector();
vv.observe(opId('alice', 3));
vv.observe(opId('bob', 1));
// "Have I already seen this op?" — the basis for dedup.
vv.has(opId('alice', 2)); // true (we've seen alice up to 3)
vv.has(opId('alice', 3)); // true
vv.has(opId('alice', 4)); // false (not yet)
vv.has(opId('carol', 1)); // false (never heard from carol)
// Highest dense clock per site (0 if a site is unknown).
vv.get('alice'); // 3
vv.get('carol'); // 0`;
const vvWireSrc = `import { VersionVector, opId } from '@robonen/crdt';
const local = new VersionVector();
local.observe(opId('alice', 5));
local.observe(opId('bob', 2));
// Snapshot for transport: a plain { site: clock } object.
const snapshot = local.toJSON(); // { alice: 5, bob: 2 }
// The other side reconstructs it and compares against its own log
// to compute exactly which ops you're missing.
const remoteKnows = VersionVector.fromJSON(snapshot);
remoteKnows.has(opId('alice', 4)); // true → skip it
remoteKnows.has(opId('alice', 6)); // false → send it`;
const propsSrc = `// Commutative — order of application doesn't matter:
// apply(apply(s, x), y) === apply(apply(s, y), x)
//
// Idempotent — re-applying a seen op is a no-op:
// apply(s, x) === apply(apply(s, x), x)
//
// Convergent — same op SET ⇒ same state, regardless of how it got there.
//
// These three together mean a network that reorders, duplicates, and
// delays messages can never push two replicas to different states.`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Concepts</h1>
<p>
Every primitive in <code>@robonen/crdt</code> rests on one small idea: if all replicas agree on a
<strong>deterministic total order</strong> over operations, then applying the same set of operations
in any order, with duplicates, after any delay always produces the same state. This page builds
that mental model from the ground up: sites and replicas, Lamport clocks and op ids, the single
tie-break that resolves every conflict, version vectors for deduplication and deltas, and the three
algebraic properties that make convergence inevitable rather than hopeful.
</p>
</div>
<div class="prose-docs">
<h2>Replicas and sites</h2>
<p>
A <strong>replica</strong> is one copy of the shared state a browser tab, a mobile app, a server
process. Each replica is owned by exactly one <strong>site</strong>, identified by a
<code>SiteId</code> (just a string). The site id is the thing that makes one replica distinguishable
from every other, so it must be unique across all participants. Use <code>createSiteId</code> to mint
one when a session begins; it trades on randomness for uniqueness, not secrecy, so there's no crypto
dependency.
</p>
<p>
Replicas never share mutable memory. They evolve independently and communicate only by exchanging
<strong>operations</strong> — small, self-describing facts like "insert this character" or "set this
key". The whole job of a CRDT is to make sure that once two replicas have seen the same operations,
they hold the same state, no matter what the network did to the messages in between.
</p>
</div>
<div class="prose-docs">
<h2>Op ids: naming every operation</h2>
<p>
For replicas to talk about the same operation — to deduplicate it, to refer to it as a causal
dependency, to break ties against it — every operation needs a stable, globally unique name. That
name is an <code>OpId</code>: a per-site counter (its Lamport <code>clock</code>) tagged with the
<code>site</code> that produced it.
</p>
</div>
<DocsCode :code="opIdSrc" lang="ts" />
<div class="prose-docs">
<p>
Because the counter is local to a site and the id carries that site, two replicas can generate ids
completely independently and never collide. There's no coordination, no central allocator, no UUID
round-trips uniqueness falls out of the structure. <code>opIdToString</code> gives the canonical
<code>site@clock</code> form, handy as a map key or for logging.
</p>
</div>
<div class="prose-docs">
<h2>Lamport clocks: encoding causality</h2>
<p>
A bare per-site counter is unique, but it isn't enough to compare two operations from different
sites in a meaningful way. <code>LamportClock</code> fixes that. It hands out monotonically
increasing ids via <code>tick()</code>, and — crucially — it <code>observe()</code>s the clocks of
remote operations it learns about, jumping its own counter ahead so that anything it produces next is
numbered <em>after</em> what it has already seen.
</p>
</div>
<DocsCode :code="lamportSrc" lang="ts" />
<div class="prose-docs">
<p>
This is the Lamport <em>happens-before</em> rule in miniature: if operation
<strong>A</strong> causally precedes <strong>B</strong> (B was generated by a replica that had
already seen A), then A's clock is strictly less than B's. The converse isn't guaranteed two ops
with unrelated clocks may simply be <strong>concurrent</strong>, produced by replicas that hadn't yet
heard from each other. That's fine, and expected: concurrency is exactly the situation a CRDT exists
to resolve.
</p>
</div>
<div class="prose-docs">
<h2>compareOpId: the one tie-break</h2>
<p>
Lamport clocks give a <em>partial</em> order they leave concurrent operations incomparable. But to
converge, every replica must agree on a single <strong>total</strong> order so that any two
operations can be ranked the same way everywhere. <code>compareOpId</code> is that total order, and it
is the only conflict-resolution rule in the entire library:
</p>
<ul>
<li><strong>Higher clock wins.</strong> A later operation supersedes an earlier one.</li>
<li>
<strong>Site id breaks ties.</strong> When two ops share a clock (they were concurrent), the
string comparison of their site ids picks a winner arbitrary, but identical on every replica.
</li>
</ul>
</div>
<DocsCode :code="compareSrc" lang="ts" />
<div class="prose-docs">
<p>
That second rule is the quiet hero of the whole design. The choice of winner doesn't matter; what
matters is that <em>every replica makes the same choice</em>. Because site ids are unique and string
comparison is deterministic, two replicas resolving the same concurrent edit will always pick the
same survivor. That single shared decision is what lets a last-writer-wins register and a sequence
CRDT, built by different code, nonetheless agree on the final document.
</p>
<div class="my-4 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why one rule for everything?</strong>
<code class="text-(--accent-text)">LwwRegister</code> uses
<code class="text-(--accent-text)">compareOpId</code> to pick the surviving value;
<code class="text-(--accent-text)">Rga</code> uses it to break ties between concurrent inserts at
the same position; <code class="text-(--accent-text)">MarkStore</code> uses it to decide which
formatting wins per character. One total order, applied consistently, is what turns a pile of
independent primitives into a coherent, converging system.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Version vectors: who has seen what</h2>
<p>
Op ids order operations; a <code>VersionVector</code> summarizes <em>which</em> operations a replica
has seen. It maps each known site to the highest clock observed from it. Its power comes from one
assumption: per-site clocks are <strong>dense</strong> — a site emits <code>1, 2, 3, …</code> with no
gaps. Given that, "highest clock seen from site X" implies "every op from X up to that clock has been
seen", so a single integer per site captures the entire causal history.
</p>
</div>
<DocsCode :code="vvSrc" lang="ts" />
<div class="prose-docs">
<h3>Deduplication</h3>
<p>
Networks redeliver. Because operations are idempotent (more on that below), re-applying one is
harmless — but <code>vv.has(id)</code> lets you skip the work entirely. If the vector already covers
an op's site and clock, you've seen it; drop it before it ever touches your state. This is the first
line of defense that keeps duplicate messages from doing anything observable.
</p>
<h3>Deltas</h3>
<p>
The same vector drives efficient sync. When a peer tells you its version vector, you compare it
against your own op log and send back <em>only</em> the operations it's missing never the whole
document. A site with clock <code>4</code> in their vector but <code>9</code> in yours means ops
<code>5</code> through <code>9</code> are the delta. Version vectors are tiny and serialize to a plain
<code>{ site: clock }</code> object, so they're cheap to ship as the "here's what I have" handshake.
</p>
</div>
<DocsCode :code="vvWireSrc" lang="ts" />
<div class="prose-docs">
<div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-amber-700 dark:text-amber-400">Density matters.</strong>
<code class="text-(--accent-text)">VersionVector</code> only works because clocks arrive without
gaps. If you generate ids with a raw <code class="text-(--accent-text)">LamportClock</code>, deliver
them in order per site (the <code class="text-(--accent-text)">Replica</code>'s causal buffer does
this for you) so a single high-water mark per site can stand in for the full set of seen ops.
</p>
</div>
</div>
<div class="prose-docs">
<h2>The three properties</h2>
<p>
Everything above exists to guarantee three algebraic properties of operations. They're the formal
promise behind "it just converges", and they're verified by property tests across the package.
</p>
</div>
<DocsCode :code="propsSrc" lang="ts" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Order of application doesn't change the result. A replica can integrate operations as they arrive,
in whatever sequence the network delivers them.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Applying the same operation twice is the same as applying it once. Redelivery and retries are safe;
version vectors make them free.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Same set of operations, same final state — full stop. Two replicas that have seen the same ops are
byte-for-byte identical.
</p>
</div>
</div>
<div class="prose-docs">
<p>
Commutativity and idempotency are <em>local</em> properties of how a single replica integrates an
operation. Convergence is the <em>global</em> consequence: if integration is both order-independent
and duplicate-safe, then the state of a replica is a pure function of the <em>set</em> of operations
it has seen, with no dependence on path or timing. That's why a CRDT tolerates the worst a network
can do — reordering, duplication, partition, arbitrary delay — and still lands every participant on
the same document.
</p>
</div>
<div class="prose-docs">
<h2>Putting it together</h2>
<p>
With the model in hand, the rest of the library reads as direct applications of it. The same
<code>OpId</code> that names an operation is the value <code>compareOpId</code> ranks; the same
Lamport clock that produced it advances when you observe a peer; the same dense clocks that make ids
unique make version vectors a one-integer-per-site summary. From here:
</p>
<ul>
<li>
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> — see the order in action across
<NuxtLink to="/crdt/rga">Rga</NuxtLink>, <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>,
and fractional indexing with <NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> — how
<NuxtLink to="/crdt/replica">Replica</NuxtLink> wires a clock, op log, and causal buffer into
version-vector deltas.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and reconcile,
live in the browser.
</li>
</ul>
</div>
</div>
</template>
+435
View File
@@ -0,0 +1,435 @@
<!-- title: Primitives -->
<!-- order: 2 -->
<script setup lang="ts">
const lwwRegister = `import { LwwRegister, opId } from '@robonen/crdt';
// Two replicas hold the same register, start from the same value.
const a = new LwwRegister('draft');
const b = new LwwRegister('draft');
// They write concurrently — A at clock 4, B at clock 5.
a.set('A wins?', opId('a', 4));
b.set('B wins!', opId('b', 5));
// Exchange the writes (order and duplicates don't matter):
a.set('B wins!', opId('b', 5)); // 5 > 4 → accepted, returns true
b.set('A wins?', opId('a', 4)); // 4 < 5 → rejected, returns false
a.get(); // 'B wins!'
a.get() === b.get(); // true — converged on the higher op id`;
const lwwMap = `import { LwwMap, opId } from '@robonen/crdt';
const a = new LwwMap<string, string>();
const b = new LwwMap<string, string>();
// Concurrent edits to the same key, plus a concurrent delete.
a.set('color', 'red', opId('a', 7));
b.set('color', 'blue', opId('b', 7)); // same clock — site id breaks the tie
a.delete('color', opId('a', 7)); // tie too: delete vs set at the same id
// After both replicas see all three ops…
b.set('color', 'red', opId('a', 7)); // already covered by b's clock-7 write
a.set('color', 'blue', opId('b', 7)); // 'b' > 'a' at clock 7 → blue wins
a.get('color'); // 'blue'
a.has('color'); // true
a.toEntries(); // [['color', 'blue']]`;
const fractionalBetween = `import { keyBetween } from '@robonen/crdt';
// Open bounds (null) ask for "before everything" / "after everything".
const first = keyBetween(null, null); // e.g. 'V'
const second = keyBetween(first, null); // a key after 'first'
const zeroth = keyBetween(null, first); // a key before 'first'
// Insert strictly between two existing neighbors — no renumbering, ever.
const mid = keyBetween(zeroth, first);
zeroth < mid && mid < first; // true
// Sorting items by key reproduces their order:
const items = [
{ text: 'b', key: first },
{ text: 'a', key: zeroth },
{ text: 'ab', key: mid },
];
items.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
items.map(i => i.text); // ['a', 'ab', 'b']`;
const fractionalBatch = `import { keysBetween } from '@robonen/crdt';
// Pre-allocate N keys at once — ascending, all strictly between the bounds.
const keys = keysBetween(null, null, 5);
// each keys[i] < keys[i + 1]
// Moving an item is a single-field write: give it a new key between its
// new neighbors and re-sort. Nothing else in the list changes.
function move(list, fromKeyLeft, fromKeyRight) {
return keyBetween(fromKeyLeft, fromKeyRight);
}`;
const rgaBasic = `import { Rga, opId } from '@robonen/crdt';
const rga = new Rga<string>();
// integrateInsert(id, value, originLeft) — originLeft = null means "at the start".
rga.integrateInsert(opId('a', 1), 'H', null);
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1)); // after 'H'
rga.toArray().join(''); // 'Hi'
// Delete = tombstone. The node stays as an anchor; it just stops being visible.
rga.integrateDelete(opId('a', 2));
rga.toArray().join(''); // 'H'
rga.length; // 1 (visible count, tombstones excluded)`;
const rgaConverge = `import { Rga, opId } from '@robonen/crdt';
// Two replicas both start from "AC" and concurrently insert after 'A'.
function seed() {
const rga = new Rga<string>();
rga.integrateInsert(opId('seed', 1), 'A', null);
rga.integrateInsert(opId('seed', 2), 'C', opId('seed', 1));
return rga;
}
const left = seed();
const right = seed();
// left inserts 'x' after 'A'; right inserts 'y' after 'A' — same origin.
const xOp = { id: opId('left', 5), value: 'x', origin: opId('seed', 1) };
const yOp = { id: opId('right', 5), value: 'y', origin: opId('seed', 1) };
// Apply locally, then exchange. Order and duplicates don't matter.
left.integrateInsert(xOp.id, xOp.value, xOp.origin);
left.integrateInsert(yOp.id, yOp.value, yOp.origin);
right.integrateInsert(yOp.id, yOp.value, yOp.origin);
right.integrateInsert(xOp.id, xOp.value, xOp.origin);
// Tie-break: higher op id first. opId('right', 5) > opId('left', 5)
// (same clock, 'right' > 'left'), so 'y' lands before 'x'.
left.toArray().join(''); // 'AyxC'
right.toArray().join(''); // 'AyxC' — converged`;
const rgaBuffer = `import { Rga, opId } from '@robonen/crdt';
const rga = new Rga<string>();
rga.integrateInsert(opId('a', 1), 'H', null);
// An op arrives BEFORE its origin (causal violation). integrateInsert
// returns false instead of corrupting order — the caller buffers it.
const pending = { id: opId('a', 3), value: '!', origin: opId('a', 2) };
const ok = rga.integrateInsert(pending.id, pending.value, pending.origin);
// ok === false — origin opId('a', 2) isn't present yet
// Once the missing origin lands, retry the buffered op:
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1));
rga.integrateInsert(pending.id, pending.value, pending.origin); // now true
rga.toArray().join(''); // 'Hi!'`;
const marks = `import { Rga, MarkStore, opId } from '@robonen/crdt';
// Build a sequence and grab the op ids of its characters.
const rga = new Rga<string>();
const ids: ReturnType<typeof opId>[] = [];
let left: ReturnType<typeof opId> | null = null;
for (let i = 0; i < 'bold'.length; i++) {
const id = opId('a', i + 1);
rga.integrateInsert(id, 'bold'[i]!, left);
ids.push(id);
left = id;
}
const marks = new MarkStore();
// A span anchors to the FIRST and LAST character op ids (inclusive), not to
// integer offsets — so it survives concurrent inserts/deletes around it.
marks.add({
id: opId('a', 10),
type: 'strong',
value: true,
start: ids[0]!, // 'b'
end: ids[3]!, // 'd'
});
// resolve() returns one active type→value map per character, in document order.
const active = marks.resolve(rga.visible().map(n => n.id));
active.map(m => m.get('strong')); // [true, true, true, true]`;
const marksConflict = `import { MarkStore, opId } from '@robonen/crdt';
const store = new MarkStore();
const start = opId('a', 1);
const end = opId('a', 4);
// Concurrent formatting on the same range: B turns it bold, A clears it.
store.add({ id: opId('a', 9), type: 'strong', value: false, start, end });
store.add({ id: opId('b', 9), type: 'strong', value: true, start, end });
// Highest op id wins per (character, type). opId('b', 9) > opId('a', 9),
// so 'strong' resolves to true — a null/false value would have cleared it.
const order = [opId('a', 1), opId('a', 2), opId('a', 3), opId('a', 4)];
store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Primitives</h1>
<p>
<code>@robonen/crdt</code> is a small set of independent data structures, each convergent on
its own. You can use a single primitive in isolation a last-writer-wins setting, an ordered
list, a collaborative string or compose them into something bigger. This page walks through
each one with a construction example and a small converging scenario.
</p>
<p>
Every primitive leans on one shared idea:
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> a deterministic total order over
operation ids (higher Lamport clock wins; site id breaks ties). Because all primitives resolve
conflicts the same way, two replicas that have seen the same operations always agree, no matter
the order or duplicates in which those operations arrived. If op ids are new to you, start with
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink>.
</p>
</div>
<!-- Map of the package -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registers</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">LwwRegister</code> and
<code class="text-(--accent-text)">LwwMap</code> single values and keyed maps where the
write with the highest op id wins.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Ordering</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">keyBetween</code> /
<code class="text-(--accent-text)">keysBetween</code> fractional indexing to place or move
an item with a single string key.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Sequence</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Rga</code> a replicated growable array: an ordered
sequence CRDT with tombstones and a deterministic insert tie-break.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Marks</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">MarkStore</code> lightweight Peritext formatting spans
anchored to character op ids, resolved per character by highest op id.
</p>
</div>
</div>
<!-- Registers -->
<div class="prose-docs">
<h2>LWW registers</h2>
<p>
A <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> is the smallest CRDT: a single
value with a timestamp. Every write carries an <code>OpId</code>, and a write only takes effect
if its id is strictly later than the current one by <code>compareOpId</code>. That single rule
gives you the three convergence properties for free applying writes is
<strong>commutative</strong> (a later write always beats an earlier one regardless of arrival
order), <strong>idempotent</strong> (re-applying a write is a no-op), and
<strong>convergent</strong> (every replica ends on the same winning write).
</p>
<p>
<code>set(value, id)</code> returns <code>true</code> when the write won and
<code>false</code> when it was superseded, which is handy for skipping downstream work.
</p>
</div>
<DocsCode :code="lwwRegister" lang="ts" />
<div class="prose-docs">
<h3>LwwMap</h3>
<p>
<NuxtLink to="/crdt/lww-map">LwwMap</NuxtLink> is a register per key. Each entry tracks its own
timestamp and a tombstone flag, so concurrent <code>set</code> and <code>delete</code> on the
same key converge to whichever has the higher op id deleting is just another timestamped
write that happens to hide the value. <code>get</code>, <code>has</code>, <code>keys</code>,
and <code>toEntries</code> all skip tombstoned entries, so the map reads like a plain map even
though deletions are retained internally for convergence.
</p>
</div>
<DocsCode :code="lwwMap" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why keep tombstones?</strong> If a delete simply dropped the entry,
a concurrent <code class="text-(--accent-text)">set</code> arriving afterward would resurrect
the key the two replicas would disagree on whether it exists. Retaining the delete as a
timestamped tombstone lets <code class="text-(--accent-text)">compareOpId</code> decide the
winner deterministically, the same way it does for live values.
</p>
</div>
<!-- Ordering -->
<div class="prose-docs">
<h2>Fractional indexing</h2>
<p>
Ordering a collaborative list with integer indices is a trap: insert at position 2 and every
index after it shifts, so two replicas inserting concurrently clobber each other's positions.
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink> sidesteps this by giving each item a
<em>string key</em> that lives strictly between its neighbors. Order is recovered by sorting
keys with plain string comparison — the digit alphabet is ASCII-ascending, so lexical order
matches digit order.
</p>
<p>
Pass <code>null</code> for an open bound: <code>keyBetween(null, x)</code> is "before
<code>x</code>", <code>keyBetween(x, null)</code> is "after <code>x</code>", and
<code>keyBetween(null, null)</code> seeds an empty list. The result is always strictly between
the bounds, so there is unlimited room to keep subdividing — you never run out of space to
insert between two adjacent items.
</p>
</div>
<DocsCode :code="fractionalBetween" lang="ts" />
<div class="prose-docs">
<h3>Batches and moves</h3>
<p>
<NuxtLink to="/crdt/keys-between">keysBetween</NuxtLink> generates <code>n</code> keys at once,
all strictly between the bounds and in ascending order — useful for seeding a list or
bulk-inserting a run of items. Because a key is just a value on the item, <strong>moving</strong>
an item is a single-field write: compute a new key between its new neighbors and re-sort.
Nothing else in the list is touched, which is exactly what makes concurrent reorders converge
cleanly (they reduce to independent
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> writes on each item's key).
</p>
</div>
<DocsCode :code="fractionalBatch" lang="ts" />
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-amber-700 dark:text-amber-400">Heads up:</strong>
<code class="text-(--accent-text)">keyBetween</code> requires <code>lower &lt; upper</code>
and throws otherwise. Two replicas independently generating a key between the
<em>same</em> neighbors can produce identical keys; pair the key with the item's op id as a
secondary sort to keep ordering deterministic, or let
<NuxtLink to="/crdt/rga">Rga</NuxtLink> handle character-level ordering for you.
</p>
</div>
<!-- Sequence -->
<div class="prose-docs">
<h2>The RGA sequence</h2>
<p>
<NuxtLink to="/crdt/rga">Rga</NuxtLink> (Replicated Growable Array) is the heart of the
package — the CRDT behind collaborative text. Each element is a node with a unique
<code>OpId</code>, a value, and an <code>originLeft</code>: the id of the element it was
inserted <em>after</em> (<code>null</code> means the start of the sequence). Deletion never
removes a node; it sets a <strong>tombstone</strong> flag, so the node lives on as a stable
anchor that later inserts and marks can still reference.
</p>
<p>
<code>integrateInsert(id, value, originLeft)</code> and <code>integrateDelete(id)</code> are
both idempotent — re-integrating an op you've already seen is a no-op that safely returns
<code>true</code>. Read the visible state with <code>toArray()</code>; use
<code>visible()</code> to get the surviving nodes (and their ids) for cursor anchoring, and
<code>length</code> for the visible count.
</p>
</div>
<DocsCode :code="rgaBasic" lang="ts" />
<div class="prose-docs">
<h3>Concurrent inserts and the tie-break</h3>
<p>
The interesting case is two replicas inserting at the <em>same</em> origin at the same time.
Both new elements claim the slot right after the same left neighbor so which goes first? RGA
resolves this deterministically: among elements sharing an origin, the one with the
<strong>higher op id</strong> is placed first (<code>compareOpId &gt; 0</code> scans past it).
Because every replica applies the identical comparison, they all settle on the same order
without any coordination.
</p>
</div>
<DocsCode :code="rgaConverge" lang="ts" />
<div class="prose-docs">
<h3>Causal buffering</h3>
<p>
RGA requires inserts to be integrated <strong>in causal order</strong>: an element's
<code>originLeft</code> must already be present, or there's no anchor to insert after. Rather
than guess, <code>integrateInsert</code> returns <code>false</code> when the origin is missing
and <code>integrateDelete</code> returns <code>false</code> for an unknown target the signal
to <em>buffer</em> the op and retry once its dependency lands. (At a higher level,
<NuxtLink to="/crdt/replica">Replica</NuxtLink> does this bookkeeping for you, holding and
replaying ops automatically.)
</p>
</div>
<DocsCode :code="rgaBuffer" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Garbage collection.</strong> Tombstones accumulate. When every
replica has fully synced and nothing is in flight, <code class="text-(--accent-text)">gc(stable, keep?)</code>
drops deleted nodes whose insert is covered by a stable
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed.
Run it only at quiescence a late op that uses a dropped node as its origin could no longer
integrate and pass <code class="text-(--accent-text)">keep</code> to protect ids still
referenced elsewhere, such as mark span endpoints.
</p>
</div>
<!-- Marks -->
<div class="prose-docs">
<h2>Marks (lightweight Peritext)</h2>
<p>
Formatting in a collaborative editor can't be stored by offset — insert a character and every
offset after it shifts, so a "bold from 3 to 7" range would drift onto the wrong text.
<NuxtLink to="/crdt/mark-store">MarkStore</NuxtLink> follows the
<a href="https://www.inkandswitch.com/peritext/" target="_blank" rel="noopener">Peritext</a>
model: a <code>MarkSpan</code> anchors to the <code>OpId</code> of its first and last
characters (an inclusive range), so the span moves with the text it covers as the sequence
grows and shrinks around it.
</p>
<p>
A span's <code>value</code> is a JSON-serializable <code>MarkValue</code> pass
<code>true</code> (or attributes like a color string) to apply the mark, and
<code>null</code> or <code>false</code> to clear it.
</p>
</div>
<DocsCode :code="marks" lang="ts" />
<div class="prose-docs">
<h3>Resolving and converging</h3>
<p>
<code>add(span)</code> just records a span (idempotent by span id). The real work is
<code>resolve(order)</code>: given the character op ids in document order typically
<code>rga.visible().map(n =&gt; n.id)</code> it returns one <code>Map&lt;type, value&gt;</code>
of active marks per character. For each character and mark type, the covering span with the
<strong>highest op id wins</strong>, so concurrent formatting converges by the same
<code>compareOpId</code> rule as everything else; a winning <code>null</code>/<code>false</code>
span clears the mark.
</p>
</div>
<DocsCode :code="marksConflict" lang="ts" />
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> op ids, Lamport clocks, version vectors,
and why convergence holds across all of these primitives.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> wire the primitives to a
<NuxtLink to="/crdt/replica">Replica</NuxtLink> for delta sync and automatic causal
buffering.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile, live in the browser.
</li>
</ul>
</div>
</div>
</template>
+378
View File
@@ -0,0 +1,378 @@
<!-- title: Replication & Sync -->
<!-- order: 3 -->
<script setup lang="ts">
const opLogShape = `import { OpLog } from '@robonen/crdt';
// The op log only ever reads \`id\` — the rest of the op is your domain payload.
interface CharOp {
id: { site: string; clock: number };
originLeft: { site: string; clock: number } | null;
value: string;
}
const log = new OpLog<CharOp>();
log.append(op); // true if new, false if already seen (dedup by id)
log.has(op.id); // version vector lookup, not a linear scan
log.version; // VersionVector — the highest clock seen per site
log.all(); // every op, in append order
log.delta(remoteVector); // ops the remote (described by its vector) lacks`;
const deltaExample = `// A asks B: "here's everything I've seen" (a state vector).
const aWants = a.replica.version;
// B answers with exactly the ops A is missing — nothing more.
const patch = b.replica.delta(aWants); // OpLog.delta filters by the vector
// A integrates them; ids it already has are silently dropped.
a.replica.receive(patch);`;
const roundTrip = `import { Replica, Rga } from '@robonen/crdt';
import type { OpId } from '@robonen/crdt';
interface CharOp {
id: OpId;
originLeft: OpId | null;
value: string;
}
// Each site owns an RGA (the sequence state) behind a Replica
// (clock + op log + causal buffer + delta sync).
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
function type(peer: ReturnType<typeof makeReplica>, text: string): void {
let left: OpId | null = null;
for (const ch of text) {
const id = peer.replica.nextId(); // tick the Lamport clock
peer.replica.commitLocal({ id, originLeft: left, value: ch });
left = id;
}
}
const a = makeReplica('a');
const b = makeReplica('b');
// Concurrent, independent edits — neither has seen the other.
type(a, 'Hi');
type(b, 'Yo');
// Exchange ONLY the delta each side is missing, in both directions.
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
a.rga.length; // 4`;
const bufferingExample = `const a = makeReplica('a');
type(a, 'ab'); // two ops; the 2nd inserts after (depends on) the 1st
const b = makeReplica('b');
const [op1, op2] = a.replica.delta(b.replica.version);
// Deliver the DEPENDENT op first. Its origin (op1) isn't present,
// so integrate() returns false and the replica buffers it.
b.replica.receive([op2]);
b.rga.toArray().join(''); // '' — nothing applied yet
// Now deliver the dependency. op1 integrates, which unblocks op2;
// drain() loops until no further progress is possible.
b.replica.receive([op1]);
b.rga.toArray().join(''); // 'ab'`;
const wireExample = `import {
encodeStateVector, decodeStateVector,
encodeOps, decodeOps,
} from '@robonen/crdt';
// --- Peer A: announce what I have ---
const myVector: Uint8Array = encodeStateVector(a.replica.version);
socket.send(myVector); // send over WebSocket, HTTP, BroadcastChannel, …
// --- Peer B: answer with the delta A is missing ---
const remoteVector = decodeStateVector(received);
const patch: Uint8Array = encodeOps(b.replica.delta(remoteVector));
socket.send(patch);
// --- Peer A: apply the patch ---
const ops = decodeOps<CharOp>(receivedPatch);
a.replica.receive(ops);`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Replication &amp; Sync</h1>
<p>
A CRDT primitive on its own guarantees that the <em>same</em> set of operations converges.
Replication is the layer that makes sure every replica eventually holds that same set
despite messages arriving out of order, twice, or after a long offline gap. This package
does it without a central server, a global lock, or full-state diffing: each replica keeps
an append-only op log keyed by a version vector, and the two sides exchange only the
operations the other is missing.
</p>
<p>
The pieces fit together in one direction. <code>OpLog</code> stores ops and tracks a
<code>VersionVector</code>; <code>Replica</code> wraps a log plus a Lamport clock and a
causal buffer, integrating local and remote ops into your domain state; and the
<code>sync</code> helpers turn version vectors and op batches into bytes for any transport.
</p>
</div>
<!-- Mental model -->
<div class="prose-docs">
<h2>The convergence model</h2>
<p>
Every operation carries a globally-unique <code>OpId</code> a per-site
<a href="https://en.wikipedia.org/wiki/Lamport_timestamp">Lamport</a> clock value tagged
with the site that produced it (<code>{ site, clock }</code>). Two facts make replication
work, and both flow from that id:
</p>
<ul>
<li>
<strong>Identity idempotence.</strong> Because an op's id is stable, a replica can tell
whether it has already seen an op and apply it at most once. Delivering the same op twice
is a no-op, so duplicate or replayed messages are harmless.
</li>
<li>
<strong>Determinism ⇒ commutativity.</strong> Concurrent ops are resolved by one shared
tie-break — <NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> (higher clock wins,
site id breaks ties). Since every replica agrees on it, the order ops arrive in doesn't
change the final state.
</li>
</ul>
<p>
Replication therefore reduces to a set-reconciliation problem: <em>get both replicas to the
same set of ops.</em> Convergence of the resulting state is the primitive's job; getting the
ops there efficiently is this layer's.
</p>
</div>
<!-- Version vectors -->
<div class="prose-docs">
<h3>Version vectors: "what have you seen?"</h3>
<p>
A <NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> is the compact summary of
everything a replica has observed a map from site id to the highest clock seen for that
site. It relies on one assumption: each site emits <strong>dense</strong> clocks
(1, 2, 3, ), with no gaps. That's what lets a single number per site stand in for a whole
set: if a replica has seen <code>a@5</code>, it has necessarily seen <code>a@1…a@4</code>
too. So <code>has(id)</code> is just <code>get(id.site) &gt;= id.clock</code> — an O(1)
check, no per-op bookkeeping.
</p>
<p>
Two operations follow directly. <strong>Dedup:</strong> an incoming op whose id the vector
already covers can be ignored. <strong>Delta:</strong> given a remote vector, the set of ops
the remote lacks is exactly those whose id the vector does <em>not</em> cover.
</p>
</div>
<!-- OpLog -->
<div class="prose-docs">
<h2>The op log</h2>
<p>
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> is an append-only list of operations paired
with a version vector. It is deliberately domain-agnostic: the only field it reads is
<code>id</code> (the <code>HasOpId</code> constraint), so the same log stores RGA inserts,
LWW writes, mark spans, or anything else you give it.
</p>
</div>
<DocsCode :code="opLogShape" lang="ts" />
<div class="prose-docs">
<p>
<code>append</code> consults the vector first and returns <code>false</code> if the op is a
duplicate, so the log never stores the same id twice. <code>delta(remote)</code> walks the
log once and keeps every op the remote vector hasn't covered this is the heart of
"exchange only the delta".
</p>
</div>
<DocsCode :code="deltaExample" lang="ts" />
<!-- Replica -->
<div class="prose-docs">
<h2>The replica</h2>
<p>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> ties everything together. It owns a
<code>LamportClock</code>, an <code>OpLog</code>, and a pending buffer, and you give it a
single handler <code>integrate(op)</code> that applies an op to your domain state and
returns <code>false</code> when the op's causal dependencies aren't present yet.
</p>
<h3>Producing local ops</h3>
<p>
Call <code>nextId()</code> to tick the clock and mint a fresh, causally-later
<code>OpId</code>, build your op around it, then hand it to <code>commitLocal(op)</code>.
That logs it, integrates it into local state, and notifies <code>onUpdate</code> listeners
with origin <code>'local'</code>. Because <code>nextId</code> advances a Lamport clock that
also tracks observed remote ops, locally-generated ids are always ordered after everything
the replica has seen.
</p>
<h3>Receiving remote ops</h3>
<p>
<code>receive(ops)</code> is the inbound path. For each op it advances the clock past the
remote id (<code>clock.observe</code>), skips anything already logged or already buffered,
then drains the buffer integrating whatever is now causally ready, retrying until no
further progress is possible. It returns the ops it actually applied (in apply order) and
notifies listeners with origin <code>'remote'</code>.
</p>
<h3>Computing a delta</h3>
<p>
<code>delta(remoteVector)</code> forwards to the log: the ops this replica holds that the
remote, described by its <code>version</code>, has not seen. The whole round-trip is two
deltas one per direction.
</p>
</div>
<!-- Round trip -->
<div class="prose-docs">
<h2>The canonical round-trip</h2>
<p>
Here is the README's converging-string example expanded end to end. Two replicas type
concurrently, then each side sends the other exactly the ops it lacks. After both deltas,
they hold the identical op set and therefore the identical string.
</p>
</div>
<DocsCode :code="roundTrip" lang="ts" />
<div class="prose-docs">
<p>
Note the asymmetry that makes this efficient: <code>a.replica.delta(b.replica.version)</code>
is computed against <em>B's</em> vector, so it returns only what B is missing not A's
entire history. On a long document this is the difference between sending two characters and
re-sending the whole file.
</p>
</div>
<!-- Why order does not matter -->
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Why the order of the two deltas is irrelevant</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
You could swap the two <code class="text-(--accent-text)">receive</code> lines, run them
repeatedly, or interleave them with more edits — the result is the same. Each side only ever
adds ops it hasn't seen, and <code class="text-(--accent-text)">compareOpId</code> places
each op in its deterministic position regardless of arrival order. That is convergence,
and the property tests assert it across randomized schedules.
</p>
</div>
<!-- Causal buffering -->
<div class="prose-docs">
<h2>Causal buffering</h2>
<p>
Some ops can't be applied the instant they arrive. An RGA insert references an
<code>originLeft</code> — the element it goes after — and a delete references the element it
tombstones. If that target hasn't been integrated yet (a later op overtook an earlier one in
transit), the insert has nowhere to anchor.
</p>
<p>
The handler signals this by returning <code>false</code> from <code>integrate</code>:
<NuxtLink to="/crdt/rga">Rga</NuxtLink>'s <code>integrateInsert</code> returns
<code>false</code> when its origin is absent, and <code>integrateDelete</code> returns
<code>false</code> when its target is unknown. <code>Replica.receive</code> treats a
<code>false</code> as "not ready yet": it keeps the op in a pending buffer and re-runs the
buffer every time new ops land, until either the op integrates or its dependency finally
arrives. Nothing is lost; nothing is applied prematurely.
</p>
</div>
<DocsCode :code="bufferingExample" lang="ts" />
<div class="prose-docs">
<p>
Internally the drain loop sweeps the buffer repeatedly: each successful integration may
unblock another buffered op, so it keeps looping while it makes progress. This is why a
single <code>receive</code> of a batch delivered in any order still settles to the right
state — the buffer absorbs the disorder.
</p>
</div>
<!-- Wire encoding -->
<div class="prose-docs">
<h2>Transport-agnostic wire encoding</h2>
<p>
The <code>sync</code> module is the only part that touches bytes, and it stays small on
purpose. There are two things to put on the wire — a version vector (the "what do you have?"
handshake) and a batch of ops (the delta or a full snapshot) — and a helper for each
direction:
</p>
<ul>
<li>
<NuxtLink to="/crdt/encode-state-vector">encodeStateVector</NuxtLink> /
<code>decodeStateVector</code> — a <code>VersionVector</code> ⇄ <code>Uint8Array</code>.
</li>
<li>
<NuxtLink to="/crdt/encode-ops">encodeOps</NuxtLink> / <code>decodeOps</code> — an op
batch (the delta or a full snapshot) ⇄ <code>Uint8Array</code>.
</li>
<li>
<code>encodeJson</code> / <code>decodeJson</code> — the lower-level pair the others build on.
</li>
</ul>
<p>
The v1 format is JSON encoded to bytes — simple and debuggable. A compact varint format is a
later optimization that changes the bytes, not the API, so code written against these
functions keeps working. Because the result is just a <code>Uint8Array</code>, the transport
is entirely up to you: WebSocket, HTTP, <code>BroadcastChannel</code>, a file on disk.
</p>
</div>
<DocsCode :code="wireExample" lang="ts" />
<!-- A typical sync protocol -->
<div class="prose-docs">
<h3>A minimal two-way protocol</h3>
<p>
Put the pieces together and a full reconciliation between two peers is four messages:
</p>
<ol>
<li>Each peer sends its <code>encodeStateVector(replica.version)</code>.</li>
<li>
On receiving the other's vector, each peer replies with
<code>encodeOps(replica.delta(theirVector))</code>.
</li>
<li>Each peer <code>receive()</code>s the decoded delta.</li>
<li>Both replicas now hold the same op set and the same converged state.</li>
</ol>
<p>
This generalizes cleanly. For live collaboration, also forward each locally-committed op as
it happens (subscribe with <code>onUpdate</code>, encode the op, broadcast it); peers that
receive an op out of causal order simply buffer it. For catch-up after an offline gap, the
state-vector handshake above replays exactly the missed ops. The same machinery covers both.
</p>
</div>
<!-- Caveat callout -->
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5">
<h3 class="mb-1.5 text-sm font-semibold text-amber-700 dark:text-amber-400">Dense clocks are a precondition</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Version vectors assume each site's clocks are dense (1, 2, 3, ). That holds automatically
when ids come from <code class="text-(--accent-text)">Replica.nextId()</code>. If you mint
ids yourself, never skip a value for a site a gap would make
<code class="text-(--accent-text)">delta</code> believe a missing op was already delivered.
</p>
</div>
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> the full API reference for
<code>commitLocal</code>, <code>receive</code>, <code>delta</code>, and
<code>onUpdate</code>.
</li>
<li>
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> and
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> the storage and causality
primitives underneath.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile live in the browser.
</li>
</ul>
</div>
</div>
</template>
+528
View File
@@ -0,0 +1,528 @@
<!-- title: Playground -->
<!-- order: 4 -->
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import type { OpId } from '../src';
import { Replica, Rga } from '../src';
// ---------------------------------------------------------------------------
// The op shape exchanged between replicas.
//
// This is a REAL @robonen/crdt setup, not a simulation: each side owns an `Rga`
// for its sequence state, wrapped by a `Replica` that owns the Lamport clock,
// op log, causal buffer and delta computation. The only thing the demo adds is
// a tiny op union so we can both insert and delete characters.
// ---------------------------------------------------------------------------
type CharOp =
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId; target: OpId };
interface Side {
rga: Rga<string>;
replica: Replica<CharOp>;
}
function makeSide(site: string): Side {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{
// Return `false` when a dependency is missing — the Replica buffers the op
// and retries it automatically once the dependency arrives.
integrate: (op) => {
if (op.kind === 'insert')
return rga.integrateInsert(op.id, op.value, op.originLeft);
return rga.integrateDelete(op.target);
},
},
site,
);
return { rga, replica };
}
// Reactive view-model. The CRDT classes are plain (non-reactive) objects, so we
// keep a small reactive snapshot and refresh it after every mutation.
interface View {
text: string;
ops: number;
clock: number;
pending: number;
}
const snapshot = reactive<{ a: View; b: View }>({
a: { text: '', ops: 0, clock: 0, pending: 0 },
b: { text: '', ops: 0, clock: 0, pending: 0 },
});
const drafts = reactive({ a: '', b: '' });
let a: Side | null = null;
let b: Side | null = null;
function refresh(): void {
if (!a || !b)
return;
snapshot.a = {
text: a.rga.toArray().join(''),
ops: a.replica.version.get(a.replica.site),
clock: a.replica.version.get(a.replica.site),
pending: 0,
};
snapshot.b = {
text: b.rga.toArray().join(''),
ops: b.replica.version.get(b.replica.site),
clock: b.replica.version.get(b.replica.site),
pending: 0,
};
}
function init(): void {
a = makeSide('A');
b = makeSide('B');
drafts.a = '';
drafts.b = '';
refresh();
}
// The drafts are deliberately decoupled from the CRDT value until "Apply":
// that lets the user stage CONCURRENT edits on both sides before any sync, the
// scenario where convergence actually matters.
/**
* Diff the side's current CRDT string against the textarea draft and emit the
* minimal insert/delete ops to make the RGA match the draft, committing each
* locally. A real editor derives these ops from input events the same way.
*/
function apply(which: 'a' | 'b'): void {
const side = which === 'a' ? a : b;
if (!side)
return;
const current = side.rga.toArray();
const next = [...(which === 'a' ? drafts.a : drafts.b)];
// Longest common prefix / suffix → splice region (a tiny, dependency-free diff).
let start = 0;
while (start < current.length && start < next.length && current[start] === next[start])
start += 1;
let endCur = current.length;
let endNext = next.length;
while (endCur > start && endNext > start && current[endCur - 1] === next[endNext - 1]) {
endCur -= 1;
endNext -= 1;
}
// Delete the removed characters (right-to-left keeps live indices stable).
for (let i = endCur - 1; i >= start; i--) {
const target = side.rga.idAt(i);
if (target) {
const op: CharOp = { kind: 'delete', id: side.replica.nextId(), target };
side.replica.commitLocal(op);
}
}
// Insert the new characters after the surviving left neighbour.
let left = start > 0 ? side.rga.idAt(start - 1) : null;
for (let i = start; i < endNext; i++) {
const op: CharOp = {
kind: 'insert',
id: side.replica.nextId(),
value: next[i]!,
originLeft: left,
};
side.replica.commitLocal(op);
left = op.id;
}
// Re-read drafts from the authoritative CRDT value.
drafts.a = a!.rga.toArray().join('');
drafts.b = b!.rga.toArray().join('');
refresh();
}
/**
* One full sync round: each side hands the other only the ops it is missing
* (computed from the peer's version vector), and `receive` integrates them with
* dedup + causal buffering. After this both RGAs hold the identical sequence.
*/
function sync(): void {
if (!a || !b)
return;
// Snapshot versions BEFORE exchanging so each delta reflects pre-sync state.
const va = a.replica.version.clone();
const vb = b.replica.version.clone();
b.replica.receive(a.replica.delta(vb));
a.replica.receive(b.replica.delta(va));
drafts.a = a.rga.toArray().join('');
drafts.b = b.rga.toArray().join('');
refresh();
}
const ready = ref(false);
function start(): void {
if (ready.value)
return;
init();
ready.value = true;
}
const converged = computed(() =>
snapshot.a.text === snapshot.b.text && (snapshot.a.text.length > 0 || snapshot.a.ops > 0));
// --- static code samples ---------------------------------------------------
const setupCode = `import { Replica, Rga } from '@robonen/crdt';
import type { OpId } from '@robonen/crdt';
// Inserts and deletes travel as ops. Every op carries an \`id\`; that's all
// Replica's op log needs to dedup and compute deltas.
type CharOp =
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId; target: OpId };
function makeSide(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{
// Return false when a causal dependency is missing — the Replica buffers
// the op and retries it automatically once the dependency lands.
integrate: (op) =>
op.kind === 'insert'
? rga.integrateInsert(op.id, op.value, op.originLeft)
: rga.integrateDelete(op.target),
},
site,
);
return { rga, replica };
}
const a = makeSide('A');
const b = makeSide('B');`;
const localEditCode = `// A types "cat" at the start. Each character is an insert anchored to the
// previous one via originLeft; nextId() advances A's Lamport clock.
let left: OpId | null = null;
for (const ch of 'cat') {
const op = { kind: 'insert', id: a.replica.nextId(), value: ch, originLeft: left } as const;
a.replica.commitLocal(op); // integrate locally + append to the log
left = op.id;
}
// Concurrently, B types "dog" — it has NOT seen A's ops yet.
left = null;
for (const ch of 'dog') {
const op = { kind: 'insert', id: b.replica.nextId(), value: ch, originLeft: left } as const;
b.replica.commitLocal(op);
left = op.id;
}
a.rga.toArray().join(''); // 'cat'
b.rga.toArray().join(''); // 'dog' — the replicas have DIVERGED`;
const syncCode = `// Send each side only what it's missing, computed from the peer's version.
// Snapshot versions first so both deltas describe the pre-sync state.
const va = a.replica.version.clone();
const vb = b.replica.version.clone();
b.replica.receive(a.replica.delta(vb)); // B integrates A's 3 inserts
a.replica.receive(b.replica.delta(va)); // A integrates B's 3 inserts
// Both RGAs now hold the same six characters in the same order. The order is
// decided by compareOpId (higher clock wins; site id breaks the tie) — NOT by
// who synced first — so the result is identical on every replica.
a.rga.toArray().join(''); // e.g. 'dogcat'
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Playground</h1>
<p>
Reading about convergence only gets you so far the intuition lands when you
<em>watch two replicas disagree and then reconcile</em>. Below is a live, two-replica
editor backed by the real <NuxtLink to="/crdt/rga">Rga</NuxtLink> and
<NuxtLink to="/crdt/replica">Replica</NuxtLink> classes from this package. Edit each side
independently, then press <strong>Sync</strong> and see them land on the exact same string.
</p>
</div>
<!-- Live demo -->
<div class="prose-docs">
<h2>Live: two replicas, one string</h2>
<p>
Replica <strong>A</strong> and replica <strong>B</strong> each own a private copy of a
shared document. Type something different into each, click <strong>Apply</strong> to commit
those edits locally (they diverge), then <strong>Sync</strong> to exchange deltas and
converge. The readout under each side shows its current value, how many local ops its log
has produced, and its Lamport clock.
</p>
</div>
<ClientOnly>
<template #fallback>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-8 text-center text-sm text-(--fg-subtle)">
Loading interactive demo
</div>
</template>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 sm:p-5">
<div v-if="!ready" class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm text-(--fg-muted)">Spin up two fresh replicas to start editing.</p>
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="start()"
>
Start demo
</button>
</div>
<div v-else class="flex flex-col gap-4">
<!-- Two replica panes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Replica A -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica A</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: A</span>
</div>
<textarea
v-model="drafts.a"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="Type on A…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="apply('a')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<span>ops {{ snapshot.a.ops }}</span>
<span>clock {{ snapshot.a.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<span v-if="snapshot.a.text">{{ snapshot.a.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
</div>
</div>
<!-- Replica B -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica B</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: B</span>
</div>
<textarea
v-model="drafts.b"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="Type on B…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="apply('b')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<span>ops {{ snapshot.b.ops }}</span>
<span>clock {{ snapshot.b.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<span v-if="snapshot.b.text">{{ snapshot.b.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
</div>
</div>
</div>
<!-- Sync bar -->
<div class="flex flex-wrap items-center gap-3 border-t border-(--border) pt-3">
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="sync()"
>
Sync
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-(--fg-muted) hover:bg-(--bg-inset) hover:text-(--fg) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="init()"
>
Reset
</button>
<span
v-if="converged"
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400"
>
Converged both sides equal
</span>
<span
v-else
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-600 dark:text-amber-400"
>
Diverged sync to reconcile
</span>
</div>
</div>
</div>
</ClientOnly>
<div class="prose-docs">
<p>
Try the canonical experiment: type <code>cat</code> on A and <code>dog</code> on B, apply
both, then sync. The result is the same six characters on both sides, every time the order
is decided by op id, not by who synced first. Reset and try it again to confirm it's
deterministic.
</p>
</div>
<!-- How it works -->
<div class="prose-docs">
<h2>How the demo is wired</h2>
<p>
There's no mock here. Each side is a real <code>Rga&lt;string&gt;</code> wrapped in a
<code>Replica&lt;CharOp&gt;</code>. The <code>Replica</code> owns the Lamport clock, the
append-only op log, the causal buffer, and delta computation; the <code>Rga</code> holds the
actual character sequence with tombstones. We pass one handler <code>integrate</code>
that applies an op to the RGA.
</p>
</div>
<DocsCode :code="setupCode" lang="ts" />
<div class="prose-docs">
<h3>Making concurrent edits</h3>
<p>
A local edit is just an op: call <code>replica.nextId()</code> to mint a fresh op id (which
ticks that site's Lamport clock), build the insert or delete, and pass it to
<code>commitLocal</code>. That integrates the op into the RGA and appends it to the log in
one step. Because A and B edit before any sync, they produce ops with overlapping clock
values but different site ids — genuinely concurrent operations.
</p>
</div>
<DocsCode :code="localEditCode" lang="ts" />
<div class="prose-docs">
<h3>Syncing the deltas</h3>
<p>
Sync is a delta exchange driven by version vectors. Each replica's
<code>version</code> records the highest clock it has seen per site;
<code>delta(remoteVersion)</code> returns exactly the ops the remote is missing.
<code>receive</code> then dedups, integrates, and crucially <em>buffers</em> any op
whose causal dependency hasn't arrived yet, retrying it automatically once that dependency
lands.
</p>
</div>
<DocsCode :code="syncCode" lang="ts" />
<!-- Why it converges -->
<div class="prose-docs">
<h2>Why it always converges</h2>
<p>
The demo never special-cases conflicts, because the data structure can't have any. Three
properties, each verified by the package's property tests, guarantee that every replica
reaches the same state regardless of message order, duplication, or delay.
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are
ordered by <code class="text-(--accent-text)">compareOpId</code>, so order of arrival
doesn't matter.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Receiving the same op twice is a no-op. The op log's version vector dedups on
<code class="text-(--accent-text)">id</code>, and <code class="text-(--accent-text)">integrateInsert</code>
short-circuits if the id is already present.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
An insert can't integrate before its <code class="text-(--accent-text)">originLeft</code>,
nor a delete before its target. <code class="text-(--accent-text)">receive</code> buffers
such ops and retries them, so out-of-order delivery still converges.
</p>
</div>
</div>
<div class="prose-docs">
<h3>The single source of truth: op id order</h3>
<p>
Everything hinges on one comparison. When two replicas insert characters at the same
position concurrently, <code>Rga.integrateInsert</code> walks past any existing siblings
whose op id sorts <em>higher</em> and splices the new node in so the final order is fully
determined by <code>compareOpId</code>: higher Lamport clock first, with the site id as a
deterministic tie-break. Every replica runs the same comparison on the same ids, so they all
agree on the same order without a coordinator.
</p>
<p>
That's also why deletes are tombstones rather than removals: a delete only flips a node's
<code>deleted</code> flag, so a concurrent insert that anchored to that node still has a
valid origin. The character disappears from <code>toArray()</code>, but the structure stays
intact for convergence. Tombstones are reclaimed later via
<NuxtLink to="/crdt/rga"><code>Rga.gc</code></NuxtLink>, but only at quiescence.
</p>
</div>
<!-- Things to try -->
<div class="prose-docs">
<h2>Experiments to try</h2>
<ul>
<li>
<strong>Repeat sync.</strong> Press <strong>Sync</strong> twice in a row the second pass
applies nothing, because each side's delta is now empty. Idempotence in action.
</li>
<li>
<strong>Concurrent deletes.</strong> Sync to a shared value, then delete different
characters on each side and sync again. Both deletions survive; neither clobbers the other.
</li>
<li>
<strong>Edit after sync.</strong> Keep editing on one side and syncing repeatedly — only
the new ops travel each time, because <code>delta</code> filters by the peer's version
vector.
</li>
<li>
<strong>Tie-break.</strong> Type a single different character at the very start of each
side, then sync. The one whose op id sorts higher lands first deterministically.
</li>
</ul>
</div>
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/rga">Rga</NuxtLink> the full sequence API: tombstones, cursor
anchoring via op ids, and garbage collection.
</li>
<li>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> clock, op log, causal buffer, deltas,
and the <code>onUpdate</code> subscription used to drive UI.
</li>
<li>
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> and
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> the causality and tie-break
machinery behind every primitive.
</li>
</ul>
</div>
</div>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
const replicaExample = `import { Replica, Rga, opId } from '@robonen/crdt';
// Each editing site owns an RGA (the sequence state) wrapped by a Replica
// (clock + op log + causal buffering + delta sync).
type Op = {
id: ReturnType<typeof opId>;
value: string;
originLeft: ReturnType<typeof opId> | null;
};
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<Op>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
const a = makeReplica('a');
const b = makeReplica('b');
// A types "hi" locally.
let left: Op['originLeft'] = null;
for (const ch of 'hi') {
const op: Op = { id: a.replica.nextId(), value: ch, originLeft: left };
a.replica.commitLocal(op);
left = op.id;
}
// Sync: send B only the ops it is missing, then send A only what it lacks.
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join(''); // 'hi'
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged`;
</script>
<template>
<div class="docs-section">
<!-- Hero -->
<div class="prose-docs">
<h1>@robonen/crdt</h1>
<p>
Framework-agnostic CRDT primitives an RGA sequence, last-writer-wins registers,
fractional indexing, and version vectors that converge no matter the order, duplicates,
or delays in which operations arrive.
</p>
</div>
<div class="prose-docs">
<p>
Collaborative state is hard because two replicas can edit the same document at once,
offline, with messages that arrive out of order or twice. A CRDT solves this by construction:
every primitive here is <strong>commutative, idempotent, and convergent</strong>, so applying
the same set of operations in any order yields the same state a property verified by
property tests. It's the convergence engine behind <code>@robonen/editor</code>, but stays
fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
</p>
</div>
<!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent by construction</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
One deterministic tie-break — <code class="text-(--accent-text)">compareOpId</code> (higher
Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
on the same final state.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal buffering built in</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Replica.receive</code> dedups, holds ops whose dependencies
haven't arrived yet (an insert before its origin), and retries them automatically as they land.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Delta sync, not full state</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Version vectors let each side request exactly the ops it's missing via
<code class="text-(--accent-text)">delta(version)</code>, with a transport-agnostic wire format.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Zero dependencies, pure TS</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on
<code class="text-(--accent-text)">Replica</code> to tie a clock, op log, and buffer together.
</p>
</div>
</div>
<!-- Install -->
<div class="prose-docs">
<h2>Install</h2>
<p>Add the package with your preferred package manager.</p>
</div>
<DocsCode :code="`pnpm add @robonen/crdt`" lang="bash" />
<!-- Usage -->
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Two replicas edit a string independently, then exchange only the operations each is missing
and converge to the same result.
</p>
</div>
<DocsCode :code="replicaExample" lang="ts" />
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<p>New to CRDTs? Work through the guide and finish in the live playground.</p>
<ul>
<li>
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> op ids, Lamport clocks, version vectors,
and why convergence holds.
</li>
<li>
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> a tour of
<NuxtLink to="/crdt/rga">Rga</NuxtLink>,
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>, and fractional indexing with
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> wiring up
<NuxtLink to="/crdt/replica">Replica</NuxtLink>, deltas, and the wire encoding.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile, live in the browser.
</li>
</ul>
</div>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@robonen/crdt",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "Framework-agnostic CRDT primitives: RGA sequence, LWW registers, fractional indexing, version vectors",
"keywords": [
"crdt",
"rga",
"lww",
"collaborative",
"fractional-indexing",
"tools"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "core/crdt"
},
"packageManager": "pnpm@10.33.2",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {
"lint:check": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"eslint": "catalog:",
"tsdown": "catalog:"
}
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { LamportClock, VersionVector, compareOpId, opId, opIdEq } from '..';
describe('compareOpId', () => {
it('orders by clock, then by site id', () => {
expect(compareOpId(opId('a', 1), opId('a', 2))).toBeLessThan(0);
expect(compareOpId(opId('a', 2), opId('b', 2))).toBeLessThan(0);
expect(compareOpId(opId('b', 2), opId('a', 2))).toBeGreaterThan(0);
expect(compareOpId(opId('a', 2), opId('a', 2))).toBe(0);
expect(opIdEq(opId('a', 1), opId('a', 1))).toBe(true);
});
});
describe('lamportClock', () => {
it('ticks monotonically and advances past observed remote ops', () => {
const clock = new LamportClock('a');
expect(clock.tick()).toEqual({ site: 'a', clock: 1 });
expect(clock.tick()).toEqual({ site: 'a', clock: 2 });
clock.observe({ site: 'b', clock: 5 });
expect(clock.tick().clock).toBe(6);
});
});
describe('versionVector', () => {
it('tracks seen ops and round-trips through JSON', () => {
const vv = new VersionVector();
vv.observe(opId('a', 3));
vv.observe(opId('b', 1));
expect(vv.has(opId('a', 2))).toBe(true);
expect(vv.has(opId('a', 3))).toBe(true);
expect(vv.has(opId('a', 4))).toBe(false);
expect(vv.has(opId('c', 1))).toBe(false);
const restored = VersionVector.fromJSON(vv.toJSON());
expect(restored.get('a')).toBe(3);
expect(restored.get('b')).toBe(1);
});
});
+35
View File
@@ -0,0 +1,35 @@
/** A replica identifier — unique per editing site/session. */
export type SiteId = string;
/** A globally-unique operation id: a per-site Lamport counter tagged with the site. */
export interface OpId {
readonly site: SiteId;
readonly clock: number;
}
export function opId(site: SiteId, clock: number): OpId {
return { site, clock };
}
export function opIdEq(a: OpId, b: OpId): boolean {
return a.clock === b.clock && a.site === b.site;
}
/**
* Total order over op ids: higher clock wins; ties broken by site id. This is
* the deterministic tie-break every replica agrees on, so LWW and RGA converge.
*/
export function compareOpId(a: OpId, b: OpId): number {
if (a.clock !== b.clock)
return a.clock - b.clock;
return a.site < b.site ? -1 : a.site > b.site ? 1 : 0;
}
export function opIdToString(id: OpId): string {
return `${id.site}@${id.clock}`;
}
/** Generate a random site id (no crypto dependency; uniqueness, not secrecy). */
export function createSiteId(): SiteId {
return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './id';
export * from './lamport';
export * from './version-vector';
+29
View File
@@ -0,0 +1,29 @@
import type { OpId, SiteId } from './id';
/**
* A Lamport clock for one site: hands out monotonically increasing op ids and
* advances past observed remote ops so locally-generated ids stay causally later.
*/
export class LamportClock {
private counter: number;
constructor(public readonly site: SiteId, start = 0) {
this.counter = start;
}
/** Generate the next op id for a local operation. */
tick(): OpId {
this.counter += 1;
return { site: this.site, clock: this.counter };
}
/** Advance past a remote op so future local ticks are causally after it. */
observe(id: OpId): void {
if (id.clock > this.counter)
this.counter = id.clock;
}
get value(): number {
return this.counter;
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { OpId, SiteId } from './id';
/**
* Tracks the highest clock seen per site, assuming each site emits dense clocks
* (1, 2, 3, …). Used to deduplicate ops and to compute deltas during sync.
*/
export class VersionVector {
private readonly clocks = new Map<SiteId, number>();
/** Record that an op has been seen. */
observe(id: OpId): void {
if (id.clock > this.get(id.site))
this.clocks.set(id.site, id.clock);
}
/** Highest clock seen for a site (0 if none). */
get(site: SiteId): number {
return this.clocks.get(site) ?? 0;
}
/** Whether an op id has already been seen. */
has(id: OpId): boolean {
return this.get(id.site) >= id.clock;
}
/** Plain-object snapshot for transport. */
toJSON(): Record<SiteId, number> {
return Object.fromEntries(this.clocks);
}
static fromJSON(snapshot: Record<SiteId, number>): VersionVector {
const vv = new VersionVector();
for (const site in snapshot)
vv.clocks.set(site, snapshot[site]!);
return vv;
}
clone(): VersionVector {
return VersionVector.fromJSON(this.toJSON());
}
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { OpId } from '../../clock';
import { Rga } from '../../sequence';
import { Replica } from '..';
interface CharOp {
id: OpId;
originLeft: OpId | null;
value: string;
}
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
function type(peer: ReturnType<typeof makeReplica>, text: string): void {
let left: OpId | null = null;
for (const ch of text) {
const id = peer.replica.nextId();
peer.replica.commitLocal({ id, originLeft: left, value: ch });
left = id;
}
}
describe('replica', () => {
it('two replicas converge after exchanging deltas', () => {
const a = makeReplica('a');
const b = makeReplica('b');
type(a, 'Hi'); // concurrent edits
type(b, 'Yo');
// Exchange only what each side is missing (delta by version vector).
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
expect(a.rga.toArray().join('')).toBe(b.rga.toArray().join(''));
expect(a.rga.length).toBe(4);
});
it('buffers a remote op until its causal dependency arrives, then applies both', () => {
const a = makeReplica('a');
type(a, 'ab'); // two ops; the 2nd depends on the 1st
const b = makeReplica('b');
const [op1, op2] = a.replica.delta(b.replica.version) as CharOp[];
// Deliver the dependent op first — it must buffer.
b.replica.receive([op2!]);
expect(b.rga.toArray().join('')).toBe('');
// Now deliver the dependency — both integrate.
b.replica.receive([op1!]);
expect(b.rga.toArray().join('')).toBe('ab');
});
});
+1
View File
@@ -0,0 +1 @@
export * from './replica';
+102
View File
@@ -0,0 +1,102 @@
import type { OpId, SiteId, VersionVector } from '../clock';
import { LamportClock, createSiteId, opIdEq } from '../clock';
import type { HasOpId } from '../oplog';
import { OpLog } from '../oplog';
export interface ReplicaHandlers<Op extends HasOpId> {
/**
* Apply an op to domain state (RGA, marks, block list, …). Return `false` if
* its causal dependencies aren't present yet; the replica buffers and retries.
*/
integrate: (op: Op) => boolean;
}
export type UpdateListener<Op> = (ops: readonly Op[], origin: unknown) => void;
/**
* Generic op-based CRDT replica: owns a Lamport clock + op log, integrates local
* and remote ops (with causal buffering and dedup), and exposes deltas for
* transport-agnostic sync. The domain state lives behind {@link ReplicaHandlers}.
*/
export class Replica<Op extends HasOpId> {
readonly site: SiteId;
private readonly clock: LamportClock;
private readonly log = new OpLog<Op>();
private readonly pending: Op[] = [];
private readonly listeners = new Set<UpdateListener<Op>>();
constructor(private readonly handlers: ReplicaHandlers<Op>, site: SiteId = createSiteId()) {
this.site = site;
this.clock = new LamportClock(site);
}
/** Next op id for a locally-generated operation. */
nextId(): OpId {
return this.clock.tick();
}
get version(): VersionVector {
return this.log.version;
}
/** Integrate + log a local op, then notify listeners (origin `'local'`). */
commitLocal(op: Op): void {
if (!this.log.append(op))
return;
this.handlers.integrate(op);
this.emit([op], 'local');
}
/**
* Receive remote ops: dedup, buffer until causally ready, integrate, log, and
* notify with the ops actually applied. Returns the applied ops (in apply order).
*/
receive(ops: readonly Op[], origin: unknown = 'remote'): Op[] {
for (const op of ops) {
this.clock.observe(op.id);
if (!this.log.has(op.id) && !this.pending.some(p => opIdEq(p.id, op.id)))
this.pending.push(op);
}
const applied = this.drain();
if (applied.length > 0)
this.emit(applied, origin);
return applied;
}
private drain(): Op[] {
const applied: Op[] = [];
let progressed = true;
while (this.pending.length > 0 && progressed) {
progressed = false;
for (let i = this.pending.length - 1; i >= 0; i--) {
const op = this.pending[i]!;
if (this.handlers.integrate(op)) {
this.log.append(op);
this.pending.splice(i, 1);
applied.push(op);
progressed = true;
}
}
}
return applied;
}
/** Ops a remote replica (described by its version vector) is missing. */
delta(remote: VersionVector): Op[] {
return this.log.delta(remote);
}
/** Subscribe to applied ops (local + remote). Returns an unsubscribe fn. */
onUpdate(listener: UpdateListener<Op>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private emit(ops: readonly Op[], origin: unknown): void {
for (const listener of this.listeners)
listener(ops, origin);
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from './clock';
export * from './registers';
export * from './ordering';
export * from './sequence';
export * from './marks';
export * from './oplog';
export * from './sync';
export * from './doc';
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { opId } from '../../clock';
import { MarkStore } from '..';
describe('markStore', () => {
it('resolves overlapping spans by highest op id per character/type', () => {
const chars = [opId('a', 1), opId('a', 2), opId('a', 3)];
const store = new MarkStore();
store.add({ id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[2]! });
store.add({ id: opId('a', 11), type: 'bold', value: null, start: chars[1]!, end: chars[1]! });
const active = store.resolve(chars);
expect(active[0]!.get('bold')).toBe(true);
expect(active[1]!.has('bold')).toBe(false); // cleared by the higher-id span
expect(active[2]!.get('bold')).toBe(true);
});
it('converges regardless of span insertion order', () => {
const chars = [opId('a', 1), opId('a', 2)];
const spanA = { id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[1]! };
const spanB = { id: opId('b', 10), type: 'bold', value: null, start: chars[0]!, end: chars[0]! };
const first = new MarkStore();
first.add(spanA);
first.add(spanB);
const second = new MarkStore();
second.add(spanB);
second.add(spanA);
expect(first.resolve(chars).map(m => m.get('bold')))
.toEqual(second.resolve(chars).map(m => m.get('bold')));
});
});
+1
View File
@@ -0,0 +1 @@
export * from './mark-store';
+78
View File
@@ -0,0 +1,78 @@
import type { OpId } from '../clock';
import { compareOpId, opIdEq, opIdToString } from '../clock';
/** A mark's value: `true`/attrs to apply, `null`/`false` to clear. JSON-serializable. */
export type MarkValue = boolean | string | number | null | { readonly [key: string]: MarkValue };
/**
* A formatting span anchored to character op ids (inclusive range), tagged with
* an op id for LWW conflict resolution — a lightweight Peritext mark.
*/
export interface MarkSpan {
readonly id: OpId;
readonly type: string;
readonly value: MarkValue;
readonly start: OpId;
readonly end: OpId;
}
/**
* Stores formatting spans and resolves them against a character order. For each
* (character, mark type) the covering span with the highest op id wins, so
* concurrent formatting converges; a `null`/`false` value clears the mark.
*/
export class MarkStore {
private spans: MarkSpan[] = [];
add(span: MarkSpan): boolean {
if (this.has(span.id))
return false;
this.spans.push(span);
return true;
}
has(id: OpId): boolean {
return this.spans.some(span => opIdEq(span.id, id));
}
all(): readonly MarkSpan[] {
return this.spans;
}
/**
* Active marks for each character, given the character ids in document order.
* Returns one `type → value` map per index.
*/
resolve(order: readonly OpId[]): Array<Map<string, MarkValue>> {
const indexOf = new Map<string, number>();
order.forEach((id, i) => indexOf.set(opIdToString(id), i));
const active: Array<Map<string, MarkValue>> = order.map(() => new Map());
const winner: Array<Map<string, OpId>> = order.map(() => new Map());
for (const span of this.spans) {
const startIndex = indexOf.get(opIdToString(span.start));
const endIndex = indexOf.get(opIdToString(span.end));
if (startIndex === undefined || endIndex === undefined)
continue;
const lo = Math.min(startIndex, endIndex);
const hi = Math.max(startIndex, endIndex);
for (let i = lo; i <= hi; i++) {
const current = winner[i]!.get(span.type);
if (current && compareOpId(span.id, current) <= 0)
continue;
winner[i]!.set(span.type, span.id);
if (span.value === null || span.value === false)
active[i]!.delete(span.type);
else
active[i]!.set(span.type, span.value);
}
}
return active;
}
}
+1
View File
@@ -0,0 +1 @@
export * from './op-log';
+43
View File
@@ -0,0 +1,43 @@
import type { OpId } from '../clock';
import { VersionVector } from '../clock';
/** Anything carrying an op id can live in the log. */
export interface HasOpId {
readonly id: OpId;
}
/**
* An append-only log of operations with a version vector for deduplication and
* delta computation. The op shape is domain-specific; the log only reads `id`.
*/
export class OpLog<Op extends HasOpId> {
private readonly ops: Op[] = [];
private readonly vv = new VersionVector();
/** Append an op unless already seen. Returns `true` if appended. */
append(op: Op): boolean {
if (this.vv.has(op.id))
return false;
this.ops.push(op);
this.vv.observe(op.id);
return true;
}
has(id: OpId): boolean {
return this.vv.has(id);
}
get version(): VersionVector {
return this.vv;
}
all(): readonly Op[] {
return this.ops;
}
/** Ops a remote replica (described by its version vector) hasn't seen. */
delta(remote: VersionVector): Op[] {
return this.ops.filter(op => !remote.has(op.id));
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { keyBetween, keysBetween } from '..';
function seeded(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0xFFFFFFFF;
};
}
describe('keyBetween', () => {
it('produces a key strictly between its bounds', () => {
const a = keyBetween(null, null);
const b = keyBetween(a, null);
expect(b > a).toBe(true);
const mid = keyBetween(a, b);
expect(mid > a && mid < b).toBe(true);
});
it('rejects an inverted range', () => {
expect(() => keyBetween('b', 'a')).toThrow();
});
it('keysBetween returns n ascending keys within the bounds', () => {
const keys = keysBetween('a', 'b', 5);
expect(keys).toHaveLength(5);
for (let i = 1; i < keys.length; i++)
expect(keys[i - 1]! < keys[i]!).toBe(true);
expect(keys[0]! > 'a' && keys[keys.length - 1]! < 'b').toBe(true);
});
it('stays strictly ordered under 300 random insertions', () => {
const rng = seeded(7);
const keys: string[] = [keyBetween(null, null)];
for (let i = 0; i < 300; i++) {
const pos = Math.floor(rng() * (keys.length + 1));
const lower = pos > 0 ? keys[pos - 1]! : null;
const upper = pos < keys.length ? keys[pos]! : null;
keys.splice(pos, 0, keyBetween(lower, upper));
}
for (let i = 1; i < keys.length; i++)
expect(keys[i - 1]! < keys[i]!).toBe(true);
});
});
@@ -0,0 +1,54 @@
/**
* Fractional indexing: generate string "order keys" so an element can be placed
* strictly between two neighbors with a single key, and re-ordered (moved) by
* just changing its key — without touching anything else. The digit alphabet is
* ASCII-ascending, so JavaScript string comparison matches digit order.
*/
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function midpoint(a: string, b: string): string {
if (b !== '' && a >= b)
throw new Error(`fractional-index: lower '${a}' must be < upper '${b}'`);
let result = '';
let i = 0;
let upper = b;
for (;;) {
const x = i < a.length ? DIGITS.indexOf(a[i]!) : 0;
const y = upper !== '' && i < upper.length ? DIGITS.indexOf(upper[i]!) : DIGITS.length;
if (x === y) {
result += DIGITS[x]!;
i += 1;
continue;
}
const mid = x + Math.floor((y - x) / 2);
if (mid !== x)
return result + DIGITS[mid]!;
// Digits are adjacent — keep the lower digit and open the upper bound.
result += DIGITS[x]!;
i += 1;
upper = '';
}
}
/** A key strictly between `lower` and `upper` (`null` = open bound). */
export function keyBetween(lower: string | null, upper: string | null): string {
return midpoint(lower ?? '', upper ?? '');
}
/** `n` keys strictly between `lower` and `upper`, in ascending order. */
export function keysBetween(lower: string | null, upper: string | null, n: number): string[] {
if (n <= 0)
return [];
if (n === 1)
return [keyBetween(lower, upper)];
const mid = keyBetween(lower, upper);
const left = keysBetween(lower, mid, Math.floor((n - 1) / 2));
const right = keysBetween(mid, upper, n - 1 - left.length);
return [...left, mid, ...right];
}
+1
View File
@@ -0,0 +1 @@
export * from './fractional-index';
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { opId } from '../../clock';
import { LwwMap, LwwRegister } from '..';
describe('lwwRegister', () => {
it('keeps the write with the higher op id regardless of order', () => {
const a = new LwwRegister('init');
a.set('first', opId('a', 1));
a.set('second', opId('b', 2));
expect(a.get()).toBe('second');
// A later-arriving but older write must not win.
expect(a.set('stale', opId('a', 1))).toBe(false);
expect(a.get()).toBe('second');
});
it('converges when concurrent writes arrive in opposite orders', () => {
const left = new LwwRegister('x');
const right = new LwwRegister('x');
left.set('A', opId('a', 5));
left.set('B', opId('b', 5));
right.set('B', opId('b', 5));
right.set('A', opId('a', 5));
expect(left.get()).toBe(right.get());
expect(left.get()).toBe('B'); // site 'b' > 'a' at equal clock
});
});
describe('lwwMap', () => {
it('handles set/delete with tombstones', () => {
const map = new LwwMap<string, number>();
map.set('k', 1, opId('a', 1));
expect(map.get('k')).toBe(1);
map.delete('k', opId('a', 2));
expect(map.has('k')).toBe(false);
// A concurrent older set loses to the delete.
expect(map.set('k', 9, opId('a', 1))).toBe(false);
expect(map.has('k')).toBe(false);
});
});
+2
View File
@@ -0,0 +1,2 @@
export * from './lww-register';
export * from './lww-map';
+52
View File
@@ -0,0 +1,52 @@
import type { OpId } from '../clock';
import { compareOpId } from '../clock';
interface Entry<V> {
value: V;
ts: OpId;
deleted: boolean;
}
/**
* Last-writer-wins map with per-key timestamps and tombstones. Concurrent
* set/delete on a key converge to the operation with the higher op id.
*/
export class LwwMap<K, V> {
private readonly entries = new Map<K, Entry<V>>();
set(key: K, value: V, id: OpId): boolean {
const existing = this.entries.get(key);
if (existing && compareOpId(id, existing.ts) <= 0)
return false;
this.entries.set(key, { value, ts: id, deleted: false });
return true;
}
delete(key: K, id: OpId): boolean {
const existing = this.entries.get(key);
if (existing && compareOpId(id, existing.ts) <= 0)
return false;
this.entries.set(key, { value: existing?.value as V, ts: id, deleted: true });
return true;
}
get(key: K): V | undefined {
const entry = this.entries.get(key);
return entry && !entry.deleted ? entry.value : undefined;
}
has(key: K): boolean {
const entry = this.entries.get(key);
return entry !== undefined && !entry.deleted;
}
keys(): K[] {
return [...this.entries].filter(([, entry]) => !entry.deleted).map(([key]) => key);
}
toEntries(): Array<[K, V]> {
return [...this.entries].filter(([, entry]) => !entry.deleted).map(([key, entry]) => [key, entry.value]);
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { OpId } from '../clock';
import { compareOpId } from '../clock';
/**
* Last-writer-wins register. A write applies only if its op id is later than the
* current write's (by {@link compareOpId}), so concurrent writes converge to the
* one with the higher timestamp regardless of arrival order.
*/
export class LwwRegister<T> {
private ts: OpId | null = null;
constructor(private current: T) {}
get(): T {
return this.current;
}
/** Apply a timestamped write. Returns `true` if it won. */
set(value: T, id: OpId): boolean {
if (this.ts !== null && compareOpId(id, this.ts) <= 0)
return false;
this.current = value;
this.ts = id;
return true;
}
get timestamp(): OpId | null {
return this.ts;
}
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import type { OpId } from '../../clock';
import { VersionVector, opId, opIdEq } from '../../clock';
import { Rga } from '..';
type Op
= | { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId };
/** Apply ops in the given order, buffering and retrying any whose causal deps aren't met. */
function applyAll(rga: Rga<string>, ops: readonly Op[]): void {
const pending = [...ops];
let progressed = true;
while (pending.length > 0 && progressed) {
progressed = false;
for (let i = pending.length - 1; i >= 0; i--) {
const op = pending[i]!;
const applied = op.kind === 'insert'
? rga.integrateInsert(op.id, op.value, op.originLeft)
: rga.integrateDelete(op.id);
if (applied) {
pending.splice(i, 1);
progressed = true;
}
}
}
}
function seeded(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0xFFFFFFFF;
};
}
function shuffle<T>(items: readonly T[], rng: () => number): T[] {
const out = [...items];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
describe('rga', () => {
it('orders concurrent inserts at the same origin higher-op-id-first', () => {
const opA: Op = { kind: 'insert', id: opId('a', 1), value: 'X', originLeft: null };
const opB: Op = { kind: 'insert', id: opId('b', 1), value: 'Y', originLeft: null };
const first = new Rga<string>();
applyAll(first, [opA, opB]);
const second = new Rga<string>();
applyAll(second, [opB, opA]);
expect(first.toArray()).toEqual(['Y', 'X']); // site 'b' > 'a' at equal clock
expect(second.toArray()).toEqual(first.toArray());
});
it('is idempotent under re-applied ops', () => {
const rga = new Rga<string>();
const op: Op = { kind: 'insert', id: opId('a', 1), value: 'X', originLeft: null };
applyAll(rga, [op, op, op]);
expect(rga.toArray()).toEqual(['X']);
});
it('converges across two replicas for random concurrent ops + deletes', () => {
for (let trial = 0; trial < 25; trial++) {
const rng = seeded(trial + 1);
const sites = ['a', 'b', 'c'];
const counters: Record<string, number> = { a: 0, b: 0, c: 0 };
const inserted: Array<{ id: OpId; value: string }> = [];
const ops: Op[] = [];
for (let i = 0; i < 40; i++) {
const site = sites[Math.floor(rng() * sites.length)]!;
const makeDelete = inserted.length > 0 && rng() < 0.25;
if (makeDelete) {
const target = inserted[Math.floor(rng() * inserted.length)]!;
ops.push({ kind: 'delete', id: target.id });
}
else {
counters[site] = counters[site]! + 1;
const id = opId(site, counters[site]!);
const originLeft = inserted.length > 0 && rng() < 0.8
? inserted[Math.floor(rng() * inserted.length)]!.id
: null;
ops.push({ kind: 'insert', id, value: `${site}${counters[site]}`, originLeft });
inserted.push({ id, value: `${site}${counters[site]}` });
}
}
const replicaOrder = new Rga<string>();
applyAll(replicaOrder, ops);
const replicaShuffled = new Rga<string>();
applyAll(replicaShuffled, shuffle(ops, rng));
expect(replicaShuffled.toArray()).toEqual(replicaOrder.toArray());
}
});
it('gc drops stable tombstones, keeps live/protected/unstable ones', () => {
const rga = new Rga<string>();
applyAll(rga, [
{ kind: 'insert', id: opId('a', 1), value: 'a', originLeft: null },
{ kind: 'insert', id: opId('a', 2), value: 'b', originLeft: opId('a', 1) },
{ kind: 'insert', id: opId('a', 3), value: 'c', originLeft: opId('a', 2) },
]);
rga.integrateDelete(opId('a', 2)); // tombstone 'b'
rga.integrateDelete(opId('a', 3)); // tombstone 'c'
expect(rga.toArray().join('')).toBe('a');
const stable = new VersionVector();
stable.observe(opId('a', 2)); // only ops up to a@2 are stable everywhere
// a@2 tombstone is dropped; a@3 is unstable (kept); 'c' protected via keep.
const removed = rga.gc(stable, id => opIdEq(id, opId('a', 3)));
expect(removed).toBe(1);
expect(rga.has(opId('a', 2))).toBe(false);
expect(rga.has(opId('a', 3))).toBe(true);
expect(rga.has(opId('a', 1))).toBe(true); // live, never dropped
expect(rga.toArray().join('')).toBe('a');
});
});
+1
View File
@@ -0,0 +1 @@
export * from './rga';
+111
View File
@@ -0,0 +1,111 @@
import type { OpId, VersionVector } from '../clock';
import { compareOpId, opIdEq } from '../clock';
/** One element of an RGA sequence (visible or tombstoned). */
export interface RgaNode<T> {
readonly id: OpId;
readonly value: T;
readonly originLeft: OpId | null;
deleted: boolean;
}
/**
* Replicated Growable Array a sequence CRDT. Each element is inserted after a
* left-origin element (or at the start) and tombstoned on delete. Concurrent
* inserts at the same origin are ordered higher-op-id-first, a deterministic
* tie-break that makes every replica converge to the same order. Operations must
* be integrated in causal order (an insert's origin must already be present);
* {@link integrateInsert} returns `false` when the origin is missing so the
* caller can buffer and retry.
*/
export class Rga<T> {
private nodes: Array<RgaNode<T>> = [];
private nodeIndex(id: OpId): number {
for (let i = 0; i < this.nodes.length; i++) {
if (opIdEq(this.nodes[i]!.id, id))
return i;
}
return -1;
}
has(id: OpId): boolean {
return this.nodeIndex(id) !== -1;
}
/** Integrate an insert after `originLeft` (`null` = start). Idempotent. */
integrateInsert(id: OpId, value: T, originLeft: OpId | null): boolean {
if (this.has(id))
return true;
const originIndex = originLeft === null ? -1 : this.nodeIndex(originLeft);
if (originLeft !== null && originIndex === -1)
return false; // origin not present yet — caller should buffer
let i = originIndex + 1;
while (i < this.nodes.length && compareOpId(this.nodes[i]!.id, id) > 0)
i += 1;
this.nodes.splice(i, 0, { id, value, originLeft, deleted: false });
return true;
}
/** Tombstone an element. Idempotent; returns false if the element is unknown. */
integrateDelete(id: OpId): boolean {
const index = this.nodeIndex(id);
if (index === -1)
return false;
this.nodes[index]!.deleted = true;
return true;
}
/**
* Drop tombstoned nodes whose insert is covered by `stable`. Call ONLY at
* quiescence when every replica has fully synced and no operations are in
* flight otherwise a late op that uses a dropped node as its origin can no
* longer integrate. `keep` protects ids still referenced elsewhere (e.g. mark
* span endpoints). Returns the number of nodes removed.
*/
gc(stable: VersionVector, keep?: (id: OpId) => boolean): number {
const before = this.nodes.length;
this.nodes = this.nodes.filter(node =>
!node.deleted || !stable.has(node.id) || (keep?.(node.id) ?? false));
return before - this.nodes.length;
}
/** Visible values in document order. */
toArray(): T[] {
const out: T[] = [];
for (const node of this.nodes) {
if (!node.deleted)
out.push(node.value);
}
return out;
}
/** Visible nodes in document order (read ids for cursor anchoring). */
visible(): Array<RgaNode<T>> {
return this.nodes.filter(node => !node.deleted);
}
/** All nodes including tombstones (for state encoding). */
all(): ReadonlyArray<RgaNode<T>> {
return this.nodes;
}
/** Op id of the visible element at `index`, or `null` if out of range. */
idAt(index: number): OpId | null {
return this.visible()[index]?.id ?? null;
}
/** Number of visible elements. */
get length(): number {
let count = 0;
for (const node of this.nodes) {
if (!node.deleted)
count += 1;
}
return count;
}
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { VersionVector, opId } from '../../clock';
import { decodeOps, decodeStateVector, encodeOps, encodeStateVector } from '..';
describe('sync encoding', () => {
it('round-trips a version vector through bytes', () => {
const vv = new VersionVector();
vv.observe(opId('a', 3));
vv.observe(opId('b', 1));
const restored = decodeStateVector(encodeStateVector(vv));
expect(restored.get('a')).toBe(3);
expect(restored.get('b')).toBe(1);
});
it('round-trips an op batch through bytes', () => {
const ops = [{ id: opId('a', 1), kind: 'insert', value: 'x' }];
expect(decodeOps(encodeOps(ops))).toEqual(ops);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { VersionVector } from '../clock';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/**
* Transport-agnostic wire encoding. v1 is JSON-over-bytes simple and
* debuggable; a compact varint format is a later optimization with no API change.
*/
export function encodeJson(value: unknown): Uint8Array {
return encoder.encode(JSON.stringify(value));
}
export function decodeJson<T>(bytes: Uint8Array): T {
return JSON.parse(decoder.decode(bytes)) as T;
}
/** Encode a version vector for a "what do you have?" sync handshake. */
export function encodeStateVector(vv: VersionVector): Uint8Array {
return encodeJson(vv.toJSON());
}
export function decodeStateVector(bytes: Uint8Array): VersionVector {
return VersionVector.fromJSON(decodeJson(bytes));
}
/** Encode a batch of ops (the delta or a full snapshot). */
export function encodeOps<Op>(ops: readonly Op[]): Uint8Array {
return encodeJson(ops);
}
export function decodeOps<Op>(bytes: Uint8Array): Op[] {
return decodeJson<Op[]>(bytes);
}
+1
View File
@@ -0,0 +1 @@
export * from './encode';
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.src.json" },
{ "path": "./tsconfig.node.json" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@robonen/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["*.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@robonen/tsconfig/tsconfig.base.json",
"compilerOptions": {
"composite": true,
"types": ["node"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
tsconfig: './tsconfig.src.json',
entry: ['src/index.ts'],
});

Some files were not shown because too many files have changed in this diff Show More