@@ -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.
|
||||||
@@ -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
@@ -5,21 +5,26 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.x
|
NODE_VERSION: 24.x
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
code-quality:
|
# Enumerate the workspace packages so the matrix below fans out one job per
|
||||||
name: Code quality checks
|
# package (kept dynamic so new packages are picked up automatically).
|
||||||
|
discover:
|
||||||
|
name: Discover packages
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
outputs:
|
||||||
contents: read
|
packages: ${{ steps.list.outputs.packages }}
|
||||||
pull-requests: write
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v5
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -31,11 +36,52 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- 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
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm --filter "${{ matrix.package }}" --if-present run lint:check
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm --filter "${{ matrix.package }}" --if-present run test
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22.x
|
NODE_VERSION: 24.x
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-and-publish:
|
check-and-publish:
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v5
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -31,6 +31,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: pnpm --filter "@robonen/primitives" exec playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Build & Test
|
- name: Build & Test
|
||||||
run: pnpm build && pnpm test
|
run: pnpm build && pnpm test
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ dist
|
|||||||
|
|
||||||
# test
|
# test
|
||||||
coverage
|
coverage
|
||||||
|
**/.vitest-attachments
|
||||||
|
|
||||||
# env
|
# env
|
||||||
.env*
|
.env*
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"robonen-docs": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://localhost:3000/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { base, compose, imports, stylistic, typescript } from './src';
|
||||||
|
|
||||||
|
export default compose(base, typescript, imports, stylistic);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
|
```
|
||||||
@@ -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');
|
||||||
|
```
|
||||||
@@ -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`.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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 { typescript } from './typescript';
|
||||||
export { vue } from './vue';
|
export { vue } from './vue';
|
||||||
export { vitest } from './vitest';
|
export { vitest } from './vitest';
|
||||||
export { imports } from './imports';
|
export { imports } from './imports';
|
||||||
export { node } from './node';
|
export { node } from './node';
|
||||||
|
export { regexp } from './regexp';
|
||||||
|
export { stylistic } from './stylistic';
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.src.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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({
|
export default defineConfig({
|
||||||
...sharedConfig,
|
...sharedConfig,
|
||||||
|
tsconfig: './tsconfig.src.json',
|
||||||
entry: ['src/index.ts'],
|
entry: ['src/index.ts'],
|
||||||
});
|
});
|
||||||
@@ -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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { defineConfig } from 'oxlint';
|
|
||||||
import { compose, base, typescript, imports } from '@robonen/oxlint';
|
|
||||||
|
|
||||||
export default defineConfig(compose(base, typescript, imports));
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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';
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
|
||||||
}
|
|
||||||
+85
-10
@@ -1,6 +1,6 @@
|
|||||||
# @robonen/tsconfig
|
# @robonen/tsconfig
|
||||||
|
|
||||||
Shared base TypeScript configuration.
|
Shared TypeScript configurations.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -8,20 +8,95 @@ Shared base TypeScript configuration.
|
|||||||
pnpm install -D @robonen/tsconfig
|
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
|
## 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
|
## Project references (DOM + Node split)
|
||||||
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`
|
|
||||||
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`
|
Most packages contain two environments: browser/library `src` (DOM) and Node
|
||||||
- **Declarations**: `declaration` enabled
|
tooling files (`vite.config.ts`, `vitest.config.ts`, `tsdown.config.ts`). They
|
||||||
- **Interop**: `esModuleInterop`, `allowJs`, `resolveJsonModule`
|
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`
|
||||||
|
|||||||
@@ -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> → <code>dom</code> → <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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/tsconfig",
|
"name": "@robonen/tsconfig",
|
||||||
"version": "0.0.2",
|
"version": "0.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Base typescript configuration for projects",
|
"description": "Base typescript configuration for projects",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -15,12 +15,16 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "packages/tsconfig"
|
"directory": "packages/tsconfig"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13.1"
|
"node": ">=24.13.1"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"**tsconfig.json"
|
"tsconfig.json",
|
||||||
|
"tsconfig.base.json",
|
||||||
|
"tsconfig.dom.json",
|
||||||
|
"tsconfig.node.json",
|
||||||
|
"tsconfig.vue.json"
|
||||||
],
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"display": "Base TypeScript Configuration",
|
"display": "@robonen base (default)",
|
||||||
"compilerOptions": {
|
"extends": "./tsconfig.base.json"
|
||||||
/* 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"]
|
|
||||||
}
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "configs/tsdown"
|
"directory": "configs/tsdown"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.13.1"
|
"node": ">=24.13.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 */';
|
const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */';
|
||||||
|
|
||||||
@@ -10,4 +10,4 @@ export const sharedConfig = {
|
|||||||
outputOptions: {
|
outputOptions: {
|
||||||
banner: BANNER,
|
banner: BANNER,
|
||||||
},
|
},
|
||||||
} satisfies Options;
|
} satisfies InlineConfig;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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 & 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>
|
||||||
@@ -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 < 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 > 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 => n.id)</code> — it returns one <code>Map<type, value></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 & 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>
|
||||||
@@ -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 & 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) >= 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>
|
||||||
@@ -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<string></code> wrapped in a
|
||||||
|
<code>Replica<CharOp></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>
|
||||||
@@ -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 & 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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
|
||||||
|
|
||||||
|
export default compose(base, typescript, imports, stylistic);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './id';
|
||||||
|
export * from './lamport';
|
||||||
|
export * from './version-vector';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './replica';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './mark-store';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './op-log';
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './lww-register';
|
||||||
|
export * from './lww-map';
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './rga';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './encode';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.src.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["*.config.ts"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user