Compare commits

...

64 Commits

Author SHA1 Message Date
Guido D'Orsi
2b160213ef test(dependencies): refactor the dependencies tests to use dynamic fixtures 2025-02-27 19:07:17 +01:00
Anselm
c8737118a0 Sketch for making incoming messages part of state 2025-02-26 16:59:26 +00:00
Anselm
e98c6dba71 Cut out old abstractions and disable old tests that need to be ported 2025-02-26 16:55:58 +00:00
Anselm
c6ca3c356a createCoValue has no effects 2025-02-26 11:45:07 +00:00
Anselm
815deccfbd Split up tests 2025-02-25 09:40:05 +00:00
Anselm
53aa057e67 Restructure localNode into files 2025-02-20 12:06:28 +00:00
Anselm
c7bae413bc Change from class with methods to plain data + functions for local node state 2025-02-20 11:39:57 +00:00
Anselm
261042d8e6 Small refactors 2025-02-20 11:24:07 +00:00
Anselm
2a61ef0462 Implement & test verification for primitive agents and agents from account covalues 2025-02-20 11:03:06 +00:00
Anselm
00f2528f6a Implement success case of verification 2025-02-19 15:25:56 +00:00
Anselm
e422ce48fd Start sketching stageVerify 2025-02-18 16:05:34 +00:00
Anselm
370f6a98ff implement dependecies (extended group) 2025-02-18 15:30:30 +00:00
Anselm
bdcbf538c4 Refactoring 2025-02-18 15:26:01 +00:00
Anselm
1a11697b08 Add dependency test implementation (group members) 2025-02-18 13:56:47 +00:00
Anselm
b62c58027a First kind of dependents and interaction with loading 2025-02-18 13:39:46 +00:00
Anselm
627e48043c prepare dependents 2025-02-18 13:25:00 +00:00
Anselm
97ca54fbcd Make entire LocalNode state JSON serialisable 2025-02-17 15:58:32 +00:00
Anselm
3b40758901 Add decryptionState 2025-02-17 11:56:40 +00:00
Anselm
411a7be344 More counters in SessionEntry 2025-02-17 11:37:22 +00:00
Anselm
22a1c771ee Test implement adding transactions from storage 2025-02-14 17:32:50 +00:00
Anselm
eea3c6e2ab Test & implement loading transactions 2025-02-14 16:25:44 +00:00
Anselm
17e9524bfe Some initial tests and their implementation 2025-02-14 15:04:59 +00:00
Anselm
9ccd1b9948 Initial render passes structure 2025-02-14 10:42:16 +00:00
Guido D'Orsi
0b6056b96e fix: make the build & tests pass 2025-02-13 12:25:38 +01:00
Guido D'Orsi
b2a9147053 quick small fixes 2025-02-13 11:53:37 +01:00
Anselm
0b527d4010 Initial sketch for StorageAdapter and StorageDriver 2025-02-13 10:04:33 +00:00
Guido D'Orsi
5c236e5c3c Merge pull request #1371 from garden-co/jazz-706-fix-organization-example-build
Jazz 706 fix organization example build
2025-02-13 09:52:29 +01:00
Guido D'Orsi
7616d5789b chore(ci): trigger the React Native e2e tests when packages are changed 2025-02-13 09:17:29 +01:00
Guido D'Orsi
631486e235 Merge pull request #1364 from boorad/feat/move-e2e-to-android
feat: move E2E tests to Android
2025-02-13 09:15:15 +01:00
Guido D'Orsi
5e0d63a4d6 docs: fix broken links in the upgrade page 2025-02-13 08:37:54 +01:00
Benjamin S. Leveritt
ba988cbb90 Removes tsc builds of config files 2025-02-12 22:59:42 +00:00
Benjamin S. Leveritt
6f6800bcd8 Adds aliases for vite to build 2025-02-12 22:59:03 +00:00
Benjamin S. Leveritt
90290902e8 Merge pull request #1365 from garden-co/jazz-675-explicitly-assigning-undefined-to-a-cooptionaldate-on-create
Jazz 675 explicitly assigning undefined to a cooptionaldate on create
2025-02-12 21:50:09 +00:00
Benjamin S. Leveritt
d8582fc9ed Adds changeset 2025-02-12 21:49:01 +00:00
Brad Anderson
58905ae8f4 feat: move E2E tests to Android 2025-02-12 15:16:44 -05:00
Guido D'Orsi
65f630fb44 Merge pull request #1367 from garden-co/changeset-release/main
Version Packages
2025-02-12 20:26:56 +01:00
github-actions[bot]
b68f85542b Version Packages 2025-02-12 18:32:59 +00:00
Guido D'Orsi
f122a9f938 Merge pull request #1366 from garden-co/fix-metro-config
fix: correctly setup the metro config on React Native templates
2025-02-12 19:31:50 +01:00
Benjamin S. Leveritt
e15d994df6 Adds OptionalDate encoding 2025-02-12 18:23:20 +00:00
Guido D'Orsi
48ac92bc67 fix: correctly setup the metro config on React Native templates 2025-02-12 19:22:23 +01:00
Benjamin S. Leveritt
226b1171e6 Adds check for undefined to co.optional.Date 2025-02-12 18:09:15 +00:00
Benjamin S. Leveritt
29228e21fe Adds optional date test 2025-02-12 18:08:56 +00:00
Trisha Lim
d3603625fd Fix copy password button 2025-02-12 19:43:20 +07:00
Trisha Lim
fa94d8c171 Show button on mobile 2025-02-12 19:43:20 +07:00
Trisha Lim
aeb094baa1 Add "use as template" button to examples 2025-02-12 19:43:20 +07:00
Guido D'Orsi
18f3497397 Merge pull request #1361 from garden-co/changeset-release/main
Version Packages
2025-02-12 12:55:19 +01:00
github-actions[bot]
8be7158d1f Version Packages 2025-02-12 11:52:07 +00:00
Guido D'Orsi
cae3a9ee32 feat: add debug info to load failure end missing header errors 2025-02-12 12:50:29 +01:00
Guido D'Orsi
6260045140 docs: small improvement on the upgrade guide 2025-02-12 11:57:00 +01:00
Guido D'Orsi
466d79d9a6 docs: fix missing enclosing div 2025-02-12 11:47:46 +01:00
Guido D'Orsi
67776c77a0 Merge pull request #1360 from garden-co/changeset-release/main
Version Packages
2025-02-12 11:42:59 +01:00
github-actions[bot]
e6868d3030 Version Packages 2025-02-12 10:41:24 +00:00
Guido D'Orsi
c8ae3a36ca Merge pull request #1350 from garden-co/jazz-701-docs-in-main-nav-doesnt-get-highlighted-when-active
Fix: docs in main nav doesn't get highlighted when active
2025-02-12 11:39:47 +01:00
Guido D'Orsi
bf9c158455 Merge pull request #1359 from garden-co/fix/unstable_enablePackageExports
feat: remove the unstable_enablePackageExports requirement from RN
2025-02-12 11:38:53 +01:00
Guido D'Orsi
1301112a6b docs: add the breaking change to the 0-10-0 upgrade guide 2025-02-12 11:38:25 +01:00
Guido D'Orsi
c447f08029 chore: improve pre-release comment output 2025-02-12 11:31:20 +01:00
Guido D'Orsi
5a63cbae9b feat: remove the unstable_enablePackageExports requirement from RN 2025-02-12 10:48:08 +01:00
Guido D'Orsi
7d06f1dbf4 fix: fix the username input style in dark mode 2025-02-11 16:53:51 +01:00
Guido D'Orsi
e48a3e4c27 chore: fix test type error 2025-02-11 16:09:26 +01:00
Guido D'Orsi
a326ed971c Merge pull request #1353 from garden-co/jazz-696-mark-vue-and-svelte-as-experimental
Mark vue and svelte as experimental
2025-02-11 16:03:34 +01:00
Trisha Lim
6139803679 Use dropdown for framework selector 2025-02-11 19:18:48 +07:00
Trisha Lim
bb2052e1f2 Add dropdown 2025-02-11 17:42:30 +07:00
Trisha Lim
40af02acb3 Mark vue and svelte as experimental 2025-02-11 17:39:57 +07:00
Trisha Lim
3bdb753b78 Fix: docs in main nav doesn't get highlighted when active 2025-02-11 17:37:14 +07:00
233 changed files with 4090 additions and 4919 deletions

View File

@@ -0,0 +1,5 @@
---
"jazz-tools": patch
---
Fixes co.optional.Date throwing when assigned undefined

View 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."

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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($|\/|\\)/,

View File

@@ -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",

View File

@@ -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

View File

@@ -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($|\/|\\)/,

View File

@@ -1,6 +1,6 @@
{
"name": "chat-rn",
"version": "1.0.62",
"version": "1.0.64",
"main": "index.js",
"scripts": {
"build": "expo export -p ios",

View 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

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "chat-vue",
"version": "0.0.49",
"version": "0.0.51",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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

View File

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

View File

@@ -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;

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "file-share-svelte",
"version": "0.0.29",
"version": "0.0.31",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "form",
"private": true,
"version": "0.0.40",
"version": "0.0.42",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "image-upload",
"private": true,
"version": "0.0.42",
"version": "0.0.44",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -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",

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-onboarding",
"private": true,
"version": "0.0.46",
"version": "0.0.48",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -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"

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -1,6 +0,0 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});

View File

@@ -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"),
},
},
});

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "passkey-svelte",
"version": "0.0.33",
"version": "0.0.35",
"type": "module",
"private": true,
"scripts": {

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "passkey",
"private": true,
"version": "0.0.43",
"version": "0.0.45",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "passphrase",
"private": true,
"version": "0.0.40",
"version": "0.0.42",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "reactions",
"private": true,
"version": "0.0.42",
"version": "0.0.44",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "todo-vue",
"version": "0.0.47",
"version": "0.0.49",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "version-history",
"private": true,
"version": "0.0.39",
"version": "0.0.41",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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,

View File

@@ -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);

View 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",
)}
/>
);
}

View 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>
);
}

View File

@@ -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",
)}

View File

@@ -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>

View 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);
}

View File

@@ -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({

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -0,0 +1,3 @@
```sh
npx create-jazz-app@latest --example $EXAMPLE
```

View File

@@ -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>
</>
);
}

View File

@@ -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 {

View File

@@ -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("$", "&#36;").replaceAll("_", "&#95;"),
value,
);
},
highlightedCode,
);
return <div dangerouslySetInnerHTML={{ __html: newHighlightedCode }} />;
},
};
}

View File

@@ -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()],

View File

@@ -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

View File

@@ -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",

View File

@@ -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";

View File

@@ -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

View File

@@ -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",

View File

@@ -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 (?, ?, ?)",

View File

@@ -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

View File

@@ -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": {

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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);

View File

@@ -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";

View File

@@ -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";

View File

@@ -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

View File

@@ -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"

View File

@@ -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();
}
}

View File

@@ -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);
};
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
});
}

View File

@@ -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]>;

View File

@@ -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}`);
});
}

View File

@@ -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))),

View File

@@ -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