Compare commits
83 Commits
cojson@0.7
...
cojson@0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dee9aae49 | ||
|
|
a10bff981e | ||
|
|
e333f7884a | ||
|
|
8ea7bf237b | ||
|
|
5e8409fa08 | ||
|
|
23354c1767 | ||
|
|
0efb69d0db | ||
|
|
0462c4e41b | ||
|
|
70a5673197 | ||
|
|
9ec3203485 | ||
|
|
1a46f9b2e1 | ||
|
|
77bb26a8d7 | ||
|
|
2a36dcf592 | ||
|
|
fc2bcadbe2 | ||
|
|
46b0cc1adb | ||
|
|
d75d1c6a3f | ||
|
|
13b236aeed | ||
|
|
1c0a61b0b2 | ||
|
|
ceb92438f4 | ||
|
|
9bdd62ed4c | ||
|
|
3f5ef7e799 | ||
|
|
e7a573fa94 | ||
|
|
364060eaa7 | ||
|
|
a3ddc3d5e0 | ||
|
|
185f747adb | ||
|
|
895d281088 | ||
|
|
b44e4354f7 | ||
|
|
3fcb0665ec | ||
|
|
be49d33ce5 | ||
|
|
c7dae1608b | ||
|
|
b020c5868b | ||
|
|
eae42d3afe | ||
|
|
a816e2436e | ||
|
|
b09e35e372 | ||
|
|
d2c8121c9c | ||
|
|
380bb88ffa | ||
|
|
e0e3726b3c | ||
|
|
c2253a7979 | ||
|
|
9d244226ec | ||
|
|
71df5e3a59 | ||
|
|
3a738dad88 | ||
|
|
56d301cfde | ||
|
|
5efec6d5ea | ||
|
|
32769b24f1 | ||
|
|
6ab53c263d | ||
|
|
e7f3e4e242 | ||
|
|
8bb5201647 | ||
|
|
a9fc94f53d | ||
|
|
ca7c0510d1 | ||
|
|
1bf16f0859 | ||
|
|
21b503c188 | ||
|
|
0053e9796c | ||
|
|
e84941b1b1 | ||
|
|
57f6f8d67e | ||
|
|
5b8e69d973 | ||
|
|
7213b1bfa3 | ||
|
|
11f0770f08 | ||
|
|
44e6dc3ae8 | ||
|
|
b5d20d2488 | ||
|
|
0185545838 | ||
|
|
8c8f85859c | ||
|
|
104384409e | ||
|
|
179827ae56 | ||
|
|
6645829876 | ||
|
|
68cb302722 | ||
|
|
8dc33f2790 | ||
|
|
5f64ba326c | ||
|
|
7ccb15107c | ||
|
|
b102964743 | ||
|
|
216d50a09c | ||
|
|
07ea59fdcb | ||
|
|
932a84a47f | ||
|
|
34dda7bdbd | ||
|
|
49fa153581 | ||
|
|
c80b827775 | ||
|
|
a2bf9f988a | ||
|
|
ac27b2d5c2 | ||
|
|
c813518fdc | ||
|
|
d5034ed5c3 | ||
|
|
cf2c29a365 | ||
|
|
d948823db6 | ||
|
|
060ad4630d | ||
|
|
0ddceac4c0 |
86
.github/workflows/build-and-deploy.yaml
vendored
86
.github/workflows/build-and-deploy.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
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 +53,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 +88,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 }}
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
@@ -1,5 +1,93 @@
|
||||
# jazz-example-chat
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-chat",
|
||||
"private": true,
|
||||
"version": "0.0.61",
|
||||
"version": "0.0.72",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>);
|
||||
49
examples/inspector/CHANGELOG.md
Normal file
49
examples/inspector/CHANGELOG.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# jazz-example-chat
|
||||
|
||||
## 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
|
||||
17
examples/inspector/index.html
Normal file
17
examples/inspector/index.html
Normal 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>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "inspector",
|
||||
"name": "jazz-inspector",
|
||||
"private": true,
|
||||
"version": "0.0.47",
|
||||
"version": "0.0.51",
|
||||
"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",
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
4
examples/inspector/src/app.tsx
Normal file
4
examples/inspector/src/app.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./viewer/new-app";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
92
examples/inspector/src/index.css
Normal file
92
examples/inspector/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
18
examples/inspector/src/link-icon.tsx
Normal file
18
examples/inspector/src/link-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
examples/inspector/src/viewer/breadcrumbs.tsx
Normal file
42
examples/inspector/src/viewer/breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
353
examples/inspector/src/viewer/co-stream-view.tsx
Normal file
353
examples/inspector/src/viewer/co-stream-view.tsx
Normal 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)} />;
|
||||
}
|
||||
73
examples/inspector/src/viewer/grid-view.tsx
Normal file
73
examples/inspector/src/viewer/grid-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
examples/inspector/src/viewer/index.tsx
Normal file
27
examples/inspector/src/viewer/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
examples/inspector/src/viewer/new-app.tsx
Normal file
310
examples/inspector/src/viewer/new-app.tsx
Normal 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;
|
||||
}
|
||||
55
examples/inspector/src/viewer/page-stack.tsx
Normal file
55
examples/inspector/src/viewer/page-stack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
examples/inspector/src/viewer/page.tsx
Normal file
154
examples/inspector/src/viewer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
examples/inspector/src/viewer/table-viewer.tsx
Normal file
142
examples/inspector/src/viewer/table-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
examples/inspector/src/viewer/type-icon.tsx
Normal file
47
examples/inspector/src/viewer/type-icon.tsx
Normal 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} />;
|
||||
};
|
||||
9
examples/inspector/src/viewer/types.ts
Normal file
9
examples/inspector/src/viewer/types.ts
Normal 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_");
|
||||
107
examples/inspector/src/viewer/use-page-path.ts
Normal file
107
examples/inspector/src/viewer/use-page-path.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
152
examples/inspector/src/viewer/use-resolve-covalue.ts
Normal file
152
examples/inspector/src/viewer/use-resolve-covalue.ts
Normal 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;
|
||||
}
|
||||
248
examples/inspector/src/viewer/value-renderer.tsx
Normal file
248
examples/inspector/src/viewer/value-renderer.tsx
Normal 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>;
|
||||
}
|
||||
@@ -1,5 +1,98 @@
|
||||
# jazz-example-pets
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-pets",
|
||||
"private": true,
|
||||
"version": "0.0.79",
|
||||
"version": "0.0.90",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# jazz-example-todo
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.78",
|
||||
"version": "0.0.89",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -268,11 +268,7 @@ Jazz Mesh is currently free — 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>
|
||||
@@ -83,4 +83,4 @@ export function GcmpLogo({
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
5244
homepage/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.23",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:*",
|
||||
"effect": "^3.1.5",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.23",
|
||||
"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": {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.23",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:*",
|
||||
"effect": "^3.1.5",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,108 +1,106 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const g: typeof globalThis & {
|
||||
jazzPings?: {
|
||||
received: number;
|
||||
sent: number;
|
||||
dc: string;
|
||||
}[];
|
||||
} = globalThis;
|
||||
|
||||
export function createWebSocketPeer({
|
||||
id,
|
||||
websocket,
|
||||
role,
|
||||
}: {
|
||||
id: string;
|
||||
websocket: AnyWebSocket;
|
||||
role: Peer["role"];
|
||||
}): Effect.Effect<Peer> {
|
||||
return Effect.gen(function* () {
|
||||
const ws = options.websocket;
|
||||
}): 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),
|
||||
);
|
||||
}
|
||||
pingTimeout = setTimeout(() => {
|
||||
incoming
|
||||
.push("PingTimeout")
|
||||
.catch((e) =>
|
||||
console.error("Error while pushing ping timeout", e),
|
||||
);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
const websocketOpen = new Promise<void>((resolve) => {
|
||||
websocket.addEventListener("open", resolve, { once: true });
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
incoming,
|
||||
outgoing: {
|
||||
async push(msg) {
|
||||
await websocketOpen;
|
||||
websocket.send(JSON.stringify(msg));
|
||||
},
|
||||
close() {
|
||||
if (websocket.readyState === 1) {
|
||||
websocket.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# cojson
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.23",
|
||||
"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",
|
||||
|
||||
@@ -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 = {
|
||||
@@ -383,7 +390,7 @@ export class CoValueCore {
|
||||
0,
|
||||
);
|
||||
|
||||
if (sizeOfTxsSinceLastInbetweenSignature > 100 * 1024) {
|
||||
if (sizeOfTxsSinceLastInbetweenSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
// console.log(
|
||||
// "Saving inbetween signature for tx ",
|
||||
// sessionID,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -670,6 +670,10 @@ export class LocalNode {
|
||||
|
||||
return newNode;
|
||||
}
|
||||
|
||||
gracefulShutdown() {
|
||||
this.syncManager.gracefulShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
||||
@@ -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,142 +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);
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -126,14 +125,16 @@ export function mergeChunks(
|
||||
} else {
|
||||
const lastNewEntry = newEntries[newEntries.length - 1]!;
|
||||
lastNewEntry.transactions.push(...entry.transactions);
|
||||
lastNewEntry.lastSignature = entry.lastSignature;
|
||||
|
||||
bytesSinceLastSignature += entry.transactions.length;
|
||||
}
|
||||
}
|
||||
newSessions[sessionID] = newEntries;
|
||||
} else {
|
||||
return Either.right("nonContigous" as const);
|
||||
return "nonContigous" as const;
|
||||
}
|
||||
}
|
||||
|
||||
return Either.left({ header, sessionEntries: newSessions });
|
||||
return { header, sessionEntries: newSessions };
|
||||
}
|
||||
|
||||
@@ -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,389 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...coValues,
|
||||
[newContent.id]: newContentAsChunk,
|
||||
};
|
||||
} else {
|
||||
// yield*
|
||||
// Effect.promise(() =>
|
||||
// this.toLocalNode.write({
|
||||
// action: "known",
|
||||
// id: newContent.id,
|
||||
// header: false,
|
||||
// sessions: {},
|
||||
// isCorrection: true,
|
||||
// })
|
||||
// )
|
||||
// );
|
||||
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(
|
||||
"Incontiguous incoming update for " + newContent.id,
|
||||
"Non-contigous chunks while loading " + id,
|
||||
result,
|
||||
nextChunk,
|
||||
);
|
||||
return coValues;
|
||||
} else {
|
||||
result = merged;
|
||||
}
|
||||
} else {
|
||||
const merged = mergeChunks(coValue, newContentAsChunk);
|
||||
if (Either.isRight(merged)) {
|
||||
yield* Effect.logWarning(
|
||||
"Non-contigous new content for " + newContent.id,
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
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];
|
||||
|
||||
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>();
|
||||
|
||||
console.log("Compacting WAL files", walFiles);
|
||||
if (walFiles.length === 0) return;
|
||||
for (const blockFile of blocksInLevel) {
|
||||
const { handle, size }: { handle: RH; size: number } =
|
||||
await this.getBlockHandle(blockFile, this.fs);
|
||||
|
||||
yield* SynchronizedRef.updateEffect(this.currentWal, (wal) =>
|
||||
Effect.gen(this, function* () {
|
||||
if (wal) {
|
||||
yield* this.fs.close(wal);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const fileName of walFiles) {
|
||||
const { handle, size } =
|
||||
yield* this.fs.openToRead(fileName);
|
||||
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)) {
|
||||
console.warn(
|
||||
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",
|
||||
@@ -432,13 +554,15 @@ 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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
new LSMStorage(fs, localNodeAsPeer.incoming, localNodeAsPeer.outgoing);
|
||||
|
||||
@@ -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,
|
||||
@@ -13,54 +14,57 @@ export function connectedPeers(
|
||||
peer1role?: Peer["role"];
|
||||
peer2role?: Peer["role"];
|
||||
} = {},
|
||||
): 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,
|
||||
};
|
||||
|
||||
const peer1AsPeer: Peer = {
|
||||
id: peer1id,
|
||||
incoming: from1to2Rx,
|
||||
outgoing: from2to1Tx,
|
||||
role: peer1role,
|
||||
};
|
||||
const peer1AsPeer: Peer = {
|
||||
id: peer1id,
|
||||
incoming: from1to2Rx,
|
||||
outgoing: from2to1Tx,
|
||||
role: peer1role,
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,22 +55,17 @@ 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;
|
||||
@@ -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);
|
||||
@@ -368,55 +366,39 @@ 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().catch((e) => {
|
||||
console.error("Error processing messages from peer", peer.id, e);
|
||||
});
|
||||
}
|
||||
|
||||
trySendToPeer(peer: PeerState, msg: SyncMessage) {
|
||||
return Effect.runPromise(Queue.offer(peer.outgoing, msg));
|
||||
return peer.outgoing.push(msg);
|
||||
}
|
||||
|
||||
async handleLoad(msg: LoadMessage, peer: PeerState) {
|
||||
@@ -442,7 +424,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;
|
||||
@@ -473,11 +455,13 @@ export class SyncManager {
|
||||
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;
|
||||
@@ -687,10 +671,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 +744,12 @@ export class SyncManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gracefulShutdown() {
|
||||
for (const peer of Object.values(this.peers)) {
|
||||
peer.outgoing.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function knownStateIn(msg: LoadMessage | KnownStateMessage) {
|
||||
|
||||
@@ -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
@@ -1,5 +1,81 @@
|
||||
# jazz-browser-media-images
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-browser-media-images",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.25",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,84 @@
|
||||
# jazz-browser
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.25",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -309,7 +302,7 @@ const opfsWorkerJSSrc = `
|
||||
postMessage({requestId: event.data.requestId, data: buffer, result: "done"});
|
||||
} else if (event.data.type === "close") {
|
||||
const handle = handlesByRequest.get(event.data.handle);
|
||||
console.log("Closing handle", filenamesForHandles.get(handle), event.data.handle, handle);
|
||||
// console.log("Closing handle", filenamesForHandles.get(handle), event.data.handle, handle);
|
||||
handle.flush();
|
||||
handle.close();
|
||||
handlesByRequest.delete(handle);
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
# jazz-autosub
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.25",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:*",
|
||||
"cojson-transport-ws": "workspace:*",
|
||||
"effect": "^3.1.5",
|
||||
"jazz-tools": "workspace:*",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
# jazz-react
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.25",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
# jazz-autosub
|
||||
|
||||
## 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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bin": "./dist/index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.25",
|
||||
"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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# jazz-autosub
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
"types": "./src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.7.14",
|
||||
"version": "0.7.25",
|
||||
"dependencies": {
|
||||
"@effect/schema": "^0.66.16",
|
||||
"cojson": "workspace:*",
|
||||
"effect": "^3.1.5",
|
||||
"fast-check": "^3.17.2"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(newValues: Partial<CoMapInit<this>>) {
|
||||
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[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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"> {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
isCoValueClass,
|
||||
CoValueFromRaw,
|
||||
} from "../internal.js";
|
||||
import type { Schema as EffectSchema, TypeId } from "@effect/schema/Schema";
|
||||
|
||||
export type CoMarker = { readonly __co: unique symbol };
|
||||
/** @category Schema definition */
|
||||
@@ -17,6 +16,36 @@ export type IfCo<C, R> = C extends infer _A | infer B
|
||||
: never;
|
||||
export type UnCo<T> = T extends co<infer A> ? A : T;
|
||||
|
||||
const optional = {
|
||||
ref: optionalRef,
|
||||
json<T extends JsonValue>(): co<T | undefined> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { [SchemaInit]: "json" satisfies Schema } as any;
|
||||
},
|
||||
encoded<T>(arg: OptionalEncoder<T>): co<T | undefined> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { [SchemaInit]: { encoded: arg } satisfies Schema } as any;
|
||||
},
|
||||
string: {
|
||||
[SchemaInit]: "json" satisfies Schema,
|
||||
} as unknown as co<string | undefined>,
|
||||
number: {
|
||||
[SchemaInit]: "json" satisfies Schema,
|
||||
} as unknown as co<number | undefined>,
|
||||
boolean: {
|
||||
[SchemaInit]: "json" satisfies Schema,
|
||||
} as unknown as co<boolean | undefined>,
|
||||
null: {
|
||||
[SchemaInit]: "json" satisfies Schema,
|
||||
} as unknown as co<null | undefined>,
|
||||
literal<T extends (string | number | boolean)[]>(
|
||||
..._lit: T
|
||||
): co<T[number] | undefined> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { [SchemaInit]: "json" satisfies Schema } as any;
|
||||
},
|
||||
};
|
||||
|
||||
/** @category Schema definition */
|
||||
export const co = {
|
||||
string: {
|
||||
@@ -48,8 +77,15 @@ export const co = {
|
||||
ref,
|
||||
items: ItemsSym as ItemsSym,
|
||||
members: MembersSym as MembersSym,
|
||||
optional,
|
||||
};
|
||||
|
||||
function optionalRef<C extends CoValueClass>(
|
||||
arg: C | ((_raw: InstanceType<C>["_raw"]) => C),
|
||||
): co<InstanceType<C> | null | undefined> {
|
||||
return ref(arg, { optional: true });
|
||||
}
|
||||
|
||||
function ref<C extends CoValueClass>(
|
||||
arg: C | ((_raw: InstanceType<C>["_raw"]) => C),
|
||||
): co<InstanceType<C> | null>;
|
||||
@@ -76,7 +112,7 @@ function ref<
|
||||
}
|
||||
|
||||
export type JsonEncoded = "json";
|
||||
export type EncodedAs<V> = { encoded: Encoder<V> };
|
||||
export type EncodedAs<V> = { encoded: Encoder<V> | OptionalEncoder<V> };
|
||||
export type RefEncoded<V extends CoValue> = {
|
||||
ref: CoValueClass<V> | ((raw: RawCoValue) => CoValueClass<V>);
|
||||
optional: boolean;
|
||||
@@ -115,27 +151,23 @@ export type SchemaFor<Field> = NonNullable<Field> extends CoValue
|
||||
? JsonEncoded
|
||||
: EncodedAs<NonNullable<Field>>;
|
||||
|
||||
export type EffectSchemaWithInputAndOutput<A, I = A> = EffectSchema<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any,
|
||||
never
|
||||
> & {
|
||||
[TypeId]: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
_A: (_: any) => A;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
_I: (_: any) => I;
|
||||
};
|
||||
export type Encoder<V> = {
|
||||
encode: (value: V) => JsonValue;
|
||||
decode: (value: JsonValue) => V;
|
||||
};
|
||||
export type OptionalEncoder<V> =
|
||||
| Encoder<V>
|
||||
| {
|
||||
encode: (value: V | undefined) => JsonValue;
|
||||
decode: (value: JsonValue) => V | undefined;
|
||||
};
|
||||
|
||||
export type Encoder<V> = EffectSchemaWithInputAndOutput<V, JsonValue>;
|
||||
|
||||
import { Date } from "@effect/schema/Schema";
|
||||
import { SchemaInit, ItemsSym, MembersSym } from "./symbols.js";
|
||||
|
||||
/** @category Schema definition */
|
||||
export const Encoders = {
|
||||
Date,
|
||||
Date: {
|
||||
encode: (value: Date) => value.toISOString(),
|
||||
decode: (value: JsonValue) => new Date(value as string),
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user