Compare commits
64 Commits
jazz-react
...
render-pas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b160213ef | ||
|
|
c8737118a0 | ||
|
|
e98c6dba71 | ||
|
|
c6ca3c356a | ||
|
|
815deccfbd | ||
|
|
53aa057e67 | ||
|
|
c7bae413bc | ||
|
|
261042d8e6 | ||
|
|
2a61ef0462 | ||
|
|
00f2528f6a | ||
|
|
e422ce48fd | ||
|
|
370f6a98ff | ||
|
|
bdcbf538c4 | ||
|
|
1a11697b08 | ||
|
|
b62c58027a | ||
|
|
627e48043c | ||
|
|
97ca54fbcd | ||
|
|
3b40758901 | ||
|
|
411a7be344 | ||
|
|
22a1c771ee | ||
|
|
eea3c6e2ab | ||
|
|
17e9524bfe | ||
|
|
9ccd1b9948 | ||
|
|
0b6056b96e | ||
|
|
b2a9147053 | ||
|
|
0b527d4010 | ||
|
|
5c236e5c3c | ||
|
|
7616d5789b | ||
|
|
631486e235 | ||
|
|
5e0d63a4d6 | ||
|
|
ba988cbb90 | ||
|
|
6f6800bcd8 | ||
|
|
90290902e8 | ||
|
|
d8582fc9ed | ||
|
|
58905ae8f4 | ||
|
|
65f630fb44 | ||
|
|
b68f85542b | ||
|
|
f122a9f938 | ||
|
|
e15d994df6 | ||
|
|
48ac92bc67 | ||
|
|
226b1171e6 | ||
|
|
29228e21fe | ||
|
|
d3603625fd | ||
|
|
fa94d8c171 | ||
|
|
aeb094baa1 | ||
|
|
18f3497397 | ||
|
|
8be7158d1f | ||
|
|
cae3a9ee32 | ||
|
|
6260045140 | ||
|
|
466d79d9a6 | ||
|
|
67776c77a0 | ||
|
|
e6868d3030 | ||
|
|
c8ae3a36ca | ||
|
|
bf9c158455 | ||
|
|
1301112a6b | ||
|
|
c447f08029 | ||
|
|
5a63cbae9b | ||
|
|
7d06f1dbf4 | ||
|
|
e48a3e4c27 | ||
|
|
a326ed971c | ||
|
|
6139803679 | ||
|
|
bb2052e1f2 | ||
|
|
40af02acb3 | ||
|
|
3bdb753b78 |
5
.changeset/clean-flies-sell.md
Normal file
5
.changeset/clean-flies-sell.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"jazz-tools": patch
|
||||
---
|
||||
|
||||
Fixes co.optional.Date throwing when assigned undefined
|
||||
39
.github/actions/android-emulator/action.yml
vendored
Normal file
39
.github/actions/android-emulator/action.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Setup Android Emulator
|
||||
|
||||
inputs:
|
||||
api-level:
|
||||
description: 'API level to use for the emulator'
|
||||
required: true
|
||||
default: '29'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Enable KVM
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Gradle cache
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: AVD cache
|
||||
uses: useblacksmith/cache@v5
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: avd-${{ inputs.api-level }}
|
||||
|
||||
- name: Create AVD and Generate Snapshot for Caching
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ inputs.api-level }}
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
|
||||
disable-animations: false
|
||||
script: echo "Generated AVD snapshot for caching."
|
||||
64
.github/workflows/e2e-rn-test.yml
vendored
64
.github/workflows/e2e-rn-test.yml
vendored
@@ -4,14 +4,16 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- ".github/actions/android-emulator/**"
|
||||
- ".github/actions/source-code/**"
|
||||
- ".github/workflows/e2e-rn-test.yml"
|
||||
- "examples/chat-rn/**"
|
||||
- "examples/chat-rn-clerk/**"
|
||||
- "packages/jazz-react-native*/**"
|
||||
- "packages/**"
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
runs-on: macos-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -24,55 +26,47 @@ jobs:
|
||||
run: |
|
||||
mkdir -p ~/output
|
||||
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: corretto
|
||||
java-version: 22
|
||||
cache: gradle
|
||||
|
||||
- name: Pnpm Build
|
||||
run: pnpm turbo build --filter="./packages/*"
|
||||
|
||||
- name: iOS Simulator
|
||||
id: ios-simulator
|
||||
uses: futureware-tech/simulator-action@v4
|
||||
with:
|
||||
os: iOS
|
||||
wait_for_boot: true
|
||||
|
||||
- name: chat-rn App Pre Build
|
||||
working-directory: ./examples/chat-rn
|
||||
run: |
|
||||
pnpm build
|
||||
pnpm expo prebuild --clean
|
||||
|
||||
- name: chat-rn App Build
|
||||
working-directory: ./examples/chat-rn/ios
|
||||
run: |
|
||||
xcodebuild -scheme "jazzchatrn" \
|
||||
-workspace jazzchatrn.xcworkspace \
|
||||
-archivePath $RUNNER_TEMP/jazzchatrn.xcarchive \
|
||||
-derivedDataPath $RUNNER_TEMP/build \
|
||||
-destination "id=${{ steps.ios-simulator.outputs.udid }}" \
|
||||
-configuration Release \
|
||||
-sdk iphonesimulator \
|
||||
build
|
||||
xcrun simctl install booted $RUNNER_TEMP/build/Build/Products/Release-iphonesimulator/jazzchatrn.app
|
||||
xcrun simctl spawn booted log stream --level debug | tee ~/output/sim.log &
|
||||
|
||||
|
||||
- name: Install Maestro
|
||||
run: |
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
- name: chat-rn App Test
|
||||
id: e2e_test
|
||||
working-directory: ./examples/chat-rn
|
||||
continue-on-error: true
|
||||
run: |
|
||||
export PATH="$PATH":"$HOME/.maestro/bin"
|
||||
export MAESTRO_DRIVER_STARTUP_TIMEOUT=300000 # setting to 5 mins 👀
|
||||
export MAESTRO_CLI_NO_ANALYTICS=1
|
||||
maestro test test/e2e/flow.yml
|
||||
- name: Setup Android Emulator
|
||||
id: android-emulator
|
||||
uses: ./.github/actions/android-emulator/
|
||||
with:
|
||||
api-level: 29
|
||||
|
||||
- name: Copy Maestro and Diagnostic Files
|
||||
- name: Test App
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
id: e2e_test
|
||||
continue-on-error: true
|
||||
with:
|
||||
api-level: 29
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
|
||||
disable-animations: true
|
||||
working-directory: ./examples/chat-rn/
|
||||
script: ./test/e2e/run.sh
|
||||
|
||||
- name: Copy Maestro Output
|
||||
if: steps.e2e_test.outcome != 'success'
|
||||
run: |
|
||||
cp -r ~/Library/Logs/DiagnosticReports/* ~/output
|
||||
cp -r ~/.maestro/tests/* ~/output
|
||||
|
||||
- name: Upload Output Files
|
||||
|
||||
82
.github/workflows/pre-release.yml
vendored
82
.github/workflows/pre-release.yml
vendored
@@ -19,4 +19,84 @@ jobs:
|
||||
run: pnpm turbo build --filter="./packages/*"
|
||||
|
||||
- name: Pre publish
|
||||
run: pnpm exec pkg-pr-new publish "./packages/*"
|
||||
run: pnpm exec pkg-pr-new publish --json output.json --comment=off "./packages/*"
|
||||
|
||||
- name: Post or update comment
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const output = JSON.parse(fs.readFileSync('output.json', 'utf8'));
|
||||
|
||||
const packages = output.packages
|
||||
.map((p) => `- ${p.name}: ${p.url}`)
|
||||
.join('\n');
|
||||
|
||||
const sha =
|
||||
context.event_name === 'pull_request'
|
||||
? context.payload.pull_request.head.sha
|
||||
: context.payload.after;
|
||||
|
||||
const resolutions = Object.fromEntries(
|
||||
output.packages.map((p) => [p.name, p.url])
|
||||
);
|
||||
|
||||
const commitUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha}`;
|
||||
|
||||
const body = `## Jazz pre-release
|
||||
|
||||
### Packages:
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(resolutions, null, 4)}
|
||||
\`\`\`
|
||||
|
||||
[View Commit](${commitUrl})`;
|
||||
|
||||
async function logPublishInfo() {
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('Publish Information');
|
||||
console.log('='.repeat(50));
|
||||
console.log('\nPublished Packages:');
|
||||
console.log(output.packages);
|
||||
console.log('\nTemplates:');
|
||||
console.log(templates);
|
||||
console.log(`\nCommit URL: ${commitUrl}`);
|
||||
console.log('\n' + '='.repeat(50));
|
||||
}
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
if (context.issue.number) {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
} else if (context.eventName === 'push') {
|
||||
const pullRequests = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
head: `${context.repo.owner}:${context.ref.replace(
|
||||
'refs/heads/',
|
||||
''
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (pullRequests.data.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pullRequests.data[0].number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: body,
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
'No open pull request found for this push. Logging publish information to console:'
|
||||
);
|
||||
await logPublishInfo();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
# chat-rn-clerk
|
||||
|
||||
## 1.0.67
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.10.2
|
||||
- jazz-react-native-auth-clerk@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-react-native-media-images@0.10.2
|
||||
|
||||
## 1.0.66
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react-native@0.10.1
|
||||
- jazz-react-native-auth-clerk@0.10.1
|
||||
- jazz-react-native-media-images@0.10.1
|
||||
|
||||
## 1.0.65
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -19,7 +19,6 @@ config.resolver.nodeModulesPaths = [
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
config.resolver.sourceExts = ["mjs", "js", "json", "ts", "tsx"];
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
config.resolver.requireCycleIgnorePatterns = [
|
||||
/(^|\/|\\)node_modules($|\/|\\)/,
|
||||
/(^|\/|\\)packages($|\/|\\)/,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chat-rn-clerk",
|
||||
"main": "index.js",
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.67",
|
||||
"scripts": {
|
||||
"build": "expo export -p ios",
|
||||
"start": "expo start",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# chat-rn
|
||||
|
||||
## 1.0.64
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react-native@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 1.0.63
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react-native@0.10.1
|
||||
|
||||
## 1.0.62
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -19,7 +19,6 @@ config.resolver.nodeModulesPaths = [
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
config.resolver.sourceExts = ["mjs", "js", "json", "ts", "tsx"];
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
config.resolver.requireCycleIgnorePatterns = [
|
||||
/(^|\/|\\)node_modules($|\/|\\)/,
|
||||
/(^|\/|\\)packages($|\/|\\)/,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-rn",
|
||||
"version": "1.0.62",
|
||||
"version": "1.0.64",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "expo export -p ios",
|
||||
|
||||
18
examples/chat-rn/test/e2e/run.sh
Executable file
18
examples/chat-rn/test/e2e/run.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script is necessary, because unlike ios, the android emulator action
|
||||
# accepts a script, runs it as your tests, then terminates.
|
||||
|
||||
set -e
|
||||
|
||||
# build and install the app
|
||||
cd ./android/
|
||||
./gradlew installRelease
|
||||
cd ..
|
||||
|
||||
# run the e2e tests
|
||||
export PATH="$PATH":"$HOME/.maestro/bin"
|
||||
export MAESTRO_DRIVER_STARTUP_TIMEOUT=300000 # setting to 5 mins 👀
|
||||
export MAESTRO_CLI_NO_ANALYTICS=1
|
||||
export MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED=true
|
||||
maestro test test/e2e/flow.yml
|
||||
@@ -1,5 +1,22 @@
|
||||
# chat-vue
|
||||
|
||||
## 0.0.51
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-vue@0.10.2
|
||||
|
||||
## 0.0.50
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser@0.10.1
|
||||
- jazz-vue@0.10.1
|
||||
|
||||
## 0.0.49
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-vue",
|
||||
"version": "0.0.49",
|
||||
"version": "0.0.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# jazz-example-chat
|
||||
|
||||
## 0.0.147
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-browser-media-images@0.10.2
|
||||
|
||||
## 0.0.146
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser-media-images@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.145
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-chat",
|
||||
"private": true,
|
||||
"version": "0.0.145",
|
||||
"version": "0.0.147",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -31,6 +31,7 @@ export function App() {
|
||||
<input
|
||||
type="text"
|
||||
value={me?.profile?.name ?? ""}
|
||||
className="bg-transparent"
|
||||
onChange={(e) => {
|
||||
if (!me?.profile) return;
|
||||
me.profile.name = e.target.value;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# minimal-auth-clerk
|
||||
|
||||
## 0.0.46
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-react-auth-clerk@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.45
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
- jazz-react-auth-clerk@0.10.1
|
||||
|
||||
## 0.0.44
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clerk",
|
||||
"private": true,
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@clerk/clerk-react": "^5.4.1",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-react-auth-clerk": "workspace:0.10.0",
|
||||
"jazz-react-auth-clerk": "workspace:0.10.2",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# file-share-svelte
|
||||
|
||||
## 0.0.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-svelte@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-svelte@0.10.1
|
||||
|
||||
## 0.0.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "file-share-svelte",
|
||||
"version": "0.0.29",
|
||||
"version": "0.0.31",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# form
|
||||
|
||||
## 0.0.42
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-browser-media-images@0.10.2
|
||||
|
||||
## 0.0.41
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser-media-images@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "form",
|
||||
"private": true,
|
||||
"version": "0.0.40",
|
||||
"version": "0.0.42",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# image-upload
|
||||
|
||||
## 0.0.44
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-browser-media-images@0.10.2
|
||||
|
||||
## 0.0.43
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser-media-images@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.42
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "image-upload",
|
||||
"private": true,
|
||||
"version": "0.0.42",
|
||||
"version": "0.0.44",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# jazz-example-inspector
|
||||
|
||||
## 0.0.105
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [cae3a9e]
|
||||
- cojson@0.10.2
|
||||
- cojson-transport-ws@0.10.2
|
||||
|
||||
## 0.0.104
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- cojson@0.10.1
|
||||
- cojson-transport-ws@0.10.1
|
||||
|
||||
## 0.0.103
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-inspector-app",
|
||||
"private": true,
|
||||
"version": "0.0.103",
|
||||
"version": "0.0.105",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,8 +16,8 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cojson": "workspace:0.10.0",
|
||||
"cojson-transport-ws": "workspace:0.10.0",
|
||||
"cojson": "workspace:0.10.2",
|
||||
"cojson-transport-ws": "workspace:0.10.2",
|
||||
"hash-slash": "workspace:0.2.1",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -5,13 +5,9 @@ import {
|
||||
RawCoStream,
|
||||
RawCoValue,
|
||||
} from "cojson";
|
||||
import { base64URLtoBytes } from "cojson/src/base64url.ts";
|
||||
import {
|
||||
BinaryStreamItem,
|
||||
BinaryStreamStart,
|
||||
CoStreamItem,
|
||||
} from "cojson/src/coValues/coStream.ts";
|
||||
import { JsonObject, JsonValue } from "cojson/src/jsonValue.ts";
|
||||
import { base64URLtoBytes } from "cojson";
|
||||
import { BinaryStreamItem, BinaryStreamStart, CoStreamItem } from "cojson";
|
||||
import { JsonObject, JsonValue } from "cojson";
|
||||
import { ArrowDownToLine } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PageInfo } from "./types";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { CoID, LocalNode, RawCoValue } from "cojson";
|
||||
import { JsonObject } from "cojson/src/jsonValue.ts";
|
||||
import { JsonObject } from "cojson";
|
||||
import { ResolveIcon } from "./type-icon";
|
||||
import { PageInfo, isCoId } from "./types";
|
||||
import { CoMapPreview, ValueRenderer } from "./value-renderer";
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
RawAccount,
|
||||
RawAccountID,
|
||||
RawCoValue,
|
||||
WasmCrypto,
|
||||
} from "cojson";
|
||||
import { createWebSocketPeer } from "cojson-transport-ws";
|
||||
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CoID, LocalNode, RawCoValue } from "cojson";
|
||||
import { JsonObject } from "cojson/src/jsonValue.ts";
|
||||
import { JsonObject } from "cojson";
|
||||
import { useMemo, useState } from "react";
|
||||
import { LinkIcon } from "../link-icon";
|
||||
import { PageInfo } from "./types";
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# jazz-example-musicplayer
|
||||
|
||||
## 0.0.68
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-inspector@0.10.2
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.67
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-inspector@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.66
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-music-player",
|
||||
"private": true,
|
||||
"version": "0.0.66",
|
||||
"version": "0.0.68",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -22,8 +22,8 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-inspector": "workspace:*",
|
||||
"jazz-react": "workspace:0.10.0",
|
||||
"jazz-tools": "workspace:0.10.0",
|
||||
"jazz-react": "workspace:0.10.2",
|
||||
"jazz-tools": "workspace:0.10.2",
|
||||
"lucide-react": "^0.274.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# jazz-example-onboarding
|
||||
|
||||
## 0.0.48
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-browser-media-images@0.10.2
|
||||
|
||||
## 0.0.47
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser-media-images@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.46
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-onboarding",
|
||||
"private": true,
|
||||
"version": "0.0.46",
|
||||
"version": "0.0.48",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# organization
|
||||
|
||||
## 0.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.39
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.38
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "organization",
|
||||
"private": true,
|
||||
"version": "0.0.38",
|
||||
"version": "0.0.40",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write"
|
||||
|
||||
2
examples/organization/vite.config.d.ts
vendored
2
examples/organization/vite.config.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -1,6 +0,0 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-svelte@0.10.2
|
||||
|
||||
## 0.0.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-svelte@0.10.1
|
||||
|
||||
## 0.0.33
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "passkey-svelte",
|
||||
"version": "0.0.33",
|
||||
"version": "0.0.35",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# minimal-auth-passkey
|
||||
|
||||
## 0.0.45
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.44
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.43
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "passkey",
|
||||
"private": true,
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.45",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# passphrase
|
||||
|
||||
## 0.0.42
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.41
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "passphrase",
|
||||
"private": true,
|
||||
"version": "0.0.40",
|
||||
"version": "0.0.42",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# jazz-password-manager
|
||||
|
||||
## 0.0.66
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.65
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.64
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-password-manager",
|
||||
"private": true,
|
||||
"version": "0.0.64",
|
||||
"version": "0.0.66",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,8 +12,8 @@
|
||||
"clean-install": "rm -rf node_modules pnpm-lock.yaml && pnpm install"
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-react": "workspace:0.10.0",
|
||||
"jazz-tools": "workspace:0.10.0",
|
||||
"jazz-react": "workspace:0.10.2",
|
||||
"jazz-tools": "workspace:0.10.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.41.5",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# jazz-example-pets
|
||||
|
||||
## 0.0.164
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-browser-media-images@0.10.2
|
||||
|
||||
## 0.0.163
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser-media-images@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.162
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-pets",
|
||||
"private": true,
|
||||
"version": "0.0.162",
|
||||
"version": "0.0.164",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,9 +19,9 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-browser-media-images": "workspace:0.10.0",
|
||||
"jazz-react": "workspace:0.10.0",
|
||||
"jazz-tools": "workspace:0.10.0",
|
||||
"jazz-browser-media-images": "workspace:0.10.2",
|
||||
"jazz-react": "workspace:0.10.2",
|
||||
"jazz-tools": "workspace:0.10.2",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"is-ci": "^3.0.1",
|
||||
"jazz-run": "workspace:0.10.0",
|
||||
"jazz-run": "workspace:0.10.2",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# reactions
|
||||
|
||||
## 0.0.44
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-browser-media-images@0.10.2
|
||||
|
||||
## 0.0.43
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser-media-images@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.42
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reactions",
|
||||
"private": true,
|
||||
"version": "0.0.42",
|
||||
"version": "0.0.44",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# todo-vue
|
||||
|
||||
## 0.0.49
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-browser@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
- jazz-vue@0.10.2
|
||||
|
||||
## 0.0.48
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-browser@0.10.1
|
||||
- jazz-vue@0.10.1
|
||||
|
||||
## 0.0.47
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "todo-vue",
|
||||
"version": "0.0.47",
|
||||
"version": "0.0.49",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# jazz-example-todo
|
||||
|
||||
## 0.0.163
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.162
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.161
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.161",
|
||||
"version": "0.0.163",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,8 +16,8 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "workspace:0.10.0",
|
||||
"jazz-tools": "workspace:0.10.0",
|
||||
"jazz-react": "workspace:0.10.2",
|
||||
"jazz-tools": "workspace:0.10.2",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# version-history
|
||||
|
||||
## 0.0.41
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-react@0.10.2
|
||||
- jazz-tools@0.10.2
|
||||
|
||||
## 0.0.40
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- jazz-tools@0.10.1
|
||||
- jazz-react@0.10.1
|
||||
|
||||
## 0.0.39
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "version-history",
|
||||
"private": true,
|
||||
"version": "0.0.39",
|
||||
"version": "0.0.41",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -77,6 +77,7 @@ const icons = {
|
||||
|
||||
// copied from tailwind line height https://tailwindcss.com/docs/font-size
|
||||
const sizes = {
|
||||
"2xs": 14,
|
||||
xs: 16,
|
||||
sm: 20,
|
||||
md: 24,
|
||||
@@ -93,6 +94,7 @@ const sizes = {
|
||||
};
|
||||
|
||||
const strokeWidths = {
|
||||
"2xs": 2.5,
|
||||
xs: 2,
|
||||
sm: 2,
|
||||
md: 1.5,
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Icon } from "../atoms/Icon";
|
||||
|
||||
// TODO: add tabs feature, and remove CodeExampleTabs
|
||||
|
||||
function CopyButton({ code, size }: { code: string; size: "md" | "lg" }) {
|
||||
let [copyCount, setCopyCount] = useState(0);
|
||||
let copied = copyCount > 0;
|
||||
const [copyCount, setCopyCount] = useState(0);
|
||||
const copied = copyCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (copyCount > 0) {
|
||||
let timeout = setTimeout(() => setCopyCount(0), 1000);
|
||||
const timeout = setTimeout(() => setCopyCount(0), 1000);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
@@ -23,7 +23,8 @@ function CopyButton({ code, size }: { code: string; size: "md" | "lg" }) {
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"group/button absolute overflow-hidden rounded text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100",
|
||||
"group/button absolute overflow-hidden rounded text-2xs font-medium md:opacity-0 backdrop-blur transition md:focus:opacity-100 group-hover:opacity-100",
|
||||
"right-[9px] top-[9px]",
|
||||
copied
|
||||
? "bg-emerald-400/10 ring-1 ring-inset ring-emerald-400/20"
|
||||
: "bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5",
|
||||
@@ -72,13 +73,13 @@ export function CodeGroup({
|
||||
size = "md",
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
text?: string;
|
||||
size?: "md" | "lg";
|
||||
className?: string;
|
||||
}) {
|
||||
const textRef = useRef<HTMLPreElement | null>(null);
|
||||
const [code, setCode] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current) {
|
||||
setCode(textRef.current.innerText);
|
||||
|
||||
108
homepage/design-system/src/app/components/organisms/Dialog.tsx
Normal file
108
homepage/design-system/src/app/components/organisms/Dialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as Headless from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import type React from "react";
|
||||
|
||||
const sizes = {
|
||||
xs: "sm:max-w-xs",
|
||||
sm: "sm:max-w-sm",
|
||||
md: "sm:max-w-md",
|
||||
lg: "sm:max-w-lg",
|
||||
xl: "sm:max-w-xl",
|
||||
"2xl": "sm:max-w-2xl",
|
||||
"3xl": "sm:max-w-3xl",
|
||||
"4xl": "sm:max-w-4xl",
|
||||
"5xl": "sm:max-w-5xl",
|
||||
};
|
||||
|
||||
export type DialogProps = {
|
||||
size?: keyof typeof sizes;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
} & Omit<Headless.DialogProps, "as" | "className">;
|
||||
|
||||
export function Dialog({
|
||||
size = "lg",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<Headless.Dialog {...props}>
|
||||
<Headless.DialogBackdrop
|
||||
transition
|
||||
className="z-50 fixed inset-0 flex w-screen justify-center overflow-y-auto bg-stone-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-[closed]:opacity-0 data-[enter]:ease-out data-[leave]:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-stone-950/70"
|
||||
/>
|
||||
|
||||
<div className="z-50 fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||
<Headless.DialogPanel
|
||||
transition
|
||||
className={clsx(
|
||||
className,
|
||||
sizes[size],
|
||||
"row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-[--gutter] shadow-lg ring-1 ring-stone-950/10 [--gutter:theme(spacing.8)] sm:mb-auto sm:rounded-2xl dark:bg-stone-950 dark:ring-white/10 forced-colors:outline",
|
||||
"transition duration-100 will-change-transform data-[closed]:translate-y-12 data-[closed]:opacity-0 data-[enter]:ease-out data-[leave]:ease-in sm:data-[closed]:translate-y-0 sm:data-[closed]:data-[enter]:scale-95",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Headless.DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Headless.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.DialogTitleProps,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.DialogTitle
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"text-balance text-lg/6 font-semibold text-stone-900 dark:text-white",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.DescriptionProps,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.Description
|
||||
{...props}
|
||||
className={clsx(className, "mt-2 text-pretty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogBody({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
return <div {...props} className={clsx(className, "mt-6")} />;
|
||||
}
|
||||
|
||||
export function DialogActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
227
homepage/design-system/src/app/components/organisms/Dropdown.tsx
Normal file
227
homepage/design-system/src/app/components/organisms/Dropdown.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import * as Headless from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { Button } from "../atoms/Button";
|
||||
|
||||
export function Dropdown(props: Headless.MenuProps) {
|
||||
return <Headless.Menu {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownButton<T extends React.ElementType = typeof Button>({
|
||||
as = Button,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.MenuButtonProps<T>, "className">) {
|
||||
return <Headless.MenuButton as={as} {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
anchor = "bottom",
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.MenuItemsProps, "as" | "className">) {
|
||||
return (
|
||||
<Headless.MenuItems
|
||||
{...props}
|
||||
transition
|
||||
anchor={anchor}
|
||||
className={clsx(
|
||||
className,
|
||||
// Anchor positioning
|
||||
"[--anchor-gap:theme(spacing.2)] [--anchor-padding:theme(spacing.1)] data-[anchor~=start]:[--anchor-offset:-6px] data-[anchor~=end]:[--anchor-offset:6px] sm:data-[anchor~=start]:[--anchor-offset:-4px] sm:data-[anchor~=end]:[--anchor-offset:4px]",
|
||||
// Base styles
|
||||
"isolate w-max rounded-xl p-1",
|
||||
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||
"outline outline-1 outline-transparent focus:outline-none",
|
||||
// Handle scrolling when menu won't fit in viewport
|
||||
"overflow-y-auto",
|
||||
// Popover background
|
||||
"bg-white/75 backdrop-blur-xl dark:bg-stone-925",
|
||||
// Shadows
|
||||
"shadow-lg ring-1 ring-stone-950/10 dark:ring-inset dark:ring-white/10",
|
||||
// Define grid at the menu level if subgrid is supported
|
||||
"supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]",
|
||||
// Transitions
|
||||
"transition data-[closed]:data-[leave]:opacity-0 data-[leave]:duration-100 data-[leave]:ease-in",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownItem({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & (
|
||||
| Omit<Headless.MenuItemProps<"button">, "as" | "className">
|
||||
| Omit<Headless.MenuItemProps<typeof Link>, "as" | "className">
|
||||
)) {
|
||||
let classes = clsx(
|
||||
className,
|
||||
// Base styles
|
||||
"group rounded-lg space-x-2 px-3.5 py-2.5 focus:outline-none sm:px-3 sm:py-1.5",
|
||||
// Text styles
|
||||
"text-left text-stone-600 text-sm/6 dark:text-white forced-colors:text-[CanvasText]",
|
||||
// Focus
|
||||
"data-[focus]:bg-stone-100 dark:data-[focus]:bg-stone-900 ",
|
||||
// Disabled state
|
||||
"data-[disabled]:opacity-50",
|
||||
// Forced colors mode
|
||||
"forced-color-adjust-none forced-colors:data-[focus]:bg-[Highlight] forced-colors:data-[focus]:text-[HighlightText] forced-colors:[&>[data-slot=icon]]:data-[focus]:text-[HighlightText]",
|
||||
// Use subgrid when available but fallback to an explicit grid layout if not
|
||||
"col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] items-center",
|
||||
// Icons
|
||||
"[&>[data-slot=icon]]:col-start-1 [&>[data-slot=icon]]:row-start-1 [&>[data-slot=icon]]:-ml-0.5 [&>[data-slot=icon]]:mr-2.5 [&>[data-slot=icon]]:size-5 sm:[&>[data-slot=icon]]:mr-2 [&>[data-slot=icon]]:sm:size-4",
|
||||
"[&>[data-slot=icon]]:text-stone-500 [&>[data-slot=icon]]:data-[focus]:text-white [&>[data-slot=icon]]:dark:text-stone-400 [&>[data-slot=icon]]:data-[focus]:dark:text-white",
|
||||
// Avatar
|
||||
"[&>[data-slot=avatar]]:mr-2.5 [&>[data-slot=avatar]]:size-6 sm:[&>[data-slot=avatar]]:mr-2 sm:[&>[data-slot=avatar]]:size-5",
|
||||
);
|
||||
|
||||
return "href" in props ? (
|
||||
<Headless.MenuItem as={Link} {...props} className={classes} />
|
||||
) : (
|
||||
<Headless.MenuItem
|
||||
as="button"
|
||||
type="button"
|
||||
{...props}
|
||||
className={classes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(className, "col-span-5 px-3.5 pb-1 pt-2.5 sm:px-3")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownSection({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.MenuSectionProps,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.MenuSection
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
// Define grid at the section level instead of the item level if subgrid is supported
|
||||
"col-span-full supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownHeading({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.MenuHeadingProps,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.MenuHeading
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"col-span-full grid grid-cols-[1fr,auto] gap-x-12 px-3.5 pb-1 pt-2 text-sm/5 font-medium text-stone-500 sm:px-3 sm:text-xs/5 dark:text-stone-400",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownDivider({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.MenuSeparatorProps,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.MenuSeparator
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"col-span-full mx-3.5 my-1 h-px border-0 bg-stone-950/5 sm:mx-3 dark:bg-white/10 forced-colors:bg-[CanvasText]",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownLabel({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<Headless.LabelProps, "as" | "className">) {
|
||||
return (
|
||||
<Headless.Label
|
||||
{...props}
|
||||
data-slot="label"
|
||||
className={clsx(
|
||||
className,
|
||||
"text-stone-900 dark:text-white col-start-2 row-start-1",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownDescription({
|
||||
className,
|
||||
...props
|
||||
}: { className?: string } & Omit<
|
||||
Headless.DescriptionProps,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.Description
|
||||
data-slot="description"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"col-span-2 col-start-2 row-start-2 text-sm/5 text-stone-500 group-data-[focus]:text-white sm:text-xs/5 dark:text-stone-400 forced-colors:group-data-[focus]:text-[HighlightText]",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownShortcut({
|
||||
keys,
|
||||
className,
|
||||
...props
|
||||
}: { keys: string | string[]; className?: string } & Omit<
|
||||
Headless.DescriptionProps<"kbd">,
|
||||
"as" | "className"
|
||||
>) {
|
||||
return (
|
||||
<Headless.Description
|
||||
as="kbd"
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
"col-start-5 row-start-1 flex justify-self-end",
|
||||
)}
|
||||
>
|
||||
{(Array.isArray(keys) ? keys : keys.split("")).map((char, index) => (
|
||||
<kbd
|
||||
key={index}
|
||||
className={clsx([
|
||||
"min-w-[2ch] text-center font-sans capitalize text-stone-400 group-data-[focus]:text-white forced-colors:group-data-[focus]:text-[HighlightText]",
|
||||
// Make sure key names that are longer than one character (like "Tab") have extra space
|
||||
index > 0 && char.length > 1 && "pl-1",
|
||||
])}
|
||||
>
|
||||
{char}
|
||||
</kbd>
|
||||
))}
|
||||
</Headless.Description>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ComponentType, ReactNode } from "react";
|
||||
import { isActive } from "../../utils/nav";
|
||||
import { Copyright } from "../atoms/Copyright";
|
||||
import { NewsletterForm } from "./NewsletterForm";
|
||||
import { SocialLinks, SocialLinksProps } from "./SocialLinks";
|
||||
@@ -100,7 +101,7 @@ function FooterLink({
|
||||
className={clsx(
|
||||
"py-0.5 px-0 text-sm",
|
||||
className,
|
||||
path === href
|
||||
isActive(href)
|
||||
? "font-medium text-black dark:text-white cursor-default"
|
||||
: "text-stone-600 dark:text-stone-400 hover:text-black dark:hover:text-white transition-colors hover:transition-none",
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ComponentType, ReactNode, useEffect, useState } from "react";
|
||||
import { isActive } from "../../utils/nav";
|
||||
import { Icon } from "../atoms/Icon";
|
||||
import { BreadCrumb } from "../molecules/Breadcrumb";
|
||||
import { SocialLinks, SocialLinksProps } from "./SocialLinks";
|
||||
@@ -48,6 +49,7 @@ function NavItem({
|
||||
className?: string;
|
||||
}) {
|
||||
const { href, icon, title, items, firstOnRight } = item;
|
||||
const active = isActive(href);
|
||||
|
||||
const path = usePathname();
|
||||
|
||||
@@ -67,7 +69,7 @@ function NavItem({
|
||||
className,
|
||||
"text-sm px-2 lg:px-4 py-3 ",
|
||||
firstOnRight && "ml-auto",
|
||||
path === href ? "text-stone-900 dark:text-white" : "",
|
||||
active ? "text-stone-900 dark:text-white" : "",
|
||||
)}
|
||||
{...item}
|
||||
>
|
||||
@@ -81,7 +83,7 @@ function NavItem({
|
||||
<PopoverButton
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 text-sm px-2 lg:px-4 py-3 max-sm:w-full hover:text-stone-900 dark:hover:text-white transition-colors hover:transition-none focus-visible:outline-none",
|
||||
path === href ? "text-stone-900 dark:text-white" : "",
|
||||
active ? "text-stone-900 dark:text-white" : "",
|
||||
)}
|
||||
>
|
||||
<span>{title}</span>
|
||||
|
||||
11
homepage/design-system/src/app/utils/nav.ts
Normal file
11
homepage/design-system/src/app/utils/nav.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function isActive(href: string) {
|
||||
const path = usePathname();
|
||||
|
||||
if (href === "/") {
|
||||
return path === "/";
|
||||
}
|
||||
|
||||
return path.startsWith(href);
|
||||
}
|
||||
@@ -69,21 +69,12 @@ If you are not working within a monorepo, create a new file metro.config.js in t
|
||||
```ts
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
config.resolver.unstable_enablePackageExports = true; // important setting
|
||||
config.resolver.sourceExts = ["mjs", "js", "json", "ts", "tsx"];
|
||||
config.resolver.requireCycleIgnorePatterns = [/(^|\/|\\)node_modules($|\/|\\)/];
|
||||
module.exports = config;
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If you created the project using the command `npx create-expo-app -e with-router-tailwind my-jazz-app`, then `metro.config.js` is already present. In that case, simply add this setting to the existing file:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
config.resolver.unstable_enablePackageExports = true
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
#### Monorepos
|
||||
|
||||
For monorepos, use the following metro.config.js:
|
||||
@@ -106,7 +97,6 @@ For monorepos, use the following metro.config.js:
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
config.resolver.sourceExts = ["mjs", "js", "json", "ts", "tsx"];
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
config.resolver.requireCycleIgnorePatterns = [/(^|\/|\\)node_modules($|\/|\\)/];
|
||||
config.cacheStores = [
|
||||
new FileStore({
|
||||
|
||||
@@ -17,13 +17,14 @@ export const metadata = { title: "Jazz 0.10.0 is out!" };
|
||||
|
||||
<h3>What's new?</h3>
|
||||
Here is what's changed in this release:
|
||||
- [New authentication flow](/docs/upgrade/0-10-0#new-authentication-flow): Now with anonymous auth, redesigned to make Jazz easier to start with and be more flexible.
|
||||
- [Local-only mode](/docs/upgrade/0-10-0#local-only-mode): Users can now explore your app in local-only mode before signing up.
|
||||
- [Improvements on the loading APIs](/docs/upgrade/0-10-0#improved-loading-api); `ensureLoaded` now always returns a value and `useCoState` now returns `null` if the value is not found.
|
||||
- [Jazz Workers on native WebSockets](/docs/upgrade/0-10-0#native-websocket-for-jazz-workers): Improves compatibility with a wider set of Javascript runtimes.
|
||||
- [Group inheritance with role mapping](/docs/upgrade/0-10-0#group-inheritance): Groups can now inherit members from other groups with a fixed role.
|
||||
- [New authentication flow](#new-authentication-flow): Now with anonymous auth, redesigned to make Jazz easier to start with and be more flexible.
|
||||
- [Local-only mode](#local-only-mode): Users can now explore your app in local-only mode before signing up.
|
||||
- [Improvements on the loading APIs](#improved-loading-api); `ensureLoaded` now always returns a value and `useCoState` now returns `null` if the value is not found.
|
||||
- [Jazz Workers on native WebSockets](#native-websocket-for-jazz-workers): Improves compatibility with a wider set of Javascript runtimes.
|
||||
- [Group inheritance with role mapping](#group-inheritance): Groups can now inherit members from other groups with a fixed role.
|
||||
- Support for Node 14 dropped on cojson.
|
||||
- Bugfix: `Group.removeMember` now returns a promise.
|
||||
- Now `cojson` and `jazz-tools` don't export directly the crypto providers anymore. Replace the import with `cojson/crypto/WasmCrypto` or `cojson/crypto/PureJSCrypto` depending on your use case.
|
||||
</div>
|
||||
|
||||
<h3 id="new-authentication-flow">New authentication flow</h3>
|
||||
|
||||
@@ -283,27 +283,31 @@ const PasswordManagerIllustration = () => (
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="p-2">user@gmail.com</td>
|
||||
<td className="p-2">gmail.com</td>
|
||||
<td className="p-2">
|
||||
<MockButton>Copy password</MockButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-2">user@gmail.com</td>
|
||||
<td className="p-2">fb.com</td>
|
||||
<td className="p-2">
|
||||
<MockButton>Copy password</MockButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="p-2">user@gmail.com</td>
|
||||
<td className="p-2">x.com</td>
|
||||
<td className="p-2">
|
||||
<MockButton>Copy password</MockButton>
|
||||
</td>
|
||||
</tr>
|
||||
{[
|
||||
{
|
||||
email: "user@gmail.com",
|
||||
domain: "gmail.com",
|
||||
},
|
||||
{
|
||||
email: "user@gmail.com",
|
||||
domain: "fb.com",
|
||||
},
|
||||
{
|
||||
email: "user@gmail.com",
|
||||
domain: "x.com",
|
||||
},
|
||||
].map(({ email, domain }) => (
|
||||
<tr className="border-b max-sm:last:hidden" key={domain}>
|
||||
<td className="p-2">{email}</td>
|
||||
<td className="p-2">{domain}</td>
|
||||
<td className="p-2">
|
||||
<MockButton>
|
||||
<Icon name="copy" size="2xs" className="mr-1" />
|
||||
Password
|
||||
</MockButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Framework, frameworkNames, frameworks } from "@/lib/framework";
|
||||
import { Framework } from "@/lib/framework";
|
||||
import { useFramework } from "@/lib/use-framework";
|
||||
import { clsx } from "clsx";
|
||||
import { Select } from "gcmp-design-system/src/app/components/molecules/Select";
|
||||
import { Button } from "gcmp-design-system/src/app/components/atoms/Button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownButton,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
} from "gcmp-design-system/src/app/components/organisms/Dropdown";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
const frameworks: Record<
|
||||
Framework,
|
||||
{
|
||||
label: string;
|
||||
experimental: boolean;
|
||||
}
|
||||
> = {
|
||||
[Framework.React]: {
|
||||
label: "React",
|
||||
experimental: false,
|
||||
},
|
||||
[Framework.ReactNative]: {
|
||||
label: "React Native",
|
||||
experimental: false,
|
||||
},
|
||||
[Framework.Svelte]: {
|
||||
label: "Svelte",
|
||||
experimental: true,
|
||||
},
|
||||
[Framework.Vue]: {
|
||||
label: "Vue",
|
||||
experimental: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function FrameworkSelect({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
const defaultFramework = useFramework();
|
||||
const [framework, setFramework] = useState(defaultFramework);
|
||||
const [selectedFramework, setSelectedFramework] =
|
||||
useState<Framework>(defaultFramework);
|
||||
|
||||
const path = usePathname();
|
||||
|
||||
const onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newFramework = e.target.value as Framework;
|
||||
setFramework(newFramework);
|
||||
|
||||
const selectFramework = (newFramework: Framework) => {
|
||||
setSelectedFramework(newFramework);
|
||||
router.push(path.replace(defaultFramework, newFramework));
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
label="Framework"
|
||||
value={framework}
|
||||
onChange={onChange}
|
||||
className={clsx("label:sr-only", className)}
|
||||
>
|
||||
{frameworks.map((framework) => (
|
||||
<option key={framework} value={framework}>
|
||||
{frameworkNames[framework]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Dropdown>
|
||||
<DropdownButton
|
||||
icon="chevronDown"
|
||||
className="flex-row-reverse w-full justify-between"
|
||||
as={Button}
|
||||
variant="secondary"
|
||||
>
|
||||
{frameworks[selectedFramework].label}
|
||||
</DropdownButton>
|
||||
<DropdownMenu anchor="bottom start" className="z-50">
|
||||
{Object.entries(frameworks).map(([key, framework]) => (
|
||||
<DropdownItem
|
||||
className="items-baseline"
|
||||
key={key}
|
||||
onClick={() => selectFramework(key as Framework)}
|
||||
>
|
||||
{framework.label}
|
||||
{framework.experimental && (
|
||||
<span className="ml-1 text-xs text-stone-500">
|
||||
(experimental)
|
||||
</span>
|
||||
)}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
3
homepage/homepage/components/examples/CreateJazzApp.mdx
Normal file
3
homepage/homepage/components/examples/CreateJazzApp.mdx
Normal file
@@ -0,0 +1,3 @@
|
||||
```sh
|
||||
npx create-jazz-app@latest --example $EXAMPLE
|
||||
```
|
||||
@@ -1,21 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Example } from "@/lib/example";
|
||||
import { InterpolateInCode } from "@/mdx-components";
|
||||
import { DialogDescription } from "@headlessui/react";
|
||||
import { Button } from "gcmp-design-system/src/app/components/atoms/Button";
|
||||
import { CodeGroup } from "gcmp-design-system/src/app/components/molecules/CodeGroup";
|
||||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogTitle,
|
||||
} from "gcmp-design-system/src/app/components/organisms/Dialog";
|
||||
import { useState } from "react";
|
||||
import CreateJazzApp from "./CreateJazzApp.mdx";
|
||||
|
||||
export function ExampleLinks({ example }: { example: Example }) {
|
||||
const { slug, demoUrl } = example;
|
||||
const githubUrl = `https://github.com/gardencmp/jazz/tree/main/examples/${slug}`;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button href={githubUrl} newTab variant="secondary" size="sm">
|
||||
View code
|
||||
</Button>
|
||||
|
||||
{demoUrl && (
|
||||
<Button href={demoUrl} newTab variant="secondary" size="sm">
|
||||
View demo
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setIsOpen(true)}>
|
||||
Use as template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button href={githubUrl} newTab variant="secondary" size="sm">
|
||||
<span className="md:hidden">Code</span>
|
||||
<span className="hidden md:inline">View code</span>
|
||||
</Button>
|
||||
|
||||
{demoUrl && (
|
||||
<Button href={demoUrl} newTab variant="secondary" size="sm">
|
||||
<span className="md:hidden">Demo</span>
|
||||
<span className="hidden md:inline">View demo</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog onClose={() => setIsOpen(false)} open={isOpen}>
|
||||
<DialogTitle>Use {example.name} example as a template</DialogTitle>
|
||||
<DialogBody>
|
||||
<div className="mb-6 aspect-[16/9] overflow-hidden w-full rounded-md bg-white border dark:bg-stone-925 sm:aspect-[2/1] md:aspect-[3/2]">
|
||||
{example.illustration}
|
||||
</div>
|
||||
<p className="mb-3">
|
||||
Generate a new Jazz app by running the command below.
|
||||
</p>
|
||||
<CodeGroup>
|
||||
<CreateJazzApp
|
||||
components={InterpolateInCode({
|
||||
$EXAMPLE: example.slug,
|
||||
})}
|
||||
/>
|
||||
</CodeGroup>
|
||||
</DialogBody>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsOpen(false)} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,6 @@ export enum Framework {
|
||||
|
||||
export const frameworks = Object.values(Framework);
|
||||
|
||||
export const frameworkNames: Record<Framework, string> = {
|
||||
[Framework.React]: "React",
|
||||
[Framework.ReactNative]: "React Native",
|
||||
[Framework.Vue]: "Vue",
|
||||
[Framework.Svelte]: "Svelte",
|
||||
};
|
||||
|
||||
export const DEFAULT_FRAMEWORK = Framework.React;
|
||||
|
||||
export function isValidFramework(value: string): value is Framework {
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { DocsLink } from "@/components/docs/DocsLink";
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
a: (props) => <DocsLink {...props} />,
|
||||
...components,
|
||||
CodeWithInterpolation: ({
|
||||
highlightedCode,
|
||||
}: { highlightedCode: string }) => {
|
||||
return <div dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function InterpolateInCode(replace: { [key: string]: string }) {
|
||||
return {
|
||||
CodeWithInterpolation: ({
|
||||
highlightedCode,
|
||||
}: { highlightedCode: string }) => {
|
||||
const newHighlightedCode = Object.entries(replace).reduce(
|
||||
(acc, [key, value]) => {
|
||||
return acc.replaceAll(
|
||||
key.replaceAll("$", "$").replaceAll("_", "_"),
|
||||
value,
|
||||
);
|
||||
},
|
||||
highlightedCode,
|
||||
);
|
||||
return <div dangerouslySetInnerHTML={{ __html: newHighlightedCode }} />;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function highlightPlugin() {
|
||||
return async function transformer(tree) {
|
||||
const highlighter = await getHighlighter({
|
||||
langs: ["typescript", "bash", "tsx", "json", "svelte"],
|
||||
theme: "css-variables", // use the theme
|
||||
theme: "css-variables", // use css variables in shiki.css
|
||||
});
|
||||
|
||||
visit(tree, "code", visitor);
|
||||
@@ -116,7 +116,7 @@ function remarkHtmlToJsx() {
|
||||
const [ast] = args;
|
||||
visit(ast, "html", (node) => {
|
||||
const escapedHtml = JSON.stringify(node.value);
|
||||
const jsx = `<div dangerouslySetInnerHTML={{__html: ${escapedHtml} }}/>`;
|
||||
const jsx = `<CodeWithInterpolation highlightedCode={${escapedHtml}}/>`;
|
||||
const rawHtmlNode = fromMarkdown(jsx, {
|
||||
extensions: [mdxjs()],
|
||||
mdastExtensions: [mdxFromMarkdown()],
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [cae3a9e]
|
||||
- cojson@0.10.2
|
||||
- cojson-storage@0.10.2
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- cojson@0.10.1
|
||||
- cojson-storage@0.10.1
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.2",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ControlledAgent, LocalNode, WasmCrypto } from "cojson";
|
||||
import { ControlledAgent, LocalNode } from "cojson";
|
||||
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
||||
import { expect, test } from "vitest";
|
||||
import { IDBStorage } from "../index.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.8.62
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [cae3a9e]
|
||||
- cojson@0.10.2
|
||||
- cojson-storage@0.10.2
|
||||
|
||||
## 0.8.61
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- cojson@0.10.1
|
||||
- cojson-storage@0.10.1
|
||||
|
||||
## 0.8.60
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-storage-rn-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.8.60",
|
||||
"version": "0.8.62",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
StoredSessionRow,
|
||||
TransactionRow,
|
||||
} from "cojson-storage";
|
||||
import { Transaction } from "cojson/src/coValueCore.js";
|
||||
import { Signature } from "cojson/src/crypto/crypto.js";
|
||||
|
||||
export class SQLiteClient implements DBClientInterface {
|
||||
private readonly db: DatabaseT;
|
||||
@@ -64,7 +62,7 @@ export class SQLiteClient implements DBClientInterface {
|
||||
try {
|
||||
return rows.map((row: any) => ({
|
||||
...row,
|
||||
tx: JSON.parse(row.tx) as Transaction,
|
||||
tx: JSON.parse(row.tx) as CojsonInternalTypes.Transaction,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn("Invalid JSON in transaction", e);
|
||||
@@ -121,7 +119,7 @@ export class SQLiteClient implements DBClientInterface {
|
||||
async addTransaction(
|
||||
sessionRowID: number,
|
||||
nextIdx: number,
|
||||
newTransaction: Transaction,
|
||||
newTransaction: CojsonInternalTypes.Transaction,
|
||||
): Promise<void> {
|
||||
await this.db.execute(
|
||||
"INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)",
|
||||
@@ -136,7 +134,7 @@ export class SQLiteClient implements DBClientInterface {
|
||||
}: {
|
||||
sessionRowID: number;
|
||||
idx: number;
|
||||
signature: Signature;
|
||||
signature: CojsonInternalTypes.Signature;
|
||||
}): Promise<void> {
|
||||
await this.db.execute(
|
||||
"INSERT INTO signatureAfter (ses, idx, signature) VALUES (?, ?, ?)",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [cae3a9e]
|
||||
- cojson@0.10.2
|
||||
- cojson-storage@0.10.2
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- cojson@0.10.1
|
||||
- cojson-storage@0.10.1
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.2",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cojson": "workspace:0.10.0",
|
||||
"cojson": "workspace:0.10.2",
|
||||
"cojson-storage": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# cojson-storage
|
||||
|
||||
## 0.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [cae3a9e]
|
||||
- cojson@0.10.2
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- cojson@0.10.1
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.2",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 0.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [cae3a9e]
|
||||
- cojson@0.10.2
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5a63cba]
|
||||
- cojson@0.10.1
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.2",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SyncMessage } from "cojson";
|
||||
import { CoValueKnownState } from "cojson/src/sync.js";
|
||||
import { CojsonInternalTypes } from "cojson";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
BatchedOutgoingMessages,
|
||||
@@ -58,7 +58,7 @@ describe("BatchedOutgoingMessages", () => {
|
||||
sessions: {
|
||||
// Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
|
||||
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
|
||||
} as CoValueKnownState["sessions"],
|
||||
} as CojsonInternalTypes.CoValueKnownState["sessions"],
|
||||
};
|
||||
|
||||
batchedMessages.push(largeMessage);
|
||||
@@ -82,7 +82,7 @@ describe("BatchedOutgoingMessages", () => {
|
||||
sessions: {
|
||||
// Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
|
||||
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
|
||||
} as CoValueKnownState["sessions"],
|
||||
} as CojsonInternalTypes.CoValueKnownState["sessions"],
|
||||
};
|
||||
|
||||
batchedMessages.push(smallMessage);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ControlledAgent, LocalNode, WasmCrypto } from "cojson";
|
||||
import { ControlledAgent, LocalNode } from "cojson";
|
||||
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { createWebSocketPeer } from "../createWebSocketPeer";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServer } from "http";
|
||||
import { ControlledAgent, LocalNode, WasmCrypto } from "cojson";
|
||||
import { ControlledAgent, LocalNode } from "cojson";
|
||||
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { createWebSocketPeer } from "../createWebSocketPeer";
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# cojson
|
||||
|
||||
## 0.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cae3a9e: Add debug info to load failure end missing header errors
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5a63cba: Crypto packages must now be imported from cojson/crypto/WasmCrypto or cojson/crypto/PureJSCrypto
|
||||
Removed the separated dists for React Native.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
{
|
||||
"name": "cojson",
|
||||
"module": "dist/web/index.web.js",
|
||||
"main": "dist/web/index.web.js",
|
||||
"types": "src/index.web.ts",
|
||||
"react-native": "dist/native/index.native.js",
|
||||
"module": "dist/index.js",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"react-native": "./dist/native/index.native.js",
|
||||
"types": "./src/index.web.ts",
|
||||
"default": "./dist/web/index.web.js"
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./crypto": {
|
||||
"react-native": "./dist/native/crypto/export.js",
|
||||
"types": "./src/crypto/export.ts",
|
||||
"default": "./dist/web/crypto/export.js"
|
||||
"./dist/crypto/PureJSCrypto": {
|
||||
"types": "./src/crypto/PureJSCrypto.ts",
|
||||
"default": "./dist/crypto/PureJSCrypto.js"
|
||||
},
|
||||
"./native": {
|
||||
"react-native": "./dist/native/index.native.js",
|
||||
"types": "./src/index.native.ts",
|
||||
"default": "./dist/native/index.native.js"
|
||||
"./crypto/PureJSCrypto": {
|
||||
"types": "./src/crypto/PureJSCrypto.ts",
|
||||
"default": "./dist/crypto/PureJSCrypto.js"
|
||||
},
|
||||
"./crypto/WasmCrypto": {
|
||||
"types": "./src/crypto/WasmCrypto.ts",
|
||||
"default": "./dist/crypto/WasmCrypto.js"
|
||||
},
|
||||
"./dist/*": "./dist/*",
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.2",
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^1.29.0",
|
||||
"typescript": "~5.6.2",
|
||||
@@ -42,15 +43,12 @@
|
||||
"queueueue": "^4.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --sourceMap --outDir dist/web -p tsconfig.web.json",
|
||||
"dev:native": "tsc --watch --sourceMap --outDir dist/native -p tsconfig.native.json",
|
||||
"dev": "tsc --watch --sourceMap --outDir dist",
|
||||
"test": "vitest --run --root ../../ --project cojson",
|
||||
"test:watch": "vitest --watch --root ../../ --project cojson",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
"build:web": "tsc --sourceMap --outDir dist/web -p tsconfig.web.json",
|
||||
"build:native": "tsc --sourceMap --outDir dist/native -p tsconfig.native.json",
|
||||
"build": "rm -rf ./dist && pnpm run build:native && pnpm run build:web",
|
||||
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { CoValueState } from "./coValueState.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
|
||||
export class CoValuesStore {
|
||||
coValues = new Map<RawCoID, CoValueState>();
|
||||
|
||||
get(id: RawCoID) {
|
||||
let entry = this.coValues.get(id);
|
||||
|
||||
if (!entry) {
|
||||
entry = CoValueState.Unknown(id);
|
||||
this.coValues.set(id, entry);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
setAsAvailable(id: RawCoID, coValue: CoValueCore) {
|
||||
const entry = this.get(id);
|
||||
entry.dispatch({
|
||||
type: "available",
|
||||
coValue,
|
||||
});
|
||||
}
|
||||
|
||||
getEntries() {
|
||||
return this.coValues.entries();
|
||||
}
|
||||
|
||||
getValues() {
|
||||
return this.coValues.values();
|
||||
}
|
||||
|
||||
getKeys() {
|
||||
return this.coValues.keys();
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { RawCoID, SessionID } from "./ids.js";
|
||||
import {
|
||||
CoValueKnownState,
|
||||
combinedKnownStates,
|
||||
emptyKnownState,
|
||||
} from "./sync.js";
|
||||
|
||||
export type PeerKnownStateActions =
|
||||
| {
|
||||
type: "SET_AS_EMPTY";
|
||||
id: RawCoID;
|
||||
}
|
||||
| {
|
||||
type: "UPDATE_HEADER";
|
||||
id: RawCoID;
|
||||
header: boolean;
|
||||
}
|
||||
| {
|
||||
type: "UPDATE_SESSION_COUNTER";
|
||||
id: RawCoID;
|
||||
sessionId: SessionID;
|
||||
value: number;
|
||||
}
|
||||
| {
|
||||
type: "SET";
|
||||
id: RawCoID;
|
||||
value: CoValueKnownState;
|
||||
}
|
||||
| {
|
||||
type: "COMBINE_WITH";
|
||||
id: RawCoID;
|
||||
value: CoValueKnownState;
|
||||
};
|
||||
|
||||
export class PeerKnownStates {
|
||||
private coValues = new Map<RawCoID, CoValueKnownState>();
|
||||
|
||||
private updateHeader(id: RawCoID, header: boolean) {
|
||||
const knownState = this.coValues.get(id) ?? emptyKnownState(id);
|
||||
knownState.header = header;
|
||||
this.coValues.set(id, knownState);
|
||||
}
|
||||
|
||||
private combineWith(id: RawCoID, value: CoValueKnownState) {
|
||||
const knownState = this.coValues.get(id) ?? emptyKnownState(id);
|
||||
this.coValues.set(id, combinedKnownStates(knownState, value));
|
||||
}
|
||||
|
||||
private updateSessionCounter(
|
||||
id: RawCoID,
|
||||
sessionId: SessionID,
|
||||
value: number,
|
||||
) {
|
||||
const knownState = this.coValues.get(id) ?? emptyKnownState(id);
|
||||
const currentValue = knownState.sessions[sessionId] || 0;
|
||||
knownState.sessions[sessionId] = Math.max(currentValue, value);
|
||||
|
||||
this.coValues.set(id, knownState);
|
||||
}
|
||||
|
||||
get(id: RawCoID) {
|
||||
return this.coValues.get(id);
|
||||
}
|
||||
|
||||
has(id: RawCoID) {
|
||||
return this.coValues.has(id);
|
||||
}
|
||||
|
||||
clone() {
|
||||
const clone = new PeerKnownStates();
|
||||
clone.coValues = new Map(this.coValues);
|
||||
return clone;
|
||||
}
|
||||
|
||||
dispatch(action: PeerKnownStateActions) {
|
||||
switch (action.type) {
|
||||
case "UPDATE_HEADER":
|
||||
this.updateHeader(action.id, action.header);
|
||||
break;
|
||||
case "UPDATE_SESSION_COUNTER":
|
||||
this.updateSessionCounter(action.id, action.sessionId, action.value);
|
||||
break;
|
||||
case "SET":
|
||||
this.coValues.set(action.id, action.value);
|
||||
break;
|
||||
case "COMBINE_WITH":
|
||||
this.combineWith(action.id, action.value);
|
||||
break;
|
||||
case "SET_AS_EMPTY":
|
||||
this.coValues.set(action.id, emptyKnownState(action.id));
|
||||
break;
|
||||
}
|
||||
|
||||
this.triggerUpdate(action.id);
|
||||
}
|
||||
|
||||
listeners = new Set<(id: RawCoID, knownState: CoValueKnownState) => void>();
|
||||
|
||||
triggerUpdate(id: RawCoID) {
|
||||
this.trigger(id, this.coValues.get(id) ?? emptyKnownState(id));
|
||||
}
|
||||
|
||||
private trigger(id: RawCoID, knownState: CoValueKnownState) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(id, knownState);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (id: RawCoID, knownState: CoValueKnownState) => void) {
|
||||
this.listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { PeerKnownStateActions, PeerKnownStates } from "./PeerKnownStates.js";
|
||||
import {
|
||||
PriorityBasedMessageQueue,
|
||||
QueueEntry,
|
||||
} from "./PriorityBasedMessageQueue.js";
|
||||
import { TryAddTransactionsError } from "./coValueCore.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { CO_VALUE_PRIORITY } from "./priority.js";
|
||||
import { Peer, SyncMessage } from "./sync.js";
|
||||
|
||||
export class PeerState {
|
||||
constructor(
|
||||
private peer: Peer,
|
||||
knownStates: PeerKnownStates | undefined,
|
||||
) {
|
||||
this.optimisticKnownStates = knownStates?.clone() ?? new PeerKnownStates();
|
||||
|
||||
// We assume that exchanges with storage peers are always successful
|
||||
// hence we don't need to differentiate between knownStates and optimisticKnownStates
|
||||
if (peer.role === "storage") {
|
||||
this.knownStates = this.optimisticKnownStates;
|
||||
} else {
|
||||
this.knownStates = knownStates?.clone() ?? new PeerKnownStates();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we to collect all the known states that a given peer has told us about.
|
||||
*
|
||||
* This can be used to safely track the sync state of a coValue in a given peer.
|
||||
*/
|
||||
readonly knownStates: PeerKnownStates;
|
||||
|
||||
/**
|
||||
* This one collects the known states "optimistically".
|
||||
* We use it to keep track of the content we have sent to a given peer.
|
||||
*
|
||||
* The main difference with knownState is that this is updated when the content is sent to the peer without
|
||||
* waiting for any acknowledgement from the peer.
|
||||
*/
|
||||
readonly optimisticKnownStates: PeerKnownStates;
|
||||
readonly toldKnownState: Set<RawCoID> = new Set();
|
||||
|
||||
dispatchToKnownStates(action: PeerKnownStateActions) {
|
||||
this.knownStates.dispatch(action);
|
||||
|
||||
if (this.role !== "storage") {
|
||||
this.optimisticKnownStates.dispatch(action);
|
||||
}
|
||||
}
|
||||
|
||||
readonly erroredCoValues: Map<RawCoID, TryAddTransactionsError> = new Map();
|
||||
|
||||
get id() {
|
||||
return this.peer.id;
|
||||
}
|
||||
|
||||
get role() {
|
||||
return this.peer.role;
|
||||
}
|
||||
|
||||
get priority() {
|
||||
return this.peer.priority;
|
||||
}
|
||||
|
||||
get crashOnClose() {
|
||||
return this.peer.crashOnClose;
|
||||
}
|
||||
|
||||
shouldRetryUnavailableCoValues() {
|
||||
return this.peer.role === "server";
|
||||
}
|
||||
|
||||
isServerOrStoragePeer() {
|
||||
return this.peer.role === "server" || this.peer.role === "storage";
|
||||
}
|
||||
|
||||
/**
|
||||
* We set as default priority HIGH to handle all the messages without a
|
||||
* priority property as HIGH priority.
|
||||
*
|
||||
* This way we consider all the non-content messsages as HIGH priority.
|
||||
*/
|
||||
private queue = new PriorityBasedMessageQueue(CO_VALUE_PRIORITY.HIGH);
|
||||
private processing = false;
|
||||
public closed = false;
|
||||
|
||||
async processQueue() {
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
let entry: QueueEntry<SyncMessage> | undefined;
|
||||
while ((entry = this.queue.pull())) {
|
||||
// Awaiting the push to send one message at a time
|
||||
// This way when the peer is "under pressure" we can enqueue all
|
||||
// the coming messages and organize them by priority
|
||||
await this.peer.outgoing
|
||||
.push(entry.msg)
|
||||
.then(entry.resolve)
|
||||
.catch(entry.reject);
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
pushOutgoingMessage(msg: SyncMessage) {
|
||||
if (this.closed) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const promise = this.queue.push(msg);
|
||||
|
||||
void this.processQueue();
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
get incoming() {
|
||||
if (this.closed) {
|
||||
return (async function* () {
|
||||
yield "Disconnected" as const;
|
||||
})();
|
||||
}
|
||||
|
||||
return this.peer.incoming;
|
||||
}
|
||||
|
||||
private closeQueue() {
|
||||
let entry: QueueEntry<SyncMessage> | undefined;
|
||||
while ((entry = this.queue.pull())) {
|
||||
// Using resolve here to avoid unnecessary noise in the logs
|
||||
entry.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
gracefulShutdown() {
|
||||
logger.debug("Gracefully closing", {
|
||||
peerId: this.id,
|
||||
peerRole: this.role,
|
||||
});
|
||||
this.closeQueue();
|
||||
this.peer.outgoing.close();
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { RawCoID } from "./ids.js";
|
||||
import {
|
||||
CoValueKnownState,
|
||||
PeerID,
|
||||
SyncManager,
|
||||
emptyKnownState,
|
||||
} from "./sync.js";
|
||||
|
||||
export type SyncState = {
|
||||
uploaded: boolean;
|
||||
};
|
||||
|
||||
export type GlobalSyncStateListenerCallback = (
|
||||
peerId: PeerID,
|
||||
knownState: CoValueKnownState,
|
||||
sync: SyncState,
|
||||
) => void;
|
||||
|
||||
export type PeerSyncStateListenerCallback = (
|
||||
knownState: CoValueKnownState,
|
||||
sync: SyncState,
|
||||
) => void;
|
||||
|
||||
export class SyncStateManager {
|
||||
constructor(private syncManager: SyncManager) {}
|
||||
|
||||
private listeners = new Set<GlobalSyncStateListenerCallback>();
|
||||
private listenersByPeers = new Map<
|
||||
PeerID,
|
||||
Set<PeerSyncStateListenerCallback>
|
||||
>();
|
||||
|
||||
subscribeToUpdates(listener: GlobalSyncStateListenerCallback) {
|
||||
this.listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
subscribeToPeerUpdates(
|
||||
peerId: PeerID,
|
||||
listener: PeerSyncStateListenerCallback,
|
||||
) {
|
||||
const listeners = this.listenersByPeers.get(peerId) ?? new Set();
|
||||
|
||||
if (listeners.size === 0) {
|
||||
this.listenersByPeers.set(peerId, listeners);
|
||||
}
|
||||
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getCurrentSyncState(peerId: PeerID, id: RawCoID) {
|
||||
// Build a lazy sync state object to process the isUploaded info
|
||||
// only when requested
|
||||
const syncState = {} as SyncState;
|
||||
|
||||
const getIsUploaded = () =>
|
||||
this.getIsCoValueFullyUploadedIntoPeer(peerId, id);
|
||||
|
||||
Object.defineProperties(syncState, {
|
||||
uploaded: {
|
||||
enumerable: true,
|
||||
get: getIsUploaded,
|
||||
},
|
||||
});
|
||||
|
||||
return syncState;
|
||||
}
|
||||
|
||||
triggerUpdate(peerId: PeerID, id: RawCoID) {
|
||||
const peer = this.syncManager.peers[peerId];
|
||||
|
||||
if (!peer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerListeners = this.listenersByPeers.get(peer.id);
|
||||
|
||||
// If we don't have any active listeners do nothing
|
||||
if (!peerListeners?.size && !this.listeners.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const knownState = peer.knownStates.get(id) ?? emptyKnownState(id);
|
||||
const syncState = this.getCurrentSyncState(peerId, id);
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(peerId, knownState, syncState);
|
||||
}
|
||||
|
||||
if (!peerListeners) return;
|
||||
|
||||
for (const listener of peerListeners) {
|
||||
listener(knownState, syncState);
|
||||
}
|
||||
}
|
||||
|
||||
private getKnownStateSessions(peerId: PeerID, id: RawCoID) {
|
||||
const peer = this.syncManager.peers[peerId];
|
||||
|
||||
if (!peer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const peerSessions = peer.knownStates.get(id)?.sessions;
|
||||
|
||||
if (!peerSessions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entry = this.syncManager.local.coValuesStore.get(id);
|
||||
|
||||
if (entry.state.type !== "available") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const coValue = entry.state.coValue;
|
||||
const coValueSessions = coValue.knownState().sessions;
|
||||
|
||||
return {
|
||||
peer: peerSessions,
|
||||
coValue: coValueSessions,
|
||||
};
|
||||
}
|
||||
|
||||
private getIsCoValueFullyUploadedIntoPeer(peerId: PeerID, id: RawCoID) {
|
||||
const sessions = this.getKnownStateSessions(peerId, id);
|
||||
|
||||
if (!sessions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getIsUploaded(sessions.coValue, sessions.peer);
|
||||
}
|
||||
}
|
||||
|
||||
function getIsUploaded(
|
||||
from: Record<string, number>,
|
||||
to: Record<string, number>,
|
||||
) {
|
||||
for (const sessionId of Object.keys(from)) {
|
||||
if (from[sessionId] !== to[sessionId]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,974 +0,0 @@
|
||||
import { Result, err, ok } from "neverthrow";
|
||||
import { AnyRawCoValue, RawCoValue } from "./coValue.js";
|
||||
import { ControlledAccountOrAgent, RawAccountID } from "./coValues/account.js";
|
||||
import { RawGroup } from "./coValues/group.js";
|
||||
import { coreToCoValue } from "./coreToCoValue.js";
|
||||
import {
|
||||
CryptoProvider,
|
||||
Encrypted,
|
||||
Hash,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
Signature,
|
||||
SignerID,
|
||||
StreamingHash,
|
||||
} from "./crypto/crypto.js";
|
||||
import {
|
||||
RawCoID,
|
||||
SessionID,
|
||||
TransactionID,
|
||||
getGroupDependentKeyList,
|
||||
getParentGroupId,
|
||||
isParentGroupReference,
|
||||
} from "./ids.js";
|
||||
import { Stringified, parseJSON, stableStringify } from "./jsonStringify.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { LocalNode, ResolveAccountAgentError } from "./localNode.js";
|
||||
import { logger } from "./logger.js";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { getPriorityFromHeader } from "./priority.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { expectGroup } from "./typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "./typeUtils/isAccountID.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 = {
|
||||
type: AnyRawCoValue["type"];
|
||||
ruleset: RulesetDef;
|
||||
meta: JsonObject | null;
|
||||
} & CoValueUniqueness;
|
||||
|
||||
export type CoValueUniqueness = {
|
||||
uniqueness: JsonValue;
|
||||
createdAt?: `2${string}` | null;
|
||||
};
|
||||
|
||||
export function idforHeader(
|
||||
header: CoValueHeader,
|
||||
crypto: CryptoProvider,
|
||||
): RawCoID {
|
||||
const hash = crypto.shortHash(header);
|
||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||
}
|
||||
|
||||
type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
signatureAfter: { [txIdx: number]: Signature | undefined };
|
||||
lastSignature: Signature;
|
||||
};
|
||||
|
||||
export type PrivateTransaction = {
|
||||
privacy: "private";
|
||||
madeAt: number;
|
||||
keyUsed: KeyID;
|
||||
encryptedChanges: Encrypted<JsonValue[], { in: RawCoID; tx: TransactionID }>;
|
||||
};
|
||||
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: Stringified<JsonValue[]>;
|
||||
};
|
||||
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export class CoValueCore {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
crypto: CryptoProvider;
|
||||
header: CoValueHeader;
|
||||
_sessionLogs: Map<SessionID, SessionLog>;
|
||||
_cachedContent?: RawCoValue;
|
||||
listeners: Set<(content?: RawCoValue) => void> = new Set();
|
||||
_decryptionCache: {
|
||||
[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined;
|
||||
} = {};
|
||||
_cachedKnownState?: CoValueKnownState;
|
||||
_cachedDependentOn?: RawCoID[];
|
||||
_cachedNewContentSinceEmpty?: NewContentMessage[] | undefined;
|
||||
_currentAsyncAddTransaction?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
header: CoValueHeader,
|
||||
node: LocalNode,
|
||||
internalInitSessions: Map<SessionID, SessionLog> = new Map(),
|
||||
) {
|
||||
this.crypto = node.crypto;
|
||||
this.id = idforHeader(header, node.crypto);
|
||||
this.header = header;
|
||||
this._sessionLogs = internalInitSessions;
|
||||
this.node = node;
|
||||
|
||||
if (header.ruleset.type == "ownedByGroup") {
|
||||
this.node
|
||||
.expectCoValueLoaded(header.ruleset.group)
|
||||
.subscribe((_groupUpdate) => {
|
||||
this._cachedContent = undefined;
|
||||
this.notifyUpdate("immediate");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get sessionLogs(): Map<SessionID, SessionLog> {
|
||||
return this._sessionLogs;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: ControlledAccountOrAgent,
|
||||
currentSessionID: SessionID,
|
||||
): CoValueCore {
|
||||
const newNode = this.node.testWithDifferentAccount(
|
||||
account,
|
||||
currentSessionID,
|
||||
);
|
||||
|
||||
return newNode.expectCoValueLoaded(this.id);
|
||||
}
|
||||
|
||||
knownState(): CoValueKnownState {
|
||||
if (this._cachedKnownState) {
|
||||
return this._cachedKnownState;
|
||||
} else {
|
||||
const knownState = this.knownStateUncached();
|
||||
this._cachedKnownState = knownState;
|
||||
return knownState;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
knownStateUncached(): CoValueKnownState {
|
||||
const sessions: CoValueKnownState["sessions"] = {};
|
||||
|
||||
for (const [sessionID, sessionLog] of this.sessionLogs.entries()) {
|
||||
sessions[sessionID] = sessionLog.transactions.length;
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
header: true,
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
get meta(): JsonValue {
|
||||
return this.header?.meta ?? null;
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
// This is an ugly hack to get a unique but stable session ID for editing the current account
|
||||
const sessionID =
|
||||
this.header.meta?.type === "account"
|
||||
? (this.node.currentSessionID.replace(
|
||||
this.node.account.id,
|
||||
this.node.account
|
||||
.currentAgentID()
|
||||
._unsafeUnwrap({ withStackTrace: true }),
|
||||
) as SessionID)
|
||||
: this.node.currentSessionID;
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessionLogs.get(sessionID)?.transactions.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
tryAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
givenExpectedNewHash: Hash | undefined,
|
||||
newSignature: Signature,
|
||||
skipVerify: boolean = false,
|
||||
): Result<true, TryAddTransactionsError> {
|
||||
return this.node
|
||||
.resolveAccountAgent(
|
||||
accountOrAgentIDfromSessionID(sessionID),
|
||||
"Expected to know signer of transaction",
|
||||
)
|
||||
.andThen((agent) => {
|
||||
const signerID = this.crypto.getAgentSignerID(agent);
|
||||
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions,
|
||||
);
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
return err({
|
||||
type: "InvalidHash",
|
||||
id: this.id,
|
||||
expectedNewHash,
|
||||
givenExpectedNewHash,
|
||||
} satisfies InvalidHashError);
|
||||
}
|
||||
|
||||
if (
|
||||
skipVerify !== true &&
|
||||
!this.crypto.verify(newSignature, expectedNewHash, signerID)
|
||||
) {
|
||||
return err({
|
||||
type: "InvalidSignature",
|
||||
id: this.id,
|
||||
newSignature,
|
||||
sessionID,
|
||||
signerID,
|
||||
} satisfies InvalidSignatureError);
|
||||
}
|
||||
|
||||
this.doAddTransactions(
|
||||
sessionID,
|
||||
newTransactions,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
newStreamingHash,
|
||||
"immediate",
|
||||
);
|
||||
|
||||
return ok(true as const);
|
||||
});
|
||||
}
|
||||
|
||||
private doAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
newSignature: Signature,
|
||||
expectedNewHash: Hash,
|
||||
newStreamingHash: StreamingHash,
|
||||
notifyMode: "immediate" | "deferred",
|
||||
) {
|
||||
if (this.node.crashed) {
|
||||
throw new Error("Trying to add transactions after node is crashed");
|
||||
}
|
||||
const transactions = this.sessionLogs.get(sessionID)?.transactions ?? [];
|
||||
|
||||
for (const tx of newTransactions) {
|
||||
transactions.push(tx);
|
||||
}
|
||||
|
||||
const signatureAfter =
|
||||
this.sessionLogs.get(sessionID)?.signatureAfter ?? {};
|
||||
|
||||
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
|
||||
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
|
||||
-1,
|
||||
);
|
||||
|
||||
const sizeOfTxsSinceLastInbetweenSignature = transactions
|
||||
.slice(lastInbetweenSignatureIdx + 1)
|
||||
.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0,
|
||||
);
|
||||
|
||||
if (sizeOfTxsSinceLastInbetweenSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
signatureAfter[transactions.length - 1] = newSignature;
|
||||
}
|
||||
|
||||
this._sessionLogs.set(sessionID, {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
signatureAfter: signatureAfter,
|
||||
});
|
||||
|
||||
if (
|
||||
this._cachedContent &&
|
||||
"processNewTransactions" in this._cachedContent &&
|
||||
typeof this._cachedContent.processNewTransactions === "function"
|
||||
) {
|
||||
this._cachedContent.processNewTransactions();
|
||||
} else {
|
||||
this._cachedContent = undefined;
|
||||
}
|
||||
|
||||
this._cachedKnownState = undefined;
|
||||
this._cachedDependentOn = undefined;
|
||||
this._cachedNewContentSinceEmpty = undefined;
|
||||
|
||||
this.notifyUpdate(notifyMode);
|
||||
}
|
||||
|
||||
deferredUpdates = 0;
|
||||
nextDeferredNotify: Promise<void> | undefined;
|
||||
|
||||
notifyUpdate(notifyMode: "immediate" | "deferred") {
|
||||
if (this.listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notifyMode === "immediate") {
|
||||
const content = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
} else {
|
||||
if (!this.nextDeferredNotify) {
|
||||
this.nextDeferredNotify = new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
this.nextDeferredNotify = undefined;
|
||||
this.deferredUpdates = 0;
|
||||
const content = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
this.deferredUpdates++;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: RawCoValue) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
expectedNewHashAfter(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||
const streamingHash =
|
||||
this.sessionLogs.get(sessionID)?.streamingHash.clone() ??
|
||||
new StreamingHash(this.crypto);
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
async expectedNewHashAfterAsync(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
): Promise<{ expectedNewHash: Hash; newStreamingHash: StreamingHash }> {
|
||||
const streamingHash =
|
||||
this.sessionLogs.get(sessionID)?.streamingHash.clone() ??
|
||||
new StreamingHash(this.crypto);
|
||||
let before = performance.now();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
const after = performance.now();
|
||||
if (after - before > 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
before = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
makeTransaction(
|
||||
changes: JsonValue[],
|
||||
privacy: "private" | "trusting",
|
||||
): boolean {
|
||||
const madeAt = Date.now();
|
||||
|
||||
let transaction: Transaction;
|
||||
|
||||
if (privacy === "private") {
|
||||
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
||||
|
||||
if (!keySecret) {
|
||||
throw new Error("Can't make transaction without read key secret");
|
||||
}
|
||||
|
||||
const encrypted = this.crypto.encryptForTransaction(changes, keySecret, {
|
||||
in: this.id,
|
||||
tx: this.nextTransactionID(),
|
||||
});
|
||||
|
||||
this._decryptionCache[encrypted] = changes;
|
||||
|
||||
transaction = {
|
||||
privacy: "private",
|
||||
madeAt,
|
||||
keyUsed: keyID,
|
||||
encryptedChanges: encrypted,
|
||||
};
|
||||
} else {
|
||||
transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes: stableStringify(changes),
|
||||
};
|
||||
}
|
||||
|
||||
// This is an ugly hack to get a unique but stable session ID for editing the current account
|
||||
const sessionID =
|
||||
this.header.meta?.type === "account"
|
||||
? (this.node.currentSessionID.replace(
|
||||
this.node.account.id,
|
||||
this.node.account
|
||||
.currentAgentID()
|
||||
._unsafeUnwrap({ withStackTrace: true }),
|
||||
) as SessionID)
|
||||
: this.node.currentSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
]);
|
||||
|
||||
const signature = this.crypto.sign(
|
||||
this.node.account.currentSignerSecret(),
|
||||
expectedNewHash,
|
||||
);
|
||||
|
||||
const success = this.tryAddTransactions(
|
||||
sessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
signature,
|
||||
true,
|
||||
)._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
if (success) {
|
||||
void this.node.syncManager.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(options?: {
|
||||
ignorePrivateTransactions: true;
|
||||
}): RawCoValue {
|
||||
if (!options?.ignorePrivateTransactions && this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
const newContent = coreToCoValue(this, options);
|
||||
|
||||
if (!options?.ignorePrivateTransactions) {
|
||||
this._cachedContent = newContent;
|
||||
}
|
||||
|
||||
return newContent;
|
||||
}
|
||||
|
||||
getValidTransactions(options?: {
|
||||
ignorePrivateTransactions: boolean;
|
||||
knownTransactions?: CoValueKnownState["sessions"];
|
||||
}): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = [];
|
||||
|
||||
for (const { txID, tx } of validTransactions) {
|
||||
if (options?.knownTransactions?.[txID.sessionID]! >= txID.txIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tx.privacy === "trusting") {
|
||||
allTransactions.push({
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: parseJSON(tx.changes),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options?.ignorePrivateTransactions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let decryptedChanges = this._decryptionCache[tx.encryptedChanges];
|
||||
|
||||
if (!decryptedChanges) {
|
||||
const decryptedString = this.crypto.decryptRawForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
},
|
||||
);
|
||||
decryptedChanges = decryptedString && parseJSON(decryptedString);
|
||||
this._decryptionCache[tx.encryptedChanges] = decryptedChanges;
|
||||
}
|
||||
|
||||
if (!decryptedChanges) {
|
||||
logger.error("Failed to decrypt transaction despite having key");
|
||||
continue;
|
||||
}
|
||||
|
||||
allTransactions.push({
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: decryptedChanges,
|
||||
});
|
||||
}
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(options?: {
|
||||
ignorePrivateTransactions: boolean;
|
||||
}): DecryptedTransaction[] {
|
||||
const allTransactions = this.getValidTransactions(options);
|
||||
|
||||
allTransactions.sort(this.compareTransactions);
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
compareTransactions(
|
||||
a: Pick<DecryptedTransaction, "madeAt" | "txID">,
|
||||
b: Pick<DecryptedTransaction, "madeAt" | "txID">,
|
||||
) {
|
||||
return (
|
||||
a.madeAt - b.madeAt ||
|
||||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
||||
a.txID.txIndex - b.txID.txIndex
|
||||
);
|
||||
}
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroup(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.getCurrentReadKeyId();
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentReadKey();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
let key = readKeyCache.get(this)?.[keyID];
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroup(
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true }),
|
||||
);
|
||||
|
||||
const keyForEveryone = content.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) return keyForEveryone;
|
||||
|
||||
// Try to find key revelation for us
|
||||
const lookupAccountOrAgentID =
|
||||
this.header.meta?.type === "account"
|
||||
? this.node.account
|
||||
.currentAgentID()
|
||||
._unsafeUnwrap({ withStackTrace: true })
|
||||
: this.node.account.id;
|
||||
|
||||
const lastReadyKeyEdit = content.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`,
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = this.node
|
||||
.resolveAccountAgent(revealer, "Expected to know revealer")
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
const secret = this.crypto.unseal(
|
||||
lastReadyKeyEdit.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
this.crypto.getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
},
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const co of content.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(co)!;
|
||||
|
||||
const secret = this.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of content.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = this.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(
|
||||
keyID,
|
||||
content,
|
||||
parentGroup,
|
||||
);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = content.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, group: RawGroup, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of group.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
getGroup(): RawGroup {
|
||||
if (this.header.ruleset.type !== "ownedByGroup") {
|
||||
throw new Error("Only values owned by groups have groups");
|
||||
}
|
||||
|
||||
return expectGroup(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
}
|
||||
|
||||
getTx(txID: TransactionID): Transaction | undefined {
|
||||
return this.sessionLogs.get(txID.sessionID)?.transactions[txID.txIndex];
|
||||
}
|
||||
|
||||
newContentSince(
|
||||
knownState: CoValueKnownState | undefined,
|
||||
): NewContentMessage[] | undefined {
|
||||
const isKnownStateEmpty = !knownState?.header && !knownState?.sessions;
|
||||
|
||||
if (isKnownStateEmpty && this._cachedNewContentSinceEmpty) {
|
||||
return this._cachedNewContentSinceEmpty;
|
||||
}
|
||||
|
||||
let currentPiece: NewContentMessage = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: knownState?.header ? undefined : this.header,
|
||||
priority: getPriorityFromHeader(this.header),
|
||||
new: {},
|
||||
};
|
||||
|
||||
const pieces = [currentPiece];
|
||||
|
||||
const sentState: CoValueKnownState["sessions"] = {};
|
||||
|
||||
let pieceSize = 0;
|
||||
|
||||
let sessionsTodoAgain: Set<SessionID> | undefined | "first" = "first";
|
||||
|
||||
while (sessionsTodoAgain === "first" || sessionsTodoAgain?.size || 0 > 0) {
|
||||
if (sessionsTodoAgain === "first") {
|
||||
sessionsTodoAgain = undefined;
|
||||
}
|
||||
const sessionsTodo = sessionsTodoAgain ?? this.sessionLogs.keys();
|
||||
|
||||
for (const sessionIDKey of sessionsTodo) {
|
||||
const sessionID = sessionIDKey as SessionID;
|
||||
const log = this.sessionLogs.get(sessionID)!;
|
||||
const knownStateForSessionID = knownState?.sessions[sessionID];
|
||||
const sentStateForSessionID = sentState[sessionID];
|
||||
const nextKnownSignatureIdx = getNextKnownSignatureIdx(
|
||||
log,
|
||||
knownStateForSessionID,
|
||||
sentStateForSessionID,
|
||||
);
|
||||
|
||||
const firstNewTxIdx =
|
||||
sentStateForSessionID ?? knownStateForSessionID ?? 0;
|
||||
const afterLastNewTxIdx =
|
||||
nextKnownSignatureIdx === undefined
|
||||
? log.transactions.length
|
||||
: nextKnownSignatureIdx + 1;
|
||||
|
||||
const nNewTx = Math.max(0, afterLastNewTxIdx - firstNewTxIdx);
|
||||
|
||||
if (nNewTx === 0) {
|
||||
sessionsTodoAgain?.delete(sessionID);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (afterLastNewTxIdx < log.transactions.length) {
|
||||
if (!sessionsTodoAgain) {
|
||||
sessionsTodoAgain = new Set();
|
||||
}
|
||||
sessionsTodoAgain.add(sessionID);
|
||||
}
|
||||
|
||||
const oldPieceSize = pieceSize;
|
||||
for (let txIdx = firstNewTxIdx; txIdx < afterLastNewTxIdx; txIdx++) {
|
||||
const tx = log.transactions[txIdx]!;
|
||||
pieceSize +=
|
||||
tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length;
|
||||
}
|
||||
|
||||
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
|
||||
currentPiece = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: undefined,
|
||||
new: {},
|
||||
priority: getPriorityFromHeader(this.header),
|
||||
};
|
||||
pieces.push(currentPiece);
|
||||
pieceSize = pieceSize - oldPieceSize;
|
||||
}
|
||||
|
||||
let sessionEntry = currentPiece.new[sessionID];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: sentStateForSessionID ?? knownStateForSessionID ?? 0,
|
||||
newTransactions: [],
|
||||
lastSignature: "WILL_BE_REPLACED" as Signature,
|
||||
};
|
||||
currentPiece.new[sessionID] = sessionEntry;
|
||||
}
|
||||
|
||||
for (let txIdx = firstNewTxIdx; txIdx < afterLastNewTxIdx; txIdx++) {
|
||||
const tx = log.transactions[txIdx]!;
|
||||
sessionEntry.newTransactions.push(tx);
|
||||
}
|
||||
|
||||
sessionEntry.lastSignature =
|
||||
nextKnownSignatureIdx === undefined
|
||||
? log.lastSignature!
|
||||
: log.signatureAfter[nextKnownSignatureIdx]!;
|
||||
|
||||
sentState[sessionID] =
|
||||
(sentStateForSessionID ?? knownStateForSessionID ?? 0) + nNewTx;
|
||||
}
|
||||
}
|
||||
|
||||
const piecesWithContent = pieces.filter(
|
||||
(piece) => Object.keys(piece.new).length > 0 || piece.header,
|
||||
);
|
||||
|
||||
if (piecesWithContent.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isKnownStateEmpty) {
|
||||
this._cachedNewContentSinceEmpty = piecesWithContent;
|
||||
}
|
||||
|
||||
return piecesWithContent;
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
if (this._cachedDependentOn) {
|
||||
return this._cachedDependentOn;
|
||||
} else {
|
||||
const dependentOn = this.getDependedOnCoValuesUncached();
|
||||
this._cachedDependentOn = dependentOn;
|
||||
return dependentOn;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
getDependedOnCoValuesUncached(): RawCoID[] {
|
||||
return this.header.ruleset.type === "group"
|
||||
? getGroupDependentKeyList(expectGroup(this.getCurrentContent()).keys())
|
||||
: this.header.ruleset.type === "ownedByGroup"
|
||||
? [
|
||||
this.header.ruleset.group,
|
||||
...new Set(
|
||||
[...this.sessionLogs.keys()]
|
||||
.map((sessionID) =>
|
||||
accountOrAgentIDfromSessionID(sessionID as SessionID),
|
||||
)
|
||||
.filter(
|
||||
(session): session is RawAccountID =>
|
||||
isAccountID(session) && session !== this.id,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
waitForSync(options?: {
|
||||
timeout?: number;
|
||||
}) {
|
||||
return this.node.syncManager.waitForSync(this.id, options?.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getNextKnownSignatureIdx(
|
||||
log: SessionLog,
|
||||
knownStateForSessionID?: number,
|
||||
sentStateForSessionID?: number,
|
||||
) {
|
||||
return Object.keys(log.signatureAfter)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.find(
|
||||
(idx) => idx >= (sentStateForSessionID ?? knownStateForSessionID ?? -1),
|
||||
);
|
||||
}
|
||||
|
||||
export type InvalidHashError = {
|
||||
type: "InvalidHash";
|
||||
id: RawCoID;
|
||||
expectedNewHash: Hash;
|
||||
givenExpectedNewHash: Hash;
|
||||
};
|
||||
|
||||
export type InvalidSignatureError = {
|
||||
type: "InvalidSignature";
|
||||
id: RawCoID;
|
||||
newSignature: Signature;
|
||||
sessionID: SessionID;
|
||||
signerID: SignerID;
|
||||
};
|
||||
|
||||
export type TryAddTransactionsError =
|
||||
| ResolveAccountAgentError
|
||||
| InvalidHashError
|
||||
| InvalidSignatureError;
|
||||
@@ -1,373 +0,0 @@
|
||||
import { PeerState } from "./PeerState.js";
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { PeerID } from "./sync.js";
|
||||
|
||||
export const CO_VALUE_LOADING_CONFIG = {
|
||||
MAX_RETRIES: 2,
|
||||
TIMEOUT: 30_000,
|
||||
};
|
||||
|
||||
export class CoValueUnknownState {
|
||||
type = "unknown" as const;
|
||||
}
|
||||
|
||||
export class CoValueLoadingState {
|
||||
type = "loading" as const;
|
||||
private peers = new Map<
|
||||
PeerID,
|
||||
ReturnType<typeof createResolvablePromise<void>>
|
||||
>();
|
||||
private resolveResult: (value: CoValueCore | "unavailable") => void;
|
||||
|
||||
result: Promise<CoValueCore | "unavailable">;
|
||||
|
||||
constructor(peersIds: Iterable<PeerID>) {
|
||||
this.peers = new Map();
|
||||
|
||||
for (const peerId of peersIds) {
|
||||
this.peers.set(peerId, createResolvablePromise<void>());
|
||||
}
|
||||
|
||||
const { resolve, promise } = createResolvablePromise<
|
||||
CoValueCore | "unavailable"
|
||||
>();
|
||||
|
||||
this.result = promise;
|
||||
this.resolveResult = resolve;
|
||||
}
|
||||
|
||||
markAsUnavailable(peerId: PeerID) {
|
||||
const entry = this.peers.get(peerId);
|
||||
|
||||
if (entry) {
|
||||
entry.resolve();
|
||||
}
|
||||
|
||||
this.peers.delete(peerId);
|
||||
|
||||
// If none of the peers have the coValue, we resolve to unavailable
|
||||
if (this.peers.size === 0) {
|
||||
this.resolve("unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
resolve(value: CoValueCore | "unavailable") {
|
||||
this.resolveResult(value);
|
||||
for (const entry of this.peers.values()) {
|
||||
entry.resolve();
|
||||
}
|
||||
this.peers.clear();
|
||||
}
|
||||
|
||||
// Wait for a specific peer to have a known state
|
||||
waitForPeer(peerId: PeerID) {
|
||||
const entry = this.peers.get(peerId);
|
||||
|
||||
if (!entry) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return entry.promise;
|
||||
}
|
||||
}
|
||||
|
||||
export class CoValueAvailableState {
|
||||
type = "available" as const;
|
||||
|
||||
constructor(public coValue: CoValueCore) {}
|
||||
}
|
||||
|
||||
export class CoValueUnavailableState {
|
||||
type = "unavailable" as const;
|
||||
}
|
||||
|
||||
type CoValueStateAction =
|
||||
| {
|
||||
type: "load-requested";
|
||||
peersIds: PeerID[];
|
||||
}
|
||||
| {
|
||||
type: "not-found-in-peer";
|
||||
peerId: PeerID;
|
||||
}
|
||||
| {
|
||||
type: "available";
|
||||
coValue: CoValueCore;
|
||||
};
|
||||
|
||||
type CoValueStateType =
|
||||
| CoValueUnknownState
|
||||
| CoValueLoadingState
|
||||
| CoValueAvailableState
|
||||
| CoValueUnavailableState;
|
||||
|
||||
export class CoValueState {
|
||||
promise?: Promise<CoValueCore | "unavailable">;
|
||||
private resolve?: (value: CoValueCore | "unavailable") => void;
|
||||
|
||||
constructor(
|
||||
public id: RawCoID,
|
||||
public state: CoValueStateType,
|
||||
) {}
|
||||
|
||||
static Unknown(id: RawCoID) {
|
||||
return new CoValueState(id, new CoValueUnknownState());
|
||||
}
|
||||
|
||||
static Loading(id: RawCoID, peersIds: Iterable<PeerID>) {
|
||||
return new CoValueState(id, new CoValueLoadingState(peersIds));
|
||||
}
|
||||
|
||||
static Available(coValue: CoValueCore) {
|
||||
return new CoValueState(coValue.id, new CoValueAvailableState(coValue));
|
||||
}
|
||||
|
||||
static Unavailable(id: RawCoID) {
|
||||
return new CoValueState(id, new CoValueUnavailableState());
|
||||
}
|
||||
|
||||
async getCoValue() {
|
||||
if (this.state.type === "available") {
|
||||
return this.state.coValue;
|
||||
}
|
||||
if (this.state.type === "unavailable") {
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
// If we don't have a resolved state we return a new promise
|
||||
// that will be resolved when the state will move to available or unavailable
|
||||
if (!this.promise) {
|
||||
const { promise, resolve } = createResolvablePromise<
|
||||
CoValueCore | "unavailable"
|
||||
>();
|
||||
|
||||
this.promise = promise;
|
||||
this.resolve = resolve;
|
||||
}
|
||||
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
private moveToState(value: CoValueStateType) {
|
||||
this.state = value;
|
||||
|
||||
if (!this.resolve) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the state is available we resolve the promise
|
||||
// and clear it to handle the possible transition from unavailable to available
|
||||
if (value.type === "available") {
|
||||
this.resolve(value.coValue);
|
||||
this.clearPromise();
|
||||
} else if (value.type === "unavailable") {
|
||||
this.resolve("unavailable");
|
||||
this.clearPromise();
|
||||
}
|
||||
}
|
||||
|
||||
private clearPromise() {
|
||||
this.promise = undefined;
|
||||
this.resolve = undefined;
|
||||
}
|
||||
|
||||
async loadFromPeers(peers: PeerState[]) {
|
||||
const state = this.state;
|
||||
|
||||
if (state.type !== "unknown" && state.type !== "unavailable") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doLoad = async (peersToLoadFrom: PeerState[]) => {
|
||||
const peersWithoutErrors = getPeersWithoutErrors(
|
||||
peersToLoadFrom,
|
||||
this.id,
|
||||
);
|
||||
|
||||
// If we are in the loading state we move to a new loading state
|
||||
// to reset all the loading promises
|
||||
if (this.state.type === "loading" || this.state.type === "unknown") {
|
||||
this.moveToState(
|
||||
new CoValueLoadingState(peersWithoutErrors.map((p) => p.id)),
|
||||
);
|
||||
}
|
||||
|
||||
// Assign the current state to a variable to not depend on the state changes
|
||||
// that may happen while we wait for loadCoValueFromPeers to complete
|
||||
const currentState = this.state;
|
||||
|
||||
// If we entered successfully the loading state, we load the coValue from the peers
|
||||
//
|
||||
// We may not enter the loading state if the coValue has become available in between
|
||||
// of the retries
|
||||
if (currentState.type === "loading") {
|
||||
await loadCoValueFromPeers(this, peersWithoutErrors);
|
||||
|
||||
const result = await currentState.result;
|
||||
return result !== "unavailable";
|
||||
}
|
||||
|
||||
return currentState.type === "available";
|
||||
};
|
||||
|
||||
await doLoad(peers);
|
||||
|
||||
// Retry loading from peers that have the retry flag enabled
|
||||
const peersWithRetry = peers.filter((p) =>
|
||||
p.shouldRetryUnavailableCoValues(),
|
||||
);
|
||||
|
||||
if (peersWithRetry.length > 0) {
|
||||
// We want to exit early if the coValue becomes available in between the retries
|
||||
await Promise.race([
|
||||
this.getCoValue(),
|
||||
runWithRetry(
|
||||
() => doLoad(peersWithRetry),
|
||||
CO_VALUE_LOADING_CONFIG.MAX_RETRIES,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// If after the retries the coValue is still loading, we consider the load failed
|
||||
if (this.state.type === "loading") {
|
||||
this.moveToState(new CoValueUnavailableState());
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(action: CoValueStateAction) {
|
||||
const currentState = this.state;
|
||||
|
||||
switch (action.type) {
|
||||
case "available":
|
||||
if (currentState.type === "loading") {
|
||||
currentState.resolve(action.coValue);
|
||||
}
|
||||
|
||||
// It should be always possible to move to the available state
|
||||
this.moveToState(new CoValueAvailableState(action.coValue));
|
||||
|
||||
break;
|
||||
case "not-found-in-peer":
|
||||
if (currentState.type === "loading") {
|
||||
currentState.markAsUnavailable(action.peerId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoValueFromPeers(
|
||||
coValueEntry: CoValueState,
|
||||
peers: PeerState[],
|
||||
) {
|
||||
for (const peer of peers) {
|
||||
if (peer.closed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (coValueEntry.state.type === "available") {
|
||||
/**
|
||||
* We don't need to wait for the message to be delivered here.
|
||||
*
|
||||
* This way when the coValue becomes available because it's cached we don't wait for the server
|
||||
* peer to consume the messages queue before moving forward.
|
||||
*/
|
||||
peer
|
||||
.pushOutgoingMessage({
|
||||
action: "load",
|
||||
...coValueEntry.state.coValue.knownState(),
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`Failed to push load message to peer ${peer.id}`, err);
|
||||
});
|
||||
} else {
|
||||
/**
|
||||
* We only wait for the load state to be resolved.
|
||||
*/
|
||||
peer
|
||||
.pushOutgoingMessage({
|
||||
action: "load",
|
||||
id: coValueEntry.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`Failed to push load message to peer ${peer.id}`, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (coValueEntry.state.type === "loading") {
|
||||
const timeout = setTimeout(() => {
|
||||
if (coValueEntry.state.type === "loading") {
|
||||
logger.warn("Failed to load coValue from peer", {
|
||||
peerId: peer.id,
|
||||
peerRole: peer.role,
|
||||
});
|
||||
coValueEntry.dispatch({
|
||||
type: "not-found-in-peer",
|
||||
peerId: peer.id,
|
||||
});
|
||||
}
|
||||
}, CO_VALUE_LOADING_CONFIG.TIMEOUT);
|
||||
await coValueEntry.state.waitForPeer(peer.id);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithRetry<T>(fn: () => Promise<T>, maxRetries: number) {
|
||||
let retries = 1;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
/**
|
||||
* With maxRetries of 5 we should wait:
|
||||
* 300ms
|
||||
* 900ms
|
||||
* 2700ms
|
||||
* 8100ms
|
||||
*/
|
||||
await sleep(3 ** retries * 100);
|
||||
|
||||
const result = await fn();
|
||||
|
||||
if (result === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
|
||||
function createResolvablePromise<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getPeersWithoutErrors(peers: PeerState[], coValueId: RawCoID) {
|
||||
return peers.filter((p) => {
|
||||
if (p.erroredCoValues.has(coValueId)) {
|
||||
logger.warn(
|
||||
`Skipping load on errored coValue ${coValueId} from peer ${p.id}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CoID, RawCoValue } from "../coValue.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { AgentID, TransactionID } from "../ids.js";
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { LocalNodeState } from "../localNode/structure.js";
|
||||
import { CoValueKnownState } from "../sync.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { isCoValue } from "../typeUtils/isCoValue.js";
|
||||
@@ -39,7 +39,7 @@ export class RawCoMapView<
|
||||
/** @category 6. Meta */
|
||||
type = "comap" as const;
|
||||
/** @category 6. Meta */
|
||||
core: CoValueCore;
|
||||
node: LocalNodeState;
|
||||
/** @internal */
|
||||
latest: {
|
||||
[Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>;
|
||||
|
||||
@@ -158,7 +158,7 @@ export class RawGroup<
|
||||
child.state.type === "unknown" ||
|
||||
child.state.type === "unavailable"
|
||||
) {
|
||||
child.loadFromPeers(peers).catch(() => {
|
||||
child.loadCoValue(this.core.node.storageDriver, peers).catch(() => {
|
||||
logger.error(`Failed to load child group ${id}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,9 +104,13 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
|
||||
}
|
||||
|
||||
verify(signature: Signature, message: JsonValue, id: SignerID): boolean {
|
||||
return new Ed25519VerifyingKey(
|
||||
new Memory(base58.decode(id.substring("signer_z".length))),
|
||||
).verify(
|
||||
const idBytes = base58.decode(id.substring("signer_z".length));
|
||||
if (idBytes.length !== 32) {
|
||||
throw new Error(
|
||||
`Invalid signer ID ${id} - ID bytes length is ${idBytes.length} instead of 32`,
|
||||
);
|
||||
}
|
||||
return new Ed25519VerifyingKey(new Memory(idBytes)).verify(
|
||||
new Memory(textEncoder.encode(stableStringify(message))),
|
||||
new Ed25519Signature(
|
||||
new Memory(base58.decode(signature.substring("signature_z".length))),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./PureJSCrypto.js";
|
||||
export * from "./WasmCrypto.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user