Compare commits
4 Commits
cojson@0.3
...
cojson@0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9007dd1b5 | ||
|
|
ee1e5b06e4 | ||
|
|
fae290c4cf | ||
|
|
381d68019f |
68
.github/workflows/build-and-deploy.yaml
vendored
68
.github/workflows/build-and-deploy.yaml
vendored
@@ -7,11 +7,8 @@ on:
|
|||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
example: ["todo", "pets"]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -23,47 +20,34 @@ jobs:
|
|||||||
node-version: 16
|
node-version: 16
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
cache-dependency-path: yarn.lock
|
cache-dependency-path: yarn.lock
|
||||||
|
- name: Yarn Build
|
||||||
|
run: |
|
||||||
|
yarn install --frozen-lockfile;
|
||||||
|
yarn build;
|
||||||
|
working-directory: ./examples/todo
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- uses: satackey/action-docker-layer-caching@v0.0.11
|
||||||
uses: docker/setup-buildx-action@v2
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
key: docker-layer-caching-${{ github.workflow }}-{hash}
|
||||||
|
restore-keys: |
|
||||||
|
docker-layer-caching-${{ github.workflow }}-
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: gardencmp
|
username: gardencmp
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Nuke Workspace
|
|
||||||
run: |
|
|
||||||
rm package.json yarn.lock;
|
|
||||||
|
|
||||||
- name: Yarn Build
|
|
||||||
run: |
|
|
||||||
yarn install --frozen-lockfile;
|
|
||||||
yarn build;
|
|
||||||
working-directory: ./examples/${{ matrix.example }}
|
|
||||||
|
|
||||||
- name: Docker Build & Push
|
- name: Docker Build & Push
|
||||||
uses: docker/build-push-action@v4
|
run: |
|
||||||
with:
|
export DOCKER_TAG=ghcr.io/gardencmp/jazz-example-todo:${{github.head_ref || github.ref_name}}-${{github.sha}}-$(date +%s) ;
|
||||||
context: ./examples/${{ matrix.example }}
|
docker build . --file Dockerfile --tag $DOCKER_TAG;
|
||||||
push: true
|
docker push $DOCKER_TAG;
|
||||||
tags: ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}}
|
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
|
||||||
cache-from: type=gha
|
working-directory: ./examples/todo
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
example: ["todo", "pets"]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- uses: gacts/install-nomad@v1
|
- uses: gacts/install-nomad@v1
|
||||||
- name: Tailscale
|
- name: Tailscale
|
||||||
uses: tailscale/github-action@v1
|
uses: tailscale/github-action@v1
|
||||||
@@ -82,9 +66,13 @@ jobs:
|
|||||||
|
|
||||||
export DOCKER_USER=gardencmp;
|
export DOCKER_USER=gardencmp;
|
||||||
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
|
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
|
||||||
export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
|
export DOCKER_TAG=${{ env.DOCKER_TAG }};
|
||||||
|
|
||||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
|
for region in ${{ vars.DEPLOY_REGIONS }}
|
||||||
cat job-instance.nomad;
|
do
|
||||||
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
|
export REGION=$region;
|
||||||
working-directory: ./examples/${{ matrix.example }}
|
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN} ${REGION}' < job-template.nomad > job-instance.nomad;
|
||||||
|
cat job-instance.nomad;
|
||||||
|
NOMAD_ADDR='${{ secrets.NOMAD_ADDR }}' nomad job run job-instance.nomad;
|
||||||
|
done
|
||||||
|
working-directory: ./examples/todo
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
lerna-debug.log
|
lerna-debug.log
|
||||||
docsTmp
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"typescript.tsdk": "node_modules/typescript/lib"
|
|
||||||
}
|
|
||||||
117
README.md
117
README.md
@@ -1,116 +1,9 @@
|
|||||||
# Jazz - instant sync
|
# Jazz - instant sync
|
||||||
|
|
||||||
<sub>Homepage: [jazz.tools](https://jazz.tools) — Docs: [DOCS.md](./DOCS.md) — Community & support: [Discord](https://discord.gg/utDMjHYg42) — Updates: [Twitter](https://twitter.com/jazz_tools) & [Email](https://gcmp.io/news)</sub>
|
Jazz is an open-source toolkit for telepathic data.
|
||||||
|
|
||||||
**Jazz is an open-source toolkit for building apps with *secure sync.***
|
Ship faster and simplify frontend, backend & devops by building with Telepathic Data.
|
||||||
|
Get real-time multiplayer and cross-device sync for free.
|
||||||
|
|
||||||
Quickly build and ship apps with:
|
## What is Telepathic Data?
|
||||||
|
...
|
||||||
- **Cross-device sync**
|
|
||||||
- **Collaborative features** (incl. real-time multiplayer)
|
|
||||||
- **Instantly reacting UIs**
|
|
||||||
- Local-first storage & offline support
|
|
||||||
- File upload and real-time media streaming
|
|
||||||
|
|
||||||
# What is *secure sync*?
|
|
||||||
|
|
||||||
**Sync** means that, *instead of making API requests*, you:
|
|
||||||
|
|
||||||
- **Read and write data as if it was local** — from anywhere in your app.
|
|
||||||
- **Always have data synced to wherever it's needed, instantly:** to other devices of the same user, to other users, to your backend, to your local machine for debugging, etc.
|
|
||||||
|
|
||||||
**Secure** means that, *instead of relying on your API or DB for access control*, you:
|
|
||||||
|
|
||||||
- **Set fine-grained, role-based permissions in `Group`s** that are **synced along with your data**.
|
|
||||||
- **Permissions *verifiably enforced* everywhere,** using encryption & signatures under the hood.
|
|
||||||
- **Change roles dynamically** for evolving teams, expiring invite links and more.
|
|
||||||
|
|
||||||
# What's special about Jazz?
|
|
||||||
|
|
||||||
Compared to other libraries and frameworks for local-first, sync-based or real-time apps, these are some of the things that make Jazz unique:
|
|
||||||
|
|
||||||
- **Jazz is a *batteries-included,* vertically integrated toolkit,** offering everything you need to build an app, including auth, permissions, data model, sync, conflict resolution, blob storage, file uploads, real-time media streaming and more.
|
|
||||||
- **Jazz has a *small API surface* of only a few abstractions to learn,** which combine in powerful ways to implement a broad set of features.
|
|
||||||
- **Jazz *granularly* loads and caches *only the data that is needed*,** combining *local-first* instant UI reactivity and offline support with the on-demand data efficiency of conventional APIs
|
|
||||||
- **Jazz supports end-to-end encryption, but doesn't require it,** allowing you to either manage your user's secret keys for them (based on existing auth flows) or letting your users
|
|
||||||
- **Jazz is based on CoJSON, a soon-to-be *open standard,*** which means that there will be a whole ecosystem of compatible libraries and frameworks in a variety of environments — and it will be easy to achieve (secure) interop between Jazz/CoJSON-based apps and services.
|
|
||||||
|
|
||||||
# Jazz Global Mesh
|
|
||||||
|
|
||||||
Jazz is open source and you can run your own sync & storage server, but to really provide you with everything you need, we're also running
|
|
||||||
**[Jazz Global Mesh](https://jazz.tools/mesh)**, a globally distributed mesh of servers optimized for:
|
|
||||||
|
|
||||||
- **Ultra-low-latency sync** (with geo-aware edge caching and optimal routing)
|
|
||||||
- **Low-cost, reliable storage**
|
|
||||||
|
|
||||||
|
|
||||||
**Jazz Global Mesh is free for small volumes of data** and it's the **default syncing peer,** so you can **start building multi-user Jazz apps with persistent data in minutes,** using only frontend code!
|
|
||||||
|
|
||||||
# Getting started
|
|
||||||
|
|
||||||
## Example App Walkthrough
|
|
||||||
|
|
||||||
**For now the best tutorial is the walkthrough of the [Todo List Example App](#todo-list).**
|
|
||||||
|
|
||||||
## General Scenarios
|
|
||||||
|
|
||||||
### Building a new, entirely sync-based React app
|
|
||||||
|
|
||||||
1. Define your data model with [cojson Collaborative Values (CoValues)](./DOCS.md/#covalue).
|
|
||||||
2. Implement permission logic using [cojson Groups](./DOCS.md/#group).
|
|
||||||
3. Build a user interface with [jazz-react](./DOCS.md/#jazz-react)'s reactive [synced queries](./DOCS.md/#usesyncedqueryid).
|
|
||||||
|
|
||||||
### Gradually adding sync to an existing React app
|
|
||||||
|
|
||||||
Gradually migrate app features to use sync:
|
|
||||||
|
|
||||||
1. Define data model for small aspect of your app with [cojson Collaborative Values (CoValues)](./DOCS.md/#covalue).
|
|
||||||
- Schema adapters/importers for Prisma/Drizzle/PostgreSQL introspection coming soon.
|
|
||||||
2. Map existing permission logic with [cojson Groups](./DOCS.md/#group) & integrate existing auth.
|
|
||||||
- Auth integrations coming soon.
|
|
||||||
3. Replace some of the React state and API requests in your UI with [jazz-react](./DOCS.md/#jazz-react)'s reactive [synced queries](./DOCS.md/#usesyncedqueryid).
|
|
||||||
|
|
||||||
# Example Apps
|
|
||||||
|
|
||||||
## Todo List
|
|
||||||
|
|
||||||
**A simple collaborative todo list app.**
|
|
||||||
|
|
||||||
Live version: https://example-todo.jazz.tools
|
|
||||||
|
|
||||||
Source code & walkthrough: [`./examples/todo`](./examples/todo)
|
|
||||||
|
|
||||||
Demonstrates:
|
|
||||||
- Defining a data model with `CoMap`s and `CoList`s
|
|
||||||
- Creating data and setting permissions with `Group`s
|
|
||||||
- Fetching, rendering & editing data from nested `CoValue`s with reactive synced queries
|
|
||||||
|
|
||||||
|
|
||||||
## Rate-My-Pet
|
|
||||||
|
|
||||||
**A simple social polling app.**
|
|
||||||
|
|
||||||
Live version: https://example-pets.jazz.tools
|
|
||||||
|
|
||||||
Source code (walkthrough coming soon): [`./examples/pets`](./examples/pets)
|
|
||||||
|
|
||||||
Demonstrates:
|
|
||||||
- Implementing per-account data streams (reactions) with `CoStream`s
|
|
||||||
- Implementing image upload and progressive image streaming using helpers from `jazz-react-media-images` (on top of CoJSON's `BinaryCoStreams` & `ImageDefinition` convention)
|
|
||||||
|
|
||||||
|
|
||||||
# Documentation & API Reference
|
|
||||||
|
|
||||||
For now, docs are hosted in a single well-structured markdown file: [`./DOCS.md`](./DOCS.md).
|
|
||||||
|
|
||||||
- [Package Overview](./DOCS.md/#overview)
|
|
||||||
- [`jazz-react` API](./DOCS.md/#jazz-react)
|
|
||||||
- [`cojson` API](./DOCS.md/#cojson)
|
|
||||||
- [`jazz-react-media-images` API](./DOCS.md/#jazz-react-media-images)
|
|
||||||
|
|
||||||
|
|
||||||
In the future we'll build a dedicated docs page on the Jazz homepage.
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
Copyright 2023: Garden Computing, Inc.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
],
|
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['react-refresh'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
24
examples/pets/.gitignore
vendored
24
examples/pets/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
FROM caddy:2.7.3-alpine
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
|
||||||
|
|
||||||
COPY ./dist /usr/share/caddy/
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Jazz Rate-My-Pet List Example
|
|
||||||
|
|
||||||
Live version: https://example-pets.jazz.tools
|
|
||||||
|
|
||||||
## Installing & running the example locally
|
|
||||||
|
|
||||||
Start by checking out just the example app to a folder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx degit gardencmp/jazz/examples/pets jazz-example-pets
|
|
||||||
cd jazz-example-pets
|
|
||||||
```
|
|
||||||
|
|
||||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
|
||||||
|
|
||||||
Install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Start the dev server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
## Walkthrough
|
|
||||||
|
|
||||||
### Main parts
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
### Helpers
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
## Questions / problems / feedback
|
|
||||||
|
|
||||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
|
||||||
|
|
||||||
|
|
||||||
## Configuration: sync server
|
|
||||||
|
|
||||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
|
||||||
|
|
||||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/0_main.tsx](./src/0_main.tsx).
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "src/index.css",
|
|
||||||
"baseColor": "stone",
|
|
||||||
"cssVariables": true
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/basicComponents",
|
|
||||||
"utils": "@/basicComponents/lib/utils"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Jazz Rate My Pet Example</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/2_main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
job "example-pets$BRANCH_SUFFIX" {
|
|
||||||
region = "global"
|
|
||||||
datacenters = ["*"]
|
|
||||||
|
|
||||||
group "static" {
|
|
||||||
count = 8
|
|
||||||
|
|
||||||
network {
|
|
||||||
port "http" {
|
|
||||||
to = 80
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constraint {
|
|
||||||
attribute = "${node.class}"
|
|
||||||
operator = "="
|
|
||||||
value = "mesh"
|
|
||||||
}
|
|
||||||
|
|
||||||
spread {
|
|
||||||
attribute = "${node.datacenter}"
|
|
||||||
weight = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
constraint {
|
|
||||||
distinct_hosts = true
|
|
||||||
}
|
|
||||||
|
|
||||||
task "server" {
|
|
||||||
driver = "docker"
|
|
||||||
|
|
||||||
config {
|
|
||||||
image = "$DOCKER_TAG"
|
|
||||||
ports = ["http"]
|
|
||||||
|
|
||||||
auth = {
|
|
||||||
username = "$DOCKER_USER"
|
|
||||||
password = "$DOCKER_PASSWORD"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service {
|
|
||||||
tags = ["public"]
|
|
||||||
name = "example-pets$BRANCH_SUFFIX"
|
|
||||||
port = "http"
|
|
||||||
provider = "consul"
|
|
||||||
}
|
|
||||||
|
|
||||||
resources {
|
|
||||||
cpu = 50 # MHz
|
|
||||||
memory = 50 # MB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# deploy bump 4
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jazz-example-pets",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.12",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
|
||||||
"@radix-ui/react-toast": "^1.1.4",
|
|
||||||
"@types/qrcode": "^1.5.1",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
|
||||||
"clsx": "^2.0.0",
|
|
||||||
"jazz-react": "^0.3.3",
|
|
||||||
"jazz-react-auth-local": "^0.3.3",
|
|
||||||
"jazz-react-media-images": "^0.3.3",
|
|
||||||
"lucide-react": "^0.274.0",
|
|
||||||
"qrcode": "^1.5.3",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-router": "^6.16.0",
|
|
||||||
"react-router-dom": "^6.16.0",
|
|
||||||
"tailwind-merge": "^1.14.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"uniqolor": "^1.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.2.15",
|
|
||||||
"@types/react-dom": "^18.2.7",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
|
||||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
|
||||||
"autoprefixer": "^10.4.14",
|
|
||||||
"eslint": "^8.45.0",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.3",
|
|
||||||
"postcss": "^8.4.27",
|
|
||||||
"tailwindcss": "^3.3.3",
|
|
||||||
"typescript": "^5.0.2",
|
|
||||||
"vite": "^4.4.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,29 +0,0 @@
|
|||||||
import { CoMap, CoStream, Media } from "cojson";
|
|
||||||
|
|
||||||
/** Walkthrough: Defining the data model with CoJSON
|
|
||||||
*
|
|
||||||
* Here, we define our main data model of TODO
|
|
||||||
*
|
|
||||||
* TODO
|
|
||||||
**/
|
|
||||||
|
|
||||||
export type PetPost = CoMap<{
|
|
||||||
name: string;
|
|
||||||
image: Media.ImageDefinition;
|
|
||||||
reactions: PetReactions;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const REACTION_TYPES = [
|
|
||||||
"aww",
|
|
||||||
"love",
|
|
||||||
"haha",
|
|
||||||
"wow",
|
|
||||||
"tiny",
|
|
||||||
"chonkers",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type ReactionType = (typeof REACTION_TYPES)[number];
|
|
||||||
|
|
||||||
export type PetReactions = CoStream<ReactionType>;
|
|
||||||
|
|
||||||
/** Walkthrough: Continue with ./2_App.tsx */
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import { RouterProvider, createHashRouter } from "react-router-dom";
|
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
|
|
||||||
import { LocalAuth } from "jazz-react-auth-local";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
ThemeProvider,
|
|
||||||
TitleAndLogo,
|
|
||||||
} from "./basicComponents/index.ts";
|
|
||||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
|
||||||
import { NewPetPostForm } from "./3_NewPetPostForm.tsx";
|
|
||||||
import { RatePetPostUI } from "./4_RatePetPostUI.tsx";
|
|
||||||
|
|
||||||
/** Walkthrough: The top-level provider `<WithJazz/>`
|
|
||||||
*
|
|
||||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
|
||||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
|
||||||
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
|
|
||||||
* - no backend needed. */
|
|
||||||
|
|
||||||
const appName = "Jazz Rate My Pet Example";
|
|
||||||
|
|
||||||
const auth = LocalAuth({
|
|
||||||
appName,
|
|
||||||
Component: PrettyAuthUI,
|
|
||||||
});
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<WithJazz auth={auth}>
|
|
||||||
<App />
|
|
||||||
</WithJazz>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Walkthrough: Creating pet posts & routing in `<App/>`
|
|
||||||
*
|
|
||||||
* <App> is the main app component, handling client-side routing based
|
|
||||||
* on the CoValue ID (CoID) of our PetPost, stored in the URL hash
|
|
||||||
* - which can also contain invite links.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { logOut } = useJazz();
|
|
||||||
|
|
||||||
const router = createHashRouter([
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
element: <NewPetPostForm />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/pet/:petPostId",
|
|
||||||
element: <RatePetPostUI />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/invite/*",
|
|
||||||
element: <p>Accepting invite...</p>,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
useAcceptInvite((petPostID) => router.navigate("/pet/" + petPostID));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<TitleAndLogo name={appName} />
|
|
||||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => router.navigate("/").then(logOut)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Log Out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walkthrough: continue with ./3_CreatePetPostForm.tsx */
|
|
||||||
|
|
||||||
/** Walkthrough: Continue with ./1_types.ts */
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { ChangeEvent, useCallback, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
|
|
||||||
import { CoID, CoMap, Media } from "cojson";
|
|
||||||
import { useJazz, useSyncedQuery } from "jazz-react";
|
|
||||||
import { createImage } from "jazz-browser-media-images";
|
|
||||||
|
|
||||||
import { PetReactions } from "./1_types";
|
|
||||||
|
|
||||||
import { Input, Button } from "./basicComponents";
|
|
||||||
import { useLoadImage } from "jazz-react-media-images";
|
|
||||||
|
|
||||||
/** Walkthrough: TODO
|
|
||||||
*/
|
|
||||||
|
|
||||||
type PartialPetPost = CoMap<{
|
|
||||||
name: string;
|
|
||||||
image?: Media.ImageDefinition;
|
|
||||||
reactions: PetReactions;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export function NewPetPostForm() {
|
|
||||||
const { localNode } = useJazz();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [newPostId, setNewPostId] = useState<
|
|
||||||
CoID<PartialPetPost> | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const newPetPost = useSyncedQuery(newPostId);
|
|
||||||
|
|
||||||
const onChangeName = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
if (newPetPost) {
|
|
||||||
newPetPost.set({ name });
|
|
||||||
} else {
|
|
||||||
const petPostGroup = localNode.createGroup();
|
|
||||||
const petPost = petPostGroup.createMap<PartialPetPost>({
|
|
||||||
name,
|
|
||||||
reactions: petPostGroup.createStream<PetReactions>(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setNewPostId(petPost.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[localNode, newPetPost]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onImageSelected = useCallback(
|
|
||||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!newPetPost || !event.target.files) return;
|
|
||||||
|
|
||||||
const image = await createImage(
|
|
||||||
event.target.files[0],
|
|
||||||
newPetPost.group
|
|
||||||
);
|
|
||||||
|
|
||||||
newPetPost.set({ image });
|
|
||||||
},
|
|
||||||
[newPetPost]
|
|
||||||
);
|
|
||||||
|
|
||||||
const petImage = useLoadImage(newPetPost?.image?.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-10">
|
|
||||||
<p>Share your pet with friends!</p>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Pet Name"
|
|
||||||
className="text-3xl py-6"
|
|
||||||
onChange={(event) => onChangeName(event.target.value)}
|
|
||||||
value={newPetPost?.name || ""}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{petImage ? (
|
|
||||||
<img
|
|
||||||
className="w-80 max-w-full rounded"
|
|
||||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
disabled={!newPetPost?.name}
|
|
||||||
onChange={onImageSelected}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{newPetPost?.name && newPetPost?.image && (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
navigate("/pet/" + newPetPost.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Submit Post
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { useParams } from "react-router";
|
|
||||||
import { CoID, Queried } from "cojson";
|
|
||||||
import { useSyncedQuery } from "jazz-react";
|
|
||||||
|
|
||||||
import { PetPost, ReactionType, REACTION_TYPES, PetReactions } from "./1_types";
|
|
||||||
|
|
||||||
import { ShareButton } from "./components/ShareButton";
|
|
||||||
import { Button, Skeleton } from "./basicComponents";
|
|
||||||
import { useLoadImage } from "jazz-react-media-images";
|
|
||||||
import uniqolor from "uniqolor";
|
|
||||||
|
|
||||||
/** Walkthrough: TODO
|
|
||||||
*/
|
|
||||||
|
|
||||||
const reactionEmojiMap: { [reaction in ReactionType]: string } = {
|
|
||||||
aww: "😍",
|
|
||||||
love: "❤️",
|
|
||||||
haha: "😂",
|
|
||||||
wow: "😮",
|
|
||||||
tiny: "🐥",
|
|
||||||
chonkers: "🐘",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RatePetPostUI() {
|
|
||||||
const petPostID = useParams<{ petPostId: CoID<PetPost> }>().petPostId;
|
|
||||||
|
|
||||||
const petPost = useSyncedQuery(petPostID);
|
|
||||||
const petImage = useLoadImage(petPost?.image);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<h1 className="text-3xl font-bold">{petPost?.name}</h1>
|
|
||||||
<ShareButton petPost={petPost} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{petImage && (
|
|
||||||
<img
|
|
||||||
className="w-80 max-w-full rounded"
|
|
||||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between max-w-xs flex-wrap">
|
|
||||||
{REACTION_TYPES.map((reactionType) => (
|
|
||||||
<Button
|
|
||||||
key={reactionType}
|
|
||||||
variant={
|
|
||||||
petPost?.reactions?.me?.last === reactionType
|
|
||||||
? "default"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
petPost?.reactions?.push(reactionType);
|
|
||||||
}}
|
|
||||||
title={`React with ${reactionType}`}
|
|
||||||
className="text-2xl px-2"
|
|
||||||
>
|
|
||||||
{reactionEmojiMap[reactionType]}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{petPost?.group.myRole() === "admin" && petPost.reactions && (
|
|
||||||
<ReactionOverview petReactions={petPost.reactions} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionOverview({
|
|
||||||
petReactions,
|
|
||||||
}: {
|
|
||||||
petReactions: Queried<PetReactions>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Reactions</h2>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{REACTION_TYPES.map((reactionType) => {
|
|
||||||
const reactionsOfThisType = Object.values(
|
|
||||||
petReactions.perAccount
|
|
||||||
).filter(({ last }) => last === reactionType);
|
|
||||||
|
|
||||||
if (reactionsOfThisType.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
key={reactionType}
|
|
||||||
>
|
|
||||||
{reactionEmojiMap[reactionType]}{" "}
|
|
||||||
{reactionsOfThisType.map((reaction, idx) =>
|
|
||||||
reaction.by?.profile?.name ? (
|
|
||||||
<span
|
|
||||||
className="rounded-full py-0.5 px-2 text-xs"
|
|
||||||
style={uniqueColoring(reaction.by.id)}
|
|
||||||
key={reaction.by.id}
|
|
||||||
>
|
|
||||||
{reaction.by.profile.name}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Skeleton
|
|
||||||
className="mt-1 w-[50px] h-[1em] rounded-full"
|
|
||||||
key={idx}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueColoring(seed: string) {
|
|
||||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: uniqolor(seed, { lightness: darkMode ? 80 : 20 }).color,
|
|
||||||
background: uniqolor(seed, { lightness: darkMode ? 20 : 80 }).color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Toaster } from ".";
|
|
||||||
|
|
||||||
export function TitleAndLogo({ name }: { name: string }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 justify-center mt-5">
|
|
||||||
<img src="jazz-logo.png" className="h-5" /> {name}
|
|
||||||
</div>
|
|
||||||
<Toaster />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { Button } from "./ui/button";
|
|
||||||
export { Input } from "./ui/input";
|
|
||||||
export { Toaster } from "./ui/toaster";
|
|
||||||
export { useToast } from "./ui/use-toast";
|
|
||||||
export { Skeleton } from "./ui/skeleton";
|
|
||||||
export { TitleAndLogo } from "./TitleAndLogo";
|
|
||||||
export { ThemeProvider } from "./themeProvider";
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
defaultTheme?: string;
|
|
||||||
storageKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ThemeProviderState = {
|
|
||||||
theme: string;
|
|
||||||
setTheme: (theme: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
theme: "system",
|
|
||||||
setTheme: () => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
|
||||||
|
|
||||||
export function ThemeProvider({
|
|
||||||
children,
|
|
||||||
defaultTheme = "system",
|
|
||||||
storageKey = "vite-ui-theme",
|
|
||||||
...props
|
|
||||||
}: ThemeProviderProps) {
|
|
||||||
const [theme, setTheme] = useState(
|
|
||||||
() => localStorage.getItem(storageKey) || defaultTheme
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = window.document.documentElement;
|
|
||||||
|
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
|
|
||||||
if (theme === "system") {
|
|
||||||
const systemTheme = window.matchMedia(
|
|
||||||
"(prefers-color-scheme: dark)"
|
|
||||||
).matches
|
|
||||||
? "dark"
|
|
||||||
: "light";
|
|
||||||
|
|
||||||
root.classList.add(systemTheme);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.classList.add(theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
theme,
|
|
||||||
setTheme: (theme: string) => {
|
|
||||||
localStorage.setItem(storageKey, theme);
|
|
||||||
setTheme(theme);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProviderContext.Provider {...props} value={value}>
|
|
||||||
{children}
|
|
||||||
</ThemeProviderContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTheme = () => {
|
|
||||||
const context = useContext(ThemeProviderContext);
|
|
||||||
|
|
||||||
if (context === undefined)
|
|
||||||
throw new Error("useTheme must be used within a ThemeProvider");
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
|
||||||
|
|
||||||
export interface InputProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, type, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Input.displayName = "Input"
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { cn } from "@/basicComponents/lib/utils"
|
|
||||||
|
|
||||||
function Skeleton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Skeleton }
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
|
||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider
|
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Viewport
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
|
||||||
|
|
||||||
const toastVariants = cva(
|
|
||||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "border bg-background text-foreground",
|
|
||||||
destructive:
|
|
||||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
|
||||||
VariantProps<typeof toastVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<ToastPrimitives.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(toastVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Toast.displayName = ToastPrimitives.Root.displayName
|
|
||||||
|
|
||||||
const ToastAction = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Action
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
|
||||||
|
|
||||||
const ToastClose = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Close
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
toast-close=""
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</ToastPrimitives.Close>
|
|
||||||
))
|
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm opacity-90", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
|
||||||
|
|
||||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
|
||||||
|
|
||||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
|
||||||
|
|
||||||
export {
|
|
||||||
type ToastProps,
|
|
||||||
type ToastActionElement,
|
|
||||||
ToastProvider,
|
|
||||||
ToastViewport,
|
|
||||||
Toast,
|
|
||||||
ToastTitle,
|
|
||||||
ToastDescription,
|
|
||||||
ToastClose,
|
|
||||||
ToastAction,
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import {
|
|
||||||
Toast,
|
|
||||||
ToastClose,
|
|
||||||
ToastDescription,
|
|
||||||
ToastProvider,
|
|
||||||
ToastTitle,
|
|
||||||
ToastViewport,
|
|
||||||
} from "@/basicComponents/ui/toast"
|
|
||||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
|
||||||
|
|
||||||
export function Toaster() {
|
|
||||||
const { toasts } = useToast()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastProvider>
|
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
|
||||||
return (
|
|
||||||
<Toast key={id} {...props}>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
|
||||||
{description && (
|
|
||||||
<ToastDescription>{description}</ToastDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{action}
|
|
||||||
<ToastClose />
|
|
||||||
</Toast>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
// Inspired by react-hot-toast library
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ToastActionElement,
|
|
||||||
ToastProps,
|
|
||||||
} from "@/basicComponents/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
|
||||||
id: string
|
|
||||||
title?: React.ReactNode
|
|
||||||
description?: React.ReactNode
|
|
||||||
action?: ToastActionElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionTypes = {
|
|
||||||
ADD_TOAST: "ADD_TOAST",
|
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
function genId() {
|
|
||||||
count = (count + 1) % Number.MAX_VALUE
|
|
||||||
return count.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| {
|
|
||||||
type: ActionType["ADD_TOAST"]
|
|
||||||
toast: ToasterToast
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["UPDATE_TOAST"]
|
|
||||||
toast: Partial<ToasterToast>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["DISMISS_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["REMOVE_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
toasts: ToasterToast[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
|
||||||
if (toastTimeouts.has(toastId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
toastTimeouts.delete(toastId)
|
|
||||||
dispatch({
|
|
||||||
type: "REMOVE_TOAST",
|
|
||||||
toastId: toastId,
|
|
||||||
})
|
|
||||||
}, TOAST_REMOVE_DELAY)
|
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
|
||||||
switch (action.type) {
|
|
||||||
case "ADD_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
|
||||||
const { toastId } = action
|
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|
||||||
// but I'll keep it here for simplicity
|
|
||||||
if (toastId) {
|
|
||||||
addToRemoveQueue(toastId)
|
|
||||||
} else {
|
|
||||||
state.toasts.forEach((toast) => {
|
|
||||||
addToRemoveQueue(toast.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === toastId || toastId === undefined
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "REMOVE_TOAST":
|
|
||||||
if (action.toastId === undefined) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] }
|
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
|
||||||
memoryState = reducer(memoryState, action)
|
|
||||||
listeners.forEach((listener) => {
|
|
||||||
listener(memoryState)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
|
||||||
const id = genId()
|
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_TOAST",
|
|
||||||
toast: { ...props, id },
|
|
||||||
})
|
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "ADD_TOAST",
|
|
||||||
toast: {
|
|
||||||
...props,
|
|
||||||
id,
|
|
||||||
open: true,
|
|
||||||
onOpenChange: (open) => {
|
|
||||||
if (!open) dismiss()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
dismiss,
|
|
||||||
update,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useToast() {
|
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
listeners.push(setState)
|
|
||||||
return () => {
|
|
||||||
const index = listeners.indexOf(setState)
|
|
||||||
if (index > -1) {
|
|
||||||
listeners.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toast,
|
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useToast, toast }
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
|
||||||
|
|
||||||
import { Input, Button } from "../basicComponents";
|
|
||||||
|
|
||||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
|
||||||
loading,
|
|
||||||
logIn,
|
|
||||||
signUp,
|
|
||||||
}) => {
|
|
||||||
const [username, setUsername] = useState<string>("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center p-5">
|
|
||||||
{loading ? (
|
|
||||||
<div>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-72 flex flex-col gap-4">
|
|
||||||
<form
|
|
||||||
className="w-72 flex flex-col gap-2"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
signUp(username);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Display name"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
autoComplete="webauthn"
|
|
||||||
className="text-base"
|
|
||||||
/>
|
|
||||||
<Button asChild>
|
|
||||||
<Input
|
|
||||||
type="submit"
|
|
||||||
value="Sign Up as new account"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
<Button onClick={logIn}>
|
|
||||||
Log In with existing account
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { PetPost } from "../1_types";
|
|
||||||
|
|
||||||
import { createInviteLink } from "jazz-react";
|
|
||||||
import QRCode from "qrcode";
|
|
||||||
|
|
||||||
import { useToast, Button } from "../basicComponents";
|
|
||||||
import { Queried } from "cojson";
|
|
||||||
|
|
||||||
export function ShareButton({ petPost }: { petPost?: Queried<PetPost> }) {
|
|
||||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
return (
|
|
||||||
petPost?.group.myRole() === "admin" && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="py-0"
|
|
||||||
disabled={!petPost}
|
|
||||||
variant="outline"
|
|
||||||
onClick={async () => {
|
|
||||||
let inviteLink = existingInviteLink;
|
|
||||||
if (petPost && !inviteLink) {
|
|
||||||
inviteLink = createInviteLink(petPost, "writer");
|
|
||||||
setExistingInviteLink(inviteLink);
|
|
||||||
}
|
|
||||||
if (inviteLink) {
|
|
||||||
const qr = await QRCode.toDataURL(inviteLink, {
|
|
||||||
errorCorrectionLevel: "L",
|
|
||||||
});
|
|
||||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
|
||||||
toast({
|
|
||||||
title: "Copied invite link to clipboard!",
|
|
||||||
description: (
|
|
||||||
<img src={qr} className="w-20 h-20" />
|
|
||||||
),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1
examples/pets/src/vite-env.d.ts
vendored
1
examples/pets/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: [
|
|
||||||
'./pages/**/*.{ts,tsx}',
|
|
||||||
'./components/**/*.{ts,tsx}',
|
|
||||||
'./app/**/*.{ts,tsx}',
|
|
||||||
'./src/**/*.{ts,tsx}',
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: "2rem",
|
|
||||||
screens: {
|
|
||||||
"2xl": "1400px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: "hsl(var(--border))",
|
|
||||||
input: "hsl(var(--input))",
|
|
||||||
ring: "hsl(var(--ring))",
|
|
||||||
background: "hsl(var(--background))",
|
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "hsl(var(--primary))",
|
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: "hsl(var(--popover))",
|
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
DEFAULT: "hsl(var(--card))",
|
|
||||||
foreground: "hsl(var(--card-foreground))",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: "var(--radius)",
|
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
"accordion-down": {
|
|
||||||
from: { height: 0 },
|
|
||||||
to: { height: "var(--radix-accordion-content-height)" },
|
|
||||||
},
|
|
||||||
"accordion-up": {
|
|
||||||
from: { height: "var(--radix-accordion-content-height)" },
|
|
||||||
to: { height: 0 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require("tailwindcss-animate")],
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react-swc'
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
minify: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
FROM caddy:2.7.3-alpine
|
FROM caddy:2.7.3-alpine
|
||||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||||
|
|
||||||
COPY ./dist /usr/share/caddy/
|
COPY ./dist /usr/share/caddy/
|
||||||
|
|
||||||
|
RUN caddy
|
||||||
@@ -1,64 +1,27 @@
|
|||||||
# Jazz Todo List Example
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
Live version: https://example-todo.jazz.tools
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
## Installing & running the example locally
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
Start by checking out just the example app to a folder:
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
```bash
|
## Expanding the ESLint configuration
|
||||||
npx degit gardencmp/jazz/examples/todo jazz-example-todo
|
|
||||||
cd jazz-example-todo
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||||
|
|
||||||
|
- Configure the top-level `parserOptions` property like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||||
|
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||||
Install dependencies:
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Start the dev server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
- [`src/basicComponents`](./src/basicComponents): simple components to build the UI, unrelated to Jazz (uses [shadcn/ui](https://ui.shadcn.com))
|
|
||||||
- [`src/components`](./src/components/): helper components that do contain Jazz-specific logic, but aren't very relevant to understand the basics of Jazz and CoJSON
|
|
||||||
- [`src/1_types.ts`](./src/1_types.ts),
|
|
||||||
[`src/2_main.tsx`](./src/2_main.tsx),
|
|
||||||
[`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx),
|
|
||||||
[`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx): the main files for this example, see the walkthrough below
|
|
||||||
|
|
||||||
## Walkthrough
|
|
||||||
|
|
||||||
### Main parts
|
|
||||||
|
|
||||||
1. Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
|
||||||
|
|
||||||
2. The top-level provider `<WithJazz/>` and routing: [`src/2_main.tsx`](./src/2_main.tsx)
|
|
||||||
|
|
||||||
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
|
|
||||||
|
|
||||||
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx)
|
|
||||||
|
|
||||||
### Helpers
|
|
||||||
|
|
||||||
- (not yet explained) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
|
||||||
|
|
||||||
This is the whole Todo List app!
|
|
||||||
|
|
||||||
## Questions / problems / feedback
|
|
||||||
|
|
||||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
|
||||||
|
|
||||||
|
|
||||||
## Configuration: sync server
|
|
||||||
|
|
||||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
|
||||||
|
|
||||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"cssVariables": true
|
"cssVariables": true
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/basicComponents",
|
"components": "@/components",
|
||||||
"utils": "@/basicComponents/lib/utils"
|
"utils": "@/lib/utils"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Jazz Todo List Example</title>
|
<title>Vite + React + TS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/2_main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
job "example-todo$BRANCH_SUFFIX" {
|
job "example-todo$BRANCH_SUFFIX" {
|
||||||
region = "global"
|
region = "$REGION"
|
||||||
datacenters = ["*"]
|
datacenters = ["$REGION"]
|
||||||
|
|
||||||
group "static" {
|
group "static" {
|
||||||
count = 8
|
// count = 3
|
||||||
|
|
||||||
network {
|
network {
|
||||||
port "http" {
|
port "http" {
|
||||||
@@ -14,17 +14,13 @@ job "example-todo$BRANCH_SUFFIX" {
|
|||||||
constraint {
|
constraint {
|
||||||
attribute = "${node.class}"
|
attribute = "${node.class}"
|
||||||
operator = "="
|
operator = "="
|
||||||
value = "mesh"
|
value = "edge"
|
||||||
}
|
}
|
||||||
|
|
||||||
spread {
|
// spread {
|
||||||
attribute = "${node.datacenter}"
|
// attribute = "${node.datacenter}"
|
||||||
weight = 100
|
// weight = 100
|
||||||
}
|
// }
|
||||||
|
|
||||||
constraint {
|
|
||||||
distinct_hosts = true
|
|
||||||
}
|
|
||||||
|
|
||||||
task "server" {
|
task "server" {
|
||||||
driver = "docker"
|
driver = "docker"
|
||||||
@@ -41,7 +37,9 @@ job "example-todo$BRANCH_SUFFIX" {
|
|||||||
|
|
||||||
service {
|
service {
|
||||||
tags = ["public"]
|
tags = ["public"]
|
||||||
name = "example-todo$BRANCH_SUFFIX"
|
meta {
|
||||||
|
public_name = "${BRANCH_SUBDOMAIN}example-todo"
|
||||||
|
}
|
||||||
port = "http"
|
port = "http"
|
||||||
provider = "consul"
|
provider = "consul"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "jazz-example-todo",
|
"name": "jazz-example-todo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.37",
|
"version": "0.0.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -12,21 +12,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-toast": "^1.1.4",
|
|
||||||
"@types/qrcode": "^1.5.1",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"jazz-react": "^0.3.3",
|
"jazz-react": "^0.0.10",
|
||||||
"jazz-react-auth-local": "^0.3.3",
|
"jazz-react-auth-local": "^0.0.7",
|
||||||
"lucide-react": "^0.274.0",
|
"lucide-react": "^0.265.0",
|
||||||
"qrcode": "^1.5.3",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router": "^6.16.0",
|
|
||||||
"react-router-dom": "^6.16.0",
|
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.6"
|
||||||
"uniqolor": "^1.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
1
examples/todo/public/vite.svg
Normal file
1
examples/todo/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,23 +0,0 @@
|
|||||||
import { CoMap, CoList } from "cojson";
|
|
||||||
|
|
||||||
/** Walkthrough: Defining the data model with CoJSON
|
|
||||||
*
|
|
||||||
* Here, we define our main data model of tasks, lists of tasks and projects
|
|
||||||
* using CoJSON's collaborative map and list types, CoMap & CoList.
|
|
||||||
*
|
|
||||||
* CoMap values and CoLists items can contain:
|
|
||||||
* - arbitrary immutable JSON
|
|
||||||
* - references to other CoValues (internally stored by their CoID)
|
|
||||||
**/
|
|
||||||
|
|
||||||
/** An individual task which collaborators can tick or rename */
|
|
||||||
export type Task = CoMap<{ done: boolean; text: string; }>;
|
|
||||||
|
|
||||||
/** Our top level object: a project with a title, referencing a list of tasks */
|
|
||||||
export type TodoProject = CoMap<{
|
|
||||||
title: string;
|
|
||||||
/** A collaborative, ordered list of tasks */
|
|
||||||
tasks: CoList<Task>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/** Walkthrough: Continue with ./2_main.tsx */
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import { RouterProvider, createHashRouter } from "react-router-dom";
|
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
|
|
||||||
import { LocalAuth } from "jazz-react-auth-local";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
ThemeProvider,
|
|
||||||
TitleAndLogo,
|
|
||||||
} from "./basicComponents/index.ts";
|
|
||||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
|
||||||
import { NewProjectForm } from "./3_NewProjectForm.tsx";
|
|
||||||
import { ProjectTodoTable } from "./4_ProjectTodoTable.tsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walkthrough: The top-level provider `<WithJazz/>`
|
|
||||||
*
|
|
||||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
|
||||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
|
||||||
* based on `LocalAuth` that uses Passkeys (aka WebAuthn) to store a user's account secret
|
|
||||||
* - no backend needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const appName = "Jazz Todo List Example";
|
|
||||||
|
|
||||||
const auth = LocalAuth({
|
|
||||||
appName,
|
|
||||||
Component: PrettyAuthUI,
|
|
||||||
});
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<WithJazz auth={auth}>
|
|
||||||
<App />
|
|
||||||
</WithJazz>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Routing in `<App/>`
|
|
||||||
*
|
|
||||||
* <App> is the main app component, handling client-side routing based
|
|
||||||
* on the CoValue ID (CoID) of our TodoProject, stored in the URL hash
|
|
||||||
* - which can also contain invite links.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
// logOut logs out the AuthProvider passed to `<WithJazz/>` above.
|
|
||||||
const { logOut } = useJazz();
|
|
||||||
|
|
||||||
const router = createHashRouter([
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
element: <NewProjectForm />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/project/:projectId",
|
|
||||||
element: <ProjectTodoTable />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/invite/*",
|
|
||||||
element: <p>Accepting invite...</p>
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
// `useAcceptInvite()` is a hook that accepts an invite link from the URL hash,
|
|
||||||
// and on success calls our callback where we navigate to the project that we were just invited to.
|
|
||||||
useAcceptInvite((projectID) => router.navigate("/project/" + projectID));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<TitleAndLogo name={appName} />
|
|
||||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
|
||||||
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => router.navigate("/").then(logOut)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Log Out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walkthrough: Continue with ./3_NewProjectForm.tsx */
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useJazz } from "jazz-react";
|
|
||||||
|
|
||||||
import { Task, TodoProject } from "./1_types";
|
|
||||||
|
|
||||||
import { SubmittableInput } from "./basicComponents";
|
|
||||||
|
|
||||||
import { CoList } from "cojson";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
|
|
||||||
export function NewProjectForm() {
|
|
||||||
// A `LocalNode` represents a local view of loaded & created CoValues.
|
|
||||||
// It is associated with a current user account, which will determine
|
|
||||||
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
|
|
||||||
const { localNode } = useJazz();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const createProject = useCallback(
|
|
||||||
(title: string) => {
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
// To create a new todo project, we first create a `Group`,
|
|
||||||
// which is a scope for defining access rights (reader/writer/admin)
|
|
||||||
// of its members, which will apply to all CoValues owned by that group.
|
|
||||||
const projectGroup = localNode.createGroup();
|
|
||||||
|
|
||||||
// Then we create an empty todo project within that group
|
|
||||||
const project = projectGroup.createMap<TodoProject>({
|
|
||||||
title,
|
|
||||||
tasks: projectGroup.createList<CoList<Task>>(),
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate("/project/" + project.id);
|
|
||||||
},
|
|
||||||
[localNode, navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubmittableInput
|
|
||||||
onSubmit={createProject}
|
|
||||||
label="Create New Project"
|
|
||||||
placeholder="New project title"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walkthrough: continue with ./4_ProjectTodoTable.tsx */
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { CoID, Queried } from "cojson";
|
|
||||||
import { useSyncedQuery } from "jazz-react";
|
|
||||||
|
|
||||||
import { TodoProject, Task } from "./1_types";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Checkbox,
|
|
||||||
SubmittableInput,
|
|
||||||
Skeleton,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "./basicComponents";
|
|
||||||
|
|
||||||
import { InviteButton } from "./components/InviteButton";
|
|
||||||
import uniqolor from "uniqolor";
|
|
||||||
import { useParams } from "react-router";
|
|
||||||
|
|
||||||
/** Walkthrough: Reactively rendering a todo project as a table,
|
|
||||||
* adding and editing tasks
|
|
||||||
*
|
|
||||||
* Here in `<TodoTable/>`, we use `useSyncedQuery()` for the first time,
|
|
||||||
* in this case to load the CoValue for our `TodoProject` as well as
|
|
||||||
* the `ListOfTasks` referenced in it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function ProjectTodoTable() {
|
|
||||||
const projectId = useParams<{ projectId: CoID<TodoProject> }>().projectId;
|
|
||||||
|
|
||||||
// `useSyncedQuery()` reactively subscribes to updates to a CoValue's
|
|
||||||
// content - whether we create edits locally, load persisted data, or receive
|
|
||||||
// sync updates from other devices or participants!
|
|
||||||
// It also recursively resolves and subsribes to all referenced CoValues.
|
|
||||||
const project = useSyncedQuery(projectId);
|
|
||||||
|
|
||||||
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
|
|
||||||
// for a new task (in the same group as the project), and then
|
|
||||||
// adding that as an item to the project's list of tasks.
|
|
||||||
const createTask = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
if (!project?.tasks || !text) return;
|
|
||||||
const task = project.group.createMap<Task>({
|
|
||||||
done: false,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
// project.tasks is immutable, but `append` will create an edit
|
|
||||||
// that will cause useSyncedQuery to rerender this component
|
|
||||||
// - here and on other devices!
|
|
||||||
project.tasks.append(task);
|
|
||||||
},
|
|
||||||
[project?.tasks, project?.group]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-full w-4xl">
|
|
||||||
<div className="flex justify-between items-center gap-4 mb-4">
|
|
||||||
<h1>
|
|
||||||
{
|
|
||||||
// This is how we can access properties from the project query,
|
|
||||||
// accounting for the fact that note everything might be loaded yet
|
|
||||||
project?.title ? (
|
|
||||||
<>
|
|
||||||
{project.title}{" "}
|
|
||||||
<span className="text-sm">({project.id})</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</h1>
|
|
||||||
<InviteButton value={project} />
|
|
||||||
</div>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[40px]">Done</TableHead>
|
|
||||||
<TableHead>Task</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{project?.tasks?.map(
|
|
||||||
(task) => task && <TaskRow key={task.id} task={task} />
|
|
||||||
)}
|
|
||||||
<NewTaskInputRow
|
|
||||||
createTask={createTask}
|
|
||||||
disabled={!project}
|
|
||||||
/>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskRow({ task }: { task: Queried<Task> | undefined }) {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<Checkbox
|
|
||||||
className="mt-1"
|
|
||||||
checked={task?.done}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
// Tick or untick the task
|
|
||||||
// Task is also immutable, but this will update all queries
|
|
||||||
// that include this task as a reference
|
|
||||||
task?.set({ done: !!checked });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-between items-center gap-2">
|
|
||||||
{task?.text ? (
|
|
||||||
<span className={task?.done ? "line-through" : ""}>
|
|
||||||
{task.text}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
|
||||||
)}
|
|
||||||
{
|
|
||||||
// Here we see for the first time how we can access edit history
|
|
||||||
// for a CoValue, and use it to display who created the task.
|
|
||||||
task?.edits.text?.by?.profile?.name ? (
|
|
||||||
<span
|
|
||||||
className="rounded-full py-0.5 px-2 text-xs"
|
|
||||||
style={uniqueColoring(task.edits.text.by.id)}
|
|
||||||
>
|
|
||||||
{task.edits.text.by.profile.name}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walkthrough: This is the end of the walkthrough so far! */
|
|
||||||
|
|
||||||
function NewTaskInputRow({
|
|
||||||
createTask,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
createTask: (text: string) => void;
|
|
||||||
disabled: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<Checkbox className="mt-1" disabled />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<SubmittableInput
|
|
||||||
onSubmit={(taskText) => createTask(taskText)}
|
|
||||||
label="Add"
|
|
||||||
placeholder="New task"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueColoring(seed: string) {
|
|
||||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: uniqolor(seed, { lightness: darkMode ? 80 : 20 }).color,
|
|
||||||
background: uniqolor(seed, { lightness: darkMode ? 20 : 80 }).color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
158
examples/todo/src/App.tsx
Normal file
158
examples/todo/src/App.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { CoMap, CoID } from "cojson";
|
||||||
|
import { useJazz, useTelepathicState } from "jazz-react";
|
||||||
|
|
||||||
|
type TaskContent = { done: boolean; text: string };
|
||||||
|
type Task = CoMap<TaskContent>;
|
||||||
|
|
||||||
|
type TodoListContent = { title: string; [taskId: CoID<Task>]: true };
|
||||||
|
type TodoList = CoMap<TodoListContent>;
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [listId, setListId] = useState<CoID<TodoList>>(window.location.hash.slice(1) as CoID<TodoList>);
|
||||||
|
|
||||||
|
const { localNode } = useJazz();
|
||||||
|
|
||||||
|
const createList = () => {
|
||||||
|
const listTeam = localNode.createTeam();
|
||||||
|
const list = listTeam.createMap<TodoListContent>();
|
||||||
|
|
||||||
|
list.edit((list) => {
|
||||||
|
list.set("title", "My Todo List");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.hash = list.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = () => {
|
||||||
|
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||||
|
}
|
||||||
|
window.addEventListener("hashchange", listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("hashchange", listener);
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 md:pt-[30vh] pb-10">
|
||||||
|
{listId && <TodoList listId={listId} />}
|
||||||
|
<Button onClick={createList}>Create New List</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||||
|
const list = useTelepathicState(listId);
|
||||||
|
|
||||||
|
const createTodo = (text: string) => {
|
||||||
|
if (!list) return;
|
||||||
|
let task = list.coValue.getTeam().createMap<TaskContent>();
|
||||||
|
|
||||||
|
task = task.edit((task) => {
|
||||||
|
task.set("text", text);
|
||||||
|
task.set("done", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Created task", task.id, task.toJSON());
|
||||||
|
|
||||||
|
const listAfter = list.edit((list) => {
|
||||||
|
list.set(task.id, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Updated list", listAfter.toJSON());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-full w-4xl">
|
||||||
|
<h1>
|
||||||
|
{list?.get("title")} ({list?.id})
|
||||||
|
</h1>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">Done</TableHead>
|
||||||
|
<TableHead>Task</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list &&
|
||||||
|
list
|
||||||
|
.keys()
|
||||||
|
.filter((key): key is CoID<Task> =>
|
||||||
|
key.startsWith("co_")
|
||||||
|
)
|
||||||
|
.map((taskId) => (
|
||||||
|
<TodoRow key={taskId} taskId={taskId} />
|
||||||
|
))}
|
||||||
|
<TableRow key="new">
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox className="mt-1" disabled />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<form
|
||||||
|
className="flex flex-row items-center gap-5"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const textEl =
|
||||||
|
e.currentTarget.elements.namedItem(
|
||||||
|
"text"
|
||||||
|
) as HTMLInputElement;
|
||||||
|
createTodo(textEl.value);
|
||||||
|
textEl.value = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="-ml-3 -my-2"
|
||||||
|
name="text"
|
||||||
|
placeholder="Add todo"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Button asChild type="submit">
|
||||||
|
<Input type="submit" value="Add" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoRow({ taskId }: { taskId: CoID<Task> }) {
|
||||||
|
const task = useTelepathicState(taskId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
className="mt-1"
|
||||||
|
checked={task?.get("done")}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
task?.edit((task) => {
|
||||||
|
task.set("done", !!checked);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={task?.get("done") ? "line-through" : ""}>
|
||||||
|
{task?.get("text")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
30
examples/todo/src/LocalAuth.tsx
Normal file
30
examples/todo/src/LocalAuth.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Input } from "./components/ui/input.tsx";
|
||||||
|
import { Button } from "./components/ui/button.tsx";
|
||||||
|
import { AuthComponent } from "jazz-react";
|
||||||
|
import { useLocalAuth } from "jazz-react-auth-local";
|
||||||
|
|
||||||
|
export const LocalAuth: AuthComponent = ({ onCredential }) => {
|
||||||
|
const { displayName, setDisplayName, signIn, signUp } = useLocalAuth(onCredential);
|
||||||
|
|
||||||
|
return (<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="w-72 flex flex-col gap-4">
|
||||||
|
<form
|
||||||
|
className="w-72 flex flex-col gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
signUp();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Display name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
autoComplete="webauthn" />
|
||||||
|
<Button asChild>
|
||||||
|
<Input type="submit" value="Sign Up as new account" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Button onClick={signIn}>Log In with existing account</Button>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
};
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Input } from "@/basicComponents/ui/input";
|
|
||||||
import { Button } from "@/basicComponents/ui/button";
|
|
||||||
|
|
||||||
export function SubmittableInput({
|
|
||||||
onSubmit,
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
onSubmit: (text: string) => void;
|
|
||||||
label: string;
|
|
||||||
placeholder: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="flex flex-row items-center gap-3"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const textEl = e.currentTarget.elements.namedItem(
|
|
||||||
"text"
|
|
||||||
) as HTMLInputElement;
|
|
||||||
onSubmit(textEl.value);
|
|
||||||
textEl.value = "";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
className="-ml-3 -my-2 flex-grow flex-3 text-base"
|
|
||||||
name="text"
|
|
||||||
placeholder={placeholder}
|
|
||||||
autoComplete="off"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<Button asChild type="submit" className="flex-shrink flex-1 cursor-pointer">
|
|
||||||
<Input type="submit" value={label} disabled={disabled} />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Toaster } from ".";
|
|
||||||
|
|
||||||
export function TitleAndLogo({name}: {name: string}) {
|
|
||||||
return <>
|
|
||||||
<div className="flex items-center gap-2 justify-center mt-5">
|
|
||||||
<img src="jazz-logo.png" className="h-5" /> {name}
|
|
||||||
</div>
|
|
||||||
<Toaster />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export { Button } from "./ui/button";
|
|
||||||
export { Checkbox } from "./ui/checkbox";
|
|
||||||
export { Input } from "./ui/input";
|
|
||||||
export { Skeleton } from "./ui/skeleton";
|
|
||||||
export { Toaster } from "./ui/toaster";
|
|
||||||
export { useToast } from "./ui/use-toast";
|
|
||||||
export { SubmittableInput } from "./SubmittableInput";
|
|
||||||
export { TitleAndLogo } from "./TitleAndLogo";
|
|
||||||
export { ThemeProvider } from "./themeProvider";
|
|
||||||
export {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "./ui/table";
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
defaultTheme?: string;
|
|
||||||
storageKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ThemeProviderState = {
|
|
||||||
theme: string;
|
|
||||||
setTheme: (theme: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
theme: "system",
|
|
||||||
setTheme: () => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
|
||||||
|
|
||||||
export function ThemeProvider({
|
|
||||||
children,
|
|
||||||
defaultTheme = "system",
|
|
||||||
storageKey = "vite-ui-theme",
|
|
||||||
...props
|
|
||||||
}: ThemeProviderProps) {
|
|
||||||
const [theme, setTheme] = useState(
|
|
||||||
() => localStorage.getItem(storageKey) || defaultTheme
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = window.document.documentElement;
|
|
||||||
|
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
|
|
||||||
if (theme === "system") {
|
|
||||||
const systemTheme = window.matchMedia(
|
|
||||||
"(prefers-color-scheme: dark)"
|
|
||||||
).matches
|
|
||||||
? "dark"
|
|
||||||
: "light";
|
|
||||||
|
|
||||||
root.classList.add(systemTheme);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.classList.add(theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
theme,
|
|
||||||
setTheme: (theme: string) => {
|
|
||||||
localStorage.setItem(storageKey, theme);
|
|
||||||
setTheme(theme);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProviderContext.Provider {...props} value={value}>
|
|
||||||
{children}
|
|
||||||
</ThemeProviderContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTheme = () => {
|
|
||||||
const context = useContext(ThemeProviderContext);
|
|
||||||
|
|
||||||
if (context === undefined)
|
|
||||||
throw new Error("useTheme must be used within a ThemeProvider");
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-10 px-4 py-2",
|
|
||||||
sm: "h-9 rounded-md px-3",
|
|
||||||
lg: "h-11 rounded-md px-8",
|
|
||||||
icon: "h-10 w-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Button.displayName = "Button"
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { cn } from "@/basicComponents/lib/utils"
|
|
||||||
|
|
||||||
function Skeleton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Skeleton }
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
|
||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider
|
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Viewport
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
|
||||||
|
|
||||||
const toastVariants = cva(
|
|
||||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "border bg-background text-foreground",
|
|
||||||
destructive:
|
|
||||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
|
||||||
VariantProps<typeof toastVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<ToastPrimitives.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(toastVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Toast.displayName = ToastPrimitives.Root.displayName
|
|
||||||
|
|
||||||
const ToastAction = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Action
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
|
||||||
|
|
||||||
const ToastClose = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Close
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
toast-close=""
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</ToastPrimitives.Close>
|
|
||||||
))
|
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm opacity-90", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
|
||||||
|
|
||||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
|
||||||
|
|
||||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
|
||||||
|
|
||||||
export {
|
|
||||||
type ToastProps,
|
|
||||||
type ToastActionElement,
|
|
||||||
ToastProvider,
|
|
||||||
ToastViewport,
|
|
||||||
Toast,
|
|
||||||
ToastTitle,
|
|
||||||
ToastDescription,
|
|
||||||
ToastClose,
|
|
||||||
ToastAction,
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import {
|
|
||||||
Toast,
|
|
||||||
ToastClose,
|
|
||||||
ToastDescription,
|
|
||||||
ToastProvider,
|
|
||||||
ToastTitle,
|
|
||||||
ToastViewport,
|
|
||||||
} from "@/basicComponents/ui/toast"
|
|
||||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
|
||||||
|
|
||||||
export function Toaster() {
|
|
||||||
const { toasts } = useToast()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastProvider>
|
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
|
||||||
return (
|
|
||||||
<Toast key={id} {...props}>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
|
||||||
{description && (
|
|
||||||
<ToastDescription>{description}</ToastDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{action}
|
|
||||||
<ToastClose />
|
|
||||||
</Toast>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
// Inspired by react-hot-toast library
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ToastActionElement,
|
|
||||||
ToastProps,
|
|
||||||
} from "@/basicComponents/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
|
||||||
id: string
|
|
||||||
title?: React.ReactNode
|
|
||||||
description?: React.ReactNode
|
|
||||||
action?: ToastActionElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionTypes = {
|
|
||||||
ADD_TOAST: "ADD_TOAST",
|
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
function genId() {
|
|
||||||
count = (count + 1) % Number.MAX_VALUE
|
|
||||||
return count.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| {
|
|
||||||
type: ActionType["ADD_TOAST"]
|
|
||||||
toast: ToasterToast
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["UPDATE_TOAST"]
|
|
||||||
toast: Partial<ToasterToast>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["DISMISS_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["REMOVE_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
toasts: ToasterToast[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
|
||||||
if (toastTimeouts.has(toastId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
toastTimeouts.delete(toastId)
|
|
||||||
dispatch({
|
|
||||||
type: "REMOVE_TOAST",
|
|
||||||
toastId: toastId,
|
|
||||||
})
|
|
||||||
}, TOAST_REMOVE_DELAY)
|
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
|
||||||
switch (action.type) {
|
|
||||||
case "ADD_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
|
||||||
const { toastId } = action
|
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|
||||||
// but I'll keep it here for simplicity
|
|
||||||
if (toastId) {
|
|
||||||
addToRemoveQueue(toastId)
|
|
||||||
} else {
|
|
||||||
state.toasts.forEach((toast) => {
|
|
||||||
addToRemoveQueue(toast.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === toastId || toastId === undefined
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "REMOVE_TOAST":
|
|
||||||
if (action.toastId === undefined) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] }
|
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
|
||||||
memoryState = reducer(memoryState, action)
|
|
||||||
listeners.forEach((listener) => {
|
|
||||||
listener(memoryState)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
|
||||||
const id = genId()
|
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_TOAST",
|
|
||||||
toast: { ...props, id },
|
|
||||||
})
|
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "ADD_TOAST",
|
|
||||||
toast: {
|
|
||||||
...props,
|
|
||||||
id,
|
|
||||||
open: true,
|
|
||||||
onOpenChange: (open) => {
|
|
||||||
if (!open) dismiss()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
dismiss,
|
|
||||||
update,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useToast() {
|
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
listeners.push(setState)
|
|
||||||
return () => {
|
|
||||||
const index = listeners.indexOf(setState)
|
|
||||||
if (index > -1) {
|
|
||||||
listeners.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toast,
|
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useToast, toast }
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
|
||||||
|
|
||||||
import { Input, Button } from "../basicComponents";
|
|
||||||
|
|
||||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
|
||||||
loading,
|
|
||||||
logIn,
|
|
||||||
signUp,
|
|
||||||
}) => {
|
|
||||||
const [username, setUsername] = useState<string>("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center p-5">
|
|
||||||
{loading ? (
|
|
||||||
<div>Loading...</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-72 flex flex-col gap-4">
|
|
||||||
<form
|
|
||||||
className="w-72 flex flex-col gap-2"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
signUp(username);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Display name"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
autoComplete="webauthn"
|
|
||||||
className="text-base"
|
|
||||||
/>
|
|
||||||
<Button asChild>
|
|
||||||
<Input
|
|
||||||
type="submit"
|
|
||||||
value="Sign Up as new account"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
<Button onClick={logIn}>
|
|
||||||
Log In with existing account
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { createInviteLink } from "jazz-react";
|
|
||||||
import QRCode from "qrcode";
|
|
||||||
|
|
||||||
import { useToast, Button } from "../basicComponents";
|
|
||||||
import { CoValue, Queried } from "cojson";
|
|
||||||
|
|
||||||
export function InviteButton<T extends CoValue>({ value }: { value: T | Queried<T> | undefined }) {
|
|
||||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
return (
|
|
||||||
value?.group?.myRole() === "admin" && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="py-0"
|
|
||||||
disabled={!value.group || !value.id}
|
|
||||||
variant="outline"
|
|
||||||
onClick={async () => {
|
|
||||||
let inviteLink = existingInviteLink;
|
|
||||||
if (value.group && value.id && !inviteLink) {
|
|
||||||
inviteLink = createInviteLink(value, "writer");
|
|
||||||
setExistingInviteLink(inviteLink);
|
|
||||||
}
|
|
||||||
if (inviteLink) {
|
|
||||||
const qr = await QRCode.toDataURL(inviteLink, {
|
|
||||||
errorCorrectionLevel: "L",
|
|
||||||
});
|
|
||||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
|
||||||
toast({
|
|
||||||
title: "Copied invite link to clipboard!",
|
|
||||||
description: (
|
|
||||||
<img src={qr} className="w-20 h-20" />
|
|
||||||
),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Invite
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/basicComponents/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root, body, #root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
@@ -9,63 +14,63 @@
|
|||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 20 14.3% 4.1%;
|
--card-foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 20 14.3% 4.1%;
|
--popover-foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
--primary: 24 9.8% 10%;
|
--primary: 24 9.8% 10%;
|
||||||
--primary-foreground: 60 9.1% 97.8%;
|
--primary-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--secondary: 60 4.8% 95.9%;
|
--secondary: 60 4.8% 95.9%;
|
||||||
--secondary-foreground: 24 9.8% 10%;
|
--secondary-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
--muted: 60 4.8% 95.9%;
|
--muted: 60 4.8% 95.9%;
|
||||||
--muted-foreground: 25 5.3% 44.7%;
|
--muted-foreground: 25 5.3% 44.7%;
|
||||||
|
|
||||||
--accent: 60 4.8% 95.9%;
|
--accent: 60 4.8% 95.9%;
|
||||||
--accent-foreground: 24 9.8% 10%;
|
--accent-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 60 9.1% 97.8%;
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--border: 20 5.9% 90%;
|
--border: 20 5.9% 90%;
|
||||||
--input: 20 5.9% 90%;
|
--input: 20 5.9% 90%;
|
||||||
--ring: 20 14.3% 4.1%;
|
--ring: 20 14.3% 4.1%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 20 14.3% 4.1%;
|
--background: 20 14.3% 4.1%;
|
||||||
--foreground: 60 9.1% 97.8%;
|
--foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--card: 20 14.3% 4.1%;
|
--card: 20 14.3% 4.1%;
|
||||||
--card-foreground: 60 9.1% 97.8%;
|
--card-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--popover: 20 14.3% 4.1%;
|
--popover: 20 14.3% 4.1%;
|
||||||
--popover-foreground: 60 9.1% 97.8%;
|
--popover-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--primary: 60 9.1% 97.8%;
|
--primary: 60 9.1% 97.8%;
|
||||||
--primary-foreground: 24 9.8% 10%;
|
--primary-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
--secondary: 12 6.5% 15.1%;
|
--secondary: 12 6.5% 15.1%;
|
||||||
--secondary-foreground: 60 9.1% 97.8%;
|
--secondary-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--muted: 12 6.5% 15.1%;
|
--muted: 12 6.5% 15.1%;
|
||||||
--muted-foreground: 24 5.4% 63.9%;
|
--muted-foreground: 24 5.4% 63.9%;
|
||||||
|
|
||||||
--accent: 12 6.5% 15.1%;
|
--accent: 12 6.5% 15.1%;
|
||||||
--accent-foreground: 60 9.1% 97.8%;
|
--accent-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 60 9.1% 97.8%;
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--border: 12 6.5% 15.1%;
|
--border: 12 6.5% 15.1%;
|
||||||
--input: 12 6.5% 15.1%;
|
--input: 12 6.5% 15.1%;
|
||||||
--ring: 24 5.7% 82.9%;
|
--ring: 24 5.7% 82.9%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
|||||||
14
examples/todo/src/main.tsx
Normal file
14
examples/todo/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import "./index.css";
|
||||||
|
import { WithJazz } from "jazz-react";
|
||||||
|
import { LocalAuth } from "./LocalAuth.tsx";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<WithJazz auth={LocalAuth} syncAddress={new URLSearchParams(window.location.search).get("sync") || undefined}>
|
||||||
|
<App />
|
||||||
|
</WithJazz>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -9,8 +9,5 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
|
||||||
build: {
|
|
||||||
minify: false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
1810
examples/todo/yarn.lock
Normal file
1810
examples/todo/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
787
generateDocs.ts
787
generateDocs.ts
@@ -1,787 +0,0 @@
|
|||||||
import { readFile, writeFile } from "fs/promises";
|
|
||||||
import { Application, JSONOutput, ReflectionKind } from "typedoc";
|
|
||||||
|
|
||||||
const manuallyIgnore = new Set(["CojsonInternalTypes"]);
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// Application.bootstrap also exists, which will not load plugins
|
|
||||||
// Also accepts an array of option readers if you want to disable
|
|
||||||
// TypeDoc's tsconfig.json/package.json/typedoc.json option readers
|
|
||||||
const packageDocs = Object.entries({
|
|
||||||
"jazz-react": "index.tsx",
|
|
||||||
cojson: "index.ts",
|
|
||||||
"jazz-react-media-images": "index.tsx",
|
|
||||||
"jazz-browser": "index.ts",
|
|
||||||
"jazz-browser-media-images": "index.ts",
|
|
||||||
}).map(async ([packageName, entryPoint]) => {
|
|
||||||
const app = await Application.bootstrapWithPlugins({
|
|
||||||
entryPoints: [`packages/${packageName}/src/${entryPoint}`],
|
|
||||||
tsconfig: `packages/${packageName}/tsconfig.json`,
|
|
||||||
sort: ["required-first"],
|
|
||||||
groupOrder: ["Functions", "Classes", "TypeAliases", "Namespaces"],
|
|
||||||
categorizeByGroup: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const project = await app.convert();
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new Error("Failed to convert project" + packageName);
|
|
||||||
}
|
|
||||||
// Alternatively generate JSON output
|
|
||||||
await app.generateJson(project, `docsTmp/${packageName}.json`);
|
|
||||||
|
|
||||||
const docs = JSON.parse(
|
|
||||||
await readFile(`docsTmp/${packageName}.json`, "utf8")
|
|
||||||
) as JSONOutput.ProjectReflection;
|
|
||||||
|
|
||||||
return (
|
|
||||||
`# ${packageName}\n\n` +
|
|
||||||
docs
|
|
||||||
.groups!.map((group) => {
|
|
||||||
return group.children
|
|
||||||
?.flatMap((childId) => {
|
|
||||||
const child = docs.children!.find(
|
|
||||||
(child) => child.id === childId
|
|
||||||
)!;
|
|
||||||
|
|
||||||
if (
|
|
||||||
manuallyIgnore.has(child.name) ||
|
|
||||||
child.comment?.blockTags?.some(
|
|
||||||
(tag) =>
|
|
||||||
tag.tag === "@deprecated" ||
|
|
||||||
tag.tag === "@internal" ||
|
|
||||||
tag.tag === "@ignore"
|
|
||||||
) ||
|
|
||||||
child.signatures?.every((signature) =>
|
|
||||||
signature.comment?.blockTags?.some(
|
|
||||||
(tag) =>
|
|
||||||
tag.tag === "@deprecated" ||
|
|
||||||
tag.tag === "@internal" ||
|
|
||||||
tag.tag === "@ignore"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
`## \`${renderChildName(
|
|
||||||
child
|
|
||||||
)}\`\n\n<sup>(${group.title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace("bles", "ble")
|
|
||||||
.replace("ces", "ce")
|
|
||||||
.replace(/es$/, "")
|
|
||||||
.replace(
|
|
||||||
"ns",
|
|
||||||
"n"
|
|
||||||
)} in \`${packageName}\`)</sup>\n\n` +
|
|
||||||
renderChildType(child) +
|
|
||||||
(child.kind === ReflectionKind.Class ||
|
|
||||||
child.kind === ReflectionKind.Interface ||
|
|
||||||
child.kind === ReflectionKind.Namespace
|
|
||||||
? renderSummary(child.comment) +
|
|
||||||
renderExamples(child.comment) +
|
|
||||||
(child.categories || child.groups)
|
|
||||||
?.map((category) =>
|
|
||||||
renderChildCategory(child, category)
|
|
||||||
)
|
|
||||||
.join("<br/>\n\n")
|
|
||||||
: child.kind === ReflectionKind.Function
|
|
||||||
? renderSummary(
|
|
||||||
child.signatures?.[0].comment
|
|
||||||
) +
|
|
||||||
renderParamComments(
|
|
||||||
child.signatures?.[0].parameters || []
|
|
||||||
) +
|
|
||||||
renderExamples(
|
|
||||||
child.signatures?.[0].comment
|
|
||||||
) +
|
|
||||||
"\n\n"
|
|
||||||
: "TODO: doc generator not implemented yet " +
|
|
||||||
child.kind)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("\n\n----\n\n");
|
|
||||||
})
|
|
||||||
.join("\n\n----\n\n")
|
|
||||||
);
|
|
||||||
|
|
||||||
function renderSummary(comment?: JSONOutput.Comment): string {
|
|
||||||
if (comment) {
|
|
||||||
return (
|
|
||||||
comment.summary
|
|
||||||
.map((token) =>
|
|
||||||
token.kind === "text" || token.kind === "code"
|
|
||||||
? token.text
|
|
||||||
: ""
|
|
||||||
)
|
|
||||||
.join("") +
|
|
||||||
"\n\n" +
|
|
||||||
"\n\n"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return "TODO: document\n\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderExamples(comment?: JSONOutput.Comment): string {
|
|
||||||
return (comment?.blockTags || [])
|
|
||||||
.map((blockTag) =>
|
|
||||||
blockTag.tag === "@example"
|
|
||||||
? "##### Example:\n\n" +
|
|
||||||
blockTag.content
|
|
||||||
.map((token) =>
|
|
||||||
token.kind === "text" || token.kind === "code"
|
|
||||||
? token.text
|
|
||||||
: ""
|
|
||||||
)
|
|
||||||
.join("") +
|
|
||||||
"\n\n"
|
|
||||||
: ""
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderParamComments(params: JSONOutput.ParameterReflection[]) {
|
|
||||||
const paramDocs = params.flatMap((param) => {
|
|
||||||
if (param.type?.type === "reflection") {
|
|
||||||
return param.type.declaration.children?.flatMap((child) => {
|
|
||||||
if (
|
|
||||||
child.name === "children" &&
|
|
||||||
child.type?.type === "reference" &&
|
|
||||||
child.type?.name === "ReactNode"
|
|
||||||
) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
`| \`${param.name}.${child.name}${
|
|
||||||
child.flags.isOptional || child.defaultValue
|
|
||||||
? "?"
|
|
||||||
: ""
|
|
||||||
}\` | ` +
|
|
||||||
(child.comment
|
|
||||||
? child.comment.summary
|
|
||||||
.map((token) =>
|
|
||||||
token.kind === "text" ||
|
|
||||||
token.kind === "code"
|
|
||||||
? token.text
|
|
||||||
: ""
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
: "TODO: document") +
|
|
||||||
" |"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const comment = param.comment;
|
|
||||||
return [
|
|
||||||
`| \`${param.name}${
|
|
||||||
param.flags.isOptional || param.defaultValue
|
|
||||||
? "?"
|
|
||||||
: ""
|
|
||||||
}\` | ` +
|
|
||||||
(comment
|
|
||||||
? comment.summary
|
|
||||||
.map((token) =>
|
|
||||||
token.kind === "text" ||
|
|
||||||
token.kind === "code"
|
|
||||||
? token.text
|
|
||||||
: ""
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
: "TODO: document ") +
|
|
||||||
" |",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (paramDocs.length) {
|
|
||||||
return `### Parameters:\n\n| name | description |\n| ----: | ---- |\n${paramDocs.join(
|
|
||||||
"\n"
|
|
||||||
)}\n\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChildName(child: JSONOutput.DeclarationReflection) {
|
|
||||||
if (child.signatures) {
|
|
||||||
if (
|
|
||||||
child.signatures[0].type?.type === "reference" &&
|
|
||||||
child.signatures[0].type.qualifiedName ===
|
|
||||||
"React.JSX.Element"
|
|
||||||
) {
|
|
||||||
return `<${child.name}/>`;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
child.name +
|
|
||||||
`(${(child.signatures[0].parameters || [])
|
|
||||||
.map(renderParamSimple)
|
|
||||||
.join(", ")})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return child.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChildType(
|
|
||||||
child: JSONOutput.DeclarationReflection
|
|
||||||
): string {
|
|
||||||
const isClass = child.kind === ReflectionKind.Class;
|
|
||||||
const isTypeAlias = child.kind === ReflectionKind.TypeAlias;
|
|
||||||
const isInterface = child.kind === ReflectionKind.Interface;
|
|
||||||
const isNamespace = child.kind === ReflectionKind.Namespace;
|
|
||||||
const isFunction = !!child.signatures;
|
|
||||||
|
|
||||||
const kind = isClass
|
|
||||||
? "class"
|
|
||||||
: isTypeAlias
|
|
||||||
? "type"
|
|
||||||
: isFunction
|
|
||||||
? "function"
|
|
||||||
: isInterface
|
|
||||||
? "interface"
|
|
||||||
: isNamespace
|
|
||||||
? "namespace"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
"```typescript\n" +
|
|
||||||
`export ${kind} ${child.name}` +
|
|
||||||
((child.typeParameters || child.signatures?.[0].typeParameter)
|
|
||||||
? "<" +
|
|
||||||
(child.typeParameters || child.signatures?.[0].typeParameter || []).map(renderTypeParam).join(", ") +
|
|
||||||
">"
|
|
||||||
: "") +
|
|
||||||
(child.extendedTypes
|
|
||||||
? " extends " +
|
|
||||||
child.extendedTypes.map(renderType).join(", ")
|
|
||||||
: "") +
|
|
||||||
(child.implementedTypes
|
|
||||||
? " implements " +
|
|
||||||
child.implementedTypes.map(renderType).join(", ")
|
|
||||||
: "") +
|
|
||||||
(isClass || isInterface || isNamespace
|
|
||||||
? " {...}"
|
|
||||||
: isTypeAlias
|
|
||||||
? ` = ${renderType(child.type)}`
|
|
||||||
: child.signatures
|
|
||||||
? `(${(child.signatures[0].parameters || [])
|
|
||||||
.map(renderParam)
|
|
||||||
.join(", ")}): ${renderType(
|
|
||||||
child.signatures[0].type
|
|
||||||
)}`
|
|
||||||
: "") +
|
|
||||||
"\n```\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChildCategory(
|
|
||||||
child: JSONOutput.DeclarationReflection,
|
|
||||||
category: JSONOutput.ReflectionGroup
|
|
||||||
): string {
|
|
||||||
return (
|
|
||||||
`### \`${child.name}\`: ${category.title.replace(/[^d]+\./, "")}\n\n` +
|
|
||||||
category.children
|
|
||||||
?.map((memberId) => {
|
|
||||||
const member = child.children!.find(
|
|
||||||
(member) => member.id === memberId
|
|
||||||
)!;
|
|
||||||
|
|
||||||
if (member.kind === 2048 || member.kind === 512) {
|
|
||||||
if (
|
|
||||||
member.signatures?.every(
|
|
||||||
(sig) =>
|
|
||||||
sig.comment?.modifierTags?.includes(
|
|
||||||
"@internal"
|
|
||||||
) ||
|
|
||||||
sig.comment?.modifierTags?.includes(
|
|
||||||
"@deprecated"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return "";
|
|
||||||
} else {
|
|
||||||
return documentConstructorOrMethod(
|
|
||||||
member,
|
|
||||||
child
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
member.kind === 1024 ||
|
|
||||||
member.kind === 262144
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
member.comment?.modifierTags?.includes(
|
|
||||||
"@internal"
|
|
||||||
) ||
|
|
||||||
member.comment?.modifierTags?.includes(
|
|
||||||
"@deprecated"
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return "";
|
|
||||||
} else {
|
|
||||||
return documentProperty(member, child);
|
|
||||||
}
|
|
||||||
} else if (member.kind === 2097152) {
|
|
||||||
if (
|
|
||||||
member.comment?.modifierTags?.includes(
|
|
||||||
"@internal"
|
|
||||||
) ||
|
|
||||||
member.comment?.modifierTags?.includes(
|
|
||||||
"@deprecated"
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return "";
|
|
||||||
} else {
|
|
||||||
return documentProperty(
|
|
||||||
{ ...member, flags: { isStatic: true } },
|
|
||||||
child
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "Unknown member kind " + member.kind;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join("\n\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderType(t?: JSONOutput.SomeType): string {
|
|
||||||
if (!t) return "";
|
|
||||||
if (t.type === "reference") {
|
|
||||||
return (
|
|
||||||
t.name +
|
|
||||||
(t.typeArguments
|
|
||||||
? "<" + t.typeArguments.map(renderType).join(", ") + ">"
|
|
||||||
: "")
|
|
||||||
);
|
|
||||||
} else if (t.type === "intrinsic") {
|
|
||||||
return t.name;
|
|
||||||
} else if (t.type === "literal") {
|
|
||||||
return JSON.stringify(t.value);
|
|
||||||
} else if (t.type === "union") {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return t.types
|
|
||||||
.flatMap((t) => {
|
|
||||||
const rendered =
|
|
||||||
t.type === "intersection" || t.type === "union"
|
|
||||||
? `(${renderType(t)})`
|
|
||||||
: renderType(t);
|
|
||||||
|
|
||||||
if (seen.has(rendered)) {
|
|
||||||
return [];
|
|
||||||
} else {
|
|
||||||
seen.add(rendered);
|
|
||||||
return [rendered];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join(" | ");
|
|
||||||
} else if (t.type === "intersection") {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return t.types
|
|
||||||
.flatMap((t) => {
|
|
||||||
const rendered =
|
|
||||||
t.type === "intersection" || t.type === "union"
|
|
||||||
? `(${renderType(t)})`
|
|
||||||
: renderType(t);
|
|
||||||
|
|
||||||
if (seen.has(rendered)) {
|
|
||||||
return [];
|
|
||||||
} else {
|
|
||||||
seen.add(rendered);
|
|
||||||
return [rendered];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join(" & ");
|
|
||||||
} else if (t.type === "indexedAccess") {
|
|
||||||
return (
|
|
||||||
renderType(t.objectType) +
|
|
||||||
"[" +
|
|
||||||
renderType(t.indexType) +
|
|
||||||
"]"
|
|
||||||
);
|
|
||||||
} else if (t.type === "reflection") {
|
|
||||||
if (t.declaration.indexSignature) {
|
|
||||||
return (
|
|
||||||
`{${
|
|
||||||
t.declaration.children
|
|
||||||
? t.declaration.children
|
|
||||||
.map(
|
|
||||||
(child) =>
|
|
||||||
` ${child.name}${
|
|
||||||
child.flags.isOptional
|
|
||||||
? "?"
|
|
||||||
: ""
|
|
||||||
}: ${indentEnd(
|
|
||||||
renderType(child.type)
|
|
||||||
)},`
|
|
||||||
)
|
|
||||||
.join("\n")
|
|
||||||
: ""
|
|
||||||
}\n [` +
|
|
||||||
t.declaration.indexSignature?.parameters?.[0].name +
|
|
||||||
": " +
|
|
||||||
renderType(
|
|
||||||
t.declaration.indexSignature?.parameters?.[0].type
|
|
||||||
) +
|
|
||||||
"]: " +
|
|
||||||
indentEnd(
|
|
||||||
renderType(t.declaration.indexSignature?.type)
|
|
||||||
) +
|
|
||||||
" }"
|
|
||||||
);
|
|
||||||
} else if (t.declaration.children) {
|
|
||||||
return `{\n${t.declaration.children
|
|
||||||
.map((child) =>
|
|
||||||
child.signatures
|
|
||||||
? child.signatures
|
|
||||||
.map(
|
|
||||||
(signature) =>
|
|
||||||
` ${child.name}(${
|
|
||||||
signature.parameters
|
|
||||||
? "\n " +
|
|
||||||
indent(
|
|
||||||
signature.parameters
|
|
||||||
.map((p) =>
|
|
||||||
indentEnd(
|
|
||||||
renderParam(
|
|
||||||
p
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.join(",\n ")
|
|
||||||
) +
|
|
||||||
"\n )"
|
|
||||||
: "()"
|
|
||||||
}: ${indentEnd(
|
|
||||||
renderType(signature.type)
|
|
||||||
)}`
|
|
||||||
)
|
|
||||||
.join("\n") + ",\n"
|
|
||||||
: ` ${child.name}${
|
|
||||||
child.flags.isOptional ? "?" : ""
|
|
||||||
}: ${indentEnd(renderType(child.type))},\n`
|
|
||||||
)
|
|
||||||
.join("")}}`;
|
|
||||||
} else if (t.declaration.signatures) {
|
|
||||||
return t.declaration.signatures
|
|
||||||
.map(
|
|
||||||
(signature) =>
|
|
||||||
`(${(signature.parameters || [])
|
|
||||||
.map(renderParam)
|
|
||||||
.join(", ")}) => ${renderType(
|
|
||||||
signature.type
|
|
||||||
)}`
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
} else {
|
|
||||||
return "COMPLEX_TYPE_REFLECTION";
|
|
||||||
}
|
|
||||||
} else if (t.type === "array") {
|
|
||||||
return renderType(t.elementType) + "[]";
|
|
||||||
} else if (t.type === "tuple") {
|
|
||||||
return `[${t.elements?.map(renderType).join(", ")}]`;
|
|
||||||
} else if (t.type === "templateLiteral") {
|
|
||||||
const matchingNamedType = docs.children?.find(
|
|
||||||
(child) =>
|
|
||||||
child.variant === "declaration" &&
|
|
||||||
child.type?.type === "templateLiteral" &&
|
|
||||||
child.type.head === t.head &&
|
|
||||||
child.type.tail.every(
|
|
||||||
(piece, i) => piece[1] === t.tail[i][1]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingNamedType) {
|
|
||||||
return matchingNamedType.name;
|
|
||||||
} else {
|
|
||||||
if (
|
|
||||||
t.head === "sealerSecret_z" &&
|
|
||||||
t.tail[0][1] === "/signerSecret_z"
|
|
||||||
) {
|
|
||||||
return "AgentSecret";
|
|
||||||
} else if (
|
|
||||||
t.head === "sealer_z" &&
|
|
||||||
t.tail[0][1] === "/signer_z"
|
|
||||||
) {
|
|
||||||
if (t.tail[1] && t.tail[1][1] === "_session_z") {
|
|
||||||
return "SessionID";
|
|
||||||
} else {
|
|
||||||
return "AgentID";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
"`" +
|
|
||||||
t.head +
|
|
||||||
t.tail
|
|
||||||
.map(
|
|
||||||
(bit) =>
|
|
||||||
"${" + renderType(bit[0]) + "}" + bit[1]
|
|
||||||
)
|
|
||||||
.join("") +
|
|
||||||
"`"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (t.type === "conditional") {
|
|
||||||
const trueRendered = renderType(t.trueType);
|
|
||||||
const falseRendered = renderType(t.falseType);
|
|
||||||
|
|
||||||
if (
|
|
||||||
trueRendered.includes("\n") ||
|
|
||||||
falseRendered.includes("\n")
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
renderType(t.checkType) +
|
|
||||||
" extends " +
|
|
||||||
renderType(t.extendsType) +
|
|
||||||
"\n ? " +
|
|
||||||
indentEnd(renderType(t.trueType)) +
|
|
||||||
"\n : " +
|
|
||||||
indentEnd(renderType(t.falseType))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
renderType(t.checkType) +
|
|
||||||
" extends " +
|
|
||||||
renderType(t.extendsType) +
|
|
||||||
" ? " +
|
|
||||||
renderType(t.trueType) +
|
|
||||||
" : " +
|
|
||||||
renderType(t.falseType)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (t.type === "inferred") {
|
|
||||||
return "infer " + t.name;
|
|
||||||
} else if (t.type === "typeOperator") {
|
|
||||||
return t.operator + " " + renderType(t.target);
|
|
||||||
} else if (t.type === "mapped") {
|
|
||||||
return `{\n [${t.parameter} in ${renderType(
|
|
||||||
t.parameterType
|
|
||||||
)}]: ${indentEnd(renderType(t.templateType))}\n}`;
|
|
||||||
} else {
|
|
||||||
return "COMPLEX_TYPE_" + t.type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// function renderTemplateLiteral(tempLit: JSONOutput.TemplateLiteralType) {
|
|
||||||
// return tempLit.head + tempLit.tail.map((piece) => piece[0] + piece[1]).join("");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// function resolveTemplateLiteralPieceType(t: SomeType): string {
|
|
||||||
// if (t.type === "string") {
|
|
||||||
// return "${string}"
|
|
||||||
// }
|
|
||||||
// if (t.type === "reference") {
|
|
||||||
// const referencedType = docs.children?.find(
|
|
||||||
// (child) => child.name === t.name
|
|
||||||
// );
|
|
||||||
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
function renderTypeParam(
|
|
||||||
t?: JSONOutput.TypeParameterReflection
|
|
||||||
): string {
|
|
||||||
if (!t) return "";
|
|
||||||
return t.name + (t.type ? " extends " + renderType(t.type) : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderParam(param: JSONOutput.ParameterReflection) {
|
|
||||||
return param.name === "__namedParameters"
|
|
||||||
? renderType(param.type)
|
|
||||||
: `${param.name}: ${renderType(param.type)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderParamSimple(param: JSONOutput.ParameterReflection) {
|
|
||||||
return param.name === "__namedParameters" &&
|
|
||||||
param.type?.type === "reflection"
|
|
||||||
? `{${param.type?.declaration.children
|
|
||||||
?.map(
|
|
||||||
(child) =>
|
|
||||||
child.name + (child.flags.isOptional ? "?" : "")
|
|
||||||
)
|
|
||||||
.join(", ")}}${
|
|
||||||
param.flags.isOptional || param.defaultValue ? "?" : ""
|
|
||||||
}`
|
|
||||||
: param.name +
|
|
||||||
(param.flags.isOptional || param.defaultValue ? "?" : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function documentConstructorOrMethod(
|
|
||||||
member: JSONOutput.DeclarationReflection,
|
|
||||||
child: JSONOutput.DeclarationReflection
|
|
||||||
) {
|
|
||||||
const isInClass = child.kind === 128;
|
|
||||||
const isInTypeDef = child.kind === 2097152;
|
|
||||||
const isInInterface = child.kind === 256;
|
|
||||||
const isInNamespace = child.kind === 4;
|
|
||||||
const isInFunction = !!child.signatures;
|
|
||||||
|
|
||||||
const inKind = isInClass
|
|
||||||
? "class"
|
|
||||||
: isInTypeDef
|
|
||||||
? "type"
|
|
||||||
: isInFunction
|
|
||||||
? "function"
|
|
||||||
: isInInterface
|
|
||||||
? "interface"
|
|
||||||
: isInNamespace
|
|
||||||
? "namespace"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const stem =
|
|
||||||
member.name === "constructor"
|
|
||||||
? "new " + child.name + "</code></b>"
|
|
||||||
: (member.flags.isStatic ? child.name : "") +
|
|
||||||
"." +
|
|
||||||
member.name +
|
|
||||||
"";
|
|
||||||
|
|
||||||
return member.signatures
|
|
||||||
?.map((signature) => {
|
|
||||||
return (
|
|
||||||
`<details>\n<summary><b><code>${stem}(${(
|
|
||||||
signature?.parameters?.map(renderParamSimple) || []
|
|
||||||
).join(", ")})</code></b> ${
|
|
||||||
member.inheritedFrom
|
|
||||||
? "<sub><sup>from <code>" +
|
|
||||||
member.inheritedFrom.name.split(".")[0] +
|
|
||||||
"</code></sup></sub> "
|
|
||||||
: ""
|
|
||||||
} ${
|
|
||||||
signature?.comment
|
|
||||||
? ""
|
|
||||||
: "<sub><sup>(undocumented)</sup></sub>"
|
|
||||||
}</summary>\n\n` +
|
|
||||||
("```typescript\n" +
|
|
||||||
`${inKind} ${child.name}${
|
|
||||||
child.typeParameters
|
|
||||||
? `<${child.typeParameters
|
|
||||||
.map((t) => t.name)
|
|
||||||
.join(", ")}>`
|
|
||||||
: ""
|
|
||||||
} {\n\n${indent(
|
|
||||||
`${member.name}${
|
|
||||||
signature.typeParameter
|
|
||||||
? `<${signature.typeParameter
|
|
||||||
.map(renderTypeParam)
|
|
||||||
.join(", ")}>`
|
|
||||||
: ""
|
|
||||||
}(${
|
|
||||||
(
|
|
||||||
signature.parameters?.map(
|
|
||||||
(param) =>
|
|
||||||
`\n ${param.name}${
|
|
||||||
param.flags.isOptional ||
|
|
||||||
param.defaultValue
|
|
||||||
? "?"
|
|
||||||
: ""
|
|
||||||
}: ${indentEnd(
|
|
||||||
renderType(param.type)
|
|
||||||
)}${
|
|
||||||
param.defaultValue
|
|
||||||
? ` = ${param.defaultValue}`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
) || []
|
|
||||||
).join(",") +
|
|
||||||
(signature.parameters?.length ? "\n" : "")
|
|
||||||
}): ${renderType(signature.type)} {...}`
|
|
||||||
)}\n\n}\n` +
|
|
||||||
"```\n" +
|
|
||||||
renderSummary(signature.comment)) +
|
|
||||||
renderParamComments(signature.parameters || []) +
|
|
||||||
renderExamples(signature.comment) +
|
|
||||||
"</details>\n\n"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function documentProperty(
|
|
||||||
member: JSONOutput.DeclarationReflection,
|
|
||||||
child: JSONOutput.DeclarationReflection
|
|
||||||
) {
|
|
||||||
const isInClass = child.kind === 128;
|
|
||||||
const isInTypeDef = child.kind === 2097152;
|
|
||||||
const isInInterface = child.kind === 256;
|
|
||||||
const isInNamespace = child.kind === 4;
|
|
||||||
const isInFunction = !!child.signatures;
|
|
||||||
|
|
||||||
const inKind = isInClass
|
|
||||||
? "class"
|
|
||||||
: isInTypeDef
|
|
||||||
? "type"
|
|
||||||
: isInFunction
|
|
||||||
? "function"
|
|
||||||
: isInInterface
|
|
||||||
? "interface"
|
|
||||||
: isInNamespace
|
|
||||||
? "namespace"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const stem = member.flags.isStatic ? child.name : "";
|
|
||||||
return (
|
|
||||||
`<details>\n<summary><b><code>${stem}.${
|
|
||||||
member.name
|
|
||||||
}</code></b> ${
|
|
||||||
member.inheritedFrom
|
|
||||||
? "<sub><sup>from <code>" +
|
|
||||||
member.inheritedFrom.name.split(".")[0] +
|
|
||||||
"</code></sup></sub> "
|
|
||||||
: ""
|
|
||||||
} ${
|
|
||||||
member.comment ? "" : "<sub><sup>(undocumented)</sup></sub>"
|
|
||||||
}</summary>\n\n` +
|
|
||||||
"```typescript\n" +
|
|
||||||
`${inKind} ${child.name}${
|
|
||||||
child.typeParameters
|
|
||||||
? `<${child.typeParameters
|
|
||||||
.map((t) => t.name)
|
|
||||||
.join(", ")}>`
|
|
||||||
: ""
|
|
||||||
} {\n\n${indent(
|
|
||||||
`${member.getSignature ? "get " : ""}${member.name}${
|
|
||||||
member.getSignature ? "()" : ""
|
|
||||||
}: ${renderType(member.type || member.getSignature?.type)}${
|
|
||||||
member.getSignature ? " {...}" : ""
|
|
||||||
}`
|
|
||||||
)}` +
|
|
||||||
"\n\n}\n```\n" +
|
|
||||||
renderSummary(member.comment) +
|
|
||||||
renderExamples(member.comment) +
|
|
||||||
"</details>\n\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const docsContent = await readFile("./DOCS.md", "utf8");
|
|
||||||
|
|
||||||
await writeFile(
|
|
||||||
"./DOCS.md",
|
|
||||||
docsContent.slice(
|
|
||||||
0,
|
|
||||||
docsContent.indexOf("<!-- AUTOGENERATED DOCS AFTER THIS POINT -->")
|
|
||||||
) +
|
|
||||||
"<!-- AUTOGENERATED DOCS AFTER THIS POINT -->\n" +
|
|
||||||
(await Promise.all(packageDocs)).join("\n\n\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function indent(text: string): string {
|
|
||||||
return text
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => " " + line)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function indentEnd(text: string): string {
|
|
||||||
return text
|
|
||||||
.split("\n")
|
|
||||||
.map((line, i) => (i === 0 ? line : " " + line))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/** @type {import('jest').Config} */
|
|
||||||
const config = {
|
|
||||||
projects: ['<rootDir>/packages/cojson'],
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
|
||||||
10
package.json
10
package.json
@@ -7,14 +7,6 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"lerna": "^7.1.5",
|
"lerna": "^7.1.5"
|
||||||
"ts-node": "^10.9.1",
|
|
||||||
"typedoc": "^0.25.1"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build-all": "lerna run build",
|
|
||||||
"updated": "lerna updated --include-merged-tags",
|
|
||||||
"publish-all": "yarn run gen-docs && lerna publish --include-merged-tags",
|
|
||||||
"gen-docs": "ts-node generateDocs.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['@typescript-eslint'],
|
|
||||||
parserOptions: {
|
|
||||||
project: './tsconfig.json',
|
|
||||||
},
|
|
||||||
root: true,
|
|
||||||
rules: {
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
|
||||||
},
|
|
||||||
|
|
||||||
};
|
|
||||||
174
packages/cojson-simple-sync/.gitignore
vendored
174
packages/cojson-simple-sync/.gitignore
vendored
@@ -1,174 +0,0 @@
|
|||||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
|
|
||||||
logs
|
|
||||||
_.log
|
|
||||||
npm-debug.log_
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
|
|
||||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
|
|
||||||
pids
|
|
||||||
_.pid
|
|
||||||
_.seed
|
|
||||||
\*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
|
|
||||||
coverage
|
|
||||||
\*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
|
|
||||||
\*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
|
|
||||||
\*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
|
|
||||||
.cache/
|
|
||||||
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.\*
|
|
||||||
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
out
|
|
||||||
sync.db*
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "cojson-simple-sync",
|
|
||||||
"module": "dist/index.js",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"type": "module",
|
|
||||||
"license": "MIT",
|
|
||||||
"version": "0.3.3",
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/jest": "^29.5.3",
|
|
||||||
"@types/ws": "^8.5.5",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
|
||||||
"@typescript-eslint/parser": "^6.2.1",
|
|
||||||
"eslint": "^8.46.0",
|
|
||||||
"jest": "^29.6.2",
|
|
||||||
"ts-jest": "^29.1.1",
|
|
||||||
"typescript": "5.0.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"cojson": "^0.3.3",
|
|
||||||
"cojson-storage-sqlite": "^0.3.3",
|
|
||||||
"ws": "^8.13.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist && npm run add-shebang && chmod +x ./dist/index.js",
|
|
||||||
"add-shebang": "echo \"#!/usr/bin/env node\" | cat - ./dist/index.js > /tmp/out && mv /tmp/out ./dist/index.js",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"test": "jest",
|
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
|
||||||
"bin": "./dist/index.js",
|
|
||||||
"jest": {
|
|
||||||
"preset": "ts-jest",
|
|
||||||
"testEnvironment": "node"
|
|
||||||
},
|
|
||||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
|
|
||||||
import { WebSocketServer } from "ws";
|
|
||||||
import { SQLiteStorage } from "cojson-storage-sqlite";
|
|
||||||
import { websocketReadableStream, websocketWritableStream } from "./websocketStreams.js";
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: 4200 });
|
|
||||||
|
|
||||||
console.log("COJSON sync server listening on port " + wss.options.port);
|
|
||||||
|
|
||||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
|
||||||
const agentID = cojsonInternals.getAgentID(agentSecret);
|
|
||||||
|
|
||||||
const localNode = new LocalNode(
|
|
||||||
new AnonymousControlledAccount(agentSecret),
|
|
||||||
cojsonInternals.newRandomSessionID(agentID)
|
|
||||||
);
|
|
||||||
|
|
||||||
SQLiteStorage.asPeer({ filename: "./sync.db" })
|
|
||||||
.then((storage) => localNode.syncManager.addPeer(storage))
|
|
||||||
.catch((e) => console.error(e));
|
|
||||||
|
|
||||||
wss.on("connection", function connection(ws, req) {
|
|
||||||
const pinging = setInterval(() => {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "ping",
|
|
||||||
time: Date.now(),
|
|
||||||
dc: "cojson-simple-sync",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
ws.on("close", () => {
|
|
||||||
clearInterval(pinging);
|
|
||||||
});
|
|
||||||
|
|
||||||
const clientAddress =
|
|
||||||
(req.headers["x-forwarded-for"] as string | undefined)
|
|
||||||
?.split(",")[0]
|
|
||||||
?.trim() || req.socket.remoteAddress;
|
|
||||||
|
|
||||||
const clientId = clientAddress + "@" + new Date().toISOString();
|
|
||||||
|
|
||||||
localNode.syncManager.addPeer({
|
|
||||||
id: clientId,
|
|
||||||
role: "client",
|
|
||||||
incoming: websocketReadableStream(ws),
|
|
||||||
outgoing: websocketWritableStream(ws),
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("error", (e) => console.error(`Error on connection ${clientId}:`, e));
|
|
||||||
});
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { WebSocket } from "ws";
|
|
||||||
import { WritableStream, ReadableStream } from "isomorphic-streams";
|
|
||||||
|
|
||||||
export function websocketReadableStream<T>(ws: WebSocket) {
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
return new ReadableStream<T>({
|
|
||||||
start(controller) {
|
|
||||||
ws.addEventListener("message", (event) => {
|
|
||||||
if (typeof event.data !== "string")
|
|
||||||
return console.warn(
|
|
||||||
"Got non-string message from client",
|
|
||||||
event.data
|
|
||||||
);
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === "ping") {
|
|
||||||
// console.debug(
|
|
||||||
// "Got ping from",
|
|
||||||
// msg.dc,
|
|
||||||
// "latency",
|
|
||||||
// Date.now() - msg.time,
|
|
||||||
// "ms"
|
|
||||||
// );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(msg);
|
|
||||||
});
|
|
||||||
ws.addEventListener("close", () => controller.close());
|
|
||||||
ws.addEventListener("error", () =>
|
|
||||||
controller.error(new Error("The WebSocket errored!"))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
ws.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function websocketWritableStream<T>(ws: WebSocket) {
|
|
||||||
return new WritableStream<T>({
|
|
||||||
start(controller) {
|
|
||||||
ws.addEventListener("close", () =>
|
|
||||||
controller.error(
|
|
||||||
new Error("The WebSocket closed unexpectedly!")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
ws.addEventListener("error", () =>
|
|
||||||
controller.error(new Error("The WebSocket errored!"))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => ws.once("open", resolve));
|
|
||||||
},
|
|
||||||
|
|
||||||
write(chunk) {
|
|
||||||
ws.send(JSON.stringify(chunk));
|
|
||||||
// Return immediately, since the web socket gives us no easy way to tell
|
|
||||||
// when the write completes.
|
|
||||||
},
|
|
||||||
|
|
||||||
close() {
|
|
||||||
return closeWS(1000);
|
|
||||||
},
|
|
||||||
|
|
||||||
abort(reason) {
|
|
||||||
return closeWS(4000, reason && reason.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function closeWS(code: number, reasonString?: string) {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
ws.onclose = (e) => {
|
|
||||||
if (e.wasClean) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error("The connection was not closed cleanly"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.close(code, reasonString);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext"],
|
|
||||||
"module": "esnext",
|
|
||||||
"target": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"strict": true,
|
|
||||||
"downlevelIteration": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"allowJs": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
},
|
|
||||||
"include": ["./src/**/*"],
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
|||||||
import { expect, test } from "vitest";
|
|
||||||
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
|
|
||||||
import { IDBStorage } from ".";
|
|
||||||
|
|
||||||
test.skip("Should be able to initialize and load from empty DB", async () => {
|
|
||||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
|
||||||
|
|
||||||
const node = new LocalNode(
|
|
||||||
new AnonymousControlledAccount(agentSecret),
|
|
||||||
cojsonInternals.newRandomSessionID(
|
|
||||||
cojsonInternals.getAgentID(agentSecret)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
node.syncManager.addPeer(await IDBStorage.asPeer({ trace: true }));
|
|
||||||
|
|
||||||
console.log("yay!");
|
|
||||||
|
|
||||||
const _group = node.createGroup();
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
|
|
||||||
expect(node.syncManager.peers["storage"]).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Should be able to sync data to database and then load that from a new node", async () => {
|
|
||||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
|
||||||
|
|
||||||
const node1 = new LocalNode(
|
|
||||||
new AnonymousControlledAccount(agentSecret),
|
|
||||||
cojsonInternals.newRandomSessionID(
|
|
||||||
cojsonInternals.getAgentID(agentSecret)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
node1.syncManager.addPeer(
|
|
||||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node1" })
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("yay!");
|
|
||||||
|
|
||||||
const group = node1.createGroup();
|
|
||||||
|
|
||||||
const map = group.createMap();
|
|
||||||
|
|
||||||
map.edit((m) => {
|
|
||||||
m.set("hello", "world");
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
|
|
||||||
const node2 = new LocalNode(
|
|
||||||
new AnonymousControlledAccount(agentSecret),
|
|
||||||
cojsonInternals.newRandomSessionID(
|
|
||||||
cojsonInternals.getAgentID(agentSecret)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
node2.syncManager.addPeer(
|
|
||||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node2" })
|
|
||||||
);
|
|
||||||
|
|
||||||
const map2 = await node2.load(map.id);
|
|
||||||
|
|
||||||
expect(map2.get("hello")).toBe("world");
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['@typescript-eslint'],
|
|
||||||
parserOptions: {
|
|
||||||
project: "./tsconfig.json",
|
|
||||||
},
|
|
||||||
root: true,
|
|
||||||
rules: {
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
|
||||||
// "@typescript-eslint/no-floating-promises": "error",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
171
packages/cojson-storage-sqlite/.gitignore
vendored
171
packages/cojson-storage-sqlite/.gitignore
vendored
@@ -1,171 +0,0 @@
|
|||||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
|
|
||||||
logs
|
|
||||||
_.log
|
|
||||||
npm-debug.log_
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
|
|
||||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
|
|
||||||
pids
|
|
||||||
_.pid
|
|
||||||
_.seed
|
|
||||||
\*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
|
|
||||||
coverage
|
|
||||||
\*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
|
|
||||||
\*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
|
|
||||||
\*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
|
|
||||||
.cache/
|
|
||||||
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.\*
|
|
||||||
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
coverage
|
|
||||||
node_modules
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "cojson-storage-sqlite",
|
|
||||||
"type": "module",
|
|
||||||
"version": "0.3.3",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"better-sqlite3": "^8.5.2",
|
|
||||||
"cojson": "^0.3.3",
|
|
||||||
"typescript": "^5.1.6"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint src/**/*.ts",
|
|
||||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/better-sqlite3": "^7.6.4"
|
|
||||||
},
|
|
||||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
|
||||||
}
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
import {
|
|
||||||
cojsonInternals,
|
|
||||||
SyncMessage,
|
|
||||||
Peer,
|
|
||||||
CojsonInternalTypes,
|
|
||||||
SessionID,
|
|
||||||
MAX_RECOMMENDED_TX_SIZE,
|
|
||||||
} from "cojson";
|
|
||||||
import {
|
|
||||||
ReadableStream,
|
|
||||||
WritableStream,
|
|
||||||
ReadableStreamDefaultReader,
|
|
||||||
WritableStreamDefaultWriter,
|
|
||||||
} from "isomorphic-streams";
|
|
||||||
|
|
||||||
import Database, { Database as DatabaseT } from "better-sqlite3";
|
|
||||||
|
|
||||||
type CoValueRow = {
|
|
||||||
id: CojsonInternalTypes.RawCoID;
|
|
||||||
header: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StoredCoValueRow = CoValueRow & { rowID: number };
|
|
||||||
|
|
||||||
type SessionRow = {
|
|
||||||
coValue: number;
|
|
||||||
sessionID: SessionID;
|
|
||||||
lastIdx: number;
|
|
||||||
lastSignature: CojsonInternalTypes.Signature;
|
|
||||||
bytesSinceLastSignature?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StoredSessionRow = SessionRow & { rowID: number };
|
|
||||||
|
|
||||||
type TransactionRow = {
|
|
||||||
ses: number;
|
|
||||||
idx: number;
|
|
||||||
tx: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SignatureAfterRow = {
|
|
||||||
ses: number;
|
|
||||||
idx: number;
|
|
||||||
signature: CojsonInternalTypes.Signature;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SQLiteStorage {
|
|
||||||
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
|
|
||||||
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
|
|
||||||
db: DatabaseT;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
db: DatabaseT,
|
|
||||||
fromLocalNode: ReadableStream<SyncMessage>,
|
|
||||||
toLocalNode: WritableStream<SyncMessage>
|
|
||||||
) {
|
|
||||||
this.db = db;
|
|
||||||
this.fromLocalNode = fromLocalNode.getReader();
|
|
||||||
this.toLocalNode = toLocalNode.getWriter();
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let done = false;
|
|
||||||
while (!done) {
|
|
||||||
const result = await this.fromLocalNode.read();
|
|
||||||
done = result.done;
|
|
||||||
|
|
||||||
if (result.value) {
|
|
||||||
await this.handleSyncMessage(result.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
static async asPeer({
|
|
||||||
filename,
|
|
||||||
trace,
|
|
||||||
localNodeName = "local",
|
|
||||||
}: {
|
|
||||||
filename: string;
|
|
||||||
trace?: boolean;
|
|
||||||
localNodeName?: string;
|
|
||||||
}): Promise<Peer> {
|
|
||||||
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
|
|
||||||
localNodeName,
|
|
||||||
"storage",
|
|
||||||
{ peer1role: "client", peer2role: "server", trace }
|
|
||||||
);
|
|
||||||
|
|
||||||
await SQLiteStorage.open(
|
|
||||||
filename,
|
|
||||||
localNodeAsPeer.incoming,
|
|
||||||
localNodeAsPeer.outgoing
|
|
||||||
);
|
|
||||||
|
|
||||||
return storageAsPeer;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async open(
|
|
||||||
filename: string,
|
|
||||||
fromLocalNode: ReadableStream<SyncMessage>,
|
|
||||||
toLocalNode: WritableStream<SyncMessage>
|
|
||||||
) {
|
|
||||||
const db = Database(filename);
|
|
||||||
db.pragma("journal_mode = WAL");
|
|
||||||
|
|
||||||
const oldVersion = (
|
|
||||||
db.pragma("user_version") as [{ user_version: number }]
|
|
||||||
)[0].user_version as number;
|
|
||||||
|
|
||||||
console.log("DB version", oldVersion);
|
|
||||||
|
|
||||||
if (oldVersion === 0) {
|
|
||||||
console.log("Migration 0 -> 1: Basic schema");
|
|
||||||
db.prepare(
|
|
||||||
`CREATE TABLE IF NOT EXISTS transactions (
|
|
||||||
ses INTEGER,
|
|
||||||
idx INTEGER,
|
|
||||||
tx TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (ses, idx)
|
|
||||||
) WITHOUT ROWID;`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
rowID INTEGER PRIMARY KEY,
|
|
||||||
coValue INTEGER NOT NULL,
|
|
||||||
sessionID TEXT NOT NULL,
|
|
||||||
lastIdx INTEGER,
|
|
||||||
lastSignature TEXT,
|
|
||||||
UNIQUE (sessionID, coValue)
|
|
||||||
);`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`CREATE INDEX IF NOT EXISTS sessionsByCoValue ON sessions (coValue);`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`CREATE TABLE IF NOT EXISTS coValues (
|
|
||||||
rowID INTEGER PRIMARY KEY,
|
|
||||||
id TEXT NOT NULL UNIQUE,
|
|
||||||
header TEXT NOT NULL UNIQUE
|
|
||||||
);`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`CREATE INDEX IF NOT EXISTS coValuesByID ON coValues (id);`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.pragma("user_version = 1");
|
|
||||||
console.log("Migration 0 -> 1: Basic schema - done");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldVersion <= 1) {
|
|
||||||
// fix embarrassing off-by-one error for transaction indices
|
|
||||||
console.log(
|
|
||||||
"Migration 1 -> 2: Fix off-by-one error for transaction indices"
|
|
||||||
);
|
|
||||||
|
|
||||||
const txs = db
|
|
||||||
.prepare(`SELECT * FROM transactions`)
|
|
||||||
.all() as TransactionRow[];
|
|
||||||
|
|
||||||
for (const tx of txs) {
|
|
||||||
db.prepare(
|
|
||||||
`DELETE FROM transactions WHERE ses = ? AND idx = ?`
|
|
||||||
).run(tx.ses, tx.idx);
|
|
||||||
tx.idx -= 1;
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
|
|
||||||
).run(tx.ses, tx.idx, tx.tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.pragma("user_version = 2");
|
|
||||||
console.log(
|
|
||||||
"Migration 1 -> 2: Fix off-by-one error for transaction indices - done"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldVersion <= 2) {
|
|
||||||
console.log("Migration 2 -> 3: Add signatureAfter");
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`CREATE TABLE IF NOT EXISTS signatureAfter (
|
|
||||||
ses INTEGER,
|
|
||||||
idx INTEGER,
|
|
||||||
signature TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (ses, idx)
|
|
||||||
) WITHOUT ROWID;`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`ALTER TABLE sessions ADD COLUMN bytesSinceLastSignature INTEGER;`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.pragma("user_version = 3");
|
|
||||||
console.log("Migration 2 -> 3: Add signatureAfter - done");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SQLiteStorage(db, fromLocalNode, toLocalNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleSyncMessage(msg: SyncMessage) {
|
|
||||||
switch (msg.action) {
|
|
||||||
case "load":
|
|
||||||
await this.handleLoad(msg);
|
|
||||||
break;
|
|
||||||
case "content":
|
|
||||||
await this.handleContent(msg);
|
|
||||||
break;
|
|
||||||
case "known":
|
|
||||||
await this.handleKnown(msg);
|
|
||||||
break;
|
|
||||||
case "done":
|
|
||||||
await this.handleDone(msg);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendNewContentAfter(
|
|
||||||
theirKnown: CojsonInternalTypes.CoValueKnownState,
|
|
||||||
asDependencyOf?: CojsonInternalTypes.RawCoID
|
|
||||||
) {
|
|
||||||
const coValueRow = (await this.db
|
|
||||||
.prepare(`SELECT * FROM coValues WHERE id = ?`)
|
|
||||||
.get(theirKnown.id)) as StoredCoValueRow | undefined;
|
|
||||||
|
|
||||||
const allOurSessions = coValueRow
|
|
||||||
? (this.db
|
|
||||||
.prepare<number>(`SELECT * FROM sessions WHERE coValue = ?`)
|
|
||||||
.all(coValueRow.rowID) as StoredSessionRow[])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
|
||||||
id: theirKnown.id,
|
|
||||||
header: !!coValueRow,
|
|
||||||
sessions: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsedHeader = (coValueRow?.header &&
|
|
||||||
JSON.parse(coValueRow.header)) as
|
|
||||||
| CojsonInternalTypes.CoValueHeader
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const newContentPieces: CojsonInternalTypes.NewContentMessage[] = [
|
|
||||||
{
|
|
||||||
action: "content",
|
|
||||||
id: theirKnown.id,
|
|
||||||
header: theirKnown.header ? undefined : parsedHeader,
|
|
||||||
new: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const sessionRow of allOurSessions) {
|
|
||||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
|
||||||
|
|
||||||
if (
|
|
||||||
sessionRow.lastIdx >
|
|
||||||
(theirKnown.sessions[sessionRow.sessionID] || 0)
|
|
||||||
) {
|
|
||||||
const firstNewTxIdx =
|
|
||||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
|
||||||
|
|
||||||
const signaturesAndIdxs = this.db
|
|
||||||
.prepare<[number, number]>(
|
|
||||||
`SELECT * FROM signatureAfter WHERE ses = ? AND idx >= ?`
|
|
||||||
)
|
|
||||||
.all(sessionRow.rowID, firstNewTxIdx) as SignatureAfterRow[];
|
|
||||||
|
|
||||||
// console.log(
|
|
||||||
// theirKnown.id,
|
|
||||||
// "signaturesAndIdxs",
|
|
||||||
// JSON.stringify(signaturesAndIdxs)
|
|
||||||
// );
|
|
||||||
|
|
||||||
const newTxInSession = this.db
|
|
||||||
.prepare<[number, number]>(
|
|
||||||
`SELECT * FROM transactions WHERE ses = ? AND idx >= ?`
|
|
||||||
)
|
|
||||||
.all(sessionRow.rowID, firstNewTxIdx) as TransactionRow[];
|
|
||||||
|
|
||||||
let idx = firstNewTxIdx;
|
|
||||||
|
|
||||||
// console.log(
|
|
||||||
// theirKnown.id,
|
|
||||||
// "newTxInSession",
|
|
||||||
// newTxInSession.length
|
|
||||||
// );
|
|
||||||
|
|
||||||
for (const tx of newTxInSession) {
|
|
||||||
let sessionEntry =
|
|
||||||
newContentPieces[newContentPieces.length - 1]!.new[
|
|
||||||
sessionRow.sessionID
|
|
||||||
];
|
|
||||||
if (!sessionEntry) {
|
|
||||||
sessionEntry = {
|
|
||||||
after: idx,
|
|
||||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
|
||||||
newTransactions: [],
|
|
||||||
};
|
|
||||||
newContentPieces[newContentPieces.length - 1]!.new[
|
|
||||||
sessionRow.sessionID
|
|
||||||
] = sessionEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionEntry.newTransactions.push(JSON.parse(tx.tx));
|
|
||||||
|
|
||||||
if (
|
|
||||||
signaturesAndIdxs[0] &&
|
|
||||||
idx === signaturesAndIdxs[0].idx
|
|
||||||
) {
|
|
||||||
sessionEntry.lastSignature =
|
|
||||||
signaturesAndIdxs[0].signature;
|
|
||||||
signaturesAndIdxs.shift();
|
|
||||||
newContentPieces.push({
|
|
||||||
action: "content",
|
|
||||||
id: theirKnown.id,
|
|
||||||
new: {},
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
idx ===
|
|
||||||
firstNewTxIdx + newTxInSession.length - 1
|
|
||||||
) {
|
|
||||||
sessionEntry.lastSignature = sessionRow.lastSignature;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dependedOnCoValues =
|
|
||||||
parsedHeader?.ruleset.type === "group"
|
|
||||||
? newContentPieces
|
|
||||||
.flatMap((piece) => Object.values(piece.new)).flatMap((sessionEntry) =>
|
|
||||||
sessionEntry.newTransactions.flatMap((tx) => {
|
|
||||||
if (tx.privacy !== "trusting") return [];
|
|
||||||
// TODO: avoid parsing here?
|
|
||||||
return cojsonInternals
|
|
||||||
.parseJSON(tx.changes)
|
|
||||||
.map(
|
|
||||||
(change) =>
|
|
||||||
change &&
|
|
||||||
typeof change === "object" &&
|
|
||||||
"op" in change &&
|
|
||||||
change.op === "set" &&
|
|
||||||
"key" in change &&
|
|
||||||
change.key
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(key): key is CojsonInternalTypes.RawCoID =>
|
|
||||||
typeof key === "string" &&
|
|
||||||
key.startsWith("co_")
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
: parsedHeader?.ruleset.type === "ownedByGroup"
|
|
||||||
? [parsedHeader?.ruleset.group]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
|
||||||
await this.sendNewContentAfter(
|
|
||||||
{ id: dependedOnCoValue, header: false, sessions: {} },
|
|
||||||
asDependencyOf || theirKnown.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.toLocalNode.write({
|
|
||||||
action: "known",
|
|
||||||
...ourKnown,
|
|
||||||
asDependencyOf,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nonEmptyNewContentPieces = newContentPieces.filter(
|
|
||||||
(piece) => piece.header || Object.keys(piece.new).length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// console.log(theirKnown.id, nonEmptyNewContentPieces);
|
|
||||||
|
|
||||||
for (const piece of nonEmptyNewContentPieces) {
|
|
||||||
await this.toLocalNode.write(piece);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoad(msg: CojsonInternalTypes.LoadMessage) {
|
|
||||||
return this.sendNewContentAfter(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
|
|
||||||
let storedCoValueRowID = (
|
|
||||||
this.db
|
|
||||||
.prepare<CojsonInternalTypes.RawCoID>(
|
|
||||||
`SELECT rowID FROM coValues WHERE id = ?`
|
|
||||||
)
|
|
||||||
.get(msg.id) as StoredCoValueRow | undefined
|
|
||||||
)?.rowID;
|
|
||||||
|
|
||||||
if (storedCoValueRowID === undefined) {
|
|
||||||
const header = msg.header;
|
|
||||||
if (!header) {
|
|
||||||
console.error("Expected to be sent header first");
|
|
||||||
await this.toLocalNode.write({
|
|
||||||
action: "known",
|
|
||||||
id: msg.id,
|
|
||||||
header: false,
|
|
||||||
sessions: {},
|
|
||||||
isCorrection: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
storedCoValueRowID = this.db
|
|
||||||
.prepare<[CojsonInternalTypes.RawCoID, string]>(
|
|
||||||
`INSERT INTO coValues (id, header) VALUES (?, ?)`
|
|
||||||
)
|
|
||||||
.run(msg.id, JSON.stringify(header)).lastInsertRowid as number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
|
||||||
id: msg.id,
|
|
||||||
header: true,
|
|
||||||
sessions: {},
|
|
||||||
};
|
|
||||||
let invalidAssumptions = false;
|
|
||||||
|
|
||||||
this.db.transaction(() => {
|
|
||||||
const allOurSessions = (
|
|
||||||
this.db
|
|
||||||
.prepare<number>(`SELECT * FROM sessions WHERE coValue = ?`)
|
|
||||||
.all(storedCoValueRowID!) as StoredSessionRow[]
|
|
||||||
).reduce((acc, row) => {
|
|
||||||
acc[row.sessionID] = row;
|
|
||||||
return acc;
|
|
||||||
}, {} as { [sessionID: string]: StoredSessionRow });
|
|
||||||
|
|
||||||
for (const sessionID of Object.keys(msg.new) as SessionID[]) {
|
|
||||||
const sessionRow = allOurSessions[sessionID];
|
|
||||||
if (sessionRow) {
|
|
||||||
ourKnown.sessions[sessionRow.sessionID] =
|
|
||||||
sessionRow.lastIdx;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(sessionRow?.lastIdx || 0) <
|
|
||||||
(msg.new[sessionID]?.after || 0)
|
|
||||||
) {
|
|
||||||
invalidAssumptions = true;
|
|
||||||
} else {
|
|
||||||
const newTransactions =
|
|
||||||
msg.new[sessionID]?.newTransactions || [];
|
|
||||||
|
|
||||||
const actuallyNewOffset =
|
|
||||||
(sessionRow?.lastIdx || 0) -
|
|
||||||
(msg.new[sessionID]?.after || 0);
|
|
||||||
|
|
||||||
const actuallyNewTransactions =
|
|
||||||
newTransactions.slice(actuallyNewOffset);
|
|
||||||
|
|
||||||
let newBytesSinceLastSignature =
|
|
||||||
(sessionRow?.bytesSinceLastSignature || 0) +
|
|
||||||
actuallyNewTransactions.reduce(
|
|
||||||
(sum, tx) =>
|
|
||||||
sum +
|
|
||||||
(tx.privacy === "private"
|
|
||||||
? tx.encryptedChanges.length
|
|
||||||
: tx.changes.length),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const newLastIdx =
|
|
||||||
(sessionRow?.lastIdx || 0) +
|
|
||||||
actuallyNewTransactions.length;
|
|
||||||
|
|
||||||
let shouldWriteSignature = false;
|
|
||||||
|
|
||||||
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
|
|
||||||
shouldWriteSignature = true;
|
|
||||||
newBytesSinceLastSignature = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextIdx = sessionRow?.lastIdx || 0;
|
|
||||||
|
|
||||||
const sessionUpdate = {
|
|
||||||
coValue: storedCoValueRowID!,
|
|
||||||
sessionID: sessionID,
|
|
||||||
lastIdx: newLastIdx,
|
|
||||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
|
||||||
bytesSinceLastSignature: newBytesSinceLastSignature,
|
|
||||||
};
|
|
||||||
|
|
||||||
const upsertedSession = this.db
|
|
||||||
.prepare<[number, string, number, string, number]>(
|
|
||||||
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature, bytesSinceLastSignature) VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature, bytesSinceLastSignature=excluded.bytesSinceLastSignature
|
|
||||||
RETURNING rowID`
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
sessionUpdate.coValue,
|
|
||||||
sessionUpdate.sessionID,
|
|
||||||
sessionUpdate.lastIdx,
|
|
||||||
sessionUpdate.lastSignature,
|
|
||||||
sessionUpdate.bytesSinceLastSignature,
|
|
||||||
) as { rowID: number };
|
|
||||||
|
|
||||||
const sessionRowID = upsertedSession.rowID;
|
|
||||||
|
|
||||||
if (shouldWriteSignature) {
|
|
||||||
this.db
|
|
||||||
.prepare<[number, number, string]>(
|
|
||||||
`INSERT INTO signatureAfter (ses, idx, signature) VALUES (?, ?, ?)`
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
sessionRowID,
|
|
||||||
// TODO: newLastIdx is a misnomer, it's actually more like nextIdx or length
|
|
||||||
newLastIdx - 1,
|
|
||||||
msg.new[sessionID]!.lastSignature
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const newTransaction of actuallyNewTransactions) {
|
|
||||||
this.db
|
|
||||||
.prepare<[number, number, string]>(
|
|
||||||
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
sessionRowID,
|
|
||||||
nextIdx,
|
|
||||||
JSON.stringify(newTransaction)
|
|
||||||
);
|
|
||||||
nextIdx++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (invalidAssumptions) {
|
|
||||||
await this.toLocalNode.write({
|
|
||||||
action: "known",
|
|
||||||
...ourKnown,
|
|
||||||
isCorrection: invalidAssumptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKnown(msg: CojsonInternalTypes.KnownStateMessage) {
|
|
||||||
return this.sendNewContentAfter(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDone(_msg: CojsonInternalTypes.DoneMessage) {}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext"],
|
|
||||||
"module": "esnext",
|
|
||||||
"target": "ES2020",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
},
|
|
||||||
"include": ["./src/**/*"],
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ module.exports = {
|
|||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: "./tsconfig.json",
|
project: "./tsconfig.json",
|
||||||
},
|
},
|
||||||
ignorePatterns: [".eslint.cjs", "**/tests/*"],
|
|
||||||
root: true,
|
root: true,
|
||||||
rules: {
|
rules: {
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
|
|||||||
@@ -1,3 +1,53 @@
|
|||||||
# CoJSON
|
# CoJSON
|
||||||
|
|
||||||
[See the top-level README](../../README.md#cojson)
|
CoJSON ("Collaborative JSON") will be a minimal protocol and implementation for collaborative values (CRDTs + public-key cryptography).
|
||||||
|
|
||||||
|
CoJSON is developed by [Garden Computing](https://gcmp.io) as the underpinnings of [Jazz](https://jazz.tools), a framework for building apps with telepathic data.
|
||||||
|
|
||||||
|
The protocol and implementation will cover:
|
||||||
|
|
||||||
|
- how to represent collaborative values internally
|
||||||
|
- the APIs collaborative values expose
|
||||||
|
- how to sync and query for collaborative values between peers
|
||||||
|
- how to enforce access rights within collaborative values locally and at sync boundaries
|
||||||
|
|
||||||
|
THIS IS WORK IN PROGRESS
|
||||||
|
|
||||||
|
## Core Value Types
|
||||||
|
|
||||||
|
### `Immutable` Values (JSON)
|
||||||
|
- null
|
||||||
|
- boolean
|
||||||
|
- number
|
||||||
|
- string
|
||||||
|
- stringly-encoded CoJSON identifiers & data (`CoID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`)
|
||||||
|
|
||||||
|
- array
|
||||||
|
- object
|
||||||
|
|
||||||
|
### `Collaborative` Values
|
||||||
|
- CoMap (`string` → `Immutable`, last-writer-wins per key)
|
||||||
|
- Team (`AgentID` → `Role`)
|
||||||
|
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
|
||||||
|
- Agent (`{signerID, sealerID}[]`)
|
||||||
|
- CoStream (independent per-session streams of `Immutable`s)
|
||||||
|
- Static (single addressable `Immutable`)
|
||||||
|
|
||||||
|
## Implementation Abstractions
|
||||||
|
- CoValue
|
||||||
|
- Session Logs
|
||||||
|
- Transactions
|
||||||
|
- Private (encrypted) transactions
|
||||||
|
- Trusting (unencrypted) transactions
|
||||||
|
- Rulesets
|
||||||
|
- CoValue Content Types
|
||||||
|
- LocalNode
|
||||||
|
- Peers
|
||||||
|
- AgentCredentials
|
||||||
|
- Peer
|
||||||
|
|
||||||
|
## Extensions & higher-level protocols
|
||||||
|
|
||||||
|
### More complex datastructures
|
||||||
|
- CoText: a clean way to collaboratively mark up rich text with CoJSON
|
||||||
|
- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
"name": "cojson",
|
"name": "cojson",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "0.3.3",
|
"version": "0.0.18",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.3",
|
"@types/jest": "^29.5.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||||
@@ -19,8 +19,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^0.1.3",
|
"@noble/ciphers": "^0.1.3",
|
||||||
"@noble/curves": "^1.1.0",
|
"@noble/curves": "^1.1.0",
|
||||||
|
"@noble/hashes": "^1.3.1",
|
||||||
"@scure/base": "^1.1.1",
|
"@scure/base": "^1.1.1",
|
||||||
"hash-wasm": "^4.9.0",
|
"fast-json-stable-stringify": "https://github.com/tirithen/fast-json-stable-stringify#7a3dcf2",
|
||||||
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -50,6 +51,5 @@
|
|||||||
"/node_modules/",
|
"/node_modules/",
|
||||||
"/dist/"
|
"/dist/"
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { newRandomSessionID } from "../coValueCore.js";
|
import { newRandomSessionID } from "./coValue.js";
|
||||||
import { cojsonReady } from "../index.js";
|
import { LocalNode } from "./node.js";
|
||||||
import { LocalNode } from "../localNode.js";
|
import { connectedPeers } from "./streamUtils.js";
|
||||||
import { connectedPeers } from "../streamUtils.js";
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await cojsonReady;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Can create a node while creating a new account with profile", async () => {
|
test("Can create a node while creating a new account with profile", async () => {
|
||||||
const { node, accountID, accountSecret, sessionID } =
|
const { node, accountID, accountSecret, sessionID } =
|
||||||
@@ -19,16 +14,19 @@ test("Can create a node while creating a new account with profile", async () =>
|
|||||||
expect(node.expectProfileLoaded(accountID).get("name")).toEqual(
|
expect(node.expectProfileLoaded(accountID).get("name")).toEqual(
|
||||||
"Hermes Puggington"
|
"Hermes Puggington"
|
||||||
);
|
);
|
||||||
|
expect((await node.loadProfile(accountID)).get("name")).toEqual(
|
||||||
|
"Hermes Puggington"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("A node with an account can create groups and and objects within them", async () => {
|
test("A node with an account can create teams and and objects within them", async () => {
|
||||||
const { node, accountID } =
|
const { node, accountID } =
|
||||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||||
|
|
||||||
const group = await node.createGroup();
|
const team = await node.createTeam();
|
||||||
expect(group).not.toBeNull();
|
expect(team).not.toBeNull();
|
||||||
|
|
||||||
let map = group.createMap();
|
let map = team.createMap();
|
||||||
map = map.edit((edit) => {
|
map = map.edit((edit) => {
|
||||||
edit.set("foo", "bar", "private");
|
edit.set("foo", "bar", "private");
|
||||||
expect(edit.get("foo")).toEqual("bar");
|
expect(edit.get("foo")).toEqual("bar");
|
||||||
@@ -36,17 +34,17 @@ test("A node with an account can create groups and and objects within them", asy
|
|||||||
|
|
||||||
expect(map.get("foo")).toEqual("bar");
|
expect(map.get("foo")).toEqual("bar");
|
||||||
|
|
||||||
expect(map.lastEditAt("foo")?.by).toEqual(accountID);
|
expect(map.getLastEditor("foo")).toEqual(accountID);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Can create account with one node, and then load it on another", async () => {
|
test("Can create account with one node, and then load it on another", async () => {
|
||||||
const { node, accountID, accountSecret } =
|
const { node, accountID, accountSecret } =
|
||||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||||
|
|
||||||
const group = await node.createGroup();
|
const team = await node.createTeam();
|
||||||
expect(group).not.toBeNull();
|
expect(team).not.toBeNull();
|
||||||
|
|
||||||
let map = group.createMap();
|
let map = team.createMap();
|
||||||
map = map.edit((edit) => {
|
map = map.edit((edit) => {
|
||||||
edit.set("foo", "bar", "private");
|
edit.set("foo", "bar", "private");
|
||||||
expect(edit.get("foo")).toEqual("bar");
|
expect(edit.get("foo")).toEqual("bar");
|
||||||
@@ -54,7 +52,7 @@ test("Can create account with one node, and then load it on another", async () =
|
|||||||
|
|
||||||
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {trace: true, peer1role: "server", peer2role: "client"});
|
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {trace: true, peer1role: "server", peer2role: "client"});
|
||||||
|
|
||||||
node.syncManager.addPeer(node2asPeer);
|
node.sync.addPeer(node2asPeer);
|
||||||
|
|
||||||
const node2 = await LocalNode.withLoadedAccount(
|
const node2 = await LocalNode.withLoadedAccount(
|
||||||
accountID,
|
accountID,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CoValueHeader } from "./coValueCore.js";
|
import { CoValueHeader } from "./coValue.js";
|
||||||
import { CoID } from "./coValue.js";
|
import { CoID } from "./contentType.js";
|
||||||
import {
|
import {
|
||||||
AgentSecret,
|
AgentSecret,
|
||||||
SealerID,
|
SealerID,
|
||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
getAgentSignerSecret,
|
getAgentSignerSecret,
|
||||||
} from "./crypto.js";
|
} from "./crypto.js";
|
||||||
import { AgentID } from "./ids.js";
|
import { AgentID } from "./ids.js";
|
||||||
import { CoMap } from "./coValues/coMap.js";
|
import { CoMap, LocalNode } from "./index.js";
|
||||||
import { LocalNode } from "./localNode.js";
|
import { Team, TeamContent } from "./permissions.js";
|
||||||
import { Group, GroupContent } from "./group.js";
|
|
||||||
|
|
||||||
export function accountHeaderForInitialAgentSecret(
|
export function accountHeaderForInitialAgentSecret(
|
||||||
agentSecret: AgentSecret
|
agentSecret: AgentSecret
|
||||||
@@ -23,7 +22,7 @@ export function accountHeaderForInitialAgentSecret(
|
|||||||
const agent = getAgentID(agentSecret);
|
const agent = getAgentID(agentSecret);
|
||||||
return {
|
return {
|
||||||
type: "comap",
|
type: "comap",
|
||||||
ruleset: { type: "group", initialAdmin: agent },
|
ruleset: { type: "team", initialAdmin: agent },
|
||||||
meta: {
|
meta: {
|
||||||
type: "account",
|
type: "account",
|
||||||
},
|
},
|
||||||
@@ -32,13 +31,13 @@ export function accountHeaderForInitialAgentSecret(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AccountGroup extends Group {
|
export class Account extends Team {
|
||||||
get id(): AccountID {
|
get id(): AccountID {
|
||||||
return this.underlyingMap.id as AccountID;
|
return this.teamMap.id as AccountID;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentAgentID(): AgentID {
|
getCurrentAgentID(): AgentID {
|
||||||
const agents = this.underlyingMap
|
const agents = this.teamMap
|
||||||
.keys()
|
.keys()
|
||||||
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ export class AccountGroup extends Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneralizedControlledAccount {
|
export interface GeneralizedControlledAccount {
|
||||||
id: AccountID | AgentID;
|
id: AccountIDOrAgentID;
|
||||||
agentSecret: AgentSecret;
|
agentSecret: AgentSecret;
|
||||||
|
|
||||||
currentAgentID: () => AgentID;
|
currentAgentID: () => AgentID;
|
||||||
@@ -63,19 +62,18 @@ export interface GeneralizedControlledAccount {
|
|||||||
currentSealerSecret: () => SealerSecret;
|
currentSealerSecret: () => SealerSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @hidden */
|
|
||||||
export class ControlledAccount
|
export class ControlledAccount
|
||||||
extends AccountGroup
|
extends Account
|
||||||
implements GeneralizedControlledAccount
|
implements GeneralizedControlledAccount
|
||||||
{
|
{
|
||||||
agentSecret: AgentSecret;
|
agentSecret: AgentSecret;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
agentSecret: AgentSecret,
|
agentSecret: AgentSecret,
|
||||||
groupMap: CoMap<AccountContent, AccountMeta>,
|
teamMap: CoMap<AccountContent, AccountMeta>,
|
||||||
node: LocalNode
|
node: LocalNode
|
||||||
) {
|
) {
|
||||||
super(groupMap, node);
|
super(teamMap, node);
|
||||||
|
|
||||||
this.agentSecret = agentSecret;
|
this.agentSecret = agentSecret;
|
||||||
}
|
}
|
||||||
@@ -101,7 +99,6 @@ export class ControlledAccount
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @hidden */
|
|
||||||
export class AnonymousControlledAccount
|
export class AnonymousControlledAccount
|
||||||
implements GeneralizedControlledAccount
|
implements GeneralizedControlledAccount
|
||||||
{
|
{
|
||||||
@@ -136,12 +133,15 @@ export class AnonymousControlledAccount
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountContent = { profile: Profile } & GroupContent;
|
export type AccountContent = TeamContent & { profile: CoID<Profile> };
|
||||||
export type AccountMeta = { type: "account" };
|
export type AccountMeta = { type: "account" };
|
||||||
export type Account = CoMap<AccountContent, AccountMeta>;
|
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
|
||||||
export type AccountID = CoID<Account>;
|
|
||||||
|
|
||||||
export function isAccountID(id: AccountID | AgentID): id is AccountID {
|
export type AccountIDOrAgentID = AgentID | AccountID;
|
||||||
|
export type AccountOrAgentID = AgentID | Account;
|
||||||
|
export type AccountOrAgentSecret = AgentSecret | Account;
|
||||||
|
|
||||||
|
export function isAccountID(id: AccountIDOrAgentID): id is AccountID {
|
||||||
return id.startsWith("co_");
|
return id.startsWith("co_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url";
|
|
||||||
|
|
||||||
const txt = new TextEncoder();
|
|
||||||
|
|
||||||
test("Test our Base64 URL encoding and decoding", () => {
|
|
||||||
// tests from the RFC
|
|
||||||
|
|
||||||
expect(base64URLtoBytes("")).toEqual(new Uint8Array([]));
|
|
||||||
expect(bytesToBase64url(new Uint8Array([]))).toEqual("");
|
|
||||||
|
|
||||||
expect(bytesToBase64url(txt.encode("f"))).toEqual("Zg==");
|
|
||||||
expect(bytesToBase64url(txt.encode("fo"))).toEqual("Zm8=");
|
|
||||||
expect(bytesToBase64url(txt.encode("foo"))).toEqual("Zm9v");
|
|
||||||
expect(bytesToBase64url(txt.encode("foob"))).toEqual("Zm9vYg==");
|
|
||||||
expect(bytesToBase64url(txt.encode("fooba"))).toEqual("Zm9vYmE=");
|
|
||||||
expect(bytesToBase64url(txt.encode("foobar"))).toEqual("Zm9vYmFy");
|
|
||||||
// reverse
|
|
||||||
expect(base64URLtoBytes("Zg==")).toEqual(txt.encode("f"));
|
|
||||||
expect(base64URLtoBytes("Zm8=")).toEqual(txt.encode("fo"));
|
|
||||||
expect(base64URLtoBytes("Zm9v")).toEqual(txt.encode("foo"));
|
|
||||||
expect(base64URLtoBytes("Zm9vYg==")).toEqual(txt.encode("foob"));
|
|
||||||
expect(base64URLtoBytes("Zm9vYmE=")).toEqual(txt.encode("fooba"));
|
|
||||||
expect(base64URLtoBytes("Zm9vYmFy")).toEqual(txt.encode("foobar"));
|
|
||||||
|
|
||||||
expect(base64URLtoBytes("V2hhdCBkb2VzIDIgKyAyLjEgZXF1YWw_PyB-IDQ=")).toEqual(
|
|
||||||
txt.encode("What does 2 + 2.1 equal?? ~ 4")
|
|
||||||
);
|
|
||||||
// reverse
|
|
||||||
expect(
|
|
||||||
bytesToBase64url(txt.encode("What does 2 + 2.1 equal?? ~ 4"))
|
|
||||||
).toEqual("V2hhdCBkb2VzIDIgKyAyLjEgZXF1YWw_PyB-IDQ=");
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
const encoder = new TextEncoder();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
export function base64URLtoBytes(base64: string) {
|
|
||||||
base64 = base64.replace(/=/g, "");
|
|
||||||
const n = base64.length;
|
|
||||||
const rem = n % 4;
|
|
||||||
const k = rem && rem - 1; // how many bytes the last base64 chunk encodes
|
|
||||||
const m = (n >> 2) * 3 + k; // total encoded bytes
|
|
||||||
|
|
||||||
const encoded = new Uint8Array(n + 3);
|
|
||||||
encoder.encodeInto(base64 + "===", encoded);
|
|
||||||
|
|
||||||
for (let i = 0, j = 0; i < n; i += 4, j += 3) {
|
|
||||||
const x =
|
|
||||||
(lookup[encoded[i]!]! << 18) +
|
|
||||||
(lookup[encoded[i + 1]!]! << 12) +
|
|
||||||
(lookup[encoded[i + 2]!]! << 6) +
|
|
||||||
lookup[encoded[i + 3]!]!;
|
|
||||||
encoded[j] = x >> 16;
|
|
||||||
encoded[j + 1] = (x >> 8) & 0xff;
|
|
||||||
encoded[j + 2] = x & 0xff;
|
|
||||||
}
|
|
||||||
return new Uint8Array(encoded.buffer, 0, m);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bytesToBase64url(bytes: Uint8Array) {
|
|
||||||
// const before = performance.now();
|
|
||||||
const m = bytes.length;
|
|
||||||
const k = m % 3;
|
|
||||||
const n = Math.floor(m / 3) * 4 + (k && k + 1);
|
|
||||||
const N = Math.ceil(m / 3) * 4;
|
|
||||||
const encoded = new Uint8Array(N);
|
|
||||||
|
|
||||||
for (let i = 0, j = 0; j < m; i += 4, j += 3) {
|
|
||||||
const y =
|
|
||||||
(bytes[j]! << 16) + (bytes[j + 1]! << 8) + (bytes[j + 2]! | 0);
|
|
||||||
encoded[i] = encodeLookup[y >> 18]!;
|
|
||||||
encoded[i + 1] = encodeLookup[(y >> 12) & 0x3f]!;
|
|
||||||
encoded[i + 2] = encodeLookup[(y >> 6) & 0x3f]!;
|
|
||||||
encoded[i + 3] = encodeLookup[y & 0x3f]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
let base64 = decoder.decode(new Uint8Array(encoded.buffer, 0, n));
|
|
||||||
if (k === 1) base64 += "==";
|
|
||||||
if (k === 2) base64 += "=";
|
|
||||||
// const after = performance.now();
|
|
||||||
// console.log(
|
|
||||||
// "bytesToBase64url bandwidth in MB/s for length",
|
|
||||||
// (1000 * bytes.length / (after - before)) / (1024 * 1024),
|
|
||||||
// bytes.length
|
|
||||||
// );
|
|
||||||
return base64;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alphabet =
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
||||||
|
|
||||||
const lookup = new Uint8Array(128);
|
|
||||||
for (const [i, a] of Array.from(alphabet).entries()) {
|
|
||||||
lookup[a.charCodeAt(0)] = i;
|
|
||||||
}
|
|
||||||
lookup["=".charCodeAt(0)] = 0;
|
|
||||||
|
|
||||||
const encodeLookup = new Uint8Array(64);
|
|
||||||
for (const [i, a] of Array.from(alphabet).entries()) {
|
|
||||||
encodeLookup[i] = a.charCodeAt(0);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { Transaction } from "../coValueCore.js";
|
import { Transaction } from "./coValue.js";
|
||||||
import { LocalNode } from "../localNode.js";
|
import { LocalNode } from "./node.js";
|
||||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "../crypto.js";
|
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||||
import { MapOpPayload } from "../coValues/coMap.js";
|
|
||||||
import { Role } from "../permissions.js";
|
|
||||||
import { cojsonReady } from "../index.js";
|
|
||||||
import { stableStringify } from "../jsonStringify.js";
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await cojsonReady;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
||||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||||
@@ -25,21 +17,21 @@ test("Can create coValue with new agent credentials and add transaction to it",
|
|||||||
const transaction: Transaction = {
|
const transaction: Transaction = {
|
||||||
privacy: "trusting",
|
privacy: "trusting",
|
||||||
madeAt: Date.now(),
|
madeAt: Date.now(),
|
||||||
changes: stableStringify([
|
changes: [
|
||||||
{
|
{
|
||||||
hello: "world",
|
hello: "world",
|
||||||
},
|
},
|
||||||
]),
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||||
node.currentSessionID,
|
node.ownSessionID,
|
||||||
[transaction]
|
[transaction]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
coValue.tryAddTransactions(
|
coValue.tryAddTransactions(
|
||||||
node.currentSessionID,
|
node.ownSessionID,
|
||||||
[transaction],
|
[transaction],
|
||||||
expectedNewHash,
|
expectedNewHash,
|
||||||
sign(account.currentSignerSecret(), expectedNewHash)
|
sign(account.currentSignerSecret(), expectedNewHash)
|
||||||
@@ -62,21 +54,21 @@ test("transactions with wrong signature are rejected", () => {
|
|||||||
const transaction: Transaction = {
|
const transaction: Transaction = {
|
||||||
privacy: "trusting",
|
privacy: "trusting",
|
||||||
madeAt: Date.now(),
|
madeAt: Date.now(),
|
||||||
changes: stableStringify([
|
changes: [
|
||||||
{
|
{
|
||||||
hello: "world",
|
hello: "world",
|
||||||
},
|
},
|
||||||
]),
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||||
node.currentSessionID,
|
node.ownSessionID,
|
||||||
[transaction]
|
[transaction]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
coValue.tryAddTransactions(
|
coValue.tryAddTransactions(
|
||||||
node.currentSessionID,
|
node.ownSessionID,
|
||||||
[transaction],
|
[transaction],
|
||||||
expectedNewHash,
|
expectedNewHash,
|
||||||
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
|
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
|
||||||
@@ -98,89 +90,34 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
|||||||
const transaction: Transaction = {
|
const transaction: Transaction = {
|
||||||
privacy: "trusting",
|
privacy: "trusting",
|
||||||
madeAt: Date.now(),
|
madeAt: Date.now(),
|
||||||
changes: stableStringify([
|
changes: [
|
||||||
{
|
{
|
||||||
hello: "world",
|
hello: "world",
|
||||||
},
|
},
|
||||||
]),
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||||
node.currentSessionID,
|
node.ownSessionID,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
privacy: "trusting",
|
privacy: "trusting",
|
||||||
madeAt: Date.now(),
|
madeAt: Date.now(),
|
||||||
changes: stableStringify([
|
changes: [
|
||||||
{
|
{
|
||||||
hello: "wrong",
|
hello: "wrong",
|
||||||
},
|
},
|
||||||
]),
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
coValue.tryAddTransactions(
|
coValue.tryAddTransactions(
|
||||||
node.currentSessionID,
|
node.ownSessionID,
|
||||||
[transaction],
|
[transaction],
|
||||||
expectedNewHash,
|
expectedNewHash,
|
||||||
sign(account.currentSignerSecret(), expectedNewHash)
|
sign(account.currentSignerSecret(), expectedNewHash)
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("New transactions in a group correctly update owned values, including subscriptions", async () => {
|
|
||||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
|
||||||
const node = new LocalNode(account, sessionID);
|
|
||||||
|
|
||||||
const group = node.createGroup();
|
|
||||||
|
|
||||||
const timeBeforeEdit = Date.now();
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
let map = group.createMap();
|
|
||||||
|
|
||||||
let mapAfterEdit = map.edit((map) => {
|
|
||||||
map.set("hello", "world");
|
|
||||||
});
|
|
||||||
|
|
||||||
const listener = jest.fn().mockImplementation();
|
|
||||||
|
|
||||||
map.subscribe(listener);
|
|
||||||
|
|
||||||
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
|
|
||||||
|
|
||||||
const resignationThatWeJustLearnedAbout = {
|
|
||||||
privacy: "trusting",
|
|
||||||
madeAt: timeBeforeEdit,
|
|
||||||
changes: stableStringify([
|
|
||||||
{
|
|
||||||
op: "set",
|
|
||||||
key: account.id,
|
|
||||||
value: "revoked"
|
|
||||||
} satisfies MapOpPayload<typeof account.id, Role>
|
|
||||||
])
|
|
||||||
} satisfies Transaction;
|
|
||||||
|
|
||||||
const { expectedNewHash } = group.underlyingMap.core.expectedNewHashAfter(sessionID, [
|
|
||||||
resignationThatWeJustLearnedAbout,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const signature = sign(
|
|
||||||
node.account.currentSignerSecret(),
|
|
||||||
expectedNewHash
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(map.core.getValidSortedTransactions().length).toBe(1);
|
|
||||||
|
|
||||||
const manuallyAdddedTxSuccess = group.underlyingMap.core.tryAddTransactions(node.currentSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
|
||||||
|
|
||||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
|
||||||
|
|
||||||
expect(listener.mock.calls.length).toBe(2);
|
|
||||||
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
|
|
||||||
|
|
||||||
expect(map.core.getValidSortedTransactions().length).toBe(0);
|
|
||||||
});
|
|
||||||
@@ -1,95 +1,547 @@
|
|||||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
import { randomBytes } from "@noble/hashes/utils";
|
||||||
import { RawCoID } from "./ids.js";
|
import { ContentType } from "./contentType.js";
|
||||||
import { CoMap } from "./coValues/coMap.js";
|
import { Static } from "./contentTypes/static.js";
|
||||||
|
import { CoStream } from "./contentTypes/coStream.js";
|
||||||
|
import { CoMap } from "./contentTypes/coMap.js";
|
||||||
import {
|
import {
|
||||||
BinaryCoStream,
|
Encrypted,
|
||||||
BinaryCoStreamMeta,
|
Hash,
|
||||||
CoStream,
|
KeySecret,
|
||||||
} from "./coValues/coStream.js";
|
Signature,
|
||||||
import { CoList } from "./coValues/coList.js";
|
StreamingHash,
|
||||||
import { CoValueCore } from "./coValueCore.js";
|
unseal,
|
||||||
import { Group } from "./group.js";
|
shortHash,
|
||||||
|
sign,
|
||||||
|
verify,
|
||||||
|
encryptForTransaction,
|
||||||
|
decryptForTransaction,
|
||||||
|
KeyID,
|
||||||
|
decryptKeySecret,
|
||||||
|
getAgentSignerID,
|
||||||
|
getAgentSealerID,
|
||||||
|
} from "./crypto.js";
|
||||||
|
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||||
|
import { base58 } from "@scure/base";
|
||||||
|
import {
|
||||||
|
PermissionsDef as RulesetDef,
|
||||||
|
Team,
|
||||||
|
determineValidTransactions,
|
||||||
|
expectTeamContent,
|
||||||
|
isKeyForKeyField,
|
||||||
|
} from "./permissions.js";
|
||||||
|
import { LocalNode } from "./node.js";
|
||||||
|
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||||
|
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||||
|
import { CoList } from "./contentTypes/coList.js";
|
||||||
|
import {
|
||||||
|
AccountID,
|
||||||
|
AccountIDOrAgentID,
|
||||||
|
GeneralizedControlledAccount,
|
||||||
|
} from "./account.js";
|
||||||
|
|
||||||
export type CoID<T extends CoValue> = RawCoID & {
|
export type CoValueHeader = {
|
||||||
readonly __type: T;
|
type: ContentType["type"];
|
||||||
|
ruleset: RulesetDef;
|
||||||
|
meta: JsonObject | null;
|
||||||
|
createdAt: `2${string}` | null;
|
||||||
|
uniqueness: `z${string}` | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CoValue {
|
export function idforHeader(header: CoValueHeader): RawCoID {
|
||||||
/** The `CoValue`'s (precisely typed) `CoID` */
|
const hash = shortHash(header);
|
||||||
id: CoID<this>;
|
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||||
core: CoValueCore;
|
|
||||||
/** Specifies which kind of `CoValue` this is */
|
|
||||||
type: string;
|
|
||||||
/** The `CoValue`'s (precisely typed) static metadata */
|
|
||||||
meta: JsonObject | null;
|
|
||||||
/** The `Group` this `CoValue` belongs to (determining permissions) */
|
|
||||||
group: Group;
|
|
||||||
/** Returns an immutable JSON presentation of this `CoValue` */
|
|
||||||
toJSON(): JsonValue;
|
|
||||||
atTime(time: number): this;
|
|
||||||
/** Lets you subscribe to future updates to this CoValue (whether made locally or by other users).
|
|
||||||
*
|
|
||||||
* Takes a listener function that will be called with the current state for each update.
|
|
||||||
*
|
|
||||||
* Returns an unsubscribe function.
|
|
||||||
*
|
|
||||||
* Used internally by `useTelepathicData()` for reactive updates on changes to a `CoValue`. */
|
|
||||||
subscribe(listener: (coValue: this) => void): () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnyCoMap = CoMap<
|
export function accountOrAgentIDfromSessionID(
|
||||||
{ [key: string]: JsonValue | CoValue | undefined },
|
sessionID: SessionID
|
||||||
JsonObject | null
|
): AccountIDOrAgentID {
|
||||||
>;
|
return sessionID.split("_session")[0] as AccountIDOrAgentID;
|
||||||
|
}
|
||||||
|
|
||||||
export type AnyCoList = CoList<JsonValue | CoValue, JsonObject | null>;
|
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
|
||||||
|
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type AnyCoStream = CoStream<JsonValue | CoValue, JsonObject | null>;
|
type SessionLog = {
|
||||||
|
transactions: Transaction[];
|
||||||
|
lastHash?: Hash;
|
||||||
|
streamingHash: StreamingHash;
|
||||||
|
lastSignature: Signature;
|
||||||
|
};
|
||||||
|
|
||||||
export type AnyBinaryCoStream = BinaryCoStream<BinaryCoStreamMeta>;
|
export type PrivateTransaction = {
|
||||||
|
privacy: "private";
|
||||||
|
madeAt: number;
|
||||||
|
keyUsed: KeyID;
|
||||||
|
encryptedChanges: Encrypted<
|
||||||
|
JsonValue[],
|
||||||
|
{ in: RawCoID; tx: TransactionID }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrustingTransaction = {
|
||||||
|
privacy: "trusting";
|
||||||
|
madeAt: number;
|
||||||
|
changes: JsonValue[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AnyCoValue =
|
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||||
| AnyCoMap
|
|
||||||
| AnyCoList
|
|
||||||
| AnyCoStream
|
|
||||||
| AnyBinaryCoStream
|
|
||||||
|
|
||||||
export function expectMap(
|
export type DecryptedTransaction = {
|
||||||
content: CoValue
|
txID: TransactionID;
|
||||||
): AnyCoMap {
|
changes: JsonValue[];
|
||||||
if (content.type !== "comap") {
|
madeAt: number;
|
||||||
throw new Error("Expected map");
|
};
|
||||||
|
|
||||||
|
export class CoValue {
|
||||||
|
id: RawCoID;
|
||||||
|
node: LocalNode;
|
||||||
|
header: CoValueHeader;
|
||||||
|
sessions: { [key: SessionID]: SessionLog };
|
||||||
|
content?: ContentType;
|
||||||
|
listeners: Set<(content?: ContentType) => void> = new Set();
|
||||||
|
|
||||||
|
constructor(header: CoValueHeader, node: LocalNode) {
|
||||||
|
this.id = idforHeader(header);
|
||||||
|
this.header = header;
|
||||||
|
this.sessions = {};
|
||||||
|
this.node = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
return content as AnyCoMap;
|
testWithDifferentAccount(
|
||||||
}
|
account: GeneralizedControlledAccount,
|
||||||
|
ownSessionID: SessionID
|
||||||
|
): CoValue {
|
||||||
|
const newNode = this.node.testWithDifferentAccount(
|
||||||
|
account,
|
||||||
|
ownSessionID
|
||||||
|
);
|
||||||
|
|
||||||
export function expectList(
|
return newNode.expectCoValueLoaded(this.id);
|
||||||
content: CoValue
|
|
||||||
): AnyCoList {
|
|
||||||
if (content.type !== "colist") {
|
|
||||||
throw new Error("Expected list");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return content as AnyCoList;
|
knownState(): CoValueKnownState {
|
||||||
}
|
return {
|
||||||
|
id: this.id,
|
||||||
export function expectStream(
|
header: true,
|
||||||
content: CoValue
|
sessions: Object.fromEntries(
|
||||||
): AnyCoStream {
|
Object.entries(this.sessions).map(([k, v]) => [
|
||||||
if (content.type !== "costream") {
|
k,
|
||||||
throw new Error("Expected stream");
|
v.transactions.length,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return content as AnyCoStream;
|
get meta(): JsonValue {
|
||||||
}
|
return this.header?.meta ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isCoValue(value: JsonValue | CoValue | undefined) : value is CoValue {
|
nextTransactionID(): TransactionID {
|
||||||
return (
|
const sessionID = this.node.ownSessionID;
|
||||||
value instanceof CoMap ||
|
return {
|
||||||
value instanceof CoList ||
|
sessionID,
|
||||||
value instanceof CoStream ||
|
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||||
value instanceof BinaryCoStream
|
};
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
tryAddTransactions(
|
||||||
|
sessionID: SessionID,
|
||||||
|
newTransactions: Transaction[],
|
||||||
|
givenExpectedNewHash: Hash | undefined,
|
||||||
|
newSignature: Signature
|
||||||
|
): boolean {
|
||||||
|
const signerID = getAgentSignerID(
|
||||||
|
this.node.resolveAccountAgent(
|
||||||
|
accountOrAgentIDfromSessionID(sessionID),
|
||||||
|
"Expected to know signer of transaction"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!signerID) {
|
||||||
|
console.warn(
|
||||||
|
"Unknown agent",
|
||||||
|
accountOrAgentIDfromSessionID(sessionID)
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||||
|
sessionID,
|
||||||
|
newTransactions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||||
|
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||||
|
console.warn(
|
||||||
|
"Invalid signature",
|
||||||
|
newSignature,
|
||||||
|
expectedNewHash,
|
||||||
|
signerID
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||||
|
|
||||||
|
transactions.push(...newTransactions);
|
||||||
|
|
||||||
|
this.sessions[sessionID] = {
|
||||||
|
transactions,
|
||||||
|
lastHash: expectedNewHash,
|
||||||
|
streamingHash: newStreamingHash,
|
||||||
|
lastSignature: newSignature,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.content = undefined;
|
||||||
|
|
||||||
|
const content = this.getCurrentContent();
|
||||||
|
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (content?: ContentType) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
listener(this.getCurrentContent());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedNewHashAfter(
|
||||||
|
sessionID: SessionID,
|
||||||
|
newTransactions: Transaction[]
|
||||||
|
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||||
|
const streamingHash =
|
||||||
|
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||||
|
new StreamingHash();
|
||||||
|
for (const transaction of newTransactions) {
|
||||||
|
streamingHash.update(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStreamingHash = streamingHash.clone();
|
||||||
|
|
||||||
|
return {
|
||||||
|
expectedNewHash: streamingHash.digest(),
|
||||||
|
newStreamingHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
makeTransaction(
|
||||||
|
changes: JsonValue[],
|
||||||
|
privacy: "private" | "trusting"
|
||||||
|
): boolean {
|
||||||
|
const madeAt = Date.now();
|
||||||
|
|
||||||
|
let transaction: Transaction;
|
||||||
|
|
||||||
|
if (privacy === "private") {
|
||||||
|
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
||||||
|
|
||||||
|
if (!keySecret) {
|
||||||
|
throw new Error(
|
||||||
|
"Can't make transaction without read key secret"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction = {
|
||||||
|
privacy: "private",
|
||||||
|
madeAt,
|
||||||
|
keyUsed: keyID,
|
||||||
|
encryptedChanges: encryptForTransaction(changes, keySecret, {
|
||||||
|
in: this.id,
|
||||||
|
tx: this.nextTransactionID(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
transaction = {
|
||||||
|
privacy: "trusting",
|
||||||
|
madeAt,
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionID = this.node.ownSessionID;
|
||||||
|
|
||||||
|
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||||
|
transaction,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const signature = sign(
|
||||||
|
this.node.account.currentSignerSecret(),
|
||||||
|
expectedNewHash
|
||||||
|
);
|
||||||
|
|
||||||
|
const success = this.tryAddTransactions(
|
||||||
|
sessionID,
|
||||||
|
[transaction],
|
||||||
|
expectedNewHash,
|
||||||
|
signature
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
void this.node.sync.syncCoValue(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentContent(): ContentType {
|
||||||
|
if (this.content) {
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.header.type === "comap") {
|
||||||
|
this.content = new CoMap(this);
|
||||||
|
} else if (this.header.type === "colist") {
|
||||||
|
this.content = new CoList(this);
|
||||||
|
} else if (this.header.type === "costream") {
|
||||||
|
this.content = new CoStream(this);
|
||||||
|
} else if (this.header.type === "static") {
|
||||||
|
this.content = new Static(this);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||||
|
const validTransactions = determineValidTransactions(this);
|
||||||
|
|
||||||
|
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||||
|
.map(({ txID, tx }) => {
|
||||||
|
if (tx.privacy === "trusting") {
|
||||||
|
return {
|
||||||
|
txID,
|
||||||
|
madeAt: tx.madeAt,
|
||||||
|
changes: tx.changes,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const readKey = this.getReadKey(tx.keyUsed);
|
||||||
|
|
||||||
|
if (!readKey) {
|
||||||
|
return undefined;
|
||||||
|
} else {
|
||||||
|
const decrytedChanges = decryptForTransaction(
|
||||||
|
tx.encryptedChanges,
|
||||||
|
readKey,
|
||||||
|
{
|
||||||
|
in: this.id,
|
||||||
|
tx: txID,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!decrytedChanges) {
|
||||||
|
console.error(
|
||||||
|
"Failed to decrypt transaction despite having key"
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
txID,
|
||||||
|
madeAt: tx.madeAt,
|
||||||
|
changes: decrytedChanges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((x): x is Exclude<typeof x, undefined> => !!x);
|
||||||
|
allTransactions.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.madeAt - b.madeAt ||
|
||||||
|
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
||||||
|
a.txID.txIndex - b.txID.txIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
return allTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||||
|
if (this.header.ruleset.type === "team") {
|
||||||
|
const content = expectTeamContent(this.getCurrentContent());
|
||||||
|
|
||||||
|
const currentKeyId = content.get("readKey");
|
||||||
|
|
||||||
|
if (!currentKeyId) {
|
||||||
|
throw new Error("No readKey set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = this.getReadKey(currentKeyId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
secret: secret,
|
||||||
|
id: currentKeyId,
|
||||||
|
};
|
||||||
|
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||||
|
return this.node
|
||||||
|
.expectCoValueLoaded(this.header.ruleset.team)
|
||||||
|
.getCurrentReadKey();
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Only teams or values owned by teams have read secrets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||||
|
if (this.header.ruleset.type === "team") {
|
||||||
|
const content = expectTeamContent(this.getCurrentContent());
|
||||||
|
|
||||||
|
// Try to find key revelation for us
|
||||||
|
|
||||||
|
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
|
||||||
|
|
||||||
|
if (readKeyEntry) {
|
||||||
|
const revealer = accountOrAgentIDfromSessionID(
|
||||||
|
readKeyEntry.txID.sessionID
|
||||||
|
);
|
||||||
|
const revealerAgent = this.node.resolveAccountAgent(
|
||||||
|
revealer,
|
||||||
|
"Expected to know revealer"
|
||||||
|
);
|
||||||
|
|
||||||
|
const secret = unseal(
|
||||||
|
readKeyEntry.value,
|
||||||
|
this.node.account.currentSealerSecret(),
|
||||||
|
getAgentSealerID(revealerAgent),
|
||||||
|
{
|
||||||
|
in: this.id,
|
||||||
|
tx: readKeyEntry.txID,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret) return secret as KeySecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find indirect revelation through previousKeys
|
||||||
|
|
||||||
|
for (const field of content.keys()) {
|
||||||
|
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
||||||
|
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
||||||
|
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||||
|
|
||||||
|
if (!encryptingKeySecret) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedPreviousKey = content.get(field)!;
|
||||||
|
|
||||||
|
const secret = decryptKeySecret(
|
||||||
|
{
|
||||||
|
encryptedID: keyID,
|
||||||
|
encryptingID: encryptingKeyID,
|
||||||
|
encrypted: encryptedPreviousKey,
|
||||||
|
},
|
||||||
|
encryptingKeySecret
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret) {
|
||||||
|
return secret;
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||||
|
return this.node
|
||||||
|
.expectCoValueLoaded(this.header.ruleset.team)
|
||||||
|
.getReadKey(keyID);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Only teams or values owned by teams have read secrets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTeam(): Team {
|
||||||
|
if (this.header.ruleset.type !== "ownedByTeam") {
|
||||||
|
throw new Error("Only values owned by teams have teams");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Team(
|
||||||
|
expectTeamContent(
|
||||||
|
this.node
|
||||||
|
.expectCoValueLoaded(this.header.ruleset.team)
|
||||||
|
.getCurrentContent()
|
||||||
|
),
|
||||||
|
this.node
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTx(txID: TransactionID): Transaction | undefined {
|
||||||
|
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
newContentSince(
|
||||||
|
knownState: CoValueKnownState | undefined
|
||||||
|
): NewContentMessage | undefined {
|
||||||
|
const newContent: NewContentMessage = {
|
||||||
|
action: "content",
|
||||||
|
id: this.id,
|
||||||
|
header: knownState?.header ? undefined : this.header,
|
||||||
|
new: Object.fromEntries(
|
||||||
|
Object.entries(this.sessions)
|
||||||
|
.map(([sessionID, log]) => {
|
||||||
|
const newTransactions = log.transactions.slice(
|
||||||
|
knownState?.sessions[sessionID as SessionID] || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
newTransactions.length === 0 ||
|
||||||
|
!log.lastHash ||
|
||||||
|
!log.lastSignature
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
sessionID,
|
||||||
|
{
|
||||||
|
after:
|
||||||
|
knownState?.sessions[
|
||||||
|
sessionID as SessionID
|
||||||
|
] || 0,
|
||||||
|
newTransactions,
|
||||||
|
lastSignature: log.lastSignature,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!newContent.header &&
|
||||||
|
Object.keys(newContent.new).length === 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependedOnCoValues(): RawCoID[] {
|
||||||
|
return this.header.ruleset.type === "team"
|
||||||
|
? expectTeamContent(this.getCurrentContent())
|
||||||
|
.keys()
|
||||||
|
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||||
|
: this.header.ruleset.type === "ownedByTeam"
|
||||||
|
? [this.header.ruleset.team]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,818 +0,0 @@
|
|||||||
import { randomBytes } from "@noble/hashes/utils";
|
|
||||||
import { AnyCoValue, CoValue } from "./coValue.js";
|
|
||||||
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
|
|
||||||
import { CoMap } from "./coValues/coMap.js";
|
|
||||||
import {
|
|
||||||
Encrypted,
|
|
||||||
Hash,
|
|
||||||
KeySecret,
|
|
||||||
Signature,
|
|
||||||
StreamingHash,
|
|
||||||
unseal,
|
|
||||||
shortHash,
|
|
||||||
sign,
|
|
||||||
verify,
|
|
||||||
encryptForTransaction,
|
|
||||||
KeyID,
|
|
||||||
decryptKeySecret,
|
|
||||||
getAgentSignerID,
|
|
||||||
getAgentSealerID,
|
|
||||||
decryptRawForTransaction,
|
|
||||||
} from "./crypto.js";
|
|
||||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
|
||||||
import { base58 } from "@scure/base";
|
|
||||||
import {
|
|
||||||
PermissionsDef as RulesetDef,
|
|
||||||
determineValidTransactions,
|
|
||||||
isKeyForKeyField,
|
|
||||||
} from "./permissions.js";
|
|
||||||
import { Group, expectGroupContent } from "./group.js";
|
|
||||||
import { LocalNode } from "./localNode.js";
|
|
||||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
|
||||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
|
||||||
import { CoList } from "./coValues/coList.js";
|
|
||||||
import { AccountID, GeneralizedControlledAccount } from "./account.js";
|
|
||||||
import { Stringified, stableStringify } from "./jsonStringify.js";
|
|
||||||
|
|
||||||
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
|
|
||||||
|
|
||||||
export type CoValueHeader = {
|
|
||||||
type: AnyCoValue["type"];
|
|
||||||
ruleset: RulesetDef;
|
|
||||||
meta: JsonObject | null;
|
|
||||||
createdAt: `2${string}` | null;
|
|
||||||
uniqueness: `z${string}` | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function idforHeader(header: CoValueHeader): RawCoID {
|
|
||||||
const hash = shortHash(header);
|
|
||||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function accountOrAgentIDfromSessionID(
|
|
||||||
sessionID: SessionID
|
|
||||||
): AccountID | AgentID {
|
|
||||||
return sessionID.split("_session")[0] as AccountID | AgentID;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
|
|
||||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionLog = {
|
|
||||||
transactions: Transaction[];
|
|
||||||
lastHash?: Hash;
|
|
||||||
streamingHash: StreamingHash;
|
|
||||||
signatureAfter: { [txIdx: number]: Signature | undefined };
|
|
||||||
lastSignature: Signature;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PrivateTransaction = {
|
|
||||||
privacy: "private";
|
|
||||||
madeAt: number;
|
|
||||||
keyUsed: KeyID;
|
|
||||||
encryptedChanges: Encrypted<
|
|
||||||
JsonValue[],
|
|
||||||
{ in: RawCoID; tx: TransactionID }
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TrustingTransaction = {
|
|
||||||
privacy: "trusting";
|
|
||||||
madeAt: number;
|
|
||||||
changes: Stringified<JsonValue[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
|
||||||
|
|
||||||
export type DecryptedTransaction = {
|
|
||||||
txID: TransactionID;
|
|
||||||
changes: Stringified<JsonValue[]>;
|
|
||||||
madeAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
|
||||||
|
|
||||||
export class CoValueCore {
|
|
||||||
id: RawCoID;
|
|
||||||
node: LocalNode;
|
|
||||||
header: CoValueHeader;
|
|
||||||
_sessions: { [key: SessionID]: SessionLog };
|
|
||||||
_cachedContent?: CoValue;
|
|
||||||
listeners: Set<(content?: CoValue) => void> = new Set();
|
|
||||||
_decryptionCache: {
|
|
||||||
[key: Encrypted<JsonValue[], JsonValue>]:
|
|
||||||
| Stringified<JsonValue[]>
|
|
||||||
| undefined;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
header: CoValueHeader,
|
|
||||||
node: LocalNode,
|
|
||||||
internalInitSessions: { [key: SessionID]: SessionLog } = {}
|
|
||||||
) {
|
|
||||||
this.id = idforHeader(header);
|
|
||||||
this.header = header;
|
|
||||||
this._sessions = internalInitSessions;
|
|
||||||
this.node = node;
|
|
||||||
|
|
||||||
if (header.ruleset.type == "ownedByGroup") {
|
|
||||||
this.node
|
|
||||||
.expectCoValueLoaded(header.ruleset.group)
|
|
||||||
.subscribe((_groupUpdate) => {
|
|
||||||
this._cachedContent = undefined;
|
|
||||||
const newContent = this.getCurrentContent();
|
|
||||||
for (const listener of this.listeners) {
|
|
||||||
listener(newContent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
|
|
||||||
return this._sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
testWithDifferentAccount(
|
|
||||||
account: GeneralizedControlledAccount,
|
|
||||||
currentSessionID: SessionID
|
|
||||||
): CoValueCore {
|
|
||||||
const newNode = this.node.testWithDifferentAccount(
|
|
||||||
account,
|
|
||||||
currentSessionID
|
|
||||||
);
|
|
||||||
|
|
||||||
return newNode.expectCoValueLoaded(this.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
knownState(): CoValueKnownState {
|
|
||||||
return {
|
|
||||||
id: this.id,
|
|
||||||
header: true,
|
|
||||||
sessions: Object.fromEntries(
|
|
||||||
Object.entries(this.sessions).map(([k, v]) => [
|
|
||||||
k,
|
|
||||||
v.transactions.length,
|
|
||||||
])
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
get meta(): JsonValue {
|
|
||||||
return this.header?.meta ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTransactionID(): TransactionID {
|
|
||||||
const sessionID = this.node.currentSessionID;
|
|
||||||
return {
|
|
||||||
sessionID,
|
|
||||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
tryAddTransactions(
|
|
||||||
sessionID: SessionID,
|
|
||||||
newTransactions: Transaction[],
|
|
||||||
givenExpectedNewHash: Hash | undefined,
|
|
||||||
newSignature: Signature
|
|
||||||
): boolean {
|
|
||||||
const signerID = getAgentSignerID(
|
|
||||||
this.node.resolveAccountAgent(
|
|
||||||
accountOrAgentIDfromSessionID(sessionID),
|
|
||||||
"Expected to know signer of transaction"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!signerID) {
|
|
||||||
console.warn(
|
|
||||||
"Unknown agent",
|
|
||||||
accountOrAgentIDfromSessionID(sessionID)
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// const beforeHash = performance.now();
|
|
||||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
|
||||||
sessionID,
|
|
||||||
newTransactions
|
|
||||||
);
|
|
||||||
// const afterHash = performance.now();
|
|
||||||
// console.log(
|
|
||||||
// "Hashing took",
|
|
||||||
// afterHash - beforeHash
|
|
||||||
// );
|
|
||||||
|
|
||||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
|
||||||
console.warn("Invalid hash", {
|
|
||||||
expectedNewHash,
|
|
||||||
givenExpectedNewHash,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// const beforeVerify = performance.now();
|
|
||||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
|
||||||
console.warn(
|
|
||||||
"Invalid signature in",
|
|
||||||
this.id,
|
|
||||||
newSignature,
|
|
||||||
expectedNewHash,
|
|
||||||
signerID
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// const afterVerify = performance.now();
|
|
||||||
// console.log(
|
|
||||||
// "Verify took",
|
|
||||||
// afterVerify - beforeVerify
|
|
||||||
// );
|
|
||||||
|
|
||||||
this.doAddTransactions(
|
|
||||||
sessionID,
|
|
||||||
newTransactions,
|
|
||||||
newSignature,
|
|
||||||
expectedNewHash,
|
|
||||||
newStreamingHash
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryAddTransactionsAsync(
|
|
||||||
sessionID: SessionID,
|
|
||||||
newTransactions: Transaction[],
|
|
||||||
givenExpectedNewHash: Hash | undefined,
|
|
||||||
newSignature: Signature
|
|
||||||
): Promise<boolean> {
|
|
||||||
const signerID = getAgentSignerID(
|
|
||||||
this.node.resolveAccountAgent(
|
|
||||||
accountOrAgentIDfromSessionID(sessionID),
|
|
||||||
"Expected to know signer of transaction"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!signerID) {
|
|
||||||
console.warn(
|
|
||||||
"Unknown agent",
|
|
||||||
accountOrAgentIDfromSessionID(sessionID)
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nTxBefore = this.sessions[sessionID]?.transactions.length ?? 0;
|
|
||||||
|
|
||||||
// const beforeHash = performance.now();
|
|
||||||
const { expectedNewHash, newStreamingHash } =
|
|
||||||
await this.expectedNewHashAfterAsync(sessionID, newTransactions);
|
|
||||||
// const afterHash = performance.now();
|
|
||||||
// console.log(
|
|
||||||
// "Hashing took",
|
|
||||||
// afterHash - beforeHash
|
|
||||||
// );
|
|
||||||
|
|
||||||
const nTxAfter = this.sessions[sessionID]?.transactions.length ?? 0;
|
|
||||||
|
|
||||||
if (nTxAfter !== nTxBefore) {
|
|
||||||
const newTransactionLengthBefore = newTransactions.length;
|
|
||||||
newTransactions = newTransactions.slice(nTxAfter - nTxBefore);
|
|
||||||
console.warn("Transactions changed while async hashing", {
|
|
||||||
nTxBefore,
|
|
||||||
nTxAfter,
|
|
||||||
newTransactionLengthBefore,
|
|
||||||
remainingNewTransactions: newTransactions.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
|
||||||
console.warn("Invalid hash", {
|
|
||||||
expectedNewHash,
|
|
||||||
givenExpectedNewHash,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// const beforeVerify = performance.now();
|
|
||||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
|
||||||
console.warn(
|
|
||||||
"Invalid signature in",
|
|
||||||
this.id,
|
|
||||||
newSignature,
|
|
||||||
expectedNewHash,
|
|
||||||
signerID
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// const afterVerify = performance.now();
|
|
||||||
// console.log(
|
|
||||||
// "Verify took",
|
|
||||||
// afterVerify - beforeVerify
|
|
||||||
// );
|
|
||||||
|
|
||||||
this.doAddTransactions(
|
|
||||||
sessionID,
|
|
||||||
newTransactions,
|
|
||||||
newSignature,
|
|
||||||
expectedNewHash,
|
|
||||||
newStreamingHash
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private doAddTransactions(
|
|
||||||
sessionID: SessionID,
|
|
||||||
newTransactions: Transaction[],
|
|
||||||
newSignature: Signature,
|
|
||||||
expectedNewHash: Hash,
|
|
||||||
newStreamingHash: StreamingHash
|
|
||||||
) {
|
|
||||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
|
||||||
transactions.push(...newTransactions);
|
|
||||||
|
|
||||||
const signatureAfter = this.sessions[sessionID]?.signatureAfter ?? {};
|
|
||||||
|
|
||||||
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
|
|
||||||
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
|
|
||||||
-1
|
|
||||||
);
|
|
||||||
|
|
||||||
const sizeOfTxsSinceLastInbetweenSignature = transactions
|
|
||||||
.slice(lastInbetweenSignatureIdx + 1)
|
|
||||||
.reduce(
|
|
||||||
(sum, tx) =>
|
|
||||||
sum +
|
|
||||||
(tx.privacy === "private"
|
|
||||||
? tx.encryptedChanges.length
|
|
||||||
: tx.changes.length),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sizeOfTxsSinceLastInbetweenSignature > 100 * 1024) {
|
|
||||||
// console.log(
|
|
||||||
// "Saving inbetween signature for tx ",
|
|
||||||
// sessionID,
|
|
||||||
// transactions.length - 1,
|
|
||||||
// sizeOfTxsSinceLastInbetweenSignature
|
|
||||||
// );
|
|
||||||
signatureAfter[transactions.length - 1] = newSignature;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._sessions[sessionID] = {
|
|
||||||
transactions,
|
|
||||||
lastHash: expectedNewHash,
|
|
||||||
streamingHash: newStreamingHash,
|
|
||||||
lastSignature: newSignature,
|
|
||||||
signatureAfter: signatureAfter,
|
|
||||||
};
|
|
||||||
|
|
||||||
this._cachedContent = undefined;
|
|
||||||
|
|
||||||
if (this.listeners.size > 0) {
|
|
||||||
const content = this.getCurrentContent();
|
|
||||||
for (const listener of this.listeners) {
|
|
||||||
listener(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(listener: (content?: CoValue) => void): () => void {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
listener(this.getCurrentContent());
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedNewHashAfter(
|
|
||||||
sessionID: SessionID,
|
|
||||||
newTransactions: Transaction[]
|
|
||||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
|
||||||
const streamingHash =
|
|
||||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
|
||||||
new StreamingHash();
|
|
||||||
for (const transaction of newTransactions) {
|
|
||||||
streamingHash.update(transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStreamingHash = streamingHash.clone();
|
|
||||||
|
|
||||||
return {
|
|
||||||
expectedNewHash: streamingHash.digest(),
|
|
||||||
newStreamingHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async expectedNewHashAfterAsync(
|
|
||||||
sessionID: SessionID,
|
|
||||||
newTransactions: Transaction[]
|
|
||||||
): Promise<{ expectedNewHash: Hash; newStreamingHash: StreamingHash }> {
|
|
||||||
const streamingHash =
|
|
||||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
|
||||||
new StreamingHash();
|
|
||||||
let before = performance.now();
|
|
||||||
for (const transaction of newTransactions) {
|
|
||||||
streamingHash.update(transaction);
|
|
||||||
const after = performance.now();
|
|
||||||
if (after - before > 1) {
|
|
||||||
// console.log("Hashing blocked for", after - before);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
before = performance.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStreamingHash = streamingHash.clone();
|
|
||||||
|
|
||||||
return {
|
|
||||||
expectedNewHash: streamingHash.digest(),
|
|
||||||
newStreamingHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
makeTransaction(
|
|
||||||
changes: JsonValue[],
|
|
||||||
privacy: "private" | "trusting"
|
|
||||||
): boolean {
|
|
||||||
const madeAt = Date.now();
|
|
||||||
|
|
||||||
let transaction: Transaction;
|
|
||||||
|
|
||||||
if (privacy === "private") {
|
|
||||||
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
|
||||||
|
|
||||||
if (!keySecret) {
|
|
||||||
throw new Error(
|
|
||||||
"Can't make transaction without read key secret"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const encrypted = encryptForTransaction(changes, keySecret, {
|
|
||||||
in: this.id,
|
|
||||||
tx: this.nextTransactionID(),
|
|
||||||
});
|
|
||||||
|
|
||||||
this._decryptionCache[encrypted] = stableStringify(changes);
|
|
||||||
|
|
||||||
transaction = {
|
|
||||||
privacy: "private",
|
|
||||||
madeAt,
|
|
||||||
keyUsed: keyID,
|
|
||||||
encryptedChanges: encrypted,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
transaction = {
|
|
||||||
privacy: "trusting",
|
|
||||||
madeAt,
|
|
||||||
changes: stableStringify(changes),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionID = this.node.currentSessionID;
|
|
||||||
|
|
||||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
|
||||||
transaction,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const signature = sign(
|
|
||||||
this.node.account.currentSignerSecret(),
|
|
||||||
expectedNewHash
|
|
||||||
);
|
|
||||||
|
|
||||||
const success = this.tryAddTransactions(
|
|
||||||
sessionID,
|
|
||||||
[transaction],
|
|
||||||
expectedNewHash,
|
|
||||||
signature
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
void this.node.syncManager.syncCoValue(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentContent(): CoValue {
|
|
||||||
if (this._cachedContent) {
|
|
||||||
return this._cachedContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.header.type === "comap") {
|
|
||||||
this._cachedContent = new CoMap(this);
|
|
||||||
} else if (this.header.type === "colist") {
|
|
||||||
this._cachedContent = new CoList(this);
|
|
||||||
} else if (this.header.type === "costream") {
|
|
||||||
if (this.header.meta && this.header.meta.type === "binary") {
|
|
||||||
this._cachedContent = new BinaryCoStream(this);
|
|
||||||
} else {
|
|
||||||
this._cachedContent = new CoStream(this);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._cachedContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
|
||||||
const validTransactions = determineValidTransactions(this);
|
|
||||||
|
|
||||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
|
||||||
.map(({ txID, tx }) => {
|
|
||||||
if (tx.privacy === "trusting") {
|
|
||||||
return {
|
|
||||||
txID,
|
|
||||||
madeAt: tx.madeAt,
|
|
||||||
changes: tx.changes,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const readKey = this.getReadKey(tx.keyUsed);
|
|
||||||
|
|
||||||
if (!readKey) {
|
|
||||||
return undefined;
|
|
||||||
} else {
|
|
||||||
let decrytedChanges =
|
|
||||||
this._decryptionCache[tx.encryptedChanges];
|
|
||||||
|
|
||||||
if (!decrytedChanges) {
|
|
||||||
decrytedChanges = decryptRawForTransaction(
|
|
||||||
tx.encryptedChanges,
|
|
||||||
readKey,
|
|
||||||
{
|
|
||||||
in: this.id,
|
|
||||||
tx: txID,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this._decryptionCache[tx.encryptedChanges] =
|
|
||||||
decrytedChanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!decrytedChanges) {
|
|
||||||
console.error(
|
|
||||||
"Failed to decrypt transaction despite having key"
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
txID,
|
|
||||||
madeAt: tx.madeAt,
|
|
||||||
changes: decrytedChanges,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((x): x is Exclude<typeof x, undefined> => !!x);
|
|
||||||
allTransactions.sort(
|
|
||||||
(a, b) =>
|
|
||||||
a.madeAt - b.madeAt ||
|
|
||||||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
|
||||||
a.txID.txIndex - b.txID.txIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
return allTransactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
|
||||||
if (this.header.ruleset.type === "group") {
|
|
||||||
const content = expectGroupContent(this.getCurrentContent());
|
|
||||||
|
|
||||||
const currentKeyId = content.get("readKey");
|
|
||||||
|
|
||||||
if (!currentKeyId) {
|
|
||||||
throw new Error("No readKey set");
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = this.getReadKey(currentKeyId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secret: secret,
|
|
||||||
id: currentKeyId,
|
|
||||||
};
|
|
||||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
|
||||||
return this.node
|
|
||||||
.expectCoValueLoaded(this.header.ruleset.group)
|
|
||||||
.getCurrentReadKey();
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Only groups or values owned by groups have read secrets"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
|
||||||
if (readKeyCache.get(this)?.[keyID]) {
|
|
||||||
return readKeyCache.get(this)?.[keyID];
|
|
||||||
}
|
|
||||||
if (this.header.ruleset.type === "group") {
|
|
||||||
const content = expectGroupContent(this.getCurrentContent());
|
|
||||||
|
|
||||||
// Try to find key revelation for us
|
|
||||||
|
|
||||||
const lastReadyKeyEdit = content.lastEditAt(
|
|
||||||
`${keyID}_for_${this.node.account.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastReadyKeyEdit?.value) {
|
|
||||||
const revealer = lastReadyKeyEdit.by;
|
|
||||||
const revealerAgent = this.node.resolveAccountAgent(
|
|
||||||
revealer,
|
|
||||||
"Expected to know revealer"
|
|
||||||
);
|
|
||||||
|
|
||||||
const secret = unseal(
|
|
||||||
lastReadyKeyEdit.value,
|
|
||||||
this.node.account.currentSealerSecret(),
|
|
||||||
getAgentSealerID(revealerAgent),
|
|
||||||
{
|
|
||||||
in: this.id,
|
|
||||||
tx: lastReadyKeyEdit.tx,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (secret) {
|
|
||||||
let cache = readKeyCache.get(this);
|
|
||||||
if (!cache) {
|
|
||||||
cache = {};
|
|
||||||
readKeyCache.set(this, cache);
|
|
||||||
}
|
|
||||||
cache[keyID] = secret;
|
|
||||||
|
|
||||||
return secret as KeySecret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find indirect revelation through previousKeys
|
|
||||||
|
|
||||||
for (const field of content.keys()) {
|
|
||||||
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
|
||||||
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
|
||||||
const encryptingKeySecret =
|
|
||||||
this.getReadKey(encryptingKeyID);
|
|
||||||
|
|
||||||
if (!encryptingKeySecret) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedPreviousKey = content.get(field)!;
|
|
||||||
|
|
||||||
const secret = decryptKeySecret(
|
|
||||||
{
|
|
||||||
encryptedID: keyID,
|
|
||||||
encryptingID: encryptingKeyID,
|
|
||||||
encrypted: encryptedPreviousKey,
|
|
||||||
},
|
|
||||||
encryptingKeySecret
|
|
||||||
);
|
|
||||||
|
|
||||||
if (secret) {
|
|
||||||
let cache = readKeyCache.get(this);
|
|
||||||
if (!cache) {
|
|
||||||
cache = {};
|
|
||||||
readKeyCache.set(this, cache);
|
|
||||||
}
|
|
||||||
cache[keyID] = secret;
|
|
||||||
|
|
||||||
return secret as KeySecret;
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
|
||||||
return this.node
|
|
||||||
.expectCoValueLoaded(this.header.ruleset.group)
|
|
||||||
.getReadKey(keyID);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Only groups or values owned by groups have read secrets"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getGroup(): Group {
|
|
||||||
if (this.header.ruleset.type !== "ownedByGroup") {
|
|
||||||
throw new Error("Only values owned by groups have groups");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Group(
|
|
||||||
expectGroupContent(
|
|
||||||
this.node
|
|
||||||
.expectCoValueLoaded(this.header.ruleset.group)
|
|
||||||
.getCurrentContent()
|
|
||||||
),
|
|
||||||
this.node
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTx(txID: TransactionID): Transaction | undefined {
|
|
||||||
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
newContentSince(
|
|
||||||
knownState: CoValueKnownState | undefined
|
|
||||||
): NewContentMessage[] | undefined {
|
|
||||||
let currentPiece: NewContentMessage = {
|
|
||||||
action: "content",
|
|
||||||
id: this.id,
|
|
||||||
header: knownState?.header ? undefined : this.header,
|
|
||||||
new: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const pieces = [currentPiece];
|
|
||||||
|
|
||||||
const sentState: CoValueKnownState["sessions"] = {
|
|
||||||
...knownState?.sessions,
|
|
||||||
};
|
|
||||||
|
|
||||||
let newTxsWereAdded = true;
|
|
||||||
let pieceSize = 0;
|
|
||||||
while (newTxsWereAdded) {
|
|
||||||
newTxsWereAdded = false;
|
|
||||||
|
|
||||||
for (const [sessionID, log] of Object.entries(this.sessions) as [
|
|
||||||
SessionID,
|
|
||||||
SessionLog
|
|
||||||
][]) {
|
|
||||||
const nextKnownSignatureIdx = Object.keys(log.signatureAfter)
|
|
||||||
.map(Number)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.find((idx) => idx >= (sentState[sessionID] ?? -1));
|
|
||||||
|
|
||||||
const txsToAdd = log.transactions.slice(
|
|
||||||
sentState[sessionID] ?? 0,
|
|
||||||
nextKnownSignatureIdx === undefined
|
|
||||||
? undefined
|
|
||||||
: nextKnownSignatureIdx + 1
|
|
||||||
);
|
|
||||||
|
|
||||||
if (txsToAdd.length === 0) continue;
|
|
||||||
|
|
||||||
newTxsWereAdded = true;
|
|
||||||
|
|
||||||
const oldPieceSize = pieceSize;
|
|
||||||
pieceSize += txsToAdd.reduce(
|
|
||||||
(sum, tx) =>
|
|
||||||
sum +
|
|
||||||
(tx.privacy === "private"
|
|
||||||
? tx.encryptedChanges.length
|
|
||||||
: tx.changes.length),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
|
|
||||||
currentPiece = {
|
|
||||||
action: "content",
|
|
||||||
id: this.id,
|
|
||||||
header: undefined,
|
|
||||||
new: {},
|
|
||||||
};
|
|
||||||
pieces.push(currentPiece);
|
|
||||||
pieceSize = pieceSize - oldPieceSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessionEntry = currentPiece.new[sessionID];
|
|
||||||
if (!sessionEntry) {
|
|
||||||
sessionEntry = {
|
|
||||||
after: sentState[sessionID] ?? 0,
|
|
||||||
newTransactions: [],
|
|
||||||
lastSignature: "WILL_BE_REPLACED" as Signature,
|
|
||||||
};
|
|
||||||
currentPiece.new[sessionID] = sessionEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionEntry.newTransactions.push(...txsToAdd);
|
|
||||||
sessionEntry.lastSignature =
|
|
||||||
nextKnownSignatureIdx === undefined
|
|
||||||
? log.lastSignature!
|
|
||||||
: log.signatureAfter[nextKnownSignatureIdx]!;
|
|
||||||
|
|
||||||
sentState[sessionID] =
|
|
||||||
(sentState[sessionID] || 0) + txsToAdd.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const piecesWithContent = pieces.filter(
|
|
||||||
(piece) => Object.keys(piece.new).length > 0 || piece.header
|
|
||||||
);
|
|
||||||
|
|
||||||
if (piecesWithContent.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return piecesWithContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDependedOnCoValues(): RawCoID[] {
|
|
||||||
return this.header.ruleset.type === "group"
|
|
||||||
? expectGroupContent(this.getCurrentContent())
|
|
||||||
.keys()
|
|
||||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
|
||||||
: this.header.ruleset.type === "ownedByGroup"
|
|
||||||
? [this.header.ruleset.group]
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,596 +0,0 @@
|
|||||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
|
||||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
|
||||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
|
||||||
import { AgentID, SessionID, TransactionID } from "../ids.js";
|
|
||||||
import { Group } from "../group.js";
|
|
||||||
import { AccountID } from "../account.js";
|
|
||||||
import { parseJSON } from "../jsonStringify.js";
|
|
||||||
|
|
||||||
type OpID = TransactionID & { changeIdx: number };
|
|
||||||
|
|
||||||
type InsertionOpPayload<T extends JsonValue | CoValue> =
|
|
||||||
| {
|
|
||||||
op: "pre";
|
|
||||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
|
||||||
before: OpID | "end";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
op: "app";
|
|
||||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
|
||||||
after: OpID | "start";
|
|
||||||
};
|
|
||||||
|
|
||||||
type DeletionOpPayload = {
|
|
||||||
op: "del";
|
|
||||||
insertion: OpID;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListOpPayload<T extends JsonValue | CoValue> =
|
|
||||||
| InsertionOpPayload<T>
|
|
||||||
| DeletionOpPayload;
|
|
||||||
|
|
||||||
type InsertionEntry<T extends JsonValue | CoValue> = {
|
|
||||||
madeAt: number;
|
|
||||||
predecessors: OpID[];
|
|
||||||
successors: OpID[];
|
|
||||||
} & InsertionOpPayload<T>;
|
|
||||||
|
|
||||||
type DeletionEntry = {
|
|
||||||
madeAt: number;
|
|
||||||
deletionID: OpID;
|
|
||||||
} & DeletionOpPayload;
|
|
||||||
|
|
||||||
export class CoListView<
|
|
||||||
Item extends JsonValue | CoValue,
|
|
||||||
Meta extends JsonObject | null = null
|
|
||||||
> implements CoValue
|
|
||||||
{
|
|
||||||
/** @category 6. Meta */
|
|
||||||
id: CoID<this>;
|
|
||||||
/** @category 6. Meta */
|
|
||||||
type = "colist" as const;
|
|
||||||
/** @category 6. Meta */
|
|
||||||
core: CoValueCore;
|
|
||||||
/** @internal */
|
|
||||||
afterStart: OpID[];
|
|
||||||
/** @internal */
|
|
||||||
beforeEnd: OpID[];
|
|
||||||
/** @internal */
|
|
||||||
insertions: {
|
|
||||||
[sessionID: SessionID]: {
|
|
||||||
[txIdx: number]: {
|
|
||||||
[changeIdx: number]: InsertionEntry<Item>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @internal */
|
|
||||||
deletionsByInsertion: {
|
|
||||||
[deletedSessionID: SessionID]: {
|
|
||||||
[deletedTxIdx: number]: {
|
|
||||||
[deletedChangeIdx: number]: DeletionEntry[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @category 6. Meta */
|
|
||||||
readonly _item!: Item;
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
constructor(core: CoValueCore) {
|
|
||||||
this.id = core.id as CoID<this>;
|
|
||||||
this.core = core;
|
|
||||||
this.afterStart = [];
|
|
||||||
this.beforeEnd = [];
|
|
||||||
this.insertions = {};
|
|
||||||
this.deletionsByInsertion = {};
|
|
||||||
|
|
||||||
this.insertions = {};
|
|
||||||
this.deletionsByInsertion = {};
|
|
||||||
this.afterStart = [];
|
|
||||||
this.beforeEnd = [];
|
|
||||||
|
|
||||||
for (const {
|
|
||||||
txID,
|
|
||||||
changes,
|
|
||||||
madeAt,
|
|
||||||
} of this.core.getValidSortedTransactions()) {
|
|
||||||
for (const [changeIdx, changeUntyped] of parseJSON(
|
|
||||||
changes
|
|
||||||
).entries()) {
|
|
||||||
const change = changeUntyped as ListOpPayload<Item>;
|
|
||||||
|
|
||||||
if (change.op === "pre" || change.op === "app") {
|
|
||||||
let sessionEntry = this.insertions[txID.sessionID];
|
|
||||||
if (!sessionEntry) {
|
|
||||||
sessionEntry = {};
|
|
||||||
this.insertions[txID.sessionID] = sessionEntry;
|
|
||||||
}
|
|
||||||
let txEntry = sessionEntry[txID.txIndex];
|
|
||||||
if (!txEntry) {
|
|
||||||
txEntry = {};
|
|
||||||
sessionEntry[txID.txIndex] = txEntry;
|
|
||||||
}
|
|
||||||
txEntry[changeIdx] = {
|
|
||||||
madeAt,
|
|
||||||
predecessors: [],
|
|
||||||
successors: [],
|
|
||||||
...change,
|
|
||||||
};
|
|
||||||
if (change.op === "pre") {
|
|
||||||
if (change.before === "end") {
|
|
||||||
this.beforeEnd.push({
|
|
||||||
...txID,
|
|
||||||
changeIdx,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const beforeEntry =
|
|
||||||
this.insertions[change.before.sessionID]?.[
|
|
||||||
change.before.txIndex
|
|
||||||
]?.[change.before.changeIdx];
|
|
||||||
if (!beforeEntry) {
|
|
||||||
throw new Error(
|
|
||||||
"Not yet implemented: insertion before missing op " +
|
|
||||||
change.before
|
|
||||||
);
|
|
||||||
}
|
|
||||||
beforeEntry.predecessors.splice(0, 0, {
|
|
||||||
...txID,
|
|
||||||
changeIdx,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (change.after === "start") {
|
|
||||||
this.afterStart.push({
|
|
||||||
...txID,
|
|
||||||
changeIdx,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const afterEntry =
|
|
||||||
this.insertions[change.after.sessionID]?.[
|
|
||||||
change.after.txIndex
|
|
||||||
]?.[change.after.changeIdx];
|
|
||||||
if (!afterEntry) {
|
|
||||||
throw new Error(
|
|
||||||
"Not yet implemented: insertion after missing op " +
|
|
||||||
change.after
|
|
||||||
);
|
|
||||||
}
|
|
||||||
afterEntry.successors.push({
|
|
||||||
...txID,
|
|
||||||
changeIdx,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (change.op === "del") {
|
|
||||||
let sessionEntry =
|
|
||||||
this.deletionsByInsertion[change.insertion.sessionID];
|
|
||||||
if (!sessionEntry) {
|
|
||||||
sessionEntry = {};
|
|
||||||
this.deletionsByInsertion[change.insertion.sessionID] =
|
|
||||||
sessionEntry;
|
|
||||||
}
|
|
||||||
let txEntry = sessionEntry[change.insertion.txIndex];
|
|
||||||
if (!txEntry) {
|
|
||||||
txEntry = {};
|
|
||||||
sessionEntry[change.insertion.txIndex] = txEntry;
|
|
||||||
}
|
|
||||||
let changeEntry = txEntry[change.insertion.changeIdx];
|
|
||||||
if (!changeEntry) {
|
|
||||||
changeEntry = [];
|
|
||||||
txEntry[change.insertion.changeIdx] = changeEntry;
|
|
||||||
}
|
|
||||||
changeEntry.push({
|
|
||||||
madeAt,
|
|
||||||
deletionID: {
|
|
||||||
...txID,
|
|
||||||
changeIdx,
|
|
||||||
},
|
|
||||||
...change,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Unknown list operation " +
|
|
||||||
(change as { op: unknown }).op
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 6. Meta */
|
|
||||||
get meta(): Meta {
|
|
||||||
return this.core.header.meta as Meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 6. Meta */
|
|
||||||
get group(): Group {
|
|
||||||
return this.core.getGroup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Not yet implemented
|
|
||||||
*
|
|
||||||
* @category 4. Time travel
|
|
||||||
*/
|
|
||||||
atTime(_time: number): this {
|
|
||||||
throw new Error("Not yet implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the item currently at `idx`.
|
|
||||||
*
|
|
||||||
* @category 1. Reading
|
|
||||||
*/
|
|
||||||
get(
|
|
||||||
idx: number
|
|
||||||
):
|
|
||||||
| (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)
|
|
||||||
| undefined {
|
|
||||||
const entry = this.entries()[idx];
|
|
||||||
if (!entry) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current items in the CoList as an array.
|
|
||||||
*
|
|
||||||
* @category 1. Reading
|
|
||||||
**/
|
|
||||||
asArray(): (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[] {
|
|
||||||
return this.entries().map((entry) => entry.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
entries(): {
|
|
||||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
|
||||||
madeAt: number;
|
|
||||||
opID: OpID;
|
|
||||||
}[] {
|
|
||||||
const arr: {
|
|
||||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
|
||||||
madeAt: number;
|
|
||||||
opID: OpID;
|
|
||||||
}[] = [];
|
|
||||||
for (const opID of this.afterStart) {
|
|
||||||
this.fillArrayFromOpID(opID, arr);
|
|
||||||
}
|
|
||||||
for (const opID of this.beforeEnd) {
|
|
||||||
this.fillArrayFromOpID(opID, arr);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
private fillArrayFromOpID(
|
|
||||||
opID: OpID,
|
|
||||||
arr: {
|
|
||||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
|
||||||
madeAt: number;
|
|
||||||
opID: OpID;
|
|
||||||
}[]
|
|
||||||
) {
|
|
||||||
const entry =
|
|
||||||
this.insertions[opID.sessionID]?.[opID.txIndex]?.[opID.changeIdx];
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error("Missing op " + opID);
|
|
||||||
}
|
|
||||||
for (const predecessor of entry.predecessors) {
|
|
||||||
this.fillArrayFromOpID(predecessor, arr);
|
|
||||||
}
|
|
||||||
const deleted =
|
|
||||||
(this.deletionsByInsertion[opID.sessionID]?.[opID.txIndex]?.[
|
|
||||||
opID.changeIdx
|
|
||||||
]?.length || 0) > 0;
|
|
||||||
if (!deleted) {
|
|
||||||
arr.push({
|
|
||||||
value: entry.value,
|
|
||||||
madeAt: entry.madeAt,
|
|
||||||
opID,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const successor of entry.successors) {
|
|
||||||
this.fillArrayFromOpID(successor, arr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current items in the CoList as an array. (alias of `asArray`)
|
|
||||||
*
|
|
||||||
* @category 1. Reading
|
|
||||||
*/
|
|
||||||
toJSON(): (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[] {
|
|
||||||
return this.asArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 5. Edit history */
|
|
||||||
editAt(idx: number):
|
|
||||||
| {
|
|
||||||
by: AccountID | AgentID;
|
|
||||||
tx: TransactionID;
|
|
||||||
at: Date;
|
|
||||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
|
||||||
}
|
|
||||||
| undefined {
|
|
||||||
const entry = this.entries()[idx];
|
|
||||||
if (!entry) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const madeAt = new Date(entry.madeAt);
|
|
||||||
const by = accountOrAgentIDfromSessionID(entry.opID.sessionID);
|
|
||||||
const value = entry.value;
|
|
||||||
return {
|
|
||||||
by,
|
|
||||||
tx: {
|
|
||||||
sessionID: entry.opID.sessionID,
|
|
||||||
txIndex: entry.opID.txIndex,
|
|
||||||
},
|
|
||||||
at: madeAt,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 5. Edit history */
|
|
||||||
deletionEdits(): {
|
|
||||||
by: AccountID | AgentID;
|
|
||||||
tx: TransactionID;
|
|
||||||
at: Date;
|
|
||||||
// TODO: add indices that are now before and after the deleted item
|
|
||||||
}[] {
|
|
||||||
const edits: {
|
|
||||||
by: AccountID | AgentID;
|
|
||||||
tx: TransactionID;
|
|
||||||
at: Date;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
for (const sessionID in this.deletionsByInsertion) {
|
|
||||||
const sessionEntry =
|
|
||||||
this.deletionsByInsertion[sessionID as SessionID];
|
|
||||||
for (const txIdx in sessionEntry) {
|
|
||||||
const txEntry = sessionEntry[Number(txIdx)];
|
|
||||||
for (const changeIdx in txEntry) {
|
|
||||||
const changeEntry = txEntry[Number(changeIdx)];
|
|
||||||
for (const deletion of changeEntry || []) {
|
|
||||||
const madeAt = new Date(deletion.madeAt);
|
|
||||||
const by = accountOrAgentIDfromSessionID(
|
|
||||||
deletion.deletionID.sessionID
|
|
||||||
);
|
|
||||||
edits.push({
|
|
||||||
by,
|
|
||||||
tx: deletion.deletionID,
|
|
||||||
at: madeAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return edits;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 3. Subscription */
|
|
||||||
subscribe(listener: (coList: this) => void): () => void {
|
|
||||||
return this.core.subscribe((content) => {
|
|
||||||
listener(content as this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CoList<
|
|
||||||
Item extends JsonValue | CoValue,
|
|
||||||
Meta extends JsonObject | null = null
|
|
||||||
>
|
|
||||||
extends CoListView<Item, Meta>
|
|
||||||
implements CoValue
|
|
||||||
{
|
|
||||||
/** Returns a new version of this CoList with `item` appended after the item currently at index `after`.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* @category 2. Editing
|
|
||||||
**/
|
|
||||||
append(
|
|
||||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
|
||||||
after?: number,
|
|
||||||
privacy: "private" | "trusting" = "private"
|
|
||||||
): this {
|
|
||||||
const entries = this.entries();
|
|
||||||
after =
|
|
||||||
after === undefined
|
|
||||||
? entries.length > 0
|
|
||||||
? entries.length - 1
|
|
||||||
: 0
|
|
||||||
: 0;
|
|
||||||
let opIDBefore;
|
|
||||||
if (entries.length > 0) {
|
|
||||||
const entryBefore = entries[after];
|
|
||||||
if (!entryBefore) {
|
|
||||||
throw new Error("Invalid index " + after);
|
|
||||||
}
|
|
||||||
opIDBefore = entryBefore.opID;
|
|
||||||
} else {
|
|
||||||
if (after !== 0) {
|
|
||||||
throw new Error("Invalid index " + after);
|
|
||||||
}
|
|
||||||
opIDBefore = "start";
|
|
||||||
}
|
|
||||||
this.core.makeTransaction(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
op: "app",
|
|
||||||
value: isCoValue(item) ? item.id : item,
|
|
||||||
after: opIDBefore,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
privacy
|
|
||||||
);
|
|
||||||
|
|
||||||
return new CoList(this.core) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new version of this CoList with `item` prepended before the item currently at index `before`.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* @category 2. Editing
|
|
||||||
*/
|
|
||||||
prepend(
|
|
||||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
|
||||||
before?: number,
|
|
||||||
privacy: "private" | "trusting" = "private"
|
|
||||||
): this {
|
|
||||||
const entries = this.entries();
|
|
||||||
before = before === undefined ? 0 : before;
|
|
||||||
let opIDAfter;
|
|
||||||
if (entries.length > 0) {
|
|
||||||
const entryAfter = entries[before];
|
|
||||||
if (entryAfter) {
|
|
||||||
opIDAfter = entryAfter.opID;
|
|
||||||
} else {
|
|
||||||
if (before !== entries.length) {
|
|
||||||
throw new Error("Invalid index " + before);
|
|
||||||
}
|
|
||||||
opIDAfter = "end";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (before !== 0) {
|
|
||||||
throw new Error("Invalid index " + before);
|
|
||||||
}
|
|
||||||
opIDAfter = "end";
|
|
||||||
}
|
|
||||||
this.core.makeTransaction(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
op: "pre",
|
|
||||||
value: isCoValue(item) ? item.id : item,
|
|
||||||
before: opIDAfter,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
privacy
|
|
||||||
);
|
|
||||||
|
|
||||||
return new CoList(this.core) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a new version of this CoList with the item at index `at` deleted from the list.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, the fact of this deletion is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, the fact of this deletion is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* @category 2. Editing
|
|
||||||
**/
|
|
||||||
delete(at: number, privacy: "private" | "trusting" = "private"): this {
|
|
||||||
const entries = this.entries();
|
|
||||||
const entry = entries[at];
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error("Invalid index " + at);
|
|
||||||
}
|
|
||||||
this.core.makeTransaction(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
op: "del",
|
|
||||||
insertion: entry.opID,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
privacy
|
|
||||||
);
|
|
||||||
|
|
||||||
return new CoList(this.core) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 2. Editing */
|
|
||||||
mutate(mutator: (mutable: MutableCoList<Item, Meta>) => void): this {
|
|
||||||
const mutable = new MutableCoList<Item, Meta>(this.core);
|
|
||||||
mutator(mutable);
|
|
||||||
return new CoList(this.core) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use `mutate` instead. */
|
|
||||||
edit(mutator: (mutable: MutableCoList<Item, Meta>) => void): this {
|
|
||||||
return this.mutate(mutator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MutableCoList<
|
|
||||||
Item extends JsonValue | CoValue,
|
|
||||||
Meta extends JsonObject | null = null
|
|
||||||
>
|
|
||||||
extends CoListView<Item, Meta>
|
|
||||||
implements CoValue
|
|
||||||
{
|
|
||||||
/** Appends `item` after the item currently at index `after`.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* @category 2. Mutating
|
|
||||||
**/
|
|
||||||
append(
|
|
||||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
|
||||||
after?: number,
|
|
||||||
privacy: "private" | "trusting" = "private"
|
|
||||||
): void {
|
|
||||||
const listAfter = CoList.prototype.append.call(
|
|
||||||
this,
|
|
||||||
item,
|
|
||||||
after,
|
|
||||||
privacy
|
|
||||||
) as CoList<Item, Meta>;
|
|
||||||
this.afterStart = listAfter.afterStart;
|
|
||||||
this.beforeEnd = listAfter.beforeEnd;
|
|
||||||
this.insertions = listAfter.insertions;
|
|
||||||
this.deletionsByInsertion = listAfter.deletionsByInsertion;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prepends `item` before the item currently at index `before`.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* * @category 2. Mutating
|
|
||||||
**/
|
|
||||||
prepend(
|
|
||||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
|
||||||
before?: number,
|
|
||||||
privacy: "private" | "trusting" = "private"
|
|
||||||
): void {
|
|
||||||
const listAfter = CoList.prototype.prepend.call(
|
|
||||||
this,
|
|
||||||
item,
|
|
||||||
before,
|
|
||||||
privacy
|
|
||||||
) as CoList<Item, Meta>;
|
|
||||||
this.afterStart = listAfter.afterStart;
|
|
||||||
this.beforeEnd = listAfter.beforeEnd;
|
|
||||||
this.insertions = listAfter.insertions;
|
|
||||||
this.deletionsByInsertion = listAfter.deletionsByInsertion;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes the item at index `at` from the list.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, the fact of this deletion is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, the fact of this deletion is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* * @category 2. Mutating
|
|
||||||
**/
|
|
||||||
delete(at: number, privacy: "private" | "trusting" = "private"): void {
|
|
||||||
const listAfter = CoList.prototype.delete.call(
|
|
||||||
this,
|
|
||||||
at,
|
|
||||||
privacy
|
|
||||||
) as CoList<Item, Meta>;
|
|
||||||
this.afterStart = listAfter.afterStart;
|
|
||||||
this.beforeEnd = listAfter.beforeEnd;
|
|
||||||
this.insertions = listAfter.insertions;
|
|
||||||
this.deletionsByInsertion = listAfter.deletionsByInsertion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
|
||||||
import { AgentID, TransactionID } from "../ids.js";
|
|
||||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
|
||||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
|
||||||
import { AccountID } from "../account.js";
|
|
||||||
import { Group } from "../group.js";
|
|
||||||
import { parseJSON } from "../jsonStringify.js";
|
|
||||||
|
|
||||||
type MapOp<K extends string, V extends JsonValue | CoValue | undefined> = {
|
|
||||||
txID: TransactionID;
|
|
||||||
madeAt: number;
|
|
||||||
changeIdx: number;
|
|
||||||
} & MapOpPayload<K, V>;
|
|
||||||
// TODO: add after TransactionID[] for conflicts/ordering
|
|
||||||
|
|
||||||
export type MapOpPayload<
|
|
||||||
K extends string,
|
|
||||||
V extends JsonValue | CoValue | undefined
|
|
||||||
> =
|
|
||||||
| {
|
|
||||||
op: "set";
|
|
||||||
key: K;
|
|
||||||
value: V extends CoValue ? CoID<V> : Exclude<V, CoValue>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
op: "del";
|
|
||||||
key: K;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CoMapView<
|
|
||||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
|
||||||
Meta extends JsonObject | null = null
|
|
||||||
> implements CoValue
|
|
||||||
{
|
|
||||||
/** @category 6. Meta */
|
|
||||||
id: CoID<this>;
|
|
||||||
/** @category 6. Meta */
|
|
||||||
type = "comap" as const;
|
|
||||||
/** @category 6. Meta */
|
|
||||||
core: CoValueCore;
|
|
||||||
/** @internal */
|
|
||||||
ops: {
|
|
||||||
[Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
|
|
||||||
};
|
|
||||||
/** @internal */
|
|
||||||
atTimeFilter?: number = undefined;
|
|
||||||
/** @category 6. Meta */
|
|
||||||
readonly _shape!: Shape;
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
constructor(core: CoValueCore) {
|
|
||||||
this.id = core.id as CoID<this>;
|
|
||||||
this.core = core;
|
|
||||||
this.ops = {};
|
|
||||||
|
|
||||||
for (const {
|
|
||||||
txID,
|
|
||||||
changes,
|
|
||||||
madeAt,
|
|
||||||
} of core.getValidSortedTransactions()) {
|
|
||||||
for (const [changeIdx, changeUntyped] of parseJSON(
|
|
||||||
changes
|
|
||||||
).entries()) {
|
|
||||||
const change = changeUntyped as MapOpPayload<
|
|
||||||
keyof Shape & string,
|
|
||||||
Shape[keyof Shape & string]
|
|
||||||
>;
|
|
||||||
let entries = this.ops[change.key];
|
|
||||||
if (!entries) {
|
|
||||||
entries = [];
|
|
||||||
this.ops[change.key] = entries;
|
|
||||||
}
|
|
||||||
entries.push({
|
|
||||||
txID,
|
|
||||||
madeAt,
|
|
||||||
changeIdx,
|
|
||||||
...(change as MapOpPayload<
|
|
||||||
keyof Shape & string,
|
|
||||||
Shape[keyof Shape & string]
|
|
||||||
>),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 6. Meta */
|
|
||||||
get meta(): Meta {
|
|
||||||
return this.core.header.meta as Meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 6. Meta */
|
|
||||||
get group(): Group {
|
|
||||||
return this.core.getGroup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 4. Time travel */
|
|
||||||
atTime(time: number): this {
|
|
||||||
const clone = Object.create(this) as this;
|
|
||||||
clone.id = this.id;
|
|
||||||
clone.type = this.type;
|
|
||||||
clone.core = this.core;
|
|
||||||
clone.ops = this.ops;
|
|
||||||
clone.atTimeFilter = time;
|
|
||||||
return clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
timeFilteredOps<K extends keyof Shape & string>(
|
|
||||||
key: K
|
|
||||||
): MapOp<K, Shape[K]>[] | undefined {
|
|
||||||
if (this.atTimeFilter) {
|
|
||||||
return this.ops[key]?.filter(
|
|
||||||
(op) => op.madeAt <= this.atTimeFilter!
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return this.ops[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all keys currently in the map.
|
|
||||||
*
|
|
||||||
* @category 1. Reading */
|
|
||||||
keys(): (keyof Shape & string)[] {
|
|
||||||
const keys = Object.keys(this.ops) as (keyof Shape & string)[];
|
|
||||||
|
|
||||||
if (this.atTimeFilter) {
|
|
||||||
return keys.filter((key) => {
|
|
||||||
this.timeFilteredOps(key)?.length;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current value for the given key.
|
|
||||||
*
|
|
||||||
* @category 1. Reading
|
|
||||||
**/
|
|
||||||
get<K extends keyof Shape & string>(
|
|
||||||
key: K
|
|
||||||
):
|
|
||||||
| (Shape[K] extends CoValue
|
|
||||||
? CoID<Shape[K]>
|
|
||||||
: Exclude<Shape[K], CoValue>)
|
|
||||||
| undefined {
|
|
||||||
const ops = this.timeFilteredOps(key);
|
|
||||||
if (!ops) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const includeUntil = this.atTimeFilter;
|
|
||||||
const lastEntry = includeUntil
|
|
||||||
? ops.findLast((entry) => entry.madeAt <= includeUntil)
|
|
||||||
: ops[ops.length - 1]!;
|
|
||||||
|
|
||||||
if (lastEntry?.op === "del") {
|
|
||||||
return undefined;
|
|
||||||
} else {
|
|
||||||
return lastEntry?.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 1. Reading */
|
|
||||||
asObject(): {
|
|
||||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
|
||||||
? CoID<Shape[K]>
|
|
||||||
: Exclude<Shape[K], CoValue>;
|
|
||||||
} {
|
|
||||||
const object: Partial<{
|
|
||||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
|
||||||
? CoID<Shape[K]>
|
|
||||||
: Exclude<Shape[K], CoValue>;
|
|
||||||
}> = {};
|
|
||||||
|
|
||||||
for (const key of this.keys()) {
|
|
||||||
const value = this.get(key);
|
|
||||||
if (value !== undefined) {
|
|
||||||
object[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return object as {
|
|
||||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
|
||||||
? CoID<Shape[K]>
|
|
||||||
: Exclude<Shape[K], CoValue>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 1. Reading */
|
|
||||||
toJSON(): {
|
|
||||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
|
||||||
? CoID<Shape[K]>
|
|
||||||
: Exclude<Shape[K], CoValue>;
|
|
||||||
} {
|
|
||||||
return this.asObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 5. Edit history */
|
|
||||||
nthEditAt<K extends keyof Shape & string>(
|
|
||||||
key: K,
|
|
||||||
n: number
|
|
||||||
):
|
|
||||||
| {
|
|
||||||
by: AccountID | AgentID;
|
|
||||||
tx: TransactionID;
|
|
||||||
at: Date;
|
|
||||||
value?: Shape[K] extends CoValue
|
|
||||||
? CoID<Shape[K]>
|
|
||||||
: Exclude<Shape[K], CoValue>;
|
|
||||||
}
|
|
||||||
| undefined {
|
|
||||||
const ops = this.timeFilteredOps(key);
|
|
||||||
if (!ops || ops.length <= n) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = ops[n]!;
|
|
||||||
|
|
||||||
if (this.atTimeFilter && entry.madeAt > this.atTimeFilter) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
by: accountOrAgentIDfromSessionID(entry.txID.sessionID),
|
|
||||||
tx: entry.txID,
|
|
||||||
at: new Date(entry.madeAt),
|
|
||||||
value: entry.op === "del" ? undefined : entry.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 5. Edit history */
|
|
||||||
lastEditAt<K extends keyof Shape & string>(
|
|
||||||
key: K
|
|
||||||
):
|
|
||||||
| {
|
|
||||||
by: AccountID | AgentID;
|
|
||||||
tx: TransactionID;
|
|
||||||
at: Date;
|
|
||||||
value?: Shape[K] extends CoValue
|
|
||||||
? CoID<Shape[K]>
|
|
||||||
: Exclude<Shape[K], CoValue>;
|
|
||||||
}
|
|
||||||
| undefined {
|
|
||||||
const ops = this.timeFilteredOps(key);
|
|
||||||
if (!ops || ops.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return this.nthEditAt(key, ops.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 5. Edit history */
|
|
||||||
*editsAt<K extends keyof Shape & string>(key: K) {
|
|
||||||
const ops = this.timeFilteredOps(key);
|
|
||||||
if (!ops) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < ops.length; i++) {
|
|
||||||
yield this.nthEditAt(key, i)!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 3. Subscription */
|
|
||||||
subscribe(listener: (coMap: this) => void): () => void {
|
|
||||||
return this.core.subscribe((content) => {
|
|
||||||
listener(content as this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A collaborative map with precise shape `Shape` and optional static metadata `Meta` */
|
|
||||||
export class CoMap<
|
|
||||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
|
||||||
Meta extends JsonObject | null = null
|
|
||||||
>
|
|
||||||
extends CoMapView<Shape, Meta>
|
|
||||||
implements CoValue
|
|
||||||
{
|
|
||||||
|
|
||||||
|
|
||||||
/** Returns a new version of this CoMap with a new value for the given key.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* @category 2. Editing
|
|
||||||
**/
|
|
||||||
set<K extends keyof Shape & string>(
|
|
||||||
key: K,
|
|
||||||
value: Shape[K] extends CoValue ? Shape[K] | CoID<Shape[K]> : Shape[K],
|
|
||||||
privacy?: "private" | "trusting"
|
|
||||||
): this;
|
|
||||||
set(
|
|
||||||
kv: {
|
|
||||||
[K in keyof Shape & string]?: Shape[K] extends CoValue
|
|
||||||
? Shape[K] | CoID<Shape[K]>
|
|
||||||
: Shape[K];
|
|
||||||
},
|
|
||||||
privacy?: "private" | "trusting"
|
|
||||||
): this;
|
|
||||||
set<K extends keyof Shape & string>(
|
|
||||||
...args:
|
|
||||||
| [
|
|
||||||
{
|
|
||||||
[K in keyof Shape & string]?: Shape[K] extends CoValue
|
|
||||||
? Shape[K] | CoID<Shape[K]>
|
|
||||||
: Shape[K];
|
|
||||||
},
|
|
||||||
("private" | "trusting")?
|
|
||||||
]
|
|
||||||
| [
|
|
||||||
K,
|
|
||||||
Shape[K] extends CoValue
|
|
||||||
? Shape[K] | CoID<Shape[K]>
|
|
||||||
: Shape[K],
|
|
||||||
("private" | "trusting")?
|
|
||||||
]
|
|
||||||
): this {
|
|
||||||
if (typeof args[0] === "string") {
|
|
||||||
const [key, value, privacy = "private"] = args;
|
|
||||||
this.core.makeTransaction(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
op: "set",
|
|
||||||
key,
|
|
||||||
value: isCoValue(value) ? value.id : value,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
privacy
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [kv, privacy = "private"] = args as [
|
|
||||||
{
|
|
||||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
|
||||||
? Shape[K] | CoID<Shape[K]>
|
|
||||||
: Shape[K];
|
|
||||||
},
|
|
||||||
"private" | "trusting" | undefined
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(kv)) {
|
|
||||||
this.core.makeTransaction(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
op: "set",
|
|
||||||
key,
|
|
||||||
value: isCoValue(value) ? value.id : value,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
privacy
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CoMap(this.core) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a new version of this CoMap with the given key deleted (setting it to undefined).
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* @category 2. Editing
|
|
||||||
**/
|
|
||||||
delete(
|
|
||||||
key: keyof Shape & string,
|
|
||||||
privacy: "private" | "trusting" = "private"
|
|
||||||
): this {
|
|
||||||
this.core.makeTransaction(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
op: "del",
|
|
||||||
key,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
privacy
|
|
||||||
);
|
|
||||||
|
|
||||||
return new CoMap(this.core) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @category 2. Editing */
|
|
||||||
mutate(mutator: (mutable: MutableCoMap<Shape, Meta>) => void): this {
|
|
||||||
const mutable = new MutableCoMap<Shape, Meta>(this.core);
|
|
||||||
mutator(mutable);
|
|
||||||
return new CoMap(this.core) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use `mutate` instead. */
|
|
||||||
edit(mutator: (mutable: MutableCoMap<Shape, Meta>) => void): this {
|
|
||||||
return this.mutate(mutator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MutableCoMap<
|
|
||||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
|
||||||
Meta extends JsonObject | null = null
|
|
||||||
>
|
|
||||||
extends CoMapView<Shape, Meta>
|
|
||||||
implements CoValue
|
|
||||||
{
|
|
||||||
/** Sets a new value for the given key.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
*
|
|
||||||
* @category 2. Mutation
|
|
||||||
*/
|
|
||||||
set<K extends keyof Shape & string>(
|
|
||||||
key: K,
|
|
||||||
value: Shape[K] extends CoValue ? Shape[K] | CoID<Shape[K]> : Shape[K],
|
|
||||||
privacy: "private" | "trusting" = "private"
|
|
||||||
): void {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
const after = (CoMap.prototype.set as Function).call(
|
|
||||||
this,
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
privacy
|
|
||||||
) as CoMap<Shape, Meta>;
|
|
||||||
this.ops = after.ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes the value for the given key (setting it to undefined).
|
|
||||||
*
|
|
||||||
* If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
|
||||||
*
|
|
||||||
* If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
|
||||||
* @category 2. Mutation
|
|
||||||
*/
|
|
||||||
delete(
|
|
||||||
key: keyof Shape & string,
|
|
||||||
privacy: "private" | "trusting" = "private"
|
|
||||||
): void {
|
|
||||||
const after = CoMap.prototype.delete.call(this, key, privacy) as CoMap<
|
|
||||||
Shape,
|
|
||||||
Meta
|
|
||||||
>;
|
|
||||||
this.ops = after.ops;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user