Compare commits

..

118 Commits

Author SHA1 Message Date
Anselm Eickhoff
be7e208b1c Merge pull request #333 from gardencmp/anselm-jazz-265
Make sure errors in storage peer completely crash jazz context instead of failing silently
2024-08-20 14:09:19 +01:00
Anselm
c3bffbf4de Release 2024-08-20 14:02:53 +01:00
Anselm
d46467f318 Hotfix release 2024-08-20 12:58:09 +01:00
Anselm
6d21400803 Get rid of simulated errors 2024-08-20 12:57:50 +01:00
Anselm
db53161296 Hotfix release 2024-08-20 12:46:15 +01:00
Anselm
cb4a116cec Make simulated errors even more likely 2024-08-20 12:45:34 +01:00
Anselm
013199b9b2 Increase chance of simulated storage bug 2024-08-20 11:43:28 +01:00
Anselm
a8b74ff703 Hotfix release 2024-08-20 11:43:20 +01:00
Anselm
b1985a9161 Throw properly on peer that should crash on close 2024-08-20 11:42:53 +01:00
Anselm
3bf512719f Hotfix release 2024-08-20 11:22:44 +01:00
Anselm
d83ed69d41 Allow crashing whole local node on peer errors 2024-08-20 11:20:20 +01:00
Anselm
fdde8db664 Hotfix release 2024-08-19 17:19:40 +01:00
Anselm
dd5581ba2d Handle ws closing while buffering 2024-08-19 17:19:01 +01:00
Anselm
07fe2b9dcf Release 2024-08-19 16:45:35 +01:00
Anselm
b297c93b80 Release fixes 2024-08-19 15:08:08 +01:00
Anselm
d2e62e5b44 Reduce log level on loading message 2024-08-19 14:50:26 +01:00
Anselm
fe73ce7514 Make failed transaction log message leaner 2024-08-19 14:48:57 +01:00
Anselm Eickhoff
0fed16cea4 Merge pull request #323 from gardencmp/callumflack-jazz-240
fix: chat example URL
2024-08-19 11:15:39 +01:00
Callum Flack
08804ad435 fix: chat example URL 2024-08-19 11:03:33 +10:00
Anselm
79fa7724ad Release 2024-08-15 19:35:11 +01:00
Anselm
4604c2184a Adapt type of applyDiff to make CoMaps fully subclassable again 2024-08-15 19:34:47 +01:00
Anselm
11bac697fb Release 2024-08-15 19:23:19 +01:00
Anselm
96cec27f89 Close both ends of the peer on gracefulShutdown 2024-08-15 19:22:59 +01:00
Anselm
bcd412b8f9 Properly close connecting websockets 2024-08-15 19:22:19 +01:00
Anselm
6b456e2841 Properly close and delete peer on incoming disconnect/timeout 2024-08-15 19:10:22 +01:00
Anselm
1df72b3dc8 Reintroduce nomad example deploy, but only on main 2024-08-15 19:08:42 +01:00
Anselm
402d692739 Don't deploy examples to Nomad anymore 2024-08-15 17:02:40 +01:00
Anselm
fad46b2fb5 Release 2024-08-15 16:37:47 +01:00
Anselm
0153c80cf2 Immediately resolve an already-open websocket 2024-08-15 16:37:23 +01:00
Anselm
4f75dc8d97 Release 2024-08-15 16:17:31 +01:00
Anselm
e2c79cccb5 Remove noisy log 2024-08-15 16:17:03 +01:00
Anselm
c14a0e05be Release 2024-08-15 15:30:22 +01:00
Anselm
016dd3a5dd Fix ignoring server peers 2024-08-15 15:30:00 +01:00
Anselm
5c4ca9103c Release 2024-08-15 13:36:33 +01:00
Anselm
b4aad92907 Option to not expect pings 2024-08-15 13:36:08 +01:00
Anselm
56d1e095a1 Release 2024-08-15 13:02:39 +01:00
Anselm Eickhoff
6dee9aae49 Merge pull request #281 from gardencmp/anselm-jazz-190
Remove effect from jazz
2024-08-15 11:51:53 +01:00
Anselm Eickhoff
a10bff981e Merge pull request #284 from gardencmp/anselm-jazz-227
Remove effect from peer communication
2024-08-15 11:51:10 +01:00
Anselm Eickhoff
e333f7884a Merge pull request #313 from gardencmp/anselm-jazz-246
Remove effect from jazz-tools and dependent packages
2024-08-15 11:50:44 +01:00
Anselm
8ea7bf237b Remove effect from storage implementation 2024-08-15 11:13:10 +01:00
Anselm
5e8409fa08 Remove rest of effect use 2024-08-14 17:51:10 +01:00
Anselm
23354c1767 Progress on removing effect 2024-08-14 15:24:20 +01:00
Anselm Eickhoff
0efb69d0db Merge pull request #312 from pax-k/JAZZ-252/make-sure-castas-preserves-subscriptionscope
fix: preserve subscriptionScope for castAs in CoList and CoMap
2024-08-14 14:51:54 +01:00
pax-k
0462c4e41b fix: preserve subscriptionScope for castAs in CoList and CoMap 2024-08-14 16:50:12 +03:00
Anselm
70a5673197 More progress 2024-08-13 12:25:15 +01:00
Anselm Eickhoff
9ec3203485 Merge pull request #285 from gdorsi/main
docs: fix the get started links position
2024-08-12 17:46:41 +01:00
Guido D'Orsi
1a46f9b2e1 docs: fix the get started links position 2024-08-10 16:17:18 +02:00
Anselm
77bb26a8d7 Only use queable in cojson, rework tests 2024-08-09 16:44:50 +01:00
Anselm
2a36dcf592 WIP switch to queueable 2024-08-09 13:59:26 +01:00
Anselm
fc2bcadbe2 Remove effect schema from jazz schema 2024-08-08 18:18:17 +01:00
Anselm
46b0cc1adb Release 2024-08-08 14:44:00 +01:00
Anselm Eickhoff
d75d1c6a3f Merge pull request #279 from pax-k/JAZZ-219/implement-applydiff-on-comap-to-only-update-changed-fields
feat: Implement applyDiff on CoMap to only update changed fields
2024-08-08 13:50:17 +01:00
pax-k
13b236aeed feat: Implement applyDiff on CoMap to only update changed fields 2024-08-08 11:03:08 +03:00
Anselm Eickhoff
1c0a61b0b2 Merge pull request #271 from pax-k/document-max-recommended-tx-size
chore: document MAX_RECOMMENDED_TX_SIZE
2024-08-07 16:32:07 +01:00
Anselm
ceb92438f4 Release 2024-08-07 14:23:03 +01:00
Anselm Eickhoff
9bdd62ed4c Merge pull request #244 from gardencmp/anselm-jazz-187
Remove effectful API for loading/subscribing
2024-08-07 14:20:42 +01:00
pax-k
3f5ef7e799 chore: formatting 2024-08-06 19:35:14 +03:00
pax-k
e7a573fa94 chore: document MAX_RECOMMENDED_TX_SIZE 2024-08-06 19:22:41 +03:00
Anselm Eickhoff
364060eaa7 Merge pull request #249 from Schniz/schniz/allow-optional-encoder-to-be-empty-upon-creation 2024-08-03 19:59:09 +01:00
Gal Schlezinger
a3ddc3d5e0 allow co.optional.encoded to be empty on creation 2024-08-03 21:53:13 +03:00
Anselm Eickhoff
185f747adb Merge pull request #248 from timolins/new-explorer-ux 2024-08-03 13:51:47 +01:00
Timo Lins
895d281088 Remove legacy inspector to resolve TS errors 2024-08-03 13:09:27 +02:00
Timo Lins
b44e4354f7 Merge branch 'new-explorer-ux' of https://github.com/timolins/jazz into new-explorer-ux 2024-08-03 12:59:18 +02:00
Timo Lins
3fcb0665ec Fix TS errors 2024-08-03 12:59:14 +02:00
Tobias Lins
be49d33ce5 Update co-stream-view.tsx 2024-08-03 12:24:30 +02:00
Timo Lins
c7dae1608b Add new entry point 2024-08-03 10:49:29 +02:00
Timo Lins
b020c5868b Move app to legacy app 2024-08-03 10:48:06 +02:00
Timo Lins
eae42d3afe Revert app.tsx changes 2024-08-03 10:47:51 +02:00
Anselm
a816e2436e Remove effectful API for loading/subscribing 2024-07-31 16:21:26 +01:00
Anselm
b09e35e372 release 2024-07-29 10:40:10 +01:00
Anselm
d2c8121c9c Fix storage option in jazz-react 2024-07-29 10:34:39 +01:00
Anselm
380bb88ffa Mostly complete OPFS implementation (single-tab only) 2024-07-29 10:33:18 +01:00
Timo Lins
e0e3726b3c Update title 2024-07-28 18:02:06 +02:00
Timo Lins
c2253a7979 Improve styling of blob page 2024-07-28 17:40:23 +02:00
Timo Lins
9d244226ec Redesign root page, add group support & costream pages
Co-authored-by: Tobias Lins <me@tobi.sh>
2024-07-28 17:26:34 +02:00
Timo Lins
71df5e3a59 Fix native browser controls 2024-07-28 14:53:26 +02:00
Timo Lins
3a738dad88 Persists path in url 2024-07-28 13:53:39 +02:00
Timo Lins
56d301cfde Refactor the new viewer 2024-07-27 23:16:37 +02:00
Timo Lins
5efec6d5ea Move to a table view 2024-07-27 20:58:55 +02:00
Timo Lins
32769b24f1 Add new inspector 2024-07-27 20:10:24 +02:00
Anselm
6ab53c263d Release 2024-07-26 17:23:02 +01:00
Anselm
e7f3e4e242 Increase disconnect timeout for now 2024-07-26 17:21:06 +01:00
Anselm Eickhoff
8bb5201647 Merge pull request #236 from timolins/patch-1
[Homepage] Make current year dynamic in footer
2024-07-22 15:55:16 +01:00
Timo Lins
a9fc94f53d Make current year dynamic in footer 2024-07-22 10:06:48 +02:00
Anselm Eickhoff
ca7c0510d1 Merge pull request #234 from Schniz/schniz/co-optional 2024-07-21 10:14:41 +01:00
Gal Schlezinger
1bf16f0859 add co.optional syntax 2024-07-21 09:10:44 +03:00
Anselm Eickhoff
21b503c188 Merge pull request #224 from datner/datner/give-it-a-try
cojson-transport-ws: reuse runtime and use fibers instead of setTimeout
2024-07-15 14:35:18 +01:00
Anselm
0053e9796c Release 2024-07-15 11:01:31 +01:00
Anselm
e84941b1b1 Fix another bug in CoMap 'has' proxy trap 2024-07-15 10:59:14 +01:00
Anselm
57f6f8d67e Release 2024-07-14 17:55:47 +01:00
Anselm
5b8e69d973 Fix bug in CoMap 'has' trap 2024-07-14 17:55:05 +01:00
Anselm
7213b1bfa3 Release 2024-07-13 13:32:50 +01:00
Anselm Eickhoff
11f0770f08 Merge pull request #230 from tobiaslins/add-support-for-in
Add support for property existence
2024-07-13 13:30:15 +01:00
Tobias Lins
44e6dc3ae8 Remove useless check 2024-07-13 13:57:37 +02:00
Tobias Lins
b5d20d2488 Better implementation 2024-07-13 13:26:28 +02:00
Tobias Lins
0185545838 Add support for property existence 2024-07-13 12:26:10 +02:00
Yuval Datner
8c8f85859c style: prettier 2024-07-12 15:42:52 +03:00
Yuval Datner
104384409e refactor: change to yieldable error 2024-07-12 15:42:27 +03:00
Yuval Datner
179827ae56 small refactor for readability 2024-07-12 15:13:25 +03:00
Yuval Datner
6645829876 do stream stuff 2024-07-12 15:13:23 +03:00
Gal Schlezinger
68cb302722 store jazzPings on global 2024-07-12 15:13:22 +03:00
Gal Schlezinger
8dc33f2790 fix bugs because I misindented things 2024-07-12 15:13:21 +03:00
Gal Schlezinger
5f64ba326c jazzPings 2024-07-12 15:13:20 +03:00
Gal Schlezinger
7ccb15107c cojson-transport-ws: reuse runtime and use fibers instead of setTimeout
when calling Effect.runFork we don't propagate layers
and using fibers can allow us to interrupt ongoing requests
when the pings fail
2024-07-12 15:13:16 +03:00
Anselm
b102964743 Get rid of husky for now 2024-07-12 11:56:54 +01:00
Anselm
216d50a09c Remove old homepage build 2024-07-12 11:55:44 +01:00
Anselm
07ea59fdcb Release 2024-07-12 11:14:15 +01:00
Anselm
932a84a47f Update to Effect 3.5.2 2024-07-12 11:13:41 +01:00
Anselm
34dda7bdbd Release 2024-07-05 11:18:53 +01:00
Anselm
49fa153581 Merge branch 'fix-fs' 2024-07-05 11:17:54 +01:00
Anselm
c80b827775 Release 2024-06-30 16:28:53 +01:00
Anselm Eickhoff
a2bf9f988a Merge pull request #218 from Schniz/fix-mutating-nullable-field 2024-06-30 08:31:57 +01:00
Gal Schlezinger
ac27b2d5c2 jazz-tools: allow to mutate nullable fields into null
when having a co.encoded(Schema.NullOr(Schema.String)), construction
with null works well, but mutating a value into null throws.
This commit fixes it and adds a test that verifies it actually works.
2024-06-30 09:49:15 +03:00
Anselm
c813518fdc Release 2024-06-28 16:27:15 +01:00
Anselm
d5034ed5c3 Provide current res in ProgressiveImg 2024-06-28 16:26:03 +01:00
Anselm
cf2c29a365 Use verce lanalytics & speed insights 2024-06-28 14:54:58 +01:00
Anselm
d948823db6 Add package manager to package.json 2024-06-28 12:08:23 +01:00
Anselm
060ad4630d Resurrect inspector 2024-06-27 16:54:23 +01:00
108 changed files with 9752 additions and 5508 deletions

View File

@@ -3,15 +3,13 @@ name: Build and Deploy
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build-examples:
runs-on: ubuntu-latest
strategy:
matrix:
example: ["chat", "pets", "todo"]
example: ["chat", "pets", "todo", "inspector"]
# example: ["twit", "chat", "counter-js-auth0", "pets", "twit", "file-drop", "inspector"]
steps:
@@ -53,59 +51,12 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
build-homepage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 16
cache: 'pnpm'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: gardencmp
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pnpm Install (root)
run: |
pnpm install
working-directory: .
- name: Pnpm Install & Build (homepage)
run: |
pnpm install
pnpm build;
working-directory: ./homepage/homepage
- name: Docker Build & Push
uses: docker/build-push-action@v4
with:
context: ./homepage
push: true
tags: ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{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-examples:
runs-on: ubuntu-latest
needs: build-examples
strategy:
matrix:
example: ["chat", "pets", "todo"]
example: ["chat", "pets", "todo", "inspector"]
# example: ["twit", "chat", "counter-js-auth0", "pets", "twit", "file-drop", "inspector"]
steps:
@@ -135,37 +86,4 @@ jobs:
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR=${{ secrets.NOMAD_ADDR }} nomad job run job-instance.nomad;
working-directory: ./examples/${{ matrix.example }}
deploy-homepage:
runs-on: ubuntu-latest
needs: build-homepage
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: gacts/install-nomad@v1
- name: Tailscale
uses: tailscale/github-action@v1
with:
authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
- name: Deploy on Nomad
run: |
if [ "${{github.ref_name}}" == "main" ]; then
export BRANCH_SUFFIX="";
export BRANCH_SUBDOMAIN="";
else
export BRANCH_SUFFIX=-${{github.head_ref || github.ref_name}};
export BRANCH_SUBDOMAIN=${{github.head_ref || github.ref_name}}.;
fi
export DOCKER_USER=gardencmp;
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR=${{ secrets.NOMAD_ADDR }} nomad job run job-instance.nomad;
working-directory: ./homepage
working-directory: ./examples/${{ matrix.example }}

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

View File

@@ -1,5 +1,209 @@
# jazz-example-chat
## 0.0.80
### Patch Changes
- Updated dependencies [b297c93]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson@0.7.33
- jazz-react@0.7.33
- jazz-tools@0.7.33
## 0.0.80-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- jazz-react@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.0.80-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- jazz-react@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.0.80-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.3
- jazz-react@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.0.80-hotfixes.2
### Patch Changes
- jazz-react@0.7.33-hotfixes.2
## 0.0.80-hotfixes.1
### Patch Changes
- jazz-react@0.7.33-hotfixes.1
## 0.0.80-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- jazz-react@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
## 0.0.79
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
- jazz-react@0.7.32
## 0.0.78
### Patch Changes
- Updated dependencies
- cojson@0.7.31
- jazz-react@0.7.31
- jazz-tools@0.7.31
## 0.0.77
### Patch Changes
- jazz-react@0.7.30
## 0.0.76
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- jazz-react@0.7.29
- jazz-tools@0.7.29
## 0.0.75
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- jazz-react@0.7.28
- jazz-tools@0.7.28
## 0.0.74
### Patch Changes
- jazz-react@0.7.27
## 0.0.73
### Patch Changes
- Updated dependencies
- cojson@0.7.26
- jazz-react@0.7.26
- jazz-tools@0.7.26
## 0.0.72
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
- jazz-react@0.7.25
## 0.0.71
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-react@0.7.24
## 0.0.70
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-react@0.7.23
- jazz-tools@0.7.23
## 0.0.69
### Patch Changes
- jazz-react@0.7.22
## 0.0.68
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-react@0.7.21
## 0.0.67
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-react@0.7.20
## 0.0.66
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-react@0.7.19
## 0.0.65
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- jazz-react@0.7.18
- jazz-tools@0.7.18
## 0.0.64
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- jazz-react@0.7.17
- jazz-tools@0.7.17
## 0.0.63
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-react@0.7.16
## 0.0.62
### Patch Changes
- Updated dependencies
- jazz-react@0.7.15
## 0.0.61
### Patch Changes

View File

@@ -1,12 +1,13 @@
# Jazz Chat Example
Live version: https://example-chat.jazz.tools
Live version: [https://chat.jazz.tools](https://chat.jazz.tools)
## Installing & running the example locally
(this requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
Start by checking out `jazz`
```bash
git clone https://github.com/gardencmp/jazz.git
cd jazz/examples/chat
@@ -34,7 +35,6 @@ pnpm dev
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.

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-chat",
"private": true,
"version": "0.0.61",
"version": "0.0.80",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -3,6 +3,7 @@ import { createJazzReactContext, DemoAuth } from "jazz-react";
import { createRoot } from "react-dom/client";
import { useIframeHashRouter } from "hash-slash";
import { ChatScreen } from "./chatScreen.tsx";
import { StrictMode } from "react";
export class Message extends CoMap {
text = co.string;
@@ -39,4 +40,4 @@ function App() {
}
createRoot(document.getElementById("root")!)
.render(<Jazz.Provider><App/></Jazz.Provider>);
.render(<StrictMode><Jazz.Provider><App/></Jazz.Provider></StrictMode>);

View File

@@ -0,0 +1,155 @@
# jazz-example-chat
## 0.0.58
### Patch Changes
- Updated dependencies [fdde8db]
- Updated dependencies [b297c93]
- Updated dependencies [07fe2b9]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson-transport-ws@0.7.33
- cojson@0.7.33
## 0.0.58-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- cojson-transport-ws@0.7.33-hotfixes.5
## 0.0.58-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- cojson-transport-ws@0.7.33-hotfixes.4
## 0.0.58-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.3
- cojson@0.7.33-hotfixes.3
## 0.0.58-hotfixes.2
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.2
## 0.0.58-hotfixes.1
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.1
## 0.0.58-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- cojson-transport-ws@0.7.33-hotfixes.0
## 0.0.57
### Patch Changes
- Updated dependencies
- Updated dependencies
- cojson-transport-ws@0.7.31
- cojson@0.7.31
## 0.0.56
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.30
## 0.0.55
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- cojson-transport-ws@0.7.29
## 0.0.54
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- cojson-transport-ws@0.7.28
## 0.0.53
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.27
## 0.0.52
### Patch Changes
- Updated dependencies
- cojson@0.7.26
- cojson-transport-ws@0.7.26
## 0.0.51
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- cojson-transport-ws@0.7.23
## 0.0.50
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.0.49
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-transport-ws@0.7.18
## 0.0.48
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-transport-ws@0.7.17
## 0.0.47
### Patch Changes
- Updated dependencies
- cojson@0.6.7
- jazz-react@0.5.5
- jazz-react-auth-local@0.4.18
## 0.0.46
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<link rel="stylesheet" href="/src/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jazz Inspector</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "inspector",
"name": "jazz-inspector",
"private": true,
"version": "0.0.47",
"version": "0.0.58",
"type": "module",
"scripts": {
"dev": "vite",
@@ -17,9 +17,8 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"hash-slash": "workspace:*",
"jazz-react": "workspace:*",
"jazz-react-auth-local": "workspace:*",
"cojson": "workspace:*",
"cojson-transport-ws": "workspace:*",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,4 @@
import ReactDOM from "react-dom/client";
import App from "./viewer/new-app";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,92 @@
@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;
margin: 0;
padding: 0;
}
}
.animate-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateZ(400px) translateY(30px) scale(1.05);
opacity: 0.4;
}
to {
transform: translateZ(0) scale(1);
opacity: 1;
}
}

View File

@@ -0,0 +1,18 @@
export function LinkIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
</svg>
);
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import { PageInfo } from "./types";
interface BreadcrumbsProps {
path: PageInfo[];
onBreadcrumbClick: (index: number) => void;
}
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
path,
onBreadcrumbClick,
}) => {
return (
<div className="z-20 relative bg-indigo-400/10 backdrop-blur-sm rounded-lg inline-flex px-2 py-1 whitespace-pre transition-all items-center space-x-1 min-h-10">
<button
onClick={() => onBreadcrumbClick(-1)}
className="flex items-center justify-center p-1 rounded-sm hover:bg-indigo-500/10 transition-colors"
aria-label="Go to home"
>
<img src="jazz-logo.png" alt="Jazz Logo" className="size-5" />
</button>
{path.map((page, index) => {
return (
<span
key={index}
className="inline-block first:pl-1 last:pr-1"
>
{index === 0 ? null : (
<span className="text-indigo-500/30">{" / "}</span>
)}
<button
onClick={() => onBreadcrumbClick(index)}
className="text-indigo-700 hover:underline"
>
{index === 0 ? page.name || "Root" : page.name}
</button>
</span>
);
})}
</div>
);
};

View File

@@ -0,0 +1,353 @@
import {
CoID,
LocalNode,
RawBinaryCoStream,
RawCoStream,
RawCoValue,
} from "cojson";
import { JsonObject, JsonValue } from "cojson/src/jsonValue";
import { PageInfo } from "./types";
import { base64URLtoBytes } from "cojson/src/base64url";
import { useEffect, useState } from "react";
import { ArrowDownToLine } from "lucide-react";
import {
BinaryStreamItem,
BinaryStreamStart,
CoStreamItem,
} from "cojson/src/coValues/coStream";
import { AccountOrGroupPreview } from "./value-renderer";
// typeguard for BinaryStreamStart
function isBinaryStreamStart(item: unknown): item is BinaryStreamStart {
return (
typeof item === "object" &&
item !== null &&
"type" in item &&
item.type === "start"
);
}
function detectCoStreamType(value: RawCoStream | RawBinaryCoStream) {
const firstKey = Object.keys(value.items)[0];
if (!firstKey)
return {
type: "unknown",
};
const items = value.items[firstKey as never]?.map((v) => v.value);
if (!items)
return {
type: "unknown",
};
const firstItem = items[0];
if (!firstItem)
return {
type: "unknown",
};
// This is a binary stream
if (isBinaryStreamStart(firstItem)) {
return {
type: "binary",
items: items as BinaryStreamItem[],
};
} else {
return {
type: "coStream",
};
}
}
async function getBlobFromCoStream({
items,
onlyFirstChunk = false,
}: {
items: BinaryStreamItem[];
onlyFirstChunk?: boolean;
}) {
if (onlyFirstChunk && items.length > 1) {
items = items.slice(0, 2);
}
const chunks: Uint8Array[] = [];
const binary_U_prefixLength = 8;
let lastProgressUpdate = Date.now();
for (const item of items.slice(1)) {
if (item.type === "end") {
break;
}
if (item.type !== "chunk") {
console.error("Invalid binary stream chunk", item);
return undefined;
}
const chunk = base64URLtoBytes(item.chunk.slice(binary_U_prefixLength));
// totalLength += chunk.length;
chunks.push(chunk);
if (Date.now() - lastProgressUpdate > 100) {
lastProgressUpdate = Date.now();
}
}
const defaultMime = "mimeType" in items[0] ? items[0].mimeType : null;
const blob = new Blob(chunks, defaultMime ? { type: defaultMime } : {});
const mimeType =
defaultMime === "" ? await detectPDFMimeType(blob) : defaultMime;
return {
blob,
mimeType: mimeType as string,
unfinishedChunks: items.length > 1,
totalSize:
"totalSizeBytes" in items[0]
? (items[0].totalSizeBytes as number)
: undefined,
};
}
const detectPDFMimeType = async (blob: Blob): Promise<string> => {
const arrayBuffer = await blob.slice(0, 4).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const header = uint8Array.reduce(
(acc, byte) => acc + String.fromCharCode(byte),
"",
);
if (header === "%PDF") {
return "application/pdf";
}
return "application/octet-stream";
};
const BinaryDownloadButton = ({
pdfBlob,
fileName = "document",
label,
mimeType,
}: {
pdfBlob: Blob;
mimeType?: string;
fileName?: string;
label: string;
}) => {
const downloadFile = () => {
const url = URL.createObjectURL(
new Blob([pdfBlob], mimeType ? { type: mimeType } : {}),
);
const link = document.createElement("a");
link.href = url;
link.download =
mimeType === "application/pdf" ? `${fileName}.pdf` : fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<button
className="flex items-center gap-2 px-2 py-1 text-gray-900 border border-gray-900/10 bg-clip-border shadow-sm transition-colors rounded bg-gray-50 text-sm"
onClick={downloadFile}
>
<ArrowDownToLine size={16} />
{label}
{/* Download {mimeType === "application/pdf" ? "PDF" : "File"} */}
</button>
);
};
const LabelContentPair = ({
label,
content,
}: {
label: string;
content: React.ReactNode;
}) => {
return (
<div className="flex flex-col gap-1.5 ">
<span className="uppercase text-xs font-medium text-gray-600 tracking-wide">
{label}
</span>
<span>{content}</span>
</div>
);
};
function RenderCoBinaryStream({
value,
items,
}: {
items: BinaryStreamItem[];
value: RawBinaryCoStream;
}) {
const [file, setFile] = useState<
| {
blob: Blob;
mimeType: string;
unfinishedChunks: boolean;
totalSize: number | undefined;
}
| undefined
| null
>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// load only the first chunk to get the mime type and size
getBlobFromCoStream({
items,
onlyFirstChunk: true,
})
.then((v) => {
if (v) {
setFile(v);
if (v.mimeType.includes("image")) {
// If it's an image, load the full blob
getBlobFromCoStream({
items,
}).then((s) => {
if (s) setFile(s);
});
}
}
})
.finally(() => setIsLoading(false));
}, [items]);
if (!isLoading && !file) return <div>No blob</div>;
if (isLoading) return <div>Loading...</div>;
if (!file) return <div>No blob</div>;
const { blob, mimeType } = file;
const sizeInKB = (file.totalSize || 0) / 1024;
return (
<div className="space-y-8 mt-4">
<div className="grid grid-cols-3 gap-2 max-w-3xl">
<LabelContentPair
label="Mime Type"
content={
<span className="font-mono bg-gray-100 rounded px-2 py-1 text-sm">
{mimeType || "No mime type"}
</span>
}
/>
<LabelContentPair
label="Size"
content={<span>{sizeInKB.toFixed(2)} KB</span>}
/>
<LabelContentPair
label="Download"
content={
<BinaryDownloadButton
fileName={value.id.toString()}
pdfBlob={blob}
mimeType={mimeType}
label={
mimeType === "application/pdf"
? "Download PDF"
: "Download File"
}
/>
}
/>
</div>
{mimeType === "image/png" || mimeType === "image/jpeg" ? (
<LabelContentPair
label="Preview"
content={
<div className="bg-gray-50 p-3 rounded-sm">
<RenderBlobImage blob={blob} />
</div>
}
/>
) : null}
</div>
);
}
function RenderCoStream({
value,
node,
}: {
value: RawCoStream;
node: LocalNode;
}) {
const streamPerUser = Object.keys(value.items);
const userCoIds = streamPerUser.map(
(stream) => stream.split("_session")[0],
);
return (
<div className="grid grid-cols-3 gap-2">
{userCoIds.map((id, idx) => (
<div
className="bg-gray-100 p-3 rounded-lg transition-colors overflow-hidden bg-white border hover:bg-gray-100/5 cursor-pointer shadow-sm"
key={id}
>
<AccountOrGroupPreview
coId={id as CoID<RawCoValue>}
node={node}
/>
{/* @ts-expect-error - TODO: fix types */}
{value.items[streamPerUser[idx]]?.map(
(item: CoStreamItem<JsonValue>) => (
<div>
{new Date(item.madeAt).toLocaleString()}{" "}
{JSON.stringify(item.value)}
</div>
),
)}
</div>
))}
</div>
);
}
export function CoStreamView({
value,
node,
}: {
data: JsonObject;
onNavigate: (pages: PageInfo[]) => void;
node: LocalNode;
value: RawCoStream;
}) {
// if (!value) return <div>No value</div>;
const streamType = detectCoStreamType(value);
if (streamType.type === "binary") {
if (streamType.items === undefined) {
return <div>No binary stream</div>;
}
return (
<RenderCoBinaryStream
value={value as RawBinaryCoStream}
items={streamType.items}
/>
);
}
if (streamType.type === "coStream") {
return <RenderCoStream value={value} node={node} />;
}
if (streamType.type === "unknown") return <div>Unknown stream type</div>;
return <div>Unknown stream type</div>;
}
function RenderBlobImage({ blob }: { blob: Blob }) {
const urlCreator = window.URL || window.webkitURL;
return <img src={urlCreator.createObjectURL(blob)} />;
}

View File

@@ -0,0 +1,73 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import { JsonObject } from "cojson/src/jsonValue";
import { CoMapPreview, ValueRenderer } from "./value-renderer";
import clsx from "clsx";
import { PageInfo, isCoId } from "./types";
import { ResolveIcon } from "./type-icon";
export function GridView({
data,
onNavigate,
node,
}: {
data: JsonObject;
onNavigate: (pages: PageInfo[]) => void;
node: LocalNode;
}) {
const entries = Object.entries(data);
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-2">
{entries.map(([key, child], childIndex) => (
<div
key={childIndex}
className={clsx(
"bg-gray-100 p-3 rounded-lg transition-colors overflow-hidden",
isCoId(child)
? "bg-white border hover:bg-gray-100/5 cursor-pointer shadow-sm"
: "bg-gray-50",
)}
onClick={() =>
isCoId(child) &&
onNavigate([
{ coId: child as CoID<RawCoValue>, name: key },
])
}
>
<h3 className="truncate">
{isCoId(child) ? (
<span className="font-medium flex justify-between">
{key}
<div className="px-2 py-1 text-xs bg-gray-100 rounded">
<ResolveIcon
coId={child as CoID<RawCoValue>}
node={node}
/>
</div>
</span>
) : (
<span>{key}</span>
)}
</h3>
<div className="mt-2 text-sm">
{isCoId(child) ? (
<CoMapPreview
coId={child as CoID<RawCoValue>}
node={node}
/>
) : (
<ValueRenderer
json={child}
onCoIDClick={(coId) => {
onNavigate([{ coId, name: key }]);
}}
compact
/>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { LocalNode } from "cojson";
import { Breadcrumbs } from "./breadcrumbs";
import { usePagePath } from "./use-page-path";
import { PageInfo } from "./types";
import { PageStack } from "./page-stack";
export default function CoJsonViewer({
defaultPath,
node,
}: {
defaultPath?: PageInfo[];
node: LocalNode;
}) {
const { path, addPages, goToIndex, goBack } = usePagePath(defaultPath);
return (
<div className="w-full h-screen bg-gray-100 p-4 overflow-hidden">
<Breadcrumbs path={path} onBreadcrumbClick={goToIndex} />
<PageStack
path={path}
node={node}
goBack={goBack}
addPages={addPages}
/>
</div>
);
}

View File

@@ -0,0 +1,310 @@
import React, { useState, useEffect } from "react";
import {
LocalNode,
CoID,
RawCoValue,
RawAccount,
AgentSecret,
AccountID,
cojsonInternals,
WasmCrypto,
} from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import { Trash2 } from "lucide-react";
import { Breadcrumbs } from "./breadcrumbs";
import { usePagePath } from "./use-page-path";
import { PageStack } from "./page-stack";
import { resolveCoValue, useResolvedCoValue } from "./use-resolve-covalue";
import clsx from "clsx";
interface Account {
id: CoID<RawAccount>;
secret: AgentSecret;
}
export default function CoJsonViewerApp() {
const [accounts, setAccounts] = useState<Account[]>(() => {
const storedAccounts = localStorage.getItem("inspectorAccounts");
return storedAccounts ? JSON.parse(storedAccounts) : [];
});
const [currentAccount, setCurrentAccount] = useState<Account | null>(() => {
const lastSelectedId = localStorage.getItem("lastSelectedAccountId");
if (lastSelectedId) {
const lastAccount = accounts.find(
(account) => account.id === lastSelectedId,
);
return lastAccount || null;
}
return null;
});
const [localNode, setLocalNode] = useState<LocalNode | null>(null);
const [coValueId, setCoValueId] = useState<CoID<RawCoValue> | "">("");
const { path, addPages, goToIndex, goBack, setPage } = usePagePath();
useEffect(() => {
localStorage.setItem("inspectorAccounts", JSON.stringify(accounts));
}, [accounts]);
useEffect(() => {
if (currentAccount) {
localStorage.setItem("lastSelectedAccountId", currentAccount.id);
} else {
localStorage.removeItem("lastSelectedAccountId");
}
}, [currentAccount]);
useEffect(() => {
if (!currentAccount) {
setLocalNode(null);
goToIndex(-1);
return;
}
WasmCrypto.create().then(async (crypto) => {
const wsPeer = createWebSocketPeer({
id: "mesh",
websocket: new WebSocket("wss://mesh.jazz.tools"),
role: "server",
});
const node = await LocalNode.withLoadedAccount({
accountID: currentAccount.id,
accountSecret: currentAccount.secret,
sessionID: cojsonInternals.newRandomSessionID(
currentAccount.id,
),
peersToLoadFrom: [wsPeer],
crypto,
migration: async () => {
console.log("Not running any migration in inspector");
},
});
setLocalNode(node);
});
}, [currentAccount, goToIndex]);
const addAccount = (id: AccountID, secret: AgentSecret) => {
const newAccount = { id, secret };
setAccounts([...accounts, newAccount]);
setCurrentAccount(newAccount);
};
const deleteCurrentAccount = () => {
if (currentAccount) {
const updatedAccounts = accounts.filter(
(account) => account.id !== currentAccount.id,
);
setAccounts(updatedAccounts);
setCurrentAccount(
updatedAccounts.length > 0 ? updatedAccounts[0] : null,
);
}
};
const handleCoValueIdSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (coValueId) {
setPage(coValueId);
}
};
return (
<div className="w-full h-screen bg-gray-100 p-4 overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-4">
<Breadcrumbs path={path} onBreadcrumbClick={goToIndex} />
<AccountSwitcher
accounts={accounts}
currentAccount={currentAccount}
setCurrentAccount={setCurrentAccount}
deleteCurrentAccount={deleteCurrentAccount}
localNode={localNode}
/>
</div>
<PageStack
path={path}
node={localNode}
goBack={goBack}
addPages={addPages}
>
{!currentAccount ? (
<AddAccountForm addAccount={addAccount} />
) : (
<form
onSubmit={handleCoValueIdSubmit}
aria-hidden={path.length !== 0}
className={clsx(
"flex flex-col justify-center items-center gap-2 h-full w-full mb-20 ",
"transition-all duration-150",
path.length > 0
? "opacity-0 -translate-y-2 scale-95"
: "opacity-100",
)}
>
<fieldset className="flex flex-col gap-2 text-sm">
<h2 className="text-3xl font-medium text-gray-950 text-center mb-4">
Jazz CoValue Inspector
</h2>
<input
className="border p-4 rounded-lg min-w-[21rem] font-mono"
placeholder="co_z1234567890abcdef123456789"
value={coValueId}
onChange={(e) =>
setCoValueId(
e.target.value as CoID<RawCoValue>,
)
}
/>
<button
type="submit"
className="bg-indigo-500 hover:bg-indigo-500/80 text-white px-4 py-2 rounded-md"
>
Inspect
</button>
<hr />
<button
type="button"
className="border inline-block px-2 py-1.5 text-black rounded"
onClick={() => {
setCoValueId(currentAccount.id);
setPage(currentAccount.id);
}}
>
Inspect My Account
</button>
</fieldset>
</form>
)}
</PageStack>
</div>
);
}
function AccountSwitcher({
accounts,
currentAccount,
setCurrentAccount,
deleteCurrentAccount,
localNode,
}: {
accounts: Account[];
currentAccount: Account | null;
setCurrentAccount: (account: Account | null) => void;
deleteCurrentAccount: () => void;
localNode: LocalNode | null;
}) {
return (
<div className="relative flex items-center gap-1">
<select
value={currentAccount?.id || "add-account"}
onChange={(e) => {
if (e.target.value === "add-account") {
setCurrentAccount(null);
} else {
const account = accounts.find(
(a) => a.id === e.target.value,
);
setCurrentAccount(account || null);
}
}}
className="p-2 px-4 bg-gray-100/50 border border-indigo-500/10 backdrop-blur-sm rounded-md text-indigo-700 appearance-none"
>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{localNode ? (
<AccountNameDisplay
accountId={account.id}
node={localNode}
/>
) : (
account.id
)}
</option>
))}
<option value="add-account">Add account</option>
</select>
{currentAccount && (
<button
onClick={deleteCurrentAccount}
className="p-3 rounded hover:bg-gray-200 transition-colors"
title="Delete Account"
>
<Trash2 size={16} className="text-gray-500" />
</button>
)}
</div>
);
}
function AddAccountForm({
addAccount,
}: {
addAccount: (id: AccountID, secret: AgentSecret) => void;
}) {
const [id, setId] = useState("");
const [secret, setSecret] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addAccount(id as AccountID, secret as AgentSecret);
setId("");
setSecret("");
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-2 max-w-md mx-auto h-full justify-center"
>
<h2 className="text-2xl font-medium text-gray-900 mb-3">
Add an Account to Inspect
</h2>
<input
className="border py-2 px-3 rounded-md"
placeholder="Account ID"
value={id}
onChange={(e) => setId(e.target.value)}
/>
<input
type="password"
className="border py-2 px-3 rounded-md"
placeholder="Account Secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<button
type="submit"
className="bg-indigo-500 text-white px-4 py-2 rounded-md"
>
Add Account
</button>
</form>
);
}
function AccountNameDisplay({
accountId,
node,
}: {
accountId: CoID<RawAccount>;
node: LocalNode;
}) {
const { snapshot } = useResolvedCoValue(accountId, node);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
if (snapshot && typeof snapshot === "object" && "profile" in snapshot) {
const profileId = snapshot.profile as CoID<RawCoValue>;
resolveCoValue(profileId, node).then((profileResult) => {
if (
profileResult.snapshot &&
typeof profileResult.snapshot === "object" &&
"name" in profileResult.snapshot
) {
setName(profileResult.snapshot.name as string);
}
});
}
}, [snapshot, node]);
return name ? `${name} <${accountId}>` : accountId;
}

View File

@@ -0,0 +1,55 @@
import { Page } from "./page"; // Assuming you have a Page component
import { CoID, LocalNode, RawCoValue } from "cojson";
// Define the structure of a page in the path
interface PageInfo {
coId: CoID<RawCoValue>;
name?: string;
}
// Props for the PageStack component
interface PageStackProps {
path: PageInfo[];
node?: LocalNode | null;
goBack: () => void;
addPages: (pages: PageInfo[]) => void;
children?: React.ReactNode;
}
export function PageStack({
path,
node,
goBack,
addPages,
children,
}: PageStackProps) {
return (
<div className="relative mt-4 h-[calc(100vh-6rem)]">
{children && (
<div className="absolute inset-0 pb-20">{children}</div>
)}
{node &&
path.map((page, index) => (
<Page
key={`${page.coId}-${index}`}
coId={page.coId}
node={node}
name={page.name || page.coId}
onHeaderClick={goBack}
onNavigate={addPages}
isTopLevel={index === path.length - 1}
style={{
transform: `translateZ(${(index - path.length + 1) * 200}px) scale(${
1 - (path.length - index - 1) * 0.05
}) translateY(${-(index - path.length + 1) * -4}%)`,
opacity: 1 - (path.length - index - 1) * 0.05,
zIndex: index,
transitionProperty: "transform, opacity",
transitionDuration: "0.3s",
transitionTimingFunction: "ease-out",
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,154 @@
import clsx from "clsx";
import { CoID, LocalNode, RawCoStream, RawCoValue } from "cojson";
import { useEffect, useState } from "react";
import { useResolvedCoValue } from "./use-resolve-covalue";
import { GridView } from "./grid-view";
import { PageInfo } from "./types";
import { TableView } from "./table-viewer";
import { TypeIcon } from "./type-icon";
import { CoStreamView } from "./co-stream-view";
import { AccountOrGroupPreview } from "./value-renderer";
type PageProps = {
coId: CoID<RawCoValue>;
node: LocalNode;
name: string;
onNavigate: (newPages: PageInfo[]) => void;
onHeaderClick?: () => void;
isTopLevel?: boolean;
style: React.CSSProperties;
};
export function Page({
coId,
node,
name,
onNavigate,
onHeaderClick,
style,
isTopLevel,
}: PageProps) {
const { value, snapshot, type, extendedType } = useResolvedCoValue(
coId,
node,
);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const supportsTableView = type === "colist" || extendedType === "record";
// Automatically switch to table view if the page is a CoMap record
useEffect(() => {
if (supportsTableView) {
setViewMode("table");
}
}, [supportsTableView]);
if (snapshot === "unavailable") {
return <div style={style}>Data unavailable</div>;
}
if (!snapshot) {
return <div style={style}></div>;
}
return (
<div
style={style}
className={clsx(
"absolute inset-0 border border-gray-900/5 bg-clip-padding bg-white rounded-xl shadow-lg p-6 animate-in",
)}
>
{!isTopLevel && (
<div
className="absolute inset-x-0 top-0 h-10"
aria-label="Back"
onClick={() => {
onHeaderClick?.();
}}
aria-hidden="true"
></div>
)}
<div className="flex justify-between items-center mb-4">
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-bold flex items-start flex-col gap-1">
<span>
{name}
{typeof snapshot === "object" &&
"name" in snapshot ? (
<span className="text-gray-600 font-medium">
{" "}
{
(
snapshot as {
name: string;
}
).name
}
</span>
) : null}
</span>
</h2>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-700 font-medium py-0.5 px-1 -ml-0.5 rounded bg-gray-700/5 inline-block font-mono">
{type && (
<TypeIcon
type={type}
extendedType={extendedType}
/>
)}
</span>
<span className="text-xs text-gray-700 font-medium py-0.5 px-1 -ml-0.5 rounded bg-gray-700/5 inline-block font-mono">
{coId}
</span>
</div>
</div>
{/* {supportsTableView && (
<button
onClick={toggleViewMode}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{viewMode === "grid" ? "Table View" : "Grid View"}
</button>
)} */}
</div>
<div className="overflow-auto max-h-[calc(100%-4rem)]">
{type === "costream" ? (
<CoStreamView
data={snapshot}
onNavigate={onNavigate}
node={node}
value={value as RawCoStream}
/>
) : viewMode === "grid" ? (
<GridView
data={snapshot}
onNavigate={onNavigate}
node={node}
/>
) : (
<TableView
data={snapshot}
node={node}
onNavigate={onNavigate}
/>
)}
{/* --- */}
{extendedType !== "account" && extendedType !== "group" && (
<div className="text-xs text-gray-500 mt-4">
Owned by{" "}
<AccountOrGroupPreview
coId={value.group.id}
node={node}
showId
onClick={() => {
onNavigate([
{ coId: value.group.id, name: "owner" },
]);
}}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import { JsonObject } from "cojson/src/jsonValue";
import { PageInfo } from "./types";
import { useMemo, useState } from "react";
import { ValueRenderer } from "./value-renderer";
import { LinkIcon } from "../link-icon";
import { useResolvedCoValues } from "./use-resolve-covalue";
export function TableView({
data,
node,
onNavigate,
}: {
data: JsonObject;
node: LocalNode;
onNavigate: (pages: PageInfo[]) => void;
}) {
const [visibleRowsCount, setVisibleRowsCount] = useState(10);
const [coIdArray, visibleRows] = useMemo(() => {
const coIdArray = Array.isArray(data)
? data
: Object.values(data).every(
(k) => typeof k === "string" && k.startsWith("co_"),
)
? Object.values(data).map((k) => k as CoID<RawCoValue>)
: [];
const visibleRows = coIdArray.slice(0, visibleRowsCount);
return [coIdArray, visibleRows];
}, [data, visibleRowsCount]);
const resolvedRows = useResolvedCoValues(visibleRows, node);
const hasMore = visibleRowsCount < coIdArray.length;
if (!coIdArray.length) {
return <div>No data to display</div>;
}
if (resolvedRows.length === 0) {
return <div>Loading...</div>;
}
const keys = Array.from(
new Set(
resolvedRows.flatMap((item) => Object.keys(item.snapshot || {})),
),
);
const loadMore = () => {
setVisibleRowsCount((prevVisibleRows) => prevVisibleRows + 10);
};
return (
<div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 border-b">
<tr>
{["", ...keys].map((key) => (
<th
key={key}
className="px-4 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 rounded"
>
{key}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{resolvedRows
.slice(0, visibleRowsCount)
.map((item, index) => (
<tr key={index}>
<td className="px-1 py-0">
<button
onClick={() =>
onNavigate([
{
coId: item.value!.id,
name: index.toString(),
},
])
}
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 hover:text-blue-500 hover:bg-gray-100 rounded"
>
<LinkIcon />
</button>
</td>
{keys.map((key) => (
<td
key={key}
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500"
>
<ValueRenderer
json={
(item.snapshot as JsonObject)[
key
]
}
onCoIDClick={(coId) => {
async function handleClick() {
onNavigate([
{
coId: item.value!
.id,
name: index.toString(),
},
{
coId: coId,
name: key,
},
]);
}
handleClick();
}}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="py-4 text-gray-500 flex items-center justify-between gap-2">
<span>
Showing {Math.min(visibleRowsCount, coIdArray.length)} of{" "}
{coIdArray.length}
</span>
{hasMore && (
<div className="text-center">
<button
onClick={loadMore}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Load More
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import {
CoJsonType,
ExtendedCoJsonType,
useResolvedCoValue,
} from "./use-resolve-covalue";
export const TypeIcon = ({
type,
extendedType,
}: {
type: CoJsonType;
extendedType?: ExtendedCoJsonType;
}) => {
const iconMap: Record<ExtendedCoJsonType | CoJsonType, string> = {
record: "{} Record",
image: "🖼️ Image",
comap: "{} CoMap",
costream: "≋ CoStream",
colist: "☰ CoList",
account: "👤 Account",
group: "👥 Group",
};
const iconKey = extendedType || type;
const icon = iconMap[iconKey as keyof typeof iconMap];
return icon ? <span className="font-mono">{icon}</span> : null;
};
export const ResolveIcon = ({
coId,
node,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
}) => {
const { type, extendedType, snapshot } = useResolvedCoValue(coId, node);
if (snapshot === "unavailable" && !type) {
return <div className="text-gray-600 font-medium">Unavailable</div>;
}
if (!type) return <div className="whitespace-pre w-14 font-mono"> </div>;
return <TypeIcon type={type} extendedType={extendedType} />;
};

View File

@@ -0,0 +1,9 @@
import { CoID, RawCoValue } from "cojson";
export type PageInfo = {
coId: CoID<RawCoValue>;
name?: string;
};
export const isCoId = (coId: unknown): coId is CoID<RawCoValue> =>
typeof coId === "string" && coId.startsWith("co_");

View File

@@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from "react";
import { PageInfo } from "./types";
import { CoID, RawCoValue } from "cojson";
export function usePagePath(defaultPath?: PageInfo[]) {
const [path, setPath] = useState<PageInfo[]>(() => {
const hash = window.location.hash.slice(2); // Remove '#/'
if (hash) {
try {
return decodePathFromHash(hash);
} catch (e) {
console.error("Failed to parse hash:", e);
}
}
return defaultPath || [];
});
const updatePath = useCallback((newPath: PageInfo[]) => {
setPath(newPath);
const hash = encodePathToHash(newPath);
window.location.hash = `#/${hash}`;
}, []);
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(2);
if (hash) {
try {
const newPath = decodePathFromHash(hash);
setPath(newPath);
} catch (e) {
console.error("Failed to parse hash:", e);
}
} else if (defaultPath) {
setPath(defaultPath);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [defaultPath]);
useEffect(() => {
if (
defaultPath &&
JSON.stringify(path) !== JSON.stringify(defaultPath)
) {
updatePath(defaultPath);
}
}, [defaultPath, path, updatePath]);
const addPages = useCallback(
(newPages: PageInfo[]) => {
updatePath([...path, ...newPages]);
},
[path, updatePath],
);
const goToIndex = useCallback(
(index: number) => {
updatePath(path.slice(0, index + 1));
},
[path, updatePath],
);
const setPage = useCallback(
(coId: CoID<RawCoValue>) => {
updatePath([{ coId, name: "Root" }]);
},
[updatePath],
);
const goBack = useCallback(() => {
if (path.length > 1) {
updatePath(path.slice(0, path.length - 1));
}
}, [path, updatePath]);
return {
path,
setPage,
addPages,
goToIndex,
goBack,
};
}
function encodePathToHash(path: PageInfo[]): string {
return path
.map((page) => {
if (page.name && page.name !== "Root") {
return `${page.coId}:${encodeURIComponent(page.name)}`;
}
return page.coId;
})
.join("/");
}
function decodePathFromHash(hash: string): PageInfo[] {
return hash.split("/").map((segment) => {
const [coId, encodedName] = segment.split(":");
return {
coId,
name: encodedName ? decodeURIComponent(encodedName) : undefined,
} as PageInfo;
});
}

View File

@@ -0,0 +1,152 @@
import { CoID, LocalNode, RawBinaryCoStream, RawCoValue } from "cojson";
import { useEffect, useState } from "react";
export type CoJsonType = "comap" | "costream" | "colist";
export type ExtendedCoJsonType = "image" | "record" | "account" | "group";
type JSON = string | number | boolean | null | JSON[] | { [key: string]: JSON };
type JSONObject = { [key: string]: JSON };
type ResolvedImageDefinition = {
originalSize: [number, number];
placeholderDataURL?: string;
[res: `${number}x${number}`]: RawBinaryCoStream["id"];
};
// Type guard for browser image
export const isBrowserImage = (
coValue: JSONObject,
): coValue is ResolvedImageDefinition => {
return "originalSize" in coValue && "placeholderDataURL" in coValue;
};
export type ResolvedGroup = {
readKey: string;
[key: string]: JSON;
};
export const isGroup = (coValue: JSONObject): coValue is ResolvedGroup => {
return "readKey" in coValue;
};
export type ResolvedAccount = {
profile: {
name: string;
};
[key: string]: JSON;
};
export const isAccount = (coValue: JSONObject): coValue is ResolvedAccount => {
return isGroup(coValue) && "profile" in coValue;
};
export async function resolveCoValue(
coValueId: CoID<RawCoValue>,
node: LocalNode,
): Promise<
| {
value: RawCoValue;
snapshot: JSONObject;
type: CoJsonType | null;
extendedType: ExtendedCoJsonType | undefined;
}
| {
value: undefined;
snapshot: "unavailable";
type: null;
extendedType: undefined;
}
> {
const value = await node.load(coValueId);
if (value === "unavailable") {
return {
value: undefined,
snapshot: "unavailable",
type: null,
extendedType: undefined,
};
}
const snapshot = value.toJSON() as JSONObject;
const type = value.type as CoJsonType;
// Determine extended type
let extendedType: ExtendedCoJsonType | undefined;
if (type === "comap") {
if (isBrowserImage(snapshot)) {
extendedType = "image";
} else if (isAccount(snapshot)) {
extendedType = "account";
} else if (isGroup(snapshot)) {
extendedType = "group";
} else {
// This check is a bit of a hack
// There might be a better way to do this
const children = Object.values(snapshot).slice(0, 10);
if (
children.every(
(c) => typeof c === "string" && c.startsWith("co_"),
) &&
children.length > 3
) {
extendedType = "record";
}
}
}
return {
value,
snapshot,
type,
extendedType,
};
}
export function useResolvedCoValue(
coValueId: CoID<RawCoValue>,
node: LocalNode,
) {
const [result, setResult] =
useState<Awaited<ReturnType<typeof resolveCoValue>>>();
useEffect(() => {
resolveCoValue(coValueId, node).then(setResult);
}, [coValueId, node]);
return (
result || {
value: undefined,
snapshot: undefined,
type: undefined,
extendedType: undefined,
}
);
}
export function useResolvedCoValues(
coValueIds: CoID<RawCoValue>[],
node: LocalNode,
) {
const [results, setResults] = useState<
Awaited<ReturnType<typeof resolveCoValue>>[]
>([]);
useEffect(() => {
console.log("RETECHING", coValueIds);
const fetchResults = async () => {
if (coValueIds.length === 0) return;
const resolvedValues = await Promise.all(
coValueIds.map((coValueId) => resolveCoValue(coValueId, node)),
);
console.log({ resolvedValues });
setResults(resolvedValues);
};
fetchResults();
}, [coValueIds, node]);
return results;
}

View File

@@ -0,0 +1,248 @@
import clsx from "clsx";
import { CoID, JsonValue, LocalNode, RawCoValue } from "cojson";
import { LinkIcon } from "../link-icon";
import {
isBrowserImage,
resolveCoValue,
useResolvedCoValue,
} from "./use-resolve-covalue";
import React, { useEffect, useState } from "react";
// Is there a chance we can pass the actual CoValue here?
export function ValueRenderer({
json,
compact,
onCoIDClick,
}: {
json: JsonValue | undefined;
compact?: boolean;
onCoIDClick?: (childNode: CoID<RawCoValue>) => void;
}) {
if (typeof json === "undefined" || json === undefined) {
return <span className="text-gray-400">undefined</span>;
}
if (json === null) {
return <span className="text-gray-400">null</span>;
}
if (typeof json === "string" && json.startsWith("co_")) {
return (
<span
className={clsx(
"inline-flex gap-1 items-center",
onCoIDClick &&
"text-blue-500 cursor-pointer hover:underline",
)}
onClick={() => {
onCoIDClick?.(json as CoID<RawCoValue>);
}}
>
{json}
{onCoIDClick && <LinkIcon />}
</span>
);
}
if (typeof json === "string") {
return (
<span className="text-green-900 font-mono">
{/* <span className="select-none opacity-70">{'"'}</span> */}
{json}
{/* <span className="select-none opacity-70">{'"'}</span> */}
</span>
);
}
if (typeof json === "number") {
return <span className="text-purple-500">{json}</span>;
}
if (typeof json === "boolean") {
return (
<span
className={clsx(
json
? "text-green-700 bg-green-700/5"
: "text-amber-700 bg-amber-500/5",
"font-mono",
"inline-block px-1 py-0.5 rounded",
)}
>
{json.toString()}
</span>
);
}
if (Array.isArray(json)) {
return (
<span title={JSON.stringify(json)}>
Array <span className="text-gray-500">({json.length})</span>
</span>
);
}
if (typeof json === "object") {
return (
<span
title={JSON.stringify(json, null, 2)}
className="inline-block max-w-64 truncate"
>
{compact ? (
<span>
Object{" "}
<span className="text-gray-500">
({Object.keys(json).length})
</span>
</span>
) : (
JSON.stringify(json, null, 2)
)}
</span>
);
}
return <span>{String(json)}</span>;
}
export const CoMapPreview = ({
coId,
node,
limit = 6,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
limit?: number;
}) => {
const { value, snapshot, type, extendedType } = useResolvedCoValue(
coId,
node,
);
if (!snapshot) {
return (
<div className="rounded bg-gray-100 animate-pulse whitespace-pre w-24">
{" "}
</div>
);
}
if (snapshot === "unavailable" && !value) {
return <div className="text-gray-500">Unavailable</div>;
}
if (extendedType === "image" && isBrowserImage(snapshot)) {
return (
<div>
<img
src={snapshot.placeholderDataURL}
className="size-8 border-2 border-white drop-shadow-md my-2"
/>
<span className="text-gray-500 text-sm">
{snapshot.originalSize[0]} x {snapshot.originalSize[1]}
</span>
{/* <CoMapPreview coId={value[]} node={node} /> */}
{/* <ProgressiveImg image={value}>
{({ src }) => <img src={src} className={clsx("w-full")} />}
</ProgressiveImg> */}
</div>
);
}
if (extendedType === "record") {
return (
<div>
Record{" "}
<span className="text-gray-500">
({Object.keys(snapshot).length})
</span>
</div>
);
}
if (type === "colist") {
return (
<div>
List{" "}
<span className="text-gray-500">
({(snapshot as unknown as []).length})
</span>
</div>
);
}
return (
<div className="text-sm flex flex-col gap-2 items-start">
<div className="grid grid-cols-[auto_1fr] gap-2">
{Object.entries(snapshot)
.slice(0, limit)
.map(([key, value]) => (
<React.Fragment key={key}>
<span className="font-medium">{key}: </span>
<span>
<ValueRenderer json={value} />
</span>
</React.Fragment>
))}
</div>
{Object.entries(snapshot).length > limit && (
<div className="text-left text-xs text-gray-500 mt-2">
{Object.entries(snapshot).length - limit} more
</div>
)}
</div>
);
};
export function AccountOrGroupPreview({
coId,
node,
showId = false,
onClick,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
showId?: boolean;
onClick?: (name?: string) => void;
}) {
const { snapshot, extendedType } = useResolvedCoValue(coId, node);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
if (extendedType === "account") {
resolveCoValue(
(snapshot as unknown as { profile: CoID<RawCoValue> }).profile,
node,
).then(({ snapshot }) => {
if (
typeof snapshot === "object" &&
"name" in snapshot &&
typeof snapshot.name === "string"
) {
setName(snapshot.name);
}
});
}
}, [snapshot, node, extendedType]);
if (!snapshot) return <span>Loading...</span>;
if (extendedType !== "account" && extendedType !== "group") {
return <span>CoID is not an account or group</span>;
}
const displayName =
extendedType === "account" ? name || "Account" : "Group";
const displayText = showId ? `${displayName} (${coId})` : displayName;
const props = onClick
? {
onClick: () => onClick(displayName),
className: "text-blue-500 cursor-pointer hover:underline",
}
: {
className: "text-gray-500",
};
return <span {...props}>{displayText}</span>;
}

View File

@@ -1,5 +1,208 @@
# jazz-example-pets
## 0.0.98
### Patch Changes
- jazz-react@0.7.33
- jazz-tools@0.7.33
- jazz-browser-media-images@0.7.33
## 0.0.98-hotfixes.5
### Patch Changes
- jazz-react@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
- jazz-browser-media-images@0.7.33-hotfixes.5
## 0.0.98-hotfixes.4
### Patch Changes
- jazz-react@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
- jazz-browser-media-images@0.7.33-hotfixes.4
## 0.0.98-hotfixes.3
### Patch Changes
- jazz-react@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
- jazz-browser-media-images@0.7.33-hotfixes.3
## 0.0.98-hotfixes.2
### Patch Changes
- jazz-browser-media-images@0.7.33-hotfixes.2
- jazz-react@0.7.33-hotfixes.2
## 0.0.98-hotfixes.1
### Patch Changes
- jazz-browser-media-images@0.7.33-hotfixes.1
- jazz-react@0.7.33-hotfixes.1
## 0.0.98-hotfixes.0
### Patch Changes
- jazz-react@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
- jazz-browser-media-images@0.7.33-hotfixes.0
## 0.0.97
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
- jazz-browser-media-images@0.7.32
- jazz-react@0.7.32
## 0.0.96
### Patch Changes
- jazz-react@0.7.31
- jazz-tools@0.7.31
- jazz-browser-media-images@0.7.31
## 0.0.95
### Patch Changes
- jazz-browser-media-images@0.7.30
- jazz-react@0.7.30
## 0.0.94
### Patch Changes
- jazz-react@0.7.29
- jazz-tools@0.7.29
- jazz-browser-media-images@0.7.29
## 0.0.93
### Patch Changes
- jazz-react@0.7.28
- jazz-tools@0.7.28
- jazz-browser-media-images@0.7.28
## 0.0.92
### Patch Changes
- jazz-browser-media-images@0.7.27
- jazz-react@0.7.27
## 0.0.91
### Patch Changes
- Updated dependencies
- jazz-react@0.7.26
- jazz-tools@0.7.26
- jazz-browser-media-images@0.7.26
## 0.0.90
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
- jazz-browser-media-images@0.7.25
- jazz-react@0.7.25
## 0.0.89
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-browser-media-images@0.7.24
- jazz-react@0.7.24
## 0.0.88
### Patch Changes
- Updated dependencies
- jazz-react@0.7.23
- jazz-tools@0.7.23
- jazz-browser-media-images@0.7.23
## 0.0.87
### Patch Changes
- jazz-browser-media-images@0.7.22
- jazz-react@0.7.22
## 0.0.86
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-browser-media-images@0.7.21
- jazz-react@0.7.21
## 0.0.85
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-browser-media-images@0.7.20
- jazz-react@0.7.20
## 0.0.84
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-browser-media-images@0.7.19
- jazz-react@0.7.19
## 0.0.83
### Patch Changes
- jazz-react@0.7.18
- jazz-tools@0.7.18
- jazz-browser-media-images@0.7.18
## 0.0.82
### Patch Changes
- jazz-react@0.7.17
- jazz-tools@0.7.17
- jazz-browser-media-images@0.7.17
## 0.0.81
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-browser-media-images@0.7.16
- jazz-react@0.7.16
## 0.0.80
### Patch Changes
- Updated dependencies
- jazz-react@0.7.15
## 0.0.79
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.79",
"version": "0.0.98",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,184 @@
# jazz-example-todo
## 0.0.97
### Patch Changes
- jazz-react@0.7.33
- jazz-tools@0.7.33
## 0.0.97-hotfixes.5
### Patch Changes
- jazz-react@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.0.97-hotfixes.4
### Patch Changes
- jazz-react@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.0.97-hotfixes.3
### Patch Changes
- jazz-react@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.0.97-hotfixes.2
### Patch Changes
- jazz-react@0.7.33-hotfixes.2
## 0.0.97-hotfixes.1
### Patch Changes
- jazz-react@0.7.33-hotfixes.1
## 0.0.97-hotfixes.0
### Patch Changes
- jazz-react@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
## 0.0.96
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
- jazz-react@0.7.32
## 0.0.95
### Patch Changes
- jazz-react@0.7.31
- jazz-tools@0.7.31
## 0.0.94
### Patch Changes
- jazz-react@0.7.30
## 0.0.93
### Patch Changes
- jazz-react@0.7.29
- jazz-tools@0.7.29
## 0.0.92
### Patch Changes
- jazz-react@0.7.28
- jazz-tools@0.7.28
## 0.0.91
### Patch Changes
- jazz-react@0.7.27
## 0.0.90
### Patch Changes
- Updated dependencies
- jazz-react@0.7.26
- jazz-tools@0.7.26
## 0.0.89
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
- jazz-react@0.7.25
## 0.0.88
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-react@0.7.24
## 0.0.87
### Patch Changes
- Updated dependencies
- jazz-react@0.7.23
- jazz-tools@0.7.23
## 0.0.86
### Patch Changes
- jazz-react@0.7.22
## 0.0.85
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-react@0.7.21
## 0.0.84
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-react@0.7.20
## 0.0.83
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-react@0.7.19
## 0.0.82
### Patch Changes
- jazz-react@0.7.18
- jazz-tools@0.7.18
## 0.0.81
### Patch Changes
- jazz-react@0.7.17
- jazz-tools@0.7.17
## 0.0.80
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-react@0.7.16
## 0.0.79
### Patch Changes
- Updated dependencies
- jazz-react@0.7.15
## 0.0.78
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.78",
"version": "0.0.97",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -9,9 +9,11 @@ import localFont from "next/font/local";
import { GcmpLogo, JazzLogo } from "@/components/logos";
import { SiGithub, SiDiscord, SiTwitter } from "@icons-pack/react-simple-icons";
import { Nav, NavLink, Newsletter, NewsletterButton } from "@/components/nav";
import { MailIcon } from "lucide-react";
import { DocNav } from "@/components/docs/nav";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Analytics } from "@vercel/analytics/react";
// If loading a variable font, you don't need to specify the font weight
const manrope = Manrope({
subsets: ["latin"],
@@ -48,6 +50,8 @@ export default function RootLayout({
"flex flex-col items-center bg-stone-50 dark:bg-stone-950 overflow-x-hidden",
].join(" ")}
>
<SpeedInsights />
<Analytics />
<ThemeProvider
attribute="class"
defaultTheme="system"
@@ -108,7 +112,7 @@ export default function RootLayout({
<div className="col-span-full md:col-span-1 sm:row-start-4 md:row-start-auto lg:col-span-2 md:row-span-2 md:flex-1 flex flex-row md:flex-col max-sm:mt-4 justify-between max-sm:items-start gap-2 text-sm min-w-[10rem]">
<GcmpLogo monochrome className="w-32" />
<p className="max-sm:text-right">
© 2023
© {new Date().getFullYear()}
<br />
Garden Computing, Inc.
</p>
@@ -192,12 +196,6 @@ export default function RootLayout({
</div>
</footer>
</ThemeProvider>
<script
defer
data-api="/api/event"
data-domain="jazz.tools"
src="/js/script.js"
></script>
</body>
</html>
);

View File

@@ -268,11 +268,7 @@ Jazz Mesh is currently free &mdash; and it's set up as the default sync & storag
## Get Started
- <Link href="/docs" target="_blank">
Read the docs
</Link>
- <Link href="https://discord.gg/utDMjHYg42" target="_blank">
Join our Discord
</Link>
- <Link href="/docs" target="_blank">Read the docs</Link>
- <Link href="https://discord.gg/utDMjHYg42" target="_blank">Join our Discord</Link>
</Prose>

View File

@@ -83,4 +83,4 @@ export function GcmpLogo({
</defs>
</svg>
);
}
}

View File

@@ -14,6 +14,7 @@
"*.{ts,tsx}": "eslint --fix",
"*.{js,jsx,mdx,json}": "prettier --write"
},
"packageManager": "pnpm@9.1.4",
"dependencies": {
"@evilmartians/harmony": "^1.0.0",
"@icons-pack/react-simple-icons": "^9.1.0",
@@ -21,6 +22,8 @@
"@mdx-js/react": "^2.3.0",
"@next/mdx": "^13.5.4",
"@types/mdx": "^2.0.8",
"@vercel/analytics": "^1.3.1",
"@vercel/speed-insights": "^1.0.12",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.284.0",

5244
homepage/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
# jazz-example-chat
## 0.0.47
### Patch Changes
- Updated dependencies
- cojson@0.6.7
- jazz-react@0.5.5
- jazz-react-auth-local@0.4.18
## 0.0.46
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<link rel="stylesheet" href="/src/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jazz Chat Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,243 +0,0 @@
import {
WithJazz,
useJazz,
DemoAuth,
useAutoSub,
useBinaryStream,
} from "jazz-react";
import ReactDOM from "react-dom/client";
import { HashRoute } from "hash-slash";
import { Account, CoID, CoValue, SessionID } from "cojson";
import { clsx } from "clsx";
import { ImageDefinition } from "cojson/src/media";
import { CoJsonTree } from "./cojson-tree";
ReactDOM.createRoot(document.getElementById("root")!).render(
<WithJazz
auth={DemoAuth({ appName: "Jazz Chat Example" })}
apiKey="api_z9d034j3t34ht034ir"
>
<App />
</WithJazz>
);
function App() {
return (
<div className="flex flex-col items-center justify-between w-screen h-screen p-2 ">
<button
onClick={useJazz().logOut}
className="rounded mb-5 px-2 py-1 bg-stone-200 dark:bg-stone-800 dark:text-white self-end"
>
Log Out
</button>
{HashRoute(
{
"/": <Home />,
"/:id": (id) => <Inspect coValueId={id as CoID<CoValue>} />,
},
{ reportToParentFrame: true }
)}
</div>
);
}
function Home() {
return (
<form
className="mb-auto"
onSubmit={(event) => {
const coValueId = (event.target as any).coValueId
.value as CoID<CoValue>;
location.hash = "/" + coValueId;
event.preventDefault();
}}
>
<input name="coValueId" className="border" />
<button>Inspect</button>
</form>
);
}
function Tag({ children, href }: { children: React.ReactNode; href?: string }) {
if (href) {
return (
<a
href={href}
className="border text-xs px-2 py-0.5 rounded hover:underline"
>
{children}
</a>
);
}
return <span className="border text-xs px-2 py-0.5 rounded">{children}</span>;
}
function ImageCoValue({ value }: { value: ImageDefinition["_shape"] }) {
const keys = Object.keys(value);
const keyIncludingRes = keys.find((key) => key.includes("x"));
const idToResolve = keyIncludingRes
? value[keyIncludingRes as `${number}x${number}`]
: null;
if (!idToResolve) return <div>Can't find image</div>;
const image = useBinaryStream(idToResolve);
return (
<img src={image?.blobURL || value.placeholderDataURL} alt="placeholder" />
);
}
function Inspect({ coValueId }: { coValueId: CoID<CoValue> }) {
const coValue = useAutoSub(coValueId);
const values = coValue?.meta.coValue.toJSON() || {};
const isImage = "placeholderDataURL" in values;
const isGroup = coValue?.meta.group.id === coValueId;
const entires = Object.entries(values as any) as [string, string][];
const onlyCoValues = entires.filter(([key]) => key.startsWith("co_"));
let title = "";
if (isImage) {
title = "Image";
} else if (isGroup) {
title = "Group";
}
return (
<div className="mb-auto">
<h1 className="text-xl font-bold mb-2">
Inspecting {title}{" "}
<span className="text-gray-500 text-sm">{coValueId}</span>
</h1>
{isGroup ? (
<p>
{onlyCoValues.length > 0 ? <h3>Permissions</h3> : ""}
<div className="flex gap-2 flex-col">
{onlyCoValues?.map(([key, value]) => (
<div className="flex gap-1 items-center">
<span className="bg-gray-200 text-xs px-2 py-0.5 rounded">
{value}
</span>
<AccountInfo accountID={key as CoID<Account>} />
</div>
))}
</div>
</p>
) : (
<span className="">
Group{" "}
<Tag href={`#/${coValue?.meta.group.id}`}>
{coValue?.meta.group.id}
</Tag>
</span>
)}
{isImage ? (
<div className="my-2">
<ImageCoValue value={values as any} />
</div>
) : null}
<pre className="max-w-[80vw] overflow-scroll text-sm mt-4">
<CoJsonTree coValueId={coValueId} />
</pre>
<h2 className="text-lg font-semibold mt-10 mb-4">Sessions</h2>
{coValue && <Sessions coValue={coValue.meta.coValue} />}
</div>
);
}
function Sessions({ coValue }: { coValue: CoValue }) {
const validTx = coValue.core.getValidSortedTransactions();
return (
<div className="max-w-[80vw] border rounded">
{[...coValue.core.sessionLogs.entries()].map(([sessionID, session]) => (
<div
key={sessionID}
className="mv-10 flex gap-2 border-b p-5 flex-wrap flex-col"
>
<div className="flex gap-2 flex-row">
<SessionInfo
sessionID={sessionID}
transactionCount={session.transactions.length}
/>
</div>
<div className="flex gap-1 flex-wrap max-h-64 overflow-y-auto p-1 bg-gray-50 rounded">
{session.transactions.map((tx, txIdx) => {
const correspondingValidTx = validTx.find(
(validTx) =>
validTx.txID.sessionID === sessionID &&
validTx.txID.txIndex == txIdx
);
return (
<div
key={txIdx}
className={clsx(
"text-xs flex-1 p-2 border rounded min-w-36 max-w-40 overflow-scroll bg-white",
!correspondingValidTx && "bg-red-50 border-red-100"
)}
>
<div>{new Date(tx.madeAt).toLocaleString()}</div>
<div>{tx.privacy}</div>
<pre>
{correspondingValidTx
? JSON.stringify(
correspondingValidTx.changes,
undefined,
2
)
: "invalid/undecryptable"}
</pre>
</div>
);
})}
</div>
<div className="text-xs">
{session.lastHash} / {session.lastSignature}{" "}
</div>
</div>
))}
</div>
);
}
function SessionInfo({
sessionID,
transactionCount,
}: {
sessionID: SessionID;
transactionCount: number;
}) {
let Prefix = sessionID.startsWith("co_") ? (
<AccountInfo accountID={sessionID.split("_session_")[0] as CoID<Account>} />
) : (
<pre className="text-xs">{sessionID.split("_session_")[0]}</pre>
);
return (
<div>
{Prefix}
<div>
<span className="text-xs">
Session {sessionID.split("_session_")[1]}
</span>
<span className="text-xs text-gray-600 font-medium">
{" "}
- {transactionCount} txs
</span>
</div>
</div>
);
}
function AccountInfo({ accountID }: { accountID: CoID<Account> }) {
const account = useAutoSub(accountID);
return (
<div className="flex items-center gap-2">
<h1>{account?.profile?.name}</h1>
<Tag href={`#/${accountID}`}>{account?.id}</Tag>
</div>
);
}

View File

@@ -1,151 +0,0 @@
import clsx from "clsx";
import { CoID, CoValue } from "cojson";
import { useAutoSub } from "jazz-react";
import { useState } from "react";
import { LinkIcon } from "./link-icon";
export function CoJsonTree({ coValueId }: { coValueId: CoID<CoValue> }) {
const coValue = useAutoSub(coValueId);
const values = coValue?.meta.coValue.toJSON() || {};
return <RenderCoValueJSON json={values} />;
}
function RenderObject({ json }: { json: Record<string, any> }) {
const [limit, setLimit] = useState(10);
const hasMore = Object.keys(json).length > limit;
const entries = Object.entries(json).slice(0, limit);
return (
<div className="flex gap-x-1 flex-col font-mono text-xs overflow-auto">
{entries.map(([key, value]) => {
return <RenderObjectValue property={key} value={value} />;
})}
{hasMore ? (
<div
className="text-gray-500 cursor-pointer"
onClick={() => setLimit((l) => l + 10)}
>
... {Object.keys(json).length - limit} more
</div>
) : null}
</div>
);
}
function RenderObjectValue({
property,
value,
}: {
property: string;
value: any;
}) {
const [shouldLoad, setShouldLoad] = useState(false);
const isCoValue =
typeof value === "string" ? value?.startsWith("co_") : false;
return (
<div className={clsx(`flex group`)}>
<span className="text-gray-500 flex">
<RenderCoValueJSON json={property} />:{" "}
</span>
{isCoValue ? (
<div className={clsx(shouldLoad && "pb-2")}>
<div className="flex items-center ">
<div onClick={() => setShouldLoad((s) => !s)}>
<div className="w-8 text-center text-gray-700 font-mono px-1 text-xs rounded hover:bg-gray-300 cursor-pointer">
{shouldLoad ? `-` : `...`}
</div>
</div>
<a href={`#/${value}`} className="ml-2 group-hover:block hidden">
<LinkIcon />
</a>
</div>
<span>{shouldLoad ? <CoJsonTree coValueId={value} /> : null}</span>
</div>
) : (
<div className="">
<RenderCoValueJSON json={value} />
</div>
)}
</div>
);
}
function RenderCoValueArray({ json }: { json: any[] }) {
const [limit, setLimit] = useState(10);
const hasMore = json.length > limit;
const entries = json.slice(0, limit);
return (
<div className="flex gap-x-1 flex-col font-mono text-xs overflow-auto">
{entries.map((value, idx) => {
return (
<div key={idx} className="flex gap-x-1">
<RenderCoValueJSON json={value} />
</div>
);
})}
{hasMore ? (
<div
className="text-gray-500 cursor-pointer"
onClick={() => setLimit((l) => l + 10)}
>
... {json.length - limit} more
</div>
) : null}
</div>
);
}
function RenderCoValueJSON({
json,
}: {
json:
| Record<string, any>
| any[]
| string
| null
| number
| boolean
| undefined;
}) {
if (typeof json === "undefined") {
return <>"undefined"</>;
} else if (Array.isArray(json)) {
return (
<div className="">
<span className="text-gray-500">[</span>
<div className="ml-2">
<RenderCoValueArray json={json} />
</div>
<span className="text-gray-500">]</span>
</div>
);
} else if (
typeof json === "object" &&
json &&
Object.getPrototypeOf(json) === Object.prototype
) {
return <RenderObject json={json} />;
} else if (typeof json === "string") {
if (json?.startsWith("co_")) {
return (
<>
<a className="underline" href={`#/${json}`}>
{'"'}
{json}
{'"'}
</a>
</>
);
} else {
return <div className="truncate max-w-64 ml-1">{json}</div>;
}
} else {
return <div className="truncate max-w-64">{JSON.stringify(json)}</div>;
}
}

View File

@@ -1,78 +0,0 @@
@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;
margin: 0;
padding: 0;
}
}

View File

@@ -1,18 +0,0 @@
export function LinkIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
</svg>
);
}

View File

@@ -6,10 +6,9 @@
"packages/*",
"examples/*"
],
"packageManager": "pnpm@9.1.4",
"devDependencies": {
"@changesets/cli": "^2.27.3",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"prettier": "^3.1.1",
"ts-node": "^10.9.1",
"turbo": "^1.11.2",
@@ -24,8 +23,7 @@
"format": "pnpm run -r format && cd homepage/homepage && pnpm run format",
"changeset": "changeset",
"changeset-version": "changeset version",
"release": "pnpm changeset publish && git push --follow-tags",
"prepare": "husky"
"release": "pnpm changeset publish && git push --follow-tags"
},
"lint-staged": {},
"version": "0.0.0"

View File

@@ -1,5 +1,95 @@
# cojson-storage-indexeddb
## 0.7.33
### Patch Changes
- 3bf5127: Allow crashing whole node on peer errors
- Updated dependencies [b297c93]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Allow crashing whole node on peer errors
- Updated dependencies
- cojson@0.7.33-hotfixes.3
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
## 0.7.31
### Patch Changes
- Updated dependencies
- cojson@0.7.31
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
- Updated dependencies
- cojson@0.7.26
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.14
### Patch Changes

View File

@@ -1,13 +1,12 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.7.14",
"version": "0.7.33",
"main": "dist/index.js",
"type": "module",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:*",
"effect": "^3.1.5",
"typescript": "^5.1.6"
},
"devDependencies": {

View File

@@ -10,7 +10,6 @@ import {
OutgoingSyncQueue,
} from "cojson";
import { SyncPromise } from "./syncPromises.js";
import { Effect, Queue, Stream } from "effect";
type CoValueRow = {
id: CojsonInternalTypes.RawCoID;
@@ -53,11 +52,15 @@ export class IDBStorage {
this.db = db;
this.toLocalNode = toLocalNode;
void fromLocalNode.pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => this.handleSyncMessage(msg),
catch: (e) =>
const processMessages = async () => {
for await (const msg of fromLocalNode) {
try {
if (msg === "Disconnected" || msg === "PingTimeout") {
throw new Error("Unexpected Disconnected message");
}
await this.handleSyncMessage(msg);
} catch (e) {
console.error(
new Error(
`Error reading from localNode, handling msg\n\n${JSON.stringify(
msg,
@@ -68,9 +71,13 @@ export class IDBStorage {
)}`,
{ cause: e },
),
}),
),
Effect.runPromise,
);
}
}
};
processMessages().catch((e) =>
console.error("Error in processMessages in IndexedDB", e),
);
}
@@ -82,25 +89,18 @@ export class IDBStorage {
localNodeName: "local",
},
): Promise<Peer> {
return Effect.runPromise(
Effect.gen(function* () {
const [localNodeAsPeer, storageAsPeer] =
yield* cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace },
);
yield* Effect.promise(() =>
IDBStorage.open(
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
),
);
return { ...storageAsPeer, priority: 100 };
}),
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace, crashOnClose: true },
);
await IDBStorage.open(
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
);
return { ...storageAsPeer, priority: 100 };
}
static async open(
@@ -392,35 +392,40 @@ export class IDBStorage {
),
).then(() => {
// we're done with IndexedDB stuff here so can use native Promises again
setTimeout(() =>
Effect.runPromise(
Effect.gen(this, function* () {
yield* Queue.offer(this.toLocalNode, {
action: "known",
...ourKnown,
asDependencyOf,
});
setTimeout(() => {
this.toLocalNode
.push({
action: "known",
...ourKnown,
asDependencyOf,
})
.catch((e) =>
console.error(
"Error sending known state",
e,
),
);
const nonEmptyNewContentPieces =
newContentPieces.filter(
(piece) =>
piece.header ||
Object.keys(piece.new)
.length > 0,
);
const nonEmptyNewContentPieces =
newContentPieces.filter(
(piece) =>
piece.header ||
Object.keys(piece.new).length > 0,
);
// console.log(theirKnown.id, nonEmptyNewContentPieces);
// console.log(theirKnown.id, nonEmptyNewContentPieces);
for (const piece of nonEmptyNewContentPieces) {
yield* Queue.offer(
this.toLocalNode,
piece,
);
yield* Effect.yieldNow();
}
}),
),
);
for (const piece of nonEmptyNewContentPieces) {
this.toLocalNode
.push(piece)
.catch((e) =>
console.error(
"Error sending new content piece",
e,
),
);
}
});
return Promise.resolve();
});
@@ -445,16 +450,18 @@ export class IDBStorage {
const header = msg.header;
if (!header) {
console.error("Expected to be sent header first");
void Effect.runPromise(
Queue.offer(this.toLocalNode, {
this.toLocalNode
.push({
action: "known",
id: msg.id,
header: false,
sessions: {},
isCorrection: true,
}),
);
throw new Error("Expected to be sent header first");
})
.catch((e) =>
console.error("Error sending known state", e),
);
return SyncPromise.resolve();
}
return this.makeRequest<IDBValidKey>(({ coValues }) =>
@@ -515,13 +522,18 @@ export class IDBStorage {
),
).then(() => {
if (invalidAssumptions) {
void Effect.runPromise(
Queue.offer(this.toLocalNode, {
this.toLocalNode
.push({
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
}),
);
})
.catch((e) =>
console.error(
"Error sending known state",
e,
),
);
}
});
});

View File

@@ -1,5 +1,102 @@
# cojson-storage-sqlite
## 0.7.33
### Patch Changes
- 3bf5127: Allow crashing whole node on peer errors
- Updated dependencies [b297c93]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson@0.7.33
## 0.7.33-hotfixes.6
### Patch Changes
- Get rid of simulated errors
## 0.7.33-hotfixes.5
### Patch Changes
- Make simulated errors even more likely
- Updated dependencies
- cojson@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Allow crashing whole node on peer errors
- Updated dependencies
- cojson@0.7.33-hotfixes.3
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
## 0.7.31
### Patch Changes
- Updated dependencies
- cojson@0.7.31
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
- Updated dependencies
- cojson@0.7.26
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.14
### Patch Changes

View File

@@ -1,14 +1,13 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.7.14",
"version": "0.7.33",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.5.2",
"cojson": "workspace:*",
"effect": "^3.1.5",
"typescript": "^5.1.6"
},
"devDependencies": {

View File

@@ -11,7 +11,6 @@ import {
} from "cojson";
import Database, { Database as DatabaseT } from "better-sqlite3";
import { Effect, Queue, Stream } from "effect";
type CoValueRow = {
id: CojsonInternalTypes.RawCoID;
@@ -54,11 +53,15 @@ export class SQLiteStorage {
this.db = db;
this.toLocalNode = toLocalNode;
void fromLocalNode.pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => this.handleSyncMessage(msg),
catch: (e) =>
const processMessages = async () => {
for await (const msg of fromLocalNode) {
try {
if (msg === "Disconnected" || msg === "PingTimeout") {
throw new Error("Unexpected Disconnected message");
}
await this.handleSyncMessage(msg);
} catch (e) {
console.error(
new Error(
`Error reading from localNode, handling msg\n\n${JSON.stringify(
msg,
@@ -69,9 +72,13 @@ export class SQLiteStorage {
)}`,
{ cause: e },
),
}),
),
Effect.runPromise,
);
}
}
};
processMessages().catch((e) =>
console.error("Error in processMessages in sqlite", e),
);
}
@@ -84,26 +91,19 @@ export class SQLiteStorage {
trace?: boolean;
localNodeName?: string;
}): Promise<Peer> {
return Effect.runPromise(
Effect.gen(function* () {
const [localNodeAsPeer, storageAsPeer] =
yield* cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace },
);
yield* Effect.promise(() =>
SQLiteStorage.open(
filename,
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
),
);
return { ...storageAsPeer, priority: 100 };
}),
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace, crashOnClose: true },
);
await SQLiteStorage.open(
filename,
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing,
);
return { ...storageAsPeer, priority: 100 };
}
static async open(
@@ -441,13 +441,13 @@ export class SQLiteStorage {
);
}
await Effect.runPromise(
Queue.offer(this.toLocalNode, {
this.toLocalNode
.push({
action: "known",
...ourKnown,
asDependencyOf,
}),
);
})
.catch((e) => console.error("Error while pushing known", e));
const nonEmptyNewContentPieces = newContentPieces.filter(
(piece) => piece.header || Object.keys(piece.new).length > 0,
@@ -456,7 +456,11 @@ export class SQLiteStorage {
// console.log(theirKnown.id, nonEmptyNewContentPieces);
for (const piece of nonEmptyNewContentPieces) {
await Effect.runPromise(Queue.offer(this.toLocalNode, piece));
this.toLocalNode
.push(piece)
.catch((e) =>
console.error("Error while pushing content piece", e),
);
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
@@ -478,15 +482,17 @@ export class SQLiteStorage {
const header = msg.header;
if (!header) {
console.error("Expected to be sent header first");
await Effect.runPromise(
Queue.offer(this.toLocalNode, {
this.toLocalNode
.push({
action: "known",
id: msg.id,
header: false,
sessions: {},
isCorrection: true,
}),
);
})
.catch((e) =>
console.error("Error while pushing known", e),
);
return;
}
@@ -618,13 +624,13 @@ export class SQLiteStorage {
})();
if (invalidAssumptions) {
await Effect.runPromise(
Queue.offer(this.toLocalNode, {
this.toLocalNode
.push({
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
}),
);
})
.catch((e) => console.error("Error while pushing known", e));
}
}

View File

@@ -1,5 +1,128 @@
# cojson-transport-nodejs-ws
## 0.7.33
### Patch Changes
- fdde8db: Handle ws closing while buffering
- 07fe2b9: Wait if WS buffer is full
- 3bf5127: Allow crashing whole node on peer errors
- Updated dependencies [b297c93]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Allow crashing whole node on peer errors
- Updated dependencies
- cojson@0.7.33-hotfixes.3
## 0.7.33-hotfixes.2
### Patch Changes
- Handle ws closing while buffering
## 0.7.33-hotfixes.1
### Patch Changes
- Wait if WS buffer is full
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
## 0.7.31
### Patch Changes
- Properly close connecting websockets
- Updated dependencies
- cojson@0.7.31
## 0.7.30
### Patch Changes
- Immediately resolve an already-open websocket
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
## 0.7.27
### Patch Changes
- Option to not expect pings
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
- Updated dependencies
- cojson@0.7.26
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
## 0.7.22
### Patch Changes
- Increase disconnect timeout for now
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.14
### Patch Changes

View File

@@ -1,13 +1,12 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.7.14",
"version": "0.7.33",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:*",
"effect": "^3.1.5",
"typescript": "^5.1.6"
},
"scripts": {

View File

@@ -1,108 +1,139 @@
import { DisconnectedError, Peer, PingTimeoutError, SyncMessage } from "cojson";
import { Either, Stream, Queue, Effect, Exit } from "effect";
import {
DisconnectedError,
Peer,
PingTimeoutError,
SyncMessage,
cojsonInternals,
} from "cojson";
interface AnyWebSocket {
addEventListener(
type: "close",
listener: (event: { code: number; reason: string }) => void,
): void;
addEventListener(
type: "message",
listener: (event: { data: string | unknown }) => void,
): void;
addEventListener(type: "open", listener: () => void): void;
close(): void;
send(data: string): void;
interface WebsocketEvents {
close: { code: number; reason: string };
message: { data: unknown };
open: void;
}
interface PingMsg {
time: number;
dc: string;
}
export function createWebSocketPeer(options: {
interface AnyWebSocket {
addEventListener<K extends keyof WebsocketEvents>(
type: K,
listener: (event: WebsocketEvents[K]) => void,
options?: { once: boolean },
): void;
removeEventListener<K extends keyof WebsocketEvents>(
type: K,
listener: (event: WebsocketEvents[K]) => void,
): void;
close(): void;
send(data: string): void;
readyState: number;
bufferedAmount: number;
}
const g: typeof globalThis & {
jazzPings?: {
received: number;
sent: number;
dc: string;
}[];
} = globalThis;
export function createWebSocketPeer({
id,
websocket,
role,
expectPings = true,
}: {
id: string;
websocket: AnyWebSocket;
role: Peer["role"];
}): Effect.Effect<Peer> {
return Effect.gen(function* () {
const ws = options.websocket;
expectPings?: boolean;
}): Peer {
const incoming = new cojsonInternals.Channel<
SyncMessage | DisconnectedError | PingTimeoutError
>();
const incoming =
yield* Queue.unbounded<
Either.Either<SyncMessage, DisconnectedError | PingTimeoutError>
>();
const outgoing = yield* Queue.unbounded<SyncMessage>();
ws.addEventListener("close", (event) => {
void Effect.runPromiseExit(
Queue.offer(
incoming,
Either.left(
new DisconnectedError(`${event.code}: ${event.reason}`),
),
),
).then((e) => {
if (Exit.isFailure(e) && !Exit.isInterrupted(e)) {
console.warn("Failed closing ws", e);
}
});
});
let pingTimeout: ReturnType<typeof setTimeout> | undefined;
ws.addEventListener("message", (event) => {
const msg = JSON.parse(event.data as string);
if (pingTimeout) {
clearTimeout(pingTimeout);
}
pingTimeout = setTimeout(() => {
console.debug("Ping timeout");
void Effect.runPromise(
Queue.offer(incoming, Either.left(new PingTimeoutError())),
);
try {
ws.close();
} catch (e) {
console.error(
"Error while trying to close ws on ping timeout",
e,
);
}
}, 2500);
if (msg.type === "ping") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).jazzPings =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).jazzPings || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).jazzPings.push({
received: Date.now(),
sent: msg.time,
dc: msg.dc,
});
return;
} else {
void Effect.runPromise(
Queue.offer(incoming, Either.right(msg)),
);
}
});
ws.addEventListener("open", () => {
void Stream.fromQueue(outgoing).pipe(
Stream.runForEach((msg) =>
Effect.sync(() => ws.send(JSON.stringify(msg))),
),
Effect.runPromise,
websocket.addEventListener("close", function handleClose() {
incoming
.push("Disconnected")
.catch((e) =>
console.error("Error while pushing disconnect msg", e),
);
});
return {
id: options.id,
incoming: Stream.fromQueue(incoming, { shutdown: true }).pipe(
Stream.mapEffect((either) => either),
),
outgoing,
role: options.role,
};
});
let pingTimeout: ReturnType<typeof setTimeout> | null = null;
websocket.addEventListener("message", function handleIncomingMsg(event) {
const msg = JSON.parse(event.data as string);
pingTimeout && clearTimeout(pingTimeout);
if (msg?.type === "ping") {
const ping = msg as PingMsg;
g.jazzPings ||= [];
g.jazzPings.push({
received: Date.now(),
sent: ping.time,
dc: ping.dc,
});
} else {
incoming
.push(msg)
.catch((e) =>
console.error("Error while pushing incoming msg", e),
);
}
if (expectPings) {
pingTimeout = setTimeout(() => {
incoming
.push("PingTimeout")
.catch((e) =>
console.error("Error while pushing ping timeout", e),
);
}, 10_000);
}
});
const websocketOpen = new Promise<void>((resolve) => {
if (websocket.readyState === 1) {
resolve();
} else {
websocket.addEventListener("open", resolve, { once: true });
}
});
return {
id,
incoming,
outgoing: {
async push(msg) {
await websocketOpen;
if (websocket.readyState === 1) {
while (websocket.bufferedAmount > 1_000_000) {
console.log("WS buffer filling", id, websocket.bufferedAmount);
await new Promise((resolve) =>
setTimeout(resolve, 100),
);
if (websocket.readyState !== 1) {
console.log("WebSocket closed while buffering", id, websocket.bufferedAmount);
return;
}
}
websocket.send(JSON.stringify(msg));
}
},
close() {
console.log("Trying to close", id, websocket.readyState)
if (websocket.readyState === 0) {
websocket.addEventListener("open", function handleClose() {
websocket.close();
}, { once: true });
} else if (websocket.readyState == 1) {
websocket.close();
}
},
},
role,
crashOnClose: false,
};
}

View File

@@ -1,5 +1,79 @@
# cojson
## 0.7.33
### Patch Changes
- b297c93: Improve logging
- 3bf5127: Allow crashing whole node on peer errors
- a8b74ff: Throw properly on peer that should crash on close
## 0.7.33-hotfixes.5
### Patch Changes
- Make simulated errors even more likely
## 0.7.33-hotfixes.4
### Patch Changes
- Throw properly on peer that should crash on close
## 0.7.33-hotfixes.3
### Patch Changes
- Allow crashing whole node on peer errors
## 0.7.33-hotfixes.0
### Patch Changes
- Improve logging
## 0.7.31
### Patch Changes
- Close both ends of the peer on gracefulShutdown
## 0.7.29
### Patch Changes
- Remove noisy log
## 0.7.28
### Patch Changes
- Fix ignoring server peers
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
## 0.7.23
### Patch Changes
- Mostly complete OPFS implementation (single-tab only)
## 0.7.18
### Patch Changes
- Update to Effect 3.5.2
## 0.7.17
### Patch Changes
- Fix bugs in new storage interface
## 0.7.14
### Patch Changes

View File

@@ -5,7 +5,7 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.7.14",
"version": "0.7.33",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",
@@ -18,12 +18,12 @@
},
"dependencies": {
"@hazae41/berith": "^1.2.6",
"@noble/curves": "^1.3.0",
"@noble/ciphers": "^0.1.3",
"@noble/curves": "^1.3.0",
"@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.1",
"effect": "^3.1.5",
"hash-wasm": "^4.9.0"
"hash-wasm": "^4.9.0",
"queueable": "^5.3.2"
},
"scripts": {
"dev": "tsc --watch --sourceMap --outDir dist",

View File

@@ -26,6 +26,13 @@ import { expectGroup } from "./typeUtils/expectGroup.js";
import { isAccountID } from "./typeUtils/isAccountID.js";
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
/**
In order to not block other concurrently syncing CoValues we introduce a maximum size of transactions,
since they are the smallest unit of progress that can be synced within a CoValue.
This is particularly important for storing binary data in CoValues, since they are likely to be at least on the order of megabytes.
This also means that we want to keep signatures roughly after each MAX_RECOMMENDED_TX size chunk,
to be able to verify partially loaded CoValues or CoValues that are still being created (like a video live stream).
**/
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
export type CoValueHeader = {
@@ -360,6 +367,9 @@ export class CoValueCore {
newStreamingHash: StreamingHash,
notifyMode: "immediate" | "deferred",
) {
if (this.node.crashed) {
throw new Error("Trying to add transactions after node is crashed");
}
const transactions =
this.sessionLogs.get(sessionID)?.transactions ?? [];
transactions.push(...newTransactions);
@@ -383,7 +393,7 @@ export class CoValueCore {
0,
);
if (sizeOfTxsSinceLastInbetweenSignature > 100 * 1024) {
if (sizeOfTxsSinceLastInbetweenSignature > MAX_RECOMMENDED_TX_SIZE) {
// console.log(
// "Saving inbetween signature for tx ",
// sessionID,

View File

@@ -18,7 +18,7 @@ import {
} from "./crypto/crypto.js";
import { WasmCrypto } from "./crypto/WasmCrypto.js";
import { PureJSCrypto } from "./crypto/PureJSCrypto.js";
import { connectedPeers } from "./streamUtils.js";
import { connectedPeers, Channel } from "./streamUtils.js";
import { ControlledAgent, RawControlledAccount } from "./coValues/account.js";
import type { Role } from "./permissions.js";
import { rawCoIDtoBytes, rawCoIDfromBytes, isRawCoID } from "./ids.js";
@@ -59,12 +59,7 @@ import type * as Media from "./media.js";
type Value = JsonValue | AnyRawCoValue;
import {
LSMStorage,
FSErr,
BlockFilename,
WalFilename,
} from "./storage/index.js";
import { LSMStorage, BlockFilename, WalFilename } from "./storage/index.js";
import { FileSystem } from "./storage/FileSystem.js";
/** @hidden */
@@ -84,6 +79,7 @@ export const cojsonInternals = {
accountHeaderForInitialAgentSecret,
idforHeader,
StreamingHash,
Channel,
};
export {
@@ -123,18 +119,17 @@ export {
SyncMessage,
isRawCoID,
LSMStorage,
DisconnectedError,
PingTimeoutError,
};
export type {
Value,
FileSystem,
FSErr,
BlockFilename,
WalFilename,
IncomingSyncStream,
OutgoingSyncQueue,
DisconnectedError,
PingTimeoutError,
};
// eslint-disable-next-line @typescript-eslint/no-namespace

View File

@@ -49,6 +49,8 @@ export class LocalNode {
/** @category 3. Low-level */
syncManager = new SyncManager(this);
crashed: Error | undefined = undefined;
/** @category 3. Low-level */
constructor(
account: ControlledAccountOrAgent,
@@ -240,6 +242,10 @@ export class LocalNode {
/** @internal */
createCoValue(header: CoValueHeader): CoValueCore {
if (this.crashed) {
throw new Error("Trying to create CoValue after node has crashed", { cause: this.crashed });
}
const coValue = new CoValueCore(header, this);
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
@@ -257,6 +263,10 @@ export class LocalNode {
onProgress?: (progress: number) => void;
} = {},
): Promise<CoValueCore | "unavailable"> {
if (this.crashed) {
throw new Error("Trying to load CoValue after node has crashed", { cause: this.crashed });
}
let entry = this.coValues[id];
if (!entry) {
const peersToWaitFor = new Set(
@@ -670,6 +680,10 @@ export class LocalNode {
return newNode;
}
gracefulShutdown() {
this.syncManager.gracefulShutdown();
}
}
/** @internal */

View File

@@ -1,9 +1,8 @@
import { Effect } from "effect";
import { CoValueChunk } from "./index.js";
import { RawCoID } from "../ids.js";
import { CryptoProvider, StreamingHash } from "../crypto/crypto.js";
export type BlockFilename = `${string}-L${number}-H${number}.jsonl`;
export type BlockFilename = `L${number}-${string}-${string}-H${number}.jsonl`;
export type BlockHeader = { id: RawCoID; start: number; length: number }[];
@@ -11,143 +10,124 @@ export type WalEntry = { id: RawCoID } & CoValueChunk;
export type WalFilename = `wal-${number}.jsonl`;
export type FSErr = {
type: "fileSystemError";
error: Error;
};
export interface FileSystem<WriteHandle, ReadHandle> {
crypto: CryptoProvider;
createFile(filename: string): Effect.Effect<WriteHandle, FSErr>;
append(handle: WriteHandle, data: Uint8Array): Effect.Effect<void, FSErr>;
close(handle: ReadHandle | WriteHandle): Effect.Effect<void, FSErr>;
closeAndRename(
handle: WriteHandle,
filename: BlockFilename,
): Effect.Effect<void, FSErr>;
openToRead(
filename: string,
): Effect.Effect<{ handle: ReadHandle; size: number }, FSErr>;
createFile(filename: string): Promise<WriteHandle>;
append(handle: WriteHandle, data: Uint8Array): Promise<void>;
close(handle: ReadHandle | WriteHandle): Promise<void>;
closeAndRename(handle: WriteHandle, filename: BlockFilename): Promise<void>;
openToRead(filename: string): Promise<{ handle: ReadHandle; size: number }>;
read(
handle: ReadHandle,
offset: number,
length: number,
): Effect.Effect<Uint8Array, FSErr>;
listFiles(): Effect.Effect<string[], FSErr>;
removeFile(
filename: BlockFilename | WalFilename,
): Effect.Effect<void, FSErr>;
): Promise<Uint8Array>;
listFiles(): Promise<string[]>;
removeFile(filename: BlockFilename | WalFilename): Promise<void>;
}
export const textEncoder = new TextEncoder();
export const textDecoder = new TextDecoder();
export function readChunk<RH, FS extends FileSystem<unknown, RH>>(
export async function readChunk<RH, FS extends FileSystem<unknown, RH>>(
handle: RH,
header: { start: number; length: number },
fs: FS,
): Effect.Effect<CoValueChunk, FSErr> {
return Effect.gen(function* ($) {
const chunkBytes = yield* $(
fs.read(handle, header.start, header.length),
);
): Promise<CoValueChunk> {
const chunkBytes = await fs.read(handle, header.start, header.length);
const chunk = JSON.parse(textDecoder.decode(chunkBytes));
return chunk;
});
const chunk = JSON.parse(textDecoder.decode(chunkBytes));
return chunk;
}
export function readHeader<RH, FS extends FileSystem<unknown, RH>>(
export async function readHeader<RH, FS extends FileSystem<unknown, RH>>(
filename: string,
handle: RH,
size: number,
fs: FS,
): Effect.Effect<BlockHeader, FSErr> {
return Effect.gen(function* ($) {
const headerLength = Number(filename.match(/-H(\d+)\.jsonl$/)![1]!);
): Promise<BlockHeader> {
const headerLength = Number(filename.match(/-H(\d+)\.jsonl$/)![1]!);
const headerBytes = yield* $(
fs.read(handle, size - headerLength, headerLength),
);
const headerBytes = await fs.read(
handle,
size - headerLength,
headerLength,
);
const header = JSON.parse(textDecoder.decode(headerBytes));
return header;
});
const header = JSON.parse(textDecoder.decode(headerBytes));
return header;
}
export function writeBlock<WH, RH, FS extends FileSystem<WH, RH>>(
export async function writeBlock<WH, RH, FS extends FileSystem<WH, RH>>(
chunks: Map<RawCoID, CoValueChunk>,
level: number,
blockNumber: number,
fs: FS,
): Effect.Effect<void, FSErr> {
): Promise<BlockFilename> {
if (chunks.size === 0) {
return Effect.die(new Error("No chunks to write"));
throw new Error("No chunks to write");
}
return Effect.gen(function* ($) {
const blockHeader: BlockHeader = [];
const blockHeader: BlockHeader = [];
let offset = 0;
let offset = 0;
const file = yield* $(
fs.createFile(
"wipBlock" +
Math.random().toString(36).substring(7) +
".tmp.jsonl",
),
);
const hash = new StreamingHash(fs.crypto);
const file = await fs.createFile(
"wipBlock" + Math.random().toString(36).substring(7) + ".tmp.jsonl",
);
const hash = new StreamingHash(fs.crypto);
const chunksSortedById = Array.from(chunks).sort(([id1], [id2]) =>
id1.localeCompare(id2),
);
const chunksSortedById = Array.from(chunks).sort(([id1], [id2]) =>
id1.localeCompare(id2),
);
for (const [id, chunk] of chunksSortedById) {
const encodedBytes = hash.update(chunk);
const encodedBytesWithNewline = new Uint8Array(
encodedBytes.length + 1,
);
encodedBytesWithNewline.set(encodedBytes);
encodedBytesWithNewline[encodedBytes.length] = 10;
yield* $(fs.append(file, encodedBytesWithNewline));
const length = encodedBytesWithNewline.length;
blockHeader.push({ id, start: offset, length });
offset += length;
}
for (const [id, chunk] of chunksSortedById) {
const encodedBytes = hash.update(chunk);
const encodedBytesWithNewline = new Uint8Array(encodedBytes.length + 1);
encodedBytesWithNewline.set(encodedBytes);
encodedBytesWithNewline[encodedBytes.length] = 10;
await fs.append(file, encodedBytesWithNewline);
const length = encodedBytesWithNewline.length;
blockHeader.push({ id, start: offset, length });
offset += length;
}
const headerBytes = textEncoder.encode(JSON.stringify(blockHeader));
yield* $(fs.append(file, headerBytes));
const headerBytes = textEncoder.encode(JSON.stringify(blockHeader));
await fs.append(file, headerBytes);
// console.log(
// "full file",
// yield* $(
// fs.read(file as unknown as RH, 0, offset + headerBytes.length),
// ),
// );
// console.log(
// "full file",
// yield* $(
// fs.read(file as unknown as RH, 0, offset + headerBytes.length),
// ),
// );
const filename: BlockFilename = `${hash.digest()}-L${level}-H${
headerBytes.length
}.jsonl`;
// console.log("renaming to" + filename);
yield* $(fs.closeAndRename(file, filename));
const filename: BlockFilename = `L${level}-${(blockNumber + "").padStart(
3,
"0",
)}-${hash.digest().replace("hash_", "").slice(0, 15)}-H${
headerBytes.length
}.jsonl`;
// console.log("renaming to" + filename);
await fs.closeAndRename(file, filename);
// console.log("Wrote block", filename, blockHeader);
// console.log("IDs in block", blockHeader.map(e => e.id));
});
return filename;
// console.log("Wrote block", filename, blockHeader);
// console.log("IDs in block", blockHeader.map(e => e.id));
}
export function writeToWal<WH, RH, FS extends FileSystem<WH, RH>>(
export async function writeToWal<WH, RH, FS extends FileSystem<WH, RH>>(
handle: WH,
fs: FS,
id: RawCoID,
chunk: CoValueChunk,
): Effect.Effect<void, FSErr> {
return Effect.gen(function* ($) {
const walEntry: WalEntry = {
id,
...chunk,
};
const bytes = textEncoder.encode(JSON.stringify(walEntry) + "\n");
yield* $(fs.append(handle, bytes));
});
) {
const walEntry: WalEntry = {
id,
...chunk,
};
const bytes = textEncoder.encode(JSON.stringify(walEntry) + "\n");
console.log("writing to WAL", handle, id, bytes.length);
return fs.append(handle, bytes);
}

View File

@@ -1,4 +1,3 @@
import { Either } from "effect";
import { RawCoID, SessionID } from "../ids.js";
import { MAX_RECOMMENDED_TX_SIZE } from "../index.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
@@ -80,7 +79,7 @@ export function chunkToKnownState(id: RawCoID, chunk: CoValueChunk) {
export function mergeChunks(
chunkA: CoValueChunk,
chunkB: CoValueChunk,
): Either.Either<"nonContigous", CoValueChunk> {
): "nonContigous" | CoValueChunk {
const header = chunkA.header || chunkB.header;
const newSessions = { ...chunkA.sessionEntries };
@@ -133,9 +132,9 @@ export function mergeChunks(
}
newSessions[sessionID] = newEntries;
} else {
return Either.right("nonContigous" as const);
return "nonContigous" as const;
}
}
return Either.left({ header, sessionEntries: newSessions });
return { header, sessionEntries: newSessions };
}

View File

@@ -1,4 +1,3 @@
import { Effect, Either, Queue, Stream, SynchronizedRef } from "effect";
import { RawCoID } from "../ids.js";
import { CoValueHeader, Transaction } from "../coValueCore.js";
import { Signature } from "../crypto/crypto.js";
@@ -18,7 +17,6 @@ import {
} from "./chunksAndKnownStates.js";
import {
BlockFilename,
FSErr,
FileSystem,
WalEntry,
WalFilename,
@@ -28,7 +26,9 @@ import {
writeBlock,
writeToWal,
} from "./FileSystem.js";
export type { FSErr, BlockFilename, WalFilename } from "./FileSystem.js";
export type { BlockFilename, WalFilename } from "./FileSystem.js";
const MAX_N_LEVELS = 3;
export type CoValueChunk = {
header?: CoValueHeader;
@@ -42,410 +42,511 @@ export type CoValueChunk = {
};
export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
currentWal: SynchronizedRef.SynchronizedRef<WH | undefined>;
coValues: SynchronizedRef.SynchronizedRef<{
currentWal: WH | undefined;
coValues: {
[id: RawCoID]: CoValueChunk | undefined;
}>;
};
fileCache: string[] | undefined;
headerCache = new Map<
BlockFilename,
{ [id: RawCoID]: { start: number; length: number } }
>();
blockFileHandles = new Map<
BlockFilename,
Promise<{ handle: RH; size: number }>
>();
constructor(
public fs: FS,
public fromLocalNode: IncomingSyncStream,
public toLocalNode: OutgoingSyncQueue,
) {
this.coValues = SynchronizedRef.unsafeMake({});
this.currentWal = SynchronizedRef.unsafeMake<WH | undefined>(undefined);
this.coValues = {};
this.currentWal = undefined;
void this.fromLocalNode.pipe(
Stream.runForEach((msg) =>
Effect.gen(this, function* () {
let nMsg = 0;
const processMessages = async () => {
for await (const msg of fromLocalNode) {
console.log("Storage msg start", nMsg);
try {
if (msg === "Disconnected" || msg === "PingTimeout") {
throw new Error("Unexpected Disconnected message");
}
if (msg.action === "done") {
return;
}
if (msg.action === "content") {
yield* this.handleNewContent(msg);
await this.handleNewContent(msg);
} else {
yield* this.sendNewContent(msg.id, msg, undefined);
await this.sendNewContent(msg.id, msg, undefined);
}
}),
),
Effect.runPromise,
);
setTimeout(() => this.compact(), 20000);
}
sendNewContent(
id: RawCoID,
known: CoValueKnownState | undefined,
asDependencyOf: RawCoID | undefined,
): Effect.Effect<void, FSErr> {
return SynchronizedRef.updateEffect(this.coValues, (coValues) =>
this.sendNewContentInner(coValues, id, known, asDependencyOf),
);
}
private sendNewContentInner(
coValues: { [id: `co_z${string}`]: CoValueChunk | undefined },
id: RawCoID,
known: CoValueKnownState | undefined,
asDependencyOf: RawCoID | undefined,
): Effect.Effect<
{ [id: `co_z${string}`]: CoValueChunk | undefined },
FSErr,
never
> {
return Effect.gen(this, function* () {
let coValue = coValues[id];
if (!coValue) {
coValue = yield* this.loadCoValue(id, this.fs);
} catch (e) {
console.error(
new Error(
`Error reading from localNode, handling msg\n\n${JSON.stringify(
msg,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
)}`,
{ cause: e },
),
);
}
console.log("Storage msg end", nMsg);
nMsg++;
}
};
if (!coValue) {
yield* Queue.offer(this.toLocalNode, {
processMessages().catch((e) =>
console.error("Error in processMessages in storage", e),
);
setTimeout(
() =>
this.compact().catch((e) => {
console.error("Error while compacting", e);
}),
20000,
);
}
async sendNewContent(
id: RawCoID,
known: CoValueKnownState | undefined,
asDependencyOf: RawCoID | undefined,
) {
let coValue = this.coValues[id];
if (!coValue) {
coValue = await this.loadCoValue(id, this.fs);
}
if (!coValue) {
this.toLocalNode
.push({
id: id,
action: "known",
header: false,
sessions: {},
asDependencyOf,
});
})
.catch((e) => console.error("Error while pushing known", e));
return coValues;
}
return;
}
if (
!known?.header &&
coValue.header?.ruleset.type === "ownedByGroup"
) {
coValues = yield* this.sendNewContentInner(
coValues,
coValue.header.ruleset.group,
undefined,
asDependencyOf || id,
);
} else if (
!known?.header &&
coValue.header?.ruleset.type === "group"
) {
const dependedOnAccounts = new Set();
for (const session of Object.values(coValue.sessionEntries)) {
for (const entry of session) {
for (const tx of entry.transactions) {
if (tx.privacy === "trusting") {
const parsedChanges = JSON.parse(tx.changes);
for (const change of parsedChanges) {
if (
change.op === "set" &&
change.key.startsWith("co_")
) {
dependedOnAccounts.add(change.key);
}
if (!known?.header && coValue.header?.ruleset.type === "ownedByGroup") {
await this.sendNewContent(
coValue.header.ruleset.group,
undefined,
asDependencyOf || id,
);
} else if (!known?.header && coValue.header?.ruleset.type === "group") {
const dependedOnAccounts = new Set();
for (const session of Object.values(coValue.sessionEntries)) {
for (const entry of session) {
for (const tx of entry.transactions) {
if (tx.privacy === "trusting") {
const parsedChanges = JSON.parse(tx.changes);
for (const change of parsedChanges) {
if (
change.op === "set" &&
change.key.startsWith("co_")
) {
dependedOnAccounts.add(change.key);
}
}
}
}
}
for (const account of dependedOnAccounts) {
coValues = yield* this.sendNewContentInner(
coValues,
account as CoID<RawCoValue>,
undefined,
asDependencyOf || id,
);
}
}
for (const account of dependedOnAccounts) {
await this.sendNewContent(
account as CoID<RawCoValue>,
undefined,
asDependencyOf || id,
);
}
}
const newContentMessages = contentSinceChunk(
id,
coValue,
known,
).map((message) => ({ ...message, asDependencyOf }));
const newContentMessages = contentSinceChunk(id, coValue, known).map(
(message) => ({ ...message, asDependencyOf }),
);
const ourKnown: CoValueKnownState = chunkToKnownState(id, coValue);
const ourKnown: CoValueKnownState = chunkToKnownState(id, coValue);
yield* Queue.offer(this.toLocalNode, {
this.toLocalNode
.push({
action: "known",
...ourKnown,
asDependencyOf,
});
})
.catch((e) => console.error("Error while pushing known", e));
for (const message of newContentMessages) {
if (Object.keys(message.new).length === 0) continue;
yield* Queue.offer(this.toLocalNode, message);
for (const message of newContentMessages) {
if (Object.keys(message.new).length === 0) continue;
this.toLocalNode
.push(message)
.catch((e) =>
console.error("Error while pushing new content", e),
);
}
this.coValues[id] = coValue;
}
async withWAL(handler: (wal: WH) => Promise<void>) {
if (!this.currentWal) {
this.currentWal = await this.fs.createFile(
`wal-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.jsonl`,
);
}
await handler(this.currentWal);
}
async handleNewContent(newContent: NewContentMessage) {
const coValue = this.coValues[newContent.id];
const newContentAsChunk: CoValueChunk = {
header: newContent.header,
sessionEntries: Object.fromEntries(
Object.entries(newContent.new).map(
([sessionID, newInSession]) => [
sessionID,
[
{
after: newInSession.after,
lastSignature: newInSession.lastSignature,
transactions: newInSession.newTransactions,
},
],
],
),
),
};
if (!coValue) {
if (newContent.header) {
// console.log("Creating in WAL", newContent.id);
await this.withWAL((wal) =>
writeToWal(wal, this.fs, newContent.id, newContentAsChunk),
);
this.coValues[newContent.id] = newContentAsChunk;
} else {
console.warn(
"Incontiguous incoming update for " + newContent.id,
);
return;
}
return { ...coValues, [id]: coValue };
});
}
withWAL(
handler: (wal: WH) => Effect.Effect<void, FSErr>,
): Effect.Effect<void, FSErr> {
return SynchronizedRef.updateEffect(this.currentWal, (wal) =>
Effect.gen(this, function* () {
let newWal = wal;
if (!newWal) {
newWal = yield* this.fs.createFile(
`wal-${new Date().toISOString()}-${Math.random()
.toString(36)
.slice(2)}.jsonl`,
);
}
yield* handler(newWal);
return newWal;
}),
);
}
handleNewContent(
newContent: NewContentMessage,
): Effect.Effect<void, FSErr> {
return SynchronizedRef.updateEffect(this.coValues, (coValues) =>
Effect.gen(this, function* () {
const coValue = coValues[newContent.id];
const newContentAsChunk: CoValueChunk = {
header: newContent.header,
sessionEntries: Object.fromEntries(
Object.entries(newContent.new).map(
([sessionID, newInSession]) => [
sessionID,
[
{
after: newInSession.after,
lastSignature:
newInSession.lastSignature,
transactions:
newInSession.newTransactions,
},
],
],
),
} else {
const merged = mergeChunks(coValue, newContentAsChunk);
if (merged === "nonContigous") {
console.warn(
"Non-contigous new content for " + newContent.id,
Object.entries(coValue.sessionEntries).map(
([session, entries]) =>
entries.map((entry) => ({
session: session,
after: entry.after,
length: entry.transactions.length,
})),
),
};
Object.entries(newContentAsChunk.sessionEntries).map(
([session, entries]) =>
entries.map((entry) => ({
session: session,
after: entry.after,
length: entry.transactions.length,
})),
),
);
} else {
// console.log("Appending to WAL", newContent.id);
await this.withWAL((wal) =>
writeToWal(wal, this.fs, newContent.id, newContentAsChunk),
);
if (!coValue) {
if (newContent.header) {
// console.log("Creating in WAL", newContent.id);
yield* this.withWAL((wal) =>
writeToWal(
wal,
this.fs,
newContent.id,
newContentAsChunk,
),
this.coValues[newContent.id] = merged;
}
}
}
async getBlockHandle(
blockFile: BlockFilename,
fs: FS,
): Promise<{ handle: RH; size: number }> {
if (!this.blockFileHandles.has(blockFile)) {
this.blockFileHandles.set(blockFile, fs.openToRead(blockFile));
}
return this.blockFileHandles.get(blockFile)!;
}
async loadCoValue(id: RawCoID, fs: FS): Promise<CoValueChunk | undefined> {
const files = this.fileCache || (await fs.listFiles());
this.fileCache = files;
const blockFiles = (
files.filter((name) => name.startsWith("L")) as BlockFilename[]
).sort();
let result;
for (const blockFile of blockFiles) {
let cachedHeader:
| { [id: RawCoID]: { start: number; length: number } }
| undefined = this.headerCache.get(blockFile);
const { handle, size } = await this.getBlockHandle(blockFile, fs);
// console.log("Attempting to load", id, blockFile);
if (!cachedHeader) {
cachedHeader = {};
const header = await readHeader(blockFile, handle, size, fs);
for (const entry of header) {
cachedHeader[entry.id] = {
start: entry.start,
length: entry.length,
};
}
this.headerCache.set(blockFile, cachedHeader);
}
const headerEntry = cachedHeader[id];
// console.log("Header entry", id, headerEntry);
if (headerEntry) {
const nextChunk = await readChunk(handle, headerEntry, fs);
if (result) {
const merged = mergeChunks(result, nextChunk);
if (merged === "nonContigous") {
console.warn(
"Non-contigous chunks while loading " + id,
result,
nextChunk,
);
return {
...coValues,
[newContent.id]: newContentAsChunk,
};
} else {
// yield*
// Effect.promise(() =>
// this.toLocalNode.write({
// action: "known",
// id: newContent.id,
// header: false,
// sessions: {},
// isCorrection: true,
// })
// )
// );
yield* Effect.logWarning(
"Incontiguous incoming update for " + newContent.id,
);
return coValues;
result = merged;
}
} else {
const merged = mergeChunks(coValue, newContentAsChunk);
if (Either.isRight(merged)) {
yield* Effect.logWarning(
"Non-contigous new content for " + newContent.id,
Object.entries(coValue.sessionEntries).map(
([session, entries]) =>
entries.map((entry) => ({
session: session,
after: entry.after,
length: entry.transactions.length,
})),
),
Object.entries(
newContentAsChunk.sessionEntries,
).map(([session, entries]) =>
entries.map((entry) => ({
session: session,
after: entry.after,
length: entry.transactions.length,
})),
),
);
// yield* Effect.promise(() =>
// this.toLocalNode.write({
// action: "known",
// ...chunkToKnownState(newContent.id, coValue),
// isCorrection: true,
// })
// );
return coValues;
} else {
// console.log("Appending to WAL", newContent.id);
yield* this.withWAL((wal) =>
writeToWal(
wal,
this.fs,
newContent.id,
newContentAsChunk,
),
);
return { ...coValues, [newContent.id]: merged.left };
}
result = nextChunk;
}
}),
);
}
loadCoValue<WH, RH, FS extends FileSystem<WH, RH>>(
id: RawCoID,
fs: FS,
): Effect.Effect<CoValueChunk | undefined, FSErr> {
// return _loadChunkFromWal(id, fs);
return Effect.gen(this, function* () {
const files = this.fileCache || (yield* fs.listFiles());
this.fileCache = files;
const blockFiles = files.filter((name) =>
name.startsWith("hash_"),
) as BlockFilename[];
for (const blockFile of blockFiles) {
let cachedHeader:
| { [id: RawCoID]: { start: number; length: number } }
| undefined = this.headerCache.get(blockFile);
const { handle, size } = yield* fs.openToRead(blockFile);
// console.log("Attempting to load", id, blockFile);
if (!cachedHeader) {
cachedHeader = {};
const header = yield* readHeader(
blockFile,
handle,
size,
fs,
);
for (const entry of header) {
cachedHeader[entry.id] = {
start: entry.start,
length: entry.length,
};
}
this.headerCache.set(blockFile, cachedHeader);
}
const headerEntry = cachedHeader[id];
// console.log("Header entry", id, headerEntry);
let result;
if (headerEntry) {
result = yield* readChunk(handle, headerEntry, fs);
}
yield* fs.close(handle);
return result;
}
return undefined;
});
// await fs.close(handle);
}
return result;
}
async compact() {
await Effect.runPromise(
Effect.gen(this, function* () {
const fileNames = yield* this.fs.listFiles();
const fileNames = await this.fs.listFiles();
const walFiles = fileNames.filter((name) =>
name.startsWith("wal-"),
) as WalFilename[];
walFiles.sort();
const walFiles = fileNames.filter((name) =>
name.startsWith("wal-"),
) as WalFilename[];
walFiles.sort();
const coValues = new Map<RawCoID, CoValueChunk>();
console.log("Compacting WAL files", walFiles);
if (walFiles.length === 0) return;
const oldWal = this.currentWal;
this.currentWal = undefined;
if (oldWal) {
await this.fs.close(oldWal);
}
for (const fileName of walFiles) {
const { handle, size }: { handle: RH; size: number } =
await this.fs.openToRead(fileName);
if (size === 0) {
await this.fs.close(handle);
continue;
}
const bytes = await this.fs.read(handle, 0, size);
const decoded = textDecoder.decode(bytes);
const lines = decoded.split("\n");
for (const line of lines) {
if (line.length === 0) continue;
const chunk = JSON.parse(line) as WalEntry;
const existingChunk = coValues.get(chunk.id);
if (existingChunk) {
const merged = mergeChunks(existingChunk, chunk);
if (merged === "nonContigous") {
console.log(
"Non-contigous chunks in " +
chunk.id +
", " +
fileName,
existingChunk,
chunk,
);
} else {
coValues.set(chunk.id, merged);
}
} else {
coValues.set(chunk.id, chunk);
}
}
await this.fs.close(handle);
}
const highestBlockNumber = fileNames.reduce((acc, name) => {
if (name.startsWith("L" + MAX_N_LEVELS)) {
const num = parseInt(name.split("-")[1]!);
if (num > acc) {
return num;
}
}
return acc;
}, 0);
console.log([...coValues.keys()], fileNames, highestBlockNumber);
await writeBlock(
coValues,
MAX_N_LEVELS,
highestBlockNumber + 1,
this.fs,
);
for (const walFile of walFiles) {
await this.fs.removeFile(walFile);
}
this.fileCache = undefined;
const fileNames2 = await this.fs.listFiles();
const blockFiles = (
fileNames2.filter((name) => name.startsWith("L")) as BlockFilename[]
).sort();
const blockFilesByLevelInOrder: {
[level: number]: BlockFilename[];
} = {};
for (const blockFile of blockFiles) {
const level = parseInt(blockFile.split("-")[0]!.slice(1));
if (!blockFilesByLevelInOrder[level]) {
blockFilesByLevelInOrder[level] = [];
}
blockFilesByLevelInOrder[level]!.push(blockFile);
}
console.log(blockFilesByLevelInOrder);
for (let level = MAX_N_LEVELS; level > 0; level--) {
const nBlocksDesired = Math.pow(2, level);
const blocksInLevel = blockFilesByLevelInOrder[level];
if (blocksInLevel && blocksInLevel.length > nBlocksDesired) {
console.log("Compacting blocks in level", level, blocksInLevel);
const coValues = new Map<RawCoID, CoValueChunk>();
yield* Effect.log("Compacting WAL files", walFiles);
if (walFiles.length === 0) return;
yield* SynchronizedRef.updateEffect(this.currentWal, (wal) =>
Effect.gen(this, function* () {
if (wal) {
yield* this.fs.close(wal);
}
return undefined;
}),
);
for (const fileName of walFiles) {
for (const blockFile of blocksInLevel) {
const { handle, size }: { handle: RH; size: number } =
yield* this.fs.openToRead(fileName);
await this.getBlockHandle(blockFile, this.fs);
if (size === 0) {
yield* this.fs.close(handle);
continue;
}
const bytes = yield* this.fs.read(handle, 0, size);
const header = await readHeader(
blockFile,
handle,
size,
this.fs,
);
for (const entry of header) {
const chunk = await readChunk(handle, entry, this.fs);
const decoded = textDecoder.decode(bytes);
const lines = decoded.split("\n");
for (const line of lines) {
if (line.length === 0) continue;
const chunk = JSON.parse(line) as WalEntry;
const existingChunk = coValues.get(chunk.id);
const existingChunk = coValues.get(entry.id);
if (existingChunk) {
const merged = mergeChunks(existingChunk, chunk);
if (Either.isRight(merged)) {
yield* Effect.logWarning(
if (merged === "nonContigous") {
console.log(
"Non-contigous chunks in " +
chunk.id +
entry.id +
", " +
fileName,
blockFile,
existingChunk,
chunk,
);
} else {
coValues.set(chunk.id, merged.left);
coValues.set(entry.id, merged);
}
} else {
coValues.set(chunk.id, chunk);
coValues.set(entry.id, chunk);
}
}
yield* this.fs.close(handle);
}
yield* writeBlock(coValues, 0, this.fs);
for (const walFile of walFiles) {
yield* this.fs.removeFile(walFile);
let levelBelow = blockFilesByLevelInOrder[level - 1];
if (!levelBelow) {
levelBelow = [];
blockFilesByLevelInOrder[level - 1] = levelBelow;
}
this.fileCache = undefined;
}),
const highestBlockNumberInLevelBelow = levelBelow.reduce(
(acc, name) => {
const num = parseInt(name.split("-")[1]!);
if (num > acc) {
return num;
}
return acc;
},
0,
);
const newBlockName = await writeBlock(
coValues,
level - 1,
highestBlockNumberInLevelBelow + 1,
this.fs,
);
levelBelow.push(newBlockName);
// delete blocks that went into this one
for (const blockFile of blocksInLevel) {
const handle = await this.getBlockHandle(
blockFile,
this.fs,
);
await this.fs.close(handle.handle);
await this.fs.removeFile(blockFile);
this.blockFileHandles.delete(blockFile);
}
}
}
setTimeout(
() =>
this.compact().catch((e) => {
console.error("Error while compacting", e);
}),
5000,
);
setTimeout(() => this.compact(), 5000);
}
static async asPeer<WH, RH, FS extends FileSystem<WH, RH>>({
static asPeer<WH, RH, FS extends FileSystem<WH, RH>>({
fs,
trace,
localNodeName = "local",
@@ -453,13 +554,16 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
fs: FS;
trace?: boolean;
localNodeName?: string;
}): Promise<Peer> {
const [localNodeAsPeer, storageAsPeer] = await Effect.runPromise(
connectedPeers(localNodeName, "storage", {
}): Peer {
const [localNodeAsPeer, storageAsPeer] = connectedPeers(
localNodeName,
"storage",
{
peer1role: "client",
peer2role: "server",
trace,
}),
crashOnClose: true,
},
);
new LSMStorage(fs, localNodeAsPeer.incoming, localNodeAsPeer.outgoing);

View File

@@ -1,5 +1,6 @@
import { Console, Effect, Queue, Stream } from "effect";
import { Peer, PeerID, SyncMessage } from "./sync.js";
import { Channel } from "queueable";
export { Channel } from "queueable";
export function connectedPeers(
peer1id: PeerID,
@@ -8,59 +9,66 @@ export function connectedPeers(
trace = false,
peer1role = "peer",
peer2role = "peer",
crashOnClose = false,
}: {
trace?: boolean;
peer1role?: Peer["role"];
peer2role?: Peer["role"];
crashOnClose?: boolean;
} = {},
): Effect.Effect<[Peer, Peer]> {
return Effect.gen(function* () {
const [from1to2Rx, from1to2Tx] = yield* newQueuePair(
trace ? { traceAs: `${peer1id} -> ${peer2id}` } : undefined,
);
const [from2to1Rx, from2to1Tx] = yield* newQueuePair(
trace ? { traceAs: `${peer2id} -> ${peer1id}` } : undefined,
);
): [Peer, Peer] {
const [from1to2Rx, from1to2Tx] = newQueuePair(
trace ? { traceAs: `${peer1id} -> ${peer2id}` } : undefined,
);
const [from2to1Rx, from2to1Tx] = newQueuePair(
trace ? { traceAs: `${peer2id} -> ${peer1id}` } : undefined,
);
const peer2AsPeer: Peer = {
id: peer2id,
incoming: from2to1Rx,
outgoing: from1to2Tx,
role: peer2role,
};
const peer2AsPeer: Peer = {
id: peer2id,
incoming: from2to1Rx,
outgoing: from1to2Tx,
role: peer2role,
crashOnClose: crashOnClose,
};
const peer1AsPeer: Peer = {
id: peer1id,
incoming: from1to2Rx,
outgoing: from2to1Tx,
role: peer1role,
};
const peer1AsPeer: Peer = {
id: peer1id,
incoming: from1to2Rx,
outgoing: from2to1Tx,
role: peer1role,
crashOnClose: crashOnClose,
};
return [peer1AsPeer, peer2AsPeer];
});
return [peer1AsPeer, peer2AsPeer];
}
export function newQueuePair(
options: { traceAs?: string } = {},
): Effect.Effect<[Stream.Stream<SyncMessage>, Queue.Enqueue<SyncMessage>]> {
return Effect.gen(function* () {
const queue = yield* Queue.unbounded<SyncMessage>();
): [AsyncIterable<SyncMessage>, Channel<SyncMessage>] {
const channel = new Channel<SyncMessage>();
if (options.traceAs) {
return [Stream.fromQueue(queue).pipe(Stream.tap((msg) => Console.debug(
options.traceAs,
JSON.stringify(
msg,
(k, v) =>
k === "changes" ||
k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
2,
),
))), queue];
} else {
return [Stream.fromQueue(queue), queue];
}
});
if (options.traceAs) {
return [
(async function* () {
for await (const msg of channel) {
console.debug(
options.traceAs,
JSON.stringify(
msg,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
2,
),
);
yield msg;
}
})(),
channel,
];
} else {
return [channel.wrap(), channel];
}
}

View File

@@ -3,7 +3,6 @@ import { CoValueHeader, Transaction } from "./coValueCore.js";
import { CoValueCore } from "./coValueCore.js";
import { LocalNode, newLoadingState } from "./localNode.js";
import { RawCoID, SessionID } from "./ids.js";
import { Effect, Queue, Stream } from "effect";
export type CoValueKnownState = {
id: RawCoID;
@@ -56,30 +55,25 @@ export type DoneMessage = {
export type PeerID = string;
export class DisconnectedError extends Error {
readonly _tag = "DisconnectedError";
constructor(public message: string) {
super(message);
}
}
export type DisconnectedError = "Disconnected";
export class PingTimeoutError extends Error {
readonly _tag = "PingTimeoutError";
}
export type PingTimeoutError = "PingTimeout";
export type IncomingSyncStream = Stream.Stream<
SyncMessage,
DisconnectedError | PingTimeoutError
export type IncomingSyncStream = AsyncIterable<
SyncMessage | DisconnectedError | PingTimeoutError
>;
export type OutgoingSyncQueue = Queue.Enqueue<SyncMessage>;
export type OutgoingSyncQueue = {
push: (msg: SyncMessage) => Promise<unknown>;
close: () => void;
};
export interface Peer {
id: PeerID;
incoming: IncomingSyncStream;
outgoing: OutgoingSyncQueue;
role: "peer" | "server" | "client";
delayOnError?: number;
priority?: number;
crashOnClose: boolean;
}
export interface PeerState {
@@ -89,8 +83,8 @@ export interface PeerState {
incoming: IncomingSyncStream;
outgoing: OutgoingSyncQueue;
role: "peer" | "server" | "client";
delayOnError?: number;
priority?: number;
crashOnClose: boolean;
}
export function combinedKnownStates(
@@ -147,15 +141,11 @@ export class SyncManager {
for (const peer of eligiblePeers) {
// console.log("loading", id, "from", peer.id);
Effect.runPromise(
Queue.offer(peer.outgoing, {
action: "load",
id: id,
header: false,
sessions: {},
}),
).catch((e) => {
console.error("Error writing to peer", e);
await peer.outgoing.push({
action: "load",
id: id,
header: false,
sessions: {},
});
const coValueEntry = this.local.coValues[id];
@@ -232,11 +222,13 @@ export class SyncManager {
}
if (entry.state === "loading") {
await this.trySendToPeer(peer, {
this.trySendToPeer(peer, {
action: "load",
id,
header: false,
sessions: {},
}).catch((e) => {
console.error("Error sending load", e);
});
return;
}
@@ -249,9 +241,11 @@ export class SyncManager {
if (!peer.toldKnownState.has(id)) {
peer.toldKnownState.add(id);
await this.trySendToPeer(peer, {
this.trySendToPeer(peer, {
action: "load",
...coValue.knownState(),
}).catch((e) => {
console.error("Error sending load", e);
});
}
}
@@ -276,10 +270,12 @@ export class SyncManager {
);
if (!peer.toldKnownState.has(id)) {
await this.trySendToPeer(peer, {
this.trySendToPeer(peer, {
action: "known",
asDependencyOf,
...coValue.knownState(),
}).catch((e) => {
console.error("Error sending known state", e);
});
peer.toldKnownState.add(id);
@@ -314,7 +310,9 @@ export class SyncManager {
// } header: ${!!piece.header}`,
// // Object.values(piece.new).map((s) => s.newTransactions)
// );
await this.trySendToPeer(peer, piece);
this.trySendToPeer(peer, piece).catch((e) => {
console.error("Error sending content piece", e);
});
if (performance.now() - lastYield > 10) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
@@ -345,8 +343,8 @@ export class SyncManager {
outgoing: peer.outgoing,
toldKnownState: new Set(),
role: peer.role,
delayOnError: peer.delayOnError,
priority: peer.priority,
crashOnClose: peer.crashOnClose,
};
this.peers[peer.id] = peerState;
@@ -368,55 +366,52 @@ export class SyncManager {
void initialSync();
}
void Effect.runPromise(
peerState.incoming.pipe(
Stream.ensuring(
Effect.sync(() => {
console.log("Peer disconnected:", peer.id);
delete this.peers[peer.id];
}),
),
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => this.handleSyncMessage(msg, peerState),
catch: (e) =>
new Error(
`Error reading from peer ${
peer.id
}, handling msg\n\n${JSON.stringify(
msg,
(k, v) =>
k === "changes" ||
k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
)}`,
{ cause: e },
),
}).pipe(
Effect.timeoutFail({
duration: 10000,
onTimeout: () =>
new Error("Took >10s to process message"),
}),
),
),
Effect.catchAll((e) =>
Effect.logError(
"Error in peer",
peer.id,
e.message,
typeof e.cause === "object" &&
e.cause instanceof Error &&
e.cause.message,
),
),
),
);
const processMessages = async () => {
for await (const msg of peerState.incoming) {
if (msg === "Disconnected") {
return;
}
if (msg === "PingTimeout") {
console.error("Ping timeout from peer", peer.id);
return;
}
try {
await this.handleSyncMessage(msg, peerState);
} catch (e) {
throw new Error(
`Error reading from peer ${
peer.id
}, handling msg\n\n${JSON.stringify(msg, (k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
)}`,
{ cause: e },
);
}
}
};
processMessages().then(() => {
if (peer.crashOnClose) {
console.error("Unexepcted close from peer", peer.id);
this.local.crashed = new Error("Unexpected close from peer");
throw new Error("Unexpected close from peer");
}
}).catch((e) => {
console.error("Error processing messages from peer", peer.id, e);
if (peer.crashOnClose) {
this.local.crashed = e;
throw new Error(e);
}
}).finally(() => {
peer.outgoing.close();
delete this.peers[peer.id];
});
}
trySendToPeer(peer: PeerState, msg: SyncMessage) {
return Effect.runPromise(Queue.offer(peer.outgoing, msg));
return peer.outgoing.push(msg);
}
async handleLoad(msg: LoadMessage, peer: PeerState) {
@@ -429,7 +424,7 @@ export class SyncManager {
// special case: we should be able to solve this much more neatly
// with an explicit state machine in the future
const eligiblePeers = this.peersInPriorityOrder().filter(
(other) => other.id !== peer.id && peer.role === "server",
(other) => other.id !== peer.id && other.role === "server",
);
if (eligiblePeers.length === 0) {
if (msg.header || Object.keys(msg.sessions).length > 0) {
@@ -442,7 +437,7 @@ export class SyncManager {
header: false,
sessions: {},
}).catch((e) => {
console.error("Error sending known state back", e);
console.error("Error sending known state", e);
});
}
return;
@@ -461,23 +456,25 @@ export class SyncManager {
}
if (entry.state === "loading") {
console.log(
"Waiting for loaded",
msg.id,
"after message from",
peer.id,
);
// console.debug(
// "Waiting for loaded",
// msg.id,
// "after message from",
// peer.id,
// );
const loaded = await entry.done;
console.log("Loaded", msg.id, loaded);
// console.log("Loaded", msg.id, loaded);
if (loaded === "unavailable") {
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
peer.toldKnownState.add(msg.id);
await this.trySendToPeer(peer, {
this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
}).catch((e) => {
console.error("Error sending known state back", e);
});
return;
@@ -664,11 +661,9 @@ export class SyncManager {
console.error(
"Failed to add transactions",
msg.id,
JSON.stringify(newTransactions, (k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
),
newTransactions.length + ' new transactions',
'we have' + ourTotalnTxs,
'they have' + theirTotalnTxs,
);
continue;
}
@@ -687,10 +682,12 @@ export class SyncManager {
await this.syncCoValue(coValue);
if (invalidStateAssumed) {
await this.trySendToPeer(peer, {
this.trySendToPeer(peer, {
action: "known",
isCorrection: true,
...coValue.knownState(),
}).catch((e) => {
console.error("Error sending known state correction", e);
});
}
}
@@ -758,6 +755,16 @@ export class SyncManager {
}
}
}
gracefulShutdown() {
for (const peer of Object.values(this.peers)) {
console.debug("Gracefully closing", peer.id);
peer.outgoing.close();
peer.incoming = (async function* () {
yield "Disconnected" as const;
})();
}
}
}
function knownStateIn(msg: LoadMessage | KnownStateMessage) {

View File

@@ -3,7 +3,6 @@ import { newRandomSessionID } from "../coValueCore.js";
import { LocalNode } from "../localNode.js";
import { connectedPeers } from "../streamUtils.js";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { Effect } from "effect";
const Crypto = await WasmCrypto.create();
@@ -53,13 +52,13 @@ test("Can create account with one node, and then load it on another", async () =
map.set("foo", "bar", "private");
expect(map.get("foo")).toEqual("bar");
const [node1asPeer, node2asPeer] = await Effect.runPromise(connectedPeers("node1", "node2", {
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {
trace: true,
peer1role: "server",
peer2role: "client",
}));
})
console.log("After connected peers")
console.log("After connected peers");
node.syncManager.addPeer(node2asPeer);

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,177 @@
# jazz-browser-media-images
## 0.7.33
### Patch Changes
- jazz-browser@0.7.33
- jazz-tools@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- jazz-browser@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- jazz-browser@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- jazz-browser@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.7.33-hotfixes.2
### Patch Changes
- jazz-browser@0.7.33-hotfixes.2
## 0.7.33-hotfixes.1
### Patch Changes
- jazz-browser@0.7.33-hotfixes.1
## 0.7.33-hotfixes.0
### Patch Changes
- jazz-browser@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
## 0.7.32
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
- jazz-browser@0.7.32
## 0.7.31
### Patch Changes
- jazz-browser@0.7.31
- jazz-tools@0.7.31
## 0.7.30
### Patch Changes
- jazz-browser@0.7.30
## 0.7.29
### Patch Changes
- jazz-browser@0.7.29
- jazz-tools@0.7.29
## 0.7.28
### Patch Changes
- jazz-browser@0.7.28
- jazz-tools@0.7.28
## 0.7.27
### Patch Changes
- jazz-browser@0.7.27
## 0.7.26
### Patch Changes
- Updated dependencies
- jazz-browser@0.7.26
- jazz-tools@0.7.26
## 0.7.25
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
- jazz-browser@0.7.25
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-browser@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.23
- jazz-browser@0.7.23
## 0.7.22
### Patch Changes
- jazz-browser@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-browser@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-browser@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-browser@0.7.19
## 0.7.18
### Patch Changes
- jazz-browser@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- jazz-browser@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-browser@0.7.16
## 0.7.14
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-browser-media-images",
"version": "0.7.14",
"version": "0.7.33",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,216 @@
# jazz-browser
## 0.7.33
### Patch Changes
- Updated dependencies [fdde8db]
- Updated dependencies [b297c93]
- Updated dependencies [07fe2b9]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson-transport-ws@0.7.33
- cojson@0.7.33
- cojson-storage-indexeddb@0.7.33
- jazz-tools@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- cojson-storage-indexeddb@0.7.33-hotfixes.5
- cojson-transport-ws@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- cojson-storage-indexeddb@0.7.33-hotfixes.4
- cojson-transport-ws@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson-storage-indexeddb@0.7.33-hotfixes.3
- cojson-transport-ws@0.7.33-hotfixes.3
- cojson@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.7.33-hotfixes.2
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.2
## 0.7.33-hotfixes.1
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.1
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- cojson-storage-indexeddb@0.7.33-hotfixes.0
- cojson-transport-ws@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
## 0.7.32
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
## 0.7.31
### Patch Changes
- Updated dependencies
- Updated dependencies
- cojson-transport-ws@0.7.31
- cojson@0.7.31
- cojson-storage-indexeddb@0.7.31
- jazz-tools@0.7.31
## 0.7.30
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.30
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- cojson-storage-indexeddb@0.7.29
- cojson-transport-ws@0.7.29
- jazz-tools@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- cojson-storage-indexeddb@0.7.28
- cojson-transport-ws@0.7.28
- jazz-tools@0.7.28
## 0.7.27
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.27
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
- Updated dependencies
- cojson@0.7.26
- cojson-storage-indexeddb@0.7.26
- cojson-transport-ws@0.7.26
- jazz-tools@0.7.26
## 0.7.25
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- cojson-storage-indexeddb@0.7.23
- cojson-transport-ws@0.7.23
## 0.7.22
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-storage-indexeddb@0.7.18
- cojson-transport-ws@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-storage-indexeddb@0.7.17
- cojson-transport-ws@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
## 0.7.14
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-browser",
"version": "0.7.14",
"version": "0.7.33",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -10,7 +10,6 @@
"cojson": "workspace:*",
"cojson-storage-indexeddb": "workspace:*",
"cojson-transport-ws": "workspace:*",
"effect": "^3.1.5",
"jazz-tools": "workspace:*",
"typescript": "^5.1.6"
},

View File

@@ -1,13 +1,12 @@
import {
BlockFilename,
FSErr,
FileSystem,
WalFilename,
CryptoProvider,
} from "cojson";
import { Effect } from "effect";
import { BlockFilename, FileSystem, WalFilename, CryptoProvider } from "cojson";
export class OPFSFilesystem implements FileSystem<number, number> {
export class OPFSFilesystem
implements
FileSystem<
{ id: number; filename: string },
{ id: number; filename: string }
>
{
opfsWorker: Worker;
callbacks: Map<number, (event: MessageEvent) => void> = new Map();
nextRequestId = 0;
@@ -28,18 +27,18 @@ export class OPFSFilesystem implements FileSystem<number, number> {
};
}
listFiles(): Effect.Effect<string[], FSErr, never> {
return Effect.async((cb) => {
listFiles(): Promise<string[]> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("listFiles" + requestId);
performance.mark("listFiles" + requestId + "_listFiles");
this.callbacks.set(requestId, (event) => {
performance.mark("listFilesEnd" + requestId);
performance.mark("listFilesEnd" + requestId + "_listFiles");
performance.measure(
"listFiles" + requestId,
"listFiles" + requestId,
"listFilesEnd" + requestId,
"listFiles" + requestId + "_listFiles",
"listFiles" + requestId + "_listFiles",
"listFilesEnd" + requestId + "_listFiles",
);
cb(Effect.succeed(event.data.fileNames));
resolve(event.data.fileNames);
});
this.opfsWorker.postMessage({ type: "listFiles", requestId });
});
@@ -47,22 +46,20 @@ export class OPFSFilesystem implements FileSystem<number, number> {
openToRead(
filename: string,
): Effect.Effect<{ handle: number; size: number }, FSErr, never> {
return Effect.async((cb) => {
): Promise<{ handle: { id: number; filename: string }; size: number }> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("openToRead" + requestId);
performance.mark("openToRead" + "_" + filename);
this.callbacks.set(requestId, (event) => {
cb(
Effect.succeed({
handle: event.data.handle,
size: event.data.size,
}),
);
performance.mark("openToReadEnd" + requestId);
resolve({
handle: { id: event.data.handle, filename },
size: event.data.size,
});
performance.mark("openToReadEnd" + "_" + filename);
performance.measure(
"openToRead" + requestId,
"openToRead" + requestId,
"openToReadEnd" + requestId,
"openToRead" + "_" + filename,
"openToRead" + "_" + filename,
"openToReadEnd" + "_" + filename,
);
});
this.opfsWorker.postMessage({
@@ -73,18 +70,18 @@ export class OPFSFilesystem implements FileSystem<number, number> {
});
}
createFile(filename: string): Effect.Effect<number, FSErr, never> {
return Effect.async((cb) => {
createFile(filename: string): Promise<{ id: number; filename: string }> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("createFile" + requestId);
performance.mark("createFile" + "_" + filename);
this.callbacks.set(requestId, (event) => {
performance.mark("createFileEnd" + requestId);
performance.mark("createFileEnd" + "_" + filename);
performance.measure(
"createFile" + requestId,
"createFile" + requestId,
"createFileEnd" + requestId,
"createFile" + "_" + filename,
"createFile" + "_" + filename,
"createFileEnd" + "_" + filename,
);
cb(Effect.succeed(event.data.handle));
resolve({ id: event.data.handle, filename });
});
this.opfsWorker.postMessage({
type: "createFile",
@@ -94,20 +91,18 @@ export class OPFSFilesystem implements FileSystem<number, number> {
});
}
openToWrite(
filename: string,
): Effect.Effect<FileSystemFileHandle, FSErr, never> {
return Effect.async((cb) => {
openToWrite(filename: string): Promise<{ id: number; filename: string }> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("openToWrite" + requestId);
performance.mark("openToWrite" + "_" + filename);
this.callbacks.set(requestId, (event) => {
performance.mark("openToWriteEnd" + requestId);
performance.mark("openToWriteEnd" + "_" + filename);
performance.measure(
"openToWrite" + requestId,
"openToWrite" + requestId,
"openToWriteEnd" + requestId,
"openToWrite" + "_" + filename,
"openToWrite" + "_" + filename,
"openToWriteEnd" + "_" + filename,
);
cb(Effect.succeed(event.data.handle));
resolve({ id: event.data.handle, filename });
});
this.opfsWorker.postMessage({
type: "openToWrite",
@@ -118,24 +113,24 @@ export class OPFSFilesystem implements FileSystem<number, number> {
}
append(
handle: number,
handle: { id: number; filename: string },
data: Uint8Array,
): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
): Promise<void> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("append" + requestId);
performance.mark("append" + "_" + handle.filename);
this.callbacks.set(requestId, (_) => {
performance.mark("appendEnd" + requestId);
performance.mark("appendEnd" + "_" + handle.filename);
performance.measure(
"append" + requestId,
"append" + requestId,
"appendEnd" + requestId,
"append" + "_" + handle.filename,
"append" + "_" + handle.filename,
"appendEnd" + "_" + handle.filename,
);
cb(Effect.succeed(undefined));
resolve(undefined);
});
this.opfsWorker.postMessage({
type: "append",
handle,
handle: handle.id,
data,
requestId,
});
@@ -143,25 +138,25 @@ export class OPFSFilesystem implements FileSystem<number, number> {
}
read(
handle: number,
handle: { id: number; filename: string },
offset: number,
length: number,
): Effect.Effect<Uint8Array, FSErr, never> {
return Effect.async((cb) => {
): Promise<Uint8Array> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("read" + requestId);
performance.mark("read" + "_" + handle.filename);
this.callbacks.set(requestId, (event) => {
performance.mark("readEnd" + requestId);
performance.mark("readEnd" + "_" + handle.filename);
performance.measure(
"read" + requestId,
"read" + requestId,
"readEnd" + requestId,
"read" + "_" + handle.filename,
"read" + "_" + handle.filename,
"readEnd" + "_" + handle.filename,
);
cb(Effect.succeed(event.data.data));
resolve(event.data.data);
});
this.opfsWorker.postMessage({
type: "read",
handle,
handle: handle.id,
offset,
length,
requestId,
@@ -169,66 +164,64 @@ export class OPFSFilesystem implements FileSystem<number, number> {
});
}
close(handle: number): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
close(handle: { id: number; filename: string }): Promise<void> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("close" + requestId);
performance.mark("close" + "_" + handle.filename);
this.callbacks.set(requestId, (_) => {
performance.mark("closeEnd" + requestId);
performance.mark("closeEnd" + "_" + handle.filename);
performance.measure(
"close" + requestId,
"close" + requestId,
"closeEnd" + requestId,
"close" + "_" + handle.filename,
"close" + "_" + handle.filename,
"closeEnd" + "_" + handle.filename,
);
cb(Effect.succeed(undefined));
resolve(undefined);
});
this.opfsWorker.postMessage({
type: "close",
handle,
handle: handle.id,
requestId,
});
});
}
closeAndRename(
handle: number,
handle: { id: number; filename: string },
filename: BlockFilename,
): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
): Promise<void> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("closeAndRename" + requestId);
performance.mark("closeAndRename" + "_" + handle.filename);
this.callbacks.set(requestId, () => {
performance.mark("closeAndRenameEnd" + requestId);
performance.mark("closeAndRenameEnd" + "_" + handle.filename);
performance.measure(
"closeAndRename" + requestId,
"closeAndRename" + requestId,
"closeAndRenameEnd" + requestId,
"closeAndRename" + "_" + handle.filename,
"closeAndRename" + "_" + handle.filename,
"closeAndRenameEnd" + "_" + handle.filename,
);
cb(Effect.succeed(undefined));
resolve(undefined);
});
this.opfsWorker.postMessage({
type: "closeAndRename",
handle,
handle: handle.id,
filename,
requestId,
});
});
}
removeFile(
filename: BlockFilename | WalFilename,
): Effect.Effect<void, FSErr, never> {
return Effect.async((cb) => {
removeFile(filename: BlockFilename | WalFilename): Promise<void> {
return new Promise((resolve) => {
const requestId = this.nextRequestId++;
performance.mark("removeFile" + requestId);
performance.mark("removeFile" + "_" + filename);
this.callbacks.set(requestId, () => {
performance.mark("removeFileEnd" + requestId);
performance.mark("removeFileEnd" + "_" + filename);
performance.measure(
"removeFile" + requestId,
"removeFile" + requestId,
"removeFileEnd" + requestId,
"removeFile" + "_" + filename,
"removeFile" + "_" + filename,
"removeFileEnd" + "_" + filename,
);
cb(Effect.succeed(undefined));
resolve(undefined);
});
this.opfsWorker.postMessage({
type: "removeFile",

View File

@@ -10,14 +10,10 @@ import {
WasmCrypto,
CryptoProvider,
} from "jazz-tools";
import {
AccountID,
LSMStorage,
} from "cojson";
import { AccountID, LSMStorage } from "cojson";
import { AuthProvider } from "./auth/auth.js";
import { OPFSFilesystem } from "./OPFSFilesystem.js";
import { IDBStorage } from "cojson-storage-indexeddb";
import { Effect, Queue } from "effect";
import { createWebSocketPeer } from "cojson-transport-ws";
export * from "./auth/auth.js";
@@ -39,19 +35,17 @@ export async function createJazzBrowserContext<Acc extends Account>({
auth: AuthProvider<Acc>;
peer: `wss://${string}` | `ws://${string}`;
reconnectionTimeout?: number;
storage?: "indexedDB" | "experimentalOPFSdoNotUseOrYouWillBeFired";
storage?: "indexedDB" | "singleTabOPFS";
crypto?: CryptoProvider;
}): Promise<BrowserContext<Acc>> {
const crypto = customCrypto || (await WasmCrypto.create());
let sessionDone: () => void;
const firstWsPeer = await Effect.runPromise(
createWebSocketPeer({
websocket: new WebSocket(peerAddr),
id: peerAddr + "@" + new Date().toISOString(),
role: "server",
}),
);
const firstWsPeer = createWebSocketPeer({
websocket: new WebSocket(peerAddr),
id: peerAddr + "@" + new Date().toISOString(),
role: "server",
});
let shouldTryToReconnect = true;
let currentReconnectionTimeout = initialReconnectionTimeout;
@@ -115,13 +109,11 @@ export async function createJazzBrowserContext<Acc extends Account>({
});
me._raw.core.node.syncManager.addPeer(
await Effect.runPromise(
createWebSocketPeer({
websocket: new WebSocket(peerAddr),
id: peerAddr + "@" + new Date().toISOString(),
role: "server",
}),
),
createWebSocketPeer({
websocket: new WebSocket(peerAddr),
id: peerAddr + "@" + new Date().toISOString(),
role: "server",
})
);
}
}
@@ -135,11 +127,7 @@ export async function createJazzBrowserContext<Acc extends Account>({
shouldTryToReconnect = false;
window.removeEventListener("online", onOnline);
console.log("Cleaning up node");
for (const peer of Object.values(
me._raw.core.node.syncManager.peers,
)) {
void Effect.runPromise(Queue.shutdown(peer.outgoing));
}
me._raw.core.node.gracefulShutdown();
sessionDone?.();
},
};

View File

@@ -1,5 +1,204 @@
# jazz-autosub
## 0.7.33
### Patch Changes
- Updated dependencies [fdde8db]
- Updated dependencies [b297c93]
- Updated dependencies [07fe2b9]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson-transport-ws@0.7.33
- cojson@0.7.33
- jazz-tools@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- cojson-transport-ws@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- cojson-transport-ws@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.3
- cojson@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.7.33-hotfixes.2
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.2
## 0.7.33-hotfixes.1
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.1
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- cojson-transport-ws@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
## 0.7.32
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
## 0.7.31
### Patch Changes
- Updated dependencies
- Updated dependencies
- cojson-transport-ws@0.7.31
- cojson@0.7.31
- jazz-tools@0.7.31
## 0.7.30
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.30
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- cojson-transport-ws@0.7.29
- jazz-tools@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- cojson-transport-ws@0.7.28
- jazz-tools@0.7.28
## 0.7.27
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.27
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
- Updated dependencies
- cojson@0.7.26
- cojson-transport-ws@0.7.26
- jazz-tools@0.7.26
## 0.7.25
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- cojson-transport-ws@0.7.23
## 0.7.22
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-transport-ws@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-transport-ws@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
## 0.7.14
### Patch Changes

View File

@@ -5,11 +5,10 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.7.14",
"version": "0.7.33",
"dependencies": {
"cojson": "workspace:*",
"cojson-transport-ws": "workspace:*",
"effect": "^3.1.5",
"jazz-tools": "workspace:*",
"ws": "^8.14.2"
},

View File

@@ -1,7 +1,6 @@
import { AgentSecret, Peer, SessionID, WasmCrypto } from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import { Account, CoValueClass, ID } from "jazz-tools";
import { Effect } from "effect";
import { WebSocket } from "ws";
/** @category Context Creation */
@@ -18,13 +17,11 @@ export async function startWorker<Acc extends Account>({
syncServer?: string;
accountSchema?: CoValueClass<Acc> & typeof Account;
}): Promise<{ worker: Acc }> {
const wsPeer: Peer = await Effect.runPromise(
createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peer),
role: "server",
}),
);
const wsPeer: Peer = createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peer),
role: "server",
});
if (!accountID) {
throw new Error("No accountID provided");
@@ -52,13 +49,11 @@ export async function startWorker<Acc extends Account>({
if (!worker._raw.core.node.syncManager.peers["upstream"]) {
console.log(new Date(), "Reconnecting to upstream " + peer);
const wsPeer: Peer = await Effect.runPromise(
createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peer),
role: "server",
}),
);
const wsPeer: Peer = createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peer),
role: "server",
});
worker._raw.core.node.syncManager.addPeer(wsPeer);
}

View File

@@ -1,5 +1,210 @@
# jazz-react
## 0.7.33
### Patch Changes
- Updated dependencies [b297c93]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson@0.7.33
- jazz-browser@0.7.33
- jazz-tools@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- jazz-browser@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- jazz-browser@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.3
- jazz-browser@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.7.33-hotfixes.2
### Patch Changes
- jazz-browser@0.7.33-hotfixes.2
## 0.7.33-hotfixes.1
### Patch Changes
- jazz-browser@0.7.33-hotfixes.1
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- jazz-browser@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
## 0.7.32
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
- jazz-browser@0.7.32
## 0.7.31
### Patch Changes
- Updated dependencies
- cojson@0.7.31
- jazz-browser@0.7.31
- jazz-tools@0.7.31
## 0.7.30
### Patch Changes
- jazz-browser@0.7.30
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- jazz-browser@0.7.29
- jazz-tools@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- jazz-browser@0.7.28
- jazz-tools@0.7.28
## 0.7.27
### Patch Changes
- jazz-browser@0.7.27
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
- Updated dependencies
- cojson@0.7.26
- jazz-browser@0.7.26
- jazz-tools@0.7.26
## 0.7.25
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
- jazz-browser@0.7.25
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-browser@0.7.24
## 0.7.23
### Patch Changes
- Mostly complete OPFS implementation (single-tab only)
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- jazz-browser@0.7.23
## 0.7.22
### Patch Changes
- jazz-browser@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-browser@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-browser@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-browser@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- jazz-browser@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- jazz-browser@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-browser@0.7.16
## 0.7.15
### Patch Changes
- Provide current res in ProgressiveImg
## 0.7.14
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react",
"version": "0.7.14",
"version": "0.7.33",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -104,7 +104,10 @@ const DemoAuthBasicUI = ({
signUp: (username: string) => void;
}) => {
const [username, setUsername] = useState<string>("");
const darkMode = typeof window !== 'undefined' ? window.matchMedia("(prefers-color-scheme: dark)").matches : false;
const darkMode =
typeof window !== "undefined"
? window.matchMedia("(prefers-color-scheme: dark)").matches
: false;
return (
<div

View File

@@ -23,7 +23,7 @@ export function createJazzReactContext<Acc extends Account>({
}: {
auth: ReactAuthHook<Acc>;
peer: `wss://${string}` | `ws://${string}`;
storage?: "indexedDB" | "experimentalOPFSdoNotUseOrYouWillBeFired";
storage?: "indexedDB" | "singleTabOPFS";
}): JazzReactContext<Acc> {
const JazzContext = React.createContext<
| {
@@ -127,20 +127,20 @@ export function createJazzReactContext<Acc extends Account>({
id: ID<V> | undefined,
depth: D & DepthsIn<V> = [] as D & DepthsIn<V>,
): DeeplyLoaded<V, D> | undefined {
const [state, setState] = useState<DeeplyLoaded<V, D> | undefined>(
undefined,
);
const [state, setState] = useState<{
value: DeeplyLoaded<V, D> | undefined;
}>({ value: undefined });
const me = React.useContext(JazzContext)?.me;
useEffect(() => {
if (!id || !me) return;
return subscribeToCoValue(Schema, id, me, depth, (value) => {
setState(value);
setState({ value });
});
}, [Schema, id, me]);
return state;
return state.value;
}
function useAcceptInvite<V extends CoValue>({

View File

@@ -9,7 +9,10 @@ export function useProgressiveImg({
image: ImageDefinition | null | undefined;
maxWidth?: number;
}) {
const [src, setSrc] = useState<string | undefined>(undefined);
const [current, setCurrent] = useState<
| { src?: string; res?: `${number}x${number}` | "placeholder" }
| undefined
>(undefined);
useEffect(() => {
let lastHighestRes: string | undefined;
@@ -22,21 +25,28 @@ export function useProgressiveImg({
const blob = highestRes.stream.toBlob();
if (blob) {
const blobURI = URL.createObjectURL(blob);
setSrc(blobURI);
setCurrent({ src: blobURI, res: highestRes.res });
return () => {
setTimeout(() => URL.revokeObjectURL(blobURI), 200);
};
}
}
} else {
setSrc(update?.placeholderDataURL);
setCurrent({
src: update?.placeholderDataURL,
res: "placeholder",
});
}
});
return unsub;
}, [image?.id, maxWidth]);
return { src, originalSize: image?.originalSize };
return {
src: current?.src,
res: current?.res,
originalSize: image?.originalSize,
};
}
/** @category Media */
@@ -47,6 +57,7 @@ export function ProgressiveImg({
}: {
children: (result: {
src: string | undefined;
res: `${number}x${number}` | "placeholder" | undefined;
originalSize: readonly [number, number] | undefined;
}) => React.ReactNode;
image: ImageDefinition | null | undefined;

View File

@@ -1,5 +1,203 @@
# jazz-autosub
## 0.7.33
### Patch Changes
- Updated dependencies [fdde8db]
- Updated dependencies [b297c93]
- Updated dependencies [07fe2b9]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson-transport-ws@0.7.33
- cojson@0.7.33
- jazz-tools@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- cojson-transport-ws@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- cojson-transport-ws@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.3
- cojson@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.7.33-hotfixes.2
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.2
## 0.7.33-hotfixes.1
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.1
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- cojson-transport-ws@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.0
## 0.7.32
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
## 0.7.31
### Patch Changes
- Updated dependencies
- Updated dependencies
- cojson-transport-ws@0.7.31
- cojson@0.7.31
- jazz-tools@0.7.31
## 0.7.30
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.30
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- cojson-transport-ws@0.7.29
- jazz-tools@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- cojson-transport-ws@0.7.28
- jazz-tools@0.7.28
## 0.7.27
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.27
## 0.7.26
### Patch Changes
- Updated dependencies
- cojson@0.7.26
- cojson-transport-ws@0.7.26
- jazz-tools@0.7.26
## 0.7.25
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
## 0.7.24
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
## 0.7.23
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-tools@0.7.23
- cojson-transport-ws@0.7.23
## 0.7.22
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.7.21
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
## 0.7.20
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
## 0.7.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-transport-ws@0.7.18
- jazz-tools@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-transport-ws@0.7.17
- jazz-tools@0.7.17
## 0.7.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
## 0.7.14
### Patch Changes

View File

@@ -3,7 +3,7 @@
"bin": "./dist/index.js",
"type": "module",
"license": "MIT",
"version": "0.7.14",
"version": "0.7.33",
"scripts": {
"lint": "eslint . --ext ts,tsx",
"format": "prettier --write './src/**/*.{ts,tsx}'",
@@ -16,7 +16,7 @@
"@effect/schema": "^0.66.16",
"cojson": "workspace:*",
"cojson-transport-ws": "workspace:*",
"effect": "^3.1.5",
"effect": "^3.5.2",
"fast-check": "^3.17.2",
"jazz-tools": "workspace:*",
"ws": "^8.14.2"

View File

@@ -3,7 +3,7 @@ import { Command, Options } from "@effect/cli";
import { NodeContext, NodeRuntime } from "@effect/platform-node";
import { Console, Effect } from "effect";
import { createWebSocketPeer } from "cojson-transport-ws";
import { WebSocket } from "ws"
import { WebSocket } from "ws";
import {
Account,
WasmCrypto,
@@ -25,7 +25,7 @@ const accountCreate = Command.make(
return Effect.gen(function* () {
const crypto = yield* Effect.promise(() => WasmCrypto.create());
const peer = yield* createWebSocketPeer({
const peer = createWebSocketPeer({
id: "upstream",
websocket: new WebSocket(peerAddr),
role: "server",
@@ -53,7 +53,7 @@ const accountCreate = Command.make(
),
);
const peer2 = yield* createWebSocketPeer({
const peer2 = createWebSocketPeer({
id: "upstream2",
websocket: new WebSocket(peerAddr),
role: "server",

View File

@@ -1,5 +1,136 @@
# jazz-autosub
## 0.7.33
### Patch Changes
- Updated dependencies [b297c93]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson@0.7.33
## 0.7.33-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
## 0.7.33-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
## 0.7.33-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.3
## 0.7.33-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
## 0.7.32
### Patch Changes
- Adapt type of applyDiff to make CoMaps fully subclassable again
## 0.7.31
### Patch Changes
- Updated dependencies
- cojson@0.7.31
## 0.7.29
### Patch Changes
- Updated dependencies
- cojson@0.7.29
## 0.7.28
### Patch Changes
- Updated dependencies
- cojson@0.7.28
## 0.7.26
### Patch Changes
- Remove Effect from jazz/cojson internals
- Updated dependencies
- cojson@0.7.26
## 0.7.25
### Patch Changes
- Implement applyDiff on CoMap to only update changed fields
## 0.7.24
### Patch Changes
- Remove effectful API for loading/subscribing
## 0.7.23
### Patch Changes
- Mostly complete OPFS implementation (single-tab only)
- Updated dependencies
- cojson@0.7.23
## 0.7.21
### Patch Changes
- Fix another bug in CoMap 'has' proxy trap
## 0.7.20
### Patch Changes
- Fix bug in CoMap 'has' trap
## 0.7.19
### Patch Changes
- Add support for "in" operator in CoMaps
## 0.7.18
### Patch Changes
- Updated dependencies
- cojson@0.7.18
## 0.7.17
### Patch Changes
- Updated dependencies
- cojson@0.7.17
## 0.7.16
### Patch Changes
- Fix: allow null in encoded fields
## 0.7.14
### Patch Changes

View File

@@ -5,11 +5,9 @@
"types": "./src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.7.14",
"version": "0.7.33",
"dependencies": {
"@effect/schema": "^0.66.16",
"cojson": "workspace:*",
"effect": "^3.1.5",
"fast-check": "^3.17.2"
},
"scripts": {

View File

@@ -11,7 +11,6 @@ import type {
RawControlledAccount,
SessionID,
} from "cojson";
import { Context, Effect, Stream } from "effect";
import {
CoMap,
CoValue,
@@ -22,7 +21,6 @@ import {
RefIfCoValue,
DeeplyLoaded,
DepthsIn,
UnavailableError,
} from "../internal.js";
import {
Group,
@@ -34,9 +32,7 @@ import {
inspect,
subscriptionsScopes,
loadCoValue,
loadCoValueEf,
subscribeToCoValue,
subscribeToCoValueEf,
ensureCoValueLoaded,
subscribeToExistingCoValue,
} from "../internal.js";
@@ -224,11 +220,11 @@ export class Account extends CoValueBase implements CoValue {
},
) {
// TODO: is there a cleaner way to do this?
const connectedPeers = await Effect.runPromise(cojsonInternals.connectedPeers(
const connectedPeers = cojsonInternals.connectedPeers(
"creatingAccount",
"createdAccount",
{ peer1role: "server", peer2role: "client" },
));
);
as._raw.core.node.syncManager.addPeer(connectedPeers[1]);
@@ -284,15 +280,6 @@ export class Account extends CoValueBase implements CoValue {
return loadCoValue(this, id, as, depth);
}
/** @category Subscription & Loading */
static loadEf<A extends Account, Depth>(
this: CoValueClass<A>,
id: ID<A>,
depth: Depth & DepthsIn<A>,
): Effect.Effect<DeeplyLoaded<A, Depth>, UnavailableError, AccountCtx> {
return loadCoValueEf<A, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
static subscribe<A extends Account, Depth>(
this: CoValueClass<A>,
@@ -304,15 +291,6 @@ export class Account extends CoValueBase implements CoValue {
return subscribeToCoValue<A, Depth>(this, id, as, depth, listener);
}
/** @category Subscription & Loading */
static subscribeEf<A extends Account, Depth>(
this: CoValueClass<A>,
id: ID<A>,
depth: Depth & DepthsIn<A>,
): Stream.Stream<DeeplyLoaded<A, Depth>, UnavailableError, AccountCtx> {
return subscribeToCoValueEf<A, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
ensureLoaded<A extends Account, Depth>(
this: A,
@@ -397,9 +375,6 @@ export const AccountAndGroupProxyHandler: ProxyHandler<Account | Group> = {
},
};
/** @category Identity & Permissions */
export class AccountCtx extends Context.Tag("Account")<AccountCtx, Account>() {}
/** @category Identity & Permissions */
export function isControlledAccount(account: Account): account is Account & {
isMe: true;

View File

@@ -1,4 +1,4 @@
import type { RawCoList } from "cojson";
import type { JsonValue, RawCoList } from "cojson";
import { RawAccount } from "cojson";
import type {
CoValue,
@@ -10,8 +10,6 @@ import type {
CoValueClass,
DepthsIn,
DeeplyLoaded,
UnavailableError,
AccountCtx,
CoValueFromRaw,
} from "../internal.js";
import {
@@ -25,14 +23,11 @@ import {
inspect,
isRefEncoded,
loadCoValue,
loadCoValueEf,
makeRefs,
subscribeToCoValue,
subscribeToCoValueEf,
subscribeToExistingCoValue,
subscriptionsScopes,
} from "../internal.js";
import { encodeSync, decodeSync } from "@effect/schema/Schema";
import { Effect, Stream } from "effect";
/**
* CoLists are collaborative versions of plain arrays.
@@ -308,7 +303,7 @@ export class CoList<Item = any> extends Array<Item> implements CoValue {
} else if ("encoded" in itemDescriptor) {
return this._raw
.asArray()
.map((e) => encodeSync(itemDescriptor.encoded)(e));
.map((e) => itemDescriptor.encoded.encode(e));
} else if (isRefEncoded(itemDescriptor)) {
return this.map((item, idx) =>
seenAbove?.includes((item as CoValue)?.id)
@@ -376,21 +371,6 @@ export class CoList<Item = any> extends Array<Item> implements CoValue {
return loadCoValue(this, id, as, depth);
}
/**
* Effectful version of `CoList.load()`.
*
* Needs to be run inside an `AccountCtx` context.
*
* @category Subscription & Loading
*/
static loadEf<L extends CoList, Depth>(
this: CoValueClass<L>,
id: ID<L>,
depth: Depth & DepthsIn<L>,
): Effect.Effect<DeeplyLoaded<L, Depth>, UnavailableError, AccountCtx> {
return loadCoValueEf<L, Depth>(this, id, depth);
}
/**
* Load and subscribe to a `CoList` with a given ID, as a given account.
*
@@ -429,21 +409,6 @@ export class CoList<Item = any> extends Array<Item> implements CoValue {
return subscribeToCoValue<L, Depth>(this, id, as, depth, listener);
}
/**
* Effectful version of `CoList.subscribe()` that returns a stream of updates.
*
* Needs to be run inside an `AccountCtx` context.
*
* @category Subscription & Loading
*/
static subscribeEf<L extends CoList, Depth>(
this: CoValueClass<L>,
id: ID<L>,
depth: Depth & DepthsIn<L>,
): Stream.Stream<DeeplyLoaded<L, Depth>, UnavailableError, AccountCtx> {
return subscribeToCoValueEf<L, Depth>(this, id, depth);
}
/**
* Given an already loaded `CoList`, ensure that items are loaded to the specified depth.
*
@@ -479,16 +444,21 @@ export class CoList<Item = any> extends Array<Item> implements CoValue {
castAs<Cl extends CoValueClass & CoValueFromRaw<CoValue>>(
cl: Cl,
): InstanceType<Cl> {
return cl.fromRaw(this._raw) as InstanceType<Cl>;
const casted = cl.fromRaw(this._raw) as InstanceType<Cl>;
const subscriptionScope = subscriptionsScopes.get(this);
if (subscriptionScope) {
subscriptionsScopes.set(casted, subscriptionScope);
}
return casted;
}
}
function toRawItems<Item>(items: Item[], itemDescriptor: Schema) {
const rawItems =
itemDescriptor === "json"
? items
? (items as JsonValue[])
: "encoded" in itemDescriptor
? items?.map((e) => encodeSync(itemDescriptor.encoded)(e))
? items?.map((e) => itemDescriptor.encoded.encode(e))
: isRefEncoded(itemDescriptor)
? items?.map((v) => (v as unknown as CoValue).id)
: (() => {
@@ -507,7 +477,7 @@ const CoListProxyHandler: ProxyHandler<CoList> = {
} else if ("encoded" in itemDescriptor) {
return rawValue === undefined
? undefined
: decodeSync(itemDescriptor.encoded)(rawValue);
: itemDescriptor.encoded.decode(rawValue);
} else if (isRefEncoded(itemDescriptor)) {
return rawValue === undefined
? undefined
@@ -540,7 +510,7 @@ const CoListProxyHandler: ProxyHandler<CoList> = {
if (itemDescriptor === "json") {
rawValue = value;
} else if ("encoded" in itemDescriptor) {
rawValue = encodeSync(itemDescriptor.encoded)(value);
rawValue = itemDescriptor.encoded.encode(value);
} else if (isRefEncoded(itemDescriptor)) {
rawValue = value.id;
}

View File

@@ -1,6 +1,4 @@
import type { JsonValue, RawCoMap } from "cojson";
import type { Simplify } from "effect/Types";
import { encodeSync, decodeSync } from "@effect/schema/Schema";
import type {
CoValue,
Schema,
@@ -11,8 +9,6 @@ import type {
RefIfCoValue,
DepthsIn,
DeeplyLoaded,
UnavailableError,
AccountCtx,
CoValueClass,
} from "../internal.js";
import {
@@ -26,13 +22,10 @@ import {
ItemsSym,
isRefEncoded,
loadCoValue,
loadCoValueEf,
subscribeToCoValue,
subscribeToCoValueEf,
ensureCoValueLoaded,
subscribeToExistingCoValue,
} from "../internal.js";
import { Effect, Stream } from "effect";
type CoMapEdit<V> = {
value?: V;
@@ -41,6 +34,10 @@ type CoMapEdit<V> = {
madeAt: Date;
};
export type Simplify<A> = {
[K in keyof A]: A[K]
} extends infer B ? B : never
/**
* CoMaps are collaborative versions of plain objects, mapping string-like keys to values.
*
@@ -152,7 +149,7 @@ export class CoMap extends CoValueBase implements CoValue {
descriptor === "json"
? rawEdit.value
: "encoded" in descriptor
? decodeSync(descriptor.encoded)(rawEdit.value)
? descriptor.encoded.encode(rawEdit.value)
: new Ref(
rawEdit.value as ID<CoValue>,
target._loadedAs,
@@ -322,7 +319,7 @@ export class CoMap extends CoValueBase implements CoValue {
rawInit[key] = (initValue as unknown as CoValue).id;
}
} else if ("encoded" in descriptor) {
rawInit[key] = encodeSync(descriptor.encoded)(
rawInit[key] = descriptor.encoded.encode(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initValue as any,
);
@@ -391,21 +388,6 @@ export class CoMap extends CoValueBase implements CoValue {
return loadCoValue(this, id, as, depth);
}
/**
* Effectful version of `CoMap.load()`.
*
* Needs to be run inside an `AccountCtx` context.
*
* @category Subscription & Loading
*/
static loadEf<M extends CoMap, Depth>(
this: CoValueClass<M>,
id: ID<M>,
depth: Depth & DepthsIn<M>,
): Effect.Effect<DeeplyLoaded<M, Depth>, UnavailableError, AccountCtx> {
return loadCoValueEf<M, Depth>(this, id, depth);
}
/**
* Load and subscribe to a `CoMap` with a given ID, as a given account.
*
@@ -444,21 +426,6 @@ export class CoMap extends CoValueBase implements CoValue {
return subscribeToCoValue<M, Depth>(this, id, as, depth, listener);
}
/**
* Effectful version of `CoMap.subscribe()` that returns a stream of updates.
*
* Needs to be run inside an `AccountCtx` context.
*
* @category Subscription & Loading
*/
static subscribeEf<M extends CoMap, Depth>(
this: CoValueClass<M>,
id: ID<M>,
depth: Depth & DepthsIn<M>,
): Stream.Stream<DeeplyLoaded<M, Depth>, UnavailableError, AccountCtx> {
return subscribeToCoValueEf<M, Depth>(this, id, depth);
}
/**
* Given an already loaded `CoMap`, ensure that the specified fields are loaded to the specified depth.
*
@@ -489,6 +456,37 @@ export class CoMap extends CoValueBase implements CoValue {
): () => void {
return subscribeToExistingCoValue(this, depth, listener);
}
applyDiff<N extends Partial<CoMapInit<this>>>(newValues: N) {
for (const key in newValues) {
if (Object.prototype.hasOwnProperty.call(newValues, key)) {
const tKey = key as keyof typeof newValues & keyof this;
const descriptor = (this._schema[tKey as string] ||
this._schema[ItemsSym]) as Schema;
if (tKey in this._schema) {
const newValue = newValues[tKey];
const currentValue = (this as unknown as N)[tKey];
if (descriptor === "json" || "encoded" in descriptor) {
if (currentValue !== newValue) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this as any)[tKey] = newValue;
}
} else if (isRefEncoded(descriptor)) {
const currentId = (currentValue as CoValue | undefined)
?.id;
const newId = (newValue as CoValue | undefined)?.id;
if (currentId !== newId) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this as any)[tKey] = newValue;
}
}
}
}
}
return this;
}
}
export type CoKeys<Map extends object> = Exclude<
@@ -520,7 +518,7 @@ const CoMapProxyHandler: ProxyHandler<CoMap> = {
} else if ("encoded" in descriptor) {
return raw === undefined
? undefined
: decodeSync(descriptor.encoded)(raw);
: descriptor.encoded.decode(raw);
} else if (isRefEncoded(descriptor)) {
return raw === undefined
? undefined
@@ -539,6 +537,7 @@ const CoMapProxyHandler: ProxyHandler<CoMap> = {
if (
(typeof key === "string" || ItemsSym) &&
typeof value === "object" &&
value !== null &&
SchemaInit in value
) {
(target.constructor as typeof CoMap)._schema ||= {};
@@ -553,7 +552,7 @@ const CoMapProxyHandler: ProxyHandler<CoMap> = {
if (descriptor === "json") {
target._raw.set(key, value);
} else if ("encoded" in descriptor) {
target._raw.set(key, encodeSync(descriptor.encoded)(value));
target._raw.set(key, descriptor.encoded.encode(value));
} else if (isRefEncoded(descriptor)) {
target._raw.set(key, value.id);
subscriptionsScopes
@@ -609,6 +608,16 @@ const CoMapProxyHandler: ProxyHandler<CoMap> = {
}
}
},
has(target, key) {
const descriptor = (target._schema?.[key as keyof CoMap["_schema"]] ||
target._schema?.[ItemsSym]) as Schema;
if (target._raw && typeof key === "string" && descriptor) {
return target._raw.get(key) !== undefined;
} else {
return Reflect.has(target, key);
}
},
deleteProperty(target, key) {
const descriptor = (target._schema[key as keyof CoMap["_schema"]] ||
target._schema[ItemsSym]) as Schema;

View File

@@ -17,11 +17,9 @@ import type {
ID,
IfCo,
UnCo,
AccountCtx,
CoValueClass,
DeeplyLoaded,
DepthsIn,
UnavailableError,
} from "../internal.js";
import {
ItemsSym,
@@ -33,14 +31,10 @@ import {
SchemaInit,
isRefEncoded,
loadCoValue,
loadCoValueEf,
subscribeToCoValue,
subscribeToCoValueEf,
ensureCoValueLoaded,
subscribeToExistingCoValue,
} from "../internal.js";
import { encodeSync, decodeSync } from "@effect/schema/Schema";
import { Effect, Stream } from "effect";
export type CoStreamEntry<Item> = SingleCoStreamEntry<Item> & {
all: IterableIterator<SingleCoStreamEntry<Item>>;
@@ -146,7 +140,7 @@ export class CoStream<Item = any> extends CoValueBase implements CoValue {
if (itemDescriptor === "json") {
this._raw.push(item as JsonValue);
} else if ("encoded" in itemDescriptor) {
this._raw.push(encodeSync(itemDescriptor.encoded)(item));
this._raw.push(itemDescriptor.encoded.encode(item));
} else if (isRefEncoded(itemDescriptor)) {
this._raw.push((item as unknown as CoValue).id);
}
@@ -158,7 +152,7 @@ export class CoStream<Item = any> extends CoValueBase implements CoValue {
itemDescriptor === "json"
? (v: unknown) => v
: "encoded" in itemDescriptor
? encodeSync(itemDescriptor.encoded)
? itemDescriptor.encoded.encode
: (v: unknown) => v && (v as CoValue).id;
return {
@@ -202,15 +196,6 @@ export class CoStream<Item = any> extends CoValueBase implements CoValue {
return loadCoValue(this, id, as, depth);
}
/** @category Subscription & Loading */
static loadEf<S extends CoStream, Depth>(
this: CoValueClass<S>,
id: ID<S>,
depth: Depth & DepthsIn<S>,
): Effect.Effect<DeeplyLoaded<S, Depth>, UnavailableError, AccountCtx> {
return loadCoValueEf<S, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
static subscribe<S extends CoStream, Depth>(
this: CoValueClass<S>,
@@ -222,15 +207,6 @@ export class CoStream<Item = any> extends CoValueBase implements CoValue {
return subscribeToCoValue<S, Depth>(this, id, as, depth, listener);
}
/** @category Subscription & Loading */
static subscribeEf<S extends CoStream, Depth>(
this: CoValueClass<S>,
id: ID<S>,
depth: Depth & DepthsIn<S>,
): Stream.Stream<DeeplyLoaded<S, Depth>, UnavailableError, AccountCtx> {
return subscribeToCoValueEf<S, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
ensureLoaded<S extends CoStream, Depth>(
this: S,
@@ -270,7 +246,7 @@ function entryFromRawEntry<Item>(
? (CoValue & Item) | null
: Item;
} else if ("encoded" in itemField) {
return decodeSync(itemField.encoded)(rawEntry.value);
return itemField.encoded.decode(rawEntry.value);
} else if (isRefEncoded(itemField)) {
return this.ref?.accessFrom(
accessFrom,
@@ -638,15 +614,6 @@ export class BinaryCoStream extends CoValueBase implements CoValue {
return loadCoValue(this, id, as, depth);
}
/** @category Subscription & Loading */
static loadEf<B extends BinaryCoStream, Depth>(
this: CoValueClass<B>,
id: ID<B>,
depth: Depth & DepthsIn<B>,
): Effect.Effect<DeeplyLoaded<B, Depth>, UnavailableError, AccountCtx> {
return loadCoValueEf<B, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
static subscribe<B extends BinaryCoStream, Depth>(
this: CoValueClass<B>,
@@ -658,15 +625,6 @@ export class BinaryCoStream extends CoValueBase implements CoValue {
return subscribeToCoValue<B, Depth>(this, id, as, depth, listener);
}
/** @category Subscription & Loading */
static subscribeEf<B extends BinaryCoStream, Depth>(
this: CoValueClass<B>,
id: ID<B>,
depth: Depth & DepthsIn<B>,
): Stream.Stream<DeeplyLoaded<B, Depth>, UnavailableError, AccountCtx> {
return subscribeToCoValueEf<B, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
ensureLoaded<B extends BinaryCoStream, Depth>(
this: B,

View File

@@ -4,11 +4,9 @@ import type {
ID,
RefEncoded,
Schema,
AccountCtx,
CoValueClass,
DeeplyLoaded,
DepthsIn,
UnavailableError,
} from "../internal.js";
import {
Account,
@@ -20,13 +18,10 @@ import {
AccountAndGroupProxyHandler,
MembersSym,
loadCoValue,
loadCoValueEf,
subscribeToCoValue,
subscribeToCoValueEf,
ensureCoValueLoaded,
subscribeToExistingCoValue,
} from "../internal.js";
import { Effect, Stream } from "effect";
/** @category Identity & Permissions */
export class Profile extends CoMap {
@@ -198,15 +193,6 @@ export class Group extends CoValueBase implements CoValue {
return loadCoValue(this, id, as, depth);
}
/** @category Subscription & Loading */
static loadEf<G extends Group, Depth>(
this: CoValueClass<G>,
id: ID<G>,
depth: Depth & DepthsIn<G>,
): Effect.Effect<DeeplyLoaded<G, Depth>, UnavailableError, AccountCtx> {
return loadCoValueEf<G, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
static subscribe<G extends Group, Depth>(
this: CoValueClass<G>,
@@ -218,15 +204,6 @@ export class Group extends CoValueBase implements CoValue {
return subscribeToCoValue<G, Depth>(this, id, as, depth, listener);
}
/** @category Subscription & Loading */
static subscribeEf<G extends Group, Depth>(
this: CoValueClass<G>,
id: ID<G>,
depth: Depth & DepthsIn<G>,
): Stream.Stream<DeeplyLoaded<G, Depth>, UnavailableError, AccountCtx> {
return subscribeToCoValueEf<G, Depth>(this, id, depth);
}
/** @category Subscription & Loading */
ensureLoaded<G extends Group, Depth>(
this: G,

View File

@@ -1,10 +1,8 @@
import { Effect, Option, Sink, Stream } from "effect";
import type { CojsonInternalTypes, RawCoValue } from "cojson";
import { RawAccount } from "cojson";
import type { DeeplyLoaded, DepthsIn, UnavailableError } from "../internal.js";
import type { DeeplyLoaded, DepthsIn } from "../internal.js";
import {
Account,
AccountCtx,
Group,
SubscriptionScope,
Ref,
@@ -124,7 +122,12 @@ export class CoValueBase implements CoValue {
castAs<Cl extends CoValueClass & CoValueFromRaw<CoValue>>(
cl: Cl,
): InstanceType<Cl> {
return cl.fromRaw(this._raw) as InstanceType<Cl>;
const casted = cl.fromRaw(this._raw) as InstanceType<Cl>;
const subscriptionScope = subscriptionsScopes.get(this);
if (subscriptionScope) {
subscriptionsScopes.set(casted, subscriptionScope);
}
return casted;
}
}
@@ -134,13 +137,22 @@ export function loadCoValue<V extends CoValue, Depth>(
as: Account,
depth: Depth & DepthsIn<V>,
): Promise<DeeplyLoaded<V, Depth> | undefined> {
return Effect.runPromise(
loadCoValueEf(cls, id, depth).pipe(
Effect.mapError(() => undefined),
Effect.merge,
Effect.provideService(AccountCtx, as),
),
);
return new Promise((resolve) => {
const unsubscribe = subscribeToCoValue(
cls,
id,
as,
depth,
(value) => {
resolve(value);
unsubscribe();
},
() => {
resolve(undefined);
unsubscribe();
},
);
});
}
export function ensureCoValueLoaded<V extends CoValue, Depth>(
@@ -155,41 +167,46 @@ export function ensureCoValueLoaded<V extends CoValue, Depth>(
);
}
export function loadCoValueEf<V extends CoValue, Depth>(
cls: CoValueClass<V>,
id: ID<V>,
depth: Depth & DepthsIn<V>,
): Effect.Effect<DeeplyLoaded<V, Depth>, UnavailableError, AccountCtx> {
return subscribeToCoValueEf(cls, id, depth).pipe(
Stream.runHead,
Effect.andThen(
Effect.mapError((_noSuchElem) => "unavailable" as const),
),
);
}
export function subscribeToCoValue<V extends CoValue, Depth>(
cls: CoValueClass<V>,
id: ID<V>,
as: Account,
depth: Depth & DepthsIn<V>,
listener: (value: DeeplyLoaded<V, Depth>) => void,
onUnavailable?: () => void,
): () => void {
void Effect.runPromise(
Effect.provideService(
subscribeToCoValueEf(cls, id, depth).pipe(
Stream.run(
Sink.forEach((update) =>
Effect.sync(() => listener(update)),
),
),
),
AccountCtx,
as,
),
);
const ref = new Ref(id, as, { ref: cls, optional: false });
return function unsubscribe() {};
let unsubscribed = false;
let unsubscribe: (() => void) | undefined;
ref.load()
.then((value) => {
if (!value) {
onUnavailable && onUnavailable();
return;
}
if (unsubscribed) return;
const subscription = new SubscriptionScope(
value,
cls as CoValueClass<V> & CoValueFromRaw<V>,
(update) => {
if (fulfillsDepth(depth, update)) {
listener(update as DeeplyLoaded<V, Depth>);
}
},
);
unsubscribe = () => subscription.unsubscribeAll();
})
.catch((e) => {
console.error("Failed to load / subscribe to CoValue", e);
});
return function unsubscribeAtAnyPoint() {
unsubscribed = true;
unsubscribe && unsubscribe();
};
}
export function subscribeToExistingCoValue<V extends CoValue, Depth>(
@@ -205,43 +222,3 @@ export function subscribeToExistingCoValue<V extends CoValue, Depth>(
listener,
);
}
export function subscribeToCoValueEf<V extends CoValue, Depth>(
cls: CoValueClass<V>,
id: ID<V>,
depth: Depth & DepthsIn<V>,
): Stream.Stream<DeeplyLoaded<V, Depth>, UnavailableError, AccountCtx> {
return AccountCtx.pipe(
Effect.andThen((account) =>
new Ref(id, account, {
ref: cls,
optional: false,
}).loadEf(),
),
Stream.fromEffect,
Stream.flatMap((value: V) =>
Stream.asyncScoped<V, UnavailableError>((emit) =>
Effect.gen(function* (_) {
const subscription = new SubscriptionScope(
value,
cls as CoValueClass<V> & CoValueFromRaw<V>,
(update) => void emit.single(update as V),
);
yield* _(
Effect.addFinalizer(() =>
Effect.sync(() => subscription.unsubscribeAll()),
),
);
}),
),
),
Stream.filterMap((update: V) =>
Option.fromNullable(
fulfillsDepth(depth, update)
? (update as DeeplyLoaded<V, Depth>)
: undefined,
),
),
);
}

View File

@@ -1,4 +1,3 @@
import { Effect } from "effect";
import type { CoID, RawCoValue } from "cojson";
import type {
Account,
@@ -6,7 +5,6 @@ import type {
ID,
RefEncoded,
UnCo,
UnavailableError,
} from "../internal.js";
import {
instantiateRefEncoded,
@@ -47,22 +45,6 @@ export class Ref<out V extends CoValue> {
}
}
loadEf() {
return Effect.async<V, UnavailableError>((fulfill) => {
this.loadHelper()
.then((value) => {
if (value === "unavailable") {
fulfill(Effect.fail<UnavailableError>("unavailable"));
} else {
fulfill(Effect.succeed(value));
}
})
.catch((e) => {
fulfill(Effect.die(e));
});
});
}
private async loadHelper(options?: {
onProgress: (p: number) => void;
}): Promise<V | "unavailable"> {

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