diff --git a/.github/workflows/web-demos.yml b/.github/workflows/web-demos.yml index 6979b250..1bf8d023 100644 --- a/.github/workflows/web-demos.yml +++ b/.github/workflows/web-demos.yml @@ -1,110 +1,94 @@ +name: Coln Lab + on: - pull_request: push: - branches: [main] - -name: Web demos - -permissions: - contents: read - pull-requests: write + branches: [live-demos] concurrency: - group: web-demos-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: pages + cancel-in-progress: false jobs: - # build-and-deploy: - # runs-on: ubuntu-latest - # env: - # CARGO_TARGET_DIR: ${{ github.workspace }}/target - # NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - # NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - - # steps: - # - name: Checkout ⬇️ - # uses: actions/checkout@v6 - - # - name: Install Nix ❄️ - # uses: cachix/install-nix-action@v31 - - # - name: Set up Cachix ♻️ - # if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - # uses: cachix/cachix-action@v17 - # with: - # name: coln - # authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - # - name: Restore build caches - # uses: actions/cache@v4 - # with: - # path: | - # .pnpm-store - # ~/.npm - # ~/.cargo/registry - # ~/.cargo/git - # ~/.ghc-wasm/.cabal - # target - # dist-newstyle - # key: web-demos-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'cabal.project', 'packages/**/*.cabal', 'packages/coln-js-runtime/package-lock.json', 'examples/sync-demo/pnpm-lock.yaml') }} - # restore-keys: | - # web-demos-${{ runner.os }}- - - # - name: Build sync demo - # run: nix run --accept-flake-config .#sync-demo -- "$GITHUB_WORKSPACE" - # env: - # VITE_BASE: /sync/ - - # - name: Build web compiler - # run: | - # nix develop --accept-flake-config --command wasm32-wasi-cabal update 'hackage.haskell.org,2026-07-15T17:07:49Z' - # nix develop --accept-flake-config --command just examples/build-web-compiler - - # - name: Assemble deploy directory - # run: | - # cp examples/index.html examples/style.css _build/web/ - # cp -r examples/sync-demo/dist _build/web/sync - - # - name: Deploy PR preview to Netlify - # id: deploy-preview - # if: github.event_name == 'pull_request' && env.NETLIFY_AUTH_TOKEN != '' && env.NETLIFY_SITE_ID != '' - # run: | - # npx --yes netlify-cli@latest deploy \ - # --auth "$NETLIFY_AUTH_TOKEN" \ - # --site "$NETLIFY_SITE_ID" \ - # --dir _build/web \ - # --no-build \ - # --alias "pr-${{ github.event.pull_request.number }}" \ - # --message "PR #${{ github.event.pull_request.number }} web demos" \ - # --json > netlify-deploy.json - - # cat netlify-deploy.json - # deploy_url=$(node -e "const d = require('./netlify-deploy.json'); console.log(d.deploy_ssl_url || d.ssl_url || d.deploy_url || d.url)") - # echo "deploy-url=$deploy_url" >> "$GITHUB_OUTPUT" - - # - name: Comment with deploy preview URL - # if: github.event_name == 'pull_request' && steps.deploy-preview.outputs.deploy-url != '' - # uses: actions/github-script@v8 - # with: - # script: | - # const marker = '' - # const body = `${marker}\nWeb demo preview: ${{ steps.deploy-preview.outputs.deploy-url }}` - # const { owner, repo } = context.repo - # const issue_number = context.issue.number - # const comments = await github.rest.issues.listComments({ owner, repo, issue_number, per_page: 100 }) - # const existing = comments.data.find(comment => comment.body?.includes(marker)) - # if (existing) { - # await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }) - # } else { - # await github.rest.issues.createComment({ owner, repo, issue_number, body }) - # } - # - name: Deploy main to Netlify production - # if: github.event_name == 'push' && env.NETLIFY_AUTH_TOKEN != '' && env.NETLIFY_SITE_ID != '' - # run: | - # npx --yes netlify-cli@latest deploy \ - # --auth "$NETLIFY_AUTH_TOKEN" \ - # --site "$NETLIFY_SITE_ID" \ - # --dir _build/web \ - # --no-build \ - # --prod \ - # --message "web demos ${{ github.sha }}" + build: + runs-on: ubuntu-latest + permissions: + contents: read + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} + PNPM_STORE_DIR: ${{ github.workspace }}/.pnpm-store + VITE_BASE: /coln/ + VITE_SUBDUCTION_ENDPOINT: wss://coln.sync.inkandswitch.com + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - name: Set up Cachix + if: ${{ env.CACHIX_AUTH_TOKEN != '' }} + uses: cachix/cachix-action@v17 + with: + name: coln + authToken: ${{ env.CACHIX_AUTH_TOKEN }} + + - name: Restore build caches + uses: actions/cache@v4 + with: + path: | + .pnpm-store + ~/.npm + ~/.cargo/registry + ~/.cargo/git + ~/.ghc-wasm/.cabal + target + dist-newstyle + key: lab-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'cabal.project', 'packages/**/*.cabal', 'packages/coln-js-runtime/package-lock.json', 'packages/coln-repo/pnpm-lock.yaml', 'examples/lab/pnpm-lock.yaml') }} + restore-keys: | + lab-${{ runner.os }}- + + - name: Build linked packages + run: | + nix develop --accept-flake-config --command npm ci --prefix packages/coln-js-runtime + nix develop --accept-flake-config --command npm run --prefix packages/coln-js-runtime build + nix develop --accept-flake-config --command pnpm --dir packages/coln-repo install --frozen-lockfile --store-dir "$PNPM_STORE_DIR" + nix develop --accept-flake-config --command pnpm --dir packages/coln-repo build + + - name: Install Lab dependencies + run: nix develop --accept-flake-config --command pnpm --dir examples/lab install --frozen-lockfile --store-dir "$PNPM_STORE_DIR" + + - name: Build compiler WASM + run: | + nix develop --accept-flake-config --command wasm32-wasi-cabal update 'hackage.haskell.org,2026-07-15T17:07:49Z' + nix develop --accept-flake-config --command just examples/stage-web-lab + + - name: Test Lab + run: nix develop --accept-flake-config --command pnpm --dir examples/lab test + + - name: Build Lab + run: nix develop --accept-flake-config --command pnpm --dir examples/lab build + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: _build/web/lab-app + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 38a4c7b6..3ecb61c6 100644 --- a/.gitignore +++ b/.gitignore @@ -337,6 +337,8 @@ TSWLatexianTemp* .shake _build/ +.env +.env.local /cabal.project.local # nix result diff --git a/Cargo.lock b/Cargo.lock index 47e4537c..3855a109 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2408,9 +2408,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hexane" -version = "1.0.0-alpha.2" +version = "1.0.0-alpha.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6fa73ae43aeadf80e9032268bcb216fb1bd1930a08d7f73e44bb0b740c3d702" +checksum = "81df830f11babb0eee9a082e09135ef0a7d8791f3fe9b279bb9485bf7eaf23e0" dependencies = [ "leb128", "thiserror 2.0.18", diff --git a/examples/cli-demo/.gitignore b/examples/cli-demo/.gitignore new file mode 100644 index 00000000..8166d595 --- /dev/null +++ b/examples/cli-demo/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +test-results/ +playwright-report/ diff --git a/examples/cli-demo/README.md b/examples/cli-demo/README.md new file mode 100644 index 00000000..69f2c0a9 --- /dev/null +++ b/examples/cli-demo/README.md @@ -0,0 +1,139 @@ +# Coln CLI Demo + +`coln-repo` is an experimental, JSON-only CLI for agents to read and update an +existing synchronized Coln document. It exposes the document's compiled IR +without interpreting its domain and evaluates trusted, synchronous JavaScript +for reads and transactions. + +Nothing in this protocol is stable yet. + +## Build + +Build local packages first: + +```bash +npm ci --prefix packages/coln-js-runtime +npm run --prefix packages/coln-js-runtime build +pnpm --dir packages/coln-repo install +pnpm --dir packages/coln-repo build +pnpm --dir examples/cli-demo install +pnpm --dir examples/cli-demo build +``` + +The built binary is `examples/cli-demo/dist/cli.js`, exposed as `coln-repo` by +the package manifest. Put it on your `PATH` with: + +```bash +pnpm --dir examples/cli-demo link --global +``` + +The examples below assume `coln-repo` is on `PATH`; otherwise substitute +`node examples/cli-demo/dist/cli.js`. + +## Agent Skill + +The model-facing workflow lives in [`skills/coln-repo/SKILL.md`](./skills/coln-repo/SKILL.md), +a portable skill (frontmatter plus markdown) that OpenCode, Claude Code, and +similar harnesses can load. Two ways to reach it: + +```bash +coln-repo guide # print the skill body as JSON +coln-repo install-skill # copy to ~/.agents/skills/coln-repo +coln-repo install-skill --dir ~/.claude/skills +``` + +`install-skill` reports `installed`, `updated`, or `unchanged`; rerun it after +upgrading the CLI to refresh the installed copy. No harness configuration or +permission rules are shipped; see Security below. + +## Commands + +Every command takes the document as a named flag. `--endpoint` overrides +`SUBDUCTION_ENDPOINT`; otherwise the public Subduction relay is used. + +```bash +coln-repo ir --document [--endpoint ] +``` + +`ir` returns the compiled JSON IR unchanged inside the response's `ir` field. +Agents should use it as structural evidence, not domain documentation. + +`query` reads one JavaScript expression from stdin. A read-only `store` is in +scope with `jsonIR()`, `scanTable(path)`, `rowById(path, rowRef)`, and `heads()`. + +```bash +coln-repo query --document automerge:... <<'JS' +store.scanTable("Records.Documents") +JS +``` + +`exec` reads a JavaScript function body from stdin. The current transaction API +is in scope as `txn`; today it exposes `txn.add(path, values)`. Return a +JSON-serializable value to include it in the response. + +```bash +coln-repo exec --document automerge:... <<'JS' +const folder = txn.add("Records.Folders", [{ tag: "string", value: "Inbox" }]) +const document = txn.add("Records.Documents", [ + folder, + { tag: "string", value: "Notes" }, +]) +return { folder, document } +JS +``` + +Values passed to `txn.add` are tagged: + +```js +{ tag: "string", value: "hello" } +{ tag: "int", value: 42 } +{ tag: "row_id", value: rowReference } +``` + +`txn.add` returns a complete tagged `row_id` value. Pass that value directly to +later `txn.add` calls; do not wrap it in another `{ tag: "row_id", ... }`. + +Programs must complete synchronously. Their return values must be JSON +serializable. Program `console` output is redirected to stderr so stdout remains +one JSON response. + +## Responses + +Every response contains `protocolVersion: 0`, `ok`, and the command where known. +Successful writes include heads before and after the local commit plus sync +status. Errors identify their phase and whether a mutation was applied locally. + +| Exit | Code | Phase | Mutation state | +|---|---|---|---| +| 2 | `INVALID_ARGUMENTS`, `PROGRAM_REQUIRED` | `arguments` | `not_applied` | +| 3 | `DOCUMENT_OPEN_FAILED` | `open` | `not_applied` | +| 4 | `IR_FAILED`, `QUERY_FAILED` | `read`, `evaluate` | `not_applied` | +| 4 | `GUIDE_FAILED`, `SKILL_INSTALL_FAILED` | `read`, `install` | `not_applied` | +| 5 | `TRANSACTION_FAILED` | `transaction` | `not_applied` | +| 5 | `RESULT_INVALID` | `result` | `not_applied` | +| 5 | `RESULT_FAILED` | `result` | `applied_locally` | +| 6 | `SYNC_FAILED` | `flush` | `applied_locally` | +| 1 | `INTERNAL_ERROR` | `internal` | `unknown` | + +`RESULT_INVALID` means the program's return value was not JSON; the transaction +was rolled back so the caller never loses the row references it asked for. + +If an `exec` response reports `mutationState: "applied_locally"`, do not retry it +automatically: the local commit succeeded, but relay synchronization or result +reporting failed afterwards. The CLI does not judge retryability of +`not_applied` failures; connection timeouts may be transient, schema rejections +are not. + +## Security + +`query` and `exec` evaluate trusted JavaScript with this Node process's +capabilities. Agent harnesses should request approval for them by default. +Allowing either command without approval is equivalent to allowing arbitrary +local Node execution. `ir`, `guide`, and `install-skill` do not evaluate user +JavaScript and may be allowlisted separately. + +## Verify + +```bash +pnpm --dir examples/cli-demo test +``` diff --git a/examples/cli-demo/package.json b/examples/cli-demo/package.json new file mode 100644 index 00000000..a19d6558 --- /dev/null +++ b/examples/cli-demo/package.json @@ -0,0 +1,33 @@ +{ + "name": "coln-cli-demo", + "private": true, + "type": "module", + "license": "(Apache-2.0 OR MIT)", + "bin": { + "coln-repo": "./dist/cli.js" + }, + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "cli": "tsx src/cli.ts", + "typecheck": "tsc --noEmit", + "test": "pnpm run typecheck && pnpm run test:e2e", + "test:e2e": "pnpm run build && playwright test" + }, + "dependencies": { + "@automerge/automerge-repo": "github:automerge/automerge-repo#doctypes&path:packages/automerge-repo", + "@coln-project/repo": "link:../../packages/coln-repo" + }, + "devDependencies": { + "@automerge/automerge-subduction": "0.16.1", + "@playwright/test": "^1.62.1", + "@types/node": "^25.9.5", + "@types/ws": "^8.18.1", + "tsx": "^4.23.13", + "typescript": "^6.0.3", + "ws": "^8.21.3" + } +} diff --git a/examples/cli-demo/playwright.config.ts b/examples/cli-demo/playwright.config.ts new file mode 100644 index 00000000..31262adf --- /dev/null +++ b/examples/cli-demo/playwright.config.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + testMatch: "**/*.spec.ts", + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: 0, + webServer: { + command: "node tests/server.mjs", + port: 3031, + reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", + }, +}) diff --git a/examples/cli-demo/pnpm-lock.yaml b/examples/cli-demo/pnpm-lock.yaml new file mode 100644 index 00000000..3831a7cb --- /dev/null +++ b/examples/cli-demo/pnpm-lock.yaml @@ -0,0 +1,602 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + +importers: + + .: + dependencies: + '@automerge/automerge-repo': + specifier: github:automerge/automerge-repo#doctypes&path:packages/automerge-repo + version: https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo + '@coln-project/repo': + specifier: link:../../packages/coln-repo + version: link:../../packages/coln-repo + devDependencies: + '@automerge/automerge-subduction': + specifier: 0.16.1 + version: 0.16.1 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^25.9.5 + version: 25.9.5 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + tsx: + specifier: ^4.23.13 + version: 4.23.13 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + ws: + specifier: ^8.21.3 + version: 8.21.3 + +packages: + + '@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo': + resolution: {gitHosted: true, path: packages/automerge-repo, tarball: https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5} + version: 2.6.0-subduction.47 + engines: {node: '>=22.13'} + + '@automerge/automerge-subduction@0.16.1': + resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} + + '@automerge/automerge@3.3.2': + resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.32.6: + resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} + +snapshots: + + '@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.16.1': {} + + '@automerge/automerge@3.3.2': {} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@noble/hashes@1.8.0': {} + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.5 + + base-x@5.0.1: {} + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.6: + optionalDependencies: + cbor-extract: 2.2.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: + optional: true + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + eventemitter3@5.0.4: {} + + fast-sha256@1.3.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + isomorphic-ws@5.0.0(ws@8.21.3): + dependencies: + ws: 8.21.3 + + ms@2.1.3: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + uuid@14.0.2: {} + + ws@8.21.3: {} + + xstate@5.32.6: {} diff --git a/examples/cli-demo/pnpm-workspace.yaml b/examples/cli-demo/pnpm-workspace.yaml new file mode 100644 index 00000000..087ce062 --- /dev/null +++ b/examples/cli-demo/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +allowBuilds: + cbor-extract: false + '@automerge/automerge-repo@git+https://github.com/automerge/automerge-repo.git': true + esbuild: true +overrides: + "@automerge/automerge": 3.3.2 + "@automerge/automerge-subduction": 0.16.1 diff --git a/examples/cli-demo/skills/coln-repo/SKILL.md b/examples/cli-demo/skills/coln-repo/SKILL.md new file mode 100644 index 00000000..0dad660d --- /dev/null +++ b/examples/cli-demo/skills/coln-repo/SKILL.md @@ -0,0 +1,128 @@ +--- +name: coln-repo +description: Read and update a synchronized Coln store from an automerge: URL with the coln-repo CLI. Use whenever a request names an automerge: document, a Coln store, realm, or theory, or asks to add, list, count, or connect records or table entries in one. +--- + +# Working With a Coln Store + +A Coln document is addressed by an `automerge:` URL and holds a store: a set of +tables whose paths come from a compiled theory, such as `Records.Documents`. The +`coln-repo` CLI reads and updates one such document. Every response is a single +JSON object on stdout, including errors. Run `coln-repo help` for usage and +`coln-repo guide` to print this document. + +## Procedure + +### 1. Resolve the sync endpoint + +Pass `--endpoint ` when the user names a relay. Otherwise the CLI uses +`SUBDUCTION_ENDPOINT`, then the public relay `wss://coln.sync.inkandswitch.com`. +A document held by a local relay is only reachable there; do not invent one. If +the response is `DOCUMENT_OPEN_FAILED`, report which endpoint you tried and ask. + +### 2. Read the IR + +```bash +coln-repo ir --document [--endpoint ] +``` + +The `ir` field is the compiled JSON IR, unchanged. Each entity has a `path` +(render `[["Records"],["Documents"]]` as `Records.Documents`), ordered `columns` with a +`type` of either `{ tag: "builtin", type: "builtinInt" | "builtinString" }` or +`{ tag: "rowId", path }`, and an optional `primaryKey`. Rules describe +constraints the store enforces at commit, such as foreign keys. + +Treat the IR as structural evidence, not domain documentation. A table name or +abbreviation does not establish what its rows represent. + +### 3. Infer a mapping and confirm it + +Never assume that names or abbreviations carry a particular meaning. Before +writing, state the mapping you inferred and ask the user to confirm it, +including argument order when column names do not explain it: + +> The IR defines `Records.Folders` with a `name` string and +> `Records.Documents` with `folder` referencing `Records.Folders` followed by a +> `title` string. I take "add a document to Inbox" to mean adding or finding the +> Inbox folder, then adding a document as `[folderRowId, title]`. Is that right? + +If several entities plausibly match, ask; do not pick the closest name. Copy +entity paths exactly from the IR. + +### 4. Query before writing when the request refers to existing rows + +```bash +coln-repo query --document <<'JS' +store.scanTable("Records.Folders") +JS +``` + +`query` reads one synchronous JavaScript expression from stdin. `store` exposes +`scanTable(path)`, `rowById(path, rowRef)`, `heads()`, and `jsonIR()`. Rows are +`{ rowId, values }`; a `rowId` is already a tagged `row_id` value and can be +passed straight back as a column value in `txn.add`. `rowById` takes the inner +reference instead: `store.rowById(path, row.rowId.value)`. Project or slice +large tables in the expression rather than returning everything. + +### 5. Write in one transaction + +```bash +coln-repo exec --document <<'JS' +const folder = txn.add("Records.Folders", [{ tag: "string", value: "Inbox" }]) +const document = txn.add("Records.Documents", [ + folder, + { tag: "string", value: "Notes" }, +]) +return { folder, document } +JS +``` + +`exec` reads a synchronous JavaScript function body from stdin. `txn` is the +current transaction API; today it exposes `txn.add(path, values)`, which returns +a complete tagged `row_id` value usable directly in later `add` calls within +the same script. Do not wrap that result in another `row_id` value. Return a +JSON-serializable value: it is included in the response with row references +resolved to their committed form. + +Column values are tagged: + +```js +{ tag: "string", value: "hello" } +{ tag: "int", value: 42 } +{ tag: "row_id", value: rowReference } +``` + +### 6. Read the response before doing anything else + +Success: `ok: true`, `document.headsBefore` and `headsAfter`, `sync.status`, and +`result`. + +Failure: `ok: false` and `error` with `code`, `phase`, `message`, and +`mutationState`: + +- `not_applied`: nothing was committed. Fix the cause before trying again; a + schema or rule rejection will not pass on retry. +- `applied_locally`: the transaction committed but synchronization or result + reporting failed afterwards. Never repeat the transaction automatically; tell + the user what committed and what failed. +- `unknown`: internal failure; report it. + +### 7. Verify + +Confirm a write with a `query`, for example `store.scanTable(path).length`, and +quote the returned row references to the user. + +## Limits + +- Add-only: `txn.add` is the whole transaction API today. Say so rather than + simulating updates or deletes. +- Existing documents only: the CLI cannot create a document. +- Synchronous only: a program returning a promise is rejected. +- Trusted code: programs run with the Node process's full capabilities. + +## Permissions + +`ir`, `help`, and `guide` evaluate no user code and are safe to allow without +approval. `query` and `exec` evaluate arbitrary JavaScript; request approval for +them unless the user has explicitly accepted that allowing them is equivalent to +allowing arbitrary local Node execution. diff --git a/examples/cli-demo/src/cli.ts b/examples/cli-demo/src/cli.ts new file mode 100644 index 00000000..0305b839 --- /dev/null +++ b/examples/cli-demo/src/cli.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { main } from "./cli/main.js" + +ignoreBrokenPipe(process.stdout) +ignoreBrokenPipe(process.stderr) + +let exitCode: number +try { + exitCode = await main(process.argv.slice(2)) +} catch (error) { + console.log(JSON.stringify({ + protocolVersion: 0, + ok: false, + error: { + code: "INTERNAL_ERROR", + phase: "internal", + message: error instanceof Error ? error.message : String(error), + mutationState: "unknown", + }, + }, null, 2)) + exitCode = 1 +} + +await Promise.all([drain(process.stdout), drain(process.stderr)]) +process.exit(exitCode) + +function drain(stream: NodeJS.WritableStream): Promise { + return new Promise(resolve => { + stream.once("error", () => resolve()) + stream.write("", () => resolve()) + }) +} + +// A consumer such as `head` may close a pipe while main is still running. +function ignoreBrokenPipe(stream: NodeJS.WritableStream): void { + stream.on("error", error => { + if ((error as NodeJS.ErrnoException).code !== "EPIPE") throw error + }) +} diff --git a/examples/cli-demo/src/cli/arguments.ts b/examples/cli-demo/src/cli/arguments.ts new file mode 100644 index 00000000..ebff88b5 --- /dev/null +++ b/examples/cli-demo/src/cli/arguments.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +export type DocumentCommand = "ir" | "query" | "exec" +export type Command = DocumentCommand | "guide" | "install-skill" + +export type ParsedArguments = + | { kind: "help"; command?: Command } + | { kind: "guide" } + | { kind: "install-skill"; directory?: string } + | { + kind: "command" + command: DocumentCommand + documentUrl: string + verbose: boolean + endpoint?: string + } + +const documentCommands: DocumentCommand[] = ["ir", "query", "exec"] +const commands: Command[] = [...documentCommands, "guide", "install-skill"] + +export function parseArguments(arguments_: string[]): ParsedArguments { + const positional: string[] = [] + let verbose = false + let help = false + let documentUrl: string | undefined + let endpoint: string | undefined + let directory: string | undefined + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if (argument === "-v" || argument === "--verbose") verbose = true + else if (argument === "-h" || argument === "--help") help = true + else if (argument.startsWith("--document=")) { + documentUrl = argument.slice("--document=".length) + } else if (argument === "--document") { + documentUrl = optionValue(arguments_, ++index, "--document requires an Automerge URL") + } else if (argument.startsWith("--endpoint=")) { + endpoint = argument.slice("--endpoint=".length) + if (endpoint === "") throw new Error("--endpoint requires a WebSocket URL") + } else if (argument === "--endpoint") { + endpoint = optionValue(arguments_, ++index, "--endpoint requires a WebSocket URL") + } else if (argument.startsWith("--dir=")) { + directory = argument.slice("--dir=".length) + if (directory === "") throw new Error("--dir requires a directory path") + } else if (argument === "--dir") { + directory = optionValue(arguments_, ++index, "--dir requires a directory path") + } else if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}`) + else positional.push(argument) + } + + const explicitHelp = help || positional[0] === "help" + if (explicitHelp || arguments_.length === 0) { + const command = positional[0] === "help" ? positional[1] : positional[0] + if (command !== undefined && !isCommand(command)) { + throw new Error(`Unknown command: ${command}`) + } + return { kind: "help", command } + } + + const [command, ...extra] = positional + if (command === undefined) throw new Error("Missing command") + if (!isCommand(command)) throw new Error(`Unknown command: ${command}`) + if (extra.length > 0) throw new Error(`Unexpected argument: ${extra[0]}`) + + if (command === "guide" || command === "install-skill") { + if (documentUrl !== undefined) throw new Error(`--document is not used by ${command}`) + if (endpoint !== undefined) throw new Error(`--endpoint is not used by ${command}`) + if (command === "guide") { + if (directory !== undefined) throw new Error("--dir is not used by guide") + return { kind: "guide" } + } + return { kind: "install-skill", directory } + } + + if (directory !== undefined) throw new Error(`--dir is not used by ${command}`) + if (documentUrl === undefined || documentUrl === "") { + throw new Error("--document requires an Automerge URL") + } + + return { kind: "command", command, documentUrl, verbose, endpoint } +} + +function isCommand(value: string | undefined): value is Command { + return value !== undefined && (commands as string[]).includes(value) +} + +function optionValue(arguments_: string[], index: number, message: string): string { + const value = arguments_[index] + if (value === undefined || value.startsWith("-")) throw new Error(message) + return value +} diff --git a/examples/cli-demo/src/cli/commands/exec.ts b/examples/cli-demo/src/cli/commands/exec.ts new file mode 100644 index 00000000..8ea4bd1a --- /dev/null +++ b/examples/cli-demo/src/cli/commands/exec.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { evaluateExec, snapshotJsonResult } from "../evaluation.js" +import type { ColnHandle } from "@coln-project/repo" + +export function runExec(handle: ColnHandle, source: string): unknown { + let result: unknown = null + + handle.change(transaction => { + result = evaluateExec(transaction, source) + }) + + // Commit resolves pending row references in place; snapshot only afterwards. + return snapshotJsonResult(result) +} diff --git a/examples/cli-demo/src/cli/commands/ir.ts b/examples/cli-demo/src/cli/commands/ir.ts new file mode 100644 index 00000000..5b62109f --- /dev/null +++ b/examples/cli-demo/src/cli/commands/ir.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { ColnHandle } from "@coln-project/repo" + +export function runIr(handle: ColnHandle): unknown { + return JSON.parse(handle.doc().jsonIR()) +} diff --git a/examples/cli-demo/src/cli/commands/query.ts b/examples/cli-demo/src/cli/commands/query.ts new file mode 100644 index 00000000..73481aa3 --- /dev/null +++ b/examples/cli-demo/src/cli/commands/query.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { evaluateQuery } from "../evaluation.js" +import type { ColnHandle } from "@coln-project/repo" + +export function runQuery(handle: ColnHandle, source: string): unknown { + return evaluateQuery(handle.doc(), source) +} diff --git a/examples/cli-demo/src/cli/evaluation.ts b/examples/cli-demo/src/cli/evaluation.ts new file mode 100644 index 00000000..9e7b013e --- /dev/null +++ b/examples/cli-demo/src/cli/evaluation.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { Console } from "node:console" +import type { ColnDocument, ColnTransaction } from "@coln-project/repo" + +/** A program produced a value the JSON protocol cannot carry. */ +export class InvalidResultError extends TypeError { + constructor() { + super("JavaScript result must be JSON-serializable") + this.name = "InvalidResultError" + } +} + +export function evaluateQuery(store: ColnDocument, source: string): unknown { + const evaluate = new Function("store", "console", `"use strict"; return (${source}\n)`) as ( + store: ColnDocument, + console: Console, + ) => unknown + const result = evaluate(store, evaluationConsole) + assertSynchronous(result) + const json = jsonResult(result) + return snapshotJsonResult(json) +} + +export function evaluateExec( + transaction: ColnTransaction, + source: string, +): unknown { + const evaluate = new Function("txn", "console", `"use strict";\n${source}`) as ( + transaction: ColnTransaction, + console: Console, + ) => unknown + const result = evaluate(transaction, evaluationConsole) + assertSynchronous(result) + return jsonResult(result) +} + +// Keep program logging away from the JSON protocol on stdout: every console +// method, including table/dir/group, writes to stderr. +const evaluationConsole = new Console({ stdout: process.stderr, stderr: process.stderr }) + +function assertSynchronous(value: unknown): void { + if ( + (typeof value === "object" || typeof value === "function") + && value !== null + && "then" in value + && typeof value.then === "function" + ) { + throw new TypeError("JavaScript must complete synchronously") + } +} + +function jsonResult(value: unknown): unknown { + if (value === undefined) return null + assertJsonValue(value, new WeakSet()) + return value +} + +export function snapshotJsonResult(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)) +} + +function assertJsonValue(value: unknown, ancestors: WeakSet): void { + if (value === null || typeof value === "string" || typeof value === "boolean") return + if (typeof value === "number" && Number.isFinite(value)) return + if (typeof value !== "object") throw new InvalidResultError() + if (ancestors.has(value)) throw new InvalidResultError() + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== Array.prototype && prototype !== null) { + throw new InvalidResultError() + } + if (Object.getOwnPropertySymbols(value).length > 0) throw new InvalidResultError() + + ancestors.add(value) + for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) { + if (!descriptor.enumerable) continue + if (!("value" in descriptor)) throw new InvalidResultError() + assertJsonValue(descriptor.value, ancestors) + } + ancestors.delete(value) +} diff --git a/examples/cli-demo/src/cli/help.ts b/examples/cli-demo/src/cli/help.ts new file mode 100644 index 00000000..6d38d64e --- /dev/null +++ b/examples/cli-demo/src/cli/help.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { Command } from "./arguments.js" + +const overview = `coln-repo reads and updates synchronized Coln documents. + +A Coln theory describes the entities and rules in a document. The compiler lowers +that theory to JSON IR. Read the IR, confirm inferred domain meanings with the +user, then query or execute a transaction. + +Usage: + coln-repo ir --document [--endpoint ] [-v] + coln-repo query --document [--endpoint ] [-v] + coln-repo exec --document [--endpoint ] [-v] + coln-repo guide + coln-repo install-skill [--dir ] + coln-repo help [command] + +Commands: + ir Return the full compiled JSON IR + query Read a JavaScript expression from stdin and evaluate it + exec Read a JavaScript body from stdin and run one change transaction + guide Print the agent workflow for this tool (the coln-repo skill) + install-skill Install or update the coln-repo skill for agent harnesses + +Options: + --endpoint Sync server WebSocket URL, overriding SUBDUCTION_ENDPOINT + --dir Skills root for install-skill (default ~/.agents/skills) + -v, --verbose Print connection and sync progress to stderr + -h, --help Print command help + +Environment: + SUBDUCTION_ENDPOINT Sync server WebSocket URL, used when --endpoint is absent + +The endpoint defaults to wss://coln.sync.inkandswitch.com. A document held +by a local relay is only reachable by pointing at that relay, for example +--endpoint ws://127.0.0.1:3030. + +Agents: run "coln-repo guide" first. It explains how to read the IR, confirm +inferred meanings with the user, and interpret responses. + +Every result, including an error, is JSON on stdout. JavaScript is trusted code +and runs with this Node process's capabilities.` + +const commandHelp: Record = { + guide: `Usage: coln-repo guide + +Print the coln-repo skill body: the step-by-step workflow an agent should follow +to read a document's IR, confirm its interpretation, query, write, and read the +response. The same text is what install-skill installs.`, + "install-skill": `Usage: coln-repo install-skill [--dir ] + +Copy the coln-repo skill directory to /coln-repo, creating it or +replacing it when the installed copy differs. The default root is +~/.agents/skills, which OpenCode reads. Other harnesses use their own root, for +example --dir ~/.claude/skills for Claude Code. + +The response reports status "installed", "updated", or "unchanged".`, + ir: `Usage: coln-repo ir --document [--endpoint ] [-v] + +Return the document's compiled JSON IR without interpreting its schema.`, + query: `Usage: coln-repo query --document [--endpoint ] [-v] + +Evaluate a synchronous JavaScript expression with a read-only store in scope. +Read the expression from stdin and return its value in the JSON response. + +Store methods: + store.jsonIR() Return the IR JSON string + store.scanTable(path) Return all rows in an IR table + store.rowById(path, rowRef) Return one row, or undefined (rowRef = row.rowId.value) + store.heads() Return current commit hashes + +Example: + printf 'store.scanTable("Records.Documents")' | coln-repo query --document automerge:...`, + exec: `Usage: coln-repo exec --document [--endpoint ] [-v] + +Run a synchronous JavaScript script in one atomic Coln Repo change. The script +is read from stdin and receives the current transaction API as txn. Return a +JSON-serializable value to include it in the response. + +Available operations: + txn.add(path, values) Add a row and return its tagged row reference + +Values are tagged: { tag: "int", value } | { tag: "string", value } | +{ tag: "row_id", value: rowRef }. +txn.add returns a complete tagged row_id value; pass it directly to later adds. + +Example: + coln-repo exec --document automerge:... <<'JS' + const folder = txn.add("Records.Folders", [{ tag: "string", value: "Inbox" }]) + const document = txn.add("Records.Documents", [ + folder, + { tag: "string", value: "Notes" }, + ]) + return { folder, document } +JS + +The change is committed only if the script returns synchronously without error. +The CLI waits for the connected server to flush it before reporting success.`, +} + +export function helpText(command?: Command): string { + return command === undefined ? overview : commandHelp[command] +} diff --git a/examples/cli-demo/src/cli/main.ts b/examples/cli-demo/src/cli/main.ts new file mode 100644 index 00000000..96f22e6d --- /dev/null +++ b/examples/cli-demo/src/cli/main.ts @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { join } from "node:path" +import { parseArguments, type Command } from "./arguments.js" +import { runExec } from "./commands/exec.js" +import { runIr } from "./commands/ir.js" +import { runQuery } from "./commands/query.js" +import { InvalidResultError } from "./evaluation.js" +import { helpText } from "./help.js" +import { defaultEndpoint, flush, openDocument } from "./repository.js" +import { defaultSkillInstallRoot, installSkill, readGuide, skillName } from "./skill.js" + +const protocolVersion = 0 + +export async function main(arguments_: string[]): Promise { + let argumentsResult + try { + argumentsResult = parseArguments(arguments_) + } catch (error) { + writeJson(failure(undefined, "INVALID_ARGUMENTS", "arguments", error, "not_applied")) + return 2 + } + + if (argumentsResult.kind === "help") { + writeJson({ + protocolVersion, + ok: true, + command: "help", + help: helpText(argumentsResult.command), + }) + return 0 + } + + if (argumentsResult.kind === "guide") { + try { + writeJson({ ...success("guide"), guide: readGuide() }) + return 0 + } catch (error) { + writeJson(failure("guide", "GUIDE_FAILED", "read", error, "not_applied")) + return 4 + } + } + + if (argumentsResult.kind === "install-skill") { + const root = argumentsResult.directory ?? defaultSkillInstallRoot + try { + const installed = installSkill(root) + writeJson({ ...success("install-skill"), skill: { name: skillName, ...installed } }) + return 0 + } catch (error) { + writeJson({ + ...failure("install-skill", "SKILL_INSTALL_FAILED", "install", error, "not_applied"), + skill: { name: skillName, target: join(root, skillName) }, + }) + return 4 + } + } + + const { command, documentUrl, verbose } = argumentsResult + const endpoint = argumentsResult.endpoint ?? process.env.SUBDUCTION_ENDPOINT ?? defaultEndpoint + let source: string | undefined + if (command === "query" || command === "exec") { + if (process.stdin.isTTY) { + writeJson(failure(command, "PROGRAM_REQUIRED", "arguments", new Error( + `${command} reads JavaScript from stdin; pipe or redirect a program into it`, + ), "not_applied")) + return 2 + } + source = await readStdin() + if (source.trim() === "") { + writeJson(failure(command, "PROGRAM_REQUIRED", "arguments", new Error( + `${command} requires JavaScript on stdin`, + ), "not_applied")) + return 2 + } + } + + let opened + try { + opened = await openDocument(documentUrl, endpoint, verbose) + } catch (error) { + writeJson({ + ...failure(command, "DOCUMENT_OPEN_FAILED", "open", error, "not_applied"), + document: { url: documentUrl, endpoint }, + }) + return 3 + } + + const { repo, handle } = opened + try { + if (command === "ir") { + let heads: string[] | undefined + try { + heads = handle.doc().heads() + writeJson({ + ...success(command), + document: documentState(documentUrl, endpoint, heads), + ir: runIr(handle), + }) + return 0 + } catch (error) { + writeJson({ + ...failure(command, "IR_FAILED", "read", error, "not_applied"), + document: heads === undefined + ? { url: documentUrl, endpoint } + : documentState(documentUrl, endpoint, heads), + }) + return 4 + } + } + + if (command === "query") { + let heads: string[] | undefined + try { + heads = handle.doc().heads() + const result = runQuery(handle, source!) + writeJson({ + ...success(command), + document: documentState(documentUrl, endpoint, heads), + result, + }) + return 0 + } catch (error) { + writeJson({ + ...failure(command, "QUERY_FAILED", "evaluate", error, "not_applied"), + document: heads === undefined + ? { url: documentUrl, endpoint } + : documentState(documentUrl, endpoint, heads), + }) + return 4 + } + } + + const headsBefore = handle.doc().heads() + let result: unknown + try { + result = runExec(handle, source!) + } catch (error) { + const headsAfter = handle.doc().heads() + // Three failure shapes: the transaction rejected the program (rolled back), + // the program's return value is not JSON (rolled back before commit), or + // snapshotting the value after commit failed (committed locally). + const applied = !sameHeads(headsBefore, headsAfter) + const invalidResult = error instanceof InvalidResultError + writeJson({ + ...failure( + command, + applied ? "RESULT_FAILED" : invalidResult ? "RESULT_INVALID" : "TRANSACTION_FAILED", + applied || invalidResult ? "result" : "transaction", + error, + applied ? "applied_locally" : "not_applied", + ), + document: transactionState(documentUrl, endpoint, headsBefore, headsAfter), + }) + return 5 + } + + const headsAfter = handle.doc().heads() + try { + await flush(repo, endpoint, verbose) + } catch (error) { + writeJson({ + ...failure(command, "SYNC_FAILED", "flush", error, "applied_locally"), + document: transactionState(documentUrl, endpoint, headsBefore, headsAfter), + result, + }) + return 6 + } + + writeJson({ + ...success(command), + document: transactionState(documentUrl, endpoint, headsBefore, headsAfter), + sync: { status: "flushed" }, + result, + }) + return 0 + } finally { + await repo.shutdown().catch(error => console.error(errorMessage(error))) + } +} + +function success(command: Command): object { + return { protocolVersion, ok: true, command } +} + +function failure( + command: Command | undefined, + code: string, + phase: string, + error: unknown, + mutationState: "not_applied" | "applied_locally", +): object { + return { + protocolVersion, + ok: false, + ...(command === undefined ? {} : { command }), + error: { + code, + phase, + message: errorMessage(error), + mutationState, + }, + } +} + +function documentState(url: string, endpoint: string, heads: string[]): object { + return { url, endpoint, heads } +} + +function transactionState( + url: string, + endpoint: string, + headsBefore: string[], + headsAfter: string[], +): object { + return { url, endpoint, headsBefore, headsAfter } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function sameHeads(left: string[], right: string[]): boolean { + return left.length === right.length && left.every(head => right.includes(head)) +} + +function writeJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)) +} + +async function readStdin(): Promise { + process.stdin.setEncoding("utf8") + let source = "" + for await (const chunk of process.stdin) source += chunk + return source +} diff --git a/examples/cli-demo/src/cli/repository.ts b/examples/cli-demo/src/cli/repository.ts new file mode 100644 index 00000000..250778ed --- /dev/null +++ b/examples/cli-demo/src/cli/repository.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { + initSubduction, + isValidAutomergeUrl, + Repo, + setLoggerFactory, + setSubductionLogLevel, + type AutomergeUrl, +} from "@automerge/automerge-repo" +import { find, type ColnHandle } from "@coln-project/repo" + +export const defaultEndpoint = "wss://coln.sync.inkandswitch.com" +export const operationTimeoutMs = 10_000 + +export interface OpenDocument { + repo: Repo + handle: ColnHandle +} + +export async function openDocument( + documentUrl: string, + endpoint: string, + verbose: boolean, +): Promise { + if (!isValidAutomergeUrl(documentUrl)) throw new Error(`Invalid Automerge URL: ${documentUrl}`) + validateEndpoint(endpoint) + if (verbose) console.error(`Connecting to ${endpoint}`) + + await initSubduction() + configureLogging(verbose) + const repo = new Repo({ subductionWebsocketEndpoints: [endpoint] }) + // Repo construction resets this to warn. Subduction tracing writes to stdout, + // so keep it at error; CLI-owned verbose progress uses stderr. + setSubductionLogLevel("error") + try { + await withTimeout(waitForConnection(repo), `Timed out connecting to ${endpoint}`) + if (verbose) console.error(`Connected to ${endpoint}`) + const handle = await withTimeout( + find(repo, documentUrl as AutomergeUrl), + `Timed out loading ${documentUrl}`, + ) + return { repo, handle } + } catch (error) { + await repo.shutdown() + throw error + } +} + +function configureLogging(verbose: boolean): void { + setLoggerFactory(namespace => { + const write = (message: string, ...args: unknown[]) => { + console.error(`[${namespace}]`, message, ...args) + } + const quiet = () => {} + return { + debug: verbose ? write : quiet, + info: verbose ? write : quiet, + warn: verbose ? write : quiet, + error: write, + } + }) +} + +export async function flush(repo: Repo, endpoint: string, verbose: boolean): Promise { + if (!repo.isSubductionConnected()) throw new Error(`Disconnected from ${endpoint} before sync`) + await withTimeout(repo.flush(), `Timed out syncing to ${endpoint}`) + if (verbose) console.error(`Synced to ${endpoint}`) +} + +function waitForConnection(repo: Repo): Promise { + if (repo.isSubductionConnected()) return Promise.resolve() + return new Promise(resolve => { + const connected = ({ connected: isConnected }: { connected: boolean }) => { + if (!isConnected) return + repo.off("subduction-connection", connected) + resolve() + } + repo.on("subduction-connection", connected) + if (repo.isSubductionConnected()) { + repo.off("subduction-connection", connected) + resolve() + } + }) +} + +async function withTimeout(promise: Promise, message: string): Promise { + let timeout: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), operationTimeoutMs) + }), + ]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + +function validateEndpoint(endpoint: string): void { + let url: URL + try { + url = new URL(endpoint) + } catch { + throw new Error(`Invalid Subduction endpoint: ${endpoint}`) + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new Error(`Subduction endpoint must use ws: or wss: ${endpoint}`) + } +} diff --git a/examples/cli-demo/src/cli/skill.ts b/examples/cli-demo/src/cli/skill.ts new file mode 100644 index 00000000..442fd094 --- /dev/null +++ b/examples/cli-demo/src/cli/skill.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs" +import { homedir } from "node:os" +import { join, relative } from "node:path" +import { fileURLToPath } from "node:url" + +export const skillName = "coln-repo" + +/** + * The skill directory ships beside `src/` and `dist/`, so the same relative + * path resolves whether this module runs from source (tsx) or from the build. + */ +export const skillSourceDirectory = fileURLToPath( + new URL(`../../skills/${skillName}/`, import.meta.url), +) + +export const defaultSkillInstallRoot = join(homedir(), ".agents", "skills") + +/** Body of SKILL.md with its YAML frontmatter removed. */ +export function readGuide(): string { + const text = readFileSync(join(skillSourceDirectory, "SKILL.md"), "utf8") + const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(text) + return (match === null ? text : text.slice(match[0].length)).trim() +} + +export type InstallStatus = "installed" | "updated" | "unchanged" + +export interface InstallResult { + status: InstallStatus + source: string + target: string + files: string[] +} + +/** Copy the skill directory to `/`, replacing stale content. */ +export function installSkill(root: string): InstallResult { + const target = join(root, skillName) + const files = listFiles(skillSourceDirectory) + const existed = existsSync(target) + const status: InstallStatus = !existed + ? "installed" + : sameContent(skillSourceDirectory, target, files) ? "unchanged" : "updated" + + if (status !== "unchanged") { + // Replace, don't merge: files dropped from the skill must not linger. + rmSync(target, { recursive: true, force: true }) + mkdirSync(target, { recursive: true }) + cpSync(skillSourceDirectory, target, { recursive: true, force: true }) + } + + return { status, source: skillSourceDirectory, target, files } +} + +function listFiles(directory: string, prefix = directory): string[] { + const files: string[] = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...listFiles(path, prefix)) + else files.push(relative(prefix, path)) + } + return files.sort() +} + +function sameContent(source: string, target: string, files: string[]): boolean { + if (!existsSync(target) || !statSync(target).isDirectory()) return false + const targetFiles = listFiles(target) + if (targetFiles.length !== files.length || targetFiles.some((file, index) => file !== files[index])) { + return false + } + return files.every(file => { + const targetPath = join(target, file) + if (!existsSync(targetPath) || !statSync(targetPath).isFile()) return false + return readFileSync(join(source, file)).equals(readFileSync(targetPath)) + }) +} diff --git a/examples/sync-demo/server.mjs b/examples/cli-demo/tests/server.mjs similarity index 65% rename from examples/sync-demo/server.mjs rename to examples/cli-demo/tests/server.mjs index 6a8de6a2..55bbe053 100644 --- a/examples/sync-demo/server.mjs +++ b/examples/cli-demo/tests/server.mjs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + import { WebSocketServer } from "ws" import { MemorySigner, @@ -5,8 +9,8 @@ import { Subduction, } from "@automerge/automerge-subduction" -class WsTransport { - #ws +class WebSocketTransport { + #socket #queue = [] #waiters = [] #errorWaiters = [] @@ -15,13 +19,13 @@ class WsTransport { #resolveClosed #disconnectCallback = null - constructor(ws) { - this.#ws = ws + constructor(socket) { + this.#socket = socket this.#closedPromise = new Promise(resolve => { this.#resolveClosed = resolve }) - ws.on("message", data => { + socket.on("message", data => { const bytes = toUint8Array(data) const waiter = this.#waiters.shift() if (waiter) { @@ -31,9 +35,8 @@ class WsTransport { this.#queue.push(bytes) } }) - - ws.on("close", () => this.#markClosed(new Error("WebSocket closed"))) - ws.on("error", err => this.#markClosed(err instanceof Error ? err : new Error(String(err)))) + socket.on("close", () => this.#markClosed(new Error("WebSocket closed"))) + socket.on("error", error => this.#markClosed(error)) } onDisconnect(callback) { @@ -43,7 +46,7 @@ class WsTransport { async sendBytes(bytes) { if (this.#closed) throw new Error("WebSocket closed") await new Promise((resolve, reject) => { - this.#ws.send(Buffer.from(bytes), err => (err ? reject(err) : resolve())) + this.#socket.send(Buffer.from(bytes), error => (error ? reject(error) : resolve())) }) } @@ -59,18 +62,18 @@ class WsTransport { async disconnect() { this.#markClosed(new Error("WebSocket closed"), false) - this.#ws.close() + this.#socket.close() } closed() { return this.#closedPromise } - #markClosed(err, notify = true) { + #markClosed(error, notify = true) { if (this.#closed) return this.#closed = true this.#resolveClosed() - for (const reject of this.#errorWaiters) reject(err) + for (const reject of this.#errorWaiters) reject(error) this.#waiters = [] this.#errorWaiters = [] if (notify) this.#disconnectCallback?.() @@ -86,23 +89,24 @@ function toUint8Array(data) { return new Uint8Array(Buffer.from(data)) } -const port = Number(process.env.PORT ?? 3030) -const serviceName = `localhost:${port}` +const port = 3031 +const serviceName = `127.0.0.1:${port}` const subduction = new Subduction({ signer: new MemorySigner(), storage: new MemoryStorage(), }) -const wss = new WebSocketServer({ port }) +const server = new WebSocketServer({ port }) -wss.on("connection", ws => { - subduction.acceptTransport(new WsTransport(ws), serviceName).catch(err => { - if (!String(err?.message ?? err).includes("closed")) console.error(err) +server.on("connection", socket => { + subduction.acceptTransport(new WebSocketTransport(socket), serviceName).catch(error => { + if (!String(error?.message ?? error).includes("closed")) console.error(error) }) }) -process.on("SIGINT", async () => { +async function shutdown() { await subduction.disconnectAll().catch(() => {}) - wss.close(() => process.exit(0)) -}) + server.close(() => process.exit(0)) +} -console.log(`Subduction relay listening on ws://localhost:${port}`) +process.on("SIGINT", shutdown) +process.on("SIGTERM", shutdown) diff --git a/examples/cli-demo/tests/sync.spec.ts b/examples/cli-demo/tests/sync.spec.ts new file mode 100644 index 00000000..9fd3c2da --- /dev/null +++ b/examples/cli-demo/tests/sync.spec.ts @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { expect, test } from "@playwright/test" +import { spawn } from "node:child_process" +import { existsSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { initSubduction, Repo } from "@automerge/automerge-repo" +import { colnDocType, type ColnSchema, type ColnTransaction } from "@coln-project/repo" + +const endpoint = "ws://127.0.0.1:3031" +const peopleTable = "Scheduling.People" +const meetingsTable = "Scheduling.Meetings" +const schema = { + entities: [ + { + path: [["Scheduling"], ["People"]], + value: { + entityVariant: { tag: "table" }, + columns: [{ + path: [["name"]], + type: { tag: "builtin", type: "builtinString" }, + }], + primaryKey: null, + }, + }, + { + path: [["Scheduling"], ["Meetings"]], + value: { + entityVariant: { tag: "table" }, + columns: [ + { + path: [["organizer"]], + type: { tag: "rowId", path: [["Scheduling"], ["People"]] }, + }, + { + path: [["attendee"]], + type: { tag: "rowId", path: [["Scheduling"], ["People"]] }, + }, + ], + primaryKey: null, + }, + }, + ], + rules: [], +} satisfies ColnSchema + +interface CliResult { + exitCode: number | null + stdout: string + stderr: string +} + +function runCli(arguments_: string[], input = ""): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["dist/cli.js", ...arguments_], { + cwd: process.cwd(), + env: process.env, + timeout: 15_000, + }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk }) + child.stderr.setEncoding("utf8").on("data", chunk => { stderr += chunk }) + child.on("error", reject) + child.on("close", exitCode => resolve({ exitCode, stdout, stderr })) + child.stdin.end(input) + }) +} + +function documentArguments(command: string, documentUrl: string, verbose = false): string[] { + return [ + command, + "--document", + documentUrl, + "--endpoint", + endpoint, + ...(verbose ? ["--verbose"] : []), + ] +} + +test("CLI provides help, guide, and skill installation", async () => { + const help = await runCli(["help", "exec"]) + expect(help.exitCode).toBe(0) + expect(JSON.parse(help.stdout)).toMatchObject({ + protocolVersion: 0, + ok: true, + command: "help", + help: expect.any(String), + }) + + const guide = await runCli(["guide"]) + expect(guide.exitCode).toBe(0) + expect(JSON.parse(guide.stdout)).toMatchObject({ + protocolVersion: 0, + ok: true, + command: "guide", + guide: expect.any(String), + }) + + const root = mkdtempSync(join(tmpdir(), "coln-skills-")) + try { + const installed = await runCli(["install-skill", "--dir", root]) + expect(installed.exitCode).toBe(0) + expect(JSON.parse(installed.stdout)).toMatchObject({ + protocolVersion: 0, + ok: true, + command: "install-skill", + skill: { + name: "coln-repo", + status: "installed", + target: join(root, "coln-repo"), + files: expect.arrayContaining(["SKILL.md"]), + }, + }) + expect(existsSync(join(root, "coln-repo", "SKILL.md"))).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("CLI reads a store's IR and rows", async () => { + await initSubduction() + const creator = new Repo({ subductionWebsocketEndpoints: [endpoint] }) + + try { + const handle = creator.create(schema, colnDocType) + handle.change((transaction: ColnTransaction) => { + transaction.add(peopleTable, [{ tag: "string", value: "Ada" }]) + }) + await creator.flush() + + const ir = await runCli(documentArguments("ir", handle.url)) + expect(ir.exitCode).toBe(0) + expect(ir.stderr).toBe("") + const irResponse = JSON.parse(ir.stdout) + expect(irResponse).toMatchObject({ + protocolVersion: 0, + ok: true, + command: "ir", + document: { url: handle.url, endpoint, heads: expect.any(Array) }, + ir: schema, + }) + expect(irResponse.document.heads.length).toBeGreaterThan(0) + + const query = await runCli( + documentArguments("query", handle.url), + `store.scanTable("${peopleTable}").map(row => row.values[0].value)`, + ) + expect(query.exitCode).toBe(0) + const queryResponse = JSON.parse(query.stdout) + expect(queryResponse).toMatchObject({ + protocolVersion: 0, + ok: true, + command: "query", + document: { url: handle.url, endpoint, heads: expect.any(Array) }, + result: ["Ada"], + }) + expect(queryResponse.document.heads.length).toBeGreaterThan(0) + } finally { + await creator.shutdown() + } +}) + +test("CLI commits dependent rows in one transaction", async () => { + await initSubduction() + const creator = new Repo({ subductionWebsocketEndpoints: [endpoint] }) + const verifier = new Repo({ subductionWebsocketEndpoints: [endpoint] }) + + try { + const handle = creator.create(schema, colnDocType) + await creator.flush() + + const result = await runCli(documentArguments("exec", handle.url, true), ` + const organizer = txn.add("${peopleTable}", [{ tag: "string", value: "Ada" }]) + const attendee = txn.add("${peopleTable}", [{ tag: "string", value: "Lin" }]) + const meeting = txn.add("${meetingsTable}", [organizer, attendee]) + console.log("scheduled") + return { organizer, attendee, meeting } + `) + expect(result.exitCode).toBe(0) + expect(result.stderr).toContain("scheduled") + const response = JSON.parse(result.stdout) + expect(response).toMatchObject({ + protocolVersion: 0, + ok: true, + command: "exec", + document: { + url: handle.url, + endpoint, + headsBefore: expect.any(Array), + headsAfter: expect.any(Array), + }, + sync: { status: "flushed" }, + }) + expect(response.document.headsBefore.length).toBeGreaterThan(0) + expect(response.document.headsAfter.length).toBeGreaterThan(0) + expect(response.document.headsAfter).not.toEqual(response.document.headsBefore) + expect(response.result.organizer.value.existing.commit).toBe(response.document.headsAfter[0]) + expect(response.result.attendee.value.existing.commit).toBe(response.document.headsAfter[0]) + expect(response.result.meeting.value.existing.commit).toBe(response.document.headsAfter[0]) + + const verified = await verifier.find(handle.url, colnDocType) + await expect.poll(() => verified.doc().scanTable(meetingsTable).length).toBe(1) + expect(verified.doc().scanTable(peopleTable)).toHaveLength(2) + expect(verified.doc().scanTable(meetingsTable)[0].values).toEqual([ + response.result.organizer, + response.result.attendee, + ]) + } finally { + await Promise.all([creator.shutdown(), verifier.shutdown()]) + } +}) + +test("CLI reports a failed transaction without changing the store", async () => { + await initSubduction() + const creator = new Repo({ subductionWebsocketEndpoints: [endpoint] }) + + try { + const handle = creator.create(schema, colnDocType) + await creator.flush() + + const result = await runCli( + documentArguments("exec", handle.url), + 'txn.add("Scheduling.Missing", [])', + ) + expect(result.exitCode).not.toBe(0) + const response = JSON.parse(result.stdout) + expect(response).toMatchObject({ + protocolVersion: 0, + ok: false, + command: "exec", + document: { + url: handle.url, + endpoint, + headsBefore: expect.any(Array), + headsAfter: expect.any(Array), + }, + error: { + code: "TRANSACTION_FAILED", + message: expect.any(String), + mutationState: "not_applied", + }, + }) + expect(response.error.message.length).toBeGreaterThan(0) + expect(response.document.headsAfter).toEqual(response.document.headsBefore) + + const query = await runCli( + documentArguments("query", handle.url), + `store.scanTable("${peopleTable}").length`, + ) + expect(query.exitCode).toBe(0) + expect(JSON.parse(query.stdout)).toMatchObject({ + protocolVersion: 0, + ok: true, + command: "query", + document: { url: handle.url, endpoint, heads: response.document.headsBefore }, + result: 0, + }) + } finally { + await creator.shutdown() + } +}) diff --git a/examples/cli-demo/tsconfig.build.json b/examples/cli-demo/tsconfig.build.json new file mode 100644 index 00000000..796337f7 --- /dev/null +++ b/examples/cli-demo/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/cli-demo/tsconfig.json b/examples/cli-demo/tsconfig.json new file mode 100644 index 00000000..2dbc108f --- /dev/null +++ b/examples/cli-demo/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "playwright.config.ts"] +} diff --git a/examples/compiler-demo/index.html b/examples/compiler-demo/index.html deleted file mode 100644 index 12fc5b72..00000000 --- a/examples/compiler-demo/index.html +++ /dev/null @@ -1,115 +0,0 @@ - - - - - Coln - - - -
-
-

Coln

- -
-

Source

- -

Diagnostics

-
-

IR

-
-

JSON

-

-    
- - - - diff --git a/examples/index.html b/examples/index.html deleted file mode 100644 index 9dee93e3..00000000 --- a/examples/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Coln - - - -
-

Coln demos

- -
- - diff --git a/examples/justfile b/examples/justfile index d4274e0f..343c0121 100644 --- a/examples/justfile +++ b/examples/justfile @@ -1,16 +1,21 @@ mod coln-compiler-wasm '../packages/coln-compiler-wasm' build_dir := "../_build/web/compiler" +lab_app_dir := "lab" -build-web-compiler: coln-compiler-wasm::build +stage-web-lab: #!/usr/bin/env bash set -euxo pipefail - cp compiler-demo/index.html {{ build_dir }}/index.html - cp style.css {{ build_dir }}/style.css - examples_dir="{{ build_dir }}/examples" - mkdir -p "$examples_dir" - cp ../packages/coln-compiler/test/golden/*.coln "$examples_dir/" - ls -1 "$examples_dir"/*.coln | xargs -n1 basename | jq -nR '[inputs]' > "$examples_dir/index.json" + if [[ ! -f "{{ build_dir }}/dist/coln.wasm" || ! -f "{{ build_dir }}/dist/ghc_wasm_jsffi.js" || ! -f "{{ build_dir }}/dist/loadHaskellWasm.js" ]]; then + just coln-compiler-wasm::build + fi + mkdir -p "{{ lab_app_dir }}/public/dist" + cp "{{ build_dir }}/dist/coln.wasm" "{{ lab_app_dir }}/public/dist/coln.wasm" + cp "{{ build_dir }}/dist/ghc_wasm_jsffi.js" "{{ lab_app_dir }}/public/dist/ghc_wasm_jsffi.js" + cp "{{ build_dir }}/dist/loadHaskellWasm.js" "{{ lab_app_dir }}/public/dist/loadHaskellWasm.js" -serve-web-compiler port="8000": build-web-compiler - simple-http-server --nocache --index --open --port {{ port }} {{ build_dir }} +build-web-lab: stage-web-lab + pnpm --dir {{ lab_app_dir }} build + +dev-web-lab: stage-web-lab + pnpm --dir {{ lab_app_dir }} dev diff --git a/examples/lab/.env.example b/examples/lab/.env.example new file mode 100644 index 00000000..1c65153c --- /dev/null +++ b/examples/lab/.env.example @@ -0,0 +1,2 @@ +# Browser-visible configuration for Coln Lab. +VITE_SUBDUCTION_ENDPOINT=ws://127.0.0.1:3030 diff --git a/examples/lab/.gitignore b/examples/lab/.gitignore new file mode 100644 index 00000000..4611f692 --- /dev/null +++ b/examples/lab/.gitignore @@ -0,0 +1,4 @@ +node_modules +public/dist +test-results +playwright-report diff --git a/examples/lab/README.md b/examples/lab/README.md new file mode 100644 index 00000000..833ba0a2 --- /dev/null +++ b/examples/lab/README.md @@ -0,0 +1,27 @@ +# Coln Lab + +One local workbench for Coln theories, stores, and synchronized graphs. + +From the repository root: + +```console +just examples/dev-web-lab +``` + +The command stages the browser compiler and starts or reuses Lab's local +Subduction relay. Build-time browser configuration can be placed in +`examples/lab/.env`; exported shell variables take precedence. See +`examples/lab/.env.example` for available settings. + +The Store tool executes trusted JavaScript on the browser main thread. Do not +run programs from untrusted sources. + +Lab owns its complete browser implementation, relay scripts, and graph schema. +Regenerate the checked-in graph bindings after changing `src/tools/graph/graph.coln`: + +```console +pnpm --dir examples/lab graph:generate +``` + +Run unit and browser tests with `pnpm --dir examples/lab test` and +`pnpm --dir examples/lab test:e2e`. diff --git a/examples/lab/index.html b/examples/lab/index.html new file mode 100644 index 00000000..f12fd1eb --- /dev/null +++ b/examples/lab/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Coln Lab + + +
+ + + diff --git a/examples/lab/package.json b/examples/lab/package.json new file mode 100644 index 00000000..ad699fcf --- /dev/null +++ b/examples/lab/package.json @@ -0,0 +1,44 @@ +{ + "name": "coln-lab", + "private": true, + "license": "(Apache-2.0 OR MIT)", + "type": "module", + "scripts": { + "dev": "node scripts/with-server.mjs vite --host 127.0.0.1", + "server": "node scripts/server.mjs", + "graph:generate": "bash src/tools/graph/compile.sh", + "check": "svelte-check --tsconfig ./tsconfig.json && tsc --noEmit", + "build": "pnpm run check && vite build", + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:dev": "PLAYWRIGHT_DEV=1 playwright test" + }, + "dependencies": { + "@automerge/automerge-codemirror": "0.2.0", + "@automerge/automerge-repo": "github:automerge/automerge-repo#doctypes&path:packages/automerge-repo", + "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.47", + "@automerge/automerge-subduction": "0.16.1", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/state": "^6.7.2", + "@codemirror/view": "^6.43.11", + "@coln-project/repo": "link:../../packages/coln-repo", + "@coln-project/runtime": "link:../../packages/coln-js-runtime", + "codemirror": "^6.0.2", + "json-formatter-js": "^2.5.23", + "svelte": "^5.57.0", + "ws": "^8.21.0" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@sveltejs/vite-plugin-svelte": "^7.3.0", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.4.1", + "@vitest/browser-playwright": "^4.1.11", + "svelte-check": "^4.7.6", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + "vite": "^8.2.2", + "vite-plugin-wasm": "^3.6.0", + "vitest": "^4.1.11" + } +} diff --git a/examples/lab/playwright.config.ts b/examples/lab/playwright.config.ts new file mode 100644 index 00000000..e112c911 --- /dev/null +++ b/examples/lab/playwright.config.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + timeout: 360_000, + expect: { timeout: 60_000 }, + fullyParallel: false, + retries: 0, + use: { + baseURL: "http://127.0.0.1:5180", + trace: "retain-on-failure", + }, + webServer: [ + { + command: + "SUBDUCTION_SERVICE_NAME=127.0.0.1:3033 PORT=3033 node scripts/server.mjs", + port: 3033, + reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", + }, + { + command: process.env.PLAYWRIGHT_DEV + ? "VITE_SUBDUCTION_ENDPOINT=ws://127.0.0.1:3033 ./node_modules/.bin/vite --host 127.0.0.1 --port 5180 --force" + : "VITE_SUBDUCTION_ENDPOINT=ws://127.0.0.1:3033 ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --port 5180", + url: "http://127.0.0.1:5180", + timeout: 300_000, + reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", + }, + ], + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}) diff --git a/examples/lab/pnpm-lock.yaml b/examples/lab/pnpm-lock.yaml new file mode 100644 index 00000000..f788186c --- /dev/null +++ b/examples/lab/pnpm-lock.yaml @@ -0,0 +1,1850 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + +importers: + + .: + dependencies: + '@automerge/automerge-codemirror': + specifier: 0.2.0 + version: 0.2.0 + '@automerge/automerge-repo': + specifier: github:automerge/automerge-repo#doctypes&path:packages/automerge-repo + version: https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo + '@automerge/automerge-repo-storage-indexeddb': + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 + '@automerge/automerge-subduction': + specifier: 0.16.1 + version: 0.16.1 + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/state': + specifier: ^6.7.2 + version: 6.7.2 + '@codemirror/view': + specifier: ^6.43.11 + version: 6.43.11 + '@coln-project/repo': + specifier: link:../../packages/coln-repo + version: link:../../packages/coln-repo + '@coln-project/runtime': + specifier: link:../../packages/coln-js-runtime + version: link:../../packages/coln-js-runtime + codemirror: + specifier: ^6.0.2 + version: 6.0.2 + json-formatter-js: + specifier: ^2.5.23 + version: 2.5.23 + svelte: + specifier: ^5.57.0 + version: 5.57.0 + ws: + specifier: ^8.21.0 + version: 8.21.3 + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@sveltejs/vite-plugin-svelte': + specifier: ^7.3.0 + version: 7.3.0(svelte@5.57.0)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + '@types/node': + specifier: ^26.4.1 + version: 26.4.1 + '@vitest/browser-playwright': + specifier: ^4.1.11 + version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))(vitest@4.1.11) + svelte-check: + specifier: ^4.7.6 + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.0)(typescript@6.0.3) + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) + vite-plugin-wasm: + specifier: ^3.6.0 + version: 3.6.0(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + +packages: + + '@automerge/automerge-codemirror@0.2.0': + resolution: {integrity: sha512-GBNh4ESZHFNHZKLznf/2G2KdmBD+unEdk64+l621LsjIsJs6HBmQmO8RCY1v7vIq31kFyoVHNcBMGQOnLgy4GA==} + + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.47': + resolution: {integrity: sha512-fYn6nse7jQnVb9EploTsBuYUzW7xFin2HZLneSEQ7w4pRCilIU92ghZEjZSWRKEPunSN0MYEvfJJJdlLyYTdkQ==} + engines: {node: '>=22.13'} + + '@automerge/automerge-repo@2.6.0-subduction.47': + resolution: {integrity: sha512-NGBQUjGH67Kyrc8a6zoafvDKT1+pnFi2/yiMgULS7l+apWQT2QkgQZ3vQGms/ngdrmM6ye1fhcwHwBn/DGJ6tA==} + engines: {node: '>=22.13'} + + '@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo': + resolution: {gitHosted: true, path: packages/automerge-repo, tarball: https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5} + version: 2.6.0-subduction.47 + engines: {node: '>=22.13'} + + '@automerge/automerge-subduction@0.16.1': + resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} + + '@automerge/automerge@3.3.2': + resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} + + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.11.0': + resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.2': + resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} + + '@codemirror/state@6.7.2': + resolution: {integrity: sha512-U3RiPX62Wl/Gx4ftQ7UxLlloSfsFTQqKa+7vFBYteZGCzkp6oBqcsD7iSwniWRGobCAmDcwZSY+Six3+3ztdfg==} + + '@codemirror/view@6.43.11': + resolution: {integrity: sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte@7.3.0': + resolution: {integrity: sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.4.1': + resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} + + '@vitest/browser-playwright@4.1.11': + resolution: {integrity: sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==} + peerDependencies: + playwright: '*' + vitest: 4.1.11 + + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} + peerDependencies: + vitest: 4.1.11 + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.9.2: + resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.3.6: + resolution: {integrity: sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + json-formatter-js@2.5.23: + resolution: {integrity: sha512-Cbm8wHXjo/C56aCePP1VuKvjxoMEmL7g7Ckss1oWFFlCsvOEEbye1kTeaNNaqba1Cl6YpIOYAnK65pUQ8mDIUQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte@5.57.0: + resolution: {integrity: sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==} + engines: {node: '>=18'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + vite-plugin-wasm@3.6.0: + resolution: {integrity: sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.32.6: + resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} + + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + +snapshots: + + '@automerge/automerge-codemirror@0.2.0': + dependencies: + '@automerge/automerge': 3.3.2 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + codemirror: 6.0.2 + + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.47': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.47 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.47': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.16.1': {} + + '@automerge/automerge@3.3.2': {} + + '@blazediff/core@1.9.1': {} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.11.0': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + '@lezer/common': 1.5.2 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + crelt: 1.0.7 + + '@codemirror/search@6.7.2': + dependencies: + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + crelt: 1.0.7 + + '@codemirror/state@6.7.2': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/view@6.43.11': + dependencies: + '@codemirror/state': 6.7.2 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.4': {} + + '@noble/hashes@1.8.0': {} + + '@oxc-project/types@0.148.0': {} + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.57.0)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.2.3 + obug: 2.1.4 + svelte: 5.57.0 + vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) + vitefu: 1.1.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.4.1': + dependencies: + undici-types: 8.3.0 + + '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))(vitest@4.1.11)': + dependencies: + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))(vitest@4.1.11)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + acorn@8.18.0: {} + + aria-query@5.3.1: {} + + assertion-error@2.0.1: {} + + axobject-query@4.1.0: {} + + base-x@5.0.1: {} + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.6: + optionalDependencies: + cbor-extract: 2.2.2 + + chai@6.2.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.11.0 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.2 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 + + convert-source-map@2.0.0: {} + + crelt@1.0.7: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + detect-libc@2.1.2: {} + + devalue@5.9.2: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-module-lexer@2.3.2: {} + + esm-env@1.2.2: {} + + esrap@2.3.6: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + isomorphic-ws@5.0.0(ws@8.21.3): + dependencies: + ws: 8.21.3 + + jiti@2.7.0: {} + + json-formatter-js@2.5.23: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-character@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + obug@2.1.4: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + style-mod@4.1.3: {} + + svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.0)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.57.0 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte@5.57.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@types/estree': 1.0.9 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.2 + esm-env: 1.2.2 + esrap: 2.3.6 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.5 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + totalist@3.0.1: {} + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + uuid@14.0.2: {} + + vite-plugin-wasm@3.6.0(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)): + dependencies: + vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) + + vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.4.1 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitefu@1.1.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)): + optionalDependencies: + vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) + + vitest@4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.4.1 + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))(vitest@4.1.11) + transitivePeerDependencies: + - msw + + w3c-keyname@2.2.8: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.3: {} + + xstate@5.32.6: {} + + zimmerframe@1.1.5: {} diff --git a/examples/lab/pnpm-workspace.yaml b/examples/lab/pnpm-workspace.yaml new file mode 100644 index 00000000..b82bd8d9 --- /dev/null +++ b/examples/lab/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +allowBuilds: + cbor-extract: false + esbuild: true + "@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo": true +overrides: + "@automerge/automerge": 3.3.2 + "@automerge/automerge-subduction": 0.16.1 diff --git a/examples/lab/scripts/server.mjs b/examples/lab/scripts/server.mjs new file mode 100644 index 00000000..77ed6890 --- /dev/null +++ b/examples/lab/scripts/server.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { WebSocketServer } from "ws" +import { + MemorySigner, + MemoryStorage, + Subduction, +} from "@automerge/automerge-subduction" + +class WsTransport { + #ws + #queue = [] + #waiters = [] + #errorWaiters = [] + #closed = false + #closedPromise + #resolveClosed + #disconnectCallback = null + + constructor(ws) { + this.#ws = ws + this.#closedPromise = new Promise(resolve => { + this.#resolveClosed = resolve + }) + + ws.on("message", data => { + const bytes = toUint8Array(data) + const waiter = this.#waiters.shift() + if (waiter) { + this.#errorWaiters.shift() + waiter(bytes) + } else { + this.#queue.push(bytes) + } + }) + + ws.on("close", () => this.#markClosed(new Error("WebSocket closed"))) + ws.on("error", err => this.#markClosed(err instanceof Error ? err : new Error(String(err)))) + } + + onDisconnect(callback) { + this.#disconnectCallback = callback + } + + async sendBytes(bytes) { + if (this.#closed) throw new Error("WebSocket closed") + await new Promise((resolve, reject) => { + this.#ws.send(Buffer.from(bytes), err => (err ? reject(err) : resolve())) + }) + } + + recvBytes() { + const queued = this.#queue.shift() + if (queued) return Promise.resolve(queued) + if (this.#closed) return Promise.reject(new Error("WebSocket closed")) + return new Promise((resolve, reject) => { + this.#waiters.push(resolve) + this.#errorWaiters.push(reject) + }) + } + + async disconnect() { + this.#markClosed(new Error("WebSocket closed"), false) + this.#ws.close() + } + + closed() { + return this.#closedPromise + } + + #markClosed(err, notify = true) { + if (this.#closed) return + this.#closed = true + this.#resolveClosed() + for (const reject of this.#errorWaiters) reject(err) + this.#waiters = [] + this.#errorWaiters = [] + if (notify) this.#disconnectCallback?.() + } +} + +function toUint8Array(data) { + if (data instanceof Uint8Array) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + } + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (Array.isArray(data)) return toUint8Array(Buffer.concat(data)) + return new Uint8Array(Buffer.from(data)) +} + +const port = Number(process.env.PORT ?? 3030) +const host = "127.0.0.1" +const serviceName = process.env.SUBDUCTION_SERVICE_NAME ?? `${host}:${port}` +const subduction = new Subduction({ + signer: new MemorySigner(), + storage: new MemoryStorage(), +}) +const wss = new WebSocketServer({ host, port }) +const leases = new Set() +const clients = new Set() +const managed = process.env.COLN_SYNC_MANAGED === "1" +let shutdownTimer +let shuttingDown = false + +wss.on("connection", (ws, request) => { + if (shuttingDown) { + ws.close() + return + } + if (request.url === "/.coln-sync/lease") { + clearTimeout(shutdownTimer) + leases.add(ws) + ws.send(JSON.stringify({ protocol: "coln-sync-relay", pid: process.pid })) + ws.once("close", () => { + leases.delete(ws) + scheduleShutdown() + }) + return + } + + const transport = new WsTransport(ws) + clients.add(ws) + ws.once("close", () => clients.delete(ws)) + subduction.acceptTransport(transport, serviceName).catch(err => { + if (!String(err?.message ?? err).includes("closed")) console.error(err) + }) +}) + +function scheduleShutdown() { + if (!managed || leases.size > 0 || shuttingDown) return + clearTimeout(shutdownTimer) + shutdownTimer = setTimeout(shutdown, 500) +} + +async function shutdown() { + if (shuttingDown) return + shuttingDown = true + clearTimeout(shutdownTimer) + const closed = new Promise(resolve => wss.close(resolve)) + for (const lease of leases) lease.close() + for (const client of clients) client.close() + await subduction.disconnectAll().catch(() => {}) + await closed + process.exit(0) +} + +process.on("SIGINT", shutdown) +process.on("SIGTERM", shutdown) + +wss.on("listening", () => { + console.log(`Subduction relay listening on ws://${host}:${port}`) + if (managed) shutdownTimer = setTimeout(shutdown, 10_000) +}) + +wss.on("error", error => { + if (error.code !== "EADDRINUSE") console.error(error) + process.exit(1) +}) diff --git a/examples/lab/scripts/with-server.mjs b/examples/lab/scripts/with-server.mjs new file mode 100644 index 00000000..d149aa4a --- /dev/null +++ b/examples/lab/scripts/with-server.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" +import WebSocket from "ws" + +const [, , command, ...args] = process.argv + +if (!command) { + console.error("Usage: coln-with-sync-server [args...]") + process.exit(1) +} + +const host = "127.0.0.1" +const port = Number(process.env.SUBDUCTION_PORT ?? 3030) +const endpoint = `ws://${host}:${port}` +const leaseUrl = `${endpoint}/.coln-sync/lease` +const serverPath = fileURLToPath(new URL("server.mjs", import.meta.url)) + +let lease = await acquireLease() +if (!lease) { + const server = spawn(process.execPath, [serverPath], { + detached: true, + env: { + ...process.env, + COLN_SYNC_MANAGED: "1", + PORT: String(port), + SUBDUCTION_SERVICE_NAME: `${host}:${port}`, + }, + stdio: "ignore", + }) + server.unref() + lease = await waitForLease() +} + +if (!lease) { + console.error(`Could not start the local Subduction relay at ${endpoint}`) + process.exit(1) +} + +const child = spawn(command, args, { + env: { + ...process.env, + SUBDUCTION_ENDPOINT: endpoint, + VITE_SUBDUCTION_ENDPOINT: endpoint, + }, + stdio: "inherit", +}) + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => child.kill(signal)) +} + +const exitCode = await new Promise(resolve => { + child.once("error", error => { + console.error(error.message) + resolve(1) + }) + child.once("exit", (code, signal) => { + resolve(code ?? (signal === "SIGINT" ? 130 : 143)) + }) +}) + +lease.close() +await Promise.race([ + new Promise(resolve => lease.once("close", resolve)), + new Promise(resolve => setTimeout(resolve, 250)), +]) +process.exitCode = exitCode + +async function waitForLease() { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const lease = await acquireLease() + if (lease) return lease + await new Promise(resolve => setTimeout(resolve, 50)) + } + return null +} + +function acquireLease() { + return new Promise(resolve => { + const socket = new WebSocket(leaseUrl) + const timeout = setTimeout(() => finish(null), 500) + let settled = false + + const finish = result => { + if (settled) return + settled = true + clearTimeout(timeout) + if (!result) socket.close() + resolve(result) + } + + socket.once("message", data => { + try { + const message = JSON.parse(String(data)) + finish(message.protocol === "coln-sync-relay" ? socket : null) + } catch { + finish(null) + } + }) + socket.once("error", () => finish(null)) + socket.once("close", () => finish(null)) + }) +} diff --git a/examples/lab/src/app/Home.svelte b/examples/lab/src/app/Home.svelte new file mode 100644 index 00000000..025d2b4c --- /dev/null +++ b/examples/lab/src/app/Home.svelte @@ -0,0 +1,48 @@ + + + + + +
+
+
+
+

Ledger 000 / Counting room

+

A workbench for things that follow.

+

Coln takes its name from the pebble: a small, durable mark for counting. Use the ledger below to define theories, inspect stores, and test relations.

+
+ +
+ + +
+
diff --git a/examples/lab/src/app/LabApp.svelte b/examples/lab/src/app/LabApp.svelte new file mode 100644 index 00000000..77ac7356 --- /dev/null +++ b/examples/lab/src/app/LabApp.svelte @@ -0,0 +1,149 @@ + + + + + +Coln Lab + +
+
+ router.follow(event, "home")}> + + + + +
+ +
+ {#if route.tool === "home"} + + {:else if route.tool === "compiler"} + {#await loadCompilerTool()} +
OPENING THEORY INSTRUMENT
+ {:then CompilerTool} + {#key `compiler:${route.documentUrl}`} + + {/key} + {/await} + {:else if route.tool === "editor"} + {#await loadEditorTool()} +
OPENING STORE INSTRUMENT
+ {:then EditorTool} + {#key `editor:${route.documentUrl}`} + + {/key} + {/await} + {:else if route.tool === "sync"} + {#await loadGraphTool()} +
OPENING GRAPH INSTRUMENT
+ {:then GraphTool} + {#key `sync:${route.documentUrl}`} + + {/key} + {/await} + {:else} +

ENTRY NOT FOUND

No instrument at this address.

router.follow(event, "home")}>Return to the ledger
+ {/if} +
+
diff --git a/examples/lab/src/lib/document-sync.svelte.ts b/examples/lab/src/lib/document-sync.svelte.ts new file mode 100644 index 00000000..0c27d452 --- /dev/null +++ b/examples/lab/src/lib/document-sync.svelte.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { DocumentId, Repo } from "@automerge/automerge-repo" +import { createSubscriber } from "svelte/reactivity" + +type FlushableHandle = { + documentId: DocumentId + on(event: "change", listener: () => void): void + off(event: "change", listener: () => void): void +} + +export type SyncStatus = "offline" | "syncing" | "synced" | "error" + +export class DocumentSync { + readonly #subscribe: () => void + #status: SyncStatus + #error: unknown + + constructor( + private readonly repo: Repo, + private readonly handle: FlushableHandle, + ) { + this.#status = repo.isSubductionConnected() ? "syncing" : "offline" + this.#subscribe = createSubscriber((update) => { + let active = true + let generation = 0 + let currentAttempt: symbol | undefined + + const setStatus = (status: SyncStatus, error?: unknown) => { + if (this.#status === status && this.#error === error) return + this.#status = status + this.#error = error + update() + } + const finishFlush = ( + attempt: symbol, + flushGeneration: number, + failed: boolean, + error?: unknown, + ) => { + if (!active || currentAttempt !== attempt) return + currentAttempt = undefined + if (!this.repo.isSubductionConnected()) setStatus("offline") + else if (flushGeneration !== generation) startFlush() + else if (failed) setStatus("error", error) + else setStatus("synced") + } + const startFlush = () => { + if (currentAttempt || !active || !this.repo.isSubductionConnected()) + return + const attempt = Symbol() + const flushGeneration = generation + currentAttempt = attempt + void this.repo.flush([this.handle.documentId]).then( + () => finishFlush(attempt, flushGeneration, false), + (error) => finishFlush(attempt, flushGeneration, true, error), + ) + } + const requestFlush = () => { + generation += 1 + if (!this.repo.isSubductionConnected()) { + currentAttempt = undefined + setStatus("offline") + return + } + setStatus("syncing") + startFlush() + } + + this.handle.on("change", requestFlush) + this.repo.on("subduction-connection", requestFlush) + requestFlush() + return () => { + active = false + generation += 1 + currentAttempt = undefined + this.handle.off("change", requestFlush) + this.repo.off("subduction-connection", requestFlush) + } + }) + } + + get status(): SyncStatus { + this.#subscribe() + return this.#status + } + + get error(): unknown { + this.#subscribe() + return this.#error + } +} diff --git a/examples/lab/src/lib/router.svelte.ts b/examples/lab/src/lib/router.svelte.ts new file mode 100644 index 00000000..26b33a67 --- /dev/null +++ b/examples/lab/src/lib/router.svelte.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { createSubscriber } from "svelte/reactivity" + +export type Tool = "home" | "compiler" | "editor" | "sync" | "not-found" + +export interface Route { + tool: Tool + documentUrl: string + theoryUrl: string + pathname: string +} + +const paths: Record, string> = { + home: "", + compiler: "compiler/", + editor: "editor/", + sync: "sync/", +} + +export class Router { + readonly #subscribe: () => void + + constructor() { + this.#subscribe = createSubscriber((update) => { + addEventListener("popstate", update) + addEventListener("hashchange", update) + return () => { + removeEventListener("popstate", update) + removeEventListener("hashchange", update) + } + }) + } + + get current(): Route { + this.#subscribe() + return routeFromUrl(new URL(location.href), import.meta.env.BASE_URL) + } + + href( + tool: Exclude, + documentUrl = "", + theoryUrl = "", + ): string { + const url = new URL(paths[tool], appBaseUrl()) + if (theoryUrl) url.searchParams.set("theory", theoryUrl) + url.hash = documentUrl + return `${url.pathname}${url.search}${url.hash}` + } + + navigate( + tool: Exclude, + documentUrl = "", + theoryUrl = "", + ): void { + history.pushState(null, "", this.href(tool, documentUrl, theoryUrl)) + dispatchEvent(new PopStateEvent("popstate")) + } + + follow( + event: MouseEvent, + tool: Exclude, + documentUrl = "", + theoryUrl = "", + ): void { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return + event.preventDefault() + this.navigate(tool, documentUrl, theoryUrl) + } + + replace( + tool: Exclude, + documentUrl = "", + theoryUrl = "", + ): void { + history.replaceState(null, "", this.href(tool, documentUrl, theoryUrl)) + dispatchEvent(new PopStateEvent("popstate")) + } +} + +export function readRoute(): Route { + return routeFromUrl(new URL(location.href), import.meta.env.BASE_URL) +} + +export function routeFromUrl(url: URL, basePath = "/"): Route { + const base = new URL(basePath, url.origin).pathname + const relativePath = url.pathname.startsWith(base) + ? url.pathname.slice(base.length) + : url.pathname.slice(1) + const segments = relativePath + .replace(/^\/+|\/+$/g, "") + .split("/") + .filter(Boolean) + const segment = segments[0] ?? "" + const tool: Tool = + segments.length > 1 + ? "not-found" + : segment === "" + ? "home" + : segment === "compiler" || segment === "editor" || segment === "sync" + ? segment + : "not-found" + const theoryUrl = url.searchParams.get("theory") ?? "" + return { + tool, + documentUrl: url.hash.slice(1), + theoryUrl, + pathname: url.pathname, + } +} + +function appBaseUrl(): URL { + return new URL(import.meta.env.BASE_URL, location.origin) +} diff --git a/examples/lab/src/main.ts b/examples/lab/src/main.ts new file mode 100644 index 00000000..cd00f59f --- /dev/null +++ b/examples/lab/src/main.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { Repo, type DocumentId } from "@automerge/automerge-repo" +import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb" +// @ts-expect-error initSync is exported at runtime but absent from declarations +import { initSync } from "@automerge/automerge-subduction/slim" +// @ts-expect-error wasm-base64 is exported without declarations +import { wasmBase64 } from "@automerge/automerge-subduction/wasm-base64" +import { mount } from "svelte" +import LabApp from "./app/LabApp.svelte" +import "./style.css" + +initSync({ + module: Uint8Array.from(atob(wasmBase64), (char) => char.charCodeAt(0)), +}) + +const endpoint = + import.meta.env.VITE_SUBDUCTION_ENDPOINT ?? + "wss://coln.sync.inkandswitch.com" +const repo = new Repo({ + storage: new IndexedDBStorageAdapter("coln-lab", "documents"), + subductionWebsocketEndpoints: [endpoint], +}) +const openedDocuments = new Set() +const trackDocument = (documentId: string) => + openedDocuments.add(documentId as DocumentId) +const flush = () => { + if (openedDocuments.size > 0) void repo.flush([...openedDocuments]) +} + +addEventListener("pagehide", flush) +mount(LabApp, { + target: document.getElementById("app")!, + props: { repo, endpoint, trackDocument }, +}) + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + removeEventListener("pagehide", flush) + void repo.shutdown() + }) +} diff --git a/examples/lab/src/style.css b/examples/lab/src/style.css new file mode 100644 index 00000000..0f5fc0db --- /dev/null +++ b/examples/lab/src/style.css @@ -0,0 +1,27 @@ +/* SPDX-FileCopyrightText: 2026 Coln contributors */ +/* SPDX-License-Identifier: Apache-2.0 OR MIT */ + +@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;600;700&display=swap"); +@import "tailwindcss"; + +:root { color-scheme: dark; font-variant-numeric: tabular-nums; } +html { background: #0d1211; } +body { margin: 0; } +button, input, select { font: inherit; } +::selection { background: #657a32; color: #f0f1e8; } + +.ledger-field { + background-color: #0f1513; + background-image: + linear-gradient(#26312d66 1px, transparent 1px), + linear-gradient(90deg, #26312d33 1px, transparent 1px); + background-size: 100% 32px, 32px 100%; +} + +.diagnostics pre { margin: 0; overflow-x: auto; white-space: pre-wrap; } +.diagnostics > :not(:last-child) { border-bottom: 1px solid #304041; padding-bottom: 1rem; } +.diag-debug { --diagnostic-color: #aab6b7; } +.diag-info { --diagnostic-color: #77b7ff; } +.diag-warning { --diagnostic-color: #ffd166; } +.diag-error { --diagnostic-color: #ff9a86; } +[class^="ann-"] { color: var(--diagnostic-color); font-weight: 500; } diff --git a/examples/lab/src/tools/compiler/CompilerTool.svelte b/examples/lab/src/tools/compiler/CompilerTool.svelte new file mode 100644 index 00000000..6fa14371 --- /dev/null +++ b/examples/lab/src/tools/compiler/CompilerTool.svelte @@ -0,0 +1,336 @@ + + + + + +{#if loadError} +
+
+
+

{errorCopy[loadError][0]}

+

{errorCopy[loadError][1]}

+

{errorCopy[loadError][2]}

+
+ {#if documentUrl}{documentUrl}{/if} + +
+
+{:else if handle && theoryHandle} + {@const currentTheoryUrl = handle.url} +
+ {#if feedback}

{feedback}

{/if} +
+ +
+ +
+
{statusLabel}
+
{syncStatus}/ {sourceLines} {sourceLines === 1 ? "line" : "lines"}
+
+
+
+
+

SOURCE

Collaborative Automerge document

Live theory
+
+ {#if error}{/if} + {#if syncStatus === "error"}{/if} + {#if compilation.diagnosticsHtml.length === 0}

No diagnostics

{:else}
{#each compilation.diagnosticsHtml as diagnostic}
{@html diagnostic}
{/each}
{/if}
+ {#if compilation.prettyIr.length === 0}

No intermediate representation

{:else}
{#each compilation.prettyIr as realm}
{realm}
{/each}
{/if}
+ {#if compilation.irJson === ""}

No JSON output

{:else}{/if}
+
+ (selectedRealmName = name)} oncreate={createSelectedStore} oncopy={(url) => void copyText(url, "Store URL copied")} openhref={(url) => router.href("editor", url, currentTheoryUrl)} onopen={(event, url) => router.follow(event, "editor", url, currentTheoryUrl)} /> +
+
+{:else} +

LOADING COMPILER

+{/if} diff --git a/examples/lab/src/tools/compiler/JsonViewer.svelte b/examples/lab/src/tools/compiler/JsonViewer.svelte new file mode 100644 index 00000000..3247c6f3 --- /dev/null +++ b/examples/lab/src/tools/compiler/JsonViewer.svelte @@ -0,0 +1,84 @@ + + + + + +
+ + diff --git a/examples/lab/src/tools/compiler/OutputPanel.svelte b/examples/lab/src/tools/compiler/OutputPanel.svelte new file mode 100644 index 00000000..c6c158a8 --- /dev/null +++ b/examples/lab/src/tools/compiler/OutputPanel.svelte @@ -0,0 +1,18 @@ + + + + + +
+
+

{label}

+ {index} +
+
+ {@render children()} +
+
diff --git a/examples/lab/src/tools/compiler/SourceEditor.svelte b/examples/lab/src/tools/compiler/SourceEditor.svelte new file mode 100644 index 00000000..9cc21a08 --- /dev/null +++ b/examples/lab/src/tools/compiler/SourceEditor.svelte @@ -0,0 +1,140 @@ + + + + + +
diff --git a/examples/lab/src/tools/compiler/StoresPanel.svelte b/examples/lab/src/tools/compiler/StoresPanel.svelte new file mode 100644 index 00000000..918ea998 --- /dev/null +++ b/examples/lab/src/tools/compiler/StoresPanel.svelte @@ -0,0 +1,119 @@ + + + + + + diff --git a/examples/lab/src/tools/compiler/compiled-realms.ts b/examples/lab/src/tools/compiler/compiled-realms.ts new file mode 100644 index 00000000..b2c8de94 --- /dev/null +++ b/examples/lab/src/tools/compiler/compiled-realms.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { isRealmSchema, type RealmSchema } from "./theory-document.ts" + +export type CompiledRealm = { + index: number + name: string + schema: RealmSchema +} + +export function parseCompiledRealms(irJson: string): CompiledRealm[] { + if (irJson === "") return [] + const value: unknown = JSON.parse(irJson) + if (!Array.isArray(value)) throw new TypeError("Compiler IR must be an array of realms") + + return value.map((schema, index) => { + if (!isRealmSchema(schema)) { + throw new TypeError(`Compiler IR realm ${index + 1} is malformed`) + } + return { index, name: realmName(schema, index), schema } + }) +} + +function realmName(schema: RealmSchema, index: number): string { + for (const entry of [...schema.entities, ...schema.rules]) { + if (typeof entry !== "object" || entry === null) continue + const path = (entry as { path?: unknown }).path + if (!Array.isArray(path) || !Array.isArray(path[0])) continue + if (path[0].every(part => typeof part === "string")) return path[0].join(".") + } + return `Realm ${index + 1}` +} diff --git a/examples/lab/src/tools/compiler/compiler.ts b/examples/lab/src/tools/compiler/compiler.ts new file mode 100644 index 00000000..f53ce6a8 --- /dev/null +++ b/examples/lab/src/tools/compiler/compiler.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +export type Compilation = { + diagnosticsHtml: string[] + prettyIr: string[] + irJson: string +} + +type CompileResultPointer = unknown + +type CompilerExports = { + compile(source: string): Promise + freeCompileResult(pointer: CompileResultPointer): Promise | void + getDiagnostics(asHtml: boolean, pointer: CompileResultPointer): Promise | string[] + prettyIr(pointer: CompileResultPointer): Promise | string[] + irToJson(pointer: CompileResultPointer): Promise | string +} + +type WasmLoader = (options: { + wasmUrl: string + ghc_wasm_jsffi: (exports: CompilerExports) => WebAssembly.Imports +}) => Promise + +export type Compiler = { + compile(source: string): Promise +} + +let compilerPromise: Promise | undefined + +export function loadCompiler(): Promise { + compilerPromise ??= initializeCompiler().catch(error => { + compilerPromise = undefined + throw error + }) + return compilerPromise +} + +async function initializeCompiler(): Promise { + const distUrl = `${import.meta.env.BASE_URL}dist/` + const [loaderModule, jsffiModule] = await Promise.all([ + import(/* @vite-ignore */ `${distUrl}loadHaskellWasm.js`), + import(/* @vite-ignore */ `${distUrl}ghc_wasm_jsffi.js`), + ]) + const loadHaskellWasm = loaderModule.default as WasmLoader + const exports = await loadHaskellWasm({ + wasmUrl: `${distUrl}coln.wasm`, + ghc_wasm_jsffi: jsffiModule.default, + }) + let previousCompilation = Promise.resolve() + + return { + compile(source) { + const run = async () => { + const pointer = await exports.compile(source) + try { + // GHC reactor exports must not be entered concurrently. + const prettyIr = await exports.prettyIr(pointer) + const diagnosticsHtml = await exports.getDiagnostics(true, pointer) + const irJson = await exports.irToJson(pointer) + return { diagnosticsHtml, prettyIr, irJson } + } finally { + await exports.freeCompileResult(pointer) + } + } + const compilation = previousCompilation.then(run, run) + previousCompilation = compilation.then(() => undefined, () => undefined) + return compilation + }, + } +} diff --git a/examples/lab/src/tools/compiler/stores.ts b/examples/lab/src/tools/compiler/stores.ts new file mode 100644 index 00000000..60a4d4dc --- /dev/null +++ b/examples/lab/src/tools/compiler/stores.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { Repo } from "@automerge/automerge-repo" +import { colnDocType } from "@coln-project/repo" +import type { CompiledRealm } from "./compiled-realms.ts" +import type { TheoryHandle } from "./theory-handle.svelte.ts" +import type { StoreRecord } from "./theory-document.ts" + +export async function createStore( + repo: Repo, + theory: TheoryHandle, + realm: CompiledRealm, +): Promise<{ record: StoreRecord; theoryFlushError?: unknown }> { + const createdAt = Date.now() + const sourceHeads = theory.heads + const store = repo.create(realm.schema, colnDocType) + const record: StoreRecord = { + url: store.url, + createdAt, + sourceHeads, + realmIndex: realm.index, + realmName: realm.name, + ir: realm.schema, + } + + await repo.flush([store.documentId]) + theory.appendStore(record) + try { + await repo.flush([theory.documentId]) + } catch (theoryFlushError) { + return { record, theoryFlushError } + } + + return { record } +} diff --git a/examples/lab/src/tools/compiler/theory-document.ts b/examples/lab/src/tools/compiler/theory-document.ts new file mode 100644 index 00000000..9804d5c0 --- /dev/null +++ b/examples/lab/src/tools/compiler/theory-document.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { DocHandle } from "@automerge/automerge-repo" + +export type RealmSchema = { + entities: unknown[] + rules: unknown[] +} + +export type StoreRecord = { + url: string + createdAt: number + sourceHeads: string[] + realmIndex: number + realmName: string + ir: RealmSchema +} + +export type TheoryDocument = { + version: 1 + source: string + stores: StoreRecord[] +} + +export type TheoryDocumentHandle = DocHandle + +export function newTheoryDocument(): TheoryDocument { + return { version: 1, source: "", stores: [] } +} + +export function isTheoryDocument(value: unknown): value is TheoryDocument { + if (typeof value !== "object" || value === null) return false + const document = value as Partial + return document.version === 1 + && typeof document.source === "string" + && Array.isArray(document.stores) + && document.stores.every(isStoreRecord) +} + +export function isRealmSchema(value: unknown): value is RealmSchema { + if (typeof value !== "object" || value === null) return false + const schema = value as Partial + return Array.isArray(schema.entities) && Array.isArray(schema.rules) +} + +function isStoreRecord(value: unknown): value is StoreRecord { + if (typeof value !== "object" || value === null) return false + const store = value as Partial + return typeof store.url === "string" + && typeof store.createdAt === "number" + && Number.isFinite(store.createdAt) + && Array.isArray(store.sourceHeads) + && store.sourceHeads.every(head => typeof head === "string") + && Number.isInteger(store.realmIndex) + && typeof store.realmName === "string" + && isRealmSchema(store.ir) +} diff --git a/examples/lab/src/tools/compiler/theory-handle.svelte.ts b/examples/lab/src/tools/compiler/theory-handle.svelte.ts new file mode 100644 index 00000000..3e0cc119 --- /dev/null +++ b/examples/lab/src/tools/compiler/theory-handle.svelte.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { createSubscriber } from "svelte/reactivity" +import type { + StoreRecord, + TheoryDocument, + TheoryDocumentHandle, +} from "./theory-document.ts" + +export class TheoryHandle { + readonly #subscribe: () => void + + constructor(private readonly handle: TheoryDocumentHandle) { + this.#subscribe = createSubscriber(update => { + handle.on("change", update) + return () => handle.off("change", update) + }) + } + + get state(): TheoryDocument { + this.#subscribe() + return this.handle.doc() + } + + get documentId(): TheoryDocumentHandle["documentId"] { + return this.handle.documentId + } + + get heads(): string[] { + return [...this.handle.heads()] + } + + appendStore(record: StoreRecord): void { + this.handle.change(document => { + document.stores.push(record) + }) + } +} diff --git a/examples/lab/src/tools/editor/EditorTool.svelte b/examples/lab/src/tools/editor/EditorTool.svelte new file mode 100644 index 00000000..70ccb4e0 --- /dev/null +++ b/examples/lab/src/tools/editor/EditorTool.svelte @@ -0,0 +1,225 @@ + + + + + +{#if !store} + +{:else} +
+ {#if actionError} + + {/if} + +
+ +
+ + {#if theoryHref} + router.follow(event, "compiler", theoryUrl)}>Return to Theory + {/if} + + +
+
+ +
+ (selectedTableName = name)} /> +
+
+
+

JAVASCRIPT REPL

+

Trusted code / handle in scope / explicit return

+
+ +
+ (source = value)} onrun={runProgram} /> + +
+
+
+{/if} diff --git a/examples/lab/src/tools/editor/ReplEditor.svelte b/examples/lab/src/tools/editor/ReplEditor.svelte new file mode 100644 index 00000000..811cd14b --- /dev/null +++ b/examples/lab/src/tools/editor/ReplEditor.svelte @@ -0,0 +1,110 @@ + + + + + +
diff --git a/examples/lab/src/tools/editor/ReplOutput.svelte b/examples/lab/src/tools/editor/ReplOutput.svelte new file mode 100644 index 00000000..8d1f2aba --- /dev/null +++ b/examples/lab/src/tools/editor/ReplOutput.svelte @@ -0,0 +1,35 @@ + + + + + +
+ {#if !evaluation} +

Run a program to see its result and console output.

+ {:else} + {#each evaluation.console as entry} +
+ {entry.level} +
+ {#each entry.values as value} + + {/each} +
+
+ {/each} +
+
+ {evaluation.ok ? "Result" : `${evaluation.phase} error`} + {evaluation.durationMs.toFixed(1)} ms +
+
+ +
+
+ {/if} +
diff --git a/examples/lab/src/tools/editor/StoreLoader.svelte b/examples/lab/src/tools/editor/StoreLoader.svelte new file mode 100644 index 00000000..f5a2341d --- /dev/null +++ b/examples/lab/src/tools/editor/StoreLoader.svelte @@ -0,0 +1,51 @@ + + + + + +
+
{ + event.preventDefault() + onload(documentUrl) + }} + > +
+

01 / OPEN STORE

+

Inspect a synchronized Coln store.

+

Enter an Automerge document URL. Store Lab will resolve its Coln schema, tables, and live rows.

+
+
+ + + {#if error} + + {/if} +
+
+
diff --git a/examples/lab/src/tools/editor/TableBrowser.svelte b/examples/lab/src/tools/editor/TableBrowser.svelte new file mode 100644 index 00000000..08defd13 --- /dev/null +++ b/examples/lab/src/tools/editor/TableBrowser.svelte @@ -0,0 +1,97 @@ + + + + + +
+
+
+

STORE CONTENTS

+

{tables.length} {tables.length === 1 ? "table" : "tables"}

+
+ +
+ +
+ + +
+ {#if selected} + + + + + {#each selected.columns as column} + + {/each} + + + + {#each rows as row (displayRowRef(row.rowId.value).full)} + {@const rowId = displayRowRef(row.rowId.value)} + + + {#each row.values as value} + {@const displayed = displayValue(value)} + + {/each} + + {:else} + + + + {/each} + +
Row ID + {column.name}{column.primary ? " *" : ""} + {column.type} +
{rowId.compact} + {displayed.compact} +
No rows in {selected.name}
+ {:else} +
This store has no tables.
+ {/if} +
+
+
diff --git a/examples/lab/src/tools/editor/ValueInspector.svelte b/examples/lab/src/tools/editor/ValueInspector.svelte new file mode 100644 index 00000000..50269b96 --- /dev/null +++ b/examples/lab/src/tools/editor/ValueInspector.svelte @@ -0,0 +1,104 @@ + + + + + +
+
+ {#if structured} + + + {/if} + +
+ + {#if structured} +
+ {:else} + {primitive} + {/if} +
+ + diff --git a/examples/lab/src/tools/editor/coln-handle.svelte.ts b/examples/lab/src/tools/editor/coln-handle.svelte.ts new file mode 100644 index 00000000..b6ae3a94 --- /dev/null +++ b/examples/lab/src/tools/editor/coln-handle.svelte.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { + ColnDocument, + ColnHandle as RepoColnHandle, +} from "@coln-project/repo" +import { createSubscriber } from "svelte/reactivity" + +export class ColnHandleState { + readonly #subscribe: () => void + + constructor(private readonly handle: RepoColnHandle) { + this.#subscribe = createSubscriber((update) => { + handle.on("change", update) + return () => handle.off("change", update) + }) + } + + get current(): ColnDocument { + this.#subscribe() + return this.handle.doc() + } +} diff --git a/examples/lab/src/tools/editor/evaluate.ts b/examples/lab/src/tools/editor/evaluate.ts new file mode 100644 index 00000000..86e48930 --- /dev/null +++ b/examples/lab/src/tools/editor/evaluate.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { ColnHandle } from "@coln-project/repo" +import { snapshot, type Snapshot } from "./snapshot.ts" + +export type ConsoleLevel = "debug" | "info" | "log" | "warn" | "error" + +export interface ConsoleEntry { + level: ConsoleLevel + values: Snapshot[] +} + +export interface EvaluationSuccess { + ok: true + result: Snapshot + console: ConsoleEntry[] + durationMs: number +} + +export interface EvaluationFailure { + ok: false + phase: "syntax" | "runtime" + error: Snapshot + console: ConsoleEntry[] + durationMs: number +} + +export type Evaluation = EvaluationSuccess | EvaluationFailure + +type AsyncProgram = ( + handle: ColnHandle, + console: Pick, +) => Promise + +const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as new (...parameters: string[]) => AsyncProgram + +export async function evaluate( + source: string, + handle: ColnHandle, +): Promise { + const entries: ConsoleEntry[] = [] + let active = true + const capturedConsole = Object.fromEntries( + (["debug", "info", "log", "warn", "error"] as const).map((level) => [ + level, + (...values: unknown[]) => { + if (active) + entries.push({ + level, + values: values.map((value) => snapshot(value)), + }) + }, + ]), + ) as Pick + + let program: AsyncProgram + const started = performance.now() + try { + program = new AsyncFunction( + "handle", + "console", + `"use strict";\n${source}\n//# sourceURL=coln-store-lab-repl.js`, + ) + } catch (error) { + active = false + return { + ok: false, + phase: "syntax", + error: snapshot(error), + console: entries, + durationMs: performance.now() - started, + } + } + + try { + const result = await program(handle, capturedConsole) + active = false + return { + ok: true, + result: snapshot(result), + console: entries, + durationMs: performance.now() - started, + } + } catch (error) { + active = false + return { + ok: false, + phase: "runtime", + error: snapshot(error), + console: entries, + durationMs: performance.now() - started, + } + } +} diff --git a/examples/lab/src/tools/editor/format.ts b/examples/lab/src/tools/editor/format.ts new file mode 100644 index 00000000..16f796a9 --- /dev/null +++ b/examples/lab/src/tools/editor/format.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +export interface DisplayValue { + compact: string + full: string + kind: "int" | "string" | "ref" | "unknown" +} + +type JsonObject = Record + +export function displayValue(value: unknown): DisplayValue { + if (!isObject(value) || typeof value.tag !== "string") { + return unknownValue(value) + } + if (value.tag === "int" && typeof value.value === "number") { + const rendered = String(value.value) + return { compact: rendered, full: rendered, kind: "int" } + } + if (value.tag === "string" && typeof value.value === "string") { + return { compact: value.value, full: value.value, kind: "string" } + } + if (value.tag === "row_id") return displayRowRef(value.value) + return unknownValue(value) +} + +export function displayRowRef(value: unknown): DisplayValue { + if (!isObject(value)) return unknownValue(value) + if (isObject(value.existing)) { + const commit = value.existing.commit + const counter = value.existing.counter + if (typeof commit === "string" && typeof counter === "number") { + const full = `${commit}:${counter}` + const compact = `${commit.slice(0, 8)}…${commit.slice(-4)}:${counter}` + return { compact, full, kind: "ref" } + } + } + if (isObject(value.pending)) { + const txId = value.pending.txId + const counter = value.pending.counter + if (typeof txId === "number" && typeof counter === "number") { + const rendered = `pending:${txId}:${counter}` + return { compact: rendered, full: rendered, kind: "ref" } + } + } + return unknownValue(value) +} + +function unknownValue(value: unknown): DisplayValue { + let rendered: string + try { + rendered = JSON.stringify(value) ?? String(value) + } catch { + rendered = String(value) + } + return { compact: rendered, full: rendered, kind: "unknown" } +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/examples/lab/src/tools/editor/schema.ts b/examples/lab/src/tools/editor/schema.ts new file mode 100644 index 00000000..351e23dd --- /dev/null +++ b/examples/lab/src/tools/editor/schema.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +export interface StoreColumn { + name: string + type: string + primary: boolean +} + +export interface StoreTable { + name: string + columns: StoreColumn[] +} + +type JsonObject = Record +type IrPath = string[][] + +export function readTables(json: string): StoreTable[] { + const schema: unknown = JSON.parse(json) + if (!isObject(schema) || !Array.isArray(schema.entities)) { + throw new TypeError("Store IR does not contain an entities array") + } + + return schema.entities.map((entry, index) => readTable(entry, index)) +} + +function readTable(entry: unknown, index: number): StoreTable { + if (!isObject(entry)) throw new TypeError(`Entity ${index} is not an object`) + const path = readPath(entry.path, `Entity ${index} path`) + const definition = entry.value + if (!isObject(definition)) { + throw new TypeError(`Entity ${pathName(path)} has no definition`) + } + const variant = definition.entityVariant + if (!isObject(variant) || variant.tag !== "table") { + throw new TypeError(`Entity ${pathName(path)} is not a table`) + } + if (!Array.isArray(definition.columns)) { + throw new TypeError(`Table ${pathName(path)} has no columns array`) + } + + const primaryKey = definition.primaryKey + const primaryPaths = Array.isArray(primaryKey) + ? primaryKey.map((value, column) => + pathName(readPath(value, `Primary key ${column}`)), + ) + : [] + + return { + name: pathName(path), + columns: definition.columns.map((column, columnIndex) => { + if (!isObject(column)) { + throw new TypeError( + `Column ${columnIndex} in ${pathName(path)} is invalid`, + ) + } + const columnPath = readPath( + column.path, + `Column ${columnIndex} in ${pathName(path)}`, + ) + const name = pathName(columnPath) + return { + name, + type: typeName(column.type), + primary: primaryPaths.includes(name), + } + }), + } +} + +function readPath(value: unknown, label: string): IrPath { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some( + (segment) => + !Array.isArray(segment) || + segment.length === 0 || + segment.some((part) => typeof part !== "string" || part === ""), + ) + ) { + throw new TypeError(`${label} is invalid`) + } + return value as IrPath +} + +function pathName(path: IrPath): string { + return path.flat().join(".") +} + +function typeName(value: unknown): string { + if (!isObject(value) || typeof value.tag !== "string") return "unknown" + if (value.tag === "builtin") { + if (value.type === "builtinInt") return "int" + if (value.type === "builtinString") return "string" + } + if (value.tag === "rowId") { + try { + return `ref ${pathName(readPath(value.path, "Row reference path"))}` + } catch { + return "ref" + } + } + return value.tag +} + +function isObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/examples/lab/src/tools/editor/snapshot.ts b/examples/lab/src/tools/editor/snapshot.ts new file mode 100644 index 00000000..5fc6b9cd --- /dev/null +++ b/examples/lab/src/tools/editor/snapshot.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +export type Snapshot = + | null + | boolean + | number + | string + | Snapshot[] + | { [key: string]: Snapshot } + +export function snapshot(value: unknown, maxDepth = 8): Snapshot { + try { + return visit(value, new WeakSet(), 0, maxDepth) + } catch { + return tagged("uninspectable") + } +} + +function visit( + value: unknown, + seen: WeakSet, + depth: number, + maxDepth: number, +): Snapshot { + if ( + value === null || + typeof value === "boolean" || + typeof value === "string" + ) { + return value + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : tagged("number", String(value)) + } + if (typeof value === "undefined") return tagged("undefined") + if (typeof value === "bigint") return tagged("bigint", value.toString()) + if (typeof value === "symbol") + return tagged("symbol", value.description ?? "") + if (typeof value === "function") return tagged("function", value.name) + if (typeof value !== "object") return tagged("unknown", String(value)) + if (seen.has(value)) return tagged("circular") + if (depth >= maxDepth) return tagged("truncated") + + seen.add(value) + try { + if (value instanceof Error) { + return { + $type: "error", + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + } + } + if (Array.isArray(value)) { + return value.map((item) => visit(item, seen, depth + 1, maxDepth)) + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + return tagged("instance", prototype?.constructor?.name ?? "Object") + } + + return Object.fromEntries( + Object.entries(Object.getOwnPropertyDescriptors(value)).map( + ([key, descriptor]) => [ + key, + "value" in descriptor + ? visit(descriptor.value, seen, depth + 1, maxDepth) + : tagged("accessor"), + ], + ), + ) + } finally { + seen.delete(value) + } +} + +function tagged(type: string, value?: string): { [key: string]: Snapshot } { + return value === undefined ? { $type: type } : { $type: type, value } +} diff --git a/examples/lab/src/tools/editor/source-storage.ts b/examples/lab/src/tools/editor/source-storage.ts new file mode 100644 index 00000000..1f1c5556 --- /dev/null +++ b/examples/lab/src/tools/editor/source-storage.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +const prefix = "coln-store-lab:source:" + +export const starterSource = `const doc = handle.doc() +console.log(JSON.parse(doc.jsonIR())) +return doc.heads()` + +export function loadSource(documentUrl: string): string { + try { + return localStorage.getItem(prefix + documentUrl) ?? starterSource + } catch { + return starterSource + } +} + +export function saveSource(documentUrl: string, source: string): void { + try { + localStorage.setItem(prefix + documentUrl, source) + } catch { + // The REPL remains usable when storage is unavailable. + } +} diff --git a/examples/lab/src/tools/graph/GraphCanvas.svelte b/examples/lab/src/tools/graph/GraphCanvas.svelte new file mode 100644 index 00000000..74eab6cd --- /dev/null +++ b/examples/lab/src/tools/graph/GraphCanvas.svelte @@ -0,0 +1,205 @@ + + + + + + onselectedge("")} /> + +
+
+ DOCUMENT URL +
+ event.currentTarget.select()} data-testid="doc-url" /> + +
+
+ + {#if graph.vertices.length === 0} + + {/if} + + + + + + + + + + + + + + + + {#each drawableEdges as edge (edge.id)} + { + event.stopPropagation() + onselectedge(edge.id) + }} + onkeydown={(event) => event.key === "Enter" && onselectedge(edge.id)} + role="button" + tabindex="0" + aria-label={`Edge from ${vertexLabel(edge.fromId)} to ${vertexLabel(edge.toId)}`} + data-testid="graph-edge" + > + {/each} + + {#if previewEdgePath} + + {/if} + + {#each points as vertex (vertex.id)} + onselectvertex(vertex)} + onkeydown={(event) => event.key === "Enter" && onselectvertex(vertex)} + role="button" + tabindex="0" + data-testid="graph-vertex" + > + {vertex.id} + + {vertex.label} + + {/each} + + +
+ source + target +
+
diff --git a/examples/lab/src/tools/graph/GraphControls.svelte b/examples/lab/src/tools/graph/GraphControls.svelte new file mode 100644 index 00000000..a2e2a894 --- /dev/null +++ b/examples/lab/src/tools/graph/GraphControls.svelte @@ -0,0 +1,95 @@ + + + + + + diff --git a/examples/lab/src/tools/graph/GraphTool.svelte b/examples/lab/src/tools/graph/GraphTool.svelte new file mode 100644 index 00000000..877721a9 --- /dev/null +++ b/examples/lab/src/tools/graph/GraphTool.svelte @@ -0,0 +1,261 @@ + + + + + +{#if loadError} +
+
+
+

{errorCopy[loadError].label}

+

{errorCopy[loadError].heading}

+

{errorCopy[loadError].detail}

+
+ {#if documentUrl} + {documentUrl} + {/if} + +
+
+{:else if handle} + {@const currentGraphUrl = handle.url} +
+ {#if feedback} + {#key feedback.id} +

+ {feedback.message} +

+ {/key} + {/if} + +
+ +
+ router.follow(event, "editor", currentGraphUrl)}>Open in Store +
+
+ {syncStatus === "error" ? "sync error" : syncStatus} +
+
+ {graph.heads.length} {graph.heads.length === 1 ? "head" : "heads"} + +
+
+
+
+ +
+ selectedEdgeId = id} + onselectvertex={selectVertex} + /> + fromId = id} + ontochange={id => toId = id} + /> +
+
+{:else} +
+

LOADING GRAPH

+
+{/if} diff --git a/examples/lab/src/tools/graph/coln-handle.svelte.ts b/examples/lab/src/tools/graph/coln-handle.svelte.ts new file mode 100644 index 00000000..bc27fd11 --- /dev/null +++ b/examples/lab/src/tools/graph/coln-handle.svelte.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { + ColnChange, + ColnDocument, + ColnHandle as RepoColnHandle, + RealmBindings, +} from "@coln-project/repo"; +import { createSubscriber } from "svelte/reactivity"; + +export class ColnHandle { + readonly #subscribe: () => void; + + constructor(private readonly handle: RepoColnHandle) { + this.#subscribe = createSubscriber((update) => { + handle.on("change", update); + + return () => { + handle.off("change", update); + }; + }); + } + + get state(): ColnDocument { + this.#subscribe(); + return this.handle.doc(); + } + + change(change: ColnChange): void { + this.handle.change(change); + } +} diff --git a/examples/lab/src/tools/graph/compile.sh b/examples/lab/src/tools/graph/compile.sh new file mode 100644 index 00000000..3ad2e163 --- /dev/null +++ b/examples/lab/src/tools/graph/compile.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Coln contributors +# SPDX-License-Identifier: Apache-2.0 OR MIT + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +if command -v coln >/dev/null 2>&1; then + coln_cli=(coln) +elif command -v coln-cli >/dev/null 2>&1; then + coln_cli=(coln-cli) +else + coln_cli=(cabal run coln-cli --) +fi + +"${coln_cli[@]}" generate-ts "$script_dir/graph.coln" --output-dir "$script_dir/generated" +"${coln_cli[@]}" generate-ir "$script_dir/graph.coln" --output-dir "$script_dir/generated" diff --git a/examples/sync-demo/src/generated/Graph.ts b/examples/lab/src/tools/graph/generated/Graph.ts similarity index 99% rename from examples/sync-demo/src/generated/Graph.ts rename to examples/lab/src/tools/graph/generated/Graph.ts index 3257e861..b8fc8138 100644 --- a/examples/sync-demo/src/generated/Graph.ts +++ b/examples/lab/src/tools/graph/generated/Graph.ts @@ -8,4 +8,4 @@ export interface View { export interface Transaction extends View { V: runtime.ColnSet.Transaction; E: (x: runtime.Value) => (x: runtime.Value) => runtime.ColnSet.Transaction; -} +} \ No newline at end of file diff --git a/examples/lab/src/tools/graph/generated/GraphRealm.json b/examples/lab/src/tools/graph/generated/GraphRealm.json new file mode 100644 index 00000000..b83d89f3 --- /dev/null +++ b/examples/lab/src/tools/graph/generated/GraphRealm.json @@ -0,0 +1 @@ +{"entities":[{"path":[["GraphRealm"],["V"]],"value":{"entityVariant":{"tag":"table"},"columns":[],"primaryKey":null}},{"path":[["GraphRealm"],["E"]],"value":{"entityVariant":{"tag":"table"},"columns":[{"path":[["a"]],"type":{"tag":"rowId","path":[["GraphRealm"],["V"]]}},{"path":[["b"]],"type":{"tag":"rowId","path":[["GraphRealm"],["V"]]}}],"primaryKey":null}}],"rules":[{"path":[["GraphRealm"],["V"],["foreignKey"]],"value":{"ruleVariant":"enforced","varNames":[],"varTypes":[],"antecedents":[{"tag":"atom","atom":{"entity":[["GraphRealm"],["V"]],"rowId":null,"values":[]}}],"consequents":[]}},{"path":[["GraphRealm"],["E"],["foreignKey"]],"value":{"ruleVariant":"enforced","varNames":[[["a"]],[["b"]]],"varTypes":[{"tag":"rowId","path":[["GraphRealm"],["V"]]},{"tag":"rowId","path":[["GraphRealm"],["V"]]}],"antecedents":[{"tag":"atom","atom":{"entity":[["GraphRealm"],["E"]],"rowId":null,"values":[{"column":0,"term":{"tag":"var","index":0}},{"column":1,"term":{"tag":"var","index":1}}]}}],"consequents":[{"tag":"atom","atom":{"entity":[["GraphRealm"],["V"]],"rowId":{"tag":"var","index":0},"values":[]}},{"tag":"atom","atom":{"entity":[["GraphRealm"],["V"]],"rowId":{"tag":"var","index":1},"values":[]}}]}}]} \ No newline at end of file diff --git a/examples/lab/src/tools/graph/generated/GraphRealm.pretty b/examples/lab/src/tools/graph/generated/GraphRealm.pretty new file mode 100644 index 00000000..b4a7d502 --- /dev/null +++ b/examples/lab/src/tools/graph/generated/GraphRealm.pretty @@ -0,0 +1,13 @@ +flatrealm + entities + table ℜ.V := [] + table ℜ.E := [.a : ℜ.V, .b : ℜ.V] + end + rules + enforced ℜ.V.foreignKey := ℜ.V [] ⊢ ⊤ + enforced ℜ.E.foreignKey a b := ℜ.E [ + .a ↦ a, + .b ↦ b + ] ⊢ a ∈ ℜ.V [] ∧ b ∈ ℜ.V [] + end +end \ No newline at end of file diff --git a/examples/sync-demo/src/generated/GraphRealm.ts b/examples/lab/src/tools/graph/generated/GraphRealm.ts similarity index 99% rename from examples/sync-demo/src/generated/GraphRealm.ts rename to examples/lab/src/tools/graph/generated/GraphRealm.ts index e7a54556..9b2db543 100644 --- a/examples/sync-demo/src/generated/GraphRealm.ts +++ b/examples/lab/src/tools/graph/generated/GraphRealm.ts @@ -45,4 +45,4 @@ export class Transaction extends View { } }; } -} +} \ No newline at end of file diff --git a/examples/sync-demo/graph.coln b/examples/lab/src/tools/graph/graph.coln similarity index 100% rename from examples/sync-demo/graph.coln rename to examples/lab/src/tools/graph/graph.coln diff --git a/examples/lab/src/tools/graph/graph.ts b/examples/lab/src/tools/graph/graph.ts new file mode 100644 index 00000000..e84f5df4 --- /dev/null +++ b/examples/lab/src/tools/graph/graph.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { ColnDocument, ColnHandle } from "@coln-project/repo" +import type { RowRef, RowView, Value } from "@coln-project/runtime" +import * as GraphRealm from "./generated/GraphRealm.ts" + +export type GraphHandle = ColnHandle +type GraphDocument = ColnDocument +type GraphWriter = Pick +type RowIdValue = Extract + +export type Vertex = { + id: string + label: string + ref: RowRef +} + +export type Edge = { + id: string + fromId: string + toId: string +} + +export type Graph = { + vertices: Vertex[] + edges: Edge[] + heads: string[] +} + +export function readGraph(document: GraphDocument): Graph { + const vertices = iteratorToArray(document.root.V.values()) + .map(rowRef) + .filter(ref => ref !== null) + .map(ref => ({ id: refId(ref), ref })) + .map((vertex, index) => ({ ...vertex, label: `V${index + 1}` })) + const vertexIds = new Set(vertices.map(vertex => vertex.id)) + + const edges = document.scanTable("GraphRealm.E") + .flatMap(row => projectEdge(row, vertexIds)) + .sort((left, right) => left.id.localeCompare(right.id)) + + return { vertices, edges, heads: [...document.heads()].sort() } +} + +export function addVertex(writer: GraphWriter): void { + writer.change(tx => { + tx.root.V.add() + }) +} + +export function addEdge( + writer: GraphWriter, + from: Vertex, + to: Vertex, +): void { + writer.change(tx => { + tx.root.E(rowValue(from.ref))(rowValue(to.ref)).add() + }) +} + +export function watchGraph( + handle: GraphHandle, + update: (graph: Graph) => void, +): () => void { + const refresh = () => update(readGraph(handle.doc())) + handle.on("change", refresh) + handle.on("heads-changed", refresh) + refresh() + + return () => { + handle.off("change", refresh) + handle.off("heads-changed", refresh) + } +} + +function projectEdge(row: RowView, vertexIds: Set): Edge[] { + const from = valueRef(row.values[0]) + const to = valueRef(row.values[1]) + const edge = rowRef(row) + if (!from || !to || !edge) return [] + + const fromId = refId(from) + const toId = refId(to) + if (!vertexIds.has(fromId) || !vertexIds.has(toId)) return [] + + return [{ id: refId(edge), fromId, toId }] +} + +function iteratorToArray(iterator: Iterator): T[] { + const values: T[] = [] + for (let next = iterator.next(); !next.done; next = iterator.next()) { + values.push(next.value) + } + return values +} + +function rowRef(row: RowView): RowRef | null { + return valueRef(row.rowId) +} + +function valueRef(value: Value | undefined): RowRef | null { + return value?.tag === "row_id" ? value.value : null +} + +function rowValue(ref: RowRef): RowIdValue { + return { tag: "row_id", value: ref } +} + +function refId(ref: RowRef): string { + if ("existing" in ref) return `${ref.existing.commit}:${ref.existing.counter}` + return `pending:${ref.pending.txId}:${ref.pending.counter}` +} diff --git a/examples/lab/tests/coln-handle.unit.ts b/examples/lab/tests/coln-handle.unit.ts new file mode 100644 index 00000000..a58e734f --- /dev/null +++ b/examples/lab/tests/coln-handle.unit.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { flushSync } from "svelte" +import { describe, expect, test, vi } from "vitest" +import type { ColnChange, ColnDocument, ColnHandle as RepoColnHandle } from "@coln-project/repo" +import { ColnHandle } from "../src/tools/graph/coln-handle.svelte.ts" +import * as GraphRealm from "../src/tools/graph/generated/GraphRealm.ts" +import { observe } from "./observe.svelte.ts" + +type GraphHandle = RepoColnHandle +type GraphDocument = ColnDocument +type Change = ColnChange +type ChangePayload = { doc: GraphDocument } + +describe("ColnHandle", () => { + test("returns the current document", () => { + const handle = new FakeHandle() + const colnHandle = new ColnHandle(handle as unknown as GraphHandle) + + expect(colnHandle.state).toBe(handle.document) + + handle.document = fakeDocument() + expect(colnHandle.state).toBe(handle.document) + }) + + test("delegates changes to the handle", () => { + const handle = new FakeHandle() + const colnHandle = new ColnHandle(handle as unknown as GraphHandle) + const change = vi.fn() + + colnHandle.change(change) + + expect(handle.change).toHaveBeenCalledOnce() + expect(handle.change).toHaveBeenCalledWith(change) + }) + + test("reacts to changes and releases its listener", async () => { + const handle = new FakeHandle() + const colnHandle = new ColnHandle(handle as unknown as GraphHandle) + let observed: GraphDocument | undefined + const stop = observe(() => colnHandle.state, state => { + observed = state + }) + flushSync() + + expect(observed).toBe(handle.document) + expect(handle.listenerCount("change")).toBe(1) + handle.document = fakeDocument() + handle.emit("change", { doc: handle.document }) + flushSync() + expect(observed).toBe(handle.document) + + stop() + await Promise.resolve() + expect(handle.listenerCount("change")).toBe(0) + }) +}) + +class FakeHandle { + document = fakeDocument() + readonly change = vi.fn((_change: Change) => {}) + readonly #listeners = new Set<(payload: ChangePayload) => void>() + + doc(): GraphDocument { + return this.document + } + + on(_event: "change", listener: (payload: ChangePayload) => void): void { + this.#listeners.add(listener) + } + + off(_event: "change", listener: (payload: ChangePayload) => void): void { + this.#listeners.delete(listener) + } + + emit(_event: "change", payload: ChangePayload): void { + for (const listener of this.#listeners) listener(payload) + } + + listenerCount(_event: "change"): number { + return this.#listeners.size + } +} + +function fakeDocument(): GraphDocument { + return {} as GraphDocument +} diff --git a/examples/lab/tests/document-sync.unit.ts b/examples/lab/tests/document-sync.unit.ts new file mode 100644 index 00000000..cadfe4da --- /dev/null +++ b/examples/lab/tests/document-sync.unit.ts @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { Repo } from "@automerge/automerge-repo" +import { flushSync } from "svelte" +import { describe, expect, test } from "vitest" +import { + DocumentSync, + type SyncStatus, +} from "../src/lib/document-sync.svelte.ts" +import { observe } from "./observe.svelte.ts" + +describe("DocumentSync", () => { + test("moves from offline through syncing to synced and cleans up", async () => { + const repo = new FakeRepo(false) + const handle = new FakeHandle() + const sync = new DocumentSync(repo as unknown as Repo, handle) + let status: SyncStatus | undefined + const stop = observe(() => sync.status, value => (status = value)) + flushSync() + + expect(status).toBe("offline") + expect(repo.listenerCount()).toBe(1) + expect(handle.listenerCount()).toBe(1) + expect(repo.flushes).toHaveLength(0) + + repo.connect() + flushSync() + expect(status).toBe("syncing") + expect(repo.flushedDocuments[0]).toEqual([handle.documentId]) + await repo.resolveFlush(0) + flushSync() + expect(status).toBe("synced") + + stop() + await Promise.resolve() + expect(repo.listenerCount()).toBe(0) + expect(handle.listenerCount()).toBe(0) + }) + + test("flushes a newer change before reporting synced", async () => { + const repo = new FakeRepo(true) + const handle = new FakeHandle() + const sync = new DocumentSync(repo as unknown as Repo, handle) + let status: SyncStatus | undefined + const stop = observe(() => sync.status, value => (status = value)) + flushSync() + + handle.emitChange() + await repo.resolveFlush(0) + flushSync() + expect(status).toBe("syncing") + expect(repo.flushes).toHaveLength(2) + await repo.resolveFlush(1) + flushSync() + expect(status).toBe("synced") + stop() + }) + + test("reports failures and retries after another change", async () => { + const repo = new FakeRepo(true) + const handle = new FakeHandle() + const sync = new DocumentSync(repo as unknown as Repo, handle) + let observed: { status: SyncStatus; error: unknown } | undefined + const stop = observe( + () => ({ status: sync.status, error: sync.error }), + value => (observed = value), + ) + flushSync() + + const failure = new Error("save failed") + await repo.rejectFlush(0, failure) + flushSync() + expect(observed).toEqual({ status: "error", error: failure }) + + handle.emitChange() + flushSync() + await repo.resolveFlush(1) + flushSync() + expect(observed).toEqual({ status: "synced", error: undefined }) + stop() + }) + + test("reconnects without waiting for an obsolete flush", async () => { + const repo = new FakeRepo(true) + const handle = new FakeHandle() + const sync = new DocumentSync(repo as unknown as Repo, handle) + let status: SyncStatus | undefined + const stop = observe(() => sync.status, value => (status = value)) + flushSync() + + repo.disconnect() + flushSync() + expect(status).toBe("offline") + repo.connect() + flushSync() + expect(repo.flushes).toHaveLength(2) + await repo.resolveFlush(1) + flushSync() + expect(status).toBe("synced") + await repo.rejectFlush(0, new Error("obsolete")) + flushSync() + expect(status).toBe("synced") + stop() + }) + + test("ignores completion after cleanup and can resubscribe", async () => { + const repo = new FakeRepo(true) + const handle = new FakeHandle() + const sync = new DocumentSync(repo as unknown as Repo, handle) + let status: SyncStatus | undefined + const firstStop = observe(() => sync.status, value => (status = value)) + flushSync() + + firstStop() + await Promise.resolve() + await repo.rejectFlush(0, new Error("late")) + expect(repo.listenerCount()).toBe(0) + expect(handle.listenerCount()).toBe(0) + + const secondStop = observe(() => sync.status, value => (status = value)) + flushSync() + expect(repo.flushes).toHaveLength(2) + expect(status).toBe("syncing") + await repo.resolveFlush(1) + flushSync() + expect(status).toBe("synced") + secondStop() + }) +}) + +class FakeHandle { + readonly documentId = "test-document" as never + readonly #listeners = new Set<() => void>() + + on(_event: "change", listener: () => void): void { + this.#listeners.add(listener) + } + + off(_event: "change", listener: () => void): void { + this.#listeners.delete(listener) + } + + emitChange(): void { + for (const listener of this.#listeners) listener() + } + + listenerCount(): number { + return this.#listeners.size + } +} + +class FakeRepo { + readonly flushes: Deferred[] = [] + readonly flushedDocuments: unknown[] = [] + #connected: boolean + readonly #listeners = new Set<() => void>() + + constructor(connected: boolean) { + this.#connected = connected + } + + isSubductionConnected(): boolean { + return this.#connected + } + + on(_event: "subduction-connection", listener: () => void): void { + this.#listeners.add(listener) + } + + off(_event: "subduction-connection", listener: () => void): void { + this.#listeners.delete(listener) + } + + flush(documents?: unknown): Promise { + const flush = deferred() + this.flushes.push(flush) + this.flushedDocuments.push(documents) + return flush.promise + } + + connect(): void { + this.#connected = true + for (const listener of this.#listeners) listener() + } + + disconnect(): void { + this.#connected = false + for (const listener of this.#listeners) listener() + } + + async resolveFlush(index: number): Promise { + const flush = this.flushes[index] + if (!flush) throw new Error(`Missing flush ${index}`) + flush.resolve(undefined) + await flush.promise + await Promise.resolve() + } + + async rejectFlush(index: number, error: unknown): Promise { + const flush = this.flushes[index] + if (!flush) throw new Error(`Missing flush ${index}`) + flush.reject(error) + await flush.promise.catch(() => undefined) + await Promise.resolve() + } + + listenerCount(): number { + return this.#listeners.size + } +} + +type Deferred = { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: Deferred["resolve"] + let reject!: Deferred["reject"] + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} diff --git a/examples/lab/tests/evaluate.unit.ts b/examples/lab/tests/evaluate.unit.ts new file mode 100644 index 00000000..52ee2dad --- /dev/null +++ b/examples/lab/tests/evaluate.unit.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { ColnHandle } from "@coln-project/repo" +import { describe, expect, it } from "vitest" +import { evaluate } from "../src/tools/editor/evaluate.ts" + +const handle = { marker: "handle" } as unknown as ColnHandle + +describe("evaluate", () => { + it("awaits programs and captures console output", async () => { + const result = await evaluate( + 'console.log("ready", 2); await Promise.resolve(); return handle.marker', + handle, + ) + expect(result).toMatchObject({ + ok: true, + result: "handle", + console: [{ level: "log", values: ["ready", 2] }], + }) + }) + + it("separates syntax and runtime failures", async () => { + expect(await evaluate("return )", handle)).toMatchObject({ + ok: false, + phase: "syntax", + }) + expect(await evaluate('throw new Error("broken")', handle)).toMatchObject({ + ok: false, + phase: "runtime", + error: { $type: "error", message: "broken" }, + }) + }) +}) diff --git a/examples/lab/tests/format.unit.ts b/examples/lab/tests/format.unit.ts new file mode 100644 index 00000000..1956c007 --- /dev/null +++ b/examples/lab/tests/format.unit.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { describe, expect, it } from "vitest" +import { displayRowRef, displayValue } from "../src/tools/editor/format.ts" + +describe("value formatting", () => { + it("formats primitive values", () => { + expect(displayValue({ tag: "string", value: "Ada" })).toMatchObject({ + compact: "Ada", + kind: "string", + }) + expect(displayValue({ tag: "int", value: 37 })).toMatchObject({ + compact: "37", + kind: "int", + }) + }) + + it("compacts committed references without losing the full ID", () => { + const commit = + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + expect(displayRowRef({ existing: { commit, counter: 4 } })).toEqual({ + compact: "12345678…cdef:4", + full: `${commit}:4`, + kind: "ref", + }) + }) +}) diff --git a/examples/lab/tests/lab.spec.ts b/examples/lab/tests/lab.spec.ts new file mode 100644 index 00000000..9bb26f7c --- /dev/null +++ b/examples/lab/tests/lab.spec.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { expect, test } from "@playwright/test" + +const graphTheory = `theory Graph := sig + V : Set + E : V -> V -> Set +end + +realm GraphRealm @ Graph +end` + +test("moves from theory and graph ledgers into their stores", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto("/") + await expect( + page.getByRole("heading", { name: "A workbench for things that follow." }), + ).toBeVisible() + await expect( + page.getByRole("navigation", { name: "Lab tools" }), + ).toBeVisible() + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true) + await page.setViewportSize({ width: 1280, height: 800 }) + + await page.getByRole("link", { name: /Start an experiment/ }).click() + await expect(page).toHaveURL(/\/sync\/#automerge:/) + const graphUrl = page.url() + await expect(page.getByText("COLN / GRAPH LAB")).toBeVisible() + await expect(page.getByTestId("sync-status")).toBeVisible() + await expect(page.getByTestId("head-count")).toHaveText(/^\d+ heads?$/) + const graphCanvasBox = await page.getByTestId("graph-canvas").boundingBox() + const graphControlsBox = await page + .locator("aside") + .filter({ has: page.getByTestId("add-vertex") }) + .boundingBox() + expect(graphCanvasBox).not.toBeNull() + expect(graphControlsBox).not.toBeNull() + expect(graphControlsBox!.x).toBeGreaterThan(graphCanvasBox!.x) + expect(Math.abs(graphControlsBox!.y - graphCanvasBox!.y)).toBeLessThan(2) + expect( + await page + .getByTestId("graph-canvas") + .evaluate((element) => getComputedStyle(element).minHeight), + ).toBe("680px") + await page.getByTestId("add-vertex").click() + await expect(page.getByTestId("graph-vertex")).toHaveCount(1) + const graphStoreHref = await page + .getByRole("link", { name: "Open in Store" }) + .getAttribute("href") + expect(graphStoreHref).toMatch(/^\/editor\/#automerge:/) + await page.getByRole("link", { name: "Open in Store" }).click() + await expect(page.getByText("COLN / STORE LAB")).toBeVisible() + await expect(page.getByText("JAVASCRIPT REPL")).toBeVisible() + await expect(page.getByTestId("table-option")).toHaveCount(2) + await expect( + page.getByTestId("table-option").filter({ hasText: "GraphRealm.V" }), + ).toBeVisible() + + await page + .getByRole("link", { name: /Theory/ }) + .first() + .click() + await expect(page).toHaveURL(/\/compiler\/#automerge:/) + const theoryUrl = page.url() + await expect(page.getByText("COLN / COMPILER")).toBeVisible() + await expect(page.getByText("SOURCE", { exact: true })).toBeVisible() + await expect(page.getByText("Diagnostics", { exact: true })).toBeVisible() + const editor = page.locator(".cm-content") + await editor.click() + await page.keyboard.press("ControlOrMeta+A") + await page.keyboard.insertText(graphTheory) + const createStore = page.getByRole("button", { name: /Create empty store/ }) + await expect(createStore).toBeEnabled({ timeout: 60_000 }) + const sourcePanelBox = await page + .getByText("SOURCE", { exact: true }) + .locator("xpath=ancestor::section[1]") + .boundingBox() + const storesPanelBox = await page + .locator("aside") + .filter({ hasText: "STORES" }) + .boundingBox() + expect(sourcePanelBox).not.toBeNull() + expect(storesPanelBox).not.toBeNull() + expect(storesPanelBox!.x).toBeGreaterThan(sourcePanelBox!.x) + expect(Math.abs(storesPanelBox!.y - sourcePanelBox!.y)).toBeLessThan(2) + await createStore.click() + await expect( + page.getByRole("link", { name: "Open", exact: true }), + ).toBeVisible() + + await page.getByRole("link", { name: "Open", exact: true }).click() + await expect(page).toHaveURL(/\/editor\/\?theory=automerge%3A.*#automerge:/) + await expect(page.getByTestId("table-option")).toHaveCount(2) + await expect( + page.getByRole("link", { name: "Return to Theory", exact: true }), + ).toBeVisible() + const storeUrl = page.url() + + const labTools = page.getByRole("navigation", { name: "Lab tools" }) + await labTools.getByRole("link", { name: /Graph/ }).click() + await expect(page).toHaveURL(graphUrl) + await expect(page.getByTestId("graph-vertex")).toHaveCount(1) + + await labTools.getByRole("link", { name: /Theory/ }).click() + await expect(page).toHaveURL(theoryUrl) + await expect(page.locator(".cm-content")).toContainText("theory Graph") + + await labTools.getByRole("link", { name: /Store/ }).click() + await expect(page).toHaveURL(storeUrl) + await expect(page.getByTestId("table-option")).toHaveCount(2) +}) diff --git a/examples/lab/tests/observe.svelte.ts b/examples/lab/tests/observe.svelte.ts new file mode 100644 index 00000000..b9afeb19 --- /dev/null +++ b/examples/lab/tests/observe.svelte.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +export function observe(read: () => T, update: (value: T) => void): () => void { + return $effect.root(() => { + $effect(() => update(read())) + }) +} diff --git a/examples/lab/tests/router.unit.ts b/examples/lab/tests/router.unit.ts new file mode 100644 index 00000000..de4d30d3 --- /dev/null +++ b/examples/lab/tests/router.unit.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { describe, expect, it } from "vitest" +import { routeFromUrl } from "../src/lib/router.svelte.ts" + +describe("Coln Lab routes", () => { + it("separates tool paths from document hashes", () => { + expect( + routeFromUrl( + new URL( + "https://lab.example/editor/?theory=automerge%3Atheory#automerge:store", + ), + ), + ).toEqual({ + tool: "editor", + documentUrl: "automerge:store", + theoryUrl: "automerge:theory", + pathname: "/editor/", + }) + }) + + it("supports a static deployment base", () => { + expect( + routeFromUrl( + new URL("https://example.test/lab/sync/#automerge:graph"), + "/lab/", + ), + ).toMatchObject({ tool: "sync", documentUrl: "automerge:graph" }) + }) + + it("reports unknown instruments", () => { + expect(routeFromUrl(new URL("https://lab.example/unknown/"))).toMatchObject( + { + tool: "not-found", + }, + ) + }) + + it("rejects extra path segments", () => { + expect( + routeFromUrl(new URL("https://lab.example/editor/extra/")), + ).toMatchObject({ + tool: "not-found", + }) + }) +}) diff --git a/examples/lab/tests/schema.unit.ts b/examples/lab/tests/schema.unit.ts new file mode 100644 index 00000000..28414d5a --- /dev/null +++ b/examples/lab/tests/schema.unit.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { describe, expect, it } from "vitest" +import { readTables } from "../src/tools/editor/schema.ts" + +describe("readTables", () => { + it("extracts paths, types, and primary columns", () => { + const tables = readTables( + JSON.stringify({ + entities: [ + { + path: [["Realm"], ["profile"]], + value: { + entityVariant: { tag: "table" }, + columns: [ + { + path: [["owner"]], + type: { tag: "rowId", path: [["Realm"], ["Person"]] }, + }, + { + path: [["details"], ["name"]], + type: { tag: "builtin", type: "builtinString" }, + }, + ], + primaryKey: [[["owner"]]], + }, + }, + ], + rules: [], + }), + ) + + expect(tables).toEqual([ + { + name: "Realm.profile", + columns: [ + { name: "owner", type: "ref Realm.Person", primary: true }, + { name: "details.name", type: "string", primary: false }, + ], + }, + ]) + }) + + it("rejects malformed IR", () => { + expect(() => readTables('{"rules":[]}')).toThrow("entities array") + expect(() => + readTables( + JSON.stringify({ + entities: [ + { path: [["Realm"]], value: { entityVariant: { tag: "view" } } }, + ], + }), + ), + ).toThrow("is not a table") + }) +}) diff --git a/examples/lab/tests/snapshot.unit.ts b/examples/lab/tests/snapshot.unit.ts new file mode 100644 index 00000000..3344e200 --- /dev/null +++ b/examples/lab/tests/snapshot.unit.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { describe, expect, it } from "vitest" +import { snapshot } from "../src/tools/editor/snapshot.ts" + +describe("snapshot", () => { + it("copies plain values and marks cycles", () => { + const value: Record = { answer: 42 } + value.self = value + expect(snapshot(value)).toEqual({ answer: 42, self: { $type: "circular" } }) + }) + + it("does not invoke accessors or retain class instances", () => { + let accessed = false + const value = Object.defineProperty({}, "secret", { + enumerable: true, + get() { + accessed = true + return 1 + }, + }) + expect(snapshot(value)).toEqual({ secret: { $type: "accessor" } }) + expect(accessed).toBe(false) + expect(snapshot(new Date(0))).toEqual({ $type: "instance", value: "Date" }) + }) + + it("contains objects that throw during inspection", () => { + const { proxy, revoke } = Proxy.revocable({}, {}) + revoke() + expect(snapshot(proxy)).toEqual({ $type: "uninspectable" }) + }) + + it("preserves an own __proto__ property", () => { + const value = Object.create(null) as Record + value.__proto__ = "data" + const result = snapshot(value) + expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(true) + expect((result as Record).__proto__).toBe("data") + }) +}) diff --git a/examples/lab/tests/source-storage.unit.ts b/examples/lab/tests/source-storage.unit.ts new file mode 100644 index 00000000..59b1fa60 --- /dev/null +++ b/examples/lab/tests/source-storage.unit.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + loadSource, + saveSource, + starterSource, +} from "../src/tools/editor/source-storage.ts" + +const values = new Map() + +beforeEach(() => { + values.clear() + vi.stubGlobal("localStorage", { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }) +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe("source storage", () => { + it("stores source independently by document URL", () => { + expect(loadSource("automerge:one")).toBe(starterSource) + saveSource("automerge:one", "return 1") + saveSource("automerge:two", "return 2") + expect(loadSource("automerge:one")).toBe("return 1") + expect(loadSource("automerge:two")).toBe("return 2") + }) +}) diff --git a/examples/sync-demo/tsconfig.json b/examples/lab/tsconfig.json similarity index 75% rename from examples/sync-demo/tsconfig.json rename to examples/lab/tsconfig.json index 2609a539..b53da86b 100644 --- a/examples/sync-demo/tsconfig.json +++ b/examples/lab/tsconfig.json @@ -3,7 +3,6 @@ "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], - "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, @@ -15,9 +14,13 @@ "allowImportingTsExtensions": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx", - "types": ["vite/client"] + "types": ["vite/client", "node"] }, - "include": ["src"], - "references": [] + "include": [ + "src", + "tests", + "vite.config.ts", + "vitest.config.ts", + "playwright.config.ts" + ] } diff --git a/examples/lab/vite.config.ts b/examples/lab/vite.config.ts new file mode 100644 index 00000000..972cd3ea --- /dev/null +++ b/examples/lab/vite.config.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { svelte } from "@sveltejs/vite-plugin-svelte" +import tailwindcss from "@tailwindcss/vite" +import { copyFile, mkdir } from "node:fs/promises" +import { defineConfig, type Plugin } from "vite" +import wasm from "vite-plugin-wasm" + +function staticToolRoutes(): Plugin { + let outputDirectory = "" + return { + name: "coln-lab-static-routes", + configResolved(config) { + outputDirectory = config.build.outDir + }, + async closeBundle() { + await Promise.all( + ["compiler", "editor", "sync"].map(async (route) => { + const directory = `${outputDirectory}/${route}` + await mkdir(directory, { recursive: true }) + await copyFile( + `${outputDirectory}/index.html`, + `${directory}/index.html`, + ) + }), + ) + }, + } +} + +export default defineConfig({ + base: process.env.VITE_BASE || "/", + plugins: [tailwindcss(), wasm(), svelte(), staticToolRoutes()], + resolve: { + dedupe: [ + "svelte", + "@coln-project/runtime", + "@automerge/automerge", + "@automerge/automerge-codemirror", + "@automerge/automerge-repo", + "@codemirror/state", + "@codemirror/view", + "codemirror", + ], + }, + optimizeDeps: { + include: [ + "@automerge/automerge-repo/slim", + "@automerge/automerge-codemirror", + "@codemirror/lang-javascript", + "@codemirror/state", + "@codemirror/view", + "codemirror", + "json-formatter-js", + ], + }, + build: { + emptyOutDir: true, + outDir: "../../_build/web/lab-app", + target: "esnext", + }, +}) diff --git a/examples/lab/vitest.config.ts b/examples/lab/vitest.config.ts new file mode 100644 index 00000000..224ba833 --- /dev/null +++ b/examples/lab/vitest.config.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { svelte } from "@sveltejs/vite-plugin-svelte" +import { playwright } from "@vitest/browser-playwright" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + plugins: [svelte()], + resolve: { conditions: ["browser"], dedupe: ["svelte"] }, + test: { + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + }, + include: ["tests/**/*.unit.ts"], + }, +}) diff --git a/examples/style.css b/examples/style.css deleted file mode 100644 index 87035491..00000000 --- a/examples/style.css +++ /dev/null @@ -1,115 +0,0 @@ -body { - --hue: 220; - --sat: 32%; - --main-bg: hsl(var(--hue), var(--sat), 16%); - --input-bg: hsl(var(--hue), var(--sat), 8%); - --alt-bg: hsl(var(--hue), var(--sat), 13%); - --border: hsl(var(--hue), var(--sat), 30%); - --accent: hsl(var(--hue), var(--sat), 75%); - --text-hue: 39; - --text-sat: 70%; - --text: hsl(var(--text-hue), var(--text-sat), 88%); - --debug: hsl(290, var(--text-sat), 55%); - --info: hsl(215, var(--text-sat), 55%); - --warning: hsl(45, var(--text-sat), 55%); - --error: hsl(355, var(--text-sat), 55%); - font-family: Helvetica, Arial, sans-serif; - background: var(--main-bg); - color: var(--text); - max-width: 60em; - margin: 2em auto; - > * { - margin: 0 1em; - } -} -textarea, -pre { - padding: 0.8em 1em; -} -a { - color: var(--accent); - &:hover { - color: hsl(var(--hue), var(--sat), 90%); - } -} -textarea, -select { - color: inherit; - &:focus { - border-color: var(--accent); - outline: none; - } -} -textarea { - background: var(--input-bg); - resize: vertical; - border: solid 1px var(--border); -} -pre { - margin: 0; - white-space: pre-wrap; -} -select { - background: var(--input-bg); - border: solid 1px var(--border); - padding: 0.5em 1em; -} -body[data-compiling] { - & .output { - opacity: 0.5; - } -} -.main { - display: grid; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto repeat(3, minmax(2.5em, auto)); - gap: 1em 1em; - > h2 { - text-align: right; - } - > * { - margin: 0; - } -} -.header { - grid-column: 1 / -1; - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1em; - > h1 { - padding-left: 3em; - margin: 0; - } -} -#input, -#examples, -.output { - border-radius: 0.3em; -} -.output { - border: solid 1px var(--border); - background: var(--alt-bg); - > :not(:last-child) { - border-bottom: solid 1px var(--border); - } -} -#json { - padding: 1em; -} -.diag-debug { - --diag-colour: var(--debug); -} -.diag-info { - --diag-colour: var(--info); -} -.diag-warning { - --diag-colour: var(--warning); -} -.diag-error { - --diag-colour: var(--error); -} -[class^="ann-"] { - color: var(--diag-colour); - font-weight: bold; -} diff --git a/examples/sync-demo/.gitignore b/examples/sync-demo/.gitignore deleted file mode 100644 index f06235c4..00000000 --- a/examples/sync-demo/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist diff --git a/examples/sync-demo/README.md b/examples/sync-demo/README.md deleted file mode 100644 index 071626c7..00000000 --- a/examples/sync-demo/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Coln + automerge-repo web demo - -This demo stores a compiler-generated Coln realm in `automerge-repo` using a -custom document type. Custom document types aren't actually a thing in -`automerge-repo` (yet), so we use a vendored version of `automerge-repo` from -the `doctypes` branch which adds support for custom document types. - -We define a function which takes a compiled FFI bindings from compiler -and turns it into an automerge-repo document type. This is the `colnDocType` -function. - -Then, we can create and find handles containing these documents, modify and -render them, and synchronize them using the public subduction sycn server. -That's what most of the code here does. - -## Compiling the schema - -The demo source schema is: - -- `graph.coln` - -The generated FFI/IR artifacts live in: - -- `src/generated/Graph.ts` -- `src/generated/GraphRealm.ts` -- `src/generated/GraphRealm.json` - -`GraphRealm.ts` exports the generated `View`, `Transaction`, and `schema`. The -generic `src/colnDocType.ts` consumes that compiled FFI directly: - -```ts -const coln = colnDocType(GraphRealm) -``` - -`GraphRealm.json` is passed directly to `StoreHandle.fromTheory(...)`; no local -IR-to-FlatTheory translation is needed anymore. - -You can compile them by running `./compile.sh` - -## Run - -From `coln/examples/sync-demo/`: - -```bash -pnpm install -pnpm dev -``` - -Open the Vite URL, then open the hash URL shown in the page in another tab. Each -tab creates its own Repo and syncs via Subduction. - -## Build - -```bash -pnpm build -``` - -The build script runs: - -1. `./compile.sh` to regenerate `src/generated/*` from `graph.coln` -2. `tsc --noEmit` -3. `vite build` - -## Test - -```bash -pnpm exec playwright install chromium # first time only -pnpm test:e2e -``` diff --git a/examples/sync-demo/compile.sh b/examples/sync-demo/compile.sh deleted file mode 100755 index f19b6bd8..00000000 --- a/examples/sync-demo/compile.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if command -v coln >/dev/null 2>&1; then - coln_cli=(coln) -elif command -v coln-cli >/dev/null 2>&1; then - coln_cli=(coln-cli) -else - coln_cli=(cabal run coln-cli --) -fi - -"${coln_cli[@]}" generate-ts ./graph.coln --output-dir ./src/generated -"${coln_cli[@]}" generate-ir ./graph.coln --output-dir ./src/generated - diff --git a/examples/sync-demo/package.json b/examples/sync-demo/package.json deleted file mode 100644 index eaf37cb6..00000000 --- a/examples/sync-demo/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "coln-sync-demo", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --host 0.0.0.0", - "server": "node server.mjs", - "compile": "bash ./compile.sh", - "build": "pnpm run compile && tsc --noEmit && vite build", - "test:e2e": "playwright test" - }, - "dependencies": { - "@automerge/automerge-repo": "file:./vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz", - "@automerge/automerge-subduction": "0.16.0", - "@coln-project/coln-js-runtime": "link:../../packages/coln-js-runtime", - "@coln-project/runtime": "link:../../packages/coln-js-runtime", - "@vitejs/plugin-react": "^6.0.2", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "typescript": "^6.0.3", - "vite": "^8.0.14", - "vite-plugin-wasm": "^3.6.0", - "ws": "^8.21.0" - }, - "devDependencies": { - "@playwright/test": "^1.61.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3" - } -} diff --git a/examples/sync-demo/pnpm-lock.yaml b/examples/sync-demo/pnpm-lock.yaml deleted file mode 100644 index b77306e2..00000000 --- a/examples/sync-demo/pnpm-lock.yaml +++ /dev/null @@ -1,847 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@automerge/automerge-repo': - specifier: file:./vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz - version: file:vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz - '@automerge/automerge-subduction': - specifier: 0.16.0 - version: 0.16.0 - '@coln-project/coln-js-runtime': - specifier: link:../../packages/coln-js-runtime - version: link:../../packages/coln-js-runtime - '@coln-project/runtime': - specifier: link:../../packages/coln-js-runtime - version: link:../../packages/coln-js-runtime - '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16) - react: - specifier: ^19.2.6 - version: 19.2.7 - react-dom: - specifier: ^19.2.6 - version: 19.2.7(react@19.2.7) - typescript: - specifier: ^6.0.3 - version: 6.0.3 - vite: - specifier: ^8.0.14 - version: 8.0.16 - vite-plugin-wasm: - specifier: ^3.6.0 - version: 3.6.0(vite@8.0.16) - ws: - specifier: ^8.21.0 - version: 8.21.0 - devDependencies: - '@playwright/test': - specifier: ^1.61.0 - version: 1.61.0 - '@types/react': - specifier: ^19.2.17 - version: 19.2.17 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) - -packages: - - '@automerge/automerge-repo@file:vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz': - resolution: {integrity: sha512-zz6O4jJTKHy1bncoGn2pq0OKfW0DxkCJ5ccjoPiaya5v0gDyMnqbEpB5/UU5NJ7TKIBXU97NutNTFJDHWvwdvg==, tarball: file:vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz} - version: 2.6.0-subduction.33 - engines: {node: '>=22.13'} - - '@automerge/automerge-subduction@0.16.0': - resolution: {integrity: sha512-BOOn/4p8cF4LhjDUQdgM3qyZFya+M7ZuZywuC99x/kbhk4UK7dw/QcsifcHG5E1TAW4R/K17ZGFUC5nCVUSSWg==} - - '@automerge/automerge@3.3.0-fragments.1': - resolution: {integrity: sha512-Im2n3qcYSuIsLDSjofTRo7CZyyvFn5pKLSCCO5sjWzESw+9q9G25ZIrL2mczDX43cXtyzi+w/4b9VUaiNdY56Q==} - - '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': - resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} - cpu: [arm64] - os: [darwin] - - '@cbor-extract/cbor-extract-darwin-x64@2.2.2': - resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} - cpu: [x64] - os: [darwin] - - '@cbor-extract/cbor-extract-linux-arm64@2.2.2': - resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} - cpu: [arm64] - os: [linux] - - '@cbor-extract/cbor-extract-linux-arm@2.2.2': - resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} - cpu: [arm] - os: [linux] - - '@cbor-extract/cbor-extract-linux-x64@2.2.2': - resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} - cpu: [x64] - os: [linux] - - '@cbor-extract/cbor-extract-win32-x64@2.2.2': - resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} - cpu: [x64] - os: [win32] - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - - '@playwright/test@1.61.0': - resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} - engines: {node: '>=18'} - hasBin: true - - '@rolldown/binding-android-arm64@1.0.3': - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.3': - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.3': - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.3': - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.3': - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.3': - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.3': - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.3': - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.3': - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.3': - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.3': - resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.3': - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - - '@vitejs/plugin-react@6.0.2': - resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 - babel-plugin-react-compiler: ^1.0.0 - vite: ^8.0.0 - peerDependenciesMeta: - '@rolldown/plugin-babel': - optional: true - babel-plugin-react-compiler: - optional: true - - base-x@5.0.1: - resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} - - bs58@6.0.0: - resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} - - bs58check@4.0.0: - resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} - - cbor-extract@2.2.2: - resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} - hasBin: true - - cbor-x@1.6.4: - resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - fast-sha256@1.3.0: - resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - isomorphic-ws@5.0.0: - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-gyp-build-optional-packages@5.1.1: - resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} - hasBin: true - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - playwright-core@1.61.0: - resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.61.0: - resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} - engines: {node: '>=18'} - hasBin: true - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} - peerDependencies: - react: ^19.2.7 - - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} - engines: {node: '>=0.10.0'} - - rolldown@1.0.3: - resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} - hasBin: true - - vite-plugin-wasm@3.6.0: - resolution: {integrity: sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==} - peerDependencies: - vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - - vite@8.0.16: - resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xstate@5.32.1: - resolution: {integrity: sha512-IGX9q5vEOplWjVq79edfgjJfVV/lXCup9p/fQGgUabAveZrlRwWJ/mC2iZEE7wswXbWITBCoS7gmoFfcuWAwsQ==} - -snapshots: - - '@automerge/automerge-repo@file:vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz': - dependencies: - '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-subduction': 0.16.0 - bs58check: 4.0.0 - cbor-x: 1.6.4 - debug: 4.4.3 - eventemitter3: 5.0.4 - fast-sha256: 1.3.0 - isomorphic-ws: 5.0.0(ws@8.21.0) - uuid: 14.0.0 - ws: 8.21.0 - xstate: 5.32.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@automerge/automerge-subduction@0.16.0': {} - - '@automerge/automerge@3.3.0-fragments.1': {} - - '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': - optional: true - - '@cbor-extract/cbor-extract-darwin-x64@2.2.2': - optional: true - - '@cbor-extract/cbor-extract-linux-arm64@2.2.2': - optional: true - - '@cbor-extract/cbor-extract-linux-arm@2.2.2': - optional: true - - '@cbor-extract/cbor-extract-linux-x64@2.2.2': - optional: true - - '@cbor-extract/cbor-extract-win32-x64@2.2.2': - optional: true - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@noble/hashes@1.8.0': {} - - '@oxc-project/types@0.133.0': {} - - '@playwright/test@1.61.0': - dependencies: - playwright: 1.61.0 - - '@rolldown/binding-android-arm64@1.0.3': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.3': - optional: true - - '@rolldown/binding-darwin-x64@1.0.3': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.3': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.3': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.3': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.3': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.3': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.3': - optional: true - - '@rolldown/pluginutils@1.0.1': {} - - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/react-dom@19.2.3(@types/react@19.2.17)': - dependencies: - '@types/react': 19.2.17 - - '@types/react@19.2.17': - dependencies: - csstype: 3.2.3 - - '@vitejs/plugin-react@6.0.2(vite@8.0.16)': - dependencies: - '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16 - - base-x@5.0.1: {} - - bs58@6.0.0: - dependencies: - base-x: 5.0.1 - - bs58check@4.0.0: - dependencies: - '@noble/hashes': 1.8.0 - bs58: 6.0.0 - - cbor-extract@2.2.2: - dependencies: - node-gyp-build-optional-packages: 5.1.1 - optionalDependencies: - '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 - '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 - '@cbor-extract/cbor-extract-linux-arm': 2.2.2 - '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 - '@cbor-extract/cbor-extract-linux-x64': 2.2.2 - '@cbor-extract/cbor-extract-win32-x64': 2.2.2 - optional: true - - cbor-x@1.6.4: - optionalDependencies: - cbor-extract: 2.2.2 - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - detect-libc@2.1.2: {} - - eventemitter3@5.0.4: {} - - fast-sha256@1.3.0: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fsevents@2.3.2: - optional: true - - fsevents@2.3.3: - optional: true - - isomorphic-ws@5.0.0(ws@8.21.0): - dependencies: - ws: 8.21.0 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - node-gyp-build-optional-packages@5.1.1: - dependencies: - detect-libc: 2.1.2 - optional: true - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - playwright-core@1.61.0: {} - - playwright@1.61.0: - dependencies: - playwright-core: 1.61.0 - optionalDependencies: - fsevents: 2.3.2 - - postcss@8.5.15: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - react-dom@19.2.7(react@19.2.7): - dependencies: - react: 19.2.7 - scheduler: 0.27.0 - - react@19.2.7: {} - - rolldown@1.0.3: - dependencies: - '@oxc-project/types': 0.133.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.3 - '@rolldown/binding-darwin-arm64': 1.0.3 - '@rolldown/binding-darwin-x64': 1.0.3 - '@rolldown/binding-freebsd-x64': 1.0.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.3 - '@rolldown/binding-linux-arm64-musl': 1.0.3 - '@rolldown/binding-linux-ppc64-gnu': 1.0.3 - '@rolldown/binding-linux-s390x-gnu': 1.0.3 - '@rolldown/binding-linux-x64-gnu': 1.0.3 - '@rolldown/binding-linux-x64-musl': 1.0.3 - '@rolldown/binding-openharmony-arm64': 1.0.3 - '@rolldown/binding-wasm32-wasi': 1.0.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.3 - '@rolldown/binding-win32-x64-msvc': 1.0.3 - - scheduler@0.27.0: {} - - source-map-js@1.2.1: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tslib@2.8.1: - optional: true - - typescript@6.0.3: {} - - uuid@14.0.0: {} - - vite-plugin-wasm@3.6.0(vite@8.0.16): - dependencies: - vite: 8.0.16 - - vite@8.0.16: - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - fsevents: 2.3.3 - - ws@8.21.0: {} - - xstate@5.32.1: {} diff --git a/examples/sync-demo/pnpm-workspace.yaml b/examples/sync-demo/pnpm-workspace.yaml deleted file mode 100644 index f91e0fa2..00000000 --- a/examples/sync-demo/pnpm-workspace.yaml +++ /dev/null @@ -1,5 +0,0 @@ -allowBuilds: - cbor-extract: false -minimumReleaseAgeExclude: - - '@automerge/automerge-repo@2.6.0-subduction.33' - - '@automerge/automerge-subduction@0.16.0' diff --git a/examples/sync-demo/src/App.tsx b/examples/sync-demo/src/App.tsx deleted file mode 100644 index 9b8faa63..00000000 --- a/examples/sync-demo/src/App.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react" -import type { RowRef, RowView, Value } from "@coln-project/runtime" -import type { CrdtDocHandle } from "@automerge/automerge-repo/slim" -import * as GraphRealm from "./generated/GraphRealm.ts" -import { - refId, - rowValue, - type ColnDocType, - type ColnDocument, -} from "./colnDocType" -import "./style.css" - -type GraphFfi = typeof GraphRealm -type GraphDocType = ColnDocType -type GraphDoc = ColnDocument - -type Props = { - handle: CrdtDocHandle -} - -type UiGraph = { id: string; ref: RowRef | null } -type UiVertex = { - id: string - ref: RowRef - graphId: string - graph: RowRef | null -} -type UiEdge = { - id: string - ref: RowRef - graphId: string - graph: RowRef | null - fromId: string - from: RowRef - toId: string - to: RowRef -} - -type UiGraphProjection = { - graphs: UiGraph[] - vertices: UiVertex[] - edges: UiEdge[] - heads: string[] -} - -const SYNTHETIC_GRAPH_ID = "GraphRealm" - -export function App({ handle }: Props) { - const [doc, setDoc] = useState(() => handle.doc()) - const graph = useMemo(() => projectGraph(doc), [doc]) - const [selectedGraphId, setSelectedGraphId] = useState("") - const [fromId, setFromId] = useState("") - const [toId, setToId] = useState("") - const [error, setError] = useState("") - - useEffect(() => { - const update = () => setDoc(handle.doc()) - handle.on("change", update) - handle.on("heads-changed", update) - return () => { - handle.off("change", update) - handle.off("heads-changed", update) - } - }, [handle]) - - const selectedGraph = useMemo( - () => graph.graphs.find(graph => graph.id === selectedGraphId) ?? graph.graphs[0], - [graph.graphs, selectedGraphId] - ) - - useEffect(() => { - if (!selectedGraph) { - setSelectedGraphId("") - return - } - if (selectedGraph.id !== selectedGraphId) setSelectedGraphId(selectedGraph.id) - }, [selectedGraph, selectedGraphId]) - - const vertices = useMemo( - () => graph.vertices.filter(vertex => vertex.graphId === selectedGraph?.id), - [graph.vertices, selectedGraph] - ) - const edges = useMemo( - () => graph.edges.filter(edge => edge.graphId === selectedGraph?.id), - [graph.edges, selectedGraph] - ) - - useEffect(() => { - if (!vertices.some(vertex => vertex.id === fromId)) setFromId(vertices[0]?.id ?? "") - if (!vertices.some(vertex => vertex.id === toId)) setToId(vertices[1]?.id ?? vertices[0]?.id ?? "") - }, [vertices, fromId, toId]) - - const run = (fn: () => void) => { - setError("") - try { - fn() - } catch (e) { - setError(e instanceof Error ? e.message : String(e)) - } - } - - const addVertex = (graph: UiGraph | undefined) => - run(() => { - if (!graph) throw new Error("Create/select a graph first") - handle.change(tx => { - tx.root.V.add() - }) - }) - - const addEdge = () => - run(() => { - if (!selectedGraph) throw new Error("Create/select a graph first") - const from = vertices.find(vertex => vertex.id === fromId) - const to = vertices.find(vertex => vertex.id === toId) - if (!from || !to) throw new Error("Select two vertices first") - handle.change(tx => { - tx.root.E(rowValue(from.ref))(rowValue(to.ref)).add() - }) - }) - - return ( -
-
-

Coln + automerge-repo

-

- Open this URL in another tab, then edit from either side. -

- e.currentTarget.select()} /> -
- - {error &&
{error}
} - -
- - -
- -
- - - -
- -
- - {graph.graphs.length === 0 ? ( -

No visible graphs yet. The document may still be syncing.

- ) : ( -
    - {graph.graphs.map((graph, index) => ( -
  • Graph {index + 1}: {shortId(graph.id)}
  • - ))} -
- )} -
- - - {vertices.length === 0 ? ( -

No vertices in this graph.

- ) : ( -
    - {vertices.map((vertex, index) => ( -
  • {vertexLabel(vertex, vertices, index)}
  • - ))} -
- )} -
- - - {edges.length === 0 ? ( -

No edges in this graph.

- ) : ( -
    - {edges.map(edge => ( -
  • - {vertexName(edge.fromId, vertices)} → {vertexName(edge.toId, vertices)} -
  • - ))} -
- )} -
-
- -
- {graph.heads.length} head{graph.heads.length === 1 ? "" : "s"} - {graph.vertices.length} visible vertices - {graph.edges.length} visible edges -
-
- ) -} - -function projectGraph(doc: GraphDoc): UiGraphProjection { - const vertexRows = iteratorToArray(doc.realm.root.V.values()) - const vertices = vertexRows.map(row => { - const ref = valueRef(row.rowId) - if (!ref) throw new Error("vertex row is missing a row id") - return { - id: refId(ref), - ref, - graphId: SYNTHETIC_GRAPH_ID, - graph: null, - } - }) - const visibleVertexIds = new Set(vertices.map(vertex => vertex.id)) - - return { - graphs: [{ id: SYNTHETIC_GRAPH_ID, ref: null }], - vertices, - edges: doc.store - .scanTable("GraphRealm.E") - .flatMap(row => projectEdge(row, visibleVertexIds)), - heads: doc.heads, - } -} - -function projectEdge(row: RowView, visibleVertexIds: Set): UiEdge[] { - const from = valueRef(row.values[0]) - const to = valueRef(row.values[1]) - if (!from || !to) return [] - - const fromId = refId(from) - const toId = refId(to) - if (!visibleVertexIds.has(fromId) || !visibleVertexIds.has(toId)) return [] - - const ref = valueRef(row.rowId) - if (!ref) return [] - - return [ - { - id: refId(ref), - ref, - graphId: SYNTHETIC_GRAPH_ID, - graph: null, - fromId, - from, - toId, - to, - }, - ] -} - -function iteratorToArray(iterator: Iterator): T[] { - const values: T[] = [] - for (let next = iterator.next(); !next.done; next = iterator.next()) { - values.push(next.value) - } - return values -} - -function valueRef(value: Value | undefined): RowRef | undefined { - return value?.tag === "row_id" ? value.value : undefined -} - -function VertexSelect({ - testId, - vertices, - value, - onChange, -}: { - testId: string - vertices: UiVertex[] - value: string - onChange(value: string): void -}) { - return ( - - ) -} - -function Panel({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ) -} - -function vertexLabel(vertex: UiVertex, vertices: UiVertex[], fallbackIndex: number) { - return `${vertexName(vertex.id, vertices, fallbackIndex)} · ${shortId(vertex.id)}` -} - -function vertexName(id: string, vertices: UiVertex[], fallbackIndex = -1) { - const index = vertices.findIndex(vertex => vertex.id === id) - return `V${(index >= 0 ? index : fallbackIndex) + 1}` -} - -function shortId(id: string) { - return id.slice(0, 8) -} diff --git a/examples/sync-demo/src/colnDocType.ts b/examples/sync-demo/src/colnDocType.ts deleted file mode 100644 index a27f520a..00000000 --- a/examples/sync-demo/src/colnDocType.ts +++ /dev/null @@ -1,161 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Coln contributors -// -// SPDX-License-Identifier: Apache-2.0 OR MIT - -import { - StoreHandle, - type CommitChunk, - type RowRef, - type TransactionHandle, - type Value, -} from "@coln-project/runtime" -import { - defineDocumentType, - type DocumentType, - type DocumentTypeContext, - type SedimentreeMeta, -} from "@automerge/automerge-repo/slim" - -export type RowIdValue = Extract - -export interface ColnState { - store: StoreHandle - actor: string -} - -export type ColnFfi = { - schema: unknown - View: new (store: StoreHandle) => View - Transaction: new (store: StoreHandle, transaction: TransactionHandle) => Transaction -} - -export type AnyColnFfi = ColnFfi -export type ColnFfiView = Ffi extends ColnFfi ? View : never -export type ColnFfiTransaction = Ffi extends ColnFfi ? Transaction : never - -export type ColnDocument = { - realm: ColnFfiView - store: StoreHandle - heads: string[] -} - -export type ColnChange = (tx: ColnFfiTransaction) => void - -export type ColnDocType< - Ffi extends AnyColnFfi, - View = ColnDocument, - Change = ColnChange, - Init = undefined, -> = DocumentType - -export type ColnDocTypeOptions< - Ffi extends AnyColnFfi, - View = ColnDocument, - Change = ColnChange, - Init = undefined, -> = { - name?: string - view?: (doc: ColnDocument, state: ColnState) => View - change?: (tx: ColnFfiTransaction, change: Change, state: ColnState) => void - init?: (init: Init, tx: ColnFfiTransaction, state: ColnState) => void - hasData?: (state: ColnState) => boolean -} - -export function colnDocType< - Ffi extends AnyColnFfi, - View = ColnDocument, - Change = ColnChange, - Init = undefined, ->( - ffi: Ffi, - options: ColnDocTypeOptions = {} -): ColnDocType { - const schemaJson = JSON.stringify(ffi.schema) - const projectView = options.view ?? ((doc: ColnDocument) => doc as unknown as View) - const applyChange = - options.change ?? - ((tx: ColnFfiTransaction, change: Change) => { - ;(change as unknown as ColnChange)(tx) - }) - - const makeState = (ctx: DocumentTypeContext): ColnState => ({ - store: StoreHandle.fromTheory(schemaJson), - actor: ctx.peerId, - }) - - const makeDocument = (state: ColnState): ColnDocument => ({ - realm: new ffi.View(state.store), - store: state.store, - heads: state.store.heads(), - }) - - const runTransaction = ( - state: ColnState, - body: (tx: ColnFfiTransaction) => void - ): ColnState => { - const tx = state.store.beginTransaction() - try { - const typedTx = new ffi.Transaction(state.store, tx) - body(typedTx) - const result = tx.commit() - return { ...state, store: result.takeStore() } - } catch (error) { - try { - state.store = tx.takeStore() - } catch { - // ignore; preserve the original error - } - throw error - } - } - - return defineDocumentType({ - name: options.name ?? "coln", - empty: ctx => makeState(ctx), - init: (init, ctx) => { - const state = makeState(ctx) - return options.init - ? runTransaction(state, tx => options.init?.(init, tx, state)) - : state - }, - view: state => projectView(makeDocument(state), state), - change: (state, change) => - runTransaction(state, tx => applyChange(tx, change, state)), - heads: state => state.store.heads(), - hasData: state => options.hasData?.(state) ?? state.store.heads().length > 0, - sedimentree: { - metadata: state => chunks(state).map(chunkToMeta), - materialize: (state, metas) => { - const wanted = new Set(metas.map(meta => meta.head)) - return chunks(state) - .filter(chunk => wanted.has(chunk.hash)) - .map(chunk => ({ ...chunkToMeta(chunk), bytes: new Uint8Array(chunk.bytes) })) - }, - apply: (state, blobs) => { - if (blobs.length > 0) { - state.store.applyChunkBytes(blobs.map(blob => Array.from(blob))) - } - return state - }, - liveHashes: state => chunks(state).map(chunk => chunk.hash), - }, - }) -} - -export function rowValue(ref: RowRef | null): RowIdValue { - if (!ref) throw new Error("cannot convert a synthetic row to a row_id value") - return { tag: "row_id", value: ref } -} - -export function refId(ref: RowRef): string { - if ("existing" in ref) return `${ref.existing.commit}:${ref.existing.counter}` - return `pending:${ref.pending.txId}:${ref.pending.counter}` -} - -function chunks(state: ColnState): CommitChunk[] { - return state.store.commitChunksAfter([]) -} - -function chunkToMeta(chunk: CommitChunk): SedimentreeMeta { - return { kind: "commit", head: chunk.hash, parents: chunk.parents } -} diff --git a/examples/sync-demo/src/generated/GraphRealm.json b/examples/sync-demo/src/generated/GraphRealm.json deleted file mode 100644 index 7ca1beae..00000000 --- a/examples/sync-demo/src/generated/GraphRealm.json +++ /dev/null @@ -1 +0,0 @@ -{"entities":[{"path":[["GraphRealm"],["E"]],"value":{"entityVariant":{"tag":"table"},"columns":[{"path":[["a"]],"type":{"tag":"rowId","path":[["GraphRealm"],["V"]]}},{"path":[["b"]],"type":{"tag":"rowId","path":[["GraphRealm"],["V"]]}}],"primaryKey":null}},{"path":[["GraphRealm"],["V"]],"value":{"entityVariant":{"tag":"table"},"columns":[],"primaryKey":null}}],"rules":[{"path":[["GraphRealm"],["E"],["foreignKey"]],"value":{"ruleVariant":"enforced","varNames":[[["a"]],[["b"]]],"varTypes":[{"tag":"rowId","path":[["GraphRealm"],["V"]]},{"tag":"rowId","path":[["GraphRealm"],["V"]]}],"antecedents":[{"tag":"atom","atom":{"entity":[["GraphRealm"],["E"]],"rowId":null,"values":[{"column":0,"term":{"tag":"var","index":0}},{"column":1,"term":{"tag":"var","index":1}}]}}],"consequents":[{"tag":"atom","atom":{"entity":[["GraphRealm"],["V"]],"rowId":{"tag":"var","index":0},"values":[]}},{"tag":"atom","atom":{"entity":[["GraphRealm"],["V"]],"rowId":{"tag":"var","index":1},"values":[]}}]}},{"path":[["GraphRealm"],["V"],["foreignKey"]],"value":{"ruleVariant":"enforced","varNames":[],"varTypes":[],"antecedents":[{"tag":"atom","atom":{"entity":[["GraphRealm"],["V"]],"rowId":null,"values":[]}}],"consequents":[]}}]} \ No newline at end of file diff --git a/examples/sync-demo/src/main.tsx b/examples/sync-demo/src/main.tsx deleted file mode 100644 index 16931209..00000000 --- a/examples/sync-demo/src/main.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react" -import ReactDOM from "react-dom/client" -import { - Repo, - isValidAutomergeUrl, - type CrdtDocHandle, -} from "@automerge/automerge-repo" -// @ts-ignore initSync is exported at runtime but may be missing from declarations -import { initSync } from "@automerge/automerge-subduction/slim" -// @ts-ignore wasm-base64 may not have declarations -import { wasmBase64 } from "@automerge/automerge-subduction/wasm-base64" -import { App } from "./App" -import { colnDocType, type ColnDocType } from "./colnDocType" -import * as GraphRealm from "./generated/GraphRealm.ts" - -initSync({ module: Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0)) }) - -type GraphDocType = ColnDocType - -const coln = colnDocType(GraphRealm) -const repo = new Repo({ - subductionWebsocketEndpoints: ["wss://subduction.sync.inkandswitch.com"], -}) - -const hashUrl = location.hash.slice(1) -let handle: CrdtDocHandle - -if (isValidAutomergeUrl(hashUrl)) { - handle = await repo.find(hashUrl, coln) -} else { - handle = repo.create(undefined, coln) -} - -location.hash = handle.url - -Object.assign(window, { repo, handle }) - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - -) diff --git a/examples/sync-demo/src/style.css b/examples/sync-demo/src/style.css deleted file mode 100644 index a1591adb..00000000 --- a/examples/sync-demo/src/style.css +++ /dev/null @@ -1,158 +0,0 @@ -:root { - color-scheme: light dark; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - line-height: 1.5; - font-weight: 400; - background: #111827; - color: #f9fafb; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 320px; - min-height: 100vh; -} - -button, -select, -input { - font: inherit; -} - -button { - border: 0; - border-radius: 10px; - padding: 0.6rem 0.9rem; - color: #08111f; - background: #67e8f9; - font-weight: 700; - cursor: pointer; -} - -button:hover { - background: #22d3ee; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -select, -input { - border: 1px solid #334155; - border-radius: 10px; - padding: 0.55rem 0.7rem; - background: #020617; - color: #f9fafb; -} - -main { - width: min(1100px, calc(100vw - 32px)); - margin: 0 auto; - padding: 32px 0; -} - -header { - margin-bottom: 24px; -} - -h1 { - margin: 0 0 8px; - font-size: clamp(2rem, 4vw, 3.5rem); - line-height: 1; -} - -h2 { - margin: 0 0 12px; -} - -p { - color: #cbd5e1; -} - -code { - color: #a7f3d0; -} - -.url { - width: 100%; - margin-top: 8px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.error { - margin: 16px 0; - padding: 12px 14px; - border: 1px solid #fca5a5; - border-radius: 12px; - color: #fecaca; - background: #450a0a; -} - -.toolbar { - display: flex; - flex-wrap: wrap; - align-items: end; - gap: 12px; - margin: 14px 0; - padding: 14px; - border: 1px solid #1f2937; - border-radius: 16px; - background: #0f172a; -} - -label { - display: grid; - gap: 4px; - color: #cbd5e1; - font-size: 0.9rem; -} - -.grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; - margin-top: 18px; -} - -.panel { - min-height: 180px; - padding: 18px; - border: 1px solid #1f2937; - border-radius: 18px; - background: #0f172a; -} - -ul { - margin: 0; - padding-left: 1.25rem; -} - -li { - margin: 0.35rem 0; - overflow-wrap: anywhere; -} - -.muted { - color: #94a3b8; -} - -footer { - display: flex; - gap: 14px; - flex-wrap: wrap; - margin-top: 18px; - color: #94a3b8; - font-size: 0.9rem; -} - -@media (max-width: 800px) { - .grid { - grid-template-columns: 1fr; - } -} diff --git a/examples/sync-demo/test-results/.last-run.json b/examples/sync-demo/test-results/.last-run.json deleted file mode 100644 index cbcc1fba..00000000 --- a/examples/sync-demo/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} \ No newline at end of file diff --git a/examples/sync-demo/tests/sync.spec.ts b/examples/sync-demo/tests/sync.spec.ts deleted file mode 100644 index c9efc9f6..00000000 --- a/examples/sync-demo/tests/sync.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Coln contributors -// -// SPDX-License-Identifier: Apache-2.0 OR MIT - -import { expect, test, type Page } from "@playwright/test" - -test("a tab opened after edits loads the coln graph", async ({ browser }) => { - const context = await browser.newContext() - const alice = await context.newPage() - - await alice.goto("/") - await expect(alice.getByTestId("doc-url")).toHaveValue(/^automerge:/) - await expect(alice.getByTestId("graph-item")).toHaveCount(1) - - await addTwoVerticesAndEdge(alice) - - const url = await alice.getByTestId("doc-url").inputValue() - const bob = await context.newPage() - await bob.goto(`/#${url}`) - - await expect(bob.getByTestId("doc-url")).toHaveValue(url) - await expect(bob.getByTestId("graph-item")).toHaveCount(1) - await expect(bob.getByTestId("vertex-item")).toHaveCount(2) - await expect(bob.getByTestId("edge-item")).toHaveCount(1) - - await bob.getByTestId("add-vertex").click() - await expect(bob.getByTestId("vertex-item")).toHaveCount(3) - await expect(alice.getByTestId("vertex-item")).toHaveCount(3) -}) - -test("a tab already open on the doc receives later coln edits", async ({ browser }) => { - const context = await browser.newContext() - const alice = await context.newPage() - - await alice.goto("/") - await expect(alice.getByTestId("doc-url")).toHaveValue(/^automerge:/) - await expect(alice.getByTestId("graph-item")).toHaveCount(1) - - const url = await alice.getByTestId("doc-url").inputValue() - const bob = await context.newPage() - await bob.goto(`/#${url}`) - await expect(bob.getByTestId("doc-url")).toHaveValue(url) - await expect(bob.getByTestId("graph-item")).toHaveCount(1) - - await addTwoVerticesAndEdge(alice) - - await expect(bob.getByTestId("vertex-item")).toHaveCount(2) - await expect(bob.getByTestId("edge-item")).toHaveCount(1) -}) - -async function addTwoVerticesAndEdge(page: Page) { - await page.getByTestId("add-vertex").click() - await page.getByTestId("add-vertex").click() - await expect(page.getByTestId("vertex-item")).toHaveCount(2) - - await selectByIndex(page, "from-select", 0) - await selectByIndex(page, "to-select", 1) - await page.getByTestId("add-edge").click() - await expect(page.getByTestId("edge-item")).toHaveCount(1) -} - -async function selectByIndex(page: Page, testId: string, index: number) { - const values = await page.getByTestId(testId).locator("option").evaluateAll(options => - options.map(option => (option as HTMLOptionElement).value) - ) - expect(values.length).toBeGreaterThan(index) - await page.getByTestId(testId).selectOption(values[index]) -} diff --git a/examples/sync-demo/vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz b/examples/sync-demo/vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz deleted file mode 100644 index 325ccbee..00000000 Binary files a/examples/sync-demo/vendor/automerge-automerge-repo-2.6.0-subduction.33.tgz and /dev/null differ diff --git a/flake.nix b/flake.nix index 860d0be0..071ca194 100644 --- a/flake.nix +++ b/flake.nix @@ -107,35 +107,6 @@ doCheck = false; }; - build-sync-demo = pkgs.writeShellApplication { - name = "build-sync-demo"; - runtimeInputs = [ - coln-cli - pkgs.binaryen - pkgs.esbuild - pkgs.nodejs_24 - pkgs.pnpm - rustToolchain - wasm-bindgen-cli - wasm-bodge - ]; - text = '' - repo_root="''${1:-$PWD}" - cd "$repo_root" - - export CI="''${CI:-1}" - pnpm_store_dir="''${PNPM_STORE_DIR:-$repo_root/.pnpm-store}" - - npm ci --prefix packages/coln-js-runtime - npm run --prefix packages/coln-js-runtime build - - pnpm --dir examples/sync-demo install --frozen-lockfile --store-dir "$pnpm_store_dir" - pnpm --dir examples/sync-demo build - - echo "Built sync demo at $repo_root/examples/sync-demo/dist" - ''; - }; - format-hs = nuShellCheck [pkgs.fourmolu] ./nix/checks/format-hs.nu; format-cabal = nuShellCheck [pkgs.haskellPackages.cabal-gild] ./nix/checks/format-cabal.nu; @@ -202,15 +173,6 @@ in { inherit packages; - apps = let - buildSyncDemo = { - type = "app"; - program = "${pkgs.lib.getExe packages.build-sync-demo}"; - }; - in { - build-sync-demo = buildSyncDemo; - sync-demo = buildSyncDemo; - }; devShells.default = pkgs.mkShell { name = "coln"; buildInputs = with pkgs; [ diff --git a/justfile b/justfile index 3e8b1a12..01a5aec6 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # just # list available recipes # just check # run all package checks -generated_file_pathspecs := "':(exclude,glob)packages/coln-compiler/test/golden/basic-ir/*.ts.output/**' ':(exclude,glob)packages/coln-compiler/test/golden/*.ts.output/**' ':(exclude,glob)examples/sync-demo/src/generated/**'" +generated_file_pathspecs := "':(exclude,glob)packages/coln-compiler/test/golden/basic-ir/*.ts.output/**' ':(exclude,glob)packages/coln-compiler/test/golden/*.ts.output/**' ':(exclude,glob)examples/lab/src/tools/graph/generated/**'" default: @just --list diff --git a/packages/coln-compiler-wasm/README.md b/packages/coln-compiler-wasm/README.md index b949daa2..33a941c3 100644 --- a/packages/coln-compiler-wasm/README.md +++ b/packages/coln-compiler-wasm/README.md @@ -1,3 +1,3 @@ # Coln compiler Wasm library -This package can be compiled with the [GHC WebAssembly backend](https://downloads.haskell.org/ghc/latest/docs/users_guide/wasm.html) to produce a [WASI reactor module](https://github.com/WebAssembly/WASI/blob/wasi-0.1/application-abi.md#current-unstable-abi). See [this example](../../examples/compiler-demo/index.html) for how to call it from JavaScript. The example app can be launched with `just examples/serve-web-compiler` from the project root. All necessary tools, including a Wasm-targeting GHC and Cabal, are provided by this repository's top-level Nix shell. +This package can be compiled with the [GHC WebAssembly backend](https://downloads.haskell.org/ghc/latest/docs/users_guide/wasm.html) to produce a [WASI reactor module](https://github.com/WebAssembly/WASI/blob/wasi-0.1/application-abi.md#current-unstable-abi). The [Lab](../../examples/lab/README.md) uses it from JavaScript and can be launched with `just examples/dev-web-lab` from the project root. All necessary tools, including a Wasm-targeting GHC and Cabal, are provided by this repository's top-level Nix shell. diff --git a/packages/coln-js-runtime/src/rust/handles.rs b/packages/coln-js-runtime/src/rust/handles.rs index fa475898..6446a8f6 100644 --- a/packages/coln-js-runtime/src/rust/handles.rs +++ b/packages/coln-js-runtime/src/rust/handles.rs @@ -4,7 +4,7 @@ use coln_flir_rs::ir; use coln_store::{ - commit::hash::CommitHash as StoreCommitHash, + commit::{chunk::Chunk, hash::CommitHash as StoreCommitHash}, store::Store, table::RowId as StoreRowId, txn::{OwnedTransaction, RowHandle as StoreRowHandle}, @@ -19,13 +19,26 @@ use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen] pub struct StoreHandle { - store: Option, + state: StoreHandleState, +} + +enum StoreHandleState { + Uninitialized { + chunks: Vec>, + has_root: bool, + }, + Ready { + store: Box, + pending_chunks: Vec>, + }, + Moved, } #[wasm_bindgen] pub struct TransactionHandle { tx: Option, recovered_store: Option, + pending_chunks: Vec>, pending_handles: Vec<(StoreRowHandle, JsValue)>, } @@ -94,7 +107,10 @@ impl TransactionHandle { Ok(CommitResult { commit: commit.to_string(), - store: Some(StoreHandle { store: Some(store) }), + store: Some(StoreHandle::ready_with_pending( + store, + std::mem::take(&mut self.pending_chunks), + )), }) } Err((err, store)) => { @@ -110,12 +126,21 @@ impl TransactionHandle { // after committing #[wasm_bindgen(js_name = takeStore)] pub fn take_store(&mut self) -> Result { + if let Some(tx) = self.tx.take() { + return Ok(StoreHandle::ready_with_pending( + tx.abort(), + std::mem::take(&mut self.pending_chunks), + )); + } let store = self .recovered_store .take() .ok_or_else(|| js_error("transaction does not have a recovered store"))?; - Ok(StoreHandle { store: Some(store) }) + Ok(StoreHandle::ready_with_pending( + store, + std::mem::take(&mut self.pending_chunks), + )) } } @@ -127,13 +152,22 @@ pub struct CommitResult { #[wasm_bindgen] impl StoreHandle { + pub fn empty() -> StoreHandle { + Self { + state: StoreHandleState::Uninitialized { + chunks: Vec::new(), + has_root: false, + }, + } + } + #[wasm_bindgen(js_name = fromTheory)] pub fn from_theory(flat_theory_json: String) -> Result { let theory = serde_json::from_str::(&flat_theory_json) .map_err(|err| js_error(format!("invalid flat theory JSON: {err}")))?; let store = Store::try_from_ir(theory).map_err(js_error)?; - Ok(Self { store: Some(store) }) + Ok(Self::ready(store)) } #[wasm_bindgen(js_name = jsonIR)] @@ -163,14 +197,27 @@ impl StoreHandle { #[wasm_bindgen(js_name = beginTransaction)] pub fn begin_transaction(&mut self) -> Result { - let store = self - .store - .take() - .ok_or_else(|| js_error("store handle has already been moved into a transaction"))?; + let state = std::mem::replace(&mut self.state, StoreHandleState::Moved); + let (store, pending_chunks) = match state { + StoreHandleState::Ready { + store, + pending_chunks, + } => (*store, pending_chunks), + state @ StoreHandleState::Uninitialized { .. } => { + self.state = state; + return Err(js_error("store handle has not been initialized")); + } + StoreHandleState::Moved => { + return Err(js_error( + "store handle has already been moved into a transaction", + )); + } + }; Ok(TransactionHandle { tx: Some(store.into_transaction()), recovered_store: None, + pending_chunks, pending_handles: Vec::new(), }) @@ -182,12 +229,19 @@ impl StoreHandle { // For automerge-repo interfacing pub fn heads(&self) -> Result, JsValue> { - let heads = self - .store()? - .heads() - .into_iter() - .map(CommitHash::from) - .collect::>(); + let heads = match &self.state { + StoreHandleState::Uninitialized { .. } => return Ok(Vec::new()), + StoreHandleState::Ready { store, .. } => store, + StoreHandleState::Moved => { + return Err(js_error( + "store handle has already been moved into a transaction", + )); + } + } + .heads() + .into_iter() + .map(CommitHash::from) + .collect::>(); Ok(heads) } @@ -197,6 +251,9 @@ impl StoreHandle { &self, have_heads: Vec, ) -> Result, JsValue> { + if matches!(self.state, StoreHandleState::Uninitialized { .. }) { + return Ok(Vec::new()); + } let have_heads = have_heads .into_iter() .map(StoreCommitHash::try_from) @@ -217,10 +274,7 @@ impl StoreHandle { pub fn apply_chunk_bytes(&mut self, chunk_bytes: JsValue) -> Result<(), JsValue> { let chunk_bytes = serde_wasm_bindgen::from_value::>>(chunk_bytes).map_err(js_error)?; - - self.store_mut()? - .apply_chunk_bytes(chunk_bytes) - .map_err(js_error) + self.apply_chunks(chunk_bytes).map_err(js_error) } } @@ -240,16 +294,76 @@ impl CommitResult { } impl StoreHandle { - fn store(&self) -> Result<&Store, JsValue> { - self.store - .as_ref() - .ok_or_else(|| js_error("store handle has been moved into a transaction")) + fn ready(store: Store) -> Self { + Self::ready_with_pending(store, Vec::new()) } - fn store_mut(&mut self) -> Result<&mut Store, JsValue> { - self.store - .as_mut() - .ok_or_else(|| js_error("store handle has been moved into a transaction")) + fn ready_with_pending(store: Store, pending_chunks: Vec>) -> Self { + Self { + state: StoreHandleState::Ready { + store: Box::new(store), + pending_chunks, + }, + } + } + + // TODO this function is doing causal order delivery. This logic should NOT + // be here, and should be moved to somewhere else in the future. + fn apply_chunks(&mut self, chunk_bytes: Vec>) -> Result<(), String> { + match &mut self.state { + StoreHandleState::Uninitialized { chunks, has_root } => { + let decoded = chunk_bytes + .iter() + .map(|bytes| Chunk::decode(bytes)) + .collect::, _>>() + .map_err(|error| error.to_string())?; + let previous_len = chunks.len(); + let previously_had_root = *has_root; + *has_root |= decoded.iter().any(Chunk::is_root); + chunks.extend(chunk_bytes); + if *has_root { + match Store::try_from_commit_bytes(chunks.iter()) { + Ok((store, pending)) => { + self.state = StoreHandle::ready_with_pending(store, pending).state + } + Err(error) => { + chunks.truncate(previous_len); + *has_root = previously_had_root; + return Err(error.to_string()); + } + } + } + Ok(()) + } + StoreHandleState::Ready { + store, + pending_chunks, + } => { + pending_chunks.extend(chunk_bytes); + match store.apply_chunk_bytes(pending_chunks.iter().cloned()) { + Ok(pending) => { + *pending_chunks = pending; + Ok(()) + } + Err(error) => Err(error.to_string()), + } + } + StoreHandleState::Moved => { + Err("store handle has already been moved into a transaction".into()) + } + } + } + + fn store(&self) -> Result<&Store, JsValue> { + match &self.state { + StoreHandleState::Uninitialized { .. } => { + Err(js_error("store handle has not been initialized")) + } + StoreHandleState::Ready { store, .. } => Ok(store), + StoreHandleState::Moved => Err(js_error( + "store handle has already been moved into a transaction", + )), + } } } @@ -260,3 +374,184 @@ impl TransactionHandle { .ok_or_else(|| js_error("transaction has already been committed")) } } + +#[cfg(test)] +mod tests { + use coln_flir_rs::ir::{ + BuiltinTy, ColType, ColumnEntry, EntityVariant, FlatRealm, Path, Schema, TableEntry, + }; + + use super::*; + + fn source_store() -> Store { + let theory = FlatRealm { + tables: vec![TableEntry { + path: Path::from("T"), + table: Schema { + entity_variant: EntityVariant::Table, + columns: vec![ColumnEntry { + path: Path::from("value"), + col_type: ColType::BuiltinTy { + builtin_ty: BuiltinTy::BuiltinInt, + }, + }], + primary_key: None, + }, + }], + rules: vec![], + }; + let mut store = Store::try_from_ir(theory).expect("store"); + let mut transaction = store.transaction(); + transaction + .add(&Path::from("T"), vec![42_i64.into()]) + .expect("add row"); + transaction.commit().expect("commit"); + store + } + + #[test] + fn empty_handle_buffers_data_until_root_arrives() { + let source = source_store(); + let (root, data): (Vec<_>, Vec<_>) = source + .commit_chunks_after(&[]) + .into_iter() + .map(|chunk| chunk.bytes) + .partition(|bytes| Chunk::decode(bytes).expect("chunk").is_root()); + let mut handle = StoreHandle::empty(); + + handle.apply_chunks(data).expect("buffer data"); + assert!(matches!( + handle.state, + StoreHandleState::Uninitialized { .. } + )); + assert!(handle.heads().expect("heads").is_empty()); + + handle.apply_chunks(root).expect("apply root"); + let store = handle.store().expect("initialized store"); + let table = store.table_at(&Path::from("T")).expect("table"); + assert_eq!(table.row_count(), 1); + } + + #[test] + fn empty_handle_retries_bootstrap_when_missing_parent_arrives() { + let mut source = source_store(); + let mut transaction = source.transaction(); + transaction + .add(&Path::from("T"), vec![84_i64.into()]) + .expect("add second row"); + transaction.commit().expect("second commit"); + + let mut root = None; + let mut data = Vec::new(); + for chunk in source.commit_chunks_after(&[]) { + if Chunk::decode(&chunk.bytes).expect("chunk").is_root() { + root = Some(chunk.bytes); + } else { + data.push(chunk.bytes); + } + } + assert_eq!(data.len(), 2); + + let mut handle = StoreHandle::empty(); + let child = data.pop().expect("child commit"); + handle + .apply_chunks(vec![root.expect("root"), child]) + .expect("buffer child"); + + handle.apply_chunks(data).expect("retry with parent"); + let table = handle + .store() + .expect("initialized store") + .table_at(&Path::from("T")) + .expect("table"); + assert_eq!(table.row_count(), 2); + } + + #[test] + fn ready_handle_retries_commit_when_missing_parent_arrives() { + let mut source = source_store(); + let mut transaction = source.transaction(); + transaction + .add(&Path::from("T"), vec![84_i64.into()]) + .expect("add second row"); + transaction.commit().expect("second commit"); + + let mut root = None; + let mut data = Vec::new(); + for chunk in source.commit_chunks_after(&[]) { + if Chunk::decode(&chunk.bytes).expect("chunk").is_root() { + root = Some(chunk.bytes); + } else { + data.push(chunk.bytes); + } + } + assert_eq!(data.len(), 2); + + let mut handle = StoreHandle::empty(); + handle + .apply_chunks(vec![root.expect("root")]) + .expect("apply root"); + assert!(matches!(handle.state, StoreHandleState::Ready { .. })); + + let child = data.pop().expect("child commit"); + handle.apply_chunks(vec![child]).expect("buffer child"); + let mut transaction = handle.begin_transaction().expect("begin transaction"); + handle = transaction.take_store().expect("abort transaction"); + handle.apply_chunks(data).expect("retry with parent"); + + let table = handle + .store() + .expect("initialized store") + .table_at(&Path::from("T")) + .expect("table"); + assert_eq!(table.row_count(), 2); + } + + #[test] + fn active_transaction_can_return_its_store_without_committing() { + let mut handle = StoreHandle::ready(source_store()); + let mut transaction = handle.begin_transaction().expect("transaction"); + transaction + .tx() + .expect("owned transaction") + .add(&Path::from("T"), vec![84_i64.into()]) + .expect("stage row"); + + let recovered = transaction.take_store().expect("recover store"); + let table = recovered + .store() + .expect("store") + .table_at(&Path::from("T")) + .expect("table"); + assert_eq!(table.row_count(), 1); + } + + #[test] + fn malformed_batch_does_not_poison_later_bootstrap() { + let source = source_store(); + let chunks = source + .commit_chunks_after(&[]) + .into_iter() + .map(|chunk| chunk.bytes) + .collect::>(); + let root = chunks + .iter() + .find(|bytes| Chunk::decode(bytes).expect("chunk").is_root()) + .expect("root") + .clone(); + let mut handle = StoreHandle::empty(); + + assert!(handle.apply_chunks(vec![root, vec![0xff]]).is_err()); + handle.apply_chunks(chunks).expect("valid retry"); + + assert_eq!( + handle + .store() + .expect("initialized store") + .table_at(&Path::from("T")) + .expect("table") + .row_count(), + 1 + ); + } +} diff --git a/packages/coln-js-runtime/src/ts/RealmBindings.ts b/packages/coln-js-runtime/src/ts/RealmBindings.ts new file mode 100644 index 00000000..88516c95 --- /dev/null +++ b/packages/coln-js-runtime/src/ts/RealmBindings.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { StoreHandle, TransactionHandle } from "#wasm-bodge/bindings" + +export interface ColnSchema { + entities: readonly unknown[] + rules: readonly unknown[] +} + +export interface RealmBindings { + schema: ColnSchema + View: new (store: StoreHandle) => { root: ViewRoot } + Transaction: new (store: StoreHandle, transaction: TransactionHandle) => { root: TransactionRoot } +} diff --git a/packages/coln-js-runtime/src/ts/index.ts b/packages/coln-js-runtime/src/ts/index.ts index 9d936a44..19e74047 100644 --- a/packages/coln-js-runtime/src/ts/index.ts +++ b/packages/coln-js-runtime/src/ts/index.ts @@ -5,6 +5,8 @@ export type { CommitChunk, RowRef, RowView, Value } from "#wasm-bodge/bindings"; export { CommitResult, StoreHandle, TransactionHandle, valueEqual } from "#wasm-bodge/bindings" +export type { RealmBindings, ColnSchema } from "./RealmBindings" + export * as ColnSet from "./ColnSet"; export * as ColnRef from "./ColnRef"; diff --git a/packages/coln-js-runtime/tests/basic-ir/helpers.ts b/packages/coln-js-runtime/tests/basic-ir/helpers.ts index fb0aeb10..be9e2327 100644 --- a/packages/coln-js-runtime/tests/basic-ir/helpers.ts +++ b/packages/coln-js-runtime/tests/basic-ir/helpers.ts @@ -2,19 +2,10 @@ // // SPDX-License-Identifier: Apache-2.0 OR MIT -import { StoreHandle, type TransactionHandle } from "@coln-project/runtime"; - -interface RealmModule { - schema: unknown; - View: new (store: StoreHandle) => { root: ViewRoot }; - Transaction: new ( - store: StoreHandle, - transaction: TransactionHandle, - ) => { root: TransactionRoot }; -} +import { StoreHandle, type RealmBindings } from "@coln-project/runtime"; export function beginRealm( - realm: RealmModule, + realm: RealmBindings, ) { let store = StoreHandle.fromTheory(JSON.stringify(realm.schema)); const transaction = store.beginTransaction(); diff --git a/packages/coln-repo/.gitignore b/packages/coln-repo/.gitignore new file mode 100644 index 00000000..fe30a0cf --- /dev/null +++ b/packages/coln-repo/.gitignore @@ -0,0 +1,4 @@ +dist/ +playwright-report/ +test-results/ +tests/browser/dist/ diff --git a/packages/coln-repo/.oxfmtrc.json b/packages/coln-repo/.oxfmtrc.json new file mode 100644 index 00000000..2764424d --- /dev/null +++ b/packages/coln-repo/.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 80, + "semi": false +} diff --git a/packages/coln-repo/README.md b/packages/coln-repo/README.md new file mode 100644 index 00000000..9aa0dd69 --- /dev/null +++ b/packages/coln-repo/README.md @@ -0,0 +1,77 @@ +# `@coln-project/repo` + +Coln Repo exposes Coln stores as [Automerge Repo](https://github.com/automerge/automerge-repo) document handles. Use them to create or find stores, read the current typed view, make atomic changes, and subscribe to changes to update application state or UI. Automerge Repo provides persistence and synchronization. + +Coln Repo currently requires the experimental [doctypes](https://github.com/automerge/automerge-repo/tree/doctypes) branch. + +## Create + +Create a Coln store using generated bindings. + +```ts +import * as Bindings from "./ExampleRealm.js" +import { create } from "@coln-project/repo" + +const handle = create(repo, Bindings) +``` + +## Find + +Find a Coln store by Automerge URL. Bindings are optional and must match the document schema when supplied. + +```ts +import * as Bindings from "./ExampleRealm.js" +import { find } from "@coln-project/repo" + +const handle = await find(repo, url, Bindings) +``` + +Apply bindings to a raw handle later if needed: + +```ts +import * as Bindings from "./ExampleRealm.js" +import { applyBindings, find } from "@coln-project/repo" + +const rawHandle = await find(repo, url) +const handle = applyBindings(rawHandle, Bindings) +``` + +> [!IMPORTANT] +> Once bindings are applied, later raw lookups in the same Repo may return that bound handle. It retains the raw Coln operations and adds `root` to documents and change transactions. + +## Read + +Read the current document synchronously or subscribe to changes. + +```ts +const doc = handle.doc() +doc.scanTable("Example.Items") +doc.root.Items.values() + +handle.on("change", ({ doc }) => { + doc?.scanTable("Example.Items") + doc?.root.Items.values() +}) +``` + +Documents expose `heads()`, `jsonIR()`, `rowById()`, and `scanTable()`. + +## Write + +Changes are synchronous and atomic. A thrown error aborts the transaction. + +```ts +handle.change((tx) => { + tx.root.Items.add() +}) +``` + +Transactions expose `add(path, values)`; bound transactions also expose `root`. + +The underlying `StoreHandle` is available through `handle.fullDoc().store` for advanced use. Its lifecycle operations bypass Coln Repo changes, events, and synchronization; prefer `doc()` and `change()`. + +## Current Limitations + +Values returned by `handle.doc()` are current documents, not durable snapshots. + +Generated read methods such as `has()` and `values()` are not supported inside `handle.change()`. Typed transactions currently support writes only. diff --git a/packages/coln-repo/package.json b/packages/coln-repo/package.json new file mode 100644 index 00000000..b73e558f --- /dev/null +++ b/packages/coln-repo/package.json @@ -0,0 +1,43 @@ +{ + "name": "@coln-project/repo", + "version": "0.0.1-alpha.0", + "private": true, + "license": "(Apache-2.0 OR MIT)", + "files": [ + "dist" + ], + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", + "typecheck": "tsc --noEmit", + "test": "pnpm run format:check && pnpm run typecheck && pnpm run test:unit && pnpm run test:e2e", + "test:unit": "tsx --test \"tests/**/*.test.ts\"", + "test:package": "pnpm run build && tsc -p tsconfig.package.json && node --input-type=module --eval \"const pkg = await import('@coln-project/repo'); if (typeof pkg.find !== 'function') throw new Error('missing find export')\"", + "test:e2e": "pnpm run test:package && playwright test" + }, + "dependencies": { + "@automerge/automerge-repo": "github:automerge/automerge-repo#doctypes&path:packages/automerge-repo", + "@coln-project/runtime": "link:../coln-js-runtime" + }, + "devDependencies": { + "@automerge/automerge-subduction": "0.16.1", + "@playwright/test": "^1.62.1", + "@types/node": "^25.9.5", + "@types/ws": "^8.18.1", + "oxfmt": "0.66.0", + "tsx": "^4.23.13", + "typescript": "^6.0.3", + "vite": "^8.2.2", + "vite-plugin-wasm": "^3.6.0", + "ws": "^8.21.3" + } +} diff --git a/examples/sync-demo/playwright.config.ts b/packages/coln-repo/playwright.config.ts similarity index 60% rename from examples/sync-demo/playwright.config.ts rename to packages/coln-repo/playwright.config.ts index 9374d023..9a741d42 100644 --- a/examples/sync-demo/playwright.config.ts +++ b/packages/coln-repo/playwright.config.ts @@ -4,28 +4,31 @@ import { defineConfig, devices } from "@playwright/test" +const webPort = Number(process.env.COLN_REPO_TEST_WEB_PORT ?? 5174) + export default defineConfig({ testDir: "./tests", + testMatch: "**/*.spec.ts", timeout: 30_000, expect: { timeout: 10_000 }, fullyParallel: false, retries: 0, use: { - baseURL: "http://127.0.0.1:5173", + baseURL: `http://127.0.0.1:${webPort}`, trace: "retain-on-failure", }, webServer: [ { - command: "pnpm server", - port: 3030, - reuseExistingServer: !process.env.CI, + command: "node tests/server.mjs", + port: 3031, + reuseExistingServer: false, stdout: "pipe", stderr: "pipe", }, { - command: "pnpm dev -- --port 5173", - url: "http://127.0.0.1:5173", - reuseExistingServer: !process.env.CI, + command: `./node_modules/.bin/vite --host 127.0.0.1 --port ${webPort}`, + url: `http://127.0.0.1:${webPort}`, + reuseExistingServer: false, stdout: "pipe", stderr: "pipe", }, diff --git a/packages/coln-repo/pnpm-lock.yaml b/packages/coln-repo/pnpm-lock.yaml new file mode 100644 index 00000000..c28899eb --- /dev/null +++ b/packages/coln-repo/pnpm-lock.yaml @@ -0,0 +1,1253 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + +importers: + + .: + dependencies: + '@automerge/automerge-repo': + specifier: github:automerge/automerge-repo#doctypes&path:packages/automerge-repo + version: https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo + '@coln-project/runtime': + specifier: link:../coln-js-runtime + version: link:../coln-js-runtime + devDependencies: + '@automerge/automerge-subduction': + specifier: 0.16.1 + version: 0.16.1 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^25.9.5 + version: 25.9.5 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + oxfmt: + specifier: 0.66.0 + version: 0.66.0 + tsx: + specifier: ^4.23.13 + version: 4.23.13 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@25.9.5)(esbuild@0.28.2)(tsx@4.23.13) + vite-plugin-wasm: + specifier: ^3.6.0 + version: 3.6.0(vite@8.2.2(@types/node@25.9.5)(esbuild@0.28.2)(tsx@4.23.13)) + ws: + specifier: ^8.21.3 + version: 8.21.3 + +packages: + + '@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo': + resolution: {gitHosted: true, integrity: sha512-9LCmV33S9KLTv/4mlMaRxJanF7Trh/ltok1o499dJanBJUt4AVpj1c+ZJlP4nm73/JeOsSUhW/rDr7mH393giA==, path: packages/automerge-repo, tarball: https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5} + version: 2.6.0-subduction.47 + engines: {node: '>=22.13'} + + '@automerge/automerge-subduction@0.16.1': + resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} + + '@automerge/automerge@3.3.2': + resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@oxfmt/binding-android-arm-eabi@0.66.0': + resolution: {integrity: sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.66.0': + resolution: {integrity: sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.66.0': + resolution: {integrity: sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.66.0': + resolution: {integrity: sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.66.0': + resolution: {integrity: sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': + resolution: {integrity: sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': + resolution: {integrity: sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.66.0': + resolution: {integrity: sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.66.0': + resolution: {integrity: sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': + resolution: {integrity: sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': + resolution: {integrity: sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.66.0': + resolution: {integrity: sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.66.0': + resolution: {integrity: sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.66.0': + resolution: {integrity: sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.66.0': + resolution: {integrity: sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.66.0': + resolution: {integrity: sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.66.0': + resolution: {integrity: sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.66.0': + resolution: {integrity: sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.66.0': + resolution: {integrity: sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + oxfmt@0.66.0: + resolution: {integrity: sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + vite-plugin-wasm@3.6.0: + resolution: {integrity: sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.32.6: + resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} + +snapshots: + + '@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.16.1': {} + + '@automerge/automerge@3.3.2': {} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@noble/hashes@1.8.0': {} + + '@oxc-project/types@0.147.0': {} + + '@oxfmt/binding-android-arm-eabi@0.66.0': + optional: true + + '@oxfmt/binding-android-arm64@0.66.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.66.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.66.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.66.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.66.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.66.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.66.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.66.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.66.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.66.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.66.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.66.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.66.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.66.0': + optional: true + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + + '@rolldown/binding-android-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.5 + + base-x@5.0.1: {} + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.6: + optionalDependencies: + cbor-extract: 2.2.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + eventemitter3@5.0.4: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + isomorphic-ws@5.0.0(ws@8.21.3): + dependencies: + ws: 8.21.3 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + oxfmt@0.66.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.66.0 + '@oxfmt/binding-android-arm64': 0.66.0 + '@oxfmt/binding-darwin-arm64': 0.66.0 + '@oxfmt/binding-darwin-x64': 0.66.0 + '@oxfmt/binding-freebsd-x64': 0.66.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.66.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.66.0 + '@oxfmt/binding-linux-arm64-gnu': 0.66.0 + '@oxfmt/binding-linux-arm64-musl': 0.66.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-musl': 0.66.0 + '@oxfmt/binding-linux-s390x-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-musl': 0.66.0 + '@oxfmt/binding-openharmony-arm64': 0.66.0 + '@oxfmt/binding-win32-arm64-msvc': 0.66.0 + '@oxfmt/binding-win32-ia32-msvc': 0.66.0 + '@oxfmt/binding-win32-x64-msvc': 0.66.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.1.0: {} + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + uuid@14.0.2: {} + + vite-plugin-wasm@3.6.0(vite@8.2.2(@types/node@25.9.5)(esbuild@0.28.2)(tsx@4.23.13)): + dependencies: + vite: 8.2.2(@types/node@25.9.5)(esbuild@0.28.2)(tsx@4.23.13) + + vite@8.2.2(@types/node@25.9.5)(esbuild@0.28.2)(tsx@4.23.13): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.6 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.5 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.13 + + ws@8.21.3: {} + + xstate@5.32.6: {} diff --git a/packages/coln-repo/pnpm-workspace.yaml b/packages/coln-repo/pnpm-workspace.yaml new file mode 100644 index 00000000..48974ebe --- /dev/null +++ b/packages/coln-repo/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +allowBuilds: + cbor-extract: false + "@automerge/automerge-repo@https://codeload.github.com/automerge/automerge-repo/tar.gz/7a8dbfe020aaeb73c7146d4b2b508212d37b71f5#path:packages/automerge-repo": true + esbuild: true +overrides: + "@automerge/automerge": 3.3.2 + "@automerge/automerge-subduction": 0.16.1 diff --git a/packages/coln-repo/src/colnDocType.ts b/packages/coln-repo/src/colnDocType.ts new file mode 100644 index 00000000..1b84ca73 --- /dev/null +++ b/packages/coln-repo/src/colnDocType.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { + StoreHandle, + type CommitChunk, + type ColnSchema, + type RealmBindings, + type TransactionHandle, +} from "@coln-project/runtime" +import { + defineDocumentType, + type SedimentreeMeta, +} from "@automerge/automerge-repo/slim" + +export type { ColnSchema, RealmBindings } from "@coln-project/runtime" + +export type ColnState = { + store: StoreHandle + bindings?: RealmBindings +} + +type ColnDocumentBase = Pick< + StoreHandle, + "heads" | "jsonIR" | "rowById" | "scanTable" +> +type ColnTransactionBase = Pick + +export type ColnDocument< + Bindings extends RealmBindings | undefined = undefined, +> = Bindings extends RealmBindings + ? ColnDocumentBase & Pick, "root"> + : ColnDocumentBase + +export type ColnTransaction< + Bindings extends RealmBindings | undefined = undefined, +> = Bindings extends RealmBindings + ? ColnTransactionBase & Pick, "root"> + : ColnTransactionBase + +export type ColnChange = + (transaction: ColnTransaction) => void + +export const colnDocType = defineDocumentType< + ColnState, + ColnDocumentBase, + ColnChange, + ColnSchema +>({ + name: "coln", + empty: () => ({ store: StoreHandle.empty() }), + init: (schema) => ({ + store: StoreHandle.fromTheory(serializeSchema(schema)), + }), + view: createDocument, + change: (state, change) => runTransaction(state, change), + heads: (state) => state.store.heads(), + hasData: (state) => state.store.heads().length > 0, + sedimentree: { + metadata: (state) => commitChunks(state).map(commitMetadata), + materialize: (state, metadata) => { + const wantedHeads = new Set(metadata.map((entry) => entry.head)) + // TODO: Add a runtime commitChunksByHash API if full-history scans become costly. + return commitChunks(state) + .filter((chunk) => wantedHeads.has(chunk.hash)) + .map((chunk) => ({ + ...commitMetadata(chunk), + bytes: new Uint8Array(chunk.bytes), + })) + }, + apply: (state, blobs) => { + if (blobs.length > 0) { + // TODO: "Add independent StoreHandle snapshots for Repo document states": + // apply blobs to a snapshot and return a new document. + state.store.applyChunkBytes(blobs) + } + return state + }, + liveHashes: (state) => commitChunks(state).map((chunk) => chunk.hash), + }, +}) + +function createDocument(state: ColnState): ColnDocumentBase { + const { store, bindings } = state + const document: ColnDocumentBase & { root?: unknown } = { + heads: () => store.heads(), + jsonIR: () => store.jsonIR(), + rowById: (path, rowId) => store.rowById(path, rowId), + scanTable: (path) => store.scanTable(path), + } + if (bindings) document.root = new bindings.View(store).root + return Object.freeze(document) +} + +function createTransaction( + state: ColnState, + transaction: TransactionHandle, +): ColnTransactionBase { + const { store, bindings } = state + const exposed: ColnTransactionBase & { root?: unknown } = { + // TODO: Validate values before WASM; invalid argument conversion can prevent + // transaction recovery. Avoid duplicating the runtime schema without a clear design. + add: (path, values) => transaction.add(path, values), + } + if (bindings) exposed.root = new bindings.Transaction(store, transaction).root + return Object.freeze(exposed) +} + +function runTransaction(document: ColnState, change: ColnChange): ColnState { + // TODO: "Add independent StoreHandle snapshots for Repo document states": + // start the transaction from a snapshot and leave this document unchanged. + const { store, bindings } = document + const transaction = store.beginTransaction() + try { + change(createTransaction(document, transaction)) + const commit = transaction.commit() + return { store: commit.takeStore(), bindings } + } catch (error) { + // beginTransaction() moved ownership of the store into the transaction, and on + // error we cannot return a fresh document. Recover the (aborted) store back onto + // the existing state object, otherwise it would hold a dead StoreHandle. + document.store = transaction.takeStore() + throw error + } +} + +function serializeSchema(schema: ColnSchema): string { + const serialized = JSON.stringify(schema) + if (serialized === undefined) + throw new Error("creating a Coln store requires a schema") + return serialized +} + +// TODO: Every call pays to serialize chunk bytes as number[] across the wasm +// boundary, even when only hashes/parents are needed (metadata, liveHashes). +// Switch to Uint8Array output once CommitChunk uses serde_bytes in the runtime. +function commitChunks(document: ColnState): CommitChunk[] { + return document.store.commitChunksAfter([]) +} + +function commitMetadata(chunk: CommitChunk): SedimentreeMeta { + return { kind: "commit", head: chunk.hash, parents: chunk.parents } +} diff --git a/packages/coln-repo/src/colnHandle.ts b/packages/coln-repo/src/colnHandle.ts new file mode 100644 index 00000000..c56c3e66 --- /dev/null +++ b/packages/coln-repo/src/colnHandle.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { + AutomergeUrl, + CrdtDocHandle, + DocumentType, + Repo, +} from "@automerge/automerge-repo" +import type { RealmBindings } from "@coln-project/runtime" +import { + colnDocType, + type ColnChange, + type ColnDocument, + type ColnSchema, + type ColnState, +} from "./colnDocType.js" + +type FoundColnHandle = + Bindings extends RealmBindings ? ColnHandle : ColnHandle + +export type ColnHandle = + CrdtDocHandle< + DocumentType< + ColnState, + ColnDocument, + ColnChange, + ColnSchema + > + > + +export function applyBindings( + handle: ColnHandle | ColnHandle, + bindings: Bindings, +): ColnHandle { + const state = handle.fullDoc() + const existingBindings = state.bindings + + if (existingBindings) { + if (existingBindings !== bindings) { + throw new TypeError("Coln handle already uses different realm bindings") + } + return handle as unknown as ColnHandle + } + + const actualSchema = canonicalJson(JSON.parse(handle.doc().jsonIR())) + const bindingsSchema = canonicalJson(bindings.schema) + if (actualSchema !== bindingsSchema) { + throw new TypeError( + "Realm bindings schema does not match Coln document schema", + ) + } + + state.bindings = bindings + + return handle as unknown as ColnHandle +} + +export function create( + repo: Repo, + bindings: Bindings, +): ColnHandle { + const handle = repo.create(bindings.schema, colnDocType) + + return applyBindings(handle, bindings) +} + +export async function find< + Bindings extends RealmBindings | undefined = undefined, +>( + repo: Repo, + url: AutomergeUrl, + // a rest tuple (rather than an optional parameter) keeps `undefined` in the + // inferred Bindings, so maybe-undefined arguments produce a union return type + ...rest: [bindings: Bindings] | [] +): Promise> { + const [bindings] = rest + const handle = await repo.find(url, colnDocType) + + return ( + bindings === undefined ? handle : applyBindings(handle, bindings) + ) as FoundColnHandle +} + +function canonicalJson(value: unknown): string | undefined { + return JSON.stringify(value, (_key, current: unknown) => { + if ( + current === null || + Array.isArray(current) || + typeof current !== "object" + ) + return current + return Object.fromEntries( + Object.entries(current).sort(([left], [right]) => + left.localeCompare(right), + ), + ) + }) +} diff --git a/packages/coln-repo/src/index.ts b/packages/coln-repo/src/index.ts new file mode 100644 index 00000000..7ef90e63 --- /dev/null +++ b/packages/coln-repo/src/index.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +export { + colnDocType, + type ColnChange, + type ColnDocument, + type RealmBindings, + type ColnSchema, + type ColnTransaction, +} from "./colnDocType.js" + +export { applyBindings, create, find, type ColnHandle } from "./colnHandle.js" diff --git a/examples/sync-demo/index.html b/packages/coln-repo/tests/browser/index.html similarity index 59% rename from examples/sync-demo/index.html rename to packages/coln-repo/tests/browser/index.html index 7f7f6654..37bb5c8a 100644 --- a/examples/sync-demo/index.html +++ b/packages/coln-repo/tests/browser/index.html @@ -3,10 +3,9 @@ - Coln automerge-repo demo + Coln Repo Test -
- + diff --git a/packages/coln-repo/tests/browser/main.ts b/packages/coln-repo/tests/browser/main.ts new file mode 100644 index 00000000..e7765dc9 --- /dev/null +++ b/packages/coln-repo/tests/browser/main.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { + Repo, + type AutomergeUrl, + type CrdtDocHandle, +} from "@automerge/automerge-repo" +import { initSync } from "@automerge/automerge-subduction/slim" +import { wasmBase64 } from "@automerge/automerge-subduction/wasm-base64" +import type { RowView, Value } from "@coln-project/runtime" +import { + colnDocType, + applyBindings, + type ColnHandle, + type ColnSchema, +} from "../../src/index.js" +import * as itemFfi from "../fixtures/itemFfi" + +initSync({ + module: Uint8Array.from(atob(wasmBase64), (char) => char.charCodeAt(0)), +}) + +type RawHandle = CrdtDocHandle +type ItemHandle = ColnHandle + +const repo = new Repo({ + subductionWebsocketEndpoints: ["ws://127.0.0.1:3031"], +}) +let handle: RawHandle | undefined +let typedHandle: ItemHandle | undefined + +const api = { + create(schema: ColnSchema): AutomergeUrl { + handle = repo.create(schema, colnDocType) + return handle.url + }, + + async find(url: AutomergeUrl): Promise { + handle = await repo.find(url, colnDocType) + }, + + createTyped(): AutomergeUrl { + const rawHandle = repo.create(itemFfi.schema, colnDocType) + handle = rawHandle + typedHandle = applyBindings(rawHandle, itemFfi) + return typedHandle.url + }, + + applyTypedBindings(): void { + typedHandle = applyBindings(currentHandle(), itemFfi) + }, + + add(path: string, values: Value[]): void { + currentHandle().change((transaction) => transaction.add(path, values)) + }, + + rows(path: string): RowView[] { + return currentHandle().doc().scanTable(path) + }, + + addTyped(value: string): void { + currentTypedHandle().change((transaction) => { + transaction.root.Items(stringValue(value)).add() + }) + }, + + typedCount(value: string): number { + const rows = currentTypedHandle() + .doc() + .root.Items(stringValue(value)) + .values() + let count = 0 + for (let next = rows.next(); !next.done; next = rows.next()) count += 1 + return count + }, + + heads(): string[] { + return currentHandle().doc().heads() + }, + + async flush(): Promise { + await repo.flush() + }, + + async shutdown(): Promise { + await repo.shutdown() + }, +} + +function currentHandle(): RawHandle { + if (!handle) throw new Error("no Coln store is open") + return handle +} + +function currentTypedHandle(): ItemHandle { + if (!typedHandle) throw new Error("no typed Coln store is open") + return typedHandle +} + +function stringValue(value: string): Value { + return { tag: "string", value } +} + +declare global { + interface Window { + colnTest: typeof api + } +} + +window.colnTest = api diff --git a/packages/coln-repo/tests/browser/subduction.d.ts b/packages/coln-repo/tests/browser/subduction.d.ts new file mode 100644 index 00000000..ee91318e --- /dev/null +++ b/packages/coln-repo/tests/browser/subduction.d.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +declare module "@automerge/automerge-subduction/slim" { + export * from "@automerge/automerge-subduction" + export function initSync(input: { module: Uint8Array }): void +} + +declare module "@automerge/automerge-subduction/wasm-base64" { + export const wasmBase64: string +} diff --git a/packages/coln-repo/tests/fixtures/itemFfi.ts b/packages/coln-repo/tests/fixtures/itemFfi.ts new file mode 100644 index 00000000..20085080 --- /dev/null +++ b/packages/coln-repo/tests/fixtures/itemFfi.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { + RowIdSet, + type StoreHandle, + type TransactionHandle, + type Value, +} from "@coln-project/runtime" +export { itemSchema as schema } from "./schema" + +interface Root { + Items: (value: Value) => T +} + +export class View { + root: Root + + constructor(store: StoreHandle) { + this.root = { + Items: (value) => new RowIdSet.View(store, "Test.Items", [value]), + } + } +} + +export class Transaction extends View { + declare root: Root + + constructor(store: StoreHandle, transaction: TransactionHandle) { + super(store) + this.root = { + Items: (value) => + new RowIdSet.Transaction(store, "Test.Items", [value], transaction), + } + } +} diff --git a/packages/coln-repo/tests/fixtures/schema.ts b/packages/coln-repo/tests/fixtures/schema.ts new file mode 100644 index 00000000..d8a45f9d --- /dev/null +++ b/packages/coln-repo/tests/fixtures/schema.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { ColnSchema } from "../../src/index.js" + +export const itemSchema = { + entities: [ + { + path: [["Test"], ["Items"]], + value: { + entityVariant: { tag: "table" }, + columns: [ + { + path: [["value"]], + type: { tag: "builtin", type: "builtinString" }, + }, + ], + primaryKey: null, + }, + }, + ], + rules: [], +} satisfies ColnSchema diff --git a/packages/coln-repo/tests/generated/Graph.ts b/packages/coln-repo/tests/generated/Graph.ts new file mode 100644 index 00000000..52151b3e --- /dev/null +++ b/packages/coln-repo/tests/generated/Graph.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import * as runtime from "@coln-project/runtime" + +export interface View { + V: runtime.ColnSet.View + E: (x: runtime.Value) => (x: runtime.Value) => runtime.ColnSet.View +} + +export interface Transaction extends View { + V: runtime.ColnSet.Transaction + E: (x: runtime.Value) => (x: runtime.Value) => runtime.ColnSet.Transaction +} diff --git a/packages/coln-repo/tests/generated/GraphRealm.json b/packages/coln-repo/tests/generated/GraphRealm.json new file mode 100644 index 00000000..d7f212c4 --- /dev/null +++ b/packages/coln-repo/tests/generated/GraphRealm.json @@ -0,0 +1,92 @@ +{ + "entities": [ + { + "path": [["GraphRealm"], ["V"]], + "value": { + "entityVariant": { "tag": "table" }, + "columns": [], + "primaryKey": null + } + }, + { + "path": [["GraphRealm"], ["E"]], + "value": { + "entityVariant": { "tag": "table" }, + "columns": [ + { + "path": [["a"]], + "type": { "tag": "rowId", "path": [["GraphRealm"], ["V"]] } + }, + { + "path": [["b"]], + "type": { "tag": "rowId", "path": [["GraphRealm"], ["V"]] } + } + ], + "primaryKey": null + } + } + ], + "rules": [ + { + "path": [["GraphRealm"], ["V"], ["foreignKey"]], + "value": { + "ruleVariant": "enforced", + "varNames": [], + "varTypes": [], + "antecedents": [ + { + "tag": "atom", + "atom": { + "entity": [["GraphRealm"], ["V"]], + "rowId": null, + "values": [] + } + } + ], + "consequents": [] + } + }, + { + "path": [["GraphRealm"], ["E"], ["foreignKey"]], + "value": { + "ruleVariant": "enforced", + "varNames": [[["a"]], [["b"]]], + "varTypes": [ + { "tag": "rowId", "path": [["GraphRealm"], ["V"]] }, + { "tag": "rowId", "path": [["GraphRealm"], ["V"]] } + ], + "antecedents": [ + { + "tag": "atom", + "atom": { + "entity": [["GraphRealm"], ["E"]], + "rowId": null, + "values": [ + { "column": 0, "term": { "tag": "var", "index": 0 } }, + { "column": 1, "term": { "tag": "var", "index": 1 } } + ] + } + } + ], + "consequents": [ + { + "tag": "atom", + "atom": { + "entity": [["GraphRealm"], ["V"]], + "rowId": { "tag": "var", "index": 0 }, + "values": [] + } + }, + { + "tag": "atom", + "atom": { + "entity": [["GraphRealm"], ["V"]], + "rowId": { "tag": "var", "index": 1 }, + "values": [] + } + } + ] + } + } + ] +} diff --git a/packages/coln-repo/tests/generated/GraphRealm.pretty b/packages/coln-repo/tests/generated/GraphRealm.pretty new file mode 100644 index 00000000..b4a7d502 --- /dev/null +++ b/packages/coln-repo/tests/generated/GraphRealm.pretty @@ -0,0 +1,13 @@ +flatrealm + entities + table ℜ.V := [] + table ℜ.E := [.a : ℜ.V, .b : ℜ.V] + end + rules + enforced ℜ.V.foreignKey := ℜ.V [] ⊢ ⊤ + enforced ℜ.E.foreignKey a b := ℜ.E [ + .a ↦ a, + .b ↦ b + ] ⊢ a ∈ ℜ.V [] ∧ b ∈ ℜ.V [] + end +end \ No newline at end of file diff --git a/packages/coln-repo/tests/generated/GraphRealm.ts b/packages/coln-repo/tests/generated/GraphRealm.ts new file mode 100644 index 00000000..b0b861a4 --- /dev/null +++ b/packages/coln-repo/tests/generated/GraphRealm.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import schema from "./GraphRealm.json" with { type: "json" } +export { schema } +import * as runtime from "@coln-project/runtime" +import * as Graph from "./Graph" + +export class View { + root: Graph.View + + constructor(store: runtime.StoreHandle) { + this.root = { + V: new runtime.RowIdSet.View(store, "GraphRealm.V", []), + E: (a: runtime.Value) => { + return (b: runtime.Value) => { + return new runtime.RowIdSet.View(store, "GraphRealm.E", [a, b]) + } + }, + } + } +} + +export class Transaction extends View { + root: Graph.Transaction + + constructor( + store: runtime.StoreHandle, + transaction: runtime.TransactionHandle, + ) { + super(store) + this.root = { + V: new runtime.RowIdSet.Transaction( + store, + "GraphRealm.V", + [], + transaction, + ), + E: (a: runtime.Value) => { + return (b: runtime.Value) => { + return new runtime.RowIdSet.Transaction( + store, + "GraphRealm.E", + [a, b], + transaction, + ) + } + }, + } + } +} diff --git a/packages/coln-repo/tests/handle.test.ts b/packages/coln-repo/tests/handle.test.ts new file mode 100644 index 00000000..575225c3 --- /dev/null +++ b/packages/coln-repo/tests/handle.test.ts @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import assert from "node:assert/strict" +import { afterEach, beforeEach, test } from "node:test" +import type { Value } from "@coln-project/runtime" +import { + applyBindings, + colnDocType, + create, + find, + type ColnDocument, +} from "../src/index" +import * as itemFfi from "./fixtures/itemFfi" +import schema from "./generated/GraphRealm.json" with { type: "json" } +import * as GraphRealm from "./generated/GraphRealm" +import { + createTestRepoPair, + waitForChange, + type TestRepoPair, +} from "./helpers/TestRepoPair" + +let repos: TestRepoPair + +beforeEach(async () => { + repos = await createTestRepoPair() +}) + +afterEach(async () => { + await repos.shutdown() +}) + +test("create returns a bound, initialized handle", () => { + const handle = create(repos.source, GraphRealm) + const document = handle.doc() + + assert.deepEqual(Object.keys(document).sort(), [ + "heads", + "jsonIR", + "root", + "rowById", + "scanTable", + ]) + assert.deepEqual(JSON.parse(document.jsonIR()), schema) + assert.deepEqual(document.heads(), handle.rawHeads()) + assert.deepEqual(document.scanTable("GraphRealm.V"), []) +}) + +test("document can be updated and changes are reflected", () => { + const handle = create(repos.source, GraphRealm) + + let v1!: Value, v2!: Value, e1!: Value + + handle.change((tx) => { + v1 = tx.root.V.add() + v2 = tx.root.V.add() + e1 = tx.root.E(v1)(v2).add() + }) + + const doc = handle.doc() + + assert(doc.root.V.has(v1)) + assert(doc.root.V.has(v2)) + assert(doc.root.E(v1)(v2).has(e1)) + assert.equal(v1.tag, "row_id") + assert.deepEqual(doc.rowById("GraphRealm.V", v1.value)?.rowId, v1) +}) + +test("changes expose only safe Coln operations", () => { + const handle = create(repos.source, GraphRealm) + + handle.change((transaction) => { + assert.deepEqual(Object.keys(transaction).sort(), ["add", "root"]) + transaction.add("GraphRealm.V", []) + }) + + assert.equal(handle.doc().scanTable("GraphRealm.V").length, 1) +}) + +test("failed changes are aborted and leave the handle usable", () => { + const handle = create(repos.source, GraphRealm) + let committed!: Value + + assert.throws( + () => + handle.change((transaction) => { + transaction.root.V.add() + throw new Error("abort") + }), + new Error("abort"), + ) + handle.change((transaction) => { + committed = transaction.root.V.add() + }) + + assert.deepEqual( + handle + .doc() + .scanTable("GraphRealm.V") + .map((row) => row.rowId), + [committed], + ) +}) + +test("typed handles sync changes", async () => { + const handle = create(repos.source, GraphRealm) + const replica = await find(repos.replica, handle.url, GraphRealm) + let vertex!: Value + + const changed = new Promise>((resolve) => { + replica.once("change", (payload) => { + if (payload.doc) resolve(payload.doc) + }) + }) + handle.change((tx) => { + vertex = tx.root.V.add() + }) + const changedDocument = await changed + + assert(changedDocument.root.V.has(vertex)) + assert.deepEqual( + changedDocument.scanTable("GraphRealm.V").map((row) => row.rowId), + [vertex], + ) + assert(replica.doc().root.V.has(vertex)) +}) + +test("raw changes sync into a typed handle", async () => { + const handle = create(repos.source, GraphRealm) + const replica = await repos.replica.find(handle.url, colnDocType) + let vertex!: Value + + const changed = waitForChange(handle) + replica.change((transaction) => { + vertex = transaction.add("GraphRealm.V", []) + }) + await changed + + assert(handle.doc().root.V.has(vertex)) +}) + +test("find returns a raw handle when bindings are omitted", async () => { + const handle = create(repos.source, GraphRealm) + const replica = await find(repos.replica, handle.url) + + assert.deepEqual(Object.keys(replica.doc()).sort(), [ + "heads", + "jsonIR", + "rowById", + "scanTable", + ]) +}) + +test("bindings can be applied to a raw found handle later", async () => { + const handle = create(repos.source, GraphRealm) + const rawReplica = await find(repos.replica, handle.url) + const replica = applyBindings(rawReplica, GraphRealm) + + assert.strictEqual(replica, rawReplica) + assert.strictEqual(applyBindings(replica, GraphRealm), replica) + + let vertex!: Value + replica.change((transaction) => { + vertex = transaction.root.V.add() + }) + + assert(replica.doc().root.V.has(vertex)) + assert.deepEqual( + replica + .doc() + .scanTable("GraphRealm.V") + .map((row) => row.rowId), + [vertex], + ) +}) + +test("applying different bindings to a bound handle is rejected", () => { + const handle = create(repos.source, GraphRealm) + const otherBindings = { ...GraphRealm } + + assert.throws( + () => applyBindings(handle, otherBindings), + new TypeError("Coln handle already uses different realm bindings"), + ) +}) + +test("applying bindings for a different schema is rejected", () => { + const handle = repos.source.create(schema, colnDocType) + + assert.throws( + () => applyBindings(handle, itemFfi), + new TypeError("Realm bindings schema does not match Coln document schema"), + ) +}) diff --git a/packages/coln-repo/tests/helpers/TestNetworkAdapter.ts b/packages/coln-repo/tests/helpers/TestNetworkAdapter.ts new file mode 100644 index 00000000..e25621d3 --- /dev/null +++ b/packages/coln-repo/tests/helpers/TestNetworkAdapter.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { + NetworkAdapter, + type Message, + type PeerId, +} from "@automerge/automerge-repo" + +export class TestNetworkAdapter extends NetworkAdapter { + #connected = false + #peer?: TestNetworkAdapter + + static createConnectedPair(): [TestNetworkAdapter, TestNetworkAdapter] { + const first = new TestNetworkAdapter() + const second = new TestNetworkAdapter() + first.#peer = second + second.#peer = first + return [first, second] + } + + isReady(): boolean { + return true + } + + async whenReady(): Promise {} + + connect(peerId: PeerId): void { + this.peerId = peerId + this.#connected = true + } + + disconnect(): void { + this.#connected = false + } + + announce(peerId: PeerId): void { + this.emit("peer-candidate", { peerId, peerMetadata: {} }) + } + + send(message: Message): void { + if (!this.#connected) return + queueMicrotask(() => this.#peer?.receive(message)) + } + + private receive(message: Message): void { + if (this.#connected) this.emit("message", message) + } +} diff --git a/packages/coln-repo/tests/helpers/TestRepoPair.ts b/packages/coln-repo/tests/helpers/TestRepoPair.ts new file mode 100644 index 00000000..324330c9 --- /dev/null +++ b/packages/coln-repo/tests/helpers/TestRepoPair.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { initSubduction, Repo } from "@automerge/automerge-repo" +import { setTimeout as delay } from "node:timers/promises" +import { TestNetworkAdapter } from "./TestNetworkAdapter" + +export interface TestRepoPair { + source: Repo + replica: Repo + shutdown(): Promise +} + +export async function createTestRepoPair(): Promise { + await initSubduction() + const [sourceAdapter, replicaAdapter] = + TestNetworkAdapter.createConnectedPair() + const source = new Repo({ + subductionAdapters: [ + { adapter: sourceAdapter, serviceName: "test", role: "connect" }, + ], + subductionTimeouts: { healMaxAttempts: 0, syncMs: 1_000 }, + }) + const replica = new Repo({ + subductionAdapters: [ + { adapter: replicaAdapter, serviceName: "test", role: "accept" }, + ], + subductionTimeouts: { healMaxAttempts: 0, syncMs: 1_000 }, + }) + + const sourceConnected = waitForConnection(source) + const replicaConnected = waitForConnection(replica) + sourceAdapter.announce(replica.peerId) + replicaAdapter.announce(source.peerId) + await Promise.all([sourceConnected, replicaConnected]) + + return { + source, + replica, + async shutdown() { + await Promise.all([source.flush(), replica.flush()]) + await Promise.all([ + source.subduction.then((subduction) => subduction.disconnectAll()), + replica.subduction.then((subduction) => subduction.disconnectAll()), + ]) + await Promise.all([source.shutdown(), replica.shutdown()]) + }, + } +} + +export function waitForChange(handle: { + once(event: "change", listener: () => void): unknown +}): Promise { + return withTimeout( + new Promise((resolve) => handle.once("change", () => resolve())), + "handle change", + ) +} + +function waitForConnection(repo: Repo): Promise { + if (repo.isSubductionConnected()) return Promise.resolve() + return withTimeout( + new Promise((resolve) => { + const onConnection = ({ connected }: { connected: boolean }) => { + if (!connected) return + repo.off("subduction-connection", onConnection) + resolve() + } + repo.on("subduction-connection", onConnection) + }), + "Subduction connection", + ) +} + +function withTimeout(promise: Promise, operation: string): Promise { + return Promise.race([ + promise, + delay(5_000, undefined, { ref: false }).then(() => { + throw new Error(`${operation} timed out`) + }), + ]) +} diff --git a/packages/coln-repo/tests/package/consumer.ts b/packages/coln-repo/tests/package/consumer.ts new file mode 100644 index 00000000..aefeed0f --- /dev/null +++ b/packages/coln-repo/tests/package/consumer.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import type { AutomergeUrl, Repo } from "@automerge/automerge-repo" +import type { Value } from "@coln-project/runtime" +import { + colnDocType, + find, + type ColnChange, + type ColnDocument, + type ColnHandle, + type ColnTransaction, + type RealmBindings, +} from "@coln-project/repo" + +type Equal = + (() => Type extends Left ? 1 : 2) extends () => Type extends Right + ? 1 + : 2 + ? true + : false +type Expect = Type + +declare const repo: Repo +declare const url: AutomergeUrl +declare const bindings: RealmBindings +declare const optionalBindings: RealmBindings | undefined + +const raw = find(repo, url) +const explicitUndefined = find(repo, url, undefined) +const typed = find(repo, url, bindings) +const optional = find(repo, url, optionalBindings) + +void raw.then((handle) => { + handle.doc().scanTable("Example.Items") + handle.fullDoc().store.heads() +}) + +void typed.then((handle) => { + handle.doc().root + handle.on("change", (payload) => { + payload.doc?.root + payload.handle.doc().root + }) + handle.addListener("change", (payload) => payload.doc?.root) + handle.listeners("change").forEach((listener) => listener) + handle.on("heads-changed", (payload) => { + payload.doc.store + payload.handle.doc().root + }) + handle.change((transaction) => { + const value: Value = transaction.add("Example.Items", []) + value + transaction.root + }) +}) + +type DocumentOperation = "heads" | "jsonIR" | "rowById" | "scanTable" + +export type TypeChecks = [ + Expect>>, + Expect>>, + Expect>>>, + Expect< + Equal>> + >, + Expect< + Equal< + ReturnType["doc"]>, + ColnDocument + > + >, + Expect< + Equal< + Parameters["change"]>[0], + ColnChange + > + >, + Expect< + Equal< + Parameters>[0], + ColnTransaction + > + >, + Expect>, + Expect< + Equal, DocumentOperation | "root"> + >, + Expect>, + Expect, "add" | "root">>, +] diff --git a/packages/coln-repo/tests/server.mjs b/packages/coln-repo/tests/server.mjs new file mode 100644 index 00000000..a8cef1f9 --- /dev/null +++ b/packages/coln-repo/tests/server.mjs @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { WebSocketServer } from "ws" +import { + MemorySigner, + MemoryStorage, + Subduction, +} from "@automerge/automerge-subduction" + +class WebSocketTransport { + #socket + #queue = [] + #waiters = [] + #errorWaiters = [] + #closed = false + #closedPromise + #resolveClosed + #disconnectCallback = null + + constructor(socket) { + this.#socket = socket + this.#closedPromise = new Promise((resolve) => { + this.#resolveClosed = resolve + }) + + socket.on("message", (data) => { + const bytes = toUint8Array(data) + const waiter = this.#waiters.shift() + if (waiter) { + this.#errorWaiters.shift() + waiter(bytes) + } else { + this.#queue.push(bytes) + } + }) + socket.on("close", () => this.#markClosed(new Error("WebSocket closed"))) + socket.on("error", (error) => this.#markClosed(error)) + } + + onDisconnect(callback) { + this.#disconnectCallback = callback + } + + async sendBytes(bytes) { + if (this.#closed) throw new Error("WebSocket closed") + await new Promise((resolve, reject) => { + this.#socket.send(Buffer.from(bytes), (error) => + error ? reject(error) : resolve(), + ) + }) + } + + recvBytes() { + const queued = this.#queue.shift() + if (queued) return Promise.resolve(queued) + if (this.#closed) return Promise.reject(new Error("WebSocket closed")) + return new Promise((resolve, reject) => { + this.#waiters.push(resolve) + this.#errorWaiters.push(reject) + }) + } + + async disconnect() { + this.#markClosed(new Error("WebSocket closed"), false) + this.#socket.close() + } + + closed() { + return this.#closedPromise + } + + #markClosed(error, notify = true) { + if (this.#closed) return + this.#closed = true + this.#resolveClosed() + for (const reject of this.#errorWaiters) reject(error) + this.#waiters = [] + this.#errorWaiters = [] + if (notify) this.#disconnectCallback?.() + } +} + +function toUint8Array(data) { + if (data instanceof Uint8Array) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + } + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (Array.isArray(data)) return toUint8Array(Buffer.concat(data)) + return new Uint8Array(Buffer.from(data)) +} + +const port = 3031 +const serviceName = `127.0.0.1:${port}` +const subduction = new Subduction({ + signer: new MemorySigner(), + storage: new MemoryStorage(), +}) +const server = new WebSocketServer({ port }) + +server.on("connection", (socket) => { + subduction + .acceptTransport(new WebSocketTransport(socket), serviceName) + .catch((error) => { + if (!String(error?.message ?? error).includes("closed")) + console.error(error) + }) +}) + +async function shutdown() { + await subduction.disconnectAll().catch(() => {}) + server.close(() => process.exit(0)) +} + +process.on("SIGINT", shutdown) +process.on("SIGTERM", shutdown) + +console.log(`Subduction test relay listening on ws://127.0.0.1:${port}`) diff --git a/packages/coln-repo/tests/sync.spec.ts b/packages/coln-repo/tests/sync.spec.ts new file mode 100644 index 00000000..9d22089d --- /dev/null +++ b/packages/coln-repo/tests/sync.spec.ts @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 Coln contributors +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +import { expect, test, type Page } from "@playwright/test" +import { + initSubduction, + Repo, + type AutomergeUrl, + type CrdtDocHandle, +} from "@automerge/automerge-repo" +import { colnDocType } from "../src/index.js" +import { itemSchema } from "./fixtures/schema" + +const table = "Test.Items" +type RawHandle = CrdtDocHandle + +test("browser and Node repos create, find, and update each other's stores", async ({ + browser, +}) => { + await initSubduction() + const context = await browser.newContext() + const pages: Page[] = [] + let nodeRepo: Repo | undefined + + try { + nodeRepo = new Repo({ + subductionWebsocketEndpoints: ["ws://127.0.0.1:3031"], + }) + const browserCreator = await openPage(context.newPage()) + pages.push(browserCreator) + const browserUrl = await browserCreator.evaluate( + (schema) => window.colnTest.create(schema), + itemSchema, + ) + await add(browserCreator, "browser-one") + await browserCreator.evaluate(() => window.colnTest.flush()) + + const nodeFinder = await nodeRepo.find(browserUrl, colnDocType) + await expectNodeRows(nodeFinder, ["browser-one"]) + + addInNode(nodeFinder, "node-two") + await nodeRepo.flush() + await expectRows(browserCreator, ["browser-one", "node-two"]) + await expectSameHeads(browserCreator, nodeFinder) + + const nodeCreator = nodeRepo.create(itemSchema, colnDocType) + addInNode(nodeCreator, "node-one") + await nodeRepo.flush() + + const browserFinder = await openPage(context.newPage()) + pages.push(browserFinder) + await browserFinder.evaluate( + (url) => window.colnTest.find(url), + nodeCreator.url as AutomergeUrl, + ) + await expectRows(browserFinder, ["node-one"]) + + await add(browserFinder, "browser-two") + await browserFinder.evaluate(() => window.colnTest.flush()) + await expectNodeRows(nodeCreator, ["browser-two", "node-one"]) + await expectSameHeads(browserFinder, nodeCreator) + } finally { + try { + await shutdownPages(pages) + } finally { + try { + await nodeRepo?.shutdown() + } finally { + await context.close() + } + } + } +}) + +test("reloads without schema and applies typed bindings", async ({ + browser, +}) => { + const context = await browser.newContext() + const pages: Page[] = [] + + try { + const creator = await openPage(context.newPage()) + pages.push(creator) + const url = await creator.evaluate(() => window.colnTest.createTyped()) + + await creator.evaluate(() => window.colnTest.addTyped("one")) + await creator.evaluate(() => window.colnTest.flush()) + + const finder = await openPage(context.newPage()) + pages.push(finder) + await finder.evaluate((url) => window.colnTest.find(url), url) + await expectRows(finder, ["one"]) + await finder.evaluate(() => window.colnTest.applyTypedBindings()) + await expectTypedCount(finder, "one", 1) + + await finder.evaluate(() => window.colnTest.addTyped("two")) + await finder.evaluate(() => window.colnTest.flush()) + await expectTypedCount(creator, "two", 1) + + await shutdownPages([creator, finder]) + await creator.close() + await finder.close() + + const reloaded = await openPage(context.newPage()) + pages.push(reloaded) + await reloaded.evaluate((url) => window.colnTest.find(url), url) + await expectRows(reloaded, ["one", "two"]) + await reloaded.evaluate(() => window.colnTest.applyTypedBindings()) + await expectTypedCount(reloaded, "one", 1) + await expectTypedCount(reloaded, "two", 1) + } finally { + try { + await shutdownPages(pages) + } finally { + await context.close() + } + } +}) + +async function openPage(pagePromise: Promise): Promise { + const page = await pagePromise + await page.goto("/") + await page.waitForFunction(() => window.colnTest !== undefined) + return page +} + +async function shutdownPages(pages: Page[]): Promise { + await Promise.all( + pages + .filter((page) => !page.isClosed()) + .map((page) => + page.evaluate(() => window.colnTest.shutdown()).catch(() => undefined), + ), + ) +} + +async function add(page: Page, value: string) { + await page.evaluate( + ({ table, value }) => + window.colnTest.add(table, [{ tag: "string", value }]), + { table, value }, + ) +} + +async function expectRows(page: Page, values: string[]) { + await expect + .poll(() => + page.evaluate( + (table) => + window.colnTest + .rows(table) + .map((row) => row.values[0]) + .filter((value) => value?.tag === "string") + .map((value) => value.value) + .sort(), + table, + ), + ) + .toEqual([...values].sort()) +} + +function addInNode(handle: RawHandle, value: string) { + handle.change((transaction) => + transaction.add(table, [{ tag: "string", value }]), + ) +} + +async function expectNodeRows(handle: RawHandle, values: string[]) { + await expect + .poll(() => + handle + .doc() + .scanTable(table) + .map((row) => row.values[0]) + .filter((value) => value?.tag === "string") + .map((value) => value.value) + .sort(), + ) + .toEqual([...values].sort()) +} + +async function expectSameHeads(page: Page, handle: RawHandle) { + await expect + .poll(async () => { + const browserHeads = ( + await page.evaluate(() => window.colnTest.heads()) + ).sort() + const nodeHeads = handle.doc().heads().sort() + return browserHeads.join(",") === nodeHeads.join(",") + ? "equal" + : `browser: ${browserHeads.join(",")}; Node: ${nodeHeads.join(",")}` + }) + .toBe("equal") +} + +async function expectTypedCount(page: Page, value: string, count: number) { + await expect + .poll(() => + page.evaluate((value) => window.colnTest.typedCount(value), value), + ) + .toBe(count) +} diff --git a/packages/coln-repo/tsconfig.build.json b/packages/coln-repo/tsconfig.build.json new file mode 100644 index 00000000..274a9026 --- /dev/null +++ b/packages/coln-repo/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["tests"] +} diff --git a/packages/coln-repo/tsconfig.json b/packages/coln-repo/tsconfig.json new file mode 100644 index 00000000..6d028484 --- /dev/null +++ b/packages/coln-repo/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "ES2022"], + "strict": true, + "noEmit": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"], + "exclude": ["tests/package"] +} diff --git a/packages/coln-repo/tsconfig.package.json b/packages/coln-repo/tsconfig.package.json new file mode 100644 index 00000000..d8dc1cd9 --- /dev/null +++ b/packages/coln-repo/tsconfig.package.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["tests/package/**/*.ts"], + "exclude": [] +} diff --git a/examples/sync-demo/vite.config.ts b/packages/coln-repo/vite.config.ts similarity index 68% rename from examples/sync-demo/vite.config.ts rename to packages/coln-repo/vite.config.ts index d704cedc..06488873 100644 --- a/examples/sync-demo/vite.config.ts +++ b/packages/coln-repo/vite.config.ts @@ -3,11 +3,10 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT import { defineConfig } from "vite" -import react from "@vitejs/plugin-react" import wasm from "vite-plugin-wasm" export default defineConfig({ - base: process.env.VITE_BASE || "/", - plugins: [wasm(), react()], + root: "tests/browser", + plugins: [wasm()], build: { target: "esnext" }, }) diff --git a/packages/coln-store/Cargo.toml b/packages/coln-store/Cargo.toml index efd8f93a..bbf4d804 100644 --- a/packages/coln-store/Cargo.toml +++ b/packages/coln-store/Cargo.toml @@ -26,7 +26,7 @@ csv = "1.4.0" coln-query = { path = "../coln-query", optional = true } ena = "0.14.4" hex = "0.4.3" -hexane = "1.0.0-alpha.2" +hexane = "1.0.0-alpha.5" js-sys = { version = "0.3.83", optional = true } leb128 = "0.2.6" petgraph = "0.8.3" diff --git a/packages/coln-store/src/commit/chunk.rs b/packages/coln-store/src/commit/chunk.rs index 39789cb4..3b8f368f 100644 --- a/packages/coln-store/src/commit/chunk.rs +++ b/packages/coln-store/src/commit/chunk.rs @@ -27,6 +27,8 @@ impl From for u8 { } } +/// A chunk what the external world sees as a "chunk" of data, right now it is +/// just a commit, but in the future it might be a sedimemtree fragment. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Chunk { Commit { header: Header, payload: Vec }, @@ -53,6 +55,10 @@ impl Chunk { Ok(chunk) } + pub fn is_root(&self) -> bool { + self.chunk_type() == ChunkType::Root + } + pub(crate) fn chunk_type(&self) -> ChunkType { match self { Chunk::Commit { header, .. } | Chunk::Root { header, .. } => header.chunk_type, diff --git a/packages/coln-store/src/commit/error.rs b/packages/coln-store/src/commit/error.rs index a70a34bc..6a77d69f 100644 --- a/packages/coln-store/src/commit/error.rs +++ b/packages/coln-store/src/commit/error.rs @@ -13,6 +13,7 @@ pub enum CodecError { IOError(io::Error), SchemaError(String), DataFormatError(String), + DataContentError(String), ChecksumMismatch, DecodeError(hexane::PackError), ChunkMismatch { expected: ChunkType, got: ChunkType }, @@ -26,6 +27,7 @@ impl fmt::Display for CodecError { CodecError::IOError(err) => write!(f, "io error: {err}"), CodecError::SchemaError(msg) => write!(f, "schema error: {msg}"), CodecError::DataFormatError(msg) => write!(f, "data format error: {msg}"), + CodecError::DataContentError(msg) => write!(f, "data content error: {msg}"), CodecError::ChecksumMismatch => write!(f, "chunk checksum mismatch"), CodecError::DecodeError(err) => write!(f, "decode error: {err:?}"), CodecError::ChunkMismatch { expected, got } => write!( diff --git a/packages/coln-store/src/commit/pst.rs b/packages/coln-store/src/commit/pst.rs index 592ad05d..3c2e923e 100644 --- a/packages/coln-store/src/commit/pst.rs +++ b/packages/coln-store/src/commit/pst.rs @@ -5,7 +5,7 @@ use std::io::Write; use crate::commit::Commit; -use crate::commit::chunk::{Chunk, ChunkType}; +use crate::commit::chunk::Chunk; use crate::commit::error::CodecError; use crate::commit::leb128 as commit_leb128; use crate::store::Store; @@ -38,7 +38,14 @@ pub fn encode_store(store: &Store) -> Result, CodecError> { /// Decode a store from bytes produced by [`encode_store`]. pub fn decode_store(data: &[u8]) -> Result { let encoded = read_store_envelope(data)?; - decode_store_chunks(encoded.chunks) + let (store, pending) = Store::try_from_chunks(encoded.chunks)?; + if !pending.is_empty() { + return Err(CodecError::DataContentError( + "store snapshot contains commits that could not be applied".into(), + ) + .into()); + } + Ok(store) } struct EncodedStore { @@ -83,43 +90,6 @@ fn write_commit_chunk(buf: &mut Vec, commit: &Commit<'_>) { Chunk::from(commit).write(buf) } -fn decode_store_chunks(chunks: Vec) -> Result { - let roots = chunks - .iter() - .filter(|chunk| chunk.chunk_type() == ChunkType::Root) - .collect::>(); - if roots.is_empty() { - return Err(CodecError::DataFormatError("commit graph has no root commit".into()).into()); - } - if roots.len() > 1 { - return Err( - CodecError::DataFormatError("commit graph has multiple root commits".into()).into(), - ); - } - - let root_commit = Commit::from_chunk((*roots[0]).clone(), |_| None)?; - let root_payload = root_commit.root_payload()?; - let mut store = Store::try_from_ir(root_payload)?; - - let mut commits = Vec::new(); - for chunk in chunks { - if chunk.chunk_type() == ChunkType::Root { - continue; - } - - let commit = Commit::from_chunk(chunk, |path| { - store - .resolve_table(path) - .and_then(|oid| store.table_meta(oid)) - })?; - commits.push(commit); - } - - store.apply_commits(commits)?; - - Ok(store) -} - #[cfg(test)] mod tests { use coln_flir_rs::ir::{BuiltinTy, ColType, ColumnEntry, EntityVariant}; @@ -209,13 +179,13 @@ mod tests { .expect("encoded store contains a chunk magic") } - fn is_missing_dep_error(result: Result) -> bool { + fn is_data_content_error(result: Result) -> bool { matches!( result, Err(err) if matches!( &err, - StoreError::Commit(crate::store::error::CommitApplyError::MissingDep) + StoreError::Encode(CodecError::DataContentError(_)) ) ) } @@ -352,7 +322,7 @@ mod tests { frame_chunk(ChunkType::Commit, commit.payload()), ]); - assert!(is_missing_dep_error(decode_store(&bytes))); + assert!(is_data_content_error(decode_store(&bytes))); } #[test] diff --git a/packages/coln-store/src/commit/wire/prim.rs b/packages/coln-store/src/commit/wire/prim.rs index 027d0bfd..02bf174f 100644 --- a/packages/coln-store/src/commit/wire/prim.rs +++ b/packages/coln-store/src/commit/wire/prim.rs @@ -5,7 +5,7 @@ use std::io::Write; use coln_flir_rs::ir::{self, BuiltinTy}; -use hexane::{PackError, lebsize}; +use hexane::{Codec, PackError, lebsize}; use crate::commit::leb128 as commit_leb128; use crate::{commit::error::CodecError, txn::TxnCellValue}; @@ -116,17 +116,17 @@ impl ValueMeta { } impl hexane::ColumnValue for ValueMeta { - type Encoding = hexane::RleEncoding; + type Encoding = hexane::RleEncoding; } impl hexane::RleValue for ValueMeta { - fn try_unpack(data: &[u8]) -> Result<(usize, ValueMeta), PackError> { + fn try_unpack(data: &[u8]) -> Result<(usize, ValueMeta), PackError> { let mut buf = data; let start = buf.len(); let v = leb128::read::unsigned(&mut buf)?; Ok((start - buf.len(), ValueMeta(v))) } - fn pack(value: ValueMeta, out: &mut Vec) -> bool { + fn pack(value: ValueMeta, out: &mut Vec) -> bool { leb128::write::unsigned(out, value.0).unwrap(); true } diff --git a/packages/coln-store/src/store/error.rs b/packages/coln-store/src/store/error.rs index bfb53b9d..e2ecd889 100644 --- a/packages/coln-store/src/store/error.rs +++ b/packages/coln-store/src/store/error.rs @@ -4,6 +4,7 @@ use crate::commit::error::CodecError; use crate::commit::graph::CommitGraphError; +use crate::commit::hash::CommitHash; use crate::solver::compile::CompileError; use crate::solver::validate::RuleViolation; use crate::table::ValidationError; @@ -27,15 +28,10 @@ pub enum StoreError { #[derive(Debug, thiserror::Error)] pub enum CommitApplyError { - #[error("missing commit dependency")] - MissingDep, - #[error("disconnected commit")] - DisconnectedCommit, - // A commit that should definitely exist but is missing - #[error("missing commit")] - MissingCommit, + #[error("A commit {0} with no dependency")] + DanglingCommit(CommitHash), #[error("An existing commit has conflict payload")] - ConflictPayload, - #[error("Root commit cannot be applied")] - RootCommit, + ConflictPayload(CommitHash), + #[error("Root commit {0} cannot be applied")] + RootCommit(CommitHash), } diff --git a/packages/coln-store/src/store/mod.rs b/packages/coln-store/src/store/mod.rs index 8da3ec6a..26df187b 100644 --- a/packages/coln-store/src/store/mod.rs +++ b/packages/coln-store/src/store/mod.rs @@ -203,47 +203,6 @@ impl Store { Ok(graph) } - #[cfg(feature = "native")] - // used in SQL mode only - pub(crate) fn create_table( - &mut self, - path: ir::Path, - schema: ir::Schema, - ) -> Result { - let oid = self.tables.len(); - self.path_to_oid.insert(path.clone(), oid); - self.tables.insert(oid, Table::new(path, oid, schema)); - - let mut tables: Vec<_> = self - .tables - .values() - .map(|table| { - ( - table.oid(), - ir::TableEntry { - path: table.path().clone(), - table: table.schema().clone(), - }, - ) - }) - .collect(); - tables.sort_by_key(|(oid, _)| *oid); - let ir = FlatRealm { - tables: tables.into_iter().map(|(_, entry)| entry).collect(), - rules: self.rule_entries.clone(), - }; - self.commits = Self::graph_with_root_commit(&ir)?; - Ok(oid) - } - - pub fn transaction(&mut self) -> Transaction<'_> { - Transaction::new(self) - } - - pub fn into_transaction(self) -> OwnedTransaction { - OwnedTransaction::new(self) - } - /// Builds an empty column store per `theory.tables` and keeps only `theory.rules` /// (schemas are stored on each [`Table`]). pub fn try_from_ir(ir: FlatRealm) -> Result { @@ -279,6 +238,18 @@ impl Store { } } +impl Store { + // transactions + + pub fn transaction(&mut self) -> Transaction<'_> { + Transaction::new(self) + } + + pub fn into_transaction(self) -> OwnedTransaction { + OwnedTransaction::new(self) + } +} + impl Store { // Dealing with rules fn compile_rules(rules: &[RuleEntry]) -> Result, CompileError> { @@ -370,21 +341,31 @@ impl Store { .collect() } + /// This will try to merge the `other` store as much as possible into this store + // TODO need to rethink `merge` more carefully pub fn merge(&mut self, other: &Self) -> Result, StoreError> { let commits = self.commits_added(other); self.apply_commits(commits)?; Ok(self.heads()) } - pub fn apply_commit(&mut self, commit: Commit<'static>) -> Result<(), StoreError> { + /// Apply a single commit, respect its dependency. + /// Return the commit if it cannot be applied due to missing deps + pub fn apply_commit( + &mut self, + commit: Commit<'static>, + ) -> Result>, StoreError> { // This needs to call apply_commits because it needs to do dependency check - self.apply_commits([commit]) + self.apply_commits([commit]).map(|mut h| h.pop()) } + /// Apply as many commits as possible respecting their dependencies. Return the + /// commit hashes that are NOT applied, so the caller knows which ones they + /// need to retry. pub fn apply_commits( &mut self, commits: impl IntoIterator>, - ) -> Result<(), StoreError> { + ) -> Result>, StoreError> { let mut pending = HashMap::new(); for commit in commits { @@ -393,17 +374,21 @@ impl Store { continue; } + // We assume that the root commit has been used to construct the store + // and therefore must have been applied if commit.is_root() { - return Err(CommitApplyError::RootCommit.into()); + return Err(CommitApplyError::RootCommit(commit.hash()).into()); } + + // We assume that all commits will have deps if commit.deps.is_empty() { - return Err(CommitApplyError::MissingDep.into()); + return Err(CommitApplyError::DanglingCommit(commit.hash()).into()); } if let Some(existing) = pending.get(&hash) { let existing: &Commit<'static> = existing; if *existing != commit { - return Err(CommitApplyError::ConflictPayload.into()); + return Err(CommitApplyError::ConflictPayload(commit.hash()).into()); } continue; } @@ -430,8 +415,12 @@ impl Store { count += 1; waiting_on.entry(*dep).or_default().push(*hash); } else { - // deps is not in pending or applied commits - return Err(CommitApplyError::MissingDep.into()); + tracing::info!( + commit_hash = %commit.hash(), + missing_dep = %dep, + "skipping commit with dependency that is neither applied nor pending" + ); + count += 1; } } @@ -445,14 +434,14 @@ impl Store { while let Some(hash) = ready.pop_first() { let commit = pending .remove(&hash) - .ok_or(CommitApplyError::MissingCommit)?; + .expect("hash in ready should also exist in pending"); self.apply_commit_atomic(commit)?; if let Some(waitings) = waiting_on.remove(&hash) { for wh in waitings { let count = unsatisfied .get_mut(&wh) - .ok_or(CommitApplyError::MissingCommit)?; + .expect("A commit that is waiting must be in unsatisfied"); *count -= 1; if *count == 0 { unsatisfied.remove(&wh).unwrap(); @@ -462,12 +451,7 @@ impl Store { } } - // pending is not empty, but there is no commit to apply - if !pending.is_empty() { - return Err(CommitApplyError::DisconnectedCommit.into()); - } - - Ok(()) + Ok(pending.into_values().collect()) } fn apply_commit_atomic(&mut self, commit: Commit<'static>) -> Result<(), StoreError> { @@ -644,10 +628,11 @@ impl Store { } /// Apply the bytes received by interpreting them as chunks, for syncing purposes + /// Return chunk bytes that cannot be applied yet. pub fn apply_chunk_bytes( &mut self, chunk_bytes: impl IntoIterator>, - ) -> Result<(), StoreError> { + ) -> Result>, StoreError> { let commits = chunk_bytes .into_iter() .map(|bytes| Chunk::decode(&bytes)) @@ -661,12 +646,104 @@ impl Store { }) .collect::, _>>()?; - self.apply_commits(commits) + Ok(Self::commits_to_chunk_bytes(self.apply_commits(commits)?)) + } + + /// Build a store from commit chunks from scratch, assuming that the input + /// `chunk_bytes` contains a valid root commit. + pub fn try_from_commit_bytes( + chunk_bytes: impl IntoIterator>, + ) -> Result<(Self, Vec>), StoreError> { + let chunks = chunk_bytes + .into_iter() + .map(|bytes| Chunk::decode(bytes.as_ref())) + .collect::, _>>()?; + Self::try_from_chunks(chunks) + } + + /// Create a store from the commit chunks, assuming the chunk contains a valid root + pub(crate) fn try_from_chunks(chunks: Vec) -> Result<(Self, Vec>), StoreError> { + let roots = chunks + .iter() + .filter(|chunk| chunk.is_root()) + .collect::>(); + if roots.is_empty() { + return Err( + CodecError::DataFormatError("commit graph has no root commit".into()).into(), + ); + } + if roots.len() > 1 { + return Err(CodecError::DataFormatError( + "commit graph has multiple root commits".into(), + ) + .into()); + } + + let root_commit = Commit::from_chunk((*roots[0]).clone(), |_| None)?; + let root_payload = root_commit.root_payload()?; + let mut store = Store::try_from_ir(root_payload)?; + + let mut commits = Vec::new(); + for chunk in chunks { + if chunk.is_root() { + continue; + } + + let commit = Commit::from_chunk(chunk, |path| { + store + .resolve_table(path) + .and_then(|oid| store.table_meta(oid)) + })?; + commits.push(commit); + } + + let pending_bytes = Self::commits_to_chunk_bytes(store.apply_commits(commits)?); + Ok((store, pending_bytes)) + } + + fn commits_to_chunk_bytes(commits: Vec>) -> Vec> { + commits + .into_iter() + .map(|commit| Chunk::from(commit).encoded()) + .collect() } } impl Store { - // for debugging and testing + // for debugging and testing and experiments + + #[cfg(feature = "native")] + // used in SQL mode only + pub(crate) fn create_table( + &mut self, + path: ir::Path, + schema: ir::Schema, + ) -> Result { + let oid = self.tables.len(); + self.path_to_oid.insert(path.clone(), oid); + self.tables.insert(oid, Table::new(path, oid, schema)); + + let mut tables: Vec<_> = self + .tables + .values() + .map(|table| { + ( + table.oid(), + ir::TableEntry { + path: table.path().clone(), + table: table.schema().clone(), + }, + ) + }) + .collect(); + tables.sort_by_key(|(oid, _)| *oid); + let ir = FlatRealm { + tables: tables.into_iter().map(|(_, entry)| entry).collect(), + rules: self.rule_entries.clone(), + }; + self.commits = Self::graph_with_root_commit(&ir)?; + Ok(oid) + } /// Dump every table in the store for debugging, in ascending [`TableOid`] order, /// separated by a blank line. diff --git a/packages/coln-store/src/store/tests.rs b/packages/coln-store/src/store/tests.rs index 47efaf04..4a706547 100644 --- a/packages/coln-store/src/store/tests.rs +++ b/packages/coln-store/src/store/tests.rs @@ -826,6 +826,41 @@ mod commits { assert_eq!(hashes, vec![commit]); } + #[test] + fn commit_chunks_create_empty_store() { + let source = Store::new(); + let chunks = source + .commit_chunks_after(&[]) + .into_iter() + .map(|chunk| chunk.bytes) + .collect::>(); + + let (restored, pending) = Store::try_from_commit_bytes(chunks).expect("store from chunks"); + + assert!(pending.is_empty()); + assert_eq!(restored.table_count(), 0); + assert_eq!(restored.heads(), source.heads()); + } + + #[test] + fn commit_chunks_create_store_from_out_of_order_data() { + let mut source = single_int_store(); + let commit = commit_int(&mut source, 99); + let mut chunks = source + .commit_chunks_after(&[]) + .into_iter() + .map(|chunk| chunk.bytes) + .collect::>(); + chunks.reverse(); + + let (restored, pending) = Store::try_from_commit_bytes(chunks).expect("store from chunks"); + + assert!(pending.is_empty()); + let table = restored.table_at(&Path::from("T")).expect("table"); + assert_eq!(table.cell_at(0, 0), Some(CellValue::Int(99))); + assert_eq!(restored.heads(), vec![commit]); + } + #[test] fn apply_commits_applies_rows_and_updates_heads() { let mut source = single_int_store(); @@ -882,7 +917,7 @@ mod commits { } #[test] - fn apply_commits_rejects_missing_dependency_without_changing_store() { + fn apply_commits_skips_missing_dependency_without_changing_store() { let mut source = single_int_store(); let mut target = single_int_store(); commit_int(&mut source, 1); @@ -892,12 +927,65 @@ mod commits { .expect("second commit") .clone(); - let err = target.apply_commits([second_commit]).unwrap_err(); + let leftover = target + .apply_commits([second_commit]) + .expect("skip missing dependency"); + let leftover_hashes: Vec<_> = leftover.iter().map(Commit::hash).collect(); - assert!(matches!( - err, - StoreError::Commit(CommitApplyError::MissingDep) - )); + assert_eq!(leftover_hashes, vec![second]); + assert_eq!( + target + .table_at(&Path::from("T")) + .expect("table") + .row_count(), + 0 + ); + } + + #[test] + fn apply_commits_applies_ready_commits_and_returns_blocked_ones() { + let mut source = single_int_store(); + let mut target = single_int_store(); + let first = commit_int(&mut source, 1); + commit_int(&mut source, 2); + let third = commit_int(&mut source, 3); + let first_commit = source.commit_by_hash(&first).expect("first commit").clone(); + let third_commit = source.commit_by_hash(&third).expect("third commit").clone(); + + let leftover = target + .apply_commits([first_commit, third_commit]) + .expect("apply ready commits"); + let leftover_hashes: Vec<_> = leftover.iter().map(Commit::hash).collect(); + + assert_eq!(leftover_hashes, vec![third]); + assert_eq!( + target + .table_at(&Path::from("T")) + .expect("table") + .row_count(), + 1 + ); + assert_eq!(target.heads(), vec![first]); + } + + #[test] + fn apply_chunk_bytes_retries_leftover_when_missing_parent_arrives() { + let mut source = single_int_store(); + let mut target = single_int_store(); + commit_int(&mut source, 1); + let second = commit_int(&mut source, 2); + + let chunks: Vec> = source + .commit_chunks_after(&target.heads()) + .into_iter() + .map(|chunk| chunk.bytes) + .collect(); + assert_eq!(chunks.len(), 2); + + let leftover = target + .apply_chunk_bytes([chunks[1].clone()]) + .expect("skip child without parent"); + assert_eq!(leftover.len(), 1); assert_eq!( target .table_at(&Path::from("T")) @@ -905,6 +993,21 @@ mod commits { .row_count(), 0 ); + + let leftover = target + .apply_chunk_bytes( + leftover + .into_iter() + .chain(std::iter::once(chunks[0].clone())), + ) + .expect("retry leftover with parent"); + assert!(leftover.is_empty()); + + let table = target.table_at(&Path::from("T")).expect("table"); + assert_eq!(table.row_count(), 2); + assert_eq!(table.cell_at(0, 0), Some(CellValue::Int(1))); + assert_eq!(table.cell_at(1, 0), Some(CellValue::Int(2))); + assert_eq!(target.heads(), vec![second]); } } diff --git a/packages/coln-store/src/table/col.rs b/packages/coln-store/src/table/col.rs index 489090d9..35d9af9f 100644 --- a/packages/coln-store/src/table/col.rs +++ b/packages/coln-store/src/table/col.rs @@ -52,7 +52,6 @@ impl IdColumn { self.counters.remove(row); } - // TODO use scope_to_value / seek when supported /// Sorted position of `id`: `Ok(row)` when present, `Err(row)` with the /// insertion point otherwise. Requires ids sorted by /// `(commit_idx, counter)`. @@ -72,53 +71,21 @@ impl IdColumn { Ordering::Greater => {} } - // Binary search the rest of the run. Each `get` probe jumps to the - // containing slab through the column index instead of decoding the - // run from the start. - let mut lo = run.start; - let mut hi = run.end - 1; - while lo < hi { - let mid = lo + (hi - lo) / 2; - let counter = self.counters.get(mid).expect("run is in bounds"); - match counter.cmp(&id.counter) { - Ordering::Less => lo = mid + 1, - Ordering::Equal => return Ok(mid), - Ordering::Greater => hi = mid, - } + let found = self.counters.scope_to_value(id.counter, run); + if found.is_empty() { + Err(found.start) + } else { + Ok(found.start) } - Err(lo) } pub(super) fn len(&self) -> usize { self.counters.len() } - // TODO switch to scope_to_value when delta column supports it. pub(super) fn scope_to_value(&self, value: PackedRowId, range: Range) -> Range { let range = self.commit_idxs.scope_to_value(value.commit_idx, range); - - let mut start = range.start; - let mut end = range.end; - while start < end { - let mid = start + (end - start) / 2; - if self.counters.get(mid).expect("range is in bounds") < value.counter { - start = mid + 1; - } else { - end = mid; - } - } - let lower = start; - - end = range.end; - while start < end { - let mid = start + (end - start) / 2; - if self.counters.get(mid).expect("range is in bounds") <= value.counter { - start = mid + 1; - } else { - end = mid; - } - } - lower..start + self.counters.scope_to_value(value.counter, range) } } diff --git a/packages/coln-store/src/table/tests.rs b/packages/coln-store/src/table/tests.rs index 584f6e03..e58b3d95 100644 --- a/packages/coln-store/src/table/tests.rs +++ b/packages/coln-store/src/table/tests.rs @@ -251,7 +251,7 @@ fn staged_delete_removes_row_and_undo_restores_it() { /// scanning the table. Inserts and deletes are the only ways in and out of /// storage, so both have to keep it in step. #[test] -#[ignore = "indexing disabled until we need it"] +#[ignore = "rebuild index disabled until we need it"] fn rebuild_index_tracks_rows_referring_to_an_id() { let mut tbl = TestTable::new(Path::from("edge"), id_schema(&["left", "right"])); let a = row_id_from(1, 0); @@ -647,6 +647,7 @@ fn invalid_primary_key_name_panics_at_construction() { } /// Manual benchmark for the primary key duplicate check on insert. +/// Inserting `n` rows of one integer (the primary key) and one row id. /// Run with: /// cargo test -p coln-store --release pk_insert_benchmark -- --ignored --nocapture #[test] @@ -654,21 +655,30 @@ fn invalid_primary_key_name_panics_at_construction() { fn pk_insert_benchmark() { let schema = ir::Schema { entity_variant: ir::EntityVariant::Table, - columns: vec![ir::ColumnEntry { - path: Path::from("c0"), - col_type: ColType::BuiltinTy { - builtin_ty: BuiltinTy::BuiltinInt, + columns: vec![ + ir::ColumnEntry { + path: Path::from("rid"), + col_type: ColType::RowId { + path: Path::from("T"), + }, }, - }], + ir::ColumnEntry { + path: Path::from("c0"), + col_type: ColType::BuiltinTy { + builtin_ty: BuiltinTy::BuiltinInt, + }, + }, + ], primary_key: Some(vec![Path::from("c0")]), }; let mut tbl = TestTable::new(Path::from("bench"), schema); let n = 50_000; let start = std::time::Instant::now(); for i in 0..n { - let values = vec![CellValue::Int(i)]; + let row_id = test_row_id(i as u32); + let values = vec![CellValue::Id(row_id), CellValue::Int(i)]; tbl.validate_insert(&values).expect("keys are unique"); - tbl.insert_row(values, test_row_id(i as u32)); + tbl.insert_row(values, row_id); } println!("inserted {n} rows with pk check in {:?}", start.elapsed()); } diff --git a/packages/coln-store/src/txn/inner.rs b/packages/coln-store/src/txn/inner.rs index ac02ac33..f75b6920 100644 --- a/packages/coln-store/src/txn/inner.rs +++ b/packages/coln-store/src/txn/inner.rs @@ -31,7 +31,7 @@ pub(crate) struct TxnInner { } impl TxnInner { - pub(crate) fn new(deps: Vec) -> Self { + pub(super) fn new(deps: Vec) -> Self { Self { deps, author: Author::foo(), @@ -66,7 +66,7 @@ impl TxnInner { Ok(temp_id) } - pub(crate) fn add( + pub(super) fn add( &mut self, store: &Store, table: &ir::Path, @@ -108,7 +108,7 @@ impl TxnInner { }); } - pub(crate) fn commit(self, store: &mut Store) -> Result { + pub(super) fn commit(self, store: &mut Store) -> Result { info!(op_count = self.pending.len(), "commit txn"); let TxnInner { deps, @@ -133,22 +133,22 @@ impl TxnInner { let h = cmt.hash(); match store.apply_commit(cmt) { - Ok(()) => { + Ok(None) => { + // Everything applied successfully Self::finalize_handles(pending_handles, h, store); Ok(h) } + Ok(Some(_)) => { + unreachable!("commit a local transaction should always succeed"); + } Err(err) => { Self::invalidate_handles(pending_handles, "txn commit failed"); Err(err) } } - // 1. validate full batch (PK conflicts including intra-batch) - // 2. compute hash: blake3(deps || timestamp || message || canonical(ops)) - // 3. resolve: TxnRowId(k) -> RowId { commit: hash, counter: k } - // CellValue::TxnId(k) -> CellValue::Id(RowId { commit: hash, counter: k }) - // 4. apply resolved Ops to tables via table.insert_row - // 5. check_rules - // 6. push CommitMeta into store.commit_graph, advance heads - // 7. return hash + } + + pub(super) fn abort(self) { + Self::invalidate_handles(self.pending_handles, "txn abort"); } } diff --git a/packages/coln-store/src/txn/mod.rs b/packages/coln-store/src/txn/mod.rs index bc17adf6..30061f30 100644 --- a/packages/coln-store/src/txn/mod.rs +++ b/packages/coln-store/src/txn/mod.rs @@ -55,6 +55,10 @@ impl<'a> Transaction<'a> { self.inner.commit(self.store) } // pub fn commit_with(mut self, opts: CommitOptions) -> Result { ... } + + pub fn abort(self) { + self.inner.abort() + } } pub struct OwnedTransaction { @@ -79,6 +83,11 @@ impl OwnedTransaction { self.inner.add(&self.store, table, values) } + pub fn abort(self) -> Store { + self.inner.abort(); + self.store + } + // We need to return Store to the user for roll back purposes, so the Err variant must be large #[allow(clippy::result_large_err)] pub fn commit(mut self) -> Result<(CommitHash, Store), (StoreError, Store)> { @@ -225,6 +234,39 @@ mod tests { assert_eq!(edge.cell_at(0, 0), Some(CellValue::Id(node_id))); } + #[test] + fn abort_invalidates_returned_row_handles() { + let nodes = Path::from("Nodes"); + let edges = Path::from("Edges"); + let mut store = Store::new(); + store + .create_table(nodes.clone(), table_schema(vec![], None)) + .expect("create nodes table"); + store + .create_table( + edges.clone(), + table_schema(vec![row_id_col("node", nodes.clone())], None), + ) + .expect("create edges table"); + let mut tx = store.transaction(); + let node = tx.add(&nodes, vec![]).expect("add node"); + tx.abort(); + let err = node.row_id().expect_err("abort invalidates handle"); + assert!(matches!( + err, + StoreError::Validation(ValidationError::InvalidRowHandle { .. }) + )); + let mut tx = store.transaction(); + let err = tx + .add(&edges, vec![node.into()]) + .expect_err("aborted handle cannot be reused"); + assert!(matches!( + err, + StoreError::Validation(ValidationError::InvalidRowHandle { .. }) + )); + assert_eq!(store.table_at(&nodes).expect("Nodes").row_count(), 0); + } + #[test] fn failed_transaction_invalidates_returned_row_handles() { let nodes = Path::from("Nodes");