Compare commits
164 Commits
cojson@0.0
...
cojson@0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c5a6b9833 | ||
|
|
d300d265c4 | ||
|
|
1d72ce587f | ||
|
|
3fdb41dcb9 | ||
|
|
f20de2f04a | ||
|
|
31b31f111b | ||
|
|
2ae9fb9778 | ||
|
|
cd0da0f6bf | ||
|
|
cd9bfbb9fa | ||
|
|
ed0428bf97 | ||
|
|
c038a02051 | ||
|
|
31abcfeef4 | ||
|
|
5f32d9ccf5 | ||
|
|
0510600104 | ||
|
|
7f30fbf3c5 | ||
|
|
3d56260ca4 | ||
|
|
1137775da9 | ||
|
|
3951fdc938 | ||
|
|
5779e357dd | ||
|
|
2842d80f26 | ||
|
|
96387d8023 | ||
|
|
6720c19233 | ||
|
|
ef732b4700 | ||
|
|
ee7e3ee5a7 | ||
|
|
ceeed88fa5 | ||
|
|
79353a1d97 | ||
|
|
7fdc42c62f | ||
|
|
3a2e854a88 | ||
|
|
661a2d023a | ||
|
|
6ef5b6b2ab | ||
|
|
1384ebed84 | ||
|
|
17e53f9998 | ||
|
|
cfb1f39efe | ||
|
|
2234276dcf | ||
|
|
bb0a6a0600 | ||
|
|
0a6eb0c10a | ||
|
|
88b67d89e0 | ||
|
|
1a65d826b2 | ||
|
|
6c65ec2b46 | ||
|
|
5b578a832d | ||
|
|
042afc52d7 | ||
|
|
1b83493964 | ||
|
|
3b50da1a74 | ||
|
|
8e0fc74d9f | ||
|
|
e28326f32c | ||
|
|
d7e8b0b9da | ||
|
|
c46a1f6b0a | ||
|
|
7947918278 | ||
|
|
50c36e7255 | ||
|
|
c39a7ed1b7 | ||
|
|
83762dbb0f | ||
|
|
7c82e12508 | ||
|
|
6db149be36 | ||
|
|
909a101f99 | ||
|
|
df0b6fe138 | ||
|
|
0543756016 | ||
|
|
92eae0e180 | ||
|
|
9ccc97fcd3 | ||
|
|
120ba57274 | ||
|
|
0679a64002 | ||
|
|
e9d561adbd | ||
|
|
bb5fd24f6a | ||
|
|
18d5b9146f | ||
|
|
39850d465f | ||
|
|
27e0d6df46 | ||
|
|
6d0c820724 | ||
|
|
78a1d5a614 | ||
|
|
33c2705329 | ||
|
|
4873a634a4 | ||
|
|
edb43cd070 | ||
|
|
b128a2d6f7 | ||
|
|
27abcb4f6f | ||
|
|
e9b41c4344 | ||
|
|
d93b376e4b | ||
|
|
aeb38eb7d5 | ||
|
|
07bffb5050 | ||
|
|
012bd43865 | ||
|
|
ffc1181b81 | ||
|
|
4ca5e258b5 | ||
|
|
2255c824b7 | ||
|
|
8ed59e40e9 | ||
|
|
03b34b4b66 | ||
|
|
53c93f6a0b | ||
|
|
4af7f25eab | ||
|
|
6d6e8a0e28 | ||
|
|
4a617c8323 | ||
|
|
eaed275a79 | ||
|
|
01fdcaed34 | ||
|
|
7aeb1a789b | ||
|
|
a00649fa29 | ||
|
|
764954c727 | ||
|
|
b0ec93eb3a | ||
|
|
4dd226bc95 | ||
|
|
1692340856 | ||
|
|
fbda78f908 | ||
|
|
61e9f6afad | ||
|
|
246bbb119d | ||
|
|
80054515c9 | ||
|
|
f9486a82c3 | ||
|
|
d0babab822 | ||
|
|
ab34172e01 | ||
|
|
b779a91611 | ||
|
|
297a8646dd | ||
|
|
25eb3e097f | ||
|
|
fe1092ccf6 | ||
|
|
29abbc455c | ||
|
|
f6864e0f93 | ||
|
|
9440b5306c | ||
|
|
aa34f1e8a6 | ||
|
|
24ce7dbdf1 | ||
|
|
65a7a66c15 | ||
|
|
0f999a2c2d | ||
|
|
2247c97080 | ||
|
|
cbdc722959 | ||
|
|
bb157b6099 | ||
|
|
e1f8ec6f11 | ||
|
|
9854238346 | ||
|
|
3b5ab90006 | ||
|
|
988dc37902 | ||
|
|
4ef4b87d95 | ||
|
|
27f811b9e9 | ||
|
|
52be603996 | ||
|
|
d1123866c2 | ||
|
|
9750fbee68 | ||
|
|
2f91184201 | ||
|
|
97badc24fb | ||
|
|
eaeb201f10 | ||
|
|
9c5dd96f58 | ||
|
|
a1a96e1118 | ||
|
|
40b4ebaf00 | ||
|
|
37559b2dec | ||
|
|
81fd3e8aff | ||
|
|
f1747e1aaf | ||
|
|
934365c24d | ||
|
|
ff20c3a260 | ||
|
|
1d0ce83019 | ||
|
|
2951d8452f | ||
|
|
6532e79790 | ||
|
|
09603d17a3 | ||
|
|
10b372da6e | ||
|
|
b556a36db3 | ||
|
|
094c505cf0 | ||
|
|
4d71ab8aac | ||
|
|
f1297c613b | ||
|
|
1ee5c2b3c8 | ||
|
|
f14337f862 | ||
|
|
b8f4571474 | ||
|
|
6557532743 | ||
|
|
ea6835664b | ||
|
|
a00332f4af | ||
|
|
6a6fb2eb3c | ||
|
|
0437223d50 | ||
|
|
30c7e1bf6d | ||
|
|
aaa9d876d5 | ||
|
|
264009a1a9 | ||
|
|
f2cb5d1b59 | ||
|
|
8610db2d8e | ||
|
|
c672a03338 | ||
|
|
cb60088d2a | ||
|
|
a59d5d3b70 | ||
|
|
dcdf829a05 | ||
|
|
a907321d02 | ||
|
|
a2b9be9dd0 | ||
|
|
432d114438 |
73
.github/workflows/build-and-deploy.yaml
vendored
73
.github/workflows/build-and-deploy.yaml
vendored
@@ -7,8 +7,11 @@ on:
|
|||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
example: ["todo", "pets"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -17,40 +20,50 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 16
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
cache-dependency-path: yarn.lock
|
cache-dependency-path: yarn.lock
|
||||||
- name: Nuke Workspace
|
|
||||||
run: |
|
|
||||||
rm package.json yarn.lock;
|
|
||||||
- name: Yarn Build
|
|
||||||
run: |
|
|
||||||
yarn install --frozen-lockfile;
|
|
||||||
yarn build;
|
|
||||||
working-directory: ./examples/todo
|
|
||||||
|
|
||||||
- uses: satackey/action-docker-layer-caching@v0.0.11
|
- name: Set up Docker Buildx
|
||||||
continue-on-error: true
|
uses: docker/setup-buildx-action@v2
|
||||||
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@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: gardencmp
|
username: gardencmp
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Docker Build & Push
|
- name: Nuke Workspace
|
||||||
run: |
|
run: |
|
||||||
export DOCKER_TAG=ghcr.io/gardencmp/jazz-example-todo:${{github.head_ref || github.ref_name}}-${{github.sha}}-$(date +%s) ;
|
rm package.json yarn.lock;
|
||||||
docker build . --file Dockerfile --tag $DOCKER_TAG;
|
|
||||||
docker push $DOCKER_TAG;
|
|
||||||
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
|
|
||||||
working-directory: ./examples/todo
|
|
||||||
|
|
||||||
|
- name: Yarn Build
|
||||||
|
run: |
|
||||||
|
yarn install --frozen-lockfile;
|
||||||
|
yarn build;
|
||||||
|
working-directory: ./examples/${{ matrix.example }}
|
||||||
|
|
||||||
|
- name: Docker Build & Push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: ./examples/${{ matrix.example }}
|
||||||
|
push: true
|
||||||
|
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}}
|
||||||
|
cache-from: type=gha
|
||||||
|
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
|
||||||
@@ -69,13 +82,9 @@ 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=${{ env.DOCKER_TAG }};
|
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}};
|
||||||
|
|
||||||
for region in ${{ vars.DEPLOY_REGIONS }}
|
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
|
||||||
do
|
cat job-instance.nomad;
|
||||||
export REGION=$region;
|
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
|
||||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN} ${REGION}' < job-template.nomad > job-instance.nomad;
|
working-directory: ./examples/${{ matrix.example }}
|
||||||
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,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
lerna-debug.log
|
lerna-debug.log
|
||||||
|
docsTmp
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
117
README.md
117
README.md
@@ -1,9 +1,116 @@
|
|||||||
# Jazz - instant sync
|
# Jazz - instant sync
|
||||||
|
|
||||||
Jazz is an open-source toolkit for telepathic data.
|
<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>
|
||||||
|
|
||||||
Ship faster and simplify frontend, backend & devops by building with Telepathic Data.
|
**Jazz is an open-source toolkit for building apps with *secure sync.***
|
||||||
Get real-time multiplayer and cross-device sync for free.
|
|
||||||
|
|
||||||
## What is Telepathic Data?
|
Quickly build and ship apps with:
|
||||||
...
|
|
||||||
|
- **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.
|
||||||
18
examples/pets/.eslintrc.cjs
Normal file
18
examples/pets/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
Normal file
24
examples/pets/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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?
|
||||||
4
examples/pets/Dockerfile
Normal file
4
examples/pets/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM caddy:2.7.3-alpine
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||||
|
|
||||||
|
COPY ./dist /usr/share/caddy/
|
||||||
51
examples/pets/README.md
Normal file
51
examples/pets/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 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).
|
||||||
16
examples/pets/components.json
Normal file
16
examples/pets/components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
examples/pets/index.html
Normal file
13
examples/pets/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
56
examples/pets/job-template.nomad
Normal file
56
examples/pets/job-template.nomad
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
47
examples/pets/package.json
Normal file
47
examples/pets/package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
examples/pets/postcss.config.js
Normal file
6
examples/pets/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
examples/pets/public/jazz-logo.png
Normal file
BIN
examples/pets/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
29
examples/pets/src/1_types.ts
Normal file
29
examples/pets/src/1_types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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 */
|
||||||
86
examples/pets/src/2_main.tsx
Normal file
86
examples/pets/src/2_main.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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 */
|
||||||
100
examples/pets/src/3_NewPetPostForm.tsx
Normal file
100
examples/pets/src/3_NewPetPostForm.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
examples/pets/src/4_RatePetPostUI.tsx
Normal file
124
examples/pets/src/4_RatePetPostUI.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
examples/pets/src/basicComponents/TitleAndLogo.tsx
Normal file
12
examples/pets/src/basicComponents/TitleAndLogo.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
examples/pets/src/basicComponents/index.ts
Normal file
7
examples/pets/src/basicComponents/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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";
|
||||||
@@ -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 "@/lib/utils"
|
import { cn } from "@/basicComponents/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",
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/basicComponents/lib/utils"
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
15
examples/pets/src/basicComponents/ui/skeleton.tsx
Normal file
15
examples/pets/src/basicComponents/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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 }
|
||||||
127
examples/pets/src/basicComponents/ui/toast.tsx
Normal file
127
examples/pets/src/basicComponents/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
33
examples/pets/src/basicComponents/ui/toaster.tsx
Normal file
33
examples/pets/src/basicComponents/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
examples/pets/src/basicComponents/ui/use-toast.ts
Normal file
192
examples/pets/src/basicComponents/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// 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,9 +1,10 @@
|
|||||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export const PrettyAuthComponent: LocalAuthComponent = ({
|
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||||
|
|
||||||
|
import { Input, Button } from "../basicComponents";
|
||||||
|
|
||||||
|
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||||
loading,
|
loading,
|
||||||
logIn,
|
logIn,
|
||||||
signUp,
|
signUp,
|
||||||
@@ -11,7 +12,7 @@ export const PrettyAuthComponent: LocalAuthComponent = ({
|
|||||||
const [username, setUsername] = useState<string>("");
|
const [username, setUsername] = useState<string>("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center p-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div>Loading...</div>
|
<div>Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -28,6 +29,7 @@ export const PrettyAuthComponent: LocalAuthComponent = ({
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="webauthn"
|
autoComplete="webauthn"
|
||||||
|
className="text-base"
|
||||||
/>
|
/>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Input
|
<Input
|
||||||
47
examples/pets/src/components/ShareButton.tsx
Normal file
47
examples/pets/src/components/ShareButton.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
76
examples/pets/src/index.css
Normal file
76
examples/pets/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
|
--primary: 24 9.8% 10%;
|
||||||
|
--primary-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--secondary: 60 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
|
--muted: 60 4.8% 95.9%;
|
||||||
|
--muted-foreground: 25 5.3% 44.7%;
|
||||||
|
|
||||||
|
--accent: 60 4.8% 95.9%;
|
||||||
|
--accent-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--border: 20 5.9% 90%;
|
||||||
|
--input: 20 5.9% 90%;
|
||||||
|
--ring: 20 14.3% 4.1%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 20 14.3% 4.1%;
|
||||||
|
--foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--card: 20 14.3% 4.1%;
|
||||||
|
--card-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--popover: 20 14.3% 4.1%;
|
||||||
|
--popover-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--primary: 60 9.1% 97.8%;
|
||||||
|
--primary-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
|
--secondary: 12 6.5% 15.1%;
|
||||||
|
--secondary-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--muted: 12 6.5% 15.1%;
|
||||||
|
--muted-foreground: 24 5.4% 63.9%;
|
||||||
|
|
||||||
|
--accent: 12 6.5% 15.1%;
|
||||||
|
--accent-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
|
--border: 12 6.5% 15.1%;
|
||||||
|
--input: 12 6.5% 15.1%;
|
||||||
|
--ring: 24 5.7% 82.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/pets/src/vite-env.d.ts
vendored
Normal file
1
examples/pets/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
76
examples/pets/tailwind.config.js
Normal file
76
examples/pets/tailwind.config.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/** @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")],
|
||||||
|
}
|
||||||
29
examples/pets/tsconfig.json
Normal file
29
examples/pets/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"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" }]
|
||||||
|
}
|
||||||
10
examples/pets/tsconfig.node.json
Normal file
10
examples/pets/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
16
examples/pets/vite.config.ts
Normal file
16
examples/pets/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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,27 +1,64 @@
|
|||||||
# React + TypeScript + Vite
|
# Jazz Todo List Example
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Live version: https://example-todo.jazz.tools
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Installing & running the example locally
|
||||||
|
|
||||||
- [@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
|
Start by checking out just the example app to a folder:
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
```bash
|
||||||
|
npx degit gardencmp/jazz/examples/todo jazz-example-todo
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
cd jazz-example-todo
|
||||||
|
|
||||||
- Configure the top-level `parserOptions` property like this:
|
|
||||||
|
|
||||||
```js
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
|
||||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
},
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
|
||||||
- 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
|
Install dependencies:
|
||||||
|
|
||||||
|
```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": "@/components",
|
"components": "@/basicComponents",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/basicComponents/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/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Jazz Todo List Example</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/2_main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
job "example-todo$BRANCH_SUFFIX" {
|
job "example-todo$BRANCH_SUFFIX" {
|
||||||
region = "$REGION"
|
region = "global"
|
||||||
datacenters = ["$REGION"]
|
datacenters = ["*"]
|
||||||
|
|
||||||
group "static" {
|
group "static" {
|
||||||
// count = 3
|
count = 8
|
||||||
|
|
||||||
network {
|
network {
|
||||||
port "http" {
|
port "http" {
|
||||||
@@ -14,13 +14,17 @@ job "example-todo$BRANCH_SUFFIX" {
|
|||||||
constraint {
|
constraint {
|
||||||
attribute = "${node.class}"
|
attribute = "${node.class}"
|
||||||
operator = "="
|
operator = "="
|
||||||
value = "edge"
|
value = "mesh"
|
||||||
}
|
}
|
||||||
|
|
||||||
// spread {
|
spread {
|
||||||
// attribute = "${node.datacenter}"
|
attribute = "${node.datacenter}"
|
||||||
// weight = 100
|
weight = 100
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
constraint {
|
||||||
|
distinct_hosts = true
|
||||||
|
}
|
||||||
|
|
||||||
task "server" {
|
task "server" {
|
||||||
driver = "docker"
|
driver = "docker"
|
||||||
@@ -37,9 +41,7 @@ job "example-todo$BRANCH_SUFFIX" {
|
|||||||
|
|
||||||
service {
|
service {
|
||||||
tags = ["public"]
|
tags = ["public"]
|
||||||
meta {
|
name = "example-todo$BRANCH_SUFFIX"
|
||||||
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.6",
|
"version": "0.0.37",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -12,15 +12,21 @@
|
|||||||
"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.0.12",
|
"jazz-react": "^0.3.3",
|
||||||
"jazz-react-auth-local": "^0.0.9",
|
"jazz-react-auth-local": "^0.3.3",
|
||||||
"lucide-react": "^0.265.0",
|
"lucide-react": "^0.274.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.6"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uniqolor": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
|
|||||||
BIN
examples/todo/public/jazz-logo.png
Normal file
BIN
examples/todo/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
23
examples/todo/src/1_types.ts
Normal file
23
examples/todo/src/1_types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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 */
|
||||||
91
examples/todo/src/2_main.tsx
Normal file
91
examples/todo/src/2_main.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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 */
|
||||||
48
examples/todo/src/3_NewProjectForm.tsx
Normal file
48
examples/todo/src/3_NewProjectForm.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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 */
|
||||||
177
examples/todo/src/4_ProjectTodoTable.tsx
Normal file
177
examples/todo/src/4_ProjectTodoTable.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { CoMap, CoID, AccountID } from "cojson";
|
|
||||||
import { useJazz, useProfile, useTelepathicState } from "jazz-react";
|
|
||||||
import { SubmittableInput } from "./components/SubmittableInput";
|
|
||||||
|
|
||||||
type TaskContent = { done: boolean; text: string };
|
|
||||||
type Task = CoMap<TaskContent>;
|
|
||||||
|
|
||||||
type TodoListContent = {
|
|
||||||
title: string;
|
|
||||||
// other keys form a set of task IDs
|
|
||||||
[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, logOut } = useJazz();
|
|
||||||
|
|
||||||
const createList = useCallback((title: string) => {
|
|
||||||
const listTeam = localNode.createTeam();
|
|
||||||
const list = listTeam.createMap<TodoListContent>();
|
|
||||||
|
|
||||||
list.edit((list) => {
|
|
||||||
list.set("title", title);
|
|
||||||
});
|
|
||||||
|
|
||||||
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} />
|
|
||||||
) : (
|
|
||||||
<SubmittableInput
|
|
||||||
onSubmit={createList}
|
|
||||||
label="Create New List"
|
|
||||||
placeholder="New list title"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
window.location.hash = "";
|
|
||||||
logOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Log Out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
|
||||||
const list = useTelepathicState(listId);
|
|
||||||
|
|
||||||
const createTask = (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) => (
|
|
||||||
<TaskRow key={taskId} taskId={taskId} />
|
|
||||||
))}
|
|
||||||
<TableRow key="new">
|
|
||||||
<TableCell>
|
|
||||||
<Checkbox className="mt-1" disabled />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<SubmittableInput
|
|
||||||
onSubmit={(taskText) => createTask(taskText)}
|
|
||||||
label="Add"
|
|
||||||
placeholder="New task"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskRow({ 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>
|
|
||||||
<div className="flex flex-row justify-between">
|
|
||||||
<span className={task?.get("done") ? "line-through" : ""}>
|
|
||||||
{task?.get("text")}
|
|
||||||
</span>
|
|
||||||
<NameBadge accountID={task?.getLastEditor("text")} />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
|
||||||
const profile = useProfile({ accountID });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="rounded-full bg-neutral-200 dark:bg-neutral-600 py-0.5 px-2 text-xs text-neutral-500 dark:text-neutral-300">
|
|
||||||
{profile?.get("name") || "..."}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/basicComponents/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/basicComponents/ui/button";
|
||||||
|
|
||||||
export function SubmittableInput({
|
export function SubmittableInput({
|
||||||
onSubmit, label, placeholder,
|
onSubmit,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -21,12 +25,14 @@ export function SubmittableInput({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
className="-ml-3 -my-2 flex-grow flex-3"
|
className="-ml-3 -my-2 flex-grow flex-3 text-base"
|
||||||
name="text"
|
name="text"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoComplete="off" />
|
autoComplete="off"
|
||||||
<Button asChild type="submit" className="flex-shrink flex-1">
|
disabled={disabled}
|
||||||
<Input type="submit" value={label} />
|
/>
|
||||||
|
<Button asChild type="submit" className="flex-shrink flex-1 cursor-pointer">
|
||||||
|
<Input type="submit" value={label} disabled={disabled} />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
10
examples/todo/src/basicComponents/TitleAndLogo.tsx
Normal file
10
examples/todo/src/basicComponents/TitleAndLogo.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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 />
|
||||||
|
</>
|
||||||
|
}
|
||||||
17
examples/todo/src/basicComponents/index.ts
Normal file
17
examples/todo/src/basicComponents/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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";
|
||||||
6
examples/todo/src/basicComponents/lib/utils.ts
Normal file
6
examples/todo/src/basicComponents/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
72
examples/todo/src/basicComponents/themeProvider.tsx
Normal file
72
examples/todo/src/basicComponents/themeProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
56
examples/todo/src/basicComponents/ui/button.tsx
Normal file
56
examples/todo/src/basicComponents/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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 }
|
||||||
@@ -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 "@/lib/utils"
|
import { cn } from "@/basicComponents/lib/utils"
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
25
examples/todo/src/basicComponents/ui/input.tsx
Normal file
25
examples/todo/src/basicComponents/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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 }
|
||||||
15
examples/todo/src/basicComponents/ui/skeleton.tsx
Normal file
15
examples/todo/src/basicComponents/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/basicComponents/lib/utils"
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
127
examples/todo/src/basicComponents/ui/toast.tsx
Normal file
127
examples/todo/src/basicComponents/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
33
examples/todo/src/basicComponents/ui/toaster.tsx
Normal file
33
examples/todo/src/basicComponents/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
examples/todo/src/basicComponents/ui/use-toast.ts
Normal file
192
examples/todo/src/basicComponents/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// 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 }
|
||||||
48
examples/todo/src/components/Auth.tsx
Normal file
48
examples/todo/src/components/Auth.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
examples/todo/src/components/InviteButton.tsx
Normal file
45
examples/todo/src/components/InviteButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
@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%;
|
||||||
@@ -14,63 +9,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;
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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 "jazz-react-auth-local";
|
|
||||||
import { PrettyAuthComponent } from "./components/prettyAuth.tsx";
|
|
||||||
import { ThemeProvider } from "./components/themeProvider.tsx";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<ThemeProvider>
|
|
||||||
<WithJazz
|
|
||||||
auth={LocalAuth({
|
|
||||||
appName: "Todo List Example",
|
|
||||||
Component: PrettyAuthComponent,
|
|
||||||
})}
|
|
||||||
syncAddress={
|
|
||||||
new URLSearchParams(window.location.search).get("sync") ||
|
|
||||||
undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<App />
|
|
||||||
</WithJazz>
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -9,5 +9,8 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
minify: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
787
generateDocs.ts
Normal file
787
generateDocs.ts
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
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);
|
||||||
6
jest.config.js
Normal file
6
jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
const config = {
|
||||||
|
projects: ['<rootDir>/packages/cojson'],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
10
package.json
10
package.json
@@ -7,6 +7,14 @@
|
|||||||
],
|
],
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
packages/cojson-simple-sync/.eslintrc.cjs
Normal file
18
packages/cojson-simple-sync/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
Normal file
174
packages/cojson-simple-sync/.gitignore
vendored
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 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*
|
||||||
36
packages/cojson-simple-sync/package.json
Normal file
36
packages/cojson-simple-sync/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
52
packages/cojson-simple-sync/src/index.ts
Normal file
52
packages/cojson-simple-sync/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
86
packages/cojson-simple-sync/src/websocketStreams.ts
Normal file
86
packages/cojson-simple-sync/src/websocketStreams.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/cojson-simple-sync/tsconfig.json
Normal file
19
packages/cojson-simple-sync/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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/**/*"],
|
||||||
|
}
|
||||||
2801
packages/cojson-simple-sync/yarn.lock
Normal file
2801
packages/cojson-simple-sync/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "jazz-storage-indexeddb",
|
"name": "cojson-storage-indexeddb",
|
||||||
"version": "0.0.6",
|
"version": "0.3.3",
|
||||||
"main": "src/index.ts",
|
"main": "dist/index.js",
|
||||||
"types": "src/index.ts",
|
"types": "dist/index.d.ts",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cojson": "^0.0.19",
|
"cojson": "^0.3.3",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -18,5 +18,6 @@
|
|||||||
"lint": "eslint src/**/*.ts",
|
"lint": "eslint src/**/*.ts",
|
||||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
}
|
},
|
||||||
|
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||||
}
|
}
|
||||||
66
packages/cojson-storage-indexeddb/src/index.test.ts
Normal file
66
packages/cojson-storage-indexeddb/src/index.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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,5 +1,11 @@
|
|||||||
import { LocalNode, cojsonInternals, SessionID, SyncMessage, Peer } from "cojson";
|
import {
|
||||||
import { CojsonInternalTypes } from "cojson";
|
cojsonInternals,
|
||||||
|
SessionID,
|
||||||
|
SyncMessage,
|
||||||
|
Peer,
|
||||||
|
CojsonInternalTypes,
|
||||||
|
MAX_RECOMMENDED_TX_SIZE,
|
||||||
|
} from "cojson";
|
||||||
import {
|
import {
|
||||||
ReadableStream,
|
ReadableStream,
|
||||||
WritableStream,
|
WritableStream,
|
||||||
@@ -19,6 +25,7 @@ type SessionRow = {
|
|||||||
sessionID: SessionID;
|
sessionID: SessionID;
|
||||||
lastIdx: number;
|
lastIdx: number;
|
||||||
lastSignature: CojsonInternalTypes.Signature;
|
lastSignature: CojsonInternalTypes.Signature;
|
||||||
|
bytesSinceLastSignature?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoredSessionRow = SessionRow & { rowID: number };
|
type StoredSessionRow = SessionRow & { rowID: number };
|
||||||
@@ -29,6 +36,12 @@ type TransactionRow = {
|
|||||||
tx: CojsonInternalTypes.Transaction;
|
tx: CojsonInternalTypes.Transaction;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SignatureAfterRow = {
|
||||||
|
ses: number;
|
||||||
|
idx: number;
|
||||||
|
signature: CojsonInternalTypes.Signature;
|
||||||
|
};
|
||||||
|
|
||||||
export class IDBStorage {
|
export class IDBStorage {
|
||||||
db: IDBDatabase;
|
db: IDBDatabase;
|
||||||
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
|
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
|
||||||
@@ -50,7 +63,7 @@ export class IDBStorage {
|
|||||||
done = result.done;
|
done = result.done;
|
||||||
|
|
||||||
if (result.value) {
|
if (result.value) {
|
||||||
this.handleSyncMessage(result.value);
|
await this.handleSyncMessage(result.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -83,42 +96,63 @@ export class IDBStorage {
|
|||||||
toLocalNode: WritableStream<SyncMessage>
|
toLocalNode: WritableStream<SyncMessage>
|
||||||
) {
|
) {
|
||||||
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const request = indexedDB.open("jazz-storage", 1);
|
const request = indexedDB.open("jazz-storage", 4);
|
||||||
request.onerror = () => {
|
request.onerror = () => {
|
||||||
reject(request.error);
|
reject(request.error);
|
||||||
};
|
};
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
resolve(request.result);
|
resolve(request.result);
|
||||||
};
|
};
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = async (ev) => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
|
if (ev.oldVersion === 0) {
|
||||||
|
const coValues = db.createObjectStore("coValues", {
|
||||||
|
autoIncrement: true,
|
||||||
|
keyPath: "rowID",
|
||||||
|
});
|
||||||
|
|
||||||
const coValues = db.createObjectStore("coValues", {
|
coValues.createIndex("coValuesById", "id", {
|
||||||
autoIncrement: true,
|
|
||||||
keyPath: "rowID",
|
|
||||||
});
|
|
||||||
|
|
||||||
coValues.createIndex("coValuesById", "id", {
|
|
||||||
unique: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessions = db.createObjectStore("sessions", {
|
|
||||||
autoIncrement: true,
|
|
||||||
keyPath: "rowID",
|
|
||||||
});
|
|
||||||
|
|
||||||
sessions.createIndex("sessionsByCoValue", "coValue");
|
|
||||||
sessions.createIndex(
|
|
||||||
"uniqueSessions",
|
|
||||||
["coValue", "sessionID"],
|
|
||||||
{
|
|
||||||
unique: true,
|
unique: true,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
db.createObjectStore("transactions", {
|
const sessions = db.createObjectStore("sessions", {
|
||||||
keyPath: ["ses", "idx"],
|
autoIncrement: true,
|
||||||
});
|
keyPath: "rowID",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessions.createIndex("sessionsByCoValue", "coValue");
|
||||||
|
sessions.createIndex(
|
||||||
|
"uniqueSessions",
|
||||||
|
["coValue", "sessionID"],
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.createObjectStore("transactions", {
|
||||||
|
keyPath: ["ses", "idx"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ev.oldVersion <= 1) {
|
||||||
|
db.createObjectStore("signatureAfter", {
|
||||||
|
keyPath: ["ses", "idx"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ev.oldVersion !== 0 && ev.oldVersion <= 3) {
|
||||||
|
// fix embarrassing off-by-one error for transaction indices
|
||||||
|
console.log("Migration: fixing off-by-one error");
|
||||||
|
const transaction = (ev.target as unknown as {transaction: IDBTransaction}).transaction;
|
||||||
|
|
||||||
|
const txsStore = transaction.objectStore("transactions");
|
||||||
|
const txs = await promised(txsStore.getAll());
|
||||||
|
|
||||||
|
for (const tx of txs) {
|
||||||
|
await promised(txsStore.delete([tx.ses, tx.idx]));
|
||||||
|
tx.idx -= 1;
|
||||||
|
await promised(txsStore.add(tx));
|
||||||
|
}
|
||||||
|
console.log("Migration: fixing off-by-one error - done");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,10 +182,12 @@ export class IDBStorage {
|
|||||||
coValues,
|
coValues,
|
||||||
sessions,
|
sessions,
|
||||||
transactions,
|
transactions,
|
||||||
|
signatureAfter,
|
||||||
}: {
|
}: {
|
||||||
coValues: IDBObjectStore;
|
coValues: IDBObjectStore;
|
||||||
sessions: IDBObjectStore;
|
sessions: IDBObjectStore;
|
||||||
transactions: IDBObjectStore;
|
transactions: IDBObjectStore;
|
||||||
|
signatureAfter: IDBObjectStore;
|
||||||
},
|
},
|
||||||
asDependencyOf?: CojsonInternalTypes.RawCoID
|
asDependencyOf?: CojsonInternalTypes.RawCoID
|
||||||
) {
|
) {
|
||||||
@@ -171,12 +207,14 @@ export class IDBStorage {
|
|||||||
sessions: {},
|
sessions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const newContent: CojsonInternalTypes.NewContentMessage = {
|
const newContentPieces: CojsonInternalTypes.NewContentMessage[] = [
|
||||||
action: "content",
|
{
|
||||||
id: theirKnown.id,
|
action: "content",
|
||||||
header: theirKnown.header ? undefined : coValueRow?.header,
|
id: theirKnown.id,
|
||||||
new: {},
|
header: theirKnown.header ? undefined : coValueRow?.header,
|
||||||
};
|
new: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
for (const sessionRow of allOurSessions) {
|
for (const sessionRow of allOurSessions) {
|
||||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||||
@@ -188,6 +226,21 @@ export class IDBStorage {
|
|||||||
const firstNewTxIdx =
|
const firstNewTxIdx =
|
||||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
theirKnown.sessions[sessionRow.sessionID] || 0;
|
||||||
|
|
||||||
|
const signaturesAndIdxs = await promised<SignatureAfterRow[]>(
|
||||||
|
signatureAfter.getAll(
|
||||||
|
IDBKeyRange.bound(
|
||||||
|
[sessionRow.rowID, firstNewTxIdx],
|
||||||
|
[sessionRow.rowID, Infinity]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// console.log(
|
||||||
|
// theirKnown.id,
|
||||||
|
// "signaturesAndIdxs",
|
||||||
|
// JSON.stringify(signaturesAndIdxs)
|
||||||
|
// );
|
||||||
|
|
||||||
const newTxInSession = await promised<TransactionRow[]>(
|
const newTxInSession = await promised<TransactionRow[]>(
|
||||||
transactions.getAll(
|
transactions.getAll(
|
||||||
IDBKeyRange.bound(
|
IDBKeyRange.bound(
|
||||||
@@ -197,44 +250,91 @@ export class IDBStorage {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
newContent.new[sessionRow.sessionID] = {
|
let idx = firstNewTxIdx;
|
||||||
after: firstNewTxIdx,
|
|
||||||
lastSignature: sessionRow.lastSignature,
|
// console.log(
|
||||||
newTransactions: newTxInSession.map((row) => row.tx),
|
// 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(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 =
|
const dependedOnCoValues =
|
||||||
coValueRow?.header.ruleset.type === "team"
|
coValueRow?.header.ruleset.type === "group"
|
||||||
? Object.values(newContent.new).flatMap((sessionEntry) =>
|
? newContentPieces
|
||||||
sessionEntry.newTransactions.flatMap((tx) => {
|
.flatMap((piece) => Object.values(piece.new))
|
||||||
if (tx.privacy !== "trusting") return [];
|
.flatMap((sessionEntry) =>
|
||||||
return tx.changes
|
sessionEntry.newTransactions.flatMap((tx) => {
|
||||||
.map(
|
if (tx.privacy !== "trusting") return [];
|
||||||
(change) =>
|
// TODO: avoid parse here?
|
||||||
change &&
|
return cojsonInternals
|
||||||
typeof change === "object" &&
|
.parseJSON(tx.changes)
|
||||||
"op" in change &&
|
.map(
|
||||||
change.op === "set" &&
|
(change) =>
|
||||||
"key" in change &&
|
change &&
|
||||||
change.key
|
typeof change === "object" &&
|
||||||
)
|
"op" in change &&
|
||||||
.filter(
|
change.op === "set" &&
|
||||||
(key): key is CojsonInternalTypes.RawCoID =>
|
"key" in change &&
|
||||||
typeof key === "string" &&
|
change.key
|
||||||
key.startsWith("co_")
|
)
|
||||||
);
|
.filter(
|
||||||
})
|
(
|
||||||
)
|
key
|
||||||
: coValueRow?.header.ruleset.type === "ownedByTeam"
|
): key is CojsonInternalTypes.RawCoID =>
|
||||||
? [coValueRow?.header.ruleset.team]
|
typeof key === "string" &&
|
||||||
|
key.startsWith("co_")
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: coValueRow?.header.ruleset.type === "ownedByGroup"
|
||||||
|
? [coValueRow?.header.ruleset.group]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
for (const dependedOnCoValue of dependedOnCoValues) {
|
||||||
await this.sendNewContentAfter(
|
await this.sendNewContentAfter(
|
||||||
{ id: dependedOnCoValue, header: false, sessions: {} },
|
{ id: dependedOnCoValue, header: false, sessions: {} },
|
||||||
{ coValues, sessions, transactions },
|
{ coValues, sessions, transactions, signatureAfter },
|
||||||
asDependencyOf || theirKnown.id
|
asDependencyOf || theirKnown.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -245,8 +345,15 @@ export class IDBStorage {
|
|||||||
asDependencyOf,
|
asDependencyOf,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newContent.header || Object.keys(newContent.new).length > 0) {
|
const nonEmptyNewContentPieces = newContentPieces.filter(
|
||||||
await this.toLocalNode.write(newContent);
|
(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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +362,7 @@ export class IDBStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
|
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
|
||||||
const { coValues, sessions, transactions } =
|
const { coValues, sessions, transactions, signatureAfter } =
|
||||||
this.inTransaction("readwrite");
|
this.inTransaction("readwrite");
|
||||||
|
|
||||||
let storedCoValueRowID = (
|
let storedCoValueRowID = (
|
||||||
@@ -326,18 +433,39 @@ export class IDBStorage {
|
|||||||
const actuallyNewOffset =
|
const actuallyNewOffset =
|
||||||
(sessionRow?.lastIdx || 0) -
|
(sessionRow?.lastIdx || 0) -
|
||||||
(msg.new[sessionID]?.after || 0);
|
(msg.new[sessionID]?.after || 0);
|
||||||
|
|
||||||
const actuallyNewTransactions =
|
const actuallyNewTransactions =
|
||||||
newTransactions.slice(actuallyNewOffset);
|
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;
|
let nextIdx = sessionRow?.lastIdx || 0;
|
||||||
|
|
||||||
const sessionUpdate = {
|
const sessionUpdate = {
|
||||||
coValue: storedCoValueRowID,
|
coValue: storedCoValueRowID,
|
||||||
sessionID: sessionID,
|
sessionID: sessionID,
|
||||||
lastIdx:
|
lastIdx: newLastIdx,
|
||||||
(sessionRow?.lastIdx || 0) +
|
|
||||||
actuallyNewTransactions.length,
|
|
||||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
lastSignature: msg.new[sessionID]!.lastSignature,
|
||||||
|
bytesSinceLastSignature: newBytesSinceLastSignature,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionRowID = (await promised(
|
const sessionRowID = (await promised(
|
||||||
@@ -351,8 +479,18 @@ export class IDBStorage {
|
|||||||
)
|
)
|
||||||
)) as number;
|
)) as number;
|
||||||
|
|
||||||
|
if (shouldWriteSignature) {
|
||||||
|
await promised(
|
||||||
|
signatureAfter.put({
|
||||||
|
ses: sessionRowID,
|
||||||
|
// TODO: newLastIdx is a misnomer, it's actually more like nextIdx or length
|
||||||
|
idx: newLastIdx - 1,
|
||||||
|
signature: msg.new[sessionID]!.lastSignature,
|
||||||
|
} satisfies SignatureAfterRow)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const newTransaction of actuallyNewTransactions) {
|
for (const newTransaction of actuallyNewTransactions) {
|
||||||
nextIdx++;
|
|
||||||
await promised(
|
await promised(
|
||||||
transactions.add({
|
transactions.add({
|
||||||
ses: sessionRowID,
|
ses: sessionRowID,
|
||||||
@@ -360,6 +498,7 @@ export class IDBStorage {
|
|||||||
tx: newTransaction,
|
tx: newTransaction,
|
||||||
} satisfies TransactionRow)
|
} satisfies TransactionRow)
|
||||||
);
|
);
|
||||||
|
nextIdx++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,14 +522,15 @@ export class IDBStorage {
|
|||||||
coValues: IDBObjectStore;
|
coValues: IDBObjectStore;
|
||||||
sessions: IDBObjectStore;
|
sessions: IDBObjectStore;
|
||||||
transactions: IDBObjectStore;
|
transactions: IDBObjectStore;
|
||||||
|
signatureAfter: IDBObjectStore;
|
||||||
} {
|
} {
|
||||||
const tx = this.db.transaction(
|
const tx = this.db.transaction(
|
||||||
["coValues", "sessions", "transactions"],
|
["coValues", "sessions", "transactions", "signatureAfter"],
|
||||||
mode
|
mode
|
||||||
);
|
);
|
||||||
|
|
||||||
tx.onerror = (event) => {
|
tx.onerror = (event) => {
|
||||||
const target = event.target as {
|
const target = event.target as unknown as {
|
||||||
error: DOMException;
|
error: DOMException;
|
||||||
source?: { name: string };
|
source?: { name: string };
|
||||||
} | null;
|
} | null;
|
||||||
@@ -402,8 +542,9 @@ export class IDBStorage {
|
|||||||
const coValues = tx.objectStore("coValues");
|
const coValues = tx.objectStore("coValues");
|
||||||
const sessions = tx.objectStore("sessions");
|
const sessions = tx.objectStore("sessions");
|
||||||
const transactions = tx.objectStore("transactions");
|
const transactions = tx.objectStore("transactions");
|
||||||
|
const signatureAfter = tx.objectStore("signatureAfter");
|
||||||
|
|
||||||
return { coValues, sessions, transactions };
|
return { coValues, sessions, transactions, signatureAfter };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
17
packages/cojson-storage-sqlite/.eslintrc.cjs
Normal file
17
packages/cojson-storage-sqlite/.eslintrc.cjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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
Normal file
171
packages/cojson-storage-sqlite/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# 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
|
||||||
2
packages/cojson-storage-sqlite/.npmignore
Normal file
2
packages/cojson-storage-sqlite/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
coverage
|
||||||
|
node_modules
|
||||||
22
packages/cojson-storage-sqlite/package.json
Normal file
22
packages/cojson-storage-sqlite/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
550
packages/cojson-storage-sqlite/src/index.ts
Normal file
550
packages/cojson-storage-sqlite/src/index.ts
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
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) {}
|
||||||
|
}
|
||||||
15
packages/cojson-storage-sqlite/tsconfig.json
Normal file
15
packages/cojson-storage-sqlite/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "ES2020",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
},
|
||||||
|
"include": ["./src/**/*"],
|
||||||
|
}
|
||||||
2767
packages/cojson-storage-sqlite/yarn.lock
Normal file
2767
packages/cojson-storage-sqlite/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ 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,53 +1,3 @@
|
|||||||
# CoJSON
|
# CoJSON
|
||||||
|
|
||||||
CoJSON ("Collaborative JSON") will be a minimal protocol and implementation for collaborative values (CRDTs + public-key cryptography).
|
[See the top-level README](../../README.md#cojson)
|
||||||
|
|
||||||
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": "src/index.ts",
|
"types": "dist/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "0.0.19",
|
"version": "0.3.3",
|
||||||
"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,9 +19,8 @@
|
|||||||
"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",
|
||||||
"fast-json-stable-stringify": "https://github.com/tirithen/fast-json-stable-stringify#7a3dcf2",
|
"hash-wasm": "^4.9.0",
|
||||||
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -51,5 +50,6 @@
|
|||||||
"/node_modules/",
|
"/node_modules/",
|
||||||
"/dist/"
|
"/dist/"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CoValueHeader } from "./coValue.js";
|
import { CoValueHeader } from "./coValueCore.js";
|
||||||
import { CoID } from "./contentType.js";
|
import { CoID } from "./coValue.js";
|
||||||
import {
|
import {
|
||||||
AgentSecret,
|
AgentSecret,
|
||||||
SealerID,
|
SealerID,
|
||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
getAgentSignerSecret,
|
getAgentSignerSecret,
|
||||||
} from "./crypto.js";
|
} from "./crypto.js";
|
||||||
import { AgentID } from "./ids.js";
|
import { AgentID } from "./ids.js";
|
||||||
import { CoMap, LocalNode } from "./index.js";
|
import { CoMap } from "./coValues/coMap.js";
|
||||||
import { Team, TeamContent } from "./permissions.js";
|
import { LocalNode } from "./localNode.js";
|
||||||
|
import { Group, GroupContent } from "./group.js";
|
||||||
|
|
||||||
export function accountHeaderForInitialAgentSecret(
|
export function accountHeaderForInitialAgentSecret(
|
||||||
agentSecret: AgentSecret
|
agentSecret: AgentSecret
|
||||||
@@ -22,7 +23,7 @@ export function accountHeaderForInitialAgentSecret(
|
|||||||
const agent = getAgentID(agentSecret);
|
const agent = getAgentID(agentSecret);
|
||||||
return {
|
return {
|
||||||
type: "comap",
|
type: "comap",
|
||||||
ruleset: { type: "team", initialAdmin: agent },
|
ruleset: { type: "group", initialAdmin: agent },
|
||||||
meta: {
|
meta: {
|
||||||
type: "account",
|
type: "account",
|
||||||
},
|
},
|
||||||
@@ -31,13 +32,13 @@ export function accountHeaderForInitialAgentSecret(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Account extends Team {
|
export class AccountGroup extends Group {
|
||||||
get id(): AccountID {
|
get id(): AccountID {
|
||||||
return this.teamMap.id as AccountID;
|
return this.underlyingMap.id as AccountID;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentAgentID(): AgentID {
|
getCurrentAgentID(): AgentID {
|
||||||
const agents = this.teamMap
|
const agents = this.underlyingMap
|
||||||
.keys()
|
.keys()
|
||||||
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export class Account extends Team {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneralizedControlledAccount {
|
export interface GeneralizedControlledAccount {
|
||||||
id: AccountIDOrAgentID;
|
id: AccountID | AgentID;
|
||||||
agentSecret: AgentSecret;
|
agentSecret: AgentSecret;
|
||||||
|
|
||||||
currentAgentID: () => AgentID;
|
currentAgentID: () => AgentID;
|
||||||
@@ -62,18 +63,19 @@ export interface GeneralizedControlledAccount {
|
|||||||
currentSealerSecret: () => SealerSecret;
|
currentSealerSecret: () => SealerSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @hidden */
|
||||||
export class ControlledAccount
|
export class ControlledAccount
|
||||||
extends Account
|
extends AccountGroup
|
||||||
implements GeneralizedControlledAccount
|
implements GeneralizedControlledAccount
|
||||||
{
|
{
|
||||||
agentSecret: AgentSecret;
|
agentSecret: AgentSecret;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
agentSecret: AgentSecret,
|
agentSecret: AgentSecret,
|
||||||
teamMap: CoMap<AccountContent, AccountMeta>,
|
groupMap: CoMap<AccountContent, AccountMeta>,
|
||||||
node: LocalNode
|
node: LocalNode
|
||||||
) {
|
) {
|
||||||
super(teamMap, node);
|
super(groupMap, node);
|
||||||
|
|
||||||
this.agentSecret = agentSecret;
|
this.agentSecret = agentSecret;
|
||||||
}
|
}
|
||||||
@@ -99,6 +101,7 @@ export class ControlledAccount
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @hidden */
|
||||||
export class AnonymousControlledAccount
|
export class AnonymousControlledAccount
|
||||||
implements GeneralizedControlledAccount
|
implements GeneralizedControlledAccount
|
||||||
{
|
{
|
||||||
@@ -133,15 +136,12 @@ export class AnonymousControlledAccount
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountContent = TeamContent & { profile: CoID<Profile> };
|
export type AccountContent = { profile: Profile } & GroupContent;
|
||||||
export type AccountMeta = { type: "account" };
|
export type AccountMeta = { type: "account" };
|
||||||
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
|
export type Account = CoMap<AccountContent, AccountMeta>;
|
||||||
|
export type AccountID = CoID<Account>;
|
||||||
|
|
||||||
export type AccountIDOrAgentID = AgentID | AccountID;
|
export function isAccountID(id: AccountID | AgentID): id is 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_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
packages/cojson/src/base64url.test.ts
Normal file
32
packages/cojson/src/base64url.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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=");
|
||||||
|
});
|
||||||
68
packages/cojson/src/base64url.ts
Normal file
68
packages/cojson/src/base64url.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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,547 +1,95 @@
|
|||||||
import { randomBytes } from "@noble/hashes/utils";
|
|
||||||
import { ContentType } from "./contentType.js";
|
|
||||||
import { Static } from "./contentTypes/static.js";
|
|
||||||
import { CoStream } from "./contentTypes/coStream.js";
|
|
||||||
import { CoMap } from "./contentTypes/coMap.js";
|
|
||||||
import {
|
|
||||||
Encrypted,
|
|
||||||
Hash,
|
|
||||||
KeySecret,
|
|
||||||
Signature,
|
|
||||||
StreamingHash,
|
|
||||||
unseal,
|
|
||||||
shortHash,
|
|
||||||
sign,
|
|
||||||
verify,
|
|
||||||
encryptForTransaction,
|
|
||||||
decryptForTransaction,
|
|
||||||
KeyID,
|
|
||||||
decryptKeySecret,
|
|
||||||
getAgentSignerID,
|
|
||||||
getAgentSealerID,
|
|
||||||
} from "./crypto.js";
|
|
||||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||||
import { base58 } from "@scure/base";
|
import { RawCoID } from "./ids.js";
|
||||||
|
import { CoMap } from "./coValues/coMap.js";
|
||||||
import {
|
import {
|
||||||
PermissionsDef as RulesetDef,
|
BinaryCoStream,
|
||||||
Team,
|
BinaryCoStreamMeta,
|
||||||
determineValidTransactions,
|
CoStream,
|
||||||
expectTeamContent,
|
} from "./coValues/coStream.js";
|
||||||
isKeyForKeyField,
|
import { CoList } from "./coValues/coList.js";
|
||||||
} from "./permissions.js";
|
import { CoValueCore } from "./coValueCore.js";
|
||||||
import { LocalNode } from "./node.js";
|
import { Group } from "./group.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 CoValueHeader = {
|
export type CoID<T extends CoValue> = RawCoID & {
|
||||||
type: ContentType["type"];
|
readonly __type: T;
|
||||||
ruleset: RulesetDef;
|
};
|
||||||
|
|
||||||
|
export interface CoValue {
|
||||||
|
/** The `CoValue`'s (precisely typed) `CoID` */
|
||||||
|
id: CoID<this>;
|
||||||
|
core: CoValueCore;
|
||||||
|
/** Specifies which kind of `CoValue` this is */
|
||||||
|
type: string;
|
||||||
|
/** The `CoValue`'s (precisely typed) static metadata */
|
||||||
meta: JsonObject | null;
|
meta: JsonObject | null;
|
||||||
createdAt: `2${string}` | null;
|
/** The `Group` this `CoValue` belongs to (determining permissions) */
|
||||||
uniqueness: `z${string}` | null;
|
group: Group;
|
||||||
};
|
/** Returns an immutable JSON presentation of this `CoValue` */
|
||||||
|
toJSON(): JsonValue;
|
||||||
export function idforHeader(header: CoValueHeader): RawCoID {
|
atTime(time: number): this;
|
||||||
const hash = shortHash(header);
|
/** Lets you subscribe to future updates to this CoValue (whether made locally or by other users).
|
||||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
*
|
||||||
|
* 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 function accountOrAgentIDfromSessionID(
|
export type AnyCoMap = CoMap<
|
||||||
sessionID: SessionID
|
{ [key: string]: JsonValue | CoValue | undefined },
|
||||||
): AccountIDOrAgentID {
|
JsonObject | null
|
||||||
return sessionID.split("_session")[0] as AccountIDOrAgentID;
|
>;
|
||||||
|
|
||||||
|
export type AnyCoList = CoList<JsonValue | CoValue, JsonObject | null>;
|
||||||
|
|
||||||
|
export type AnyCoStream = CoStream<JsonValue | CoValue, JsonObject | null>;
|
||||||
|
|
||||||
|
export type AnyBinaryCoStream = BinaryCoStream<BinaryCoStreamMeta>;
|
||||||
|
|
||||||
|
|
||||||
|
export type AnyCoValue =
|
||||||
|
| AnyCoMap
|
||||||
|
| AnyCoList
|
||||||
|
| AnyCoStream
|
||||||
|
| AnyBinaryCoStream
|
||||||
|
|
||||||
|
export function expectMap(
|
||||||
|
content: CoValue
|
||||||
|
): AnyCoMap {
|
||||||
|
if (content.type !== "comap") {
|
||||||
|
throw new Error("Expected map");
|
||||||
|
}
|
||||||
|
|
||||||
|
return content as AnyCoMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
|
export function expectList(
|
||||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
content: CoValue
|
||||||
|
): AnyCoList {
|
||||||
|
if (content.type !== "colist") {
|
||||||
|
throw new Error("Expected list");
|
||||||
|
}
|
||||||
|
|
||||||
|
return content as AnyCoList;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionLog = {
|
export function expectStream(
|
||||||
transactions: Transaction[];
|
content: CoValue
|
||||||
lastHash?: Hash;
|
): AnyCoStream {
|
||||||
streamingHash: StreamingHash;
|
if (content.type !== "costream") {
|
||||||
lastSignature: Signature;
|
throw new Error("Expected stream");
|
||||||
};
|
|
||||||
|
|
||||||
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 Transaction = PrivateTransaction | TrustingTransaction;
|
|
||||||
|
|
||||||
export type DecryptedTransaction = {
|
|
||||||
txID: TransactionID;
|
|
||||||
changes: JsonValue[];
|
|
||||||
madeAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
testWithDifferentAccount(
|
return content as AnyCoStream;
|
||||||
account: GeneralizedControlledAccount,
|
}
|
||||||
ownSessionID: SessionID
|
|
||||||
): CoValue {
|
export function isCoValue(value: JsonValue | CoValue | undefined) : value is CoValue {
|
||||||
const newNode = this.node.testWithDifferentAccount(
|
return (
|
||||||
account,
|
value instanceof CoMap ||
|
||||||
ownSessionID
|
value instanceof CoList ||
|
||||||
);
|
value instanceof CoStream ||
|
||||||
|
value instanceof BinaryCoStream
|
||||||
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.ownSessionID;
|
|
||||||
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 { 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]
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
818
packages/cojson/src/coValueCore.ts
Normal file
818
packages/cojson/src/coValueCore.ts
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
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]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user